diff --git a/nptyping/__init__.py b/nptyping/__init__.py index 5fd5b2c..57f2ca5 100644 --- a/nptyping/__init__.py +++ b/nptyping/__init__.py @@ -32,6 +32,7 @@ from nptyping.ndarray import NDArray from nptyping.package_info import __version__ from nptyping.pandas_.dataframe import DataFrame +from nptyping.geopandas_.geodataframe import GeoDataFrame from nptyping.recarray import RecArray from nptyping.shape import Shape from nptyping.shape_expression import ( @@ -185,4 +186,5 @@ "Unicode", "Str0", "DataFrame", + "GeoDataFrame", ] diff --git a/nptyping/geopandas_/__init__.py b/nptyping/geopandas_/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nptyping/geopandas_/geodataframe.py b/nptyping/geopandas_/geodataframe.py new file mode 100644 index 0000000..ed451ad --- /dev/null +++ b/nptyping/geopandas_/geodataframe.py @@ -0,0 +1,116 @@ +""" +MIT License + +Copyright (c) 2023 Ramon Hagenaars + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import inspect +from abc import ABC +from typing import Any, Tuple + +import numpy as np + +from nptyping import InvalidArgumentsError +from nptyping.base_meta_classes import ( + FinalMeta, + ImmutableMeta, + InconstructableMeta, + MaybeCheckableMeta, + PrintableMeta, + SubscriptableMeta, +) +from nptyping.error import DependencyError +from nptyping.nptyping_type import NPTypingType +from nptyping.geopandas_.typing_ import dtype_per_name +from nptyping.pandas_.dataframe import DataFrameMeta +from nptyping.structure import Structure +from nptyping.structure_expression import check_structure + +try: + import geopandas as gpd +except ImportError: # pragma: no cover + gpd = None # type: ignore[misc, assignment] + + +class GeoDataFrameMeta( + DataFrameMeta, + SubscriptableMeta, + InconstructableMeta, + ImmutableMeta, + FinalMeta, + MaybeCheckableMeta, + PrintableMeta, + implementation="GeoDataFrame", +): + """ + Metaclass that is coupled to nptyping.GeoDataFrame. It contains all actual logic + such as instance checking. + """ + + def __instancecheck__( # pylint: disable=bad-mcs-method-argument + self, instance: Any + ) -> bool: + structure = self.__args__[0] + + if gpd is None: + raise DependencyError( # pragma: no cover + "GeoPandas needs to be installed for instance checking." + ) + + if not isinstance(instance, gpd.GeoDataFrame): + return False + + if structure is Any: + return True + + structured_dtype = np.dtype( + [(column, dtype.str) for column, dtype in instance.dtypes.items()] + ) + return check_structure(structured_dtype, structure, dtype_per_name) + + @property + def __module__(cls) -> str: + return cls._get_module(inspect.stack(), "nptyping.geopandas_.geodataframe") + + +class GeoDataFrame(NPTypingType, ABC, metaclass=GeoDataFrameMeta): + """ + An nptyping equivalent of geopandas GeoDataFrame. + + ## No arguments means a GeoDataFrame of any structure. + >>> GeoDataFrame + GeoDataFrame[Any] + + ## You can use Structure Expression. + >>> from nptyping import GeoDataFrame, Structure + >>> GeoDataFrame[Structure["x: Int, y: Int"]] + GeoDataFrame[Structure['[x, y]: Int']] + + ## Instance checking can be done and the structure is also checked. + >>> import geopandas as gpd + >>> gdf = gpd.GeoDataFrame({'x': [1, 2, 3], 'y': [4., 5., 6.]}) + >>> isinstance(gdf, GeoDataFrame[Structure['x: Int, y: Float']]) + True + >>> isinstance(gdf, GeoDataFrame[Structure['x: Float, y: Int']]) + False + + """ + + __args__ = (Any,) diff --git a/nptyping/geopandas_/geodataframe.pyi b/nptyping/geopandas_/geodataframe.pyi new file mode 100644 index 0000000..9be16bf --- /dev/null +++ b/nptyping/geopandas_/geodataframe.pyi @@ -0,0 +1,27 @@ +""" +MIT License + +Copyright (c) 2023 Ramon Hagenaars + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import geopandas as gpd + +GeoDataFrame = gpd.GeoDataFrame diff --git a/nptyping/geopandas_/typing_.py b/nptyping/geopandas_/typing_.py new file mode 100644 index 0000000..e14033c --- /dev/null +++ b/nptyping/geopandas_/typing_.py @@ -0,0 +1,34 @@ + +""" +MIT License + +Copyright (c) 2023 Ramon Hagenaars + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from nptyping.typing_ import Object +from nptyping.typing_ import dtype_per_name as dtype_per_name_default + +dtype_per_name = { + **dtype_per_name_default, # type: ignore[arg-type] + # Override the `String` and `Str` to point to `Object`. Pandas uses Object + # for string types in Dataframes and Series. + "String": Object, + "Str": Object, +} diff --git a/tests/geopandas_/__init__.py b/tests/geopandas_/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/geopandas_/test_dataframe.py b/tests/geopandas_/test_dataframe.py new file mode 100644 index 0000000..9d15851 --- /dev/null +++ b/tests/geopandas_/test_dataframe.py @@ -0,0 +1,75 @@ +from typing import Any +from unittest import TestCase + +import geopandas as gpd +from nptyping import InvalidArgumentsError, GeoDataFrame +from nptyping import Structure as S +from nptyping.typing_ import Literal as L +from shapely.geometry import Point + + +class DataframeTest(TestCase): + def test_isinstance_success(self): + gdf = gpd.GeoDataFrame( + { + "x": [1, 2, 3], + "y": [2.0, 3.0, 4.0], + "z": [Point(0, 0), Point(1, 1), Point(2, 2)], + } + ) + + self.assertIsInstance(gdf, GeoDataFrame[S["x: Int, y: Float, z: Obj"]]) + + def test_isinstance_any(self): + gdf = gpd.GeoDataFrame( + { + "x": [1, 2, 3], + "y": [2.0, 3.0, 4.0], + "z": ["a", "b", "c"], + } + ) + + self.assertIsInstance(gdf, GeoDataFrame[Any]) + + def test_isinstance_fail(self): + gdf = gpd.GeoDataFrame( + { + "x": [1, 2, 3], + "y": [2.0, 3.0, 4.0], + "z": ["a", "b", "c"], + } + ) + + self.assertNotIsInstance(gdf, GeoDataFrame[S["x: Float, y: Int, z: Obj"]]) + + def test_string_is_aliased(self): + gdf = gpd.GeoDataFrame( + { + "x": ["a", "b", "c"], + "y": ["d", "e", "f"], + } + ) + + self.assertIsInstance(gdf, GeoDataFrame[S["x: Str, y: String"]]) + + def test_isinstance_fail_with_random_type(self): + self.assertNotIsInstance(42, GeoDataFrame[S["x: Float, y: Int, z: Obj"]]) + + def test_literal_is_allowed(self): + GeoDataFrame[L["x: Int, y: Int"]] + + def test_string_is_not_allowed(self): + with self.assertRaises(InvalidArgumentsError): + GeoDataFrame["x: Int, y: Int"] + + def test_repr(self): + self.assertEqual( + "GeoDataFrame[Structure['[x, y]: Int']]", repr(GeoDataFrame[S["x: Int, y: Int"]]) + ) + self.assertEqual("GeoDataFrame[Any]", repr(GeoDataFrame)) + self.assertEqual("GeoDataFrame[Any]", repr(GeoDataFrame[Any])) + + def test_str(self): + self.assertEqual("GeoDataFrame[[x, y]: Int]", str(GeoDataFrame[S["x: Int, y: Int"]])) + self.assertEqual("GeoDataFrame[Any]", str(GeoDataFrame)) + self.assertEqual("GeoDataFrame[Any]", str(GeoDataFrame[Any]))