├── .github ├── codecov.yml └── workflows │ ├── ci.yml │ └── deploy_mkdocs.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── mkdocs.yml └── src │ ├── contributing.md │ ├── index.md │ ├── intro.md │ ├── migrations │ └── v1.0_migration.md │ └── release-notes.md ├── geojson_pydantic ├── __init__.py ├── base.py ├── features.py ├── geometries.py ├── py.typed └── types.py ├── pyproject.toml └── tests ├── test_base.py ├── test_features.py ├── test_geometries.py ├── test_package.py └── test_types.py /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | env: 11 | LATEST_PY_VERSION: '3.13' 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: 19 | - '3.9' 20 | - '3.10' 21 | - '3.11' 22 | - '3.12' 23 | - '3.13' 24 | # - '3.14.0-alpha.2', waiting for shapely to support 3.14 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | python -m pip install .["test"] 37 | 38 | - name: Run pre-commit 39 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 40 | run: | 41 | python -m pip install pre-commit 42 | pre-commit run --all-files 43 | 44 | - name: Run tests 45 | run: python -m pytest --cov geojson_pydantic --cov-report xml --cov-report term-missing 46 | 47 | - name: Upload Results 48 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 49 | uses: codecov/codecov-action@v1 50 | with: 51 | file: ./coverage.xml 52 | flags: unittests 53 | name: ${{ matrix.python-version }} 54 | fail_ci_if_error: false 55 | 56 | publish: 57 | needs: [tests] 58 | runs-on: ubuntu-latest 59 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Set up Python 63 | uses: actions/setup-python@v5 64 | with: 65 | python-version: ${{ env.LATEST_PY_VERSION }} 66 | 67 | - name: Install dependencies 68 | run: | 69 | python -m pip install --upgrade pip 70 | python -m pip install flit 71 | python -m pip install . 72 | 73 | - name: Set tag version 74 | id: tag 75 | run: | 76 | echo "version=${GITHUB_REF#refs/*/}" 77 | echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 78 | 79 | - name: Set module version 80 | id: module 81 | run: | 82 | echo version=$(python -c'import geojson_pydantic; print(geojson_pydantic.__version__)') >> $GITHUB_OUTPUT 83 | 84 | - name: Build and publish 85 | if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}} 86 | env: 87 | FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} 88 | FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 89 | run: flit publish 90 | -------------------------------------------------------------------------------- /.github/workflows/deploy_mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | # Only rebuild website when docs have changed 9 | - 'README.md' 10 | - 'CHANGELOG.md' 11 | - 'CONTRIBUTING.md' 12 | - 'docs/**' 13 | 14 | jobs: 15 | build: 16 | name: Deploy docs 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout main 20 | uses: actions/checkout@v2 21 | 22 | - name: Set up Python 3.9 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: 3.9 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install numpy 31 | python -m pip install .["docs"] 32 | 33 | - name: Deploy docs 34 | run: mkdocs gh-deploy --force -f docs/mkdocs.yml 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | 104 | .pytest_cache 105 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/abravalheri/validate-pyproject 3 | rev: v0.12.1 4 | hooks: 5 | - id: validate-pyproject 6 | 7 | - repo: https://github.com/PyCQA/isort 8 | rev: 5.13.2 9 | hooks: 10 | - id: isort 11 | language_version: python 12 | 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.3.5 15 | hooks: 16 | - id: ruff 17 | args: ["--fix"] 18 | - id: ruff-format 19 | 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.11.2 22 | hooks: 23 | - id: mypy 24 | language_version: python 25 | # No reason to run if only tests have changed. They intentionally break typing. 26 | exclude: tests/.* 27 | additional_dependencies: 28 | - pydantic~=2.0 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Change Log 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/). 6 | 7 | Note: Minor version `0.X.0` update might break the API, It's recommended to pin geojson-pydantic to minor version: `geojson-pydantic>=0.6,<0.7` 8 | 9 | ## [unreleased] 10 | 11 | ## [2.0.0] - 2025-05-05 12 | 13 | * remove custom `__iter__`, `__getitem__` and `__len__` methods from `GeometryCollection` class **breaking change** 14 | 15 | ```python 16 | from geojson_pydantic.geometries import GeometryCollection, Point, MultiPoint 17 | 18 | geoms = GeometryCollection( 19 | type="GeometryCollection", 20 | geometries=[ 21 | Point(type="Point", coordinates=(102.0, 0.5)), 22 | MultiPoint(type="MultiPoint", coordinates=[(100.0, 0.0), (101.0, 1.0)]), 23 | ], 24 | ) 25 | 26 | ######## 27 | # Before 28 | for geom in geom: # __iter__ 29 | pass 30 | 31 | assert len(geoms) == 2 # __len__ 32 | 33 | _ = geoms[0] # __getitem__ 34 | 35 | ##### 36 | # Now 37 | for geom in geom.iter(): # __iter__ 38 | pass 39 | 40 | assert geoms.length == 2 # __len__ 41 | 42 | _ = geoms.geometries[0] # __getitem__ 43 | ``` 44 | 45 | * remove custom `__iter__`, `__getitem__` and `__len__` methods from `FeatureCollection` class **breaking change** 46 | 47 | ```python 48 | from geojson_pydantic import FeatureCollection, Feature, Point 49 | 50 | fc = FeatureCollection( 51 | type="FeatureCollection", features=[ 52 | Feature(type="Feature", geometry=Point(type="Point", coordinates=(102.0, 0.5)), properties={"name": "point1"}), 53 | Feature(type="Feature", geometry=Point(type="Point", coordinates=(102.0, 1.5)), properties={"name": "point2"}), 54 | ] 55 | ) 56 | 57 | ######## 58 | # Before 59 | for feat in fc: # __iter__ 60 | pass 61 | 62 | assert len(fc) == 2 # __len__ 63 | 64 | _ = fc[0] # __getitem__ 65 | 66 | ##### 67 | # Now 68 | for feat in fc.iter(): # __iter__ 69 | pass 70 | 71 | assert fc.length == 2 # __len__ 72 | 73 | _ = fe.features[0] # __getitem__ 74 | ``` 75 | 76 | * make sure `GeometryCollection` are homogeneous for Z coordinates 77 | 78 | ```python 79 | from geojson_pydantic.geometries import Point, LineString, GeometryCollection 80 | # Before 81 | GeometryCollection( 82 | type="GeometryCollection", 83 | geometries=[ 84 | Point(type="Point", coordinates=[0, 0]), # 2D point 85 | LineString( 86 | type="LineString", coordinates=[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] # 3D LineString 87 | ), 88 | ], 89 | ) 90 | >>> GeometryCollection(bbox=None, type='GeometryCollection', geometries=[Point(bbox=None, type='Point', coordinates=Position3D(longitude=0.0, latitude=0.0, altitude=0.0)), LineString(bbox=None, type='LineString', coordinates=[Position3D(longitude=0.0, latitude=0.0, altitude=0.0), Position3D(longitude=1.0, latitude=1.0, altitude=1.0)])]) 91 | 92 | # Now 93 | GeometryCollection( 94 | type="GeometryCollection", 95 | geometries=[ 96 | Point(type="Point", coordinates=[0, 0]), # 2D point 97 | LineString( 98 | type="LineString", coordinates=[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] # 3D LineString 99 | ), 100 | ], 101 | ) 102 | >>> ValidationError: 1 validation error for GeometryCollection 103 | geometries 104 | Value error, GeometryCollection cannot have mixed Z dimensionality. [type=value_error, input_value=[Point(bbox=None, type='P...de=1.0, altitude=1.0)])], input_type=list] 105 | For further information visit https://errors.pydantic.dev/2.11/v/value_error 106 | ``` 107 | 108 | ## [1.2.0] - 2024-12-19 109 | 110 | * drop python 3.8 support 111 | * add python 3.13 support 112 | 113 | ## [1.1.2] - 2024-10-22 114 | 115 | * relax `bbox` validation and allow antimeridian crossing bboxes 116 | 117 | ## [1.1.1] - 2024-08-29 118 | 119 | * add python 3.12 support 120 | * switch to `flit-core` for packaging build backend 121 | 122 | ## [1.1.0] - 2024-05-10 123 | 124 | ### Added 125 | 126 | * Add Position2D and Position3D of type NamedTuple (author @impocode, https://github.com/developmentseed/geojson-pydantic/pull/161) 127 | 128 | ## [1.0.2] - 2024-01-16 129 | 130 | ### Fixed 131 | 132 | * Temporary workaround for surfacing model attributes in FastAPI application (author @markus-work, https://github.com/developmentseed/geojson-pydantic/pull/153) 133 | 134 | ## [1.0.1] - 2023-10-04 135 | 136 | ### Fixed 137 | 138 | * Model serialization when using include/exclude (ref: https://github.com/developmentseed/geojson-pydantic/pull/148) 139 | 140 | ## [1.0.0] - 2023-07-24 141 | 142 | ### Fixed 143 | 144 | * reduce validation error message verbosity when discriminating Geometry types 145 | * MultiPoint WKT now includes parentheses around each Point 146 | 147 | ### Added 148 | 149 | * more tests for `GeometryCollection` warnings 150 | 151 | ### Changed 152 | 153 | * update pydantic requirement to `~=2.0` 154 | 155 | * update pydantic `FeatureCollection` generic model to allow named features in the generated schemas. 156 | 157 | ```python 158 | # before 159 | FeatureCollection[Geometry, Properties] 160 | 161 | # now 162 | FeatureCollection[Feature[Geometry, Properties]] 163 | ``` 164 | 165 | * raise `ValueError` in `geometries.parse_geometry_obj` instead of `ValidationError` 166 | 167 | ```python 168 | # before 169 | parse_geometry_obj({"type": "This type", "obviously": "doesn't exist"}) 170 | >> ValidationError 171 | 172 | # now 173 | parse_geometry_obj({"type": "This type", "obviously": "doesn't exist"}) 174 | >> ValueError("Unknown type: This type") 175 | ``` 176 | 177 | * update JSON serializer to exclude null `bbox` and `id` 178 | 179 | ```python 180 | # before 181 | Point(type="Point", coordinates=[0, 0]).json() 182 | >> '{"type":"Point","coordinates":[0.0,0.0],"bbox":null}' 183 | 184 | # now 185 | Point(type="Point", coordinates=[0, 0]).model_dump_json() 186 | >> '{"type":"Point","coordinates":[0.0,0.0]}' 187 | ``` 188 | 189 | * delete `geojson_pydantic.geo_interface.GeoInterfaceMixin` and replaced by `geojson_pydantic.base._GeoJsonBase` class 190 | 191 | * delete `geojson_pydantic.types.validate_bbox` 192 | 193 | ## [0.6.3] - 2023-07-02 194 | 195 | * limit pydantic requirement to `~=1.0` 196 | 197 | ## [0.6.2] - 2023-05-16 198 | 199 | ### Added 200 | 201 | * Additional bbox validation (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/122) 202 | 203 | ## [0.6.1] - 2023-05-12 204 | 205 | ### Fixed 206 | 207 | * Fix issue with null bbox validation (author @bmschmidt, https://github.com/developmentseed/geojson-pydantic/pull/119) 208 | 209 | ## [0.6.0] - 2023-05-09 210 | 211 | No change since 0.6.0a0 212 | 213 | ## [0.6.0a0] - 2023-04-04 214 | 215 | ### Added 216 | 217 | - Validate order of bounding box values. (author @moradology, https://github.com/developmentseed/geojson-pydantic/pull/114) 218 | - Enforce required keys and avoid defaults. This aim to follow the geojson specification to the letter. 219 | 220 | ```python 221 | # Before 222 | Feature(geometry=Point(coordinates=(0,0))) 223 | 224 | # Now 225 | Feature( 226 | type="Feature", 227 | geometry=Point( 228 | type="Point", 229 | coordinates=(0,0) 230 | ), 231 | properties=None, 232 | ) 233 | ``` 234 | 235 | - Add has_z function to Geometries (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/103) 236 | - Add optional bbox to geometries. (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/108) 237 | - Add support for nested GeometryCollection and a corresponding warning. (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/111) 238 | 239 | ### Changed 240 | 241 | - Refactor and simplify WKT construction (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/97) 242 | - Support empty geometry coordinates (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/100) 243 | - Refactored `__geo_interface__` to be a Mixin which returns `self.dict` (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/105) 244 | - GeometryCollection containing a single geometry or geometries of only one type will now produce a warning. (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/111) 245 | 246 | ### Fixed 247 | 248 | - Do not validates arbitrary dictionaries. Make `Type` a mandatory key for objects (author @vincentsarago, https://github.com/developmentseed/geojson-pydantic/pull/94) 249 | - Add Geometry discriminator when parsing geometry objects (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/101) 250 | - Mixed Dimensionality WKTs (make sure the coordinates are either all 2D or 3D) (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/107) 251 | - Allow Feature's **id** to be either a String or a Number (author @vincentsarago, https://github.com/developmentseed/geojson-pydantic/pull/91) 252 | 253 | ### Removed 254 | 255 | - Python 3.7 support (author @vincentsarago, https://github.com/developmentseed/geojson-pydantic/pull/94) 256 | - Unused `LinearRing` Model (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/106) 257 | 258 | ## [0.5.0] - 2022-12-16 259 | 260 | ### Added 261 | 262 | - python 3.11 support 263 | 264 | ### Fixed 265 | 266 | - Derive WKT type from Geometry's type instead of class name (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/81) 267 | 268 | ### Changed 269 | 270 | - Replace `NumType` with `float` throughout (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/83) 271 | - `__geo_interface__` definition to not use pydantic `BaseModel.dict()` method and better match the specification 272 | - Adjusted mypy configuration and updated type definitions to satisfy all rules (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/87) 273 | - Updated pre-commit config to run mypy on the whole library instead of individual changed files. 274 | - Defaults are more explicit. This keeps pyright from thinking they are required. 275 | 276 | ### Removed 277 | 278 | - Remove `validate` classmethods used to implicitly load json strings (author @eseglem, https://github.com/developmentseed/geojson-pydantic/pull/88) 279 | 280 | ## [0.4.3] - 2022-07-18 281 | 282 | ### Fixed 283 | 284 | - The bbox key should not be in a `__geo_interface__` object if the bbox is None (author @yellowcap, https://github.com/developmentseed/geojson-pydantic/pull/77) 285 | 286 | ## [0.4.2] - 2022-06-13 287 | 288 | ### Added 289 | 290 | - `GeometryCollection` as optional input to geometry field in `Feature` (author @davidraleigh, https://github.com/developmentseed/geojson-pydantic/pull/72) 291 | 292 | ## [0.4.1] - 2022-06-10 293 | 294 | ### Added 295 | 296 | - `Geometry` and `GeometryCollection` validation from dict or string (author @Vikka, https://github.com/developmentseed/geojson-pydantic/pull/69) 297 | 298 | ```python 299 | Point.validate('{"coordinates": [1.0, 2.0], "type": "Point"}') 300 | >> Point(coordinates=(1.0, 2.0), type='Point') 301 | ``` 302 | 303 | - `Feature` and `FeatureCollection` validation from dict or string 304 | 305 | ```python 306 | FeatureCollection.validate('{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"coordinates": [1.0, 2.0], "type": "Point"}}]}') 307 | >> FeatureCollection(type='FeatureCollection', features=[Feature(type='Feature', geometry=Point(coordinates=(1.0, 2.0), type='Point'), properties=None, id=None, bbox=None)], bbox=None) 308 | ``` 309 | 310 | ## [0.4.0] - 2022-06-03 311 | 312 | ### Added 313 | - `.wkt` property for Geometry object 314 | ```python 315 | from geojson_pydantic.geometries import Point 316 | 317 | Point(coordinates=(1, 2)).wkt 318 | >> 'POINT (1.0 2.0)' 319 | ``` 320 | 321 | - `.exterior` and `.interiors` properties for `geojson_pydantic.geometries.Polygon` object. 322 | ```python 323 | from geojson_pydantic.geometries import Polygon 324 | polygon = Polygon( 325 | coordinates=[ 326 | [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)], 327 | [(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)], 328 | ] 329 | ) 330 | polygon.exterior 331 | >> [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0), (0.0, 0.0)] 332 | 333 | list(polygon.interiors) 334 | >> [[(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0)]] 335 | ``` 336 | 337 | - `__geo_interface__` to `geojson_pydantic.geometries.GeometryCollection` object 338 | - `__geo_interface__` to `geojson_pydantic.feature.Feature` and `geojson_pydantic.feature.FeatureCollection` object 339 | - `geojson_pydantic.__all__` to declaring public objects (author @farridav, https://github.com/developmentseed/geojson-pydantic/pull/52) 340 | 341 | ### Changed 342 | - switch to `pyproject.toml` 343 | - rename `geojson_pydantic.version` to `geojson_pydantic.__version__` 344 | 345 | ### Fixed 346 | - changelog compare links 347 | 348 | ## [0.3.4] - 2022-04-28 349 | 350 | - Fix optional geometry and bbox fields on `Feature`; allowing users to pass in `None` or even omit either field (author @moradology, https://github.com/developmentseed/geojson-pydantic/pull/56) 351 | - Fix `Polygon.from_bounds` to respect geojson specification and return counterclockwise linear ring (author @jmfee-usgs, https://github.com/developmentseed/geojson-pydantic/pull/49) 352 | 353 | ## [0.3.3] - 2022-03-04 354 | 355 | - Follow geojson specification and make feature geometry optional (author @yellowcap, https://github.com/developmentseed/geojson-pydantic/pull/47) 356 | ```python 357 | from geojson_pydantic import Feature 358 | # Before 359 | feature = Feature(type="Feature", geometry=None, properties={}) 360 | 361 | >> ValidationError: 1 validation error for Feature 362 | geometry none is not an allowed value (type=type_error.none.not_allowed) 363 | 364 | # Now 365 | feature = Feature(type="Feature", geometry=None, properties={}) 366 | assert feature.geometry is None 367 | ``` 368 | 369 | ## [0.3.2] - 2022-02-21 370 | 371 | - fix `parse_geometry_obj` potential bug (author @geospatial-jeff, https://github.com/developmentseed/geojson-pydantic/pull/45) 372 | - improve type definition (and validation) for geometry coordinate arrays (author @geospatial-jeff, https://github.com/developmentseed/geojson-pydantic/pull/44) 373 | 374 | ## [0.3.1] - 2021-08-04 375 | 376 | ### Added 377 | - `Polygon.from_bounds` class method to create a Polygon geometry from a bounding box. 378 | ```python 379 | from geojson_pydantic import Polygon 380 | print(Polygon.from_bounds((1, 2, 3, 4)).dict(exclude_none=True)) 381 | >> {'coordinates': [[(1.0, 2.0), (1.0, 4.0), (3.0, 4.0), (3.0, 2.0), (1.0, 2.0)]], 'type': 'Polygon'} 382 | ``` 383 | 384 | ### Fixed 385 | - Added validation for Polygons with zero size. 386 | 387 | 388 | ## [0.3.0] - 2021-05-25 389 | 390 | ### Added 391 | - `Feature` and `FeatureCollection` model generics to support custom geometry and/or properties validation (author @iwpnd, https://github.com/developmentseed/geojson-pydantic/pull/29) 392 | 393 | ```python 394 | from pydantic import BaseModel 395 | from geojson_pydantic.features import Feature 396 | from geojson_pydantic.geometries import Polygon 397 | 398 | class MyFeatureProperties(BaseModel): 399 | name: str 400 | value: int 401 | 402 | feature = Feature[Polygon, MyFeatureProperties]( 403 | **{ 404 | "type": "Feature", 405 | "geometry": { 406 | "type": "Polygon", 407 | "coordinates": [ 408 | [ 409 | [13.38272,52.46385], 410 | [13.42786,52.46385], 411 | [13.42786,52.48445], 412 | [13.38272,52.48445], 413 | [13.38272,52.46385] 414 | ] 415 | ] 416 | }, 417 | "properties": { 418 | "name": "test", 419 | "value": 1 420 | } 421 | } 422 | ) 423 | ``` 424 | 425 | - Top level export (https://github.com/developmentseed/geojson-pydantic/pull/34) 426 | 427 | ```python 428 | # before 429 | from geojson_pydantic.features import Feature, FeatureCollection 430 | from geojson_pydantic.geometries import Polygon 431 | 432 | # now 433 | from geojson_pydantic import Feature, Polygon 434 | ``` 435 | 436 | ### Removed 437 | - Drop python 3.6 support 438 | - Renamed `utils.py` to `types.py` 439 | - Removed `Coordinate` type in `geojson_pydantic.features` (replaced by `Position`) 440 | 441 | ## [0.2.3] - 2021-05-05 442 | 443 | ### Fixed 444 | - incorrect version number set in `__init__.py` 445 | 446 | ## [0.2.2] - 2020-12-29 447 | 448 | ### Added 449 | - Made collections iterable (#12) 450 | - Added `parse_geometry_obj` function (#9) 451 | 452 | ## [0.2.1] - 2020-08-07 453 | 454 | Although the type file was added in `0.2.0` it wasn't included in the distributed package. Use this version `0.2.1` for type annotations. 455 | 456 | ### Fixed 457 | - Correct package type information files 458 | 459 | ## [0.2.0] - 2020-08-06 460 | 461 | ### Added 462 | - Added documentation on locally running tests (#3) 463 | - Publish type information (#6) 464 | 465 | ### Changed 466 | - Removed geojson dependency (#4) 467 | 468 | ### Fixed 469 | - Include MultiPoint as a valid geometry for a Feature (#1) 470 | 471 | ## [0.1.0] - 2020-05-21 472 | 473 | ### Added 474 | - Initial Release 475 | 476 | [unreleased]: https://github.com/developmentseed/geojson-pydantic/compare/2.0.0...HEAD 477 | [2.0.0]: https://github.com/developmentseed/geojson-pydantic/compare/1.2.0...2.0.0 478 | [1.2.0]: https://github.com/developmentseed/geojson-pydantic/compare/1.1.2...1.2.0 479 | [1.1.2]: https://github.com/developmentseed/geojson-pydantic/compare/1.1.1...1.1.2 480 | [1.1.1]: https://github.com/developmentseed/geojson-pydantic/compare/1.1.0...1.1.1 481 | [1.1.0]: https://github.com/developmentseed/geojson-pydantic/compare/1.0.2...1.1.0 482 | [1.0.2]: https://github.com/developmentseed/geojson-pydantic/compare/1.0.1...1.0.2 483 | [1.0.1]: https://github.com/developmentseed/geojson-pydantic/compare/1.0.0...1.0.1 484 | [1.0.0]: https://github.com/developmentseed/geojson-pydantic/compare/0.6.3...1.0.0 485 | [0.6.3]: https://github.com/developmentseed/geojson-pydantic/compare/0.6.2...0.6.3 486 | [0.6.2]: https://github.com/developmentseed/geojson-pydantic/compare/0.6.1...0.6.2 487 | [0.6.1]: https://github.com/developmentseed/geojson-pydantic/compare/0.6.0...0.6.1 488 | [0.6.0]: https://github.com/developmentseed/geojson-pydantic/compare/0.6.0a0...0.6.0 489 | [0.6.0a]: https://github.com/developmentseed/geojson-pydantic/compare/0.5.0...0.6.0a0 490 | [0.5.0]: https://github.com/developmentseed/geojson-pydantic/compare/0.4.3...0.5.0 491 | [0.4.3]: https://github.com/developmentseed/geojson-pydantic/compare/0.4.2...0.4.3 492 | [0.4.2]: https://github.com/developmentseed/geojson-pydantic/compare/0.4.1...0.4.2 493 | [0.4.1]: https://github.com/developmentseed/geojson-pydantic/compare/0.4.0...0.4.1 494 | [0.4.0]: https://github.com/developmentseed/geojson-pydantic/compare/0.3.4...0.4.0 495 | [0.3.4]: https://github.com/developmentseed/geojson-pydantic/compare/0.3.3...0.3.4 496 | [0.3.3]: https://github.com/developmentseed/geojson-pydantic/compare/0.3.2...0.3.3 497 | [0.3.2]: https://github.com/developmentseed/geojson-pydantic/compare/0.3.1...0.3.2 498 | [0.3.1]: https://github.com/developmentseed/geojson-pydantic/compare/0.3.0...0.3.1 499 | [0.3.0]: https://github.com/developmentseed/geojson-pydantic/compare/0.2.3...0.3.0 500 | [0.2.3]: https://github.com/developmentseed/geojson-pydantic/compare/0.2.2...0.2.3 501 | [0.2.2]: https://github.com/developmentseed/geojson-pydantic/compare/0.2.1...0.2.2 502 | [0.2.1]: https://github.com/developmentseed/geojson-pydantic/compare/0.2.0...0.2.1 503 | [0.2.0]: https://github.com/developmentseed/geojson-pydantic/compare/0.1.0...0.2.0 504 | [0.1.0]: https://github.com/developmentseed/geojson-pydantic/compare/005f3e57ad07272c99c54302decc63eec12175c9...0.1.0 505 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To run the tests, first install the package in a virtual environment: 4 | 5 | ```sh 6 | virtualenv venv 7 | source venv/bin/activate 8 | python -m pip install -e '.[test]' 9 | ``` 10 | 11 | You can then run the tests with the following command: 12 | 13 | ```sh 14 | python -m pytest --cov geojson_pydantic --cov-report term-missing 15 | ``` 16 | 17 | This repo is set to use pre-commit to run `isort`, `flake8`, `pydocstring`, `black` ("uncompromising Python code formatter") and `mypy` when committing new code. 18 | 19 | ``` sh 20 | pre-commit install 21 | ``` 22 | 23 | 24 | ## Release 25 | 26 | we use https://github.com/c4urself/bump2version to update the package version. 27 | 28 | ``` 29 | # Install bump2version 30 | $ pip install --upgrade bump2version 31 | 32 | # Update version (edit files, commit and create tag) 33 | # this will do `0.2.1 -> 0.2.2` because we use the `patch` tag 34 | $ bump2version patch 35 | 36 | # Push change and tag to github 37 | $ git push origin main --tags 38 | ``` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Development Seed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # geojson-pydantic 2 | 3 |

4 | Pydantic models for GeoJSON. 5 |

6 |

7 | 8 | Test 9 | 10 | 11 | Coverage 12 | 13 | 14 | Package version 15 | 16 | 17 | Downloads 18 | 19 | 20 | License 21 | 22 | 23 | Conda 24 | 25 |

26 | 27 | --- 28 | 29 | **Documentation**: https://developmentseed.org/geojson-pydantic/ 30 | 31 | **Source Code**: https://github.com/developmentseed/geojson-pydantic 32 | 33 | --- 34 | 35 | ## Description 36 | 37 | `geojson_pydantic` provides a suite of Pydantic models matching the [GeoJSON specification rfc7946](https://datatracker.ietf.org/doc/html/rfc7946). Those models can be used for creating or validating geojson data. 38 | 39 | ## Install 40 | 41 | ```bash 42 | $ python -m pip install -U pip 43 | $ python -m pip install geojson-pydantic 44 | ``` 45 | 46 | Or install from source: 47 | 48 | ```bash 49 | $ python -m pip install -U pip 50 | $ python -m pip install git+https://github.com/developmentseed/geojson-pydantic.git 51 | ``` 52 | 53 | Install with conda from [`conda-forge`](https://anaconda.org/conda-forge/geojson-pydantic): 54 | 55 | ```bash 56 | $ conda install -c conda-forge geojson-pydantic 57 | ``` 58 | 59 | ## Contributing 60 | 61 | See [CONTRIBUTING.md](https://github.com/developmentseed/geojson-pydantic/blob/main/CONTRIBUTING.md). 62 | 63 | ## Changes 64 | 65 | See [CHANGES.md](https://github.com/developmentseed/geojson-pydantic/blob/main/CHANGELOG.md). 66 | 67 | ## Authors 68 | 69 | Initial implementation by @geospatial-jeff; taken liberally from https://github.com/arturo-ai/stac-pydantic/ 70 | 71 | See [contributors](hhttps://github.com/developmentseed/geojson-pydantic/graphs/contributors) for a listing of individual contributors. 72 | 73 | ## License 74 | 75 | See [LICENSE](https://github.com/developmentseed/geojson-pydantic/blob/main/LICENSE) 76 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project Information 2 | site_name: 'geojson-pydantic' 3 | site_description: 'Pydantic models for GeoJSON' 4 | 5 | docs_dir: 'src' 6 | site_dir: 'build' 7 | 8 | # Repository 9 | repo_name: 'developmentseed/geojson-pydantic' 10 | repo_url: 'https://github.com/developmentseed/geojson-pydantic' 11 | edit_uri: 'blob/main/src/' 12 | site_url: 'https://developmentseed.org/geojson-pydantic/' 13 | 14 | # Social links 15 | extra: 16 | social: 17 | - icon: 'fontawesome/brands/github' 18 | link: 'https://github.com/developmentseed' 19 | - icon: 'fontawesome/brands/twitter' 20 | link: 'https://twitter.com/developmentseed' 21 | 22 | # Layout 23 | nav: 24 | - Home: 'index.md' 25 | - Intro: 'intro.md' 26 | - Migration guides: 27 | - v0.6 -> v1.0: migrations/v1.0_migration.md 28 | - Development - Contributing: 'contributing.md' 29 | - Release: 'release-notes.md' 30 | 31 | # Theme 32 | theme: 33 | icon: 34 | logo: 'material/home' 35 | repo: 'fontawesome/brands/github' 36 | name: 'material' 37 | language: 'en' 38 | palette: 39 | primary: 'pink' 40 | accent: 'light pink' 41 | font: 42 | text: 'Nunito Sans' 43 | code: 'Fira Code' 44 | 45 | plugins: 46 | - search 47 | 48 | # These extensions are chosen to be a superset of Pandoc's Markdown. 49 | # This way, I can write in Pandoc's Markdown and have it be supported here. 50 | # https://pandoc.org/MANUAL.html 51 | markdown_extensions: 52 | - admonition 53 | - attr_list 54 | - codehilite: 55 | guess_lang: false 56 | - def_list 57 | - footnotes 58 | - pymdownx.arithmatex 59 | - pymdownx.betterem 60 | - pymdownx.caret: 61 | insert: false 62 | - pymdownx.details 63 | - pymdownx.emoji 64 | - pymdownx.escapeall: 65 | hardbreak: true 66 | nbsp: true 67 | - pymdownx.magiclink: 68 | hide_protocol: true 69 | repo_url_shortener: true 70 | - pymdownx.smartsymbols 71 | - pymdownx.superfences 72 | - pymdownx.tasklist: 73 | custom_checkbox: true 74 | - pymdownx.tilde 75 | - toc: 76 | permalink: true 77 | -------------------------------------------------------------------------------- /docs/src/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/src/intro.md: -------------------------------------------------------------------------------- 1 | 2 | ## Usage 3 | 4 | ```python 5 | from geojson_pydantic import Feature, FeatureCollection, Point 6 | 7 | geojson_feature = { 8 | "type": "Feature", 9 | "geometry": { 10 | "type": "Point", 11 | "coordinates": [13.38272, 52.46385], 12 | }, 13 | "properties": { 14 | "name": "jeff", 15 | }, 16 | } 17 | 18 | 19 | feat = Feature(**geojson_feature) 20 | assert feat.type == "Feature" 21 | assert type(feat.geometry) == Point 22 | assert feat.properties["name"] == "jeff" 23 | 24 | fc = FeatureCollection(type="FeatureCollection", features=[geojson_feature, geojson_feature]) 25 | assert fc.type == "FeatureCollection" 26 | assert len(fc.features) == 2 27 | assert type(fc.features[0].geometry) == Point 28 | assert fc.features[0].properties["name"] == "jeff" 29 | ``` 30 | 31 | ## Geometry Model methods and properties 32 | 33 | - `__geo_interface__`: GeoJSON-like protocol for geo-spatial (GIS) vector data ([spec](https://gist.github.com/sgillies/2217756#__geo_interface)). 34 | - `has_z`: returns true if any coordinate has a Z value. 35 | - `wkt`: returns the Well Known Text representation of the geometry. 36 | 37 | ##### For Polygon geometry 38 | 39 | - `exterior`: returns the exterior Linear Ring of the polygon. 40 | - `interiors`: returns the interiors (Holes) of the polygon. 41 | - `Polygon.from_bounds(xmin, ymin, xmax, ymax)`: creates a Polygon geometry from a bounding box. 42 | 43 | ##### For GeometryCollection and FeatureCollection 44 | 45 | - `iter()`: iterates over geometries or features 46 | - `length`: returns geometries or features count 47 | 48 | ## Advanced usage 49 | 50 | In `geojson_pydantic` we've implemented pydantic's [Generic Models](https://pydantic-docs.helpmanual.io/usage/models/#generic-models) which allow the creation of more advanced models to validate either the geometry type or the properties. 51 | 52 | In order to make use of this generic typing, there are two steps: first create a new model, then use that model to validate your data. To create a model using a `Generic` type, you **HAVE TO** pass `Type definitions` to the `Feature` model in form of `Feature[Geometry Type, Properties Type]`. Then pass your data to this constructor. 53 | 54 | By default `Feature` and `FeatureCollections` are defined using `geojson_pydantic.geometries.Geometry` for the geometry and `typing.Dict` for the properties. 55 | 56 | Here's an example where we want to validate that GeoJSON features have Polygon types, but don't do any specific property validation. 57 | 58 | ```python 59 | from typing import Dict 60 | 61 | from geojson_pydantic import Feature, Polygon 62 | from pydantic import BaseModel 63 | 64 | geojson_feature = { 65 | "type": "Feature", 66 | "geometry": { 67 | "type": "Point", 68 | "coordinates": [13.38272, 52.46385], 69 | }, 70 | "properties": { 71 | "name": "jeff", 72 | }, 73 | } 74 | 75 | # Define a Feature model with Geometry as `Polygon` and Properties as `Dict` 76 | MyPolygonFeatureModel = Feature[Polygon, Dict] 77 | 78 | feat = MyPolygonFeatureModel(**geojson_feature) # should raise Validation Error because `geojson_feature` is a point 79 | >>> ValidationError: 3 validation errors for Feature[Polygon, Dict] 80 | ... 81 | geometry -> type 82 | unexpected value; permitted: 'Polygon' (type=value_error.const; given=Point; permitted=['Polygon']) 83 | 84 | 85 | geojson_feature = { 86 | "type": "Feature", 87 | "geometry": { 88 | "type": "Polygon", 89 | "coordinates": [ 90 | [ 91 | [13.38272, 52.46385], 92 | [13.42786, 52.46385], 93 | [13.42786, 52.48445], 94 | [13.38272, 52.48445], 95 | [13.38272, 52.46385], 96 | ] 97 | ], 98 | }, 99 | "properties": { 100 | "name": "jeff", 101 | }, 102 | } 103 | 104 | feat = MyPolygonFeatureModel(**geojson_feature) 105 | assert type(feat.geometry) == Polygon 106 | ``` 107 | 108 | Or with optional geometry 109 | 110 | ```python 111 | from geojson_pydantic import Feature, Point 112 | from typing import Optional 113 | 114 | MyPointFeatureModel = Feature[Optional[Point], Dict] 115 | 116 | assert MyPointFeatureModel(type="Feature", geometry=None, properties={}).geometry is None 117 | assert MyPointFeatureModel(type="Feature", geometry=Point(type="Point", coordinates=(0,0)), properties={}).geometry is not None 118 | ``` 119 | 120 | And now with constrained properties 121 | 122 | ```python 123 | from typing_extensions import Annotated 124 | from geojson_pydantic import Feature, Point 125 | from pydantic import BaseModel 126 | 127 | # Define a Feature model with Geometry as `Point` and Properties as a constrained Model 128 | class MyProps(BaseModel): 129 | name: Annotated[str, Field(pattern=r"^(drew|vincent)$")] 130 | 131 | MyPointFeatureModel = Feature[Point, MyProps] 132 | 133 | geojson_feature = { 134 | "type": "Feature", 135 | "geometry": { 136 | "type": "Point", 137 | "coordinates": [13.38272, 52.46385], 138 | }, 139 | "properties": { 140 | "name": "jeff", 141 | }, 142 | } 143 | 144 | feat = MyPointFeatureModel(**geojson_feature) 145 | >>> ValidationError: 1 validation error for Feature[Point, MyProps] 146 | properties -> name 147 | string does not match regex "^(drew|vincent)$" (type=value_error.str.regex; pattern=^(drew|vincent)$) 148 | 149 | geojson_feature["properties"]["name"] = "drew" 150 | feat = MyPointFeatureModel(**geojson_feature) 151 | assert feat.properties.name == "drew" 152 | ``` 153 | 154 | ## Enforced Keys 155 | 156 | Starting with version `0.6.0`, geojson-pydantic's classes will not define default keys such has `type`, `geometry` or `properties`. 157 | This is to make sure the library does well its first goal, which is `validating` GeoJSON object based on the [specification](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1) 158 | 159 | o A GeoJSON object has a member with the name "type". The value of 160 | the member MUST be one of the GeoJSON types. 161 | 162 | o A Feature object HAS a "type" member with the value "Feature". 163 | 164 | o A Feature object HAS a member with the name "geometry". The value 165 | of the geometry member SHALL be either a Geometry object as 166 | defined above or, in the case that the Feature is unlocated, a 167 | JSON null value. 168 | 169 | o A Feature object HAS a member with the name "properties". The 170 | value of the properties member is an object (any JSON object or a 171 | JSON null value). 172 | 173 | 174 | ```python 175 | from geojson_pydantic import Point 176 | 177 | ## Before 0.6 178 | Point(coordinates=(0,0)) 179 | >> Point(type='Point', coordinates=(0.0, 0.0), bbox=None) 180 | 181 | ## After 0.6 182 | Point(coordinates=(0,0)) 183 | >> ValidationError: 1 validation error for Point 184 | type 185 | field required (type=value_error.missing) 186 | 187 | Point(type="Point", coordinates=(0,0)) 188 | >> Point(type='Point', coordinates=(0.0, 0.0), bbox=None) 189 | ``` 190 | -------------------------------------------------------------------------------- /docs/src/migrations/v1.0_migration.md: -------------------------------------------------------------------------------- 1 | `geojson-pydantic` version 1.0 introduced [many breaking changes](../release-notes.md). This 2 | document aims to help with migrating your code to use `geojson-pydantic` 1.0. 3 | 4 | 5 | ## Pydantic 2.0 6 | 7 | The biggest update introduced in **1.0** is the new pydantic *major* version requirement [**~2.0**](https://docs.pydantic.dev/2.0/migration/). 8 | 9 | In addition to being faster, this new major version has plenty of new API which we used in `geojson-pydantic` (like the new `model_serializer` method). 10 | 11 | ```python 12 | 13 | from geojson_pydantic import Point 14 | 15 | # Before 16 | Point.dict() # serialize to dict object 17 | Point.json() # serialize to json string 18 | 19 | with open("point.geojson") as f: 20 | Point.parse_file(f) # parse file content to model 21 | 22 | p = {} 23 | Point.parse_obj(obj) # parse dict object 24 | 25 | ################## 26 | # Now (geojson-pydantic ~= 1.0) 27 | 28 | Point.model_dump() 29 | Point.model_dump_json() 30 | 31 | with open("point.geojson") as f: 32 | Point.model_validate_json(f.read()) 33 | 34 | p = {} 35 | Point.model_validate(obj) 36 | ``` 37 | 38 | ref: https://github.com/developmentseed/geojson-pydantic/pull/130 39 | 40 | ## FeatureCollection Generic model 41 | 42 | In **1.0** we updated the generic FeatureCollection model to depends only on a generic Feature model. 43 | 44 | ```python 45 | # Before 46 | FeatureCollection[Geometry, Properties] 47 | 48 | # Now (geojson-pydantic ~= 1.0) 49 | FeatureCollection[Feature[Geometry, Properties]] 50 | ``` 51 | 52 | e.g 53 | 54 | ```python 55 | from pydantic import BaseModel 56 | from geojson_pydantic import Feature, FeatureCollection, Polygon 57 | 58 | class CustomProperties(BaseModel): 59 | id: str 60 | description: str 61 | size: int 62 | 63 | # Create a new FeatureCollection Model which should only 64 | # Accept Features with Polygon geometry type and matching the properties 65 | MyFc = FeatureCollection[Feature[Polygon, CustomProperties]] 66 | ``` 67 | 68 | ref: https://github.com/developmentseed/geojson-pydantic/issues/134 69 | 70 | ## Exclude `bbox` and `id` if null 71 | 72 | Using the new pydantic `model_serializer` method, we are now able to `customize` JSON output for the models to better match the GeoJSON spec 73 | 74 | ```python 75 | # Before 76 | Point(type="Point", coordinates=[0, 0]).json() 77 | >> '{"type":"Point","coordinates":[0.0,0.0],"bbox":null}' 78 | 79 | # Now (geojson-pydantic ~= 1.0) 80 | Point(type="Point", coordinates=[0, 0]).model_dump_json() 81 | >> '{"type":"Point","coordinates":[0.0,0.0]}' 82 | ``` 83 | 84 | ref: https://github.com/developmentseed/geojson-pydantic/issues/125 85 | 86 | 87 | ## Change in WKT output for Multi* geometries 88 | 89 | ```python 90 | from geojson_pydantic import MultiPoint 91 | 92 | geom = MultiPoint(type='MultiPoint', coordinates=[(1.0, 2.0, 3.0)]) 93 | 94 | # Before 95 | print(geom.wkt) 96 | >> MULTIPOINT Z (1 2 3) 97 | 98 | # Now (geojson-pydantic ~= 1.0) 99 | print(geom.wkt) 100 | >> MULTIPOINT Z ((1 2 3)) 101 | ``` 102 | 103 | ref: https://github.com/developmentseed/geojson-pydantic/issues/139 104 | -------------------------------------------------------------------------------- /docs/src/release-notes.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /geojson_pydantic/__init__.py: -------------------------------------------------------------------------------- 1 | """geojson-pydantic.""" 2 | 3 | from .features import Feature, FeatureCollection # noqa 4 | from .geometries import ( # noqa 5 | GeometryCollection, 6 | LineString, 7 | MultiLineString, 8 | MultiPoint, 9 | MultiPolygon, 10 | Point, 11 | Polygon, 12 | ) 13 | 14 | __version__ = "2.0.0" 15 | 16 | __all__ = [ 17 | "Feature", 18 | "FeatureCollection", 19 | "GeometryCollection", 20 | "LineString", 21 | "MultiLineString", 22 | "MultiPoint", 23 | "MultiPolygon", 24 | "Point", 25 | "Polygon", 26 | ] 27 | -------------------------------------------------------------------------------- /geojson_pydantic/base.py: -------------------------------------------------------------------------------- 1 | """pydantic BaseModel for GeoJSON objects.""" 2 | 3 | from __future__ import annotations 4 | 5 | import warnings 6 | from typing import Any, Dict, List, Optional, Set 7 | 8 | from pydantic import BaseModel, SerializationInfo, field_validator, model_serializer 9 | 10 | from geojson_pydantic.types import BBox 11 | 12 | 13 | class _GeoJsonBase(BaseModel): 14 | bbox: Optional[BBox] = None 15 | 16 | # These fields will not be included when serializing in json mode 17 | # `.model_dump_json()` or `.model_dump(mode="json")` 18 | __geojson_exclude_if_none__: Set[str] = {"bbox"} 19 | 20 | @property 21 | def __geo_interface__(self) -> Dict[str, Any]: 22 | """GeoJSON-like protocol for geo-spatial (GIS) vector data. 23 | 24 | ref: https://gist.github.com/sgillies/2217756#__geo_interface 25 | """ 26 | return self.model_dump(mode="json") 27 | 28 | @field_validator("bbox") 29 | def validate_bbox(cls, bbox: Optional[BBox]) -> Optional[BBox]: 30 | """Validate BBox values are ordered correctly.""" 31 | # If bbox is None, there is nothing to validate. 32 | if bbox is None: 33 | return None 34 | 35 | # A list to store any errors found so we can raise them all at once. 36 | errors: List[str] = [] 37 | 38 | # Determine where the second position starts. 2 for 2D, 3 for 3D. 39 | offset = len(bbox) // 2 40 | 41 | # Check X 42 | if bbox[0] > bbox[offset]: 43 | warnings.warn( 44 | f"BBOX crossing the Antimeridian line, Min X ({bbox[0]}) > Max X ({bbox[offset]}).", 45 | UserWarning, 46 | stacklevel=1, 47 | ) 48 | 49 | # Check Y 50 | if bbox[1] > bbox[1 + offset]: 51 | errors.append(f"Min Y ({bbox[1]}) must be <= Max Y ({bbox[1 + offset]}).") 52 | 53 | # If 3D, check Z values. 54 | if offset > 2 and bbox[2] > bbox[2 + offset]: 55 | errors.append(f"Min Z ({bbox[2]}) must be <= Max Z ({bbox[2 + offset]}).") 56 | 57 | # Raise any errors found. 58 | if errors: 59 | raise ValueError("Invalid BBox. Error(s): " + " ".join(errors)) 60 | 61 | return bbox 62 | 63 | # This return is untyped due to a workaround until this issue is resolved: 64 | # https://github.com/tiangolo/fastapi/discussions/10661 65 | @model_serializer(when_used="always", mode="wrap") 66 | def clean_model(self, serializer: Any, info: SerializationInfo): # type: ignore [no-untyped-def] 67 | """Custom Model serializer to match the GeoJSON specification. 68 | 69 | Used to remove fields which are optional but cannot be null values. 70 | """ 71 | # This seems like the best way to have the least amount of unexpected consequences. 72 | # We want to avoid forcing values in `exclude_none` or `exclude_unset` which could 73 | # cause issues or unexpected behavior for downstream users. 74 | # ref: https://github.com/pydantic/pydantic/issues/6575 75 | data: Dict[str, Any] = serializer(self) 76 | 77 | # Only remove fields when in JSON mode. 78 | if info.mode_is_json(): 79 | for field in self.__geojson_exclude_if_none__: 80 | if field in data and data[field] is None: 81 | del data[field] 82 | 83 | return data 84 | -------------------------------------------------------------------------------- /geojson_pydantic/features.py: -------------------------------------------------------------------------------- 1 | """pydantic models for GeoJSON Feature objects.""" 2 | 3 | from typing import Any, Dict, Generic, Iterator, List, Literal, Optional, TypeVar, Union 4 | 5 | from pydantic import BaseModel, Field, StrictInt, StrictStr, field_validator 6 | 7 | from geojson_pydantic.base import _GeoJsonBase 8 | from geojson_pydantic.geometries import Geometry 9 | 10 | Props = TypeVar("Props", bound=Union[Dict[str, Any], BaseModel]) 11 | Geom = TypeVar("Geom", bound=Geometry) 12 | 13 | 14 | class Feature(_GeoJsonBase, Generic[Geom, Props]): 15 | """Feature Model""" 16 | 17 | type: Literal["Feature"] 18 | geometry: Union[Geom, None] = Field(...) 19 | properties: Union[Props, None] = Field(...) 20 | id: Optional[Union[StrictInt, StrictStr]] = None 21 | 22 | __geojson_exclude_if_none__ = {"bbox", "id"} 23 | 24 | @field_validator("geometry", mode="before") 25 | def set_geometry(cls, geometry: Any) -> Any: 26 | """set geometry from geo interface or input""" 27 | if hasattr(geometry, "__geo_interface__"): 28 | return geometry.__geo_interface__ 29 | 30 | return geometry 31 | 32 | 33 | Feat = TypeVar("Feat", bound=Feature) 34 | 35 | 36 | class FeatureCollection(_GeoJsonBase, Generic[Feat]): 37 | """FeatureCollection Model""" 38 | 39 | type: Literal["FeatureCollection"] 40 | features: List[Feat] 41 | 42 | def iter(self) -> Iterator[Feat]: 43 | """iterate over features""" 44 | return iter(self.features) 45 | 46 | @property 47 | def length(self) -> int: 48 | """return features length""" 49 | return len(self.features) 50 | -------------------------------------------------------------------------------- /geojson_pydantic/geometries.py: -------------------------------------------------------------------------------- 1 | """pydantic models for GeoJSON Geometry objects.""" 2 | 3 | from __future__ import annotations 4 | 5 | import abc 6 | import warnings 7 | from typing import Any, Iterator, List, Literal, Union 8 | 9 | from pydantic import Field, field_validator 10 | from typing_extensions import Annotated 11 | 12 | from geojson_pydantic.base import _GeoJsonBase 13 | from geojson_pydantic.types import ( 14 | LinearRing, 15 | LineStringCoords, 16 | MultiLineStringCoords, 17 | MultiPointCoords, 18 | MultiPolygonCoords, 19 | PolygonCoords, 20 | Position, 21 | ) 22 | 23 | 24 | def _position_wkt_coordinates(coordinates: Position, force_z: bool = False) -> str: 25 | """Converts a Position to WKT Coordinates.""" 26 | wkt_coordinates = " ".join(str(number) for number in coordinates) 27 | if force_z and len(coordinates) < 3: 28 | wkt_coordinates += " 0.0" 29 | return wkt_coordinates 30 | 31 | 32 | def _position_has_z(position: Position) -> bool: 33 | return len(position) == 3 34 | 35 | 36 | def _position_list_wkt_coordinates( 37 | coordinates: List[Position], force_z: bool = False 38 | ) -> str: 39 | """Converts a list of Positions to WKT Coordinates.""" 40 | return ", ".join( 41 | _position_wkt_coordinates(position, force_z) for position in coordinates 42 | ) 43 | 44 | 45 | def _position_list_has_z(positions: List[Position]) -> bool: 46 | """Checks if any position in a list has a Z.""" 47 | return any(_position_has_z(position) for position in positions) 48 | 49 | 50 | def _lines_wtk_coordinates( 51 | coordinates: List[LineStringCoords], force_z: bool = False 52 | ) -> str: 53 | """Converts lines to WKT Coordinates.""" 54 | return ", ".join( 55 | f"({_position_list_wkt_coordinates(line, force_z)})" for line in coordinates 56 | ) 57 | 58 | 59 | def _lines_has_z(lines: List[LineStringCoords]) -> bool: 60 | """Checks if any position in a list has a Z.""" 61 | return any( 62 | _position_has_z(position) for positions in lines for position in positions 63 | ) 64 | 65 | 66 | def _polygons_wkt_coordinates( 67 | coordinates: List[PolygonCoords], force_z: bool = False 68 | ) -> str: 69 | return ", ".join( 70 | f"({_lines_wtk_coordinates(polygon, force_z)})" for polygon in coordinates 71 | ) 72 | 73 | 74 | class _GeometryBase(_GeoJsonBase, abc.ABC): 75 | """Base class for geometry models""" 76 | 77 | type: str 78 | coordinates: Any 79 | 80 | @abc.abstractmethod 81 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 82 | """return WKT coordinates.""" 83 | ... 84 | 85 | @property 86 | @abc.abstractmethod 87 | def has_z(self) -> bool: 88 | """Checks if any coordinate has a Z value.""" 89 | ... 90 | 91 | @property 92 | def wkt(self) -> str: 93 | """Return the Well Known Text representation.""" 94 | # Start with the WKT Type 95 | wkt = self.type.upper() 96 | has_z = self.has_z 97 | if self.coordinates: 98 | # If any of the coordinates have a Z add a "Z" to the WKT 99 | wkt += " Z " if has_z else " " 100 | # Add the rest of the WKT inside parentheses 101 | wkt += f"({self.__wkt_coordinates__(self.coordinates, force_z=has_z)})" 102 | else: 103 | # Otherwise it will be "EMPTY" 104 | wkt += " EMPTY" 105 | 106 | return wkt 107 | 108 | 109 | class Point(_GeometryBase): 110 | """Point Model""" 111 | 112 | type: Literal["Point"] 113 | coordinates: Position 114 | 115 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 116 | """return WKT coordinates.""" 117 | return _position_wkt_coordinates(coordinates, force_z) 118 | 119 | @property 120 | def has_z(self) -> bool: 121 | """Checks if any coordinate has a Z value.""" 122 | return _position_has_z(self.coordinates) 123 | 124 | 125 | class MultiPoint(_GeometryBase): 126 | """MultiPoint Model""" 127 | 128 | type: Literal["MultiPoint"] 129 | coordinates: MultiPointCoords 130 | 131 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 132 | """return WKT coordinates.""" 133 | return ", ".join( 134 | f"({_position_wkt_coordinates(position, force_z)})" 135 | for position in coordinates 136 | ) 137 | 138 | @property 139 | def has_z(self) -> bool: 140 | """Checks if any coordinate has a Z value.""" 141 | return _position_list_has_z(self.coordinates) 142 | 143 | 144 | class LineString(_GeometryBase): 145 | """LineString Model""" 146 | 147 | type: Literal["LineString"] 148 | coordinates: LineStringCoords 149 | 150 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 151 | """return WKT coordinates.""" 152 | return _position_list_wkt_coordinates(coordinates, force_z) 153 | 154 | @property 155 | def has_z(self) -> bool: 156 | """Checks if any coordinate has a Z value.""" 157 | return _position_list_has_z(self.coordinates) 158 | 159 | 160 | class MultiLineString(_GeometryBase): 161 | """MultiLineString Model""" 162 | 163 | type: Literal["MultiLineString"] 164 | coordinates: MultiLineStringCoords 165 | 166 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 167 | """return WKT coordinates.""" 168 | return _lines_wtk_coordinates(coordinates, force_z) 169 | 170 | @property 171 | def has_z(self) -> bool: 172 | """Checks if any coordinate has a Z value.""" 173 | return _lines_has_z(self.coordinates) 174 | 175 | 176 | class Polygon(_GeometryBase): 177 | """Polygon Model""" 178 | 179 | type: Literal["Polygon"] 180 | coordinates: PolygonCoords 181 | 182 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 183 | """return WKT coordinates.""" 184 | return _lines_wtk_coordinates(coordinates, force_z) 185 | 186 | @field_validator("coordinates") 187 | def check_closure(cls, coordinates: List) -> List: 188 | """Validate that Polygon is closed (first and last coordinate are the same).""" 189 | if any(ring[-1] != ring[0] for ring in coordinates): 190 | raise ValueError("All linear rings have the same start and end coordinates") 191 | 192 | return coordinates 193 | 194 | @property 195 | def exterior(self) -> Union[LinearRing, None]: 196 | """Return the exterior Linear Ring of the polygon.""" 197 | return self.coordinates[0] if self.coordinates else None 198 | 199 | @property 200 | def interiors(self) -> Iterator[LinearRing]: 201 | """Interiors (Holes) of the polygon.""" 202 | yield from ( 203 | interior for interior in self.coordinates[1:] if len(self.coordinates) > 1 204 | ) 205 | 206 | @property 207 | def has_z(self) -> bool: 208 | """Checks if any coordinates have a Z value.""" 209 | return _lines_has_z(self.coordinates) 210 | 211 | @classmethod 212 | def from_bounds( 213 | cls, xmin: float, ymin: float, xmax: float, ymax: float 214 | ) -> "Polygon": 215 | """Create a Polygon geometry from a boundingbox.""" 216 | return cls( 217 | type="Polygon", 218 | coordinates=[ 219 | [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax), (xmin, ymin)] 220 | ], 221 | ) 222 | 223 | 224 | class MultiPolygon(_GeometryBase): 225 | """MultiPolygon Model""" 226 | 227 | type: Literal["MultiPolygon"] 228 | coordinates: MultiPolygonCoords 229 | 230 | def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str: 231 | """return WKT coordinates.""" 232 | return _polygons_wkt_coordinates(coordinates, force_z) 233 | 234 | @property 235 | def has_z(self) -> bool: 236 | """Checks if any coordinates have a Z value.""" 237 | return any(_lines_has_z(polygon) for polygon in self.coordinates) 238 | 239 | @field_validator("coordinates") 240 | def check_closure(cls, coordinates: List) -> List: 241 | """Validate that Polygon is closed (first and last coordinate are the same).""" 242 | if any(ring[-1] != ring[0] for polygon in coordinates for ring in polygon): 243 | raise ValueError("All linear rings have the same start and end coordinates") 244 | 245 | return coordinates 246 | 247 | 248 | class GeometryCollection(_GeoJsonBase): 249 | """GeometryCollection Model""" 250 | 251 | type: Literal["GeometryCollection"] 252 | geometries: List[Geometry] 253 | 254 | def iter(self) -> Iterator[Geometry]: 255 | """iterate over geometries""" 256 | return iter(self.geometries) 257 | 258 | @property 259 | def length(self) -> int: 260 | """return geometries length""" 261 | return len(self.geometries) 262 | 263 | @property 264 | def wkt(self) -> str: 265 | """Return the Well Known Text representation.""" 266 | # Each geometry will check its own coordinates for Z and include "Z" in the wkt 267 | # if necessary. Rather than looking at the coordinates for each of the geometries 268 | # again, we can just get the wkt from each of them and check if there is a Z 269 | # anywhere in the text. 270 | 271 | # Get the wkt from each of the geometries in the collection 272 | geometries = ( 273 | f'({", ".join(geom.wkt for geom in self.geometries)})' 274 | if self.geometries 275 | else "EMPTY" 276 | ) 277 | # If any of them contain `Z` add Z to the output wkt 278 | z = " Z " if "Z" in geometries else " " 279 | return f"{self.type.upper()}{z}{geometries}" 280 | 281 | @property 282 | def has_z(self) -> bool: 283 | """Checks if any coordinates have a Z value.""" 284 | return any(geom.has_z for geom in self.geometries) 285 | 286 | @field_validator("geometries") 287 | def check_geometries(cls, geometries: List) -> List: 288 | """Add warnings for conditions the spec does not explicitly forbid.""" 289 | if len(geometries) == 1: 290 | warnings.warn( 291 | "GeometryCollection should not be used for single geometries.", 292 | stacklevel=1, 293 | ) 294 | 295 | if any(geom.type == "GeometryCollection" for geom in geometries): 296 | warnings.warn( 297 | "GeometryCollection should not be used for nested GeometryCollections.", 298 | stacklevel=1, 299 | ) 300 | 301 | if len({geom.type for geom in geometries}) == 1: 302 | warnings.warn( 303 | "GeometryCollection should not be used for homogeneous collections.", 304 | stacklevel=1, 305 | ) 306 | 307 | if len({geom.has_z for geom in geometries}) == 2: 308 | raise ValueError("GeometryCollection cannot have mixed Z dimensionality.") 309 | 310 | return geometries 311 | 312 | 313 | Geometry = Annotated[ 314 | Union[ 315 | Point, 316 | MultiPoint, 317 | LineString, 318 | MultiLineString, 319 | Polygon, 320 | MultiPolygon, 321 | GeometryCollection, 322 | ], 323 | Field(discriminator="type"), 324 | ] 325 | 326 | GeometryCollection.model_rebuild() 327 | 328 | 329 | def parse_geometry_obj(obj: Any) -> Geometry: 330 | """ 331 | `obj` is an object that is supposed to represent a GeoJSON geometry. This method returns the 332 | reads the `"type"` field and returns the correct pydantic Geometry model. 333 | """ 334 | if "type" not in obj: 335 | raise ValueError("Missing 'type' field in geometry") 336 | 337 | if obj["type"] == "Point": 338 | return Point.model_validate(obj) 339 | 340 | elif obj["type"] == "MultiPoint": 341 | return MultiPoint.model_validate(obj) 342 | 343 | elif obj["type"] == "LineString": 344 | return LineString.model_validate(obj) 345 | 346 | elif obj["type"] == "MultiLineString": 347 | return MultiLineString.model_validate(obj) 348 | 349 | elif obj["type"] == "Polygon": 350 | return Polygon.model_validate(obj) 351 | 352 | elif obj["type"] == "MultiPolygon": 353 | return MultiPolygon.model_validate(obj) 354 | 355 | elif obj["type"] == "GeometryCollection": 356 | return GeometryCollection.model_validate(obj) 357 | 358 | raise ValueError(f"Unknown type: {obj['type']}") 359 | -------------------------------------------------------------------------------- /geojson_pydantic/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/geojson-pydantic/32e37c8a180194607926ee1f99af66bb8e9b30ec/geojson_pydantic/py.typed -------------------------------------------------------------------------------- /geojson_pydantic/types.py: -------------------------------------------------------------------------------- 1 | """Types for geojson_pydantic models""" 2 | 3 | from typing import List, NamedTuple, Tuple, Union 4 | 5 | from pydantic import Field 6 | from typing_extensions import Annotated 7 | 8 | BBox = Union[ 9 | Tuple[float, float, float, float], # 2D bbox 10 | Tuple[float, float, float, float, float, float], # 3D bbox 11 | ] 12 | 13 | Position2D = NamedTuple("Position2D", [("longitude", float), ("latitude", float)]) 14 | Position3D = NamedTuple( 15 | "Position3D", [("longitude", float), ("latitude", float), ("altitude", float)] 16 | ) 17 | Position = Union[Position2D, Position3D] 18 | 19 | # Coordinate arrays 20 | LineStringCoords = Annotated[List[Position], Field(min_length=2)] 21 | LinearRing = Annotated[List[Position], Field(min_length=4)] 22 | MultiPointCoords = List[Position] 23 | MultiLineStringCoords = List[LineStringCoords] 24 | PolygonCoords = List[LinearRing] 25 | MultiPolygonCoords = List[PolygonCoords] 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "geojson-pydantic" 3 | description = "Pydantic data models for the GeoJSON spec." 4 | readme = "README.md" 5 | requires-python = ">=3.9" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "Drew Bollinger", email = "drew@developmentseed.org"}, 9 | ] 10 | keywords = ["geojson", "Pydantic"] 11 | classifiers = [ 12 | "Intended Audience :: Information Technology", 13 | "Intended Audience :: Science/Research", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Topic :: Scientific/Engineering :: GIS", 21 | "Typing :: Typed", 22 | ] 23 | dynamic = ["version"] 24 | dependencies = ["pydantic~=2.0"] 25 | 26 | [project.optional-dependencies] 27 | test = ["pytest", "pytest-cov", "shapely"] 28 | dev = [ 29 | "pre-commit", 30 | "bump-my-version", 31 | ] 32 | docs = [ 33 | "mkdocs", 34 | "mkdocs-material", 35 | "pygments", 36 | ] 37 | 38 | [project.urls] 39 | Source = "https://github.com/developmentseed/geojson-pydantic" 40 | 41 | [build-system] 42 | requires = ["flit_core>=3.2,<4"] 43 | build-backend = "flit_core.buildapi" 44 | 45 | [tool.flit.module] 46 | name = "geojson_pydantic" 47 | 48 | [tool.flit.sdist] 49 | exclude = [ 50 | "tests/", 51 | "docs/", 52 | ".github/", 53 | "CHANGELOG.md", 54 | "CONTRIBUTING.md", 55 | ] 56 | 57 | [tool.coverage.run] 58 | branch = true 59 | parallel = true 60 | 61 | [tool.coverage.report] 62 | exclude_lines = [ 63 | "no cov", 64 | "if __name__ == .__main__.:", 65 | "if TYPE_CHECKING:", 66 | ] 67 | 68 | [tool.isort] 69 | profile = "black" 70 | known_first_party = ["geojson_pydantic"] 71 | known_third_party = ["pydantic"] 72 | default_section = "THIRDPARTY" 73 | 74 | [tool.mypy] 75 | plugins = ["pydantic.mypy"] 76 | disallow_untyped_calls = true 77 | disallow_untyped_defs = true 78 | disallow_incomplete_defs = true 79 | warn_redundant_casts = true 80 | warn_unused_ignores = true 81 | no_implicit_optional = true 82 | show_error_codes = true 83 | 84 | [tool.ruff.lint] 85 | select = [ 86 | "D1", # pydocstyle errors 87 | "E", # pycodestyle errors 88 | "W", # pycodestyle warnings 89 | "F", # flake8 90 | "C", # flake8-comprehensions 91 | "B", # flake8-bugbear 92 | ] 93 | ignore = [ 94 | "E501", # line too long, handled by black 95 | "B008", # do not perform function calls in argument defaults 96 | "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10 97 | ] 98 | 99 | [tool.ruff.lint.per-file-ignores] 100 | "tests/*.py" = ["D1"] 101 | 102 | [tool.bumpversion] 103 | current_version = "2.0.0" 104 | 105 | search = "{current_version}" 106 | replace = "{new_version}" 107 | regex = false 108 | tag = true 109 | commit = true 110 | tag_name = "{new_version}" 111 | 112 | [[tool.bumpversion.files]] 113 | filename = "geojson_pydantic/__init__.py" 114 | search = '__version__ = "{current_version}"' 115 | replace = '__version__ = "{new_version}"' 116 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from typing import Set, Tuple, Union 2 | 3 | import pytest 4 | from pydantic import Field, ValidationError 5 | 6 | from geojson_pydantic.base import _GeoJsonBase 7 | 8 | BBOXES = ( 9 | (0, 100, 0, 0), 10 | (0, 0, 100, 0, 0, 0), 11 | (0, "a", 0, 0), # Invalid Type 12 | ) 13 | 14 | 15 | @pytest.mark.parametrize("values", BBOXES) 16 | def test_bbox_validation(values: Tuple) -> None: 17 | # Ensure validation is happening correctly on the base model 18 | with pytest.raises(ValidationError): 19 | _GeoJsonBase(bbox=values) 20 | 21 | 22 | def test_bbox_antimeridian() -> None: 23 | with pytest.warns(UserWarning): 24 | _GeoJsonBase(bbox=(100, 0, 0, 0)) 25 | 26 | 27 | @pytest.mark.parametrize("values", BBOXES) 28 | def test_bbox_validation_subclass(values: Tuple) -> None: 29 | # Ensure validation is happening correctly when subclassed 30 | class TestClass(_GeoJsonBase): 31 | test_field: str = None 32 | 33 | with pytest.raises(ValidationError): 34 | TestClass(bbox=values) 35 | 36 | 37 | @pytest.mark.parametrize("values", BBOXES) 38 | def test_bbox_validation_field(values: Tuple) -> None: 39 | # Ensure validation is happening correctly when used as a field 40 | class TestClass(_GeoJsonBase): 41 | geo: _GeoJsonBase 42 | 43 | with pytest.raises(ValidationError): 44 | TestClass(geo={"bbox": values}) 45 | 46 | 47 | def test_exclude_if_none() -> None: 48 | model = _GeoJsonBase() 49 | # Included in default dump 50 | assert model.model_dump() == {"bbox": None} 51 | # Not included when in json mode 52 | assert model.model_dump(mode="json") == {} 53 | # And not included in the output json string. 54 | assert model.model_dump_json() == "{}" 55 | 56 | # Included if it has a value 57 | model = _GeoJsonBase(bbox=(0, 0, 0, 0)) 58 | assert model.model_dump() == {"bbox": (0, 0, 0, 0)} 59 | assert model.model_dump(mode="json") == {"bbox": [0, 0, 0, 0]} 60 | assert model.model_dump_json() == '{"bbox":[0.0,0.0,0.0,0.0]}' 61 | 62 | # Since `validate_assignment` is not set, you can do this without an error. 63 | # The solution should handle this and not just look at if the field is set. 64 | model.bbox = None 65 | assert model.model_dump() == {"bbox": None} 66 | assert model.model_dump(mode="json") == {} 67 | assert model.model_dump_json() == "{}" 68 | 69 | 70 | def test_exclude_if_none_subclass() -> None: 71 | # Create a subclass that adds a field, and ensure it works. 72 | class TestClass(_GeoJsonBase): 73 | test_field: str = None 74 | __geojson_exclude_if_none__: Set[str] = {"bbox", "test_field"} 75 | 76 | assert TestClass().model_dump_json() == "{}" 77 | assert TestClass(test_field="a").model_dump_json() == '{"test_field":"a"}' 78 | assert ( 79 | TestClass(bbox=(0, 0, 0, 0)).model_dump_json() == '{"bbox":[0.0,0.0,0.0,0.0]}' 80 | ) 81 | 82 | 83 | def test_exclude_if_none_kwargs() -> None: 84 | # Create a subclass that adds fields and dumps it with kwargs to ensure 85 | # the kwargs are still being utilized. 86 | class TestClass(_GeoJsonBase): 87 | test_field: str = Field(default="test", alias="field") 88 | null_field: Union[str, None] = None 89 | 90 | model = TestClass(bbox=(0, 0, 0, 0)) 91 | assert ( 92 | model.model_dump_json(indent=2, by_alias=True, exclude_none=True) 93 | == """{ 94 | "bbox": [ 95 | 0.0, 96 | 0.0, 97 | 0.0, 98 | 0.0 99 | ], 100 | "field": "test" 101 | }""" 102 | ) 103 | -------------------------------------------------------------------------------- /tests/test_features.py: -------------------------------------------------------------------------------- 1 | import json 2 | from random import randint 3 | from typing import Any, Dict 4 | from uuid import uuid4 5 | 6 | import pytest 7 | from pydantic import BaseModel, ValidationError 8 | 9 | from geojson_pydantic.features import Feature, FeatureCollection 10 | from geojson_pydantic.geometries import ( 11 | Geometry, 12 | GeometryCollection, 13 | MultiPolygon, 14 | Polygon, 15 | ) 16 | 17 | 18 | class GenericProperties(BaseModel): 19 | id: str 20 | description: str 21 | size: int 22 | 23 | 24 | properties: Dict[str, Any] = { 25 | "id": str(uuid4()), 26 | "description": str(uuid4()), 27 | "size": randint(0, 1000), 28 | } 29 | 30 | coordinates = [ 31 | [ 32 | [13.38272, 52.46385], 33 | [13.42786, 52.46385], 34 | [13.42786, 52.48445], 35 | [13.38272, 52.48445], 36 | [13.38272, 52.46385], 37 | ] 38 | ] 39 | 40 | polygon: Dict[str, Any] = { 41 | "type": "Polygon", 42 | "coordinates": coordinates, 43 | } 44 | 45 | multipolygon: Dict[str, Any] = { 46 | "type": "MultiPolygon", 47 | "coordinates": [coordinates], 48 | } 49 | 50 | geom_collection: Dict[str, Any] = { 51 | "type": "GeometryCollection", 52 | "geometries": [polygon, multipolygon], 53 | } 54 | 55 | test_feature: Dict[str, Any] = { 56 | "type": "Feature", 57 | "geometry": polygon, 58 | "properties": properties, 59 | "bbox": [13.38272, 52.46385, 13.42786, 52.48445], 60 | } 61 | 62 | test_feature_geom_null: Dict[str, Any] = { 63 | "type": "Feature", 64 | "geometry": None, 65 | "properties": properties, 66 | } 67 | 68 | test_feature_geometry_collection: Dict[str, Any] = { 69 | "type": "Feature", 70 | "geometry": geom_collection, 71 | "properties": properties, 72 | } 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "obj", 77 | [ 78 | FeatureCollection, 79 | Feature, 80 | ], 81 | ) 82 | def test_pydantic_schema(obj): 83 | """Test schema for Pydantic Object.""" 84 | assert obj.model_json_schema() 85 | 86 | 87 | def test_feature_collection_iteration(): 88 | """test if feature collection is iterable""" 89 | gc = FeatureCollection( 90 | type="FeatureCollection", features=[test_feature, test_feature] 91 | ) 92 | assert hasattr(gc, "__geo_interface__") 93 | assert list(iter(gc)) 94 | assert len(list(gc.iter())) == 2 95 | assert dict(gc) 96 | 97 | 98 | def test_geometry_collection_iteration(): 99 | """test if feature collection is iterable""" 100 | gc = FeatureCollection( 101 | type="FeatureCollection", features=[test_feature_geometry_collection] 102 | ) 103 | assert hasattr(gc, "__geo_interface__") 104 | assert list(iter(gc)) 105 | assert len(list(gc.iter())) == 1 106 | assert dict(gc) 107 | 108 | 109 | def test_generic_properties_is_dict(): 110 | feature = Feature(**test_feature) 111 | assert hasattr(feature, "__geo_interface__") 112 | assert feature.properties["id"] == test_feature["properties"]["id"] 113 | assert isinstance(feature.properties, dict) 114 | assert not hasattr(feature.properties, "id") 115 | 116 | 117 | def test_generic_properties_is_dict_collection(): 118 | feature = Feature(**test_feature_geometry_collection) 119 | assert hasattr(feature, "__geo_interface__") 120 | assert ( 121 | feature.properties["id"] == test_feature_geometry_collection["properties"]["id"] 122 | ) 123 | assert isinstance(feature.properties, dict) 124 | assert not hasattr(feature.properties, "id") 125 | 126 | 127 | def test_generic_properties_is_object(): 128 | feature = Feature[Geometry, GenericProperties](**test_feature) 129 | assert feature.properties.id == test_feature["properties"]["id"] 130 | assert type(feature.properties) == GenericProperties 131 | assert hasattr(feature.properties, "id") 132 | 133 | 134 | def test_generic_geometry(): 135 | feature = Feature[Polygon, GenericProperties](**test_feature) 136 | assert feature.properties.id == test_feature_geometry_collection["properties"]["id"] 137 | assert type(feature.geometry) == Polygon 138 | assert type(feature.properties) == GenericProperties 139 | assert hasattr(feature.properties, "id") 140 | 141 | feature = Feature[Polygon, Dict](**test_feature) 142 | assert type(feature.geometry) == Polygon 143 | assert feature.properties["id"] == test_feature["properties"]["id"] 144 | assert isinstance(feature.properties, dict) 145 | assert not hasattr(feature.properties, "id") 146 | 147 | with pytest.raises(ValidationError): 148 | Feature[MultiPolygon, Dict](**({"type": "Feature", "geometry": polygon})) 149 | 150 | 151 | def test_generic_geometry_collection(): 152 | feature = Feature[GeometryCollection, GenericProperties]( 153 | **test_feature_geometry_collection 154 | ) 155 | assert feature.properties.id == test_feature_geometry_collection["properties"]["id"] 156 | assert type(feature.geometry) == GeometryCollection 157 | assert feature.geometry.wkt.startswith("GEOMETRYCOLLECTION (POLYGON ") 158 | assert type(feature.properties) == GenericProperties 159 | assert hasattr(feature.properties, "id") 160 | 161 | feature = Feature[GeometryCollection, Dict](**test_feature_geometry_collection) 162 | assert type(feature.geometry) == GeometryCollection 163 | assert ( 164 | feature.properties["id"] == test_feature_geometry_collection["properties"]["id"] 165 | ) 166 | assert isinstance(feature.properties, dict) 167 | assert not hasattr(feature.properties, "id") 168 | 169 | with pytest.raises(ValidationError): 170 | Feature[MultiPolygon, Dict](**({"type": "Feature", "geometry": polygon})) 171 | 172 | 173 | def test_generic_properties_should_raise_for_string(): 174 | with pytest.raises(ValidationError): 175 | Feature( 176 | **({"type": "Feature", "geometry": polygon, "properties": "should raise"}) 177 | ) 178 | 179 | 180 | def test_feature_collection_generic(): 181 | fc = FeatureCollection[Feature[Polygon, GenericProperties]]( 182 | type="FeatureCollection", features=[test_feature, test_feature] 183 | ) 184 | assert fc.length == 2 185 | assert len(list(fc.iter())) == 2 186 | assert type(fc.features[0].properties) == GenericProperties 187 | assert type(fc.features[0].geometry) == Polygon 188 | assert dict(fc) 189 | 190 | 191 | def test_geo_interface_protocol(): 192 | class Pointy: 193 | __geo_interface__ = {"type": "Point", "coordinates": (0.0, 0.0)} 194 | 195 | feat = Feature(type="Feature", geometry=Pointy(), properties={}) 196 | assert feat.geometry.model_dump(exclude_unset=True) == Pointy.__geo_interface__ 197 | 198 | 199 | def test_feature_with_null_geometry(): 200 | feature = Feature(**test_feature_geom_null) 201 | assert feature.geometry is None 202 | 203 | 204 | def test_feature_geo_interface_with_null_geometry(): 205 | feature = Feature(**test_feature_geom_null) 206 | assert "bbox" not in feature.__geo_interface__ 207 | 208 | 209 | def test_feature_collection_geo_interface_with_null_geometry(): 210 | fc = FeatureCollection( 211 | type="FeatureCollection", features=[test_feature_geom_null, test_feature] 212 | ) 213 | assert "bbox" not in fc.__geo_interface__ 214 | assert "bbox" not in fc.__geo_interface__["features"][0] 215 | assert "bbox" in fc.__geo_interface__["features"][1] 216 | 217 | 218 | @pytest.mark.parametrize("id", ["a", 1, "1"]) 219 | def test_feature_id(id): 220 | """Test if a string stays a string and if an int stays an int.""" 221 | feature = Feature(**test_feature, id=id) 222 | assert feature.id == id 223 | 224 | 225 | @pytest.mark.parametrize("id", [True, 1.0]) 226 | def test_bad_feature_id(id): 227 | """make sure it raises error.""" 228 | with pytest.raises(ValidationError): 229 | Feature(**test_feature, id=id) 230 | 231 | 232 | def test_feature_validation(): 233 | """Test default.""" 234 | assert Feature(type="Feature", properties=None, geometry=None) 235 | assert Feature(type="Feature", properties=None, geometry=None, bbox=None) 236 | 237 | with pytest.raises(ValidationError): 238 | # should be type=Feature 239 | Feature(type="feature", properties=None, geometry=None) 240 | 241 | with pytest.raises(ValidationError): 242 | # missing type 243 | Feature(properties=None, geometry=None) 244 | 245 | with pytest.raises(ValidationError): 246 | # missing properties 247 | Feature(type="Feature", geometry=None) 248 | 249 | with pytest.raises(ValidationError): 250 | # missing geometry 251 | Feature(type="Feature", properties=None) 252 | 253 | assert Feature( 254 | type="Feature", properties=None, bbox=(0, 0, 100, 100), geometry=None 255 | ) 256 | assert Feature( 257 | type="Feature", properties=None, bbox=(0, 0, 0, 100, 100, 100), geometry=None 258 | ) 259 | 260 | with pytest.raises(ValidationError): 261 | # bad bbox2d 262 | Feature(type="Feature", properties=None, bbox=(0, 100, 100, 0), geometry=None) 263 | 264 | with pytest.raises(ValidationError): 265 | # bad bbox3d 266 | Feature( 267 | type="Feature", 268 | properties=None, 269 | bbox=(0, 100, 100, 100, 0, 0), 270 | geometry=None, 271 | ) 272 | 273 | # Antimeridian 274 | with pytest.warns(UserWarning): 275 | Feature(type="Feature", properties=None, bbox=(100, 0, 0, 100), geometry=None) 276 | 277 | with pytest.warns(UserWarning): 278 | Feature( 279 | type="Feature", 280 | properties=None, 281 | bbox=(100, 0, 0, 0, 100, 100), 282 | geometry=None, 283 | ) 284 | 285 | 286 | def test_bbox_validation(): 287 | # Some attempts at generic validation did not validate the types within 288 | # bbox before passing them to the function and resulted in TypeErrors. 289 | # This test exists to ensure that doesn't happen in the future. 290 | with pytest.raises(ValidationError): 291 | Feature( 292 | type="Feature", 293 | properties=None, 294 | bbox=(0, "a", 0, 1, 1, 1), 295 | geometry=None, 296 | ) 297 | 298 | 299 | def test_feature_validation_error_count(): 300 | # Tests that validation does not include irrelevant errors to make them 301 | # easier to read. The input below used to raise 18 errors. 302 | # See #93 for more details. 303 | with pytest.raises(ValidationError): 304 | try: 305 | Feature( 306 | type="Feature", 307 | geometry=Polygon( 308 | type="Polygon", 309 | coordinates=[ 310 | [ 311 | (-55.9947406591177, -9.26104045526505), 312 | (-55.9976752102375, -9.266589696568962), 313 | (-56.00200328975916, -9.264041751931352), 314 | (-55.99899921566248, -9.257935213034594), 315 | (-55.99477406591177, -9.26103945526505), 316 | ] 317 | ], 318 | ), 319 | properties={}, 320 | ) 321 | except ValidationError as e: 322 | assert e.error_count() == 1 323 | raise 324 | 325 | 326 | def test_feature_serializer(): 327 | f = Feature( 328 | **{ 329 | "type": "Feature", 330 | "geometry": { 331 | "type": "Polygon", 332 | "coordinates": coordinates, 333 | }, 334 | "properties": {}, 335 | "id": "Yo", 336 | "bbox": [13.38272, 52.46385, 13.42786, 52.48445], 337 | } 338 | ) 339 | assert "bbox" in f.model_dump() 340 | assert "id" in f.model_dump() 341 | 342 | # Exclude 343 | assert "bbox" not in f.model_dump(exclude={"bbox"}) 344 | assert "bbox" not in list(json.loads(f.model_dump_json(exclude={"bbox"})).keys()) 345 | 346 | # Include 347 | assert ["bbox"] == list(f.model_dump(include={"bbox"}).keys()) 348 | assert ["bbox"] == list(json.loads(f.model_dump_json(include={"bbox"})).keys()) 349 | 350 | feat_ser = json.loads(f.model_dump_json()) 351 | assert "bbox" in feat_ser 352 | assert "id" in feat_ser 353 | assert "bbox" not in feat_ser["geometry"] 354 | 355 | f = Feature( 356 | **{ 357 | "type": "Feature", 358 | "geometry": { 359 | "type": "Polygon", 360 | "coordinates": coordinates, 361 | }, 362 | "properties": {}, 363 | } 364 | ) 365 | # BBOX Should'nt be present if `None` 366 | # https://github.com/developmentseed/geojson-pydantic/issues/125 367 | assert "bbox" in f.model_dump() 368 | 369 | feat_ser = json.loads(f.model_dump_json()) 370 | assert "bbox" not in feat_ser 371 | assert "id" not in feat_ser 372 | assert "bbox" not in feat_ser["geometry"] 373 | 374 | f = Feature( 375 | **{ 376 | "type": "Feature", 377 | "geometry": { 378 | "type": "Polygon", 379 | "coordinates": coordinates, 380 | "bbox": [13.38272, 52.46385, 13.42786, 52.48445], 381 | }, 382 | "properties": {}, 383 | } 384 | ) 385 | feat_ser = json.loads(f.model_dump_json()) 386 | assert "bbox" not in feat_ser 387 | assert "id" not in feat_ser 388 | assert "bbox" in feat_ser["geometry"] 389 | 390 | 391 | def test_feature_collection_serializer(): 392 | fc = FeatureCollection( 393 | **{ 394 | "type": "FeatureCollection", 395 | "features": [ 396 | { 397 | "type": "Feature", 398 | "geometry": { 399 | "type": "Polygon", 400 | "coordinates": coordinates, 401 | "bbox": [13.38272, 52.46385, 13.42786, 52.48445], 402 | }, 403 | "properties": {}, 404 | "bbox": [13.38272, 52.46385, 13.42786, 52.48445], 405 | } 406 | ], 407 | "bbox": [13.38272, 52.46385, 13.42786, 52.48445], 408 | } 409 | ) 410 | assert "bbox" in fc.model_dump() 411 | 412 | # Exclude 413 | assert "bbox" not in fc.model_dump(exclude={"bbox"}) 414 | assert "bbox" not in list(json.loads(fc.model_dump_json(exclude={"bbox"})).keys()) 415 | 416 | # Include 417 | assert ["bbox"] == list(fc.model_dump(include={"bbox"}).keys()) 418 | assert ["bbox"] == list(json.loads(fc.model_dump_json(include={"bbox"})).keys()) 419 | 420 | featcoll_ser = json.loads(fc.model_dump_json()) 421 | assert "bbox" in featcoll_ser 422 | assert "bbox" in featcoll_ser["features"][0] 423 | assert "bbox" in featcoll_ser["features"][0]["geometry"] 424 | 425 | fc = FeatureCollection( 426 | **{ 427 | "type": "FeatureCollection", 428 | "features": [ 429 | { 430 | "type": "Feature", 431 | "geometry": { 432 | "type": "Polygon", 433 | "coordinates": coordinates, 434 | }, 435 | "properties": {}, 436 | } 437 | ], 438 | } 439 | ) 440 | assert "bbox" in fc.model_dump() 441 | 442 | featcoll_ser = json.loads(fc.model_dump_json()) 443 | assert "bbox" not in featcoll_ser 444 | assert "bbox" not in featcoll_ser["features"][0] 445 | assert "bbox" not in featcoll_ser["features"][0]["geometry"] 446 | -------------------------------------------------------------------------------- /tests/test_geometries.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import shapely 5 | from pydantic import ValidationError 6 | 7 | from geojson_pydantic.geometries import ( 8 | Geometry, 9 | GeometryCollection, 10 | LineString, 11 | MultiLineString, 12 | MultiPoint, 13 | MultiPolygon, 14 | Point, 15 | Polygon, 16 | parse_geometry_obj, 17 | ) 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "obj", 22 | [ 23 | GeometryCollection, 24 | LineString, 25 | MultiLineString, 26 | MultiPoint, 27 | MultiPolygon, 28 | Point, 29 | Polygon, 30 | ], 31 | ) 32 | def test_pydantic_schema(obj): 33 | """Test schema for Pydantic Object.""" 34 | assert obj.model_json_schema() 35 | 36 | 37 | @pytest.mark.parametrize("coordinates", [(1.01, 2.01), (1.0, 2.0, 3.0), (1.0, 2.0)]) 38 | def test_point_valid_coordinates(coordinates): 39 | """ 40 | Two or three number elements as coordinates should be okay 41 | """ 42 | p = Point(type="Point", coordinates=coordinates) 43 | assert p.type == "Point" 44 | assert p.coordinates == coordinates 45 | assert hasattr(p, "__geo_interface__") 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "coordinates", 50 | [(1.0,), (1.0, 2.0, 3.0, 4.0), "Foo", (None, 2.0), (1.0, (2.0,)), (), [], None], 51 | ) 52 | def test_point_invalid_coordinates(coordinates): 53 | """ 54 | Too few or to many elements should not, nor weird data types 55 | """ 56 | with pytest.raises(ValidationError): 57 | Point(type="Point", coordinates=coordinates) 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "coordinates", 62 | [ 63 | # Empty array 64 | [], 65 | # No Z 66 | [(1.0, 2.0)], 67 | [(1.0, 2.0), (1.0, 2.0)], 68 | # Has Z 69 | [(1.0, 2.0, 3.0), (1.0, 2.0, 3.0)], 70 | # Mixed 71 | [(1.0, 2.0), (1.0, 2.0, 3.0)], 72 | ], 73 | ) 74 | def test_multi_point_valid_coordinates(coordinates): 75 | """ 76 | Two or three number elements as coordinates should be okay, as well as an empty array. 77 | """ 78 | p = MultiPoint(type="MultiPoint", coordinates=coordinates) 79 | assert p.type == "MultiPoint" 80 | assert p.coordinates == coordinates 81 | assert hasattr(p, "__geo_interface__") 82 | 83 | 84 | @pytest.mark.parametrize( 85 | "coordinates", 86 | [[(1.0,)], [(1.0, 2.0, 3.0, 4.0)], ["Foo"], [(None, 2.0)], [(1.0, (2.0,))], None], 87 | ) 88 | def test_multi_point_invalid_coordinates(coordinates): 89 | """ 90 | Too few or to many elements should not, nor weird data types 91 | """ 92 | with pytest.raises(ValidationError): 93 | MultiPoint(type="MultiPoint", coordinates=coordinates) 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "coordinates", 98 | [ 99 | # Two Points, no Z 100 | [(1.0, 2.0), (3.0, 4.0)], 101 | # Three Points, no Z 102 | [(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)], 103 | # Two Points, has Z 104 | [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)], 105 | # Shapely doesn't like mixed here 106 | ], 107 | ) 108 | def test_line_string_valid_coordinates(coordinates): 109 | """ 110 | A list of two coordinates or more should be okay 111 | """ 112 | linestring = LineString(type="LineString", coordinates=coordinates) 113 | assert linestring.type == "LineString" 114 | assert linestring.coordinates == coordinates 115 | assert hasattr(linestring, "__geo_interface__") 116 | 117 | 118 | @pytest.mark.parametrize("coordinates", [None, "Foo", [], [(1.0, 2.0)], ["Foo", "Bar"]]) 119 | def test_line_string_invalid_coordinates(coordinates): 120 | """ 121 | But we don't accept non-list inputs, too few coordinates, or bogus coordinates 122 | """ 123 | with pytest.raises(ValidationError): 124 | LineString(type="LineString", coordinates=coordinates) 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "coordinates", 129 | [ 130 | # Empty array 131 | [], 132 | # One line, two points, no Z 133 | [[(1.0, 2.0), (3.0, 4.0)]], 134 | # One line, two points, has Z 135 | [[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]], 136 | # One line, three points, no Z 137 | [[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]], 138 | # Two lines, two points each, no Z 139 | [[(1.0, 2.0), (3.0, 4.0)], [(0.0, 0.0), (1.0, 1.0)]], 140 | # Two lines, two points each, has Z 141 | [[(1.0, 2.0, 0.0), (3.0, 4.0, 1.0)], [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]], 142 | # Mixed 143 | [[(1.0, 2.0), (3.0, 4.0)], [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]], 144 | ], 145 | ) 146 | def test_multi_line_string_valid_coordinates(coordinates): 147 | """ 148 | A list of two coordinates or more should be okay 149 | """ 150 | multilinestring = MultiLineString(type="MultiLineString", coordinates=coordinates) 151 | assert multilinestring.type == "MultiLineString" 152 | assert multilinestring.coordinates == coordinates 153 | assert hasattr(multilinestring, "__geo_interface__") 154 | 155 | 156 | @pytest.mark.parametrize( 157 | "coordinates", [None, [None], ["Foo"], [[]], [[(1.0, 2.0)]], [["Foo", "Bar"]]] 158 | ) 159 | def test_multi_line_string_invalid_coordinates(coordinates): 160 | """ 161 | But we don't accept non-list inputs, too few coordinates, or bogus coordinates 162 | """ 163 | with pytest.raises(ValidationError): 164 | MultiLineString(type="MultiLineString", coordinates=coordinates) 165 | 166 | 167 | @pytest.mark.parametrize( 168 | "coordinates", 169 | [ 170 | # Empty array 171 | [], 172 | # Polygon, no Z 173 | [[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]], 174 | # Polygon, has Z 175 | [[(0.0, 0.0, 0.0), (1.0, 1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 0.0)]], 176 | ], 177 | ) 178 | def test_polygon_valid_coordinates(coordinates): 179 | """ 180 | Should accept lists of linear rings 181 | """ 182 | polygon = Polygon(type="Polygon", coordinates=coordinates) 183 | assert polygon.type == "Polygon" 184 | assert polygon.coordinates == coordinates 185 | assert hasattr(polygon, "__geo_interface__") 186 | if polygon.coordinates: 187 | assert polygon.exterior == coordinates[0] 188 | else: 189 | assert polygon.exterior is None 190 | assert not list(polygon.interiors) 191 | 192 | 193 | @pytest.mark.parametrize( 194 | "coordinates", 195 | [ 196 | # Polygon with holes, no Z 197 | [ 198 | [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0), (0.0, 0.0)], 199 | [(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0)], 200 | ], 201 | # Polygon with holes, has Z 202 | [ 203 | [ 204 | (0.0, 0.0, 0.0), 205 | (0.0, 10.0, 0.0), 206 | (10.0, 10.0, 0.0), 207 | (10.0, 0.0, 0.0), 208 | (0.0, 0.0, 0.0), 209 | ], 210 | [ 211 | (2.0, 2.0, 1.0), 212 | (2.0, 4.0, 1.0), 213 | (4.0, 4.0, 1.0), 214 | (4.0, 2.0, 1.0), 215 | (2.0, 2.0, 1.0), 216 | ], 217 | ], 218 | # Mixed 219 | [ 220 | [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0), (0.0, 0.0)], 221 | [ 222 | (2.0, 2.0, 2.0), 223 | (2.0, 4.0, 0.0), 224 | (4.0, 4.0, 0.0), 225 | (4.0, 2.0, 0.0), 226 | (2.0, 2.0, 2.0), 227 | ], 228 | ], 229 | ], 230 | ) 231 | def test_polygon_with_holes(coordinates): 232 | """Check interior and exterior rings.""" 233 | polygon = Polygon(type="Polygon", coordinates=coordinates) 234 | assert polygon.type == "Polygon" 235 | assert hasattr(polygon, "__geo_interface__") 236 | assert polygon.exterior == polygon.coordinates[0] 237 | assert list(polygon.interiors) == [polygon.coordinates[1]] 238 | 239 | 240 | @pytest.mark.parametrize( 241 | "coordinates", 242 | [ 243 | "foo", 244 | None, 245 | [[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)], "foo", None], 246 | [[(1.0, 2.0), (3.0, 4.0), (1.0, 2.0)]], 247 | [[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (7.0, 8.0)]], 248 | ], 249 | ) 250 | def test_polygon_invalid_coordinates(coordinates): 251 | """ 252 | Should not accept when: 253 | - Coordinates is not a list 254 | - Not all elements in coordinates are lists 255 | - If not all elements have four or more coordinates 256 | - If not all elements are linear rings 257 | """ 258 | with pytest.raises(ValidationError): 259 | Polygon(type="Polygon", coordinates=coordinates) 260 | 261 | 262 | @pytest.mark.parametrize( 263 | "coordinates", 264 | [ 265 | # Empty array 266 | [], 267 | # Multipolygon, no Z 268 | [ 269 | [ 270 | [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)], 271 | [(2.1, 2.1), (2.2, 2.1), (2.2, 2.2), (2.1, 2.2), (2.1, 2.1)], 272 | ] 273 | ], 274 | # Multipolygon, has Z 275 | [ 276 | [ 277 | [ 278 | (0.0, 0.0, 4.0), 279 | (1.0, 0.0, 4.0), 280 | (1.0, 1.0, 4.0), 281 | (0.0, 1.0, 4.0), 282 | (0.0, 0.0, 4.0), 283 | ], 284 | [ 285 | (2.1, 2.1, 4.0), 286 | (2.2, 2.1, 4.0), 287 | (2.2, 2.2, 4.0), 288 | (2.1, 2.2, 4.0), 289 | (2.1, 2.1, 4.0), 290 | ], 291 | ] 292 | ], 293 | # Mixed 294 | [ 295 | [ 296 | [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)], 297 | [ 298 | (2.1, 2.1, 2.1), 299 | (2.2, 2.1, 2.0), 300 | (2.2, 2.2, 2.2), 301 | (2.1, 2.2, 2.3), 302 | (2.1, 2.1, 2.1), 303 | ], 304 | ] 305 | ], 306 | ], 307 | ) 308 | def test_multi_polygon(coordinates): 309 | """Should accept sequence of polygons.""" 310 | multi_polygon = MultiPolygon(type="MultiPolygon", coordinates=coordinates) 311 | 312 | assert multi_polygon.type == "MultiPolygon" 313 | assert hasattr(multi_polygon, "__geo_interface__") 314 | 315 | 316 | @pytest.mark.parametrize( 317 | "coordinates", 318 | [ 319 | "foo", 320 | None, 321 | [ 322 | [ 323 | [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)], 324 | ], 325 | [ 326 | [(2.1, 2.1), (2.2, 2.1), (2.2, 2.2), (2.1, 4.2)], 327 | ], 328 | ], 329 | ], 330 | ) 331 | def test_multipolygon_invalid_coordinates(coordinates): 332 | with pytest.raises(ValidationError): 333 | MultiPolygon(type="MultiPolygon", coordinates=coordinates) 334 | 335 | 336 | def test_parse_geometry_obj_point(): 337 | assert parse_geometry_obj({"type": "Point", "coordinates": [102.0, 0.5]}) == Point( 338 | type="Point", coordinates=(102.0, 0.5) 339 | ) 340 | 341 | 342 | @pytest.mark.parametrize( 343 | "geojson_pydantic_model", 344 | ( 345 | GeometryCollection, 346 | LineString, 347 | MultiLineString, 348 | MultiPoint, 349 | MultiPolygon, 350 | Point, 351 | Polygon, 352 | ), 353 | ) 354 | def test_schema_consistency(geojson_pydantic_model): 355 | """Test to check that the schema is the same for validation and serialization""" 356 | assert geojson_pydantic_model.model_json_schema( 357 | mode="validation" 358 | ) == geojson_pydantic_model.model_json_schema(mode="serialization") 359 | 360 | 361 | def test_parse_geometry_obj_multi_point(): 362 | assert parse_geometry_obj( 363 | {"type": "MultiPoint", "coordinates": [[100.0, 0.0], [101.0, 1.0]]} 364 | ) == MultiPoint(type="MultiPoint", coordinates=[(100.0, 0.0), (101.0, 1.0)]) 365 | 366 | 367 | def test_parse_geometry_obj_line_string(): 368 | assert parse_geometry_obj( 369 | { 370 | "type": "LineString", 371 | "coordinates": [[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]], 372 | } 373 | ) == LineString( 374 | type="LineString", 375 | coordinates=[(102.0, 0.0), (103.0, 1.0), (104.0, 0.0), (105.0, 1.0)], 376 | ) 377 | 378 | 379 | def test_parse_geometry_obj_multi_line_string(): 380 | assert parse_geometry_obj( 381 | { 382 | "type": "MultiLineString", 383 | "coordinates": [[[100.0, 0.0], [101.0, 1.0]], [[102.0, 2.0], [103.0, 3.0]]], 384 | } 385 | ) == MultiLineString( 386 | type="MultiLineString", 387 | coordinates=[[(100.0, 0.0), (101.0, 1.0)], [(102.0, 2.0), (103.0, 3.0)]], 388 | ) 389 | 390 | 391 | def test_parse_geometry_obj_polygon(): 392 | assert parse_geometry_obj( 393 | { 394 | "type": "Polygon", 395 | "coordinates": [ 396 | [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]] 397 | ], 398 | } 399 | ) == Polygon( 400 | type="Polygon", 401 | coordinates=[ 402 | [(100.0, 0.0), (101.0, 0.0), (101.0, 1.0), (100.0, 1.0), (100.0, 0.0)] 403 | ], 404 | ) 405 | 406 | 407 | def test_parse_geometry_obj_multi_polygon(): 408 | assert parse_geometry_obj( 409 | { 410 | "type": "MultiPolygon", 411 | "coordinates": [ 412 | [ 413 | [ 414 | [102.0, 2.0], 415 | [103.0, 2.0], 416 | [103.0, 3.0], 417 | [102.0, 3.0], 418 | [102.0, 2.0], 419 | ] 420 | ], 421 | [ 422 | [ 423 | [100.0, 0.0], 424 | [101.0, 0.0], 425 | [101.0, 1.0], 426 | [100.0, 1.0], 427 | [100.0, 0.0], 428 | ], 429 | [ 430 | [100.2, 0.2], 431 | [100.8, 0.2], 432 | [100.8, 0.8], 433 | [100.2, 0.8], 434 | [100.2, 0.2], 435 | ], 436 | ], 437 | ], 438 | } 439 | ) == MultiPolygon( 440 | type="MultiPolygon", 441 | coordinates=[ 442 | [[(102.0, 2.0), (103.0, 2.0), (103.0, 3.0), (102.0, 3.0), (102.0, 2.0)]], 443 | [ 444 | [(100.0, 0.0), (101.0, 0.0), (101.0, 1.0), (100.0, 1.0), (100.0, 0.0)], 445 | [(100.2, 0.2), (100.8, 0.2), (100.8, 0.8), (100.2, 0.8), (100.2, 0.2)], 446 | ], 447 | ], 448 | ) 449 | 450 | 451 | def test_parse_geometry_obj_geometry_collection(): 452 | assert parse_geometry_obj( 453 | { 454 | "type": "GeometryCollection", 455 | "geometries": [ 456 | {"type": "Point", "coordinates": [102.0, 0.5]}, 457 | {"type": "MultiPoint", "coordinates": [[100.0, 0.0], [101.0, 1.0]]}, 458 | ], 459 | } 460 | ) == GeometryCollection( 461 | type="GeometryCollection", 462 | geometries=[ 463 | Point(type="Point", coordinates=(102.0, 0.5)), 464 | MultiPoint(type="MultiPoint", coordinates=[(100.0, 0.0), (101.0, 1.0)]), 465 | ], 466 | ) 467 | 468 | 469 | def test_parse_geometry_obj_invalid_type(): 470 | with pytest.raises(ValueError): 471 | parse_geometry_obj({"type": "This type", "obviously": "doesn't exist"}) 472 | 473 | with pytest.raises(ValueError): 474 | parse_geometry_obj({"type": "", "obviously": "doesn't exist"}) 475 | 476 | with pytest.raises(ValueError): 477 | parse_geometry_obj({}) 478 | 479 | 480 | def test_parse_geometry_obj_invalid_point(): 481 | """ 482 | litmus test that invalid geometries don't get parsed 483 | """ 484 | with pytest.raises(ValidationError): 485 | parse_geometry_obj( 486 | {"type": "Point", "coordinates": ["not", "valid", "coordinates"]} 487 | ) 488 | 489 | 490 | @pytest.mark.parametrize( 491 | "coordinates", [[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]] 492 | ) 493 | def test_geometry_collection_iteration(coordinates): 494 | """test if geometry collection is iterable""" 495 | polygon = Polygon(type="Polygon", coordinates=coordinates) 496 | multipolygon = MultiPolygon(type="MultiPolygon", coordinates=[coordinates]) 497 | gc = GeometryCollection( 498 | type="GeometryCollection", geometries=[polygon, multipolygon] 499 | ) 500 | assert hasattr(gc, "__geo_interface__") 501 | assert len(list(gc.iter())) == 2 502 | assert list(iter(gc)) 503 | 504 | 505 | @pytest.mark.parametrize( 506 | "coordinates", [[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]] 507 | ) 508 | def test_len_geometry_collection(coordinates): 509 | """test if GeometryCollection return self leng""" 510 | polygon = Polygon(type="Polygon", coordinates=coordinates) 511 | multipolygon = MultiPolygon(type="MultiPolygon", coordinates=[coordinates]) 512 | gc = GeometryCollection( 513 | type="GeometryCollection", geometries=[polygon, multipolygon] 514 | ) 515 | assert gc.length == 2 516 | assert len(list(gc.iter())) == 2 517 | assert dict(gc) 518 | assert list(iter(gc)) 519 | 520 | 521 | @pytest.mark.parametrize( 522 | "coordinates", [[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]] 523 | ) 524 | def test_getitem_geometry_collection(coordinates): 525 | """test if GeometryCollection is subscriptable""" 526 | polygon = Polygon(type="Polygon", coordinates=coordinates) 527 | multipolygon = MultiPolygon(type="MultiPolygon", coordinates=[coordinates]) 528 | gc = GeometryCollection( 529 | type="GeometryCollection", geometries=[polygon, multipolygon] 530 | ) 531 | assert polygon == gc.geometries[0] 532 | assert multipolygon == gc.geometries[1] 533 | 534 | 535 | def test_wkt_mixed_geometry_collection(): 536 | point = Point(type="Point", coordinates=(0.0, 0.0, 0.0)) 537 | line_string = LineString( 538 | type="LineString", coordinates=[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] 539 | ) 540 | assert ( 541 | GeometryCollection( 542 | type="GeometryCollection", geometries=[point, line_string] 543 | ).wkt 544 | == "GEOMETRYCOLLECTION Z (POINT Z (0.0 0.0 0.0), LINESTRING Z (0.0 0.0 0.0, 1.0 1.0 1.0))" 545 | ) 546 | 547 | 548 | def test_wkt_empty_geometry_collection(): 549 | assert ( 550 | GeometryCollection(type="GeometryCollection", geometries=[]).wkt 551 | == "GEOMETRYCOLLECTION EMPTY" 552 | ) 553 | 554 | 555 | def test_geometry_collection_warnings(): 556 | point = Point(type="Point", coordinates=(0.0, 0.0, 0.0)) 557 | line_string_z = LineString( 558 | type="LineString", coordinates=[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] 559 | ) 560 | line_string = LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]) 561 | 562 | # one geometry 563 | with pytest.warns( 564 | UserWarning, 565 | match="GeometryCollection should not be used for single geometries.", 566 | ): 567 | GeometryCollection( 568 | type="GeometryCollection", 569 | geometries=[ 570 | point, 571 | ], 572 | ) 573 | 574 | # collections of collections 575 | with pytest.warns( 576 | UserWarning, 577 | match="GeometryCollection should not be used for nested GeometryCollections.", 578 | ): 579 | GeometryCollection( 580 | type="GeometryCollection", 581 | geometries=[ 582 | GeometryCollection( 583 | type="GeometryCollection", geometries=[point, line_string_z] 584 | ), 585 | point, 586 | ], 587 | ) 588 | 589 | # homogeneous (Z) geometry 590 | with pytest.raises(ValidationError): 591 | GeometryCollection(type="GeometryCollection", geometries=[point, line_string]) 592 | 593 | 594 | def test_polygon_from_bounds(): 595 | """Result from `from_bounds` class method should be the same.""" 596 | coordinates = [[(1.0, 2.0), (3.0, 2.0), (3.0, 4.0), (1.0, 4.0), (1.0, 2.0)]] 597 | assert Polygon(type="Polygon", coordinates=coordinates) == Polygon.from_bounds( 598 | 1.0, 2.0, 3.0, 4.0 599 | ) 600 | 601 | 602 | def test_wkt_name(): 603 | """Make sure WKT name is derived from geometry Type.""" 604 | 605 | class PointType(Point): ... 606 | 607 | assert ( 608 | PointType(type="Point", coordinates=(1.01, 2.01)).wkt 609 | == Point(type="Point", coordinates=(1.01, 2.01)).wkt 610 | ) 611 | 612 | 613 | @pytest.mark.parametrize( 614 | "coordinates,expected", 615 | [ 616 | ((0, 0), False), 617 | ((0, 0, 0), True), 618 | ], 619 | ) 620 | def test_point_has_z(coordinates, expected): 621 | assert Point(type="Point", coordinates=coordinates).has_z == expected 622 | 623 | 624 | @pytest.mark.parametrize( 625 | "coordinates,expected", 626 | [ 627 | ([(0, 0)], False), 628 | ([(0, 0), (1, 1)], False), 629 | ([(0, 0), (1, 1, 1)], True), 630 | ([(0, 0, 0)], True), 631 | ([(0, 0, 0), (1, 1)], True), 632 | ], 633 | ) 634 | def test_multipoint_has_z(coordinates, expected): 635 | assert MultiPoint(type="MultiPoint", coordinates=coordinates).has_z == expected 636 | 637 | 638 | @pytest.mark.parametrize( 639 | "coordinates,expected", 640 | [ 641 | ([(0, 0), (1, 1)], False), 642 | ([(0, 0), (1, 1, 1)], True), 643 | ([(0, 0, 0), (1, 1, 1)], True), 644 | ([(0, 0, 0), (1, 1)], True), 645 | ], 646 | ) 647 | def test_linestring_has_z(coordinates, expected): 648 | assert LineString(type="LineString", coordinates=coordinates).has_z == expected 649 | 650 | 651 | @pytest.mark.parametrize( 652 | "coordinates,expected", 653 | [ 654 | ([[(0, 0), (1, 1)]], False), 655 | ([[(0, 0), (1, 1)], [(0, 0), (1, 1)]], False), 656 | ([[(0, 0), (1, 1)], [(0, 0, 0), (1, 1)]], True), 657 | ([[(0, 0), (1, 1)], [(0, 0), (1, 1, 1)]], True), 658 | ([[(0, 0), (1, 1, 1)]], True), 659 | ([[(0, 0, 0), (1, 1, 1)]], True), 660 | ([[(0, 0, 0), (1, 1)]], True), 661 | ([[(0, 0, 0), (1, 1, 1)], [(0, 0, 0), (1, 1, 1)]], True), 662 | ], 663 | ) 664 | def test_multilinestring_has_z(coordinates, expected): 665 | assert ( 666 | MultiLineString(type="MultiLineString", coordinates=coordinates).has_z 667 | == expected 668 | ) 669 | 670 | 671 | @pytest.mark.parametrize( 672 | "coordinates,expected", 673 | [ 674 | ([[(0, 0), (1, 1), (2, 2), (0, 0)]], False), 675 | ([[(0, 0), (1, 1), (2, 2, 2), (0, 0)]], True), 676 | ([[(0, 0), (1, 1), (2, 2), (0, 0)], [(0, 0), (1, 1), (2, 2), (0, 0)]], False), 677 | ( 678 | [[(0, 0), (1, 1), (2, 2), (0, 0)], [(0, 0), (1, 1), (2, 2, 2), (0, 0)]], 679 | True, 680 | ), 681 | ([[(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 0, 0)]], True), 682 | ( 683 | [ 684 | [(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 0, 0)], 685 | [(0, 0), (1, 1), (2, 2), (0, 0)], 686 | ], 687 | True, 688 | ), 689 | ], 690 | ) 691 | def test_polygon_has_z(coordinates, expected): 692 | assert Polygon(type="Polygon", coordinates=coordinates).has_z == expected 693 | 694 | 695 | @pytest.mark.parametrize( 696 | "coordinates,expected", 697 | [ 698 | ([[[(0, 0), (1, 1), (2, 2), (0, 0)]]], False), 699 | ([[[(0, 0), (1, 1), (2, 2, 2), (0, 0)]]], True), 700 | ( 701 | [[[(0, 0), (1, 1), (2, 2), (0, 0)]], [[(0, 0), (1, 1), (2, 2), (0, 0)]]], 702 | False, 703 | ), 704 | ( 705 | [ 706 | [[(0, 0), (1, 1), (2, 2), (0, 0)]], 707 | [ 708 | [(0, 0), (1, 1), (2, 2), (0, 0)], 709 | [(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 0, 0)], 710 | ], 711 | ], 712 | True, 713 | ), 714 | ( 715 | [[[(0, 0), (1, 1), (2, 2), (0, 0)]], [[(0, 0), (1, 1), (2, 2, 2), (0, 0)]]], 716 | True, 717 | ), 718 | ([[[(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 0, 0)]]], True), 719 | ( 720 | [ 721 | [[(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 0, 0)]], 722 | [[(0, 0), (1, 1), (2, 2), (0, 0)]], 723 | ], 724 | True, 725 | ), 726 | ], 727 | ) 728 | def test_multipolygon_has_z(coordinates, expected): 729 | assert MultiPolygon(type="MultiPolygon", coordinates=coordinates).has_z == expected 730 | 731 | 732 | @pytest.mark.parametrize( 733 | "shape", 734 | [ 735 | MultiPoint, 736 | MultiLineString, 737 | Polygon, 738 | MultiPolygon, 739 | ], 740 | ) 741 | def test_wkt_empty(shape): 742 | assert shape(type=shape.__name__, coordinates=[]).wkt.endswith(" EMPTY") 743 | 744 | 745 | def test_wkt_empty_geometrycollection(): 746 | assert GeometryCollection(type="GeometryCollection", geometries=[]).wkt.endswith( 747 | " EMPTY" 748 | ) 749 | 750 | 751 | @pytest.mark.parametrize( 752 | "wkt", 753 | ( 754 | "POINT (0.0 0.0)", 755 | # "POINT EMPTY" does not result in valid GeoJSON 756 | "POINT Z (0.0 0.0 0.0)", 757 | "MULTIPOINT ((0.0 0.0))", 758 | "MULTIPOINT Z ((0.0 0.0 0.0))", 759 | "MULTIPOINT ((0.0 0.0), (1.0 1.0))", 760 | "MULTIPOINT Z ((0.0 0.0 0.0), (1.0 1.0 1.0))", 761 | "MULTIPOINT EMPTY", 762 | "LINESTRING (0.0 0.0, 1.0 1.0, 2.0 2.0)", 763 | "LINESTRING Z (0.0 0.0 0.0, 1.0 1.0 1.0, 2.0 2.0 2.0)", 764 | # "LINESTRING EMPTY" does not result in valid GeoJSON 765 | "MULTILINESTRING ((0.0 0.0, 1.0 1.0))", 766 | "MULTILINESTRING ((0.0 0.0, 1.0 1.0), (1.0 1.0, 2.0 2.0))", 767 | "MULTILINESTRING Z ((0.0 0.0 0.0, 1.0 1.0 1.0))", 768 | "MULTILINESTRING Z ((0.0 0.0 0.0, 1.0 1.0 1.0), (1.0 1.0 1.0, 2.0 2.0 2.0))", 769 | "MULTILINESTRING EMPTY", 770 | "POLYGON ((0.0 0.0, 1.0 1.0, 2.0 2.0, 3.0 3.0, 0.0 0.0))", 771 | "POLYGON ((0.0 0.0, 4.0 0.0, 4.0 4.0, 0.0 4.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))", 772 | "POLYGON Z ((0.0 0.0 0.0, 1.0 1.0 0.0, 2.0 2.0 0.0, 3.0 3.0 0.0, 0.0 0.0 0.0))", 773 | "POLYGON Z ((0.0 0.0 0.0, 4.0 0.0 0.0, 4.0 4.0 0.0, 0.0 4.0 0.0, 0.0 0.0 0.0), (1.0 1.0 0.0, 1.0 2.0 0.0, 2.0 2.0 0.0, 2.0 1.0 0.0, 1.0 1.0 0.0))", 774 | "POLYGON EMPTY", 775 | "MULTIPOLYGON (((0.0 0.0, 1.0 1.0, 2.0 2.0, 3.0 3.0, 0.0 0.0)))", 776 | "MULTIPOLYGON (((0.0 0.0, 1.0 1.0, 2.0 2.0, 3.0 3.0, 0.0 0.0)), ((1.0 1.0, 2.0 2.0, 3.0 3.0, 4.0 4.0, 1.0 1.0)))", 777 | "MULTIPOLYGON Z (((0.0 0.0 0.0, 1.0 1.0 0.0, 2.0 2.0 0.0, 3.0 3.0 0.0, 0.0 0.0 0.0)))", 778 | "MULTIPOLYGON Z (((0.0 0.0 0.0, 1.0 1.0 0.0, 2.0 2.0 0.0, 3.0 3.0 0.0, 0.0 0.0 0.0)), ((1.0 1.0 0.0, 2.0 2.0 0.0, 3.0 3.0 0.0, 4.0 4.0 0.0, 1.0 1.0 0.0)))", 779 | "MULTIPOLYGON EMPTY", 780 | "GEOMETRYCOLLECTION (POINT (0.0 0.0))", 781 | "GEOMETRYCOLLECTION (POLYGON EMPTY, MULTIPOLYGON (((0.0 0.0, 1.0 1.0, 2.0 2.0, 3.0 3.0, 0.0 0.0))))", 782 | "GEOMETRYCOLLECTION (POINT (0.0 0.0), MULTIPOINT ((0.0 0.0), (1.0 1.0)))", 783 | "GEOMETRYCOLLECTION Z (POLYGON Z ((0.0 0.0 0.0, 1.0 1.0 0.0, 2.0 2.0 0.0, 3.0 3.0 0.0, 0.0 0.0 0.0)), MULTIPOLYGON Z (((0.0 0.0 0.0, 1.0 1.0 0.0, 2.0 2.0 0.0, 3.0 3.0 0.0, 0.0 0.0 0.0))))", 784 | "GEOMETRYCOLLECTION EMPTY", 785 | ), 786 | ) 787 | def test_wkt(wkt: str): 788 | # Use Shapely to parse the input WKT so we know it is parsable by other tools. 789 | # Then load it into a Geometry and ensure the output WKT is the same as the input. 790 | assert parse_geometry_obj(shapely.from_wkt(wkt).__geo_interface__).wkt == wkt 791 | 792 | 793 | @pytest.mark.parametrize( 794 | "geom", 795 | ( 796 | Point(type="Point", coordinates=[0, 0], bbox=[0, 0, 0, 0]), 797 | Point(type="Point", coordinates=[0, 0]), 798 | MultiPoint(type="MultiPoint", coordinates=[(0.0, 0.0)], bbox=[0, 0, 0, 0]), 799 | MultiPoint(type="MultiPoint", coordinates=[(0.0, 0.0)]), 800 | LineString( 801 | type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)], bbox=[0, 0, 1, 1] 802 | ), 803 | LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]), 804 | MultiLineString( 805 | type="MultiLineString", 806 | coordinates=[[(0.0, 0.0), (1.0, 1.0)]], 807 | bbox=[0, 0, 1, 1], 808 | ), 809 | MultiLineString(type="MultiLineString", coordinates=[[(0.0, 0.0), (1.0, 1.0)]]), 810 | Polygon( 811 | type="Polygon", 812 | coordinates=[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]], 813 | bbox=[1.0, 2.0, 5.0, 6.0], 814 | ), 815 | Polygon( 816 | type="Polygon", 817 | coordinates=[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]], 818 | ), 819 | MultiPolygon( 820 | type="MultiPolygon", 821 | coordinates=[[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]], 822 | bbox=[1.0, 2.0, 5.0, 6.0], 823 | ), 824 | MultiPolygon( 825 | type="MultiPolygon", 826 | coordinates=[[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]], 827 | ), 828 | ), 829 | ) 830 | def test_geometry_serializer(geom: Geometry): 831 | # bbox should always be in the dictionary version of the model 832 | # but should only be in the JSON version if not None 833 | assert "bbox" in geom.model_dump() 834 | if geom.bbox is not None: 835 | assert "bbox" in geom.model_dump_json() 836 | else: 837 | assert "bbox" not in geom.model_dump_json() 838 | 839 | 840 | def test_geometry_collection_serializer(): 841 | geom = GeometryCollection( 842 | type="GeometryCollection", 843 | geometries=[ 844 | Point(type="Point", coordinates=[0, 0]), 845 | LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]), 846 | ], 847 | ) 848 | assert not geom.has_z 849 | # bbox will be in the Dict 850 | assert "bbox" in geom.model_dump() 851 | assert "bbox" in geom.model_dump()["geometries"][0] 852 | 853 | geom = GeometryCollection( 854 | type="GeometryCollection", 855 | geometries=[ 856 | Point(type="Point", coordinates=[0, 0, 0]), 857 | LineString( 858 | type="LineString", coordinates=[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] 859 | ), 860 | ], 861 | ) 862 | assert geom.has_z 863 | 864 | # bbox should not be in any Geometry nor at the top level 865 | geom_ser = json.loads(geom.model_dump_json()) 866 | assert "bbox" not in geom_ser 867 | assert "bbox" not in geom_ser["geometries"][0] 868 | assert "bbox" not in geom_ser["geometries"][0] 869 | 870 | geom = GeometryCollection( 871 | type="GeometryCollection", 872 | geometries=[ 873 | Point(type="Point", coordinates=[0, 0], bbox=[0, 0, 0, 0]), 874 | LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]), 875 | ], 876 | ) 877 | # bbox not in the top level but in the first geometry (point) 878 | geom_ser = json.loads(geom.model_dump_json()) 879 | assert "bbox" not in geom_ser 880 | assert "bbox" in geom_ser["geometries"][0] 881 | assert "bbox" not in geom_ser["geometries"][1] 882 | 883 | geom = GeometryCollection( 884 | type="GeometryCollection", 885 | geometries=[ 886 | Point(type="Point", coordinates=[0, 0], bbox=[0, 0, 0, 0]), 887 | LineString( 888 | type="LineString", 889 | coordinates=[(0.0, 0.0), (1.0, 1.0)], 890 | bbox=[0, 0, 1, 1], 891 | ), 892 | ], 893 | ) 894 | geom_ser = json.loads(geom.model_dump_json()) 895 | assert "bbox" not in geom_ser 896 | assert "bbox" in geom_ser["geometries"][0] 897 | assert "bbox" in geom_ser["geometries"][1] 898 | 899 | geom = GeometryCollection( 900 | type="GeometryCollection", 901 | geometries=[ 902 | Point(type="Point", coordinates=[0, 0]), 903 | LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]), 904 | ], 905 | bbox=[0, 0, 1, 1], 906 | ) 907 | geom_ser = json.loads(geom.model_dump_json()) 908 | assert "bbox" in geom_ser 909 | assert "bbox" not in geom_ser["geometries"][0] 910 | assert "bbox" not in geom_ser["geometries"][1] 911 | -------------------------------------------------------------------------------- /tests/test_package.py: -------------------------------------------------------------------------------- 1 | import geojson_pydantic 2 | 3 | 4 | def test_import_namespace(): 5 | """We have exposed all of the public objects via __all__""" 6 | assert sorted(geojson_pydantic.__all__) == [ 7 | "Feature", 8 | "FeatureCollection", 9 | "GeometryCollection", 10 | "LineString", 11 | "MultiLineString", 12 | "MultiPoint", 13 | "MultiPolygon", 14 | "Point", 15 | "Polygon", 16 | ] 17 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from geojson_pydantic.types import Position2D, Position3D 4 | 5 | 6 | @pytest.mark.parametrize("coordinates", [(1, 2), (1.0, 2.0), (1.01, 2.01)]) 7 | def test_position2d_valid_coordinates(coordinates): 8 | """ 9 | Two number elements as coordinates should be okay 10 | """ 11 | p = Position2D(longitude=coordinates[0], latitude=coordinates[1]) 12 | assert p[0] == coordinates[0] 13 | assert p[1] == coordinates[1] 14 | assert p.longitude == coordinates[0] 15 | assert p.latitude == coordinates[1] 16 | assert p == coordinates 17 | 18 | p = Position2D(*coordinates) 19 | assert p[0] == coordinates[0] 20 | assert p[1] == coordinates[1] 21 | assert p.longitude == coordinates[0] 22 | assert p.latitude == coordinates[1] 23 | assert p == coordinates 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "coordinates", [(1, 2, 3), (1.0, 2.0, 3.0), (1.01, 2.01, 3.01)] 28 | ) 29 | def test_position3d_valid_coordinates(coordinates): 30 | """ 31 | Three number elements as coordinates should be okay 32 | """ 33 | p = Position3D( 34 | longitude=coordinates[0], latitude=coordinates[1], altitude=coordinates[2] 35 | ) 36 | assert p[0] == coordinates[0] 37 | assert p[1] == coordinates[1] 38 | assert p[2] == coordinates[2] 39 | assert p.longitude == coordinates[0] 40 | assert p.latitude == coordinates[1] 41 | assert p.altitude == coordinates[2] 42 | assert p == coordinates 43 | 44 | p = Position3D(*coordinates) 45 | assert p[0] == coordinates[0] 46 | assert p[1] == coordinates[1] 47 | assert p[2] == coordinates[2] 48 | assert p.longitude == coordinates[0] 49 | assert p.latitude == coordinates[1] 50 | assert p.altitude == coordinates[2] 51 | --------------------------------------------------------------------------------