Repository: innobi/pantab
Pull request #27 (2019-11-22 18:11:45)

The Big Split


Patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index dc4b86d..8140f68 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -22,6 +22,7 @@ jobs:
         python -m pip install --upgrade pip      
         pip install mypy isort black
         black --check pantab
+        # isort **/*.py -c
         mypy pantab
     - name: Install dependencies (pandas)
       run: |
diff --git a/pantab/__init__.py b/pantab/__init__.py
index 7702581..6680582 100644
--- a/pantab/__init__.py
+++ b/pantab/__init__.py
@@ -1,9 +1,13 @@
 __version__ = "0.0.1.b5"
 
 
-from ._pantab import (
-    frame_from_hyper,
-    frame_to_hyper,
-    frames_from_hyper,
-    frames_to_hyper,
-)
+from ._reader import frame_from_hyper, frames_from_hyper
+from ._writer import frame_to_hyper, frames_to_hyper
+
+__all__ = [
+    "__version__",
+    "frame_from_hyper",
+    "frames_from_hyper",
+    "frame_to_hyper",
+    "frames_to_hyper",
+]
diff --git a/pantab/_reader.py b/pantab/_reader.py
new file mode 100644
index 0000000..2566d7b
--- /dev/null
+++ b/pantab/_reader.py
@@ -0,0 +1,88 @@
+import pathlib
+import shutil
+import tempfile
+from typing import Dict, Union
+
+import numpy as np
+import pandas as pd
+import tableauhyperapi as tab_api
+
+import pantab._types as pantab_types
+
+TableType = Union[str, tab_api.Name, tab_api.TableName]
+
+
+def _tableau_to_pandas_type(typ: tab_api.TableDefinition.Column) -> str:
+    try:
+        return pantab_types._pandas_types[typ]
+    except KeyError:
+        return "object"
+
+
+def _interval_to_timedelta(interval: tab_api.Interval) -> pd.Timedelta:
+    """Converts a tableau Hyper API Interval to a pandas Timedelta."""
+    if interval.months != 0:
+        raise ValueError("Cannot read Intervals with month componenets.")
+
+    return pd.Timedelta(days=interval.days, microseconds=interval.microseconds)
+
+
+def _read_table(*, connection: tab_api.Connection, table: TableType) -> pd.DataFrame:
+    if isinstance(table, str):
+        table = tab_api.TableName(table)
+
+    table_def = connection.catalog.get_table_definition(table)
+    columns = table_def.columns
+
+    dtypes: Dict[str, str] = {}
+    for column in columns:
+        column_type = pantab_types._ColumnType(column.type, column.nullability)
+        dtypes[column.name.unescaped] = _tableau_to_pandas_type(column_type)
+
+    with connection.execute_query(f"SELECT * from {table}") as result:
+        df = pd.DataFrame(result)
+
+    df.columns = dtypes.keys()
+    # The tableauhyperapi.Timestamp class is not implicitly convertible to a datetime
+    # so we need to run an apply against applicable types
+    for key, val in dtypes.items():
+        if val == "datetime64[ns]":
+            df[key] = df[key].apply(lambda x: x._to_datetime())
+        elif val == "datetime64[ns, UTC]":
+            df[key] = df[key].apply(lambda x: x._to_datetime()).dt.tz_localize("UTC")
+        elif val == "timedelta64[ns]":
+            df[key] = df[key].apply(_interval_to_timedelta)
+
+    df = df.astype(dtypes)
+    df = df.fillna(value=np.nan)  # Replace any appearances of None
+
+    return df
+
+
+def frame_from_hyper(
+    database: Union[str, pathlib.Path], *, table: TableType
+) -> pd.DataFrame:
+    """See api.rst for documentation"""
+    with tempfile.TemporaryDirectory() as tmp_dir, tab_api.HyperProcess(
+        tab_api.Telemetry.DO_NOT_SEND_USAGE_DATA_TO_TABLEAU
+    ) as hpe:
+        tmp_db = shutil.copy(database, tmp_dir)
+        with tab_api.Connection(hpe.endpoint, tmp_db) as connection:
+            return _read_table(connection=connection, table=table)
+
+
+def frames_from_hyper(
+    database: Union[str, pathlib.Path]
+) -> Dict[tab_api.TableName, pd.DataFrame]:
+    """See api.rst for documentation."""
+    result: Dict[TableType, pd.DataFrame] = {}
+    with tempfile.TemporaryDirectory() as tmp_dir, tab_api.HyperProcess(
+        tab_api.Telemetry.DO_NOT_SEND_USAGE_DATA_TO_TABLEAU
+    ) as hpe:
+        tmp_db = shutil.copy(database, tmp_dir)
+        with tab_api.Connection(hpe.endpoint, tmp_db) as connection:
+            for schema in connection.catalog.get_schema_names():
+                for table in connection.catalog.get_table_names(schema=schema):
+                    result[table] = _read_table(connection=connection, table=table)
+
+    return result
diff --git a/pantab/_types.py b/pantab/_types.py
new file mode 100644
index 0000000..1ceb305
--- /dev/null
+++ b/pantab/_types.py
@@ -0,0 +1,36 @@
+import collections
+from typing import Union
+
+import tableauhyperapi as tab_api
+
+# The Hyper API as of writing doesn't offer great hashability for column comparison
+# so we create out namedtuple for that purpose
+_ColumnType = collections.namedtuple("_ColumnType", ["type_", "nullability"])
+
+TableType = Union[str, tab_api.Name, tab_api.TableName]
+
+_column_types = {
+    "int16": _ColumnType(tab_api.SqlType.small_int(), tab_api.Nullability.NOT_NULLABLE),
+    "int32": _ColumnType(tab_api.SqlType.int(), tab_api.Nullability.NOT_NULLABLE),
+    "int64": _ColumnType(tab_api.SqlType.big_int(), tab_api.Nullability.NOT_NULLABLE),
+    "Int16": _ColumnType(tab_api.SqlType.small_int(), tab_api.Nullability.NULLABLE),
+    "Int32": _ColumnType(tab_api.SqlType.int(), tab_api.Nullability.NULLABLE),
+    "Int64": _ColumnType(tab_api.SqlType.big_int(), tab_api.Nullability.NULLABLE),
+    "float32": _ColumnType(tab_api.SqlType.double(), tab_api.Nullability.NULLABLE),
+    "float64": _ColumnType(tab_api.SqlType.double(), tab_api.Nullability.NULLABLE),
+    "bool": _ColumnType(tab_api.SqlType.bool(), tab_api.Nullability.NOT_NULLABLE),
+    "datetime64[ns]": _ColumnType(
+        tab_api.SqlType.timestamp(), tab_api.Nullability.NULLABLE
+    ),
+    "datetime64[ns, UTC]": _ColumnType(
+        tab_api.SqlType.timestamp_tz(), tab_api.Nullability.NULLABLE
+    ),
+    "timedelta64[ns]": _ColumnType(
+        tab_api.SqlType.interval(), tab_api.Nullability.NULLABLE
+    ),
+    "object": _ColumnType(tab_api.SqlType.text(), tab_api.Nullability.NULLABLE),
+}
+
+
+# Invert this, but exclude float32 as that does not roundtrip
+_pandas_types = {v: k for k, v in _column_types.items() if k != "float32"}
diff --git a/pantab/_pantab.py b/pantab/_writer.py
similarity index 59%
rename from pantab/_pantab.py
rename to pantab/_writer.py
index f325a75..901b5fe 100644
--- a/pantab/_pantab.py
+++ b/pantab/_writer.py
@@ -1,4 +1,3 @@
-import collections
 import itertools
 import pathlib
 import shutil
@@ -6,61 +5,19 @@ import tempfile
 import uuid
 from typing import Dict, List, Sequence, Union
 
-import numpy as np
 import pandas as pd
 import tableauhyperapi as tab_api
 
-__all__ = ["frame_to_hyper", "frame_from_hyper", "frames_from_hyper", "frames_to_hyper"]
-
-
-# The Hyper API as of writing doesn't offer great hashability for column comparison
-# so we create out namedtuple for that purpose
-_ColumnType = collections.namedtuple("_ColumnType", ["type_", "nullability"])
-
-_column_types = {
-    "int16": _ColumnType(tab_api.SqlType.small_int(), tab_api.Nullability.NOT_NULLABLE),
-    "int32": _ColumnType(tab_api.SqlType.int(), tab_api.Nullability.NOT_NULLABLE),
-    "int64": _ColumnType(tab_api.SqlType.big_int(), tab_api.Nullability.NOT_NULLABLE),
-    "Int16": _ColumnType(tab_api.SqlType.small_int(), tab_api.Nullability.NULLABLE),
-    "Int32": _ColumnType(tab_api.SqlType.int(), tab_api.Nullability.NULLABLE),
-    "Int64": _ColumnType(tab_api.SqlType.big_int(), tab_api.Nullability.NULLABLE),
-    "float32": _ColumnType(tab_api.SqlType.double(), tab_api.Nullability.NULLABLE),
-    "float64": _ColumnType(tab_api.SqlType.double(), tab_api.Nullability.NULLABLE),
-    "bool": _ColumnType(tab_api.SqlType.bool(), tab_api.Nullability.NOT_NULLABLE),
-    "datetime64[ns]": _ColumnType(
-        tab_api.SqlType.timestamp(), tab_api.Nullability.NULLABLE
-    ),
-    "datetime64[ns, UTC]": _ColumnType(
-        tab_api.SqlType.timestamp_tz(), tab_api.Nullability.NULLABLE
-    ),
-    "timedelta64[ns]": _ColumnType(
-        tab_api.SqlType.interval(), tab_api.Nullability.NULLABLE
-    ),
-    "object": _ColumnType(tab_api.SqlType.text(), tab_api.Nullability.NULLABLE),
-}
-
-
-# Invert this, but exclude float32 as that does not roundtrip
-_pandas_types = {v: k for k, v in _column_types.items() if k != "float32"}
-
-
-TableType = Union[str, tab_api.Name, tab_api.TableName]
+import pantab._types as pantab_types
 
 
-def _pandas_to_tableau_type(typ: str) -> _ColumnType:
+def _pandas_to_tableau_type(typ: str) -> pantab_types._ColumnType:
     try:
-        return _column_types[typ]
+        return pantab_types._column_types[typ]
     except KeyError:
         raise TypeError("Conversion of '{}' dtypes not supported!".format(typ))
 
 
-def _tableau_to_pandas_type(typ: tab_api.TableDefinition.Column) -> str:
-    try:
-        return _pandas_types[typ]
-    except KeyError:
-        return "object"
-
-
 # The Hyper API doesn't expose these functions directly and wraps them with
 # validation; we can skip the validation because the column dtypes enforce that
 _insert_functions = {
@@ -86,14 +43,6 @@ def _timedelta_to_interval(td: pd.Timedelta) -> tab_api.Interval:
     return tab_api.Interval(months=0, days=days, microseconds=microseconds)
 
 
-def _interval_to_timedelta(interval: tab_api.Interval) -> pd.Timedelta:
-    """Converts a tableau Hyper API Interval to a pandas Timedelta."""
-    if interval.months != 0:
-        raise ValueError("Cannot read Intervals with month componenets.")
-
-    return pd.Timedelta(days=interval.days, microseconds=interval.microseconds)
-
-
 def _validate_table_mode(table_mode: str) -> None:
     if table_mode not in {"a", "w"}:
         raise ValueError("'table_mode' must be either 'w' or 'a'")
@@ -145,7 +94,7 @@ def _insert_frame(
     df: pd.DataFrame,
     *,
     connection: tab_api.Connection,
-    table: TableType,
+    table: pantab_types.TableType,
     table_mode: str,
 ) -> None:
     _validate_table_mode(table_mode)
@@ -155,7 +104,7 @@ def _insert_frame(
 
     # Populate insertion mechanisms dependent on column types
     insert_funcs: List[str] = []
-    column_types: List[_ColumnType] = []
+    column_types: List[pantab_types._ColumnType] = []
     columns: List[tab_api.TableDefinition.Column] = []
     for col_name, dtype in df.dtypes.items():
         column_type = _pandas_to_tableau_type(dtype.name)
@@ -215,43 +164,11 @@ def _insert_frame(
         inserter.execute()
 
 
-def _read_table(*, connection: tab_api.Connection, table: TableType) -> pd.DataFrame:
-    if isinstance(table, str):
-        table = tab_api.TableName(table)
-
-    table_def = connection.catalog.get_table_definition(table)
-    columns = table_def.columns
-
-    dtypes: Dict[str, str] = {}
-    for column in columns:
-        column_type = _ColumnType(column.type, column.nullability)
-        dtypes[column.name.unescaped] = _tableau_to_pandas_type(column_type)
-
-    with connection.execute_query(f"SELECT * from {table}") as result:
-        df = pd.DataFrame(result)
-
-    df.columns = dtypes.keys()
-    # The tableauhyperapi.Timestamp class is not implicitly convertible to a datetime
-    # so we need to run an apply against applicable types
-    for key, val in dtypes.items():
-        if val == "datetime64[ns]":
-            df[key] = df[key].apply(lambda x: x._to_datetime())
-        elif val == "datetime64[ns, UTC]":
-            df[key] = df[key].apply(lambda x: x._to_datetime()).dt.tz_localize("UTC")
-        elif val == "timedelta64[ns]":
-            df[key] = df[key].apply(_interval_to_timedelta)
-
-    df = df.astype(dtypes)
-    df = df.fillna(value=np.nan)  # Replace any appearances of None
-
-    return df
-
-
 def frame_to_hyper(
     df: pd.DataFrame,
     database: Union[str, pathlib.Path],
     *,
-    table: TableType,
+    table: pantab_types.TableType,
     table_mode: str = "w",
 ) -> None:
     """See api.rst for documentation"""
@@ -271,20 +188,8 @@ def frame_to_hyper(
         shutil.move(tmp_db, database)
 
 
-def frame_from_hyper(
-    database: Union[str, pathlib.Path], *, table: TableType
-) -> pd.DataFrame:
-    """See api.rst for documentation"""
-    with tempfile.TemporaryDirectory() as tmp_dir, tab_api.HyperProcess(
-        tab_api.Telemetry.DO_NOT_SEND_USAGE_DATA_TO_TABLEAU
-    ) as hpe:
-        tmp_db = shutil.copy(database, tmp_dir)
-        with tab_api.Connection(hpe.endpoint, tmp_db) as connection:
-            return _read_table(connection=connection, table=table)
-
-
 def frames_to_hyper(
-    dict_of_frames: Dict[TableType, pd.DataFrame],
+    dict_of_frames: Dict[pantab_types.TableType, pd.DataFrame],
     database: Union[str, pathlib.Path],
     table_mode: str = "w",
 ) -> None:
@@ -308,20 +213,3 @@ def frames_to_hyper(
                 )
 
         shutil.move(tmp_db, database)
-
-
-def frames_from_hyper(
-    database: Union[str, pathlib.Path]
-) -> Dict[tab_api.TableName, pd.DataFrame]:
-    """See api.rst for documentation."""
-    result: Dict[TableType, pd.DataFrame] = {}
-    with tempfile.TemporaryDirectory() as tmp_dir, tab_api.HyperProcess(
-        tab_api.Telemetry.DO_NOT_SEND_USAGE_DATA_TO_TABLEAU
-    ) as hpe:
-        tmp_db = shutil.copy(database, tmp_dir)
-        with tab_api.Connection(hpe.endpoint, tmp_db) as connection:
-            for schema in connection.catalog.get_schema_names():
-                for table in connection.catalog.get_table_names(schema=schema):
-                    result[table] = _read_table(connection=connection, table=table)
-
-    return result
diff --git a/setup.cfg b/setup.cfg
index 7cbd6d2..1d634d3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -12,7 +12,6 @@ ignore_missing_imports=True
 
 [flake8]
 max-line-length=88
-per-file-ignores=pantab/__init__.py:F401
 
 [isort]
 include_trailing_comma=True


Test patch:
diff --git a/pantab/tests/conftest.py b/pantab/tests/conftest.py
new file mode 100644
index 0000000..d9ab533
--- /dev/null
+++ b/pantab/tests/conftest.py
@@ -0,0 +1,101 @@
+import numpy as np
+import pandas as pd
+import pytest
+import tableauhyperapi as tab_api
+
+
+@pytest.fixture
+def df():
+    """Fixture to use which should contain all data types."""
+    df = pd.DataFrame(
+        [
+            [
+                1,
+                2,
+                3,
+                1,
+                2,
+                3,
+                4.0,
+                5.0,
+                True,
+                pd.to_datetime("2018-01-01"),
+                pd.to_datetime("2018-01-01", utc=True),
+                pd.Timedelta("1 days 2 hours 3 minutes 4 seconds"),
+                "foo",
+            ],
+            [
+                6,
+                7,
+                8,
+                np.nan,
+                np.nan,
+                np.nan,
+                9.0,
+                10.0,
+                False,
+                pd.to_datetime("1/1/19"),
+                pd.to_datetime("2019-01-01", utc=True),
+                pd.Timedelta("-1 days 2 hours 3 minutes 4 seconds"),
+                "bar",
+            ],
+        ],
+        columns=[
+            "int16",
+            "int32",
+            "int64",
+            "Int16",
+            "Int32",
+            "Int64",
+            "float32",
+            "float64",
+            "bool",
+            "datetime64",
+            "datetime64_utc",
+            "timedelta64",
+            "object",
+        ],
+    )
+
+    df = df.astype(
+        {
+            "int16": np.int16,
+            "int32": np.int32,
+            "int64": np.int64,
+            "float32": np.float32,
+            "float64": np.float64,
+            "bool": np.bool,
+            "datetime64": "datetime64[ns]",
+            "datetime64_utc": "datetime64[ns, UTC]",
+            "timedelta64": "timedelta64[ns]",
+            "object": "object",
+        }
+    )
+
+    return df
+
+
+@pytest.fixture
+def tmp_hyper(tmp_path):
+    """A temporary file name to write / read a Hyper extract from."""
+    return tmp_path / "test.hyper"
+
+
+@pytest.fixture(params=["w", "a"])
+def table_mode(request):
+    """Write or append markers for table handling."""
+    return request.param
+
+
+@pytest.fixture(
+    params=[
+        "table",
+        tab_api.Name("table"),
+        tab_api.TableName("table"),
+        tab_api.TableName("public", "table"),
+        tab_api.TableName("nonpublic", "table"),
+    ]
+)
+def table_name(request):
+    """Various ways to represent a table in Tableau."""
+    return request.param
diff --git a/pantab/tests/test_pantab.py b/pantab/tests/test_pantab.py
index 3f89a35..02e8bdc 100644
--- a/pantab/tests/test_pantab.py
+++ b/pantab/tests/test_pantab.py
@@ -9,77 +9,6 @@ import tableauhyperapi as tab_api
 import pantab
 
 
-@pytest.fixture
-def df():
-    """Fixture to use which should contain all data types."""
-    df = pd.DataFrame(
-        [
-            [
-                1,
-                2,
-                3,
-                1,
-                2,
-                3,
-                4.0,
-                5.0,
-                True,
-                pd.to_datetime("2018-01-01"),
-                pd.to_datetime("2018-01-01", utc=True),
-                pd.Timedelta("1 days 2 hours 3 minutes 4 seconds"),
-                "foo",
-            ],
-            [
-                6,
-                7,
-                8,
-                np.nan,
-                np.nan,
-                np.nan,
-                9.0,
-                10.0,
-                False,
-                pd.to_datetime("1/1/19"),
-                pd.to_datetime("2019-01-01", utc=True),
-                pd.Timedelta("-1 days 2 hours 3 minutes 4 seconds"),
-                "bar",
-            ],
-        ],
-        columns=[
-            "int16",
-            "int32",
-            "int64",
-            "Int16",
-            "Int32",
-            "Int64",
-            "float32",
-            "float64",
-            "bool",
-            "datetime64",
-            "datetime64_utc",
-            "timedelta64",
-            "object",
-        ],
-    )
-
-    df = df.astype(
-        {
-            "int16": np.int16,
-            "int32": np.int32,
-            "int64": np.int64,
-            "float32": np.float32,
-            "float64": np.float64,
-            "bool": np.bool,
-            "datetime64": "datetime64[ns]",
-            "datetime64_utc": "datetime64[ns, UTC]",
-            "timedelta64": "timedelta64[ns]",
-            "object": "object",
-        }
-    )
-
-    return df
-
-
 @pytest.fixture
 def tmp_hyper(tmp_path):
     """A temporary file name to write / read a Hyper extract from."""
@@ -214,7 +143,7 @@ def test_failed_write_doesnt_overwrite_file(df, tmp_hyper, monkeypatch, table_mo
     def failure(*args, **kwargs):
         raise ValueError("dummy failure")
 
-    monkeypatch.setattr(pantab._pantab.tab_api, "Inserter", failure, raising=True)
+    monkeypatch.setattr(pantab._writer.tab_api, "Inserter", failure, raising=True)
 
     # Try out our write methods
     with pytest.raises(ValueError, match="dummy failure"):
@@ -256,7 +185,7 @@ def test_months_in_interval_raises(df, tmp_hyper, monkeypatch):
         self.days = days
         self.microseconds = microseconds
 
-    monkeypatch.setattr(pantab._pantab.tab_api.Interval, "__init__", __init__)
+    monkeypatch.setattr(pantab._writer.tab_api.Interval, "__init__", __init__)
     pantab.frame_to_hyper(df, tmp_hyper, table="test")
     with pytest.raises(
         ValueError, match=r"Cannot read Intervals with month componenets\."
diff --git a/pantab/tests/test_reader.py b/pantab/tests/test_reader.py
new file mode 100644
index 0000000..5f46ec9
--- /dev/null
+++ b/pantab/tests/test_reader.py
@@ -0,0 +1,35 @@
+import pytest
+
+import pantab
+
+
+def test_read_doesnt_modify_existing_file(df, tmp_hyper):
+    pantab.frame_to_hyper(df, tmp_hyper, table="test")
+    last_modified = tmp_hyper.stat().st_mtime
+
+    # Try out our read methods
+    pantab.frame_from_hyper(tmp_hyper, table="test")
+    pantab.frames_from_hyper(tmp_hyper)
+
+    # Neither should not update file stats
+    assert last_modified == tmp_hyper.stat().st_mtime
+
+
+def test_months_in_interval_raises(df, tmp_hyper, monkeypatch):
+    # Monkeypatch a new constructor that hard codes months
+    def __init__(self, months: int, days: int, microseconds: int):
+        self.months = 1
+        self.days = days
+        self.microseconds = microseconds
+
+    monkeypatch.setattr(pantab._writer.tab_api.Interval, "__init__", __init__)
+    pantab.frame_to_hyper(df, tmp_hyper, table="test")
+    with pytest.raises(
+        ValueError, match=r"Cannot read Intervals with month componenets\."
+    ):
+        pantab.frame_from_hyper(tmp_hyper, table="test")
+
+    with pytest.raises(
+        ValueError, match=r"Cannot read Intervals with month componenets\."
+    ):
+        pantab.frames_from_hyper(tmp_hyper)
diff --git a/pantab/tests/test_roundtrip.py b/pantab/tests/test_roundtrip.py
new file mode 100644
index 0000000..284cfc6
--- /dev/null
+++ b/pantab/tests/test_roundtrip.py
@@ -0,0 +1,67 @@
+import numpy as np
+import pandas as pd
+import pandas.util.testing as tm
+import tableauhyperapi as tab_api
+
+import pantab
+
+
+def test_basic(df, tmp_hyper, table_name, table_mode):
+    # Write twice; depending on mode this should either overwrite or duplicate entries
+    pantab.frame_to_hyper(df, tmp_hyper, table=table_name, table_mode=table_mode)
+    pantab.frame_to_hyper(df, tmp_hyper, table=table_name, table_mode=table_mode)
+    result = pantab.frame_from_hyper(tmp_hyper, table=table_name)
+
+    expected = df.copy()
+
+    if table_mode == "a":
+        expected = pd.concat([expected, expected]).reset_index(drop=True)
+
+    expected["float32"] = expected["float32"].astype(np.float64)
+
+    tm.assert_frame_equal(result, expected)
+
+
+def test_missing_data(tmp_hyper, table_name, table_mode):
+    df = pd.DataFrame([[np.nan], [1]], columns=list("a"))
+    df["b"] = pd.Series([None, np.nan], dtype=object)  # no inference
+    df["c"] = pd.Series([np.nan, "c"])
+
+    pantab.frame_to_hyper(df, tmp_hyper, table=table_name, table_mode=table_mode)
+    pantab.frame_to_hyper(df, tmp_hyper, table=table_name, table_mode=table_mode)
+
+    result = pantab.frame_from_hyper(tmp_hyper, table=table_name)
+    expected = pd.DataFrame(
+        [[np.nan, np.nan, np.nan], [1, np.nan, "c"]], columns=list("abc")
+    )
+    if table_mode == "a":
+        expected = pd.concat([expected, expected]).reset_index(drop=True)
+
+    tm.assert_frame_equal(result, expected)
+
+
+def test_multiple_tables(df, tmp_hyper, table_name, table_mode):
+    # Write twice; depending on mode this should either overwrite or duplicate entries
+    pantab.frames_to_hyper(
+        {table_name: df, "table2": df}, tmp_hyper, table_mode=table_mode
+    )
+    pantab.frames_to_hyper(
+        {table_name: df, "table2": df}, tmp_hyper, table_mode=table_mode
+    )
+    result = pantab.frames_from_hyper(tmp_hyper)
+
+    expected = df.copy()
+    if table_mode == "a":
+        expected = pd.concat([expected, expected]).reset_index(drop=True)
+
+    expected["float32"] = expected["float32"].astype(np.float64)
+
+    # some test trickery here
+    if not isinstance(table_name, tab_api.TableName) or table_name.schema_name is None:
+        table_name = tab_api.TableName("public", table_name)
+
+    assert set(result.keys()) == set(
+        (table_name, tab_api.TableName("public", "table2"))
+    )
+    for val in result.values():
+        tm.assert_frame_equal(val, expected)
diff --git a/pantab/tests/test_writer.py b/pantab/tests/test_writer.py
new file mode 100644
index 0000000..b879fd4
--- /dev/null
+++ b/pantab/tests/test_writer.py
@@ -0,0 +1,90 @@
+import re
+
+import numpy as np
+import pandas as pd
+import pytest
+import tableauhyperapi as tab_api
+
+import pantab
+
+
+def test_bad_table_mode_raises(df, tmp_hyper):
+    msg = "'table_mode' must be either 'w' or 'a'"
+    with pytest.raises(ValueError, match=msg):
+        pantab.frame_to_hyper(df, tmp_hyper, table="test", table_mode="x")
+
+    with pytest.raises(ValueError, match=msg):
+        pantab.frames_to_hyper({"a": df}, tmp_hyper, table_mode="x")
+
+
+def test_append_mode_raises_column_mismatch(df, tmp_hyper, table_name):
+    pantab.frame_to_hyper(df, tmp_hyper, table=table_name)
+
+    df = df.drop("object", axis=1)
+    msg = "^Mismatched column definitions:"
+    with pytest.raises(TypeError, match=msg):
+        pantab.frame_to_hyper(df, tmp_hyper, table=table_name, table_mode="a")
+
+
+def test_append_mode_raises_column_dtype_mismatch(df, tmp_hyper, table_name):
+    pantab.frame_to_hyper(df, tmp_hyper, table=table_name)
+
+    df["int16"] = df["int16"].astype(np.int64)
+    msg = "^Mismatched column definitions:"
+    with pytest.raises(TypeError, match=msg):
+        pantab.frame_to_hyper(df, tmp_hyper, table=table_name, table_mode="a")
+
+
+def test_failed_write_doesnt_overwrite_file(df, tmp_hyper, monkeypatch, table_mode):
+    pantab.frame_to_hyper(df, tmp_hyper, table="test", table_mode=table_mode)
+    last_modified = tmp_hyper.stat().st_mtime
+
+    # Let's patch the Inserter to fail on creation
+    def failure(*args, **kwargs):
+        raise ValueError("dummy failure")
+
+    monkeypatch.setattr(pantab._writer.tab_api, "Inserter", failure, raising=True)
+
+    # Try out our write methods
+    with pytest.raises(ValueError, match="dummy failure"):
+        pantab.frame_to_hyper(df, tmp_hyper, table="test", table_mode=table_mode)
+        pantab.frames_to_hyper({"test": df}, tmp_hyper, table_mode=table_mode)
+
+    # Neither should not update file stats
+    assert last_modified == tmp_hyper.stat().st_mtime
+
+
+def test_duplicate_columns_raises(tmp_hyper):
+    df = pd.DataFrame([[1, 1]], columns=[1, 1])
+    with pytest.raises(
+        tab_api.hyperexception.HyperException,
+        match="column '1' specified more than once",
+    ):
+        pantab.frame_to_hyper(df, tmp_hyper, table="foo")
+
+    with pytest.raises(
+        tab_api.hyperexception.HyperException,
+        match="column '1' specified more than once",
+    ):
+        pantab.frames_to_hyper({"test": df}, tmp_hyper)
+
+
+@pytest.mark.parametrize("dtype", ["UInt64", "datetime64[ns, US/Eastern]"])
+def test_unsupported_dtype_raises(dtype, tmp_hyper):
+    df = pd.DataFrame([[1]], dtype=dtype)
+
+    msg = re.escape(f"Conversion of '{dtype}' dtypes not supported!")
+    with pytest.raises(TypeError, match=msg):
+        pantab.frame_to_hyper(df, tmp_hyper, table="test")
+
+
+def test_bad_value_gives_clear_message(tmp_hyper):
+    df = pd.DataFrame([[{"a": "b"}]], columns=["a"])
+
+    msg = (
+        r"Unsupported type '<class 'dict'>' for column type 'object' "
+        r"\(column 'a' row 0\)"
+    )
+
+    with pytest.raises(TypeError, match=msg):
+        pantab.frame_to_hyper(df, tmp_hyper, table="test")