diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..41d2e7f0da5cab00981267c6382951daea5a0966
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+on: [push, pull_request]
+jobs:
+  build:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [macos-latest, windows-latest, ubuntu-latest]
+        python-version: [2.7, 3.6, 3.7, 3.8, 3.9, pypy-2.7, pypy-3.6, pypy-3.7]
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      # https://github.com/actions/cache/blob/main/examples.md#using-a-script-to-get-cache-location
+      - id: pip-cache
+        run: python -c "from pip._internal.locations import USER_CACHE_DIR; print('::set-output name=dir::' + USER_CACHE_DIR)"
+      - uses: actions/cache@v1
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+      - run: pip install --upgrade check-manifest flake8 isort setuptools
+      - run: check-manifest
+      - run: flake8 .
+      - run: isort . --check-only
+      - run: pip install .[test]
+      - run: nosetests
diff --git a/.gitignore b/.gitignore
index 62c924732dd29eb8da79b9843287b04e0bb3e5cb..c375686c857b409c44990eaf3e288871143bdb46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,6 @@
 *.pyc
 *.swp
 *.swo
-.tox
 *.egg-info
 docs/_build
 dist
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index da38eb7144974d49c4c2dad4bd2e609ad8f2337e..0000000000000000000000000000000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-dist: xenial
-language: python
-python:
-  - "2.7"
-  - "3.4"
-  - "3.5"
-  - "3.6"
-  - "3.7"
-# command to install dependencies
-install:
-    - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -r requirements-py3.txt; else pip install -r requirements-py2.txt; fi
-# command to run tests
-script: nosetests tests
-sudo: false
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 337e95928f87be91a0bd3bf7609d62ef12ed8b1b..d8ff73c4c603a5b785393838c7da5e1d6c4ee1e1 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -1,10 +1,10 @@
 The following individuals have contributed code to agate-excel:
 
 * `Christopher Groskopf <https://github.com/onyxfish>`_
-* `James McKinney <https://github.com/jpmckinney>`_
 * `Ben Welsh <https://github.com/palewire>`_
+* `James McKinney <https://github.com/jpmckinney>`_
 * `Peter M. Landwehr <https://github.com/pmlandwehr>`_
-* `Tim Freund <https://github.com/timfreund>`_
 * `Jani Mikkonen <https://github.com/rasjani>`_
+* `Tim Freund <https://github.com/timfreund>`_
 * `Loïc Corbasson <https://github.com/lcorbasson>`_
 * `Robert Schütz <https://github.com/dotlambda>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 72f4df0c4984a0dce83c310dd802f2ec581ca83a..1c60fa596c7af8f5d48a01d77773a6be06b5c8d7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,5 +1,20 @@
-0.2.3
------
+0.2.5 - August 8, 2021
+----------------------
+
+* Add ``six`` to ``install_requires``.
+
+0.2.4 - July 13, 2021
+---------------------
+
+* Add ``row_limit`` keyword argument to ``from_xls`` and ``from_xlsx``. (#40)
+* Preserve column types from XLS files. (#36)
+* Add support for Compound File Binary File (CFBF) XLS files. (#44)
+* Close XLSX file before raising error for non-existent sheet. (#34)
+* Use less memory and close XLS files. (#39)
+* Drop support for Python 3.4 (end-of-life was March 18, 2019).
+
+0.2.3 - March 16, 2019
+----------------------
 
 * Fix bug in accepting ``column_names`` as keyword argument.
 * Add a ``reset_dimensions`` argument to :meth:`.Table.from_xlsx` to recalculate the data's dimensions, instead of trusting those in the file's properties.
@@ -24,8 +39,8 @@
 * Fix bug in handling an empty XLS.
 * Fix bug in handling non-string column names in XLSX.
 
-0.2.0
------
+0.2.0 - December 19, 2016
+-------------------------
 
 * Fix bug in handling of ``None`` in boolean columns for XLS. (#11)
 * Removed usage of deprecated openpyxl method ``get_sheet_by_name``.
@@ -33,7 +48,7 @@
 * Upgrade required agate version to ``1.5.0``.
 * Ensure columns with numbers for names (e.g. years) are parsed as strings.
 
-0.1.0
------
+0.1.0 - February 5, 2016
+------------------------
 
 * Initial version.
diff --git a/MANIFEST.in b/MANIFEST.in
index eaee0cca6d70707cec83ac927e697b771d38b58a..21103791a5997a83980881fa25cb088419bf44ae 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,9 @@
+include *.py
+include *.rst
 include COPYING
-include AUTHORS.rst
-include README.rst
+recursive-include docs *.py
+recursive-include docs *.rst
+recursive-include docs Makefile
+recursive-include examples *.xls
+recursive-include examples *.xlsx
 recursive-include tests *.py
-graft examples
diff --git a/README.rst b/README.rst
index baffd5e7537eb7f472204d70c471fcccff7693f7..bc0544d61b8eb1fdabb690a817d411372aee5aff 100644
--- a/README.rst
+++ b/README.rst
@@ -1,7 +1,11 @@
-.. image:: https://travis-ci.org/wireservice/agate-excel.png
-    :target: https://travis-ci.org/wireservice/agate-excel
+.. image:: https://github.com/wireservice/agate-excel/workflows/CI/badge.svg
+    :target: https://github.com/wireservice/agate-excel/actions
     :alt: Build status
 
+.. image:: https://img.shields.io/pypi/dm/agate-excel.svg
+    :target: https://pypi.python.org/pypi/agate-excel
+    :alt: PyPI downloads
+
 .. image:: https://img.shields.io/pypi/v/agate-excel.svg
     :target: https://pypi.python.org/pypi/agate-excel
     :alt: Version
diff --git a/agateexcel/table_xls.py b/agateexcel/table_xls.py
index 26a512201fc9206558b9c102ea371a401aee2d22..9a0a9088637d471028786f7759f53e1f91817b99 100644
--- a/agateexcel/table_xls.py
+++ b/agateexcel/table_xls.py
@@ -8,11 +8,22 @@ import datetime
 from collections import OrderedDict
 
 import agate
+import olefile
 import six
 import xlrd
 
+EXCEL_TO_AGATE_TYPE = {
+    xlrd.biffh.XL_CELL_EMPTY: agate.Boolean(),
+    xlrd.biffh.XL_CELL_TEXT: agate.Text(),
+    xlrd.biffh.XL_CELL_NUMBER: agate.Number(),
+    xlrd.biffh.XL_CELL_DATE: agate.DateTime(),
+    xlrd.biffh.XL_CELL_BOOLEAN: agate.Boolean(),
+    xlrd.biffh.XL_CELL_ERROR: agate.Text(),
+    xlrd.biffh.XL_CELL_BLANK: agate.Boolean(),
+}
 
-def from_xls(cls, path, sheet=None, skip_lines=0, header=True, encoding_override=None, **kwargs):
+
+def from_xls(cls, path, sheet=None, skip_lines=0, header=True, encoding_override=None, row_limit=None, **kwargs):
     """
     Parse an XLS file.
 
@@ -25,70 +36,104 @@ def from_xls(cls, path, sheet=None, skip_lines=0, header=True, encoding_override
         The number of rows to skip from the top of the sheet.
     :param header:
         If :code:`True`, the first row is assumed to contain column names.
+    :param row_limit:
+        Limit how many rows of data will be read.
     """
     if not isinstance(skip_lines, int):
         raise ValueError('skip_lines argument must be an int')
 
+    def open_workbook(f):
+        try:
+            book = xlrd.open_workbook(file_contents=f.read(), encoding_override=encoding_override, on_demand=True)
+        except xlrd.compdoc.CompDocError:
+            # This is not a pure XLS file; we'll try to read it though.
+            # Let's try the Compound File Binary Format:
+            ole = olefile.OleFileIO(f)
+            if ole.exists('Workbook'):
+                d = ole.openstream('Workbook')
+                book = xlrd.open_workbook(file_contents=d.read(), on_demand=True)
+            else:
+                raise IOError('No Workbook stream found in OLE file')
+        return book
+
     if hasattr(path, 'read'):
-        book = xlrd.open_workbook(file_contents=path.read(), encoding_override=encoding_override)
+        book = open_workbook(path)
     else:
         with open(path, 'rb') as f:
-            book = xlrd.open_workbook(file_contents=f.read(), encoding_override=encoding_override)
-
-    multiple = agate.utils.issequence(sheet)
-    if multiple:
-        sheets = sheet
-    else:
-        sheets = [sheet]
+            book = open_workbook(f)
 
-    tables = OrderedDict()
-
-    for i, sheet in enumerate(sheets):
-        if isinstance(sheet, six.string_types):
-            sheet = book.sheet_by_name(sheet)
-        elif isinstance(sheet, int):
-            sheet = book.sheet_by_index(sheet)
-        else:
-            sheet = book.sheet_by_index(0)
-
-        if header:
-            offset = 1
-            column_names = []
+    try:
+        multiple = agate.utils.issequence(sheet)
+        if multiple:
+            sheets = sheet
         else:
-            offset = 0
-            column_names = None
-
-        columns = []
+            sheets = [sheet]
 
-        for i in range(sheet.ncols):
-            data = sheet.col_values(i)
-            values = data[skip_lines + offset:]
-            types = sheet.col_types(i)[skip_lines + offset:]
-            excel_type = determine_excel_type(types)
+        tables = OrderedDict()
 
-            if excel_type == xlrd.biffh.XL_CELL_BOOLEAN:
-                values = normalize_booleans(values)
-            elif excel_type == xlrd.biffh.XL_CELL_DATE:
-                values = normalize_dates(values, book.datemode)
+        for i, sheet in enumerate(sheets):
+            if isinstance(sheet, six.string_types):
+                sheet = book.sheet_by_name(sheet)
+            elif isinstance(sheet, int):
+                sheet = book.sheet_by_index(sheet)
+            else:
+                sheet = book.sheet_by_index(0)
 
             if header:
-                name = six.text_type(data[skip_lines]) or None
-                column_names.append(name)
-
-            columns.append(values)
-
-        rows = []
-
-        if columns:
-            for i in range(len(columns[0])):
-                rows.append([c[i] for c in columns])
-
-        if 'column_names' in kwargs:
-            if not header:
-                column_names = kwargs['column_names']
-            del kwargs['column_names']
-
-        tables[sheet.name] = agate.Table(rows, column_names, **kwargs)
+                offset = 1
+                column_names = []
+            else:
+                offset = 0
+                column_names = None
+
+            columns = []
+            column_types = []
+
+            for i in range(sheet.ncols):
+                if row_limit is None:
+                    values = sheet.col_values(i, skip_lines + offset)
+                    types = sheet.col_types(i, skip_lines + offset)
+                else:
+                    values = sheet.col_values(i, skip_lines + offset, skip_lines + offset + row_limit)
+                    types = sheet.col_types(i, skip_lines + offset, skip_lines + offset + row_limit)
+                excel_type = determine_excel_type(types)
+                agate_type = determine_agate_type(excel_type)
+
+                if excel_type == xlrd.biffh.XL_CELL_BOOLEAN:
+                    values = normalize_booleans(values)
+                elif excel_type == xlrd.biffh.XL_CELL_DATE:
+                    values, with_date, with_time = normalize_dates(values, book.datemode)
+                    if not with_date:
+                        agate_type = agate.TimeDelta()
+                    if not with_time:
+                        agate_type = agate.Date()
+
+                if header:
+                    name = six.text_type(sheet.cell_value(skip_lines, i)) or None
+                    column_names.append(name)
+
+                columns.append(values)
+                column_types.append(agate_type)
+
+            rows = []
+
+            if columns:
+                for i in range(len(columns[0])):
+                    rows.append([c[i] for c in columns])
+
+            if 'column_names' in kwargs:
+                if not header:
+                    column_names = kwargs['column_names']
+                del kwargs['column_names']
+
+            if 'column_types' in kwargs:
+                column_types = kwargs['column_types']
+                del kwargs['column_types']
+
+            tables[sheet.name] = agate.Table(rows, column_names, column_types, **kwargs)
+
+    finally:
+        book.release_resources()
 
     if multiple:
         return agate.MappedSequence(tables.values(), tables.keys())
@@ -96,6 +141,13 @@ def from_xls(cls, path, sheet=None, skip_lines=0, header=True, encoding_override
         return tables.popitem()[1]
 
 
+def determine_agate_type(excel_type):
+    try:
+        return EXCEL_TO_AGATE_TYPE[excel_type]
+    except KeyError:
+        return agate.Text()
+
+
 def determine_excel_type(types):
     """
     Determine the correct type for a column from a list of cell types.
@@ -130,6 +182,8 @@ def normalize_dates(values, datemode=0):
     Normalize a column of date cells.
     """
     normalized = []
+    with_date = False
+    with_time = False
 
     for v in values:
         if not v:
@@ -141,13 +195,18 @@ def normalize_dates(values, datemode=0):
         if v_tuple[3:6] == (0, 0, 0):
             # Date only
             normalized.append(datetime.date(*v_tuple[:3]))
+            with_date = True
         elif v_tuple[:3] == (0, 0, 0):
+            # Time only
             normalized.append(datetime.time(*v_tuple[3:6]))
+            with_time = True
         else:
             # Date and time
             normalized.append(datetime.datetime(*v_tuple[:6]))
+            with_date = True
+            with_time = True
 
-    return normalized
+    return (normalized, with_date, with_time)
 
 
 agate.Table.from_xls = classmethod(from_xls)
diff --git a/agateexcel/table_xlsx.py b/agateexcel/table_xlsx.py
index e3f3c1d4c11e2f04c5c3a881ac5ea40328c4ff62..08e727e457444fbc41e87636712fc6b97ebc3948 100755
--- a/agateexcel/table_xlsx.py
+++ b/agateexcel/table_xlsx.py
@@ -14,8 +14,8 @@ import six
 NULL_TIME = datetime.time(0, 0, 0)
 
 
-def from_xlsx(cls, path, sheet=None, skip_lines=0, header=True, read_only=True, 
-              reset_dimensions=False, **kwargs):
+def from_xlsx(cls, path, sheet=None, skip_lines=0, header=True, read_only=True,
+              reset_dimensions=False, row_limit=None, **kwargs):
     """
     Parse an XLSX file.
 
@@ -29,8 +29,10 @@ def from_xlsx(cls, path, sheet=None, skip_lines=0, header=True, read_only=True,
     :param header:
         If :code:`True`, the first row is assumed to contain column names.
     :param reset_dimensions:
-        If :code:`True`, do not trust the dimensions in the file's properties, 
+        If :code:`True`, do not trust the dimensions in the file's properties,
         and recalculate them based on the data in the file.
+    :param row_limit:
+        Limit how many rows of data will be read.
     """
     if not isinstance(skip_lines, int):
         raise ValueError('skip_lines argument must be an int')
@@ -52,23 +54,38 @@ def from_xlsx(cls, path, sheet=None, skip_lines=0, header=True, read_only=True,
 
     for i, sheet in enumerate(sheets):
         if isinstance(sheet, six.string_types):
-            sheet = book[sheet]
+            try:
+                sheet = book[sheet]
+            except KeyError:
+                f.close()
+                raise
         elif isinstance(sheet, int):
-            sheet = book.worksheets[sheet]
+            try:
+                sheet = book.worksheets[sheet]
+            except IndexError:
+                f.close()
+                raise
         else:
             sheet = book.active
 
         column_names = None
+        offset = 0
         rows = []
 
         if reset_dimensions:
             sheet.reset_dimensions()
 
-        for i, row in enumerate(sheet.iter_rows(min_row=skip_lines + 1)):
-            if i == 0 and header:
-                column_names = [None if c.value is None else six.text_type(c.value) for c in row]
-                continue
+        if header:
+            sheet_header = sheet.iter_rows(min_row=1 + skip_lines, max_row=1 + skip_lines)
+            column_names = [None if c.value is None else six.text_type(c.value) for row in sheet_header for c in row]
+            offset = 1
 
+        if row_limit is None:
+            sheet_rows = sheet.iter_rows(min_row=1 + skip_lines + offset)
+        else:
+            sheet_rows = sheet.iter_rows(min_row=1 + skip_lines + offset, max_row=1 + skip_lines + offset + row_limit)
+
+        for i, row in enumerate(sheet_rows):
             values = []
 
             for c in row:
diff --git a/docs/conf.py b/docs/conf.py
index 05d1315fb68f4e31b8d1cb10465aafc0a23de239..39712be7091ac192f78440677990bdcfd4ad62ff 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,7 +1,5 @@
 # -*- coding: utf-8 -*-
 #
-# flake8: noqa
-#
 # This file is execfile()d with the current directory set to its containing dir.
 #
 # Note that not all possible configuration values are present in this
@@ -54,9 +52,9 @@ copyright = u'2017, Christopher Groskopf'
 # built documents.
 #
 # The short X.Y version.
-version = '0.2.3'
+version = '0.2.5'
 # The full version, including alpha/beta/rc tags.
-release = '0.2.3'
+release = version
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/example.py b/example.py
index c7a5c270834aab61acb2d06421873e02b0843a1f..20d07e467ab24b0c0ae9ece4604fd0f9006a914b 100755
--- a/example.py
+++ b/example.py
@@ -1,7 +1,8 @@
 #!/usr/bin/env python
 
 import agate
-import agateexcel  # noqa
+
+import agateexcel
 
 table = agate.Table.from_xls('examples/test.xls')
 
diff --git a/examples/test_skip_lines.xls b/examples/test_skip_lines.xls
index 76883ac00059eadc34b9adb8fdfe241a4126d31b..9f36d0d70fce754046f85e789b6a66d52a1788f8 100644
Binary files a/examples/test_skip_lines.xls and b/examples/test_skip_lines.xls differ
diff --git a/requirements-py2.txt b/requirements-py2.txt
deleted file mode 100644
index b4653950b1bdc6eae88e0ec4a24d7025c926f176..0000000000000000000000000000000000000000
--- a/requirements-py2.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-unittest2==0.5.1
-nose>=1.1.2
-tox>=1.3
-Sphinx>=1.2.2
-sphinx_rtd_theme>=0.1.6
-wheel>=0.24.0
-ordereddict>=1.1
-xlrd>=0.9.4
-openpyxl>=2.3.0
-agate>=1.2.2
diff --git a/requirements-py3.txt b/requirements-py3.txt
deleted file mode 100644
index cc5825eef602cc879fe6d8ebd723e4f4c4125cd5..0000000000000000000000000000000000000000
--- a/requirements-py3.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-nose>=1.1.2
-tox>=3.1.0
-Sphinx>=1.2.2
-sphinx_rtd_theme>=0.1.6
-wheel>=0.24.0
-xlrd>=0.9.4
-openpyxl>=2.3.0
-agate>=1.2.2
diff --git a/setup.cfg b/setup.cfg
index 2a9acf13daa95e85642ea255d3e3bd1ef8252804..3db2295fba63dbf346e7bb8e82ebf04b0b000e85 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,14 @@
+[flake8]
+max-line-length = 119
+per-file-ignores =
+    # imported but unused
+    agateexcel/__init__.py: F401
+    example.py: F401
+    # block comment should start with '# '
+    docs/conf.py: E265
+
+[isort]
+line_length = 119
+
 [bdist_wheel]
 universal = 1
diff --git a/setup.py b/setup.py
index 0f020e67714b4fe3305ba7ff98fff99b9600f95c..c4eba10b9f6ee75da32aff05ee3e9fed27dad205 100644
--- a/setup.py
+++ b/setup.py
@@ -1,18 +1,14 @@
-#!/usr/bin/env python
+from setuptools import find_packages, setup
 
-from setuptools import setup
-
-install_requires = [
-    'agate>=1.5.0',
-    'xlrd>=0.9.4',
-    'openpyxl>=2.3.0'
-]
+with open('README.rst') as f:
+    long_description = f.read()
 
 setup(
     name='agate-excel',
-    version='0.2.3',
+    version='0.2.5',
     description='agate-excel adds read support for Excel files (xls and xlsx) to agate.',
-    long_description=open('README.rst').read(),
+    long_description=long_description,
+    long_description_content_type='text/x-rst',
     author='Christopher Groskopf',
     author_email='chrisgroskopf@gmail.com',
     url='http://agate-excel.readthedocs.org/',
@@ -26,19 +22,30 @@ setup(
         'Operating System :: OS Independent',
         'Programming Language :: Python',
         'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3.4',
-        'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
         'Programming Language :: Python :: Implementation :: CPython',
         'Programming Language :: Python :: Implementation :: PyPy',
-        'Topic :: Multimedia :: Graphics',
         'Topic :: Scientific/Engineering :: Information Analysis',
-        'Topic :: Scientific/Engineering :: Visualization',
         'Topic :: Software Development :: Libraries :: Python Modules',
     ],
-    packages=[
-        'agateexcel'
+    packages=find_packages(exclude=['tests', 'tests.*']),
+    install_requires=[
+        'agate>=1.5.0',
+        'olefile',
+        'openpyxl>=2.3.0',
+        'six',
+        'xlrd>=0.9.4',
     ],
-    install_requires=install_requires
+    extras_require={
+        'test': [
+            'nose>=1.1.2',
+        ],
+        'docs': [
+            'Sphinx>=1.2.2',
+            'sphinx_rtd_theme>=0.1.6',
+        ],
+    }
 )
diff --git a/tests/test_table_xls.py b/tests/test_table_xls.py
index 6d02d74e77c068be867e3449fa506f3218d75ec9..bba96167e1ccc008620509cd5428a3bb746e559c 100644
--- a/tests/test_table_xls.py
+++ b/tests/test_table_xls.py
@@ -4,7 +4,8 @@
 import datetime
 
 import agate
-import agateexcel  # noqa
+
+import agateexcel  # noqa: F401
 
 
 class TestXLS(agate.AgateTestCase):
@@ -31,7 +32,8 @@ class TestXLS(agate.AgateTestCase):
         self.table = agate.Table(self.rows, self.column_names, self.column_types)
 
     def test_from_xls_with_column_names(self):
-        table = agate.Table.from_xls('examples/test.xls', header=False, skip_lines=1, column_names=self.user_provided_column_names )
+        table = agate.Table.from_xls('examples/test.xls', header=False, skip_lines=1,
+                                     column_names=self.user_provided_column_names)
 
         self.assertColumnNames(table, self.user_provided_column_names)
         self.assertColumnTypes(table, [agate.Number, agate.Text, agate.Boolean, agate.Date, agate.DateTime])
@@ -135,3 +137,17 @@ class TestXLS(agate.AgateTestCase):
         self.assertRows(table, [
             ['Canada', 35160000, 'value'],
         ])
+
+    def test_row_limit(self):
+        table = agate.Table.from_xls('examples/test.xls', row_limit=2)
+
+        self.assertColumnNames(table, self.column_names)
+        self.assertColumnTypes(table, [agate.Number, agate.Text, agate.Boolean, agate.Date, agate.DateTime])
+        self.assertRows(table, [r.values() for r in self.table.rows][:2])
+
+    def test_row_limit_too_high(self):
+        table = agate.Table.from_xls('examples/test.xls', row_limit=200)
+
+        self.assertColumnNames(table, self.column_names)
+        self.assertColumnTypes(table, [agate.Number, agate.Text, agate.Boolean, agate.Date, agate.DateTime])
+        self.assertRows(table, [r.values() for r in self.table.rows])
diff --git a/tests/test_table_xlsx.py b/tests/test_table_xlsx.py
index 9b56b9b2964c2d841cd821e700cd2f17dcbd2672..e95458e6b6df132858d203d5b460dcf2b41e57a1 100644
--- a/tests/test_table_xlsx.py
+++ b/tests/test_table_xlsx.py
@@ -4,7 +4,9 @@
 import datetime
 
 import agate
-import agateexcel  # noqa
+import six
+
+import agateexcel  # noqa: F401
 
 
 class TestXLSX(agate.AgateTestCase):
@@ -31,7 +33,8 @@ class TestXLSX(agate.AgateTestCase):
         self.table = agate.Table(self.rows, self.column_names, self.column_types)
 
     def test_from_xlsx_with_column_names(self):
-        table = agate.Table.from_xlsx('examples/test.xlsx', header=False, skip_lines=1, column_names=self.user_provided_column_names)
+        table = agate.Table.from_xlsx('examples/test.xlsx', header=False, skip_lines=1,
+                                      column_names=self.user_provided_column_names)
 
         self.assertColumnNames(table, self.user_provided_column_names)
         self.assertColumnTypes(table, [agate.Number, agate.Text, agate.Boolean, agate.Date, agate.DateTime])
@@ -103,10 +106,16 @@ class TestXLSX(agate.AgateTestCase):
     def test_ambiguous_date(self):
         table = agate.Table.from_xlsx('examples/test_ambiguous_date.xlsx')
 
+        # openpyxl >= 3 fixes a bug, but Python 2 is constrained to openpyxl < 3.
+        if six.PY2:
+            expected = datetime.date(1899, 12, 31)
+        else:
+            expected = datetime.date(1900, 1, 1)
+
         self.assertColumnNames(table, ['s'])
         self.assertColumnTypes(table, [agate.Date])
         self.assertRows(table, [
-            [datetime.date(1899, 12, 31)],
+            [expected],
         ])
 
     def test_empty(self):
@@ -124,3 +133,17 @@ class TestXLSX(agate.AgateTestCase):
         self.assertRows(table, [
             ['Canada', 35160000, 'value'],
         ])
+
+    def test_row_limit(self):
+        table = agate.Table.from_xlsx('examples/test.xlsx', row_limit=2)
+
+        self.assertColumnNames(table, self.column_names)
+        self.assertColumnTypes(table, [agate.Number, agate.Text, agate.Boolean, agate.Date, agate.DateTime])
+        self.assertRows(table, [r.values() for r in self.table.rows][:2])
+
+    def test_row_limit_too_high(self):
+        table = agate.Table.from_xlsx('examples/test.xlsx', row_limit=200)
+
+        self.assertColumnNames(table, self.column_names)
+        self.assertColumnTypes(table, [agate.Number, agate.Text, agate.Boolean, agate.Date, agate.DateTime])
+        self.assertRows(table, [r.values() for r in self.table.rows])
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 779fba2c44e2a69f2929d56083821dda312f7c4d..0000000000000000000000000000000000000000
--- a/tox.ini
+++ /dev/null
@@ -1,32 +0,0 @@
-[tox]
-envlist = py27,py34,py35,py36,py37,pypy
-
-[testenv]
-deps=
-    nose>=1.1.2
-    six>=1.6.1
-commands=nosetests
-
-[testenv:py27]
-deps=
-    {[testenv]deps}
-
-[testenv:py34]
-deps=
-    {[testenv]deps}
-
-[testenv:py35]
-deps=
-    {[testenv:py33]deps}
-
-[testenv:py36]
-deps=
-    {[testenv:py33]deps}
-
-[testenv:py37]
-deps=
-    {[testenv:py33]deps}
-
-[testenv:pypy]
-deps=
-    {[testenv:py33]deps}