diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index c9350224..a3c508a6 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12-dev'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3.2.0 @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3.2.0 @@ -85,7 +85,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9'] + pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - uses: actions/checkout@v3.2.0 - name: Set up Python ${{ matrix.pypy-version }} diff --git a/.gitignore b/.gitignore index 45d354fa..7a8a7bd2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,13 @@ setup.cfg # Installer logs pip-log.txt -# Unit test / coverage reports +# Unit/static test / coverage reports .coverage .tox +.pytest_cache/ nosetests.xml +.ruff_cache/ +monkeytype.sqlite3 # Translations *.mo @@ -51,6 +54,8 @@ venv .mypy_cache/ .pyre/ .watchmanconfig +.pytype/ + # misc .dccache diff --git a/fastkml/enums.py b/fastkml/enums.py index 71502458..8c1ab589 100644 --- a/fastkml/enums.py +++ b/fastkml/enums.py @@ -17,6 +17,7 @@ from enum import unique +@unique class Verbosity(Enum): """Enum to represent the different verbosity levels.""" diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 3e252f6f..35966899 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -931,24 +931,6 @@ def __init__( geometry=geometry, ) - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> geo.LinearRing: - coords = cls._get_coordinates(ns=ns, element=element, strict=strict) - try: - return cast(geo.LinearRing, geo.LinearRing.from_coordinates(coords)) - except (IndexError, TypeError) as e: - error = config.etree.tostring( # type: ignore[attr-defined] - element, - encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Invalid coordinates in {error}") from e - def etree_element( self, precision: Optional[int] = None, @@ -986,13 +968,7 @@ def etree_element( return element @classmethod - def _get_polygon_kwargs( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Dict[str, Any]: + def _get_geometry(cls, *, ns: str, element: Element, strict: bool) -> geo.Polygon: outer_boundary = element.find(f"{ns}outerBoundaryIs") if outer_boundary is None: error = config.etree.tostring( # type: ignore[attr-defined] @@ -1007,7 +983,7 @@ def _get_polygon_kwargs( encoding="UTF-8", ).decode("UTF-8") raise KMLParseError(f"Missing LinearRing in {error}") - exterior = cls._get_geometry(ns=ns, element=outer_ring, strict=strict) + exterior = LinearRing._get_geometry(ns=ns, element=outer_ring, strict=strict) interiors = [] for inner_boundary in element.findall(f"{ns}innerBoundaryIs"): inner_ring = inner_boundary.find(f"{ns}LinearRing") @@ -1018,9 +994,19 @@ def _get_polygon_kwargs( ).decode("UTF-8") raise KMLParseError(f"Missing LinearRing in {error}") interiors.append( - cls._get_geometry(ns=ns, element=inner_ring, strict=strict) + LinearRing._get_geometry(ns=ns, element=inner_ring, strict=strict) ) - return {"geometry": geo.Polygon.from_linear_rings(exterior, *interiors)} + return geo.Polygon.from_linear_rings(exterior, *interiors) + + @classmethod + def _get_polygon_kwargs( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + return {"geometry": cls._get_geometry(ns=ns, element=element, strict=strict)} @classmethod def _get_kwargs( @@ -1035,12 +1021,43 @@ def _get_kwargs( return kwargs +def create_multigeometry( + geometries: Sequence[AnyGeometryType], +) -> Optional[MultiGeometryType]: + """Create a MultiGeometry from a sequence of geometries. + + Args: + geometries: Sequence of geometries. + + Returns: + MultiGeometry + + """ + geom_types = {geom.geom_type for geom in geometries} + if not geom_types: + return None + if len(geom_types) == 1: + geom_type = geom_types.pop() + map_to_geometries = { + geo.Point.__name__: geo.MultiPoint.from_points, + geo.LineString.__name__: geo.MultiLineString.from_linestrings, + geo.Polygon.__name__: geo.MultiPolygon.from_polygons, + } + for geometry_name, constructor in map_to_geometries.items(): + if geom_type == geometry_name: + return constructor( # type: ignore[operator, no-any-return] + *geometries, + ) + + return geo.GeometryCollection(geometries) + + class MultiGeometry(_Geometry): map_to_kml = { geo.Point: Point, geo.LineString: LineString, - geo.LinearRing: LinearRing, geo.Polygon: Polygon, + geo.LinearRing: LinearRing, } multi_geometries = ( geo.MultiPoint, @@ -1094,6 +1111,19 @@ def etree_element( ) return element + @classmethod + def _get_geometry( + cls, *, ns: str, element: Element, strict: bool + ) -> Optional[MultiGeometryType]: + geometries = [] + allowed_geometries = (cls,) + tuple(cls.map_to_kml.values()) + for g in allowed_geometries: + for e in element.findall(f"{ns}{g.__name__}"): + geometry = g._get_geometry(ns=ns, element=e, strict=strict) + if geometry is not None: + geometries.append(geometry) + return create_multigeometry(geometries) + @classmethod def _get_multigeometry_kwargs( cls, @@ -1102,16 +1132,7 @@ def _get_multigeometry_kwargs( element: Element, strict: bool, ) -> Dict[str, Any]: - geometries = [] - allowed_geometries = (cls,) + tuple(cls.map_to_kml.values()) - for g in allowed_geometries: - for e in element.findall(f"{ns}{g.__name__}"): - geometry = g._get_geometry( # type: ignore[attr-defined] - ns=ns, element=e, strict=strict - ) - if geometry is not None: - geometries.append(geometry) - return {"geometry": geo.GeometryCollection(geometries)} + return {"geometry": cls._get_geometry(ns=ns, element=element, strict=strict)} @classmethod def _get_kwargs( diff --git a/pyproject.toml b/pyproject.toml index 4ec400ee..c57a1b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,4 @@ ignore_errors = true [tool.ruff] [tool.ruff.extend-per-file-ignores] "tests/oldunit_test.py" = ["E501"] +"setup.py" = ["E501"] diff --git a/requirements.txt b/requirements.txt index ad28f0e3..c7b6a580 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # Base package requirements -pygeoif>=1.0.0 +pygeoif>=1.1.0 python-dateutil +typing_extensions diff --git a/setup.py b/setup.py index c33d5194..63cb1302 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ def run_tests(self) -> None: "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", # "Development Status :: 5 - Production/Stable", @@ -58,7 +59,7 @@ def run_tests(self) -> None: python_requires=">=3.7", install_requires=[ # -*- Extra requirements: -*- - "pygeoif>=1.0.0", + "pygeoif>=1.1.0", "python-dateutil", "setuptools", "typing_extensions", diff --git a/tests/geometries/multigeometry_test.py b/tests/geometries/multigeometry_test.py index 2f37cc96..02046bef 100644 --- a/tests/geometries/multigeometry_test.py +++ b/tests/geometries/multigeometry_test.py @@ -46,6 +46,17 @@ def test_2_points(self): assert "MultiGeometry>" in mg.to_string() assert "Point>" in mg.to_string() + def test_2_points_read(self) -> None: + xml = ( + "00" + "1.000000,2.000000" + "3.000000,4.000000" + ) + + mg = MultiGeometry.class_from_string(xml, ns="") + + assert mg.geometry == geo.MultiPoint([(1, 2), (3, 4)]) + class TestMultiLineStringStdLibrary(StdLibrary): def test_1_linestring(self): @@ -69,6 +80,19 @@ def test_2_linestrings(self): assert "MultiGeometry>" in mg.to_string() assert "LineString>" in mg.to_string() + def test_2_linestrings_read(self) -> None: + xml = ( + "00" + "1.000000,2.000000 3.000000,4.000000" + "" + "5.000000,6.000000 7.000000,8.000000" + "" + ) + + mg = MultiGeometry.class_from_string(xml, ns="") + + assert mg.geometry == geo.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]]) + class TestMultiPolygonStdLibrary(StdLibrary): def test_1_polygon(self): @@ -142,6 +166,35 @@ def test_2_polygons(self): assert "outerBoundaryIs>" in mg.to_string() assert "innerBoundaryIs>" in mg.to_string() + def test_2_polygons_read(self) -> None: + xml = ( + "00" + "" + "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 " + "1.000000,0.000000 0.000000,0.000000" + "" + "" + "0.100000,0.100000 0.100000,0.200000 0.200000,0.200000 " + "0.200000,0.100000 0.100000,0.100000" + "" + "" + "0.000000,0.000000 0.000000,2.000000 1.000000,1.000000 " + "1.000000,0.000000 0.000000,0.000000" + "" + ) + + mg = MultiGeometry.class_from_string(xml, ns="") + + assert mg.geometry == geo.MultiPolygon( + [ + ( + ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)), + [((0.1, 0.1), (0.1, 0.2), (0.2, 0.2), (0.2, 0.1))], + ), + (((0.0, 0.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0)),), + ], + ) + class TestGeometryCollectionStdLibrary(StdLibrary): """Test heterogeneous geometry collections.""" @@ -202,6 +255,96 @@ def test_multi_geometries(self): assert "Polygon>" in mg.to_string() assert "MultiGeometry>" in mg.to_string() + def test_multi_geometries_read(self) -> None: + xml = ( + "00" + "1.000000,2.000000" + "1.000000,2.000000 2.000000,0.000000" + "0.000000,0.000000 0.000000,1.000000 " + "1.000000,1.000000 1.000000,0.000000 0.000000,0.000000" + "" + "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 " + "1.000000,0.000000 0.000000,0.000000" + "" + "0.100000,0.100000 0.100000,0.900000 0.900000,0.900000 " + "0.900000,0.100000 0.100000,0.100000" + "" + "" + "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 " + "1.000000,0.000000 0.000000,0.000000" + "" + "0.100000,0.100000 0.100000,0.200000 0.200000,0.200000 " + "0.200000,0.100000 0.100000,0.100000" + "" + "" + "0.000000,0.000000 0.000000,2.000000 1.000000,1.000000 " + "1.000000,0.000000 0.000000,0.000000" + "" + "1.000000,2.000000 3.000000,4.000000" + "5.000000,6.000000 7.000000,8.000000" + "" + ) + + mg = MultiGeometry.class_from_string(xml, ns="") + + assert mg.geometry == geo.GeometryCollection( + ( + geo.GeometryCollection( + ( + geo.Point(1.0, 2.0), + geo.LineString(((1.0, 2.0), (2.0, 0.0))), + geo.Polygon( + ( + (0.0, 0.0), + (0.0, 1.0), + (1.0, 1.0), + (1.0, 0.0), + (0.0, 0.0), + ), + ( + ( + (0.1, 0.1), + (0.1, 0.9), + (0.9, 0.9), + (0.9, 0.1), + (0.1, 0.1), + ), + ), + ), + geo.LinearRing( + ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)) + ), + ) + ), + geo.MultiPolygon( + ( + ( + ( + (0.0, 0.0), + (0.0, 1.0), + (1.0, 1.0), + (1.0, 0.0), + (0.0, 0.0), + ), + ( + ( + (0.1, 0.1), + (0.1, 0.2), + (0.2, 0.2), + (0.2, 0.1), + (0.1, 0.1), + ), + ), + ), + (((0.0, 0.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)),), + ) + ), + geo.MultiLineString( + (((1.0, 2.0), (3.0, 4.0)), ((5.0, 6.0), (7.0, 8.0))) + ), + ) + ) + class TestMultiPointLxml(Lxml, TestMultiPointStdLibrary): """Test with lxml."""