diff --git a/.gitignore b/.gitignore index cc9c4328..97717c04 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ html/ venv/ __pycache__/ *.stderr* +docs/_* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 996a8286..258362e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,21 +31,16 @@ repos: rev: v0.3.1 hooks: - id: absolufy-imports - - repo: https://github.com/hakancelikdev/unimport - rev: 1.0.0 - hooks: - - id: unimport - args: [--remove, --include-star-import, --ignore-init, --gitignore] - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort - repo: https://github.com/psf/black - rev: 23.10.0 + rev: 23.11.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.1.1' + rev: 'v0.1.6' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -73,7 +68,7 @@ repos: - flake8-typing-imports - flake8-use-fstring - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.7.1 hooks: - id: mypy # - repo: https://github.com/Lucas-C/pre-commit-hooks-markup @@ -89,7 +84,7 @@ repos: hooks: - id: pyprojectsort - repo: https://github.com/python-jsonschema/check-jsonschema - rev: "0.27.0" + rev: "0.27.2" hooks: - id: check-github-workflows - id: check-github-actions diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..0a3b1864 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +--- +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt +... diff --git a/README.rst b/README.rst index 551ed028..64873415 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,12 @@ Introduction ============ -PyGeoIf provides a `GeoJSON-like protocol `_ for geo-spatial (GIS) vector data. +.. inclusion-marker-do-not-remove -Other Python programs and packages that you may have heard of already +PyGeoIf provides a `GeoJSON-like protocol `_ +for geo-spatial (GIS) vector data. + +Other Python programs and packages that you may have heard of that implement this protocol: * `ArcPy `_ @@ -11,6 +14,9 @@ implement this protocol: * `PySAL `_ * `Shapely `_ * `pyshp `_ +* `GeoPandas `_ +* `Karta `_ +* `mapnik `_ When you want to write your own geospatial library with support for this protocol you may use pygeoif as a starting point and build @@ -32,18 +38,27 @@ It was written to provide clean and python only geometries for fastkml_ .. image:: https://github.com/cleder/pygeoif/actions/workflows/run-all-tests.yml/badge.svg?branch=main :target: https://github.com/cleder/pygeoif/actions/workflows/run-all-tests.yml + :alt: GitHub Actions + +.. image:: https://readthedocs.org/projects/pygeoif/badge/?version=latest + :target: https://pygeoif.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status .. image:: https://codecov.io/gh/cleder/pygeoif/branch/main/graph/badge.svg?token=2EfiwBXs9X :target: https://codecov.io/gh/cleder/pygeoif + :alt: Codecov .. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black + :target: https://github.com/psf/ + :alt: Black -.. image:: https://img.shields.io/badge/type-checker-mypy-blue +.. image:: https://img.shields.io/badge/type%20checker-mypy-blue :target: http://mypy-lang.org/ + :alt: Mypy .. image:: https://www.openhub.net/p/pygeoif/widgets/project_thin_badge.gif :target: https://www.openhub.net/p/pygeoif/ + :alt: Openhub .. image:: https://www.codefactor.io/repository/github/cleder/pygeoif/badge/main :target: https://www.codefactor.io/repository/github/cleder/pygeoif/overview/main @@ -53,6 +68,22 @@ It was written to provide clean and python only geometries for fastkml_ :target: https://github.com/pre-commit/pre-commit :alt: pre-commit +.. image:: https://img.shields.io/pypi/pyversions/pygeoif.svg + :target: https://pypi.python.org/pypi/pygeoif/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/implementation/pygeoif.svg + :target: https://pypi.python.org/pypi/pygeoif/ + :alt: Supported Python implementations + +Installation +------------ + +You can install PyGeoIf from pypi using pip:: + + pip install pygeoif + + Example ======== @@ -302,9 +333,8 @@ Create a geometry from its WKT representation signed_area ------------ -Return the signed area enclosed by a ring using the linear time -algorithm at http://www.cgafaq.info/wiki/Polygon_Area. A value >= 0 -indicates a counter-clockwise oriented ring. +Return the signed area enclosed by a ring. +A value >= 0 indicates a counter-clockwise oriented ring. orient @@ -330,28 +360,13 @@ Return the ``__geo_interface__`` dictionary. Development =========== -Installation ------------- - -You can install PyGeoIf from pypi using pip:: - - pip install pygeoif - -Testing -------- - -Install the requirements with ``pip install -r test-requirements.txt`` -and run the unit and static tests with:: +Clone this repository, create a virtualenv with Python 3.8 or later with +``python3 -m venv .venv`` and activate it with ``source .venv/bin/activate``. - pytest pygeoif - pytest --doctest-glob="README.rst" - yesqa pygeoif/*.py - black pygeoif - flake8 pygeoif - mypy pygeoif +Then install the requirements with ``pip install -e ".[dev]"``. pre-commit -~~~~~~~~~~~ +---------- Install the ``pre-commit`` hook with:: @@ -362,6 +377,19 @@ and check the code with:: pre-commit run --all-files +Testing +------- + +Run the unit and static tests with:: + + pytest tests + pytest --doctest-glob="README.rst" + black pygeoif + ruff pygeoif + flake8 pygeoif + mypy pygeoif + + Acknowledgments ================ diff --git a/docs/CONTRIBUTORS.txt b/docs/CONTRIBUTORS.rst similarity index 100% rename from docs/CONTRIBUTORS.txt rename to docs/CONTRIBUTORS.rst diff --git a/docs/HISTORY.txt b/docs/HISTORY.rst similarity index 59% rename from docs/HISTORY.txt rename to docs/HISTORY.rst index 87776683..6e5c9718 100644 --- a/docs/HISTORY.txt +++ b/docs/HISTORY.rst @@ -1,10 +1,17 @@ Changelog ========= -1.1.1 (unreleased) +1.2.0 (2023/11/27) ------------------ - - Use pyproject.toml instead of setup.py + - remove Python 3.7 support + - Geometries are now immutable (but not hashable) + - add ``force_2d`` and ``force_3d`` factories [Alex Svetkin] + +1.1.1 (2023/10/27) +------------------ + + - Use ``pyproject.toml``, remove ``setup.py`` and ``MANIFEST.in`` 1.1 (2023/10/13) ----------------- @@ -22,8 +29,8 @@ Changelog - changes to keep functionality and interface close to ``shapely`` - remove support for python 2 - minimum python version is 3.7 -- rename as_shape to shape -- add box factory +- rename ``as_shape`` to ``shape`` +- add ``box`` factory - format with black - reconstruct objects from their representation - Parse WKT that is not in upper case. @@ -37,7 +44,7 @@ Changelog ----------------- - fix broken multipolygon [mindflayer] -- add "bbox" to `__geo_interface__` output [jzmiller1] +- add "bbox" to ``__geo_interface__`` output [jzmiller1] 0.6 (2015/08/04) ----------------- @@ -47,7 +54,7 @@ Changelog 0.5 (2015/07/13) ----------------- -- Add __iter__ method to FeatureCollection and GeometryCollection [jzmiller1]. +- Add ``__iter__`` method to FeatureCollection and GeometryCollection [jzmiller1]. - add pypy and pypy3 and python 3.4 to travis. - Add tox configuration for performing local testing [Ian Lee]. - Add Travis continuous deployment. @@ -55,8 +62,8 @@ Changelog 0.4 (2013/10/25) ----------------- -- after a year in production promote it to `Development Status :: 5 - Production/Stable` -- MultiPolygons return tuples as the __geo_interface__ +- after a year in production promote it to ``Development Status :: 5 - Production/Stable`` +- MultiPolygons return tuples as the ``__geo_interface__`` 0.3.1 (2012/11/15) ------------------ @@ -70,16 +77,16 @@ Changelog - add GeometryCollection - len(Multi*) and len(GeometryCollection) returns the number of contained Geometries -- add orient function to get clockwise or counterclockwise oriented polygons -- add signed_area function -- add _set_orientation method to lineStrings, Polygons and MultiPolygons +- add ``orient`` function to get clockwise or counterclockwise oriented polygons +- add ``signed_area`` function +- add ``_set_orientation`` method to lineStrings, Polygons and MultiPolygons 0.2.1 (2012/08/02) ------------------- -- as_shape also accepts an object that is neither a dictionary nor has a __geo_interface__ - but can be converted into a __geo_interface__ compliant dictionary +- ``as_shape`` also accepts an object that is neither a dictionary nor has a ``__geo_interface__`` + but can be converted into a ``__geo_interface__`` compliant dictionary 0.2 (2012/08/01) @@ -87,7 +94,7 @@ Changelog - change license to LGPL - add wkt as a property -- as_shape also accepts a __geo_interface__ compliant dictionary +- ``as_shape`` also accepts a ``__geo_interface__`` compliant dictionary - test with python3 diff --git a/docs/LICENSE.rst b/docs/LICENSE.rst new file mode 100644 index 00000000..c769a96c --- /dev/null +++ b/docs/LICENSE.rst @@ -0,0 +1,21 @@ + +License +======= + +Copyright (C) 2012 - 2023 Christian Ledermann + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License (LGPL_) as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License (LGPL_) for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +.. _LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt deleted file mode 100644 index d8d8473b..00000000 --- a/docs/LICENSE.txt +++ /dev/null @@ -1,19 +0,0 @@ - - Pygeoif is a basic implementation of the __geo_interface__ in - pure Python - - Copyright (C) 2012 - 2023 Christian Ledermann - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..9feba681 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,48 @@ +# noqa: D100, INP001 +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +from pygeoif import about # noqa: E402 + +project = "pygeoif" +copyright = "2023, Christian Ledermann" # noqa: A001 +author = "Christian Ledermann" +release = about.__version__ + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.duration", + "sphinx.ext.doctest", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "alabaster" +html_static_path = ["_static"] +try: + import sphinx_rtd_theme + + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +except ImportError: + pass + +autodoc_member_order = "bysource" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..986eb3c2 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,24 @@ +.. pygeoif documentation master file, created by + sphinx-quickstart on Sun Oct 29 13:56:59 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to pygeoif's documentation! +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + overview + modules + HISTORY + CONTRIBUTORS + LICENSE + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..32bb2452 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 00000000..a2c8296b --- /dev/null +++ b/docs/modules.rst @@ -0,0 +1,10 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 4 + + pygeoif + +.. autosummary:: + :toctree: generated diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 00000000..75741435 --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,5 @@ +Overview +========= + +.. include:: ../README.rst + :start-after: inclusion-marker-do-not-remove diff --git a/docs/pygeoif.rst b/docs/pygeoif.rst new file mode 100644 index 00000000..024b062e --- /dev/null +++ b/docs/pygeoif.rst @@ -0,0 +1,67 @@ +pygeoif +======= + + +Module contents +--------------- + + +Submodules +---------- + +pygeoif.geometry module +----------------------- + +.. automodule:: pygeoif.geometry + :members: + :member-order: bysource + :undoc-members: + :show-inheritance: + +pygeoif.feature module +---------------------- + +.. automodule:: pygeoif.feature + :members: + :undoc-members: + :show-inheritance: + +pygeoif.factories module +------------------------ + +.. automodule:: pygeoif.factories + :members: + :undoc-members: + :show-inheritance: + +pygeoif.functions module +------------------------ + +.. automodule:: pygeoif.functions + :members: + :undoc-members: + :show-inheritance: + +pygeoif.exceptions module +------------------------- + +.. automodule:: pygeoif.exceptions + :members: + :undoc-members: + :show-inheritance: + +pygeoif.types module +-------------------- + +.. automodule:: pygeoif.types + :members: + :undoc-members: + :show-inheritance: + +pygeoif.about module +-------------------- + +.. automodule:: pygeoif.about + :members: + :undoc-members: + :special-members: __version__ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..650fbfd3 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx +sphinx-autodoc-typehints +sphinx-rtd-theme +typing-extensions diff --git a/pygeoif/about.py b/pygeoif/about.py index b3df8abd..10c86489 100644 --- a/pygeoif/about.py +++ b/pygeoif/about.py @@ -3,4 +3,4 @@ The only purpose of this module is to provide a version number for the package. """ -__version__ = "1.1.1" +__version__ = "1.2.0" diff --git a/pygeoif/factories.py b/pygeoif/factories.py index 74831167..b0f61450 100644 --- a/pygeoif/factories.py +++ b/pygeoif/factories.py @@ -24,6 +24,7 @@ from typing import cast from pygeoif.exceptions import WKTParserError +from pygeoif.functions import move_geo_interface from pygeoif.functions import signed_area from pygeoif.geometry import Geometry from pygeoif.geometry import GeometryCollection @@ -60,6 +61,11 @@ mpre: Pattern[str] = re.compile(r"\(\((.+?)\)\)") +def get_oriented_ring(ring: LineType, ccw: bool) -> LineType: # noqa: FBT001 + s = 1.0 if ccw else -1.0 + return ring if signed_area(ring) / s >= 0 else ring[::-1] + + def orient(polygon: Polygon, ccw: bool = True) -> Polygon: # noqa: FBT001, FBT002 """ Return a polygon with exteriors and interiors in the right orientation. @@ -68,19 +74,10 @@ def orient(polygon: Polygon, ccw: bool = True) -> Polygon: # noqa: FBT001, FBT0 and the interiors will be in clockwise orientation, or the other way round when ccw is False. """ - s = 1.0 if ccw else -1.0 - rings = [] - ring = polygon.exterior - if signed_area(ring.coords) / s >= 0: - rings.append(ring.coords) - else: - rings.append(list(ring.coords)[::-1]) - for ring in polygon.interiors: - if signed_area(ring.coords) / s <= 0: - rings.append(ring.coords) - else: - rings.append(list(ring.coords)[::-1]) - return Polygon(shell=rings[0], holes=rings[1:]) + shell = get_oriented_ring(polygon.exterior.coords, ccw) + ccw = not ccw # flip orientation for holes + holes = [get_oriented_ring(ring.coords, ccw) for ring in polygon.interiors] + return Polygon(shell=shell, holes=holes) def box( @@ -335,7 +332,46 @@ def mapping( return ob.__geo_interface__ +def force_2d( + context: Union[GeoType, GeoCollectionType], +) -> Union[Geometry, GeometryCollection]: + """ + Force the dimensionality of a geometry to 2D. + + >>> force_2d(Point(0, 0, 1)) + Point(0, 0) + >>> force_2d(Point(0, 0)) + Point(0, 0) + >>> force_2d(LineString([(0, 0, 0), (0, 1, 1), (1, 1, 2)])) + LineString(((0, 0), (0, 1), (1, 1))) + """ + geometry = mapping(context) + return shape(move_geo_interface(geometry, (0, 0))) + + +def force_3d( + context: Union[GeoType, GeoCollectionType], + z: float = 0, +) -> Union[Geometry, GeometryCollection]: + """ + Force the dimensionality of a geometry to 3D. + + >>> force_3d(Point(0, 0)) + Point(0, 0, 0) + >>> force_3d(Point(0, 0), 1) + Point(0, 0, 1) + >>> force_3d(Point(0, 0, 0)) + Point(0, 0, 0) + >>> force_3d(LineString([(0, 0), (0, 1), (1, 1)])) + LineString(((0, 0, 0), (0, 1, 0), (1, 1, 0))) + """ + geometry = mapping(context) + return shape(move_geo_interface(geometry, (0, 0, z))) + + __all__ = [ + "force_2d", + "force_3d", "box", "from_wkt", "mapping", diff --git a/pygeoif/feature.py b/pygeoif/feature.py index 23a45173..633d51d6 100644 --- a/pygeoif/feature.py +++ b/pygeoif/feature.py @@ -46,9 +46,9 @@ def feature_geo_interface_equals( my_interface["geometry"]["type"] == other_interface["geometry"].get("type"), compare_coordinates( coords=my_interface["geometry"]["coordinates"], - other=other_interface["geometry"].get( + other=other_interface["geometry"].get( # type: ignore [arg-type] "coordinates", - ), # type: ignore[arg-type] + ), ), ], ) @@ -128,7 +128,7 @@ def properties(self) -> Dict[str, Any]: def __geo_interface__(self) -> GeoFeatureInterface: """Return the GeoInterface of the geometry with properties.""" geo_interface: GeoFeatureInterface = { - "type": self.__class__.__name__, + "type": "Feature", "bbox": cast(Bounds, self._geometry.bounds), "geometry": self._geometry.__geo_interface__, "properties": self._properties, @@ -220,7 +220,7 @@ def bounds(self) -> Bounds: def __geo_interface__(self) -> GeoFeatureCollectionInterface: """Return the GeoInterface of the feature.""" return { - "type": self.__class__.__name__, + "type": "FeatureCollection", "bbox": self.bounds, "features": tuple(feature.__geo_interface__ for feature in self._features), } diff --git a/pygeoif/functions.py b/pygeoif/functions.py index f3e82f34..19cd7b75 100644 --- a/pygeoif/functions.py +++ b/pygeoif/functions.py @@ -31,6 +31,7 @@ from pygeoif.types import LineType from pygeoif.types import MultiCoordinatesType from pygeoif.types import Point2D +from pygeoif.types import PointType def signed_area(coords: LineType) -> float: @@ -43,12 +44,9 @@ def signed_area(coords: LineType) -> float: xs, ys = map(list, zip(*(coord[:2] for coord in coords))) xs.append(xs[1]) # pragma: no mutate ys.append(ys[1]) # pragma: no mutate - return ( - sum( - xs[i] * (ys[i + 1] - ys[i - 1]) # type: ignore [operator] - for i in range(1, len(coords)) - ) - / 2.0 + return cast( + float, + sum(xs[i] * (ys[i + 1] - ys[i - 1]) for i in range(1, len(coords))) / 2.0, ) @@ -188,11 +186,83 @@ def compare_geo_interface( return False +def move_coordinate( + coordinate: PointType, + move_by: PointType, +) -> PointType: + """ + Move the coordinate by the given vector. + + This forcefully changes the dimensions of the coordinate to match the latter. + >>> move_coordinate((0, 0), (-1, 1)) + (-1, 1) + >>> move_coordinate((0, 0, 0), (-1, 1)) + (-1, 1) + >>> move_coordinate((0, 0), (-1, 1, 0)) + (-1, 1, 0) + """ + if len(coordinate) < len(move_by): + return cast( + PointType, + tuple(c + m for c, m in zip_longest(coordinate, move_by, fillvalue=0)), + ) + + return cast(PointType, tuple(c + m for c, m in zip(coordinate, move_by))) + + +def move_coordinates( + coordinates: CoordinatesType, + move_by: PointType, +) -> CoordinatesType: + """ + Move the coordinates recursively by the given vector. + + This forcefully changes the dimension of each of the coordinate to match + the dimensionality of the vector. + >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1)) + ((-1, 1), (-2, 2)) + >>> move_coordinates(((0, 0, 0), (-1, 1, 0)), (-1, 1)) + ((-1, 1), (-2, 2)) + >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1, 0)) + ((-1, 1, 0), (-2, 2, 0)) + """ + if isinstance(coordinates[0], (int, float)): + return move_coordinate(cast(PointType, coordinates), move_by) + return cast( + CoordinatesType, + tuple(move_coordinates(cast(CoordinatesType, c), move_by) for c in coordinates), + ) + + +def move_geo_interface( + interface: Union[GeoInterface, GeoCollectionInterface], + move_by: PointType, +) -> Union[GeoInterface, GeoCollectionInterface]: + """Move the coordinates of the geo interface by the given vector.""" + if interface["type"] == "GeometryCollection": + return { + "type": "GeometryCollection", + "geometries": tuple( + move_geo_interface(g, move_by) + for g in interface["geometries"] # type: ignore [typeddict-item] + ), + } + return { + "type": interface["type"], + "coordinates": move_coordinates( + interface["coordinates"], # type: ignore [typeddict-item, arg-type] + move_by, + ), + } + + __all__ = [ "centroid", "compare_coordinates", "compare_geo_interface", "convex_hull", "dedupe", + "move_coordinate", + "move_coordinates", "signed_area", ] diff --git a/pygeoif/geometry.py b/pygeoif/geometry.py index 6ea62edd..9b862777 100644 --- a/pygeoif/geometry.py +++ b/pygeoif/geometry.py @@ -20,6 +20,8 @@ import math import warnings from itertools import chain +from typing import Any +from typing import Hashable from typing import Iterable from typing import Iterator from typing import NoReturn @@ -49,6 +51,22 @@ class _Geometry: """Base Class for geometry objects.""" + __slots__ = ("_geoms",) + + _geoms: Hashable + + def __setattr__(self, *args: Any) -> NoReturn: # noqa: ANN401 + msg = f"Attributes of {self.__class__.__name__} cannot be changed" + raise AttributeError( + msg, + ) + + def __delattr__(self, *args: Any) -> NoReturn: # noqa: ANN401 + msg = f"Attributes of {self.__class__.__name__} cannot be deleted" + raise AttributeError( + msg, + ) + def __str__(self) -> str: return self.wkt @@ -161,7 +179,7 @@ def _wkt_coords(self) -> str: @property def _wkt_inset(self) -> str: """Return Z for 3 dimensional geometry or an empty string for 2 dimensions.""" - return "" + return " Z " if self.has_z else " " @property def _wkt_type(self) -> str: @@ -217,6 +235,8 @@ class Point(_Geometry): 1.0 """ + _geoms: PointType + def __init__(self, x: float, y: float, z: Optional[float] = None) -> None: """ Initialize a Point. @@ -226,18 +246,22 @@ def __init__(self, x: float, y: float, z: Optional[float] = None) -> None: 2 or 3 coordinate parameters: x, y, [z] : float Easting, northing, and elevation. """ - self._coordinates = cast( - PointType, - tuple( - coordinate - for coordinate in (x, y, z) - if coordinate is not None and not math.isnan(coordinate) + object.__setattr__( + self, + "_geoms", + cast( + PointType, + tuple( + coordinate + for coordinate in (x, y, z) + if coordinate is not None and not math.isnan(coordinate) + ), ), ) def __repr__(self) -> str: """Return the representation.""" - return f"{self.geom_type}{self._coordinates}" + return f"{self.geom_type}{self._geoms}" @property def is_empty(self) -> bool: @@ -246,50 +270,45 @@ def is_empty(self) -> bool: A Point is considered empty when it has less than 2 coordinates. """ - return len(self._coordinates) < 2 # noqa: PLR2004 + return len(self._geoms) < 2 # noqa: PLR2004 @property def x(self) -> float: """Return x coordinate.""" - return self._coordinates[0] + return self._geoms[0] @property def y(self) -> float: """Return y coordinate.""" - return self._coordinates[1] + return self._geoms[1] @property def z(self) -> Optional[float]: """Return z coordinate.""" if self.has_z: - return self._coordinates[2] # type: ignore [misc] + return self._geoms[2] # type: ignore [misc] msg = f"The {self!r} geometry does not have z values" raise DimensionError(msg) @property def coords(self) -> Tuple[PointType]: """Return the geometry coordinates.""" - return (self._coordinates,) + return (self._geoms,) @property def has_z(self) -> bool: """Return True if the geometry's coordinate sequence(s) have z values.""" - return len(self._coordinates) == 3 # noqa: PLR2004 + return len(self._geoms) == 3 # noqa: PLR2004 @property def _wkt_coords(self) -> str: - return " ".join(str(coordinate) for coordinate in self._coordinates) - - @property - def _wkt_inset(self) -> str: - """Return Z for 3 dimensional geometry or an empty string for 2 dimensions.""" - return " Z " if len(self._coordinates) == 3 else " " # noqa: PLR2004 + return " ".join(str(coordinate) for coordinate in self._geoms) @property def __geo_interface__(self) -> GeoInterface: """Return the geo interface.""" geo_interface = super().__geo_interface__ - geo_interface["coordinates"] = cast(PointType, tuple(self._coordinates)) + geo_interface["coordinates"] = cast(PointType, tuple(self._geoms)) return geo_interface @classmethod @@ -322,6 +341,8 @@ class LineString(_Geometry): A sequence of Points """ + _geoms: Tuple[Point, ...] + def __init__(self, coordinates: LineType) -> None: """ Initialize a Linestring. @@ -337,7 +358,7 @@ def __init__(self, coordinates: LineType) -> None: >>> a = LineString([(0, 0), (1, 0), (1, 1)]) """ - self._geoms = self._set_geoms(coordinates) + object.__setattr__(self, "_geoms", self._set_geoms(coordinates)) def __repr__(self) -> str: """Return the representation.""" @@ -378,10 +399,6 @@ def maybe_valid(self) -> bool: """ return len({p.coords[0] for p in self._geoms}) > 1 - @property - def _wkt_inset(self) -> str: - return self.geoms[0]._wkt_inset # noqa: SLF001 - @property def _wkt_coords(self) -> str: return ", ".join(point._wkt_coords for point in self.geoms) # noqa: SLF001 @@ -422,7 +439,7 @@ def _set_geoms(coordinates: LineType) -> Tuple[Point, ...]: ) last_len = len(coord) point = Point(*coord) - if not point.is_empty: + if point: geoms.append(point) return tuple(geoms) @@ -461,7 +478,7 @@ def __init__(self, coordinates: LineType) -> None: """ super().__init__(coordinates) if not self.is_empty and self._geoms[0].coords != self._geoms[-1].coords: - self._geoms = (*self._geoms, self._geoms[0]) + object.__setattr__(self, "_geoms", (*self._geoms, self._geoms[0])) @property def centroid(self) -> Optional[Point]: @@ -521,6 +538,8 @@ class Polygon(_Geometry): A sequence of rings which bound all existing holes. """ + _geoms: Tuple[LinearRing, ...] + def __init__( self, shell: LineType, @@ -544,10 +563,9 @@ def __init__( >>> coords = ((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)) >>> polygon = Polygon(coords) """ - self._interiors: Tuple[LinearRing, ...] = () - if holes: - self._interiors = tuple(LinearRing(hole) for hole in holes) - self._exterior = LinearRing(shell) + interiors = tuple(LinearRing(hole) for hole in holes) if holes else () + exterior = LinearRing(shell) + object.__setattr__(self, "_geoms", (exterior, interiors)) def __repr__(self) -> str: """Return the representation.""" @@ -556,12 +574,16 @@ def __repr__(self) -> str: @property def exterior(self) -> LinearRing: """Return the exterior Linear Ring of the polygon.""" - return self._exterior + return self._geoms[0] @property def interiors(self) -> Iterator[LinearRing]: """Interiors (Holes) of the polygon.""" - yield from (interior for interior in self._interiors if interior) + yield from ( + interior + for interior in self._geoms[1] # type: ignore [attr-defined] + if interior + ) @property def is_empty(self) -> bool: @@ -570,7 +592,7 @@ def is_empty(self) -> bool: A polygon is empty when it does not have an exterior. """ - return self._exterior.is_empty + return self._geoms[0].is_empty @property def coords(self) -> PolygonType: @@ -579,7 +601,7 @@ def coords(self) -> PolygonType: Note that this is not implemented in Shapely. """ - if self._interiors: + if self._geoms[1]: return self.exterior.coords, tuple( interior.coords for interior in self.interiors if interior ) @@ -588,7 +610,7 @@ def coords(self) -> PolygonType: @property def has_z(self) -> Optional[bool]: """Return True if the geometry's coordinate sequence(s) have z values.""" - return self._exterior.has_z + return self._geoms[0].has_z @property def maybe_valid(self) -> bool: @@ -602,7 +624,7 @@ def maybe_valid(self) -> bool: return False return ( all(interior.maybe_valid for interior in self.interiors) - if self._exterior.maybe_valid + if self.exterior.maybe_valid else False ) @@ -614,10 +636,6 @@ def _wkt_coords(self) -> str: ) return f"({ec}){ic}" - @property - def _wkt_inset(self) -> str: - return self.exterior._wkt_inset # noqa: SLF001 - @property def __geo_interface__(self) -> GeoInterface: """Return the geo interface.""" @@ -692,11 +710,7 @@ def coords(self) -> NoReturn: @property def has_z(self) -> Optional[bool]: """Return True if any geometry of the collection have z values.""" - return ( - any(geom.has_z for geom in self.geoms) - if self._geoms # type: ignore [attr-defined] - else None - ) + return any(geom.has_z for geom in self.geoms) if self._geoms else None @property def geoms(self) -> Iterator[_Geometry]: @@ -735,6 +749,8 @@ class MultiPoint(_MultiGeometry): A sequence of Points """ + _geoms: Tuple[Point, ...] + def __init__(self, points: Sequence[PointType], unique: bool = False) -> None: """ Create a collection of one or more points. @@ -759,7 +775,7 @@ def __init__(self, points: Sequence[PointType], unique: bool = False) -> None: """ if unique: points = set(points) # type: ignore [assignment] - self._geoms = tuple(Point(*point) for point in points) + object.__setattr__(self, "_geoms", tuple(Point(*point) for point in points)) def __len__(self) -> int: """Return the number of points in this MultiPoint.""" @@ -811,6 +827,8 @@ class MultiLineString(_MultiGeometry): A sequence of LineStrings """ + _geoms: Tuple[LineString, ...] + def __init__(self, lines: Sequence[LineType], unique: bool = False) -> None: """ Initialize the MultiLineString. @@ -831,7 +849,7 @@ def __init__(self, lines: Sequence[LineType], unique: bool = False) -> None: """ if unique: lines = {tuple(line) for line in lines} # type: ignore [assignment] - self._geoms = tuple(LineString(line) for line in lines) + object.__setattr__(self, "_geoms", tuple(LineString(line) for line in lines)) def __len__(self) -> int: """Return the number of lines in the collection.""" @@ -893,6 +911,8 @@ class MultiPolygon(_MultiGeometry): A sequence of `Polygon` instances """ + _geoms: Tuple[Polygon, ...] + def __init__(self, polygons: Sequence[PolygonType], unique: bool = False) -> None: """ Initialize a Multipolygon. @@ -926,14 +946,16 @@ def __init__(self, polygons: Sequence[PolygonType], unique: bool = False) -> Non if unique: polygons = set(polygons) # type: ignore [assignment] - self._geoms = tuple( - Polygon( - shell=polygon[0], - holes=polygon[1] # type: ignore [misc] - if len(polygon) == 2 # noqa: PLR2004 - else None, - ) - for polygon in polygons + object.__setattr__( + self, + "_geoms", + tuple( + Polygon( + shell=polygon[0], + holes=polygon[1] if len(polygon) == 2 else None, # noqa: PLR2004 + ) + for polygon in polygons + ), ) def __len__(self) -> int: @@ -1025,6 +1047,8 @@ class isn't generally supported by ordinary GIS sw (viewers and so on). So {'type': 'Point', 'coordinates': (1.0, -1.0)}]} """ + _geoms: Tuple[Union[Geometry, "GeometryCollection"], ...] + def __init__( self, geometries: Iterable[Union[Geometry, "GeometryCollection"]], @@ -1036,7 +1060,7 @@ def __init__( ---- geometries (Iterable[Geometry] """ - self._geoms = tuple(geom for geom in geometries if geom) + object.__setattr__(self, "_geoms", tuple(geom for geom in geometries if geom)) def __eq__(self, other: object) -> bool: """ diff --git a/pygeoif/types.py b/pygeoif/types.py index 861781e8..e9de16f6 100644 --- a/pygeoif/types.py +++ b/pygeoif/types.py @@ -23,8 +23,9 @@ from typing import Union from typing_extensions import Literal +from typing_extensions import NotRequired from typing_extensions import Protocol -from typing_extensions import TypedDict +from typing_extensions import TypedDict # for Python <3.11 with (Not)Required Point2D = Tuple[float, float] Point3D = Tuple[float, float, float] @@ -48,17 +49,12 @@ MultiCoordinatesType = Sequence[CoordinatesType] -class GeoInterfaceBase(TypedDict): +class GeoInterface(TypedDict): """Required keys for the GeoInterface.""" type: str coordinates: Union[CoordinatesType, MultiCoordinatesType] - - -class GeoInterface(GeoInterfaceBase, total=False): - """GeoInterface provides an optional bbox.""" - - bbox: Bounds + bbox: NotRequired[Bounds] class GeoCollectionInterface(TypedDict): @@ -66,35 +62,26 @@ class GeoCollectionInterface(TypedDict): type: Literal["GeometryCollection"] geometries: Sequence[Union[GeoInterface, "GeoCollectionInterface"]] + bbox: NotRequired[Bounds] -class GeoFeatureInterfaceBase(TypedDict): - """Required keys for the GeoInterface for Features.""" - - type: str - geometry: GeoInterface - - -class GeoFeatureInterface(GeoFeatureInterfaceBase, total=False): +class GeoFeatureInterface(TypedDict): """The GeoFeatureInterface has optional keys.""" - bbox: Bounds - properties: Dict[str, Any] - id: Union[str, int] # noqa: A003 - - -class GeoFeatureCollectionInterfaceBase(TypedDict): - """Required Keys for the GeoInterface of a FeatureCollection.""" - - type: str - features: Sequence[GeoFeatureInterface] + type: Literal["Feature"] + bbox: NotRequired[Bounds] + properties: NotRequired[Dict[str, Any]] + id: NotRequired[Union[str, int]] + geometry: GeoInterface -class GeoFeatureCollectionInterface(GeoFeatureCollectionInterfaceBase, total=False): +class GeoFeatureCollectionInterface(TypedDict): """Bbox and id are optional keys for the GeoFeatureCollectionInterface.""" - bbox: Bounds - id: Union[str, int] # noqa: A003 + type: Literal["FeatureCollection"] + features: Sequence[GeoFeatureInterface] + bbox: NotRequired[Bounds] + id: NotRequired[Union[str, int]] class GeoType(Protocol): diff --git a/pyproject.toml b/pyproject.toml index f0570285..7a3bee81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "pygeoif[complexity]", "pygeoif[linting]", "pygeoif[tests]", + "pygeoif[typing]", ] linting = [ "black", @@ -68,7 +69,6 @@ linting = [ "flake8-expression-complexity", "flake8-function-order", "flake8-length", - "flake8-literal", "flake8-pep3101", "flake8-rst-docstrings", "flake8-string-format", @@ -91,8 +91,19 @@ content-type = "text/x-rst" file = "README.rst" [project.urls] +Changelog = "https://github.com/cleder/pygeoif/blob/develop/docs/HISTORY.rst" +Documentation = "https://pygeoif.readthedocs.io/" Homepage = "https://github.com/cleder/pygeoif/" +[tool.coverage.paths] +source = [ + "pygeoif", + "tests", +] + +[tool.coverage.run] +branch = true + [tool.flake8] max_line_length = 88 @@ -209,7 +220,7 @@ select = [ "W", "YTT", ] -target-version = "py37" +target-version = "py38" [tool.ruff.isort] force-single-line = true @@ -250,4 +261,5 @@ exclude = [ ] include = [ "pygeoif*", + "pygeoif/py.typed", ] diff --git a/tests/test_base.py b/tests/test_base.py index 615a56e0..af98c21b 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -37,7 +37,8 @@ def test_empty() -> None: def test_wkt_inset() -> None: base_geo = geometry._Geometry() - assert base_geo._wkt_inset == "" + with pytest.raises(NotImplementedError, match="^Must be implemented by subclass$"): + assert base_geo._wkt_inset == "" def test_wkt_coordinates() -> None: @@ -72,3 +73,77 @@ def test_get_bounds() -> None: base_geo = geometry._Geometry() with pytest.raises(NotImplementedError, match="^Must be implemented by subclass$"): assert base_geo._get_bounds() + + +@pytest.mark.parametrize( + ("attr_val", "expected_error", "expected_error_message"), + [ + # Happy path tests + ( + ("attribute", "value"), + AttributeError, + "Attributes of _Geometry cannot be changed", + ), + ( + ("another_attribute", 123), + AttributeError, + "Attributes of _Geometry cannot be changed", + ), + ( + ("yet_another_attribute", [1, 2, 3]), + AttributeError, + "Attributes of _Geometry cannot be changed", + ), + # Edge cases + (("", "value"), AttributeError, "Attributes of _Geometry cannot be changed"), + ((None, "value"), TypeError, ".*attribute name must be string.*"), + # Error cases + ((123, "value"), TypeError, ".*attribute name must be string.*"), + (([1, 2, 3], "value"), TypeError, ".*attribute name must be string.*"), + ], +) +def test_setattr(attr_val, expected_error, expected_error_message) -> None: + base_geo = geometry._Geometry() + + with pytest.raises(expected_error, match=f"^{expected_error_message}$"): + setattr(base_geo, *attr_val) + + +@pytest.mark.parametrize( + ("attr", "expected_error", "expected_error_message"), + [ + ( + "attr1", + AttributeError, + "Attributes of _Geometry cannot be deleted", + ), # realistic test value + ( + "", + AttributeError, + "Attributes of _Geometry cannot be deleted", + ), # edge case: empty string + ( + None, + TypeError, + ".*attribute name must be string.*", + ), # edge case: None + ( + 123, + TypeError, + ".*attribute name must be string.*", + ), # error case: non-string attribute + ], + ids=[ + "realistic_test_value", + "edge_case_empty_string", + "edge_case_None", + "error_case_non_string_attribute", + ], +) +def test_delattr(attr, expected_error, expected_error_message) -> None: + # Arrange + base_geo = geometry._Geometry() + + # Act + with pytest.raises(expected_error, match=f"^{expected_error_message}$"): + delattr(base_geo, attr) diff --git a/tests/test_factories.py b/tests/test_factories.py index 1c29aaa7..71994ac1 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -21,6 +21,167 @@ def test_num_float() -> None: assert isinstance(factories.num("1.1"), float) +def test_force_2d_point() -> None: + # 2d point to 2d point (no actual change) + p = geometry.Point(-1, 1) + p2d = factories.force_2d(p) + assert p2d.x == -1 + assert p2d.y == 1 + assert not p2d.has_z + + # 3d point to 2d point + p = geometry.Point(-1, 1, 2) + p2d = factories.force_2d(p) + assert p2d.x == -1 + assert p2d.y == 1 + assert not p2d.has_z + + +def test_force_2d_multipoint() -> None: + # 2d to 2d (no actual change) + p = geometry.MultiPoint([(-1, 1), (2, 3)]) + p2d = factories.force_2d(p) + assert list(p2d.geoms) == [geometry.Point(-1, 1), geometry.Point(2, 3)] + + +def test_force_2d_linestring() -> None: + # 2d line string to 2d line string (no actual change) + ls = geometry.LineString([(1, 2), (3, 4)]) + l2d = factories.force_2d(ls) + assert l2d.coords == ((1, 2), (3, 4)) + + # 3d line string to 2d line string + ls = geometry.LineString([(1, 2, 3), (4, 5, 6)]) + l2d = factories.force_2d(ls) + assert l2d.coords == ((1, 2), (4, 5)) + + +def test_force_2d_linearring() -> None: + # 2d linear ring to 2d linear ring (no actual change) + r = geometry.LinearRing([(1, 2), (3, 4)]) + r2d = factories.force_2d(r) + assert r2d.coords == ((1, 2), (3, 4), (1, 2)) + + # 3d linear ring to 2d linear ring + r = geometry.LinearRing([(1, 2, 3), (4, 5, 6)]) + r2d = factories.force_2d(r) + assert r2d.coords == ((1, 2), (4, 5), (1, 2)) + + +def test_force_2d_multilinestring() -> None: + # 2d multi line string to 2d multi line string (no actual change) + mls = geometry.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]]) + mls2d = factories.force_2d(mls) + assert list(mls2d.geoms) == list(mls.geoms) + + # 3d multi line string to 2d multi line string + mls = geometry.MultiLineString([[(1, 2, 3), (4, 5, 6)], [(7, 8, 9), (10, 11, 12)]]) + mls2d = factories.force_2d(mls) + assert list(mls2d.geoms) == [ + geometry.LineString([(1, 2), (4, 5)]), + geometry.LineString([(7, 8), (10, 11)]), + ] + + +def test_force_2d_polygon() -> None: + # 2d to 2d (no actual change) + external = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] + internal = [(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)] + p = geometry.Polygon(external, [internal]) + p2d = factories.force_2d(p) + assert p2d.coords[0] == (((0, 0), (0, 2), (2, 2), (2, 0), (0, 0))) + assert p2d.coords[1] == ( + ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), + ) + assert not p2d.has_z + assert p.maybe_valid == p2d.maybe_valid + + # 3d to 2d + external = [(0, 0, 1), (0, 2, 1), (2, 2, 1), (2, 0, 1), (0, 0, 1)] + internal = [ + (0.5, 0.5, 1), + (0.5, 1.5, 1), + (1.5, 1.5, 1), + (1.5, 0.5, 1), + (0.5, 0.5, 1), + ] + + p = geometry.Polygon(external, [internal]) + p2d = factories.force_2d(p) + assert p2d.coords[0] == (((0, 0), (0, 2), (2, 2), (2, 0), (0, 0))) + assert p2d.coords[1] == ( + ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), + ) + assert not p2d.has_z + + +def test_force_2d_multipolygon() -> None: + # 2d to 2d (no actual change) + external = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] + internal = [(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)] + mp = geometry.MultiPolygon([(external, [internal]), (external, [internal])]) + mp2d = factories.force_2d(mp) + + assert list(mp2d.geoms) == list(mp.geoms) + + +def test_force2d_collection() -> None: + # 2d to 2d (no actual change) + gc = geometry.GeometryCollection([geometry.Point(-1, 1), geometry.Point(-2, 2)]) + gc2d = factories.force_2d(gc) + assert list(gc2d.geoms) == list(gc.geoms) + + # 3d to 2d + gc = geometry.GeometryCollection( + [geometry.Point(-1, 1, 0), geometry.Point(-2, 2, 0)], + ) + gc2d = factories.force_2d(gc) + assert list(gc2d.geoms) == [geometry.Point(-1, 1), geometry.Point(-2, 2)] + + +def test_force_2d_nongeo() -> None: + pytest.raises(AttributeError, factories.force_2d, (1, 2, 3)) + + +def test_force_3d_point() -> None: + p = geometry.Point(0, 0) + p3d = factories.force_3d(p) + assert p3d.x == 0 + assert p3d.y == 0 + assert p3d.z == 0 + assert p3d.has_z + + +def test_force_3d_collection() -> None: + gc = geometry.GeometryCollection( + [geometry.Point(-1, 1), geometry.Point(-2, 2)], + ) + gc3d = factories.force_3d(gc) + assert list(gc3d.geoms) == [geometry.Point(-1, 1, 0), geometry.Point(-2, 2, 0)] + + +def test_force_3d_point_with_z() -> None: + p = geometry.Point(0, 0, 1) + p3d = factories.force_3d(p) + assert p3d.x == 0 + assert p3d.y == 0 + assert p3d.z == 1 + assert p3d.has_z + + +def test_force_3d_point_noop() -> None: + p = geometry.Point(1, 2, 3) + p3d = factories.force_3d(p) + assert p3d.x == 1 + assert p3d.y == 2 + assert p3d.z == 3 + assert p3d.has_z + + +def test_force_3d_nongeo() -> None: + pytest.raises(AttributeError, factories.force_3d, (1, 2)) + + def test_orient_true() -> None: ext = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] int_1 = [(0.5, 0.25), (1.5, 0.25), (1.5, 1.25), (0.5, 1.25), (0.5, 0.25)] @@ -108,8 +269,8 @@ class TestWKT: (30 20, 20 25, 20 15, 30 20)))""", """MULTIPOLYGON (((30 20, 10 40, 45 40, 30 20)), ((15 5, 40 10, 10 20, 5 10, 15 5)))""", - "MULTIPOLYGON(((0 0,10 0,10 10,0 10,0 0)),((5 5,7 5,7 7,5 7, 5 5)))", - "GEOMETRYCOLLECTION(POINT(10 10), POINT(30 30), LINESTRING(15 15, 20 20))", + "MULTIPOLYGON (((0 0,10 0,10 10,0 10,0 0)),((5 5,7 5,7 7,5 7, 5 5)))", + "GEOMETRYCOLLECTION (POINT(10 10), POINT(30 30), LINESTRING(15 15, 20 20))", ] # these are valid WKTs but not supported @@ -209,7 +370,7 @@ def test_multipoint(self) -> None: assert isinstance(p, geometry.MultiPoint) assert next(iter(p.geoms)).x == 3.5 assert list(p.geoms)[1].y == 10.5 - assert p.wkt == "MULTIPOINT(3.5 5.6, 4.8 10.5)" + assert p.wkt == "MULTIPOINT (3.5 5.6, 4.8 10.5)" p = factories.from_wkt("MULTIPOINT ((10 40), (40 30), (20 20), (30 10))") assert isinstance(p, geometry.MultiPoint) assert next(iter(p.geoms)).x == 10.0 @@ -221,14 +382,14 @@ def test_multipoint(self) -> None: def test_multilinestring(self) -> None: p = factories.from_wkt( - "MULTILINESTRING((3 4,10 50,20 25),(-5 -8,-10 -8,-15 -4))", + "MULTILINESTRING ((3 4,10 50,20 25),(-5 -8,-10 -8,-15 -4))", ) assert isinstance(p, geometry.MultiLineString) assert next(iter(p.geoms)).coords == (((3, 4), (10, 50), (20, 25))) assert list(p.geoms)[1].coords == (((-5, -8), (-10, -8), (-15, -4))) assert ( - p.wkt == "MULTILINESTRING((3 4, 10 50, " + p.wkt == "MULTILINESTRING ((3 4, 10 50, " "20 25),(-5 -8, " "-10 -8, -15 -4))" ) @@ -241,12 +402,12 @@ def test_multilinestring_1(self) -> None: assert isinstance(p, geometry.MultiLineString) assert p.wkt == ( - "MULTILINESTRING((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10))" + "MULTILINESTRING ((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10))" ) def test_multipolygon(self) -> None: p = factories.from_wkt( - "MULTIPOLYGON(((0 0,10 20,30 40,0 0)," + "MULTIPOLYGON (((0 0,10 20,30 40,0 0)," "(1 1,2 2,3 3,1 1))," "((100 100,110 110,120 120,100 100)))", ) @@ -273,7 +434,7 @@ def test_multipolygon(self) -> None: (100.0, 100.0), ) assert ( - p.wkt == "MULTIPOLYGON(((0 0, 10 20, " + p.wkt == "MULTIPOLYGON (((0 0, 10 20, " "30 40, 0 0)," "(1 1, 2 2, 3 3, 1 1))," "((100 100, 110 110," @@ -322,7 +483,7 @@ def test_geometrycollection(self) -> None: assert len(list(gc.geoms)) == 2 assert isinstance(next(iter(gc.geoms)), geometry.Point) assert isinstance(list(gc.geoms)[1], geometry.LineString) - assert gc.wkt == "GEOMETRYCOLLECTION(POINT (4 6), LINESTRING (4 6, 7 10))" + assert gc.wkt == "GEOMETRYCOLLECTION (POINT (4 6), LINESTRING (4 6, 7 10))" def test_wkt_ok(self) -> None: for wkt in self.wkt_ok: diff --git a/tests/test_functions.py b/tests/test_functions.py index 1872d64d..e1441a22 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -126,7 +126,7 @@ def test_signed_area_crescent_ish() -> None: def test_empty_hull() -> None: - assert not convex_hull([]) + assert convex_hull([]) == [] def test_point() -> None: diff --git a/tests/test_geometrycollection.py b/tests/test_geometrycollection.py index 79696c9b..51b016cc 100644 --- a/tests/test_geometrycollection.py +++ b/tests/test_geometrycollection.py @@ -70,7 +70,7 @@ def test_geo_wkt() -> None: gc = geometry.GeometryCollection([poly1, poly2, p0, p1, ring, line]) assert gc.wkt == ( - "GEOMETRYCOLLECTION" + "GEOMETRYCOLLECTION " "(POLYGON ((0 0, 1 1, 1 0, 0 0)), " "POLYGON ((0 0, 0 2, 2 2, 2 0, 0 0),(1 0, 0.5 0.5, 1 1, 1.5 0.5, 1 0)), " "POINT (0 0), POINT (-1 -1), " @@ -271,7 +271,7 @@ def test_multipoint_wkt() -> None: multipoint = geometry.MultiPoint([(0, 0), (1, 1), (1, 2), (2, 2)]) gc = geometry.GeometryCollection([multipoint]) - assert gc.wkt == "GEOMETRYCOLLECTION(MULTIPOINT(0 0, 1 1, 1 2, 2 2))" + assert gc.wkt == "GEOMETRYCOLLECTION (MULTIPOINT (0 0, 1 1, 1 2, 2 2))" def test_multipoint_repr() -> None: @@ -309,8 +309,8 @@ def test_nested_geometry_collection() -> None: gc3 = geometry.GeometryCollection([gc2, poly1]) assert gc3.wkt == ( - "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(" - "POINT (0 0), MULTIPOINT(0 0, 1 1, 1 2, 2 2)), LINESTRING (0 0, 3 1)), " + "GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (" + "POINT (0 0), MULTIPOINT (0 0, 1 1, 1 2, 2 2)), LINESTRING (0 0, 3 1)), " "POLYGON ((0 0, 1 1, 1 0, 0 0)))" ) diff --git a/tests/test_line.py b/tests/test_line.py index b0b4df8a..6cc60f40 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -19,6 +19,12 @@ def test_coords_get_3d() -> None: assert line.coords == ((0.0, 0.0, 0), (1.0, 1.0, 1)) +def test_empty_points_omitted() -> None: + line = geometry.LineString([(0, 0, 0), (None, None, None), (2, 2, 2)]) + + assert line.coords == ((0, 0, 0), (2, 2, 2)) + + def test_set_geoms_raises() -> None: line = geometry.LineString([(0, 0), (1, 0)]) # pragma: no mutate diff --git a/tests/test_linear_ring.py b/tests/test_linear_ring.py index 529d705c..8e4d3c9b 100644 --- a/tests/test_linear_ring.py +++ b/tests/test_linear_ring.py @@ -230,30 +230,6 @@ def test_centroid_valid() -> None: assert line.centroid == geometry.Point(2, 1) -def test_centroid_invalid() -> None: - ring = geometry.LinearRing([(0, 0), (2, 0), (2, 2), (0, 2)]) - line = geometry.LineString( - [ - (28, 16), - (37, 31), - (21, 50), - (-21, 64), - (-84, 64), - (-148, 46), - (-95, 10), - (-72, 46), - (-40, 64), - (-9, 64), - (12, 50), - (20, 31), - (15, 16), - ], - ) - ring._geoms = line._geoms - - assert ring.centroid is None - - def test_empty() -> None: ring = geometry.LinearRing([]) diff --git a/tests/test_multiline.py b/tests/test_multiline.py index 05a3b3cd..40d14280 100644 --- a/tests/test_multiline.py +++ b/tests/test_multiline.py @@ -52,13 +52,13 @@ def test_wkt() -> None: ([(0, 0), (1, 1), (1, 2), (2, 2)], [[0.0, 0.0], [1.0, 2.0]]), ) - assert lines.wkt == "MULTILINESTRING((0 0, 1 1, 1 2, 2 2),(0.0 0.0, 1.0 2.0))" + assert lines.wkt == "MULTILINESTRING ((0 0, 1 1, 1 2, 2 2),(0.0 0.0, 1.0 2.0))" def test_wkt_single_line() -> None: lines = geometry.MultiLineString(([(0, 0), (1, 1), (1, 2), (2, 2)],)) - assert lines.wkt == "MULTILINESTRING((0 0, 1 1, 1 2, 2 2))" + assert lines.wkt == "MULTILINESTRING ((0 0, 1 1, 1 2, 2 2))" def test_repr() -> None: diff --git a/tests/test_multipoint.py b/tests/test_multipoint.py index d4af0030..693bad36 100644 --- a/tests/test_multipoint.py +++ b/tests/test_multipoint.py @@ -81,7 +81,7 @@ def test_unique() -> None: def test_wkt() -> None: multipoint = geometry.MultiPoint([(0, 0), (1, 1), (1, 2), (2, 2)]) - assert multipoint.wkt == "MULTIPOINT(0 0, 1 1, 1 2, 2 2)" + assert multipoint.wkt == "MULTIPOINT (0 0, 1 1, 1 2, 2 2)" def test_repr() -> None: diff --git a/tests/test_multipolygon.py b/tests/test_multipolygon.py index d4bb9f1e..967b8fee 100644 --- a/tests/test_multipolygon.py +++ b/tests/test_multipolygon.py @@ -89,7 +89,7 @@ def test_wkt() -> None: ) assert polys.wkt == ( - "MULTIPOLYGON(((0.0 0.0, 0.0 1.0, 1.0 1.0, 1.0 0.0, 0.0 0.0)," + "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 1.0, 1.0 1.0, 1.0 0.0, 0.0 0.0)))" ) diff --git a/tox.ini b/tox.ini index b627f37d..eacef83a 100644 --- a/tox.ini +++ b/tox.ini @@ -3,11 +3,11 @@ min_python_version = 3.7 exclude = .git,__pycache__,docs/source/conf.py,old,build,dist max_line_length = 89 ignore= - W503,I900,IF100,NQA102, RST212 + W503,I900,IF100,NQA102,RST212,LIT002 per-file-ignores = tests/*:S101,D103,S307,DALL000,S311,ECE001,FKA100 tests/test_geometrycollection.py: ECE001,S101,D103,S307,DALL000 - tests/test_factories.py: ECE001,S10,D10,S307,DALL000,PT009,T003 + tests/test_factories.py: ECE001,S10,D10,S307,DALL000,PT009,T003,CM001 tests/test_feature.py: ECE001,S10,D10,S307,DALL000,PT009,T003,P103 pygeoif/*: S604 pygeoif/types.py: A003