├── .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 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
--------------------------------------------------------------------------------