From fb4133a9459fb928f375d437b99f5bc77c4b451c Mon Sep 17 00:00:00 2001 From: snowman2 Date: Sat, 14 Aug 2021 19:39:19 -0500 Subject: [PATCH] REF: Make CRS methods inheritable --- docs/history.rst | 2 + pyproj/_crs.pyx | 14 +- pyproj/crs/crs.py | 259 +++++++++++++++++++++++++++++++------ test/crs/test_crs.py | 32 +++++ test/crs/test_crs_maker.py | 120 +++++++++++++++++ 5 files changed, 386 insertions(+), 41 deletions(-) diff --git a/docs/history.rst b/docs/history.rst index 7af56c4bf..704d2a88d 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -4,6 +4,8 @@ Change Log Latest ------ - REF: Handle deprecation of proj_context_set_autoclose_database (issue #866) +- REF: Make CRS methods inheritable (issue #847) +- ENH: Added :attr:`pyproj.crs.CRS.is_derived` (pull #902) - DOC: Improve FAQ text about CRS formats (issue #789) - BUG: Add PyPy cython array implementation (issue #854) - BUG: Fix spelling for diff --git a/pyproj/_crs.pyx b/pyproj/_crs.pyx index 439c1c44a..c2fb51b93 100644 --- a/pyproj/_crs.pyx +++ b/pyproj/_crs.pyx @@ -2490,7 +2490,7 @@ cdef class _CRS(Base): ) if not ( - self.is_bound or proj_is_derived_crs(self.context, self.projobj) + self.is_bound or self.is_derived ): self._coordinate_operation = False return None @@ -2971,6 +2971,18 @@ cdef class _CRS(Base): return self.source_crs.is_geocentric return self._type == PJ_TYPE_GEOCENTRIC_CRS + @property + def is_derived(self): + """ + .. versionadded:: 3.2.0 + + Returns + ------- + bool: + True if CRS is a Derived CRS. + """ + return proj_is_derived_crs(self.context, self.projobj) == 1 + def _equals(self, _CRS other, bint ignore_axis_order): if ignore_axis_order: # Only to be used with DerivedCRS/ProjectedCRS/GeographicCRS diff --git a/pyproj/crs/crs.py b/pyproj/crs/crs.py index d2313f5d4..ab48b4af4 100644 --- a/pyproj/crs/crs.py +++ b/pyproj/crs/crs.py @@ -6,7 +6,8 @@ import re import threading import warnings -from typing import Any, Callable, Dict, List, Optional, Union +from abc import abstractmethod +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from pyproj._crs import ( # noqa _CRS, @@ -321,8 +322,8 @@ def _crs(self): self._local.crs = _CRS(self.srs) return self._local.crs - @staticmethod - def from_authority(auth_name: str, code: Union[str, int]) -> "CRS": + @classmethod + def from_authority(cls, auth_name: str, code: Union[str, int]) -> "CRS": """ .. versionadded:: 2.2.0 @@ -339,10 +340,10 @@ def from_authority(auth_name: str, code: Union[str, int]) -> "CRS": ------- CRS """ - return CRS(_prepare_from_authority(auth_name, code)) + return cls.from_user_input(_prepare_from_authority(auth_name, code)) - @staticmethod - def from_epsg(code: Union[str, int]) -> "CRS": + @classmethod + def from_epsg(cls, code: Union[str, int]) -> "CRS": """Make a CRS from an EPSG code Parameters @@ -354,10 +355,10 @@ def from_epsg(code: Union[str, int]) -> "CRS": ------- CRS """ - return CRS(_prepare_from_epsg(code)) + return cls.from_user_input(_prepare_from_epsg(code)) - @staticmethod - def from_proj4(in_proj_string: str) -> "CRS": + @classmethod + def from_proj4(cls, in_proj_string: str) -> "CRS": """ .. versionadded:: 2.2.0 @@ -374,10 +375,10 @@ def from_proj4(in_proj_string: str) -> "CRS": """ if not is_proj(in_proj_string): raise CRSError(f"Invalid PROJ string: {in_proj_string}") - return CRS(_prepare_from_string(in_proj_string)) + return cls.from_user_input(_prepare_from_string(in_proj_string)) - @staticmethod - def from_wkt(in_wkt_string: str) -> "CRS": + @classmethod + def from_wkt(cls, in_wkt_string: str) -> "CRS": """ .. versionadded:: 2.2.0 @@ -394,10 +395,10 @@ def from_wkt(in_wkt_string: str) -> "CRS": """ if not is_wkt(in_wkt_string): raise CRSError(f"Invalid WKT string: {in_wkt_string}") - return CRS(_prepare_from_string(in_wkt_string)) + return cls.from_user_input(_prepare_from_string(in_wkt_string)) - @staticmethod - def from_string(in_crs_string: str) -> "CRS": + @classmethod + def from_string(cls, in_crs_string: str) -> "CRS": """ Make a CRS from: @@ -416,7 +417,7 @@ def from_string(in_crs_string: str) -> "CRS": ------- CRS """ - return CRS(_prepare_from_string(in_crs_string)) + return cls.from_user_input(_prepare_from_string(in_crs_string)) def to_string(self) -> str: """ @@ -437,8 +438,8 @@ def to_string(self) -> str: return ":".join(auth_info) return self.srs - @staticmethod - def from_user_input(value: Any, **kwargs) -> "CRS": + @classmethod + def from_user_input(cls, value: Any, **kwargs) -> "CRS": """ Initialize a CRS class instance with: - PROJ string @@ -461,9 +462,9 @@ def from_user_input(value: Any, **kwargs) -> "CRS": ------- CRS """ - if isinstance(value, CRS): + if isinstance(value, cls): return value - return CRS(value, **kwargs) + return cls(value, **kwargs) def get_geod(self) -> Optional[Geod]: """ @@ -480,8 +481,8 @@ def get_geod(self) -> Optional[Geod]: b=self.ellipsoid.semi_minor_metre, ) - @staticmethod - def from_dict(proj_dict: dict) -> "CRS": + @classmethod + def from_dict(cls, proj_dict: dict) -> "CRS": """ .. versionadded:: 2.2.0 @@ -496,10 +497,10 @@ def from_dict(proj_dict: dict) -> "CRS": ------- CRS """ - return CRS(_prepare_from_dict(proj_dict)) + return cls.from_user_input(_prepare_from_dict(proj_dict)) - @staticmethod - def from_json(crs_json: str) -> "CRS": + @classmethod + def from_json(cls, crs_json: str) -> "CRS": """ .. versionadded:: 2.4.0 @@ -514,10 +515,10 @@ def from_json(crs_json: str) -> "CRS": ------- CRS """ - return CRS.from_json_dict(_load_proj_json(crs_json)) + return cls.from_user_input(_load_proj_json(crs_json)) - @staticmethod - def from_json_dict(crs_dict: dict) -> "CRS": + @classmethod + def from_json_dict(cls, crs_dict: dict) -> "CRS": """ .. versionadded:: 2.4.0 @@ -532,7 +533,7 @@ def from_json_dict(crs_dict: dict) -> "CRS": ------- CRS """ - return CRS(json.dumps(crs_dict)) + return cls.from_user_input(json.dumps(crs_dict)) def to_dict(self) -> dict: """ @@ -942,7 +943,11 @@ def geodetic_crs(self) -> Optional["CRS"]: The the geodeticCRS / geographicCRS from the CRS. """ - return None if self._crs.geodetic_crs is None else CRS(self._crs.geodetic_crs) + return ( + None + if self._crs.geodetic_crs is None + else self.__class__(self._crs.geodetic_crs) + ) @property def source_crs(self) -> Optional["CRS"]: @@ -954,7 +959,11 @@ def source_crs(self) -> Optional["CRS"]: ------- CRS """ - return None if self._crs.source_crs is None else CRS(self._crs.source_crs) + return ( + None + if self._crs.source_crs is None + else self.__class__(self._crs.source_crs) + ) @property def target_crs(self) -> Optional["CRS"]: @@ -967,7 +976,11 @@ def target_crs(self) -> Optional["CRS"]: The hub CRS of a BoundCRS or the target CRS of a CoordinateOperation. """ - return None if self._crs.target_crs is None else CRS(self._crs.target_crs) + return ( + None + if self._crs.target_crs is None + else self.__class__(self._crs.target_crs) + ) @property def sub_crs_list(self) -> List["CRS"]: @@ -978,7 +991,7 @@ def sub_crs_list(self) -> List["CRS"]: ------- List[CRS] """ - return [CRS(sub_crs) for sub_crs in self._crs.sub_crs_list] + return [self.__class__(sub_crs) for sub_crs in self._crs.sub_crs_list] @property def utm_zone(self) -> Optional[str]: @@ -1312,7 +1325,7 @@ def to_3d(self, name: Optional[str] = None) -> "CRS": ------- CRS """ - return CRS(self._crs.to_3d(name=name)) + return self.__class__(self._crs.to_3d(name=name)) @property def is_geographic(self) -> bool: @@ -1408,6 +1421,18 @@ def is_geocentric(self) -> bool: """ return self._crs.is_geocentric + @property + def is_derived(self): + """ + .. versionadded:: 3.2.0 + + Returns + ------- + bool: + True if CRS is a Derived CRS. + """ + return self._crs.is_derived + def __eq__(self, other: Any) -> bool: return self.equals(other) @@ -1482,13 +1507,148 @@ def __repr__(self) -> str: ) -class GeographicCRS(CRS): +class _MakerCRS(CRS): + """ + This class exists to handle the oddities to do with the + maker CRS classes having a different constructor than + the base CRS classes. + + .. versionadded:: 3.2.0 + + See: https://github.com/pyproj4/pyproj/issues/847 + """ + + @property + @abstractmethod + def _expected_types(self) -> Tuple[str, ...]: + """ + These are the type names of the CRS class + that are expected when using the from_* methods. + """ + raise NotImplementedError + + def _check_type(self): + """ + This validates that the type of the CRS is expected + when using the from_* methods. + """ + if self.type_name not in self._expected_types: + raise CRSError( + f"Invalid type {self.type_name}. Expected {self._expected_types}." + ) + + @classmethod + def from_user_input(cls, value: Any, **kwargs) -> "CRS": + """ + Initialize a CRS class instance with: + - PROJ string + - Dictionary of PROJ parameters + - PROJ keyword arguments for parameters + - JSON string with PROJ parameters + - CRS WKT string + - An authority string [i.e. 'epsg:4326'] + - An EPSG integer code [i.e. 4326] + - A tuple of ("auth_name": "auth_code") [i.e ('epsg', '4326')] + - An object with a `to_wkt` method. + - A :class:`pyproj.crs.CRS` class + + Parameters + ---------- + value : obj + A Python int, dict, or str. + + Returns + ------- + CRS + """ + if isinstance(value, cls): + return value + crs = cls.__new__(cls) + super(_MakerCRS, crs).__init__(value, **kwargs) + crs._check_type() + return crs + + @property + def geodetic_crs(self) -> Optional["CRS"]: + """ + .. versionadded:: 2.2.0 + + Returns + ------- + CRS: + The the geodeticCRS / geographicCRS from the CRS. + + """ + return None if self._crs.geodetic_crs is None else CRS(self._crs.geodetic_crs) + + @property + def source_crs(self) -> Optional["CRS"]: + """ + The the base CRS of a BoundCRS or a DerivedCRS/ProjectedCRS, + or the source CRS of a CoordinateOperation. + + Returns + ------- + CRS + """ + return None if self._crs.source_crs is None else CRS(self._crs.source_crs) + + @property + def target_crs(self) -> Optional["CRS"]: + """ + .. versionadded:: 2.2.0 + + Returns + ------- + CRS: + The hub CRS of a BoundCRS or the target CRS of a CoordinateOperation. + + """ + return None if self._crs.target_crs is None else CRS(self._crs.target_crs) + + @property + def sub_crs_list(self) -> List["CRS"]: + """ + If the CRS is a compound CRS, it will return a list of sub CRS objects. + + Returns + ------- + List[CRS] + """ + return [CRS(sub_crs) for sub_crs in self._crs.sub_crs_list] + + def to_3d(self, name: Optional[str] = None) -> "CRS": + """ + .. versionadded:: 3.1.0 + + Convert the current CRS to the 3D version if it makes sense. + + New vertical axis attributes: + - ellipsoidal height + - oriented upwards + - metre units + + Parameters + ---------- + name: str, optional + CRS name. Defaults to use the name of the original CRS. + + Returns + ------- + CRS + """ + return CRS(self._crs.to_3d(name=name)) + + +class GeographicCRS(_MakerCRS): """ .. versionadded:: 2.5.0 This class is for building a Geographic CRS """ + _expected_types = ("Geographic CRS", "Geographic 2D CRS", "Geographic 3D CRS") + def __init__( self, name: str = "undefined", @@ -1520,13 +1680,24 @@ def __init__( super().__init__(geographic_crs_json) -class DerivedGeographicCRS(CRS): +class DerivedGeographicCRS(_MakerCRS): """ .. versionadded:: 2.5.0 This class is for building a Derived Geographic CRS """ + _expected_types = ("Geographic CRS", "Geographic 2D CRS", "Geographic 3D CRS") + + def _check_type(self): + """ + This validates that the type of the CRS is expected + when using the from_* methods. + """ + super()._check_type() + if not self.is_derived: + raise CRSError("CRS is not a Derived Geographic CRS") + def __init__( self, base_crs: Any, @@ -1565,13 +1736,15 @@ def __init__( super().__init__(derived_geographic_crs_json) -class ProjectedCRS(CRS): +class ProjectedCRS(_MakerCRS): """ .. versionadded:: 2.5.0 This class is for building a Projected CRS. """ + _expected_types = ("Projected CRS",) + def __init__( self, conversion: Any, @@ -1612,7 +1785,7 @@ def __init__( super().__init__(proj_crs_json) -class VerticalCRS(CRS): +class VerticalCRS(_MakerCRS): """ .. versionadded:: 2.5.0 @@ -1622,6 +1795,8 @@ class VerticalCRS(CRS): """ + _expected_types = ("Vertical CRS",) + def __init__( self, name: str, @@ -1658,13 +1833,15 @@ def __init__( super().__init__(vert_crs_json) -class CompoundCRS(CRS): +class CompoundCRS(_MakerCRS): """ .. versionadded:: 2.5.0 This class is for building a Compound CRS. """ + _expected_types = ("Compound CRS",) + def __init__(self, name: str, components: List[Any]) -> None: """ Parameters @@ -1688,13 +1865,15 @@ def __init__(self, name: str, components: List[Any]) -> None: super().__init__(compound_crs_json) -class BoundCRS(CRS): +class BoundCRS(_MakerCRS): """ .. versionadded:: 2.5.0 This class is for building a Bound CRS. """ + _expected_types = ("Bound CRS",) + def __init__(self, source_crs: Any, target_crs: Any, transformation: Any) -> None: """ Parameters diff --git a/test/crs/test_crs.py b/test/crs/test_crs.py index 5f0893105..456b57141 100644 --- a/test/crs/test_crs.py +++ b/test/crs/test_crs.py @@ -1480,3 +1480,35 @@ def test_to_3d__name(): def test_crs__pickle(tmp_path): assert_can_pickle(CRS("epsg:4326"), tmp_path) + + +def test_is_derived(): + assert CRS( + "+proj=ob_tran +o_proj=longlat +o_lat_p=0 +o_lon_p=0 +lon_0=0" + ).is_derived + assert not CRS("+proj=latlon").is_derived + + +def test_inheritance__from_methods(): + class ChildCRS(CRS): + def new_method(self): + return 1 + + def assert_inheritance_valid(new_crs): + assert new_crs.new_method() == 1 + assert isinstance(new_crs, ChildCRS) + assert isinstance(new_crs.geodetic_crs, ChildCRS) + assert isinstance(new_crs.source_crs, (type(None), ChildCRS)) + assert isinstance(new_crs.target_crs, (type(None), ChildCRS)) + assert isinstance(new_crs.to_3d(), ChildCRS) + for sub_crs in new_crs.sub_crs_list: + assert isinstance(sub_crs, ChildCRS) + + assert_inheritance_valid(ChildCRS.from_epsg(4326)) + assert_inheritance_valid(ChildCRS.from_string("EPSG:2056")) + with pytest.warns(FutureWarning): + assert_inheritance_valid(ChildCRS.from_proj4("+init=epsg:4328 +towgs84=0,0,0")) + assert_inheritance_valid(ChildCRS.from_user_input("EPSG:4326+5773")) + assert_inheritance_valid(ChildCRS.from_json(CRS(4326).to_json())) + assert_inheritance_valid(ChildCRS.from_json_dict(CRS(4326).to_json_dict())) + assert_inheritance_valid(ChildCRS.from_wkt(CRS(4326).to_wkt())) diff --git a/test/crs/test_crs_maker.py b/test/crs/test_crs_maker.py index cb23517d0..34d5a7dd1 100644 --- a/test/crs/test_crs_maker.py +++ b/test/crs/test_crs_maker.py @@ -1,6 +1,7 @@ import pytest from pyproj.crs import ( + CRS, BoundCRS, CompoundCRS, DerivedGeographicCRS, @@ -19,9 +20,20 @@ from pyproj.crs.coordinate_system import Cartesian2DCS, Ellipsoidal3DCS, VerticalCS from pyproj.crs.datum import CustomDatum from pyproj.crs.enums import VerticalCSAxis +from pyproj.exceptions import CRSError from test.conftest import HAYFORD_ELLIPSOID_NAME, assert_can_pickle +def assert_maker_inheritance_valid(new_crs, class_type): + assert isinstance(new_crs, class_type) + assert isinstance(new_crs.geodetic_crs, (type(None), CRS)) + assert isinstance(new_crs.source_crs, (type(None), CRS)) + assert isinstance(new_crs.target_crs, (type(None), CRS)) + assert isinstance(new_crs.to_3d(), CRS) + for sub_crs in new_crs.sub_crs_list: + assert isinstance(sub_crs, CRS) + + def test_make_projected_crs(tmp_path): aeaop = AlbersEqualAreaConversion(0, 0) pc = ProjectedCRS(conversion=aeaop, name="Albers") @@ -31,6 +43,25 @@ def test_make_projected_crs(tmp_path): assert_can_pickle(pc, tmp_path) +def test_projected_crs__from_methods(): + assert_maker_inheritance_valid(ProjectedCRS.from_epsg(6933), ProjectedCRS) + assert_maker_inheritance_valid(ProjectedCRS.from_string("EPSG:6933"), ProjectedCRS) + assert_maker_inheritance_valid( + ProjectedCRS.from_proj4("+proj=aea +lat_1=1"), ProjectedCRS + ) + assert_maker_inheritance_valid( + ProjectedCRS.from_user_input(CRS("EPSG:6933")), ProjectedCRS + ) + assert_maker_inheritance_valid( + ProjectedCRS.from_json(CRS(6933).to_json()), ProjectedCRS + ) + assert_maker_inheritance_valid( + ProjectedCRS.from_json_dict(CRS(6933).to_json_dict()), ProjectedCRS + ) + with pytest.raises(CRSError, match="Invalid type"): + ProjectedCRS.from_epsg(4326) + + def test_make_geographic_crs(tmp_path): gc = GeographicCRS(name="WGS 84") assert gc.name == "WGS 84" @@ -39,6 +70,27 @@ def test_make_geographic_crs(tmp_path): assert_can_pickle(gc, tmp_path) +def test_geographic_crs__from_methods(): + assert_maker_inheritance_valid(GeographicCRS.from_epsg(4326), GeographicCRS) + assert_maker_inheritance_valid( + GeographicCRS.from_string("EPSG:4326"), GeographicCRS + ) + assert_maker_inheritance_valid( + GeographicCRS.from_proj4("+proj=latlon"), GeographicCRS + ) + assert_maker_inheritance_valid( + GeographicCRS.from_user_input(CRS("EPSG:4326")), GeographicCRS + ) + assert_maker_inheritance_valid( + GeographicCRS.from_json(CRS(4326).to_json()), GeographicCRS + ) + assert_maker_inheritance_valid( + GeographicCRS.from_json_dict(CRS(4326).to_json_dict()), GeographicCRS + ) + with pytest.raises(CRSError, match="Invalid type"): + GeographicCRS.from_epsg(6933) + + def test_make_geographic_3d_crs(): gcrs = GeographicCRS(ellipsoidal_cs=Ellipsoidal3DCS()) assert gcrs.type_name == "Geographic 3D CRS" @@ -51,9 +103,32 @@ def test_make_derived_geographic_crs(tmp_path): assert dgc.name == "undefined" assert dgc.type_name == "Geographic 2D CRS" assert dgc.coordinate_operation == conversion + assert dgc.is_derived assert_can_pickle(dgc, tmp_path) +def test_derived_geographic_crs__from_methods(): + crs_str = "+proj=ob_tran +o_proj=longlat +o_lat_p=0 +o_lon_p=0 +lon_0=0" + with pytest.raises(CRSError, match="CRS is not a Derived Geographic CRS"): + DerivedGeographicCRS.from_epsg(4326) + assert_maker_inheritance_valid( + DerivedGeographicCRS.from_string(crs_str), DerivedGeographicCRS + ) + assert_maker_inheritance_valid( + DerivedGeographicCRS.from_proj4(crs_str), DerivedGeographicCRS + ) + assert_maker_inheritance_valid( + DerivedGeographicCRS.from_user_input(CRS(crs_str)), DerivedGeographicCRS + ) + assert_maker_inheritance_valid( + DerivedGeographicCRS.from_json(CRS(crs_str).to_json()), DerivedGeographicCRS + ) + assert_maker_inheritance_valid( + DerivedGeographicCRS.from_json_dict(CRS(crs_str).to_json_dict()), + DerivedGeographicCRS, + ) + + def test_vertical_crs(tmp_path): vc = VerticalCRS( name="NAVD88 height", @@ -67,6 +142,22 @@ def test_vertical_crs(tmp_path): assert_can_pickle(vc, tmp_path) +def test_vertical_crs__from_methods(): + assert_maker_inheritance_valid(VerticalCRS.from_epsg(5703), VerticalCRS) + assert_maker_inheritance_valid(VerticalCRS.from_string("EPSG:5703"), VerticalCRS) + with pytest.raises(CRSError, match="Invalid type"): + VerticalCRS.from_proj4("+proj=latlon") + assert_maker_inheritance_valid( + VerticalCRS.from_user_input(CRS("EPSG:5703")), VerticalCRS + ) + assert_maker_inheritance_valid( + VerticalCRS.from_json(CRS(5703).to_json()), VerticalCRS + ) + assert_maker_inheritance_valid( + VerticalCRS.from_json_dict(CRS(5703).to_json_dict()), VerticalCRS + ) + + @pytest.mark.parametrize( "axis", [ @@ -118,6 +209,22 @@ def test_compund_crs(tmp_path): assert_can_pickle(compcrs, tmp_path) +def test_compund_crs__from_methods(): + crs = CRS("EPSG:4326+5773") + with pytest.raises(CRSError, match="Invalid type"): + CompoundCRS.from_epsg(4326) + assert_maker_inheritance_valid( + CompoundCRS.from_string("EPSG:4326+5773"), CompoundCRS + ) + with pytest.raises(CRSError, match="Invalid type"): + CompoundCRS.from_proj4("+proj=longlat +datum=WGS84 +vunits=m") + assert_maker_inheritance_valid(CompoundCRS.from_user_input(crs), CompoundCRS) + assert_maker_inheritance_valid(CompoundCRS.from_json(crs.to_json()), CompoundCRS) + assert_maker_inheritance_valid( + CompoundCRS.from_json_dict(crs.to_json_dict()), CompoundCRS + ) + + def test_bound_crs(tmp_path): proj_crs = ProjectedCRS(conversion=UTMConversion(12)) bound_crs = BoundCRS( @@ -166,3 +273,16 @@ def test_bound_crs__example(): "x_0": 2520000, "y_0": 0, } + + +def test_bound_crs_crs__from_methods(): + crs_str = "+proj=latlon +towgs84=0,0,0" + with pytest.raises(CRSError, match="Invalid type"): + BoundCRS.from_epsg(4326) + assert_maker_inheritance_valid(BoundCRS.from_string(crs_str), BoundCRS) + assert_maker_inheritance_valid(BoundCRS.from_proj4(crs_str), BoundCRS) + assert_maker_inheritance_valid(BoundCRS.from_user_input(CRS(crs_str)), BoundCRS) + assert_maker_inheritance_valid(BoundCRS.from_json(CRS(crs_str).to_json()), BoundCRS) + assert_maker_inheritance_valid( + BoundCRS.from_json_dict(CRS(crs_str).to_json_dict()), BoundCRS + )