Repository: mie-lab/trackintel
Pull request #530 (2023-09-18 17:01:07)

ENH: enable subclassing for tours
The last class. For reference see #490.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
Patch coverage: **`71.42%`** and project coverage change: **`-0.29%`** :warning:
> Comparison is base [(`296f397`)](https://app.codecov.io/gh/mie-lab/trackintel/commit/296f3971a853221e2de7bb76fa897962f58bc7db?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 93.31% compared to head [(`e860dff`)](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 93.02%.

> :exclamation: Current head e860dff differs from pull request most recent head aaeb548. Consider uploading reports for the commit aaeb548 to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #530      +/-   ##
==========================================
- Coverage   93.31%   93.02%   -0.29%     
==========================================
  Files          34       34              
  Lines        2153     2166      +13     
  Branches      409      414       +5     
==========================================
+ Hits         2009     2015       +6     
- Misses        130      134       +4     
- Partials       14       17       +3     
```


| [Files Changed](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/model/tours.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC90b3Vycy5weQ==) | `70.96% <61.90%> (-18.51%)` | :arrow_down: |
| [trackintel/\_\_init\_\_.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [trackintel/io/file.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9pby9maWxlLnB5) | `100.00% <100.00%> (ø)` | |
| [trackintel/io/postgis.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9pby9wb3N0Z2lzLnB5) | `96.07% <100.00%> (ø)` | |
| [trackintel/model/\_\_init\_\_.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mie-lab/trackintel/pull/530?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab).


Patch:
diff --git a/docs/modules/model.rst b/docs/modules/model.rst
index a726b9f..ed0f32a 100644
--- a/docs/modules/model.rst
+++ b/docs/modules/model.rst
@@ -80,16 +80,16 @@ Locations
 .. autoclass:: trackintel.model.locations.Locations
 	:members:
 
-TripsAccessor
+Trips
 -------------
 
-.. autoclass:: trackintel.model.trips.TripsAccessor
+.. autoclass:: trackintel.model.trips.Trips
 	:members:
 
-ToursAccessor
+Tours
 -------------
 
-.. autoclass:: trackintel.model.tours.ToursAccessor
+.. autoclass:: trackintel.model.tours.Tours
 	:members:
 
 
diff --git a/trackintel/__init__.py b/trackintel/__init__.py
index ff35ec6..59a32bc 100644
--- a/trackintel/__init__.py
+++ b/trackintel/__init__.py
@@ -17,6 +17,7 @@ from trackintel.model.staypoints import Staypoints
 from trackintel.model.trips import Trips
 from trackintel.model.trips import TripsDataFrame
 from trackintel.model.trips import TripsGeoDataFrame
+from trackintel.model.tours import Tours
 
 from trackintel.visualization import plot, plot_modal_split
 
diff --git a/trackintel/io/file.py b/trackintel/io/file.py
index f20bd35..dc4d0f9 100644
--- a/trackintel/io/file.py
+++ b/trackintel/io/file.py
@@ -573,4 +573,4 @@ def write_tours_csv(tours, filename, *args, **kwargs):
     --------
     >>> tours.as_tours.to_csv("export_tours.csv")
     """
-    tours.to_csv(filename, index=True, *args, **kwargs)
+    pd.DataFrame.to_csv(tours, filename, index=True, *args, **kwargs)
diff --git a/trackintel/io/postgis.py b/trackintel/io/postgis.py
index ff338b2..2a59715 100644
--- a/trackintel/io/postgis.py
+++ b/trackintel/io/postgis.py
@@ -563,9 +563,6 @@ def read_trips_postgis(
 def write_trips_postgis(
     trips, name, con, schema=None, if_exists="fail", index=True, index_label=None, chunksize=None, dtype=None
 ):
-    if "trips" in trips.columns:
-        dtype = dtype or {}
-        dtype.setdefault("trips", JSON)
     if isinstance(trips, gpd.GeoDataFrame):
         gpd.GeoDataFrame.to_postgis(
             trips,
@@ -691,10 +688,12 @@ def read_tours_postgis(
 def write_tours_postgis(
     tours, name, con, schema=None, if_exists="fail", index=True, index_label=None, chunksize=None, dtype=None
 ):
-    write_trips_postgis(
-        tours,
-        name=name,
-        con=con,
+    if "trips" in tours.columns:
+        dtype = dtype or {}
+        dtype.setdefault("trips", JSON)
+    tours.to_sql(
+        name,
+        con,
         schema=schema,
         if_exists=if_exists,
         index=index,
diff --git a/trackintel/model/__init__.py b/trackintel/model/__init__.py
index 65a4f39..5f8ba98 100644
--- a/trackintel/model/__init__.py
+++ b/trackintel/model/__init__.py
@@ -3,4 +3,4 @@ from .locations import Locations
 from .triplegs import Triplegs
 from .staypoints import Staypoints
 from .trips import Trips
-from .tours import ToursAccessor
+from .tours import Tours
diff --git a/trackintel/model/tours.py b/trackintel/model/tours.py
index 2e89834..17553d3 100644
--- a/trackintel/model/tours.py
+++ b/trackintel/model/tours.py
@@ -1,10 +1,15 @@
 import pandas as pd
+
 import trackintel as ti
-from trackintel.model.util import _register_trackintel_accessor
+from trackintel.io.file import write_tours_csv
+from trackintel.io.postgis import write_tours_postgis
+from trackintel.model.util import _register_trackintel_accessor, TrackintelBase, TrackintelDataFrame, _copy_docstring
+
+_required_columns = ["user_id", "started_at", "finished_at"]
 
 
 @_register_trackintel_accessor("as_tours")
-class ToursAccessor(object):
+class Tours(TrackintelBase, TrackintelDataFrame):
     """A pandas accessor to treat DataFrames as collections of `Tours`.
 
     Requires at least the following columns:
@@ -27,18 +32,21 @@ class ToursAccessor(object):
     >>> df.as_tours.to_csv("filename.csv")
     """
 
-    required_columns = ["user_id", "started_at", "finished_at"]
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._validate(self)
 
-    def __init__(self, pandas_obj):
-        self._validate(pandas_obj)
-        self._obj = pandas_obj
+    # createte circular reference directly -> avoid second call of init via accessor
+    @property
+    def as_tours(self):
+        return self
 
     @staticmethod
     def _validate(obj):
-        if any([c not in obj.columns for c in ToursAccessor.required_columns]):
+        if any([c not in obj.columns for c in _required_columns]):
             raise AttributeError(
                 "To process a DataFrame as a collection of tours, it must have the properties"
-                f" {ToursAccessor.required_columns}, but it has {', '.join(obj.columns)}."
+                f" {_required_columns}, but it has {', '.join(obj.columns)}."
             )
 
         # check timestamp dtypes
@@ -49,14 +57,27 @@ class ToursAccessor(object):
             obj["finished_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of finished_at is {obj['finished_at'].dtype} but has to be datetime64 and timezone aware"
 
+    @staticmethod
+    def _check(obj):
+        """Check does the same as _validate but returns bool instead of potentially raising an error."""
+        if any([c not in obj.columns for c in _required_columns]):
+            return False
+        if not isinstance(obj["started_at"].dtype, pd.DatetimeTZDtype):
+            return False
+        if not isinstance(obj["finished_at"].dtype, pd.DatetimeTZDtype):
+            return False
+        return True
+
+    @_copy_docstring(write_tours_csv)
     def to_csv(self, filename, *args, **kwargs):
         """
         Store this collection of tours as a CSV file.
 
         See :func:`trackintel.io.file.write_tours_csv`.
         """
-        ti.io.file.write_tours_csv(self._obj, filename, *args, **kwargs)
+        ti.io.file.write_tours_csv(self, filename, *args, **kwargs)
 
+    @_copy_docstring(write_tours_postgis)
     def to_postgis(
         self, name, con, schema=None, if_exists="fail", index=True, index_label=None, chunksize=None, dtype=None
     ):
@@ -65,4 +86,4 @@ class ToursAccessor(object):
 
         See :func:`trackintel.io.postgis.write_tours_postgis`.
         """
-        ti.io.postgis.write_tours_postgis(self._obj, name, con, schema, if_exists, index, index_label, chunksize, dtype)
+        ti.io.postgis.write_tours_postgis(self, name, con, schema, if_exists, index, index_label, chunksize, dtype)


Test patch:
diff --git a/tests/model/test_tours.py b/tests/model/test_tours.py
new file mode 100644
index 0000000..281a244
--- /dev/null
+++ b/tests/model/test_tours.py
@@ -0,0 +1,44 @@
+import pytest
+import os
+
+import trackintel as ti
+from trackintel import Tours
+
+
+@pytest.fixture
+def test_tours():
+    """Read test data from file"""
+    tours_file = os.path.join("tests", "data", "tours.csv")
+    tours = ti.read_tours_csv(tours_file, sep=";", index_col="id")
+    return tours
+
+
+class TestTours:
+    """Tests for the Tours class."""
+
+    def test_missing_column(self, test_tours):
+        """Test if as_tours accessor check required columns"""
+        with pytest.raises(AttributeError, match="To process a DataFrame as a collection of tours"):
+            test_tours.drop(columns="user_id").as_tours
+
+    def test_accessor_recursive(self, test_tours):
+        tours = test_tours.as_tours
+        assert type(tours) is Tours
+        assert id(tours) == id(tours.as_tours)
+
+    def test_check_succeeding(self, test_tours):
+        """Test if check returns True on valid tours"""
+        assert Tours._check(test_tours)
+
+    def test_check_missing_columns(self, test_tours):
+        """Test if check returns False if column is missing"""
+        assert not Tours._check(test_tours.drop(columns="user_id"))
+
+    def test_check_no_tz(self, test_tours):
+        """Test if check returns False if datetime columns have no tz"""
+        tmp = test_tours["started_at"]
+        test_tours["started_at"] = test_tours["started_at"].dt.tz_localize(None)
+        assert not Tours._check(test_tours)
+        test_tours["started_at"] = tmp
+        test_tours["finished_at"] = test_tours["finished_at"].dt.tz_localize(None)
+        assert not Tours._check(test_tours)