From a41d55cb2e72a26457e34a14013dbbba6ac2fa76 Mon Sep 17 00:00:00 2001 From: Pablo Panero Date: Fri, 23 Oct 2020 17:09:20 +0200 Subject: [PATCH] schemas: add geojson support --- marshmallow_utils/schemas/__init__.py | 18 ++++++ marshmallow_utils/schemas/geojson.py | 92 +++++++++++++++++++++++++++ setup.py | 2 + tests/schemas/test_geojson.py | 91 ++++++++++++++++++++++++++ tests/schemas/test_multipoint.py | 42 ++++++++++++ tests/schemas/test_point.py | 46 ++++++++++++++ tests/schemas/test_polygon.py | 44 +++++++++++++ 7 files changed, 335 insertions(+) create mode 100644 marshmallow_utils/schemas/__init__.py create mode 100644 marshmallow_utils/schemas/geojson.py create mode 100644 tests/schemas/test_geojson.py create mode 100644 tests/schemas/test_multipoint.py create mode 100644 tests/schemas/test_point.py create mode 100644 tests/schemas/test_polygon.py diff --git a/marshmallow_utils/schemas/__init__.py b/marshmallow_utils/schemas/__init__.py new file mode 100644 index 0000000..b582e36 --- /dev/null +++ b/marshmallow_utils/schemas/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Marshmallow schemas.""" + +from .geojson import GeometryObjectSchema, MultiPointSchema, PointSchema, \ + PolygonSchema + +__all__ = ( + 'GeometryObjectSchema', + 'MultiPointSchema', + 'PointSchema', + 'PolygonSchema', +) diff --git a/marshmallow_utils/schemas/geojson.py b/marshmallow_utils/schemas/geojson.py new file mode 100644 index 0000000..2d0d171 --- /dev/null +++ b/marshmallow_utils/schemas/geojson.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""GeoJSON Schema.""" + +from geojson import MultiPoint, Point, Polygon +from marshmallow import Schema, ValidationError +from marshmallow.fields import Constant, Float, List +from marshmallow.validate import Validator +from marshmallow_oneofschema import OneOfSchema + + +class GeometryValidator(Validator): + """Validator for GeoJSON geometry objects.""" + + def __init__(self, geometry_cls): + """Initialize the validator. + + :param geometry_cls: The GeoJSON geometry class to validate against. + """ + self.geometry_cls = geometry_cls + + def __call__(self, value): + """Validate a geometry object.""" + obj = self.geometry_cls(value) + if not obj.is_valid: + errors = obj.errors() + raise ValidationError({"geojson": {"coordinates": errors}}) + return value + + +class PointSchema(Schema): + """GeoJSON Point schema. + + See https://tools.ietf.org/html/rfc7946#section-3.1.2 + """ + + coordinates = List(Float, required=True, validate=GeometryValidator(Point)) + type = Constant('Point') + + +class MultiPointSchema(Schema): + """GeoJSON MultiPoint schema. + + See https://tools.ietf.org/html/rfc7946#section-3.1.3 + """ + + coordinates = List( + List(Float), required=True, validate=GeometryValidator(MultiPoint)) + type = Constant('MultiPoint') + + +class PolygonSchema(Schema): + """GeoJSON Polygon schema. + + See https://tools.ietf.org/html/rfc7946#section-3.1.6 + """ + + coordinates = List( + List(List(Float)), required=True, validate=GeometryValidator(Polygon)) + type = Constant('Polygon') + + +class GeometryObjectSchema(OneOfSchema): + """A GeoJSON Geometry Object schema. + + See https://tools.ietf.org/html/rfc7946#section-3.1 + + Only Point, MultiPoint and Polygon are supported. + """ + + type_schemas = { + "Point": PointSchema, + "MultiPoint": MultiPointSchema, + "Polygon": PolygonSchema, + } + + def get_obj_type(self, obj): + """Finds the object type in the dictionary. + + This function is needed because the schemas return the dict itself. + Not a geojson object. + """ + obj_type = obj.get("type") + if not obj_type or obj_type not in self.type_schemas.keys(): + raise Exception(f"Unknown GeoJSON object type: {obj_type}") + + return obj_type diff --git a/setup.py b/setup.py index 1bab741..5c9a7b4 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,9 @@ 'bleach>=3.0.0', 'edtf>=4.0.1,<5.0.0', 'ftfy>=4.4.3', + 'geojson>=2.5.0', 'marshmallow>=3.0.0,<4.0.0', + 'marshmallow-oneofschema>=2.1.0', 'pycountry>=18.12.8', 'uritemplate>=3.0.1', 'werkzeug>=1.0.0', diff --git a/tests/schemas/test_geojson.py b/tests/schemas/test_geojson.py new file mode 100644 index 0000000..e0d4178 --- /dev/null +++ b/tests/schemas/test_geojson.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Tests for marshmallow GeoJSON schema.""" + +import pytest +from marshmallow import ValidationError + +from marshmallow_utils.schemas import GeometryObjectSchema + + +def test_point(): + valid_full = { + "type": "Point", + "coordinates": [-32.94682, -60.63932] + } + + point = GeometryObjectSchema().load(valid_full) + assert valid_full == point == GeometryObjectSchema().dump(point) + + +def test_point_fail(): + invalid_input = { + "type": "Point", + "coordinates": [-32.94682] + } + pytest.raises(ValidationError, GeometryObjectSchema().load, invalid_input) + + +def test_multipoint(): + valid_full = { + "type": "MultiPoint", + "coordinates": [[-32.94682, -60.63932], [-32.94682, -60.63932, 10.0]] + } + + multipoint = GeometryObjectSchema().load(valid_full) + assert valid_full == multipoint == GeometryObjectSchema().dump(multipoint) + + +def test_multipoint_fail(): + invalid_input = { + "type": "MultiPoint", + "coordinates": [-32.94682, -60.63932] + } + pytest.raises(ValidationError, GeometryObjectSchema().load, invalid_input) + + +def test_polygon(): + valid_full = { + "type": "Polygon", + "coordinates": [[ + [2.38, 57.322], [23.194, -20.28], [-120.43, 19.15], [2.38, 57.322] + ]] + } + + polygon = GeometryObjectSchema().load(valid_full) + assert valid_full == polygon == GeometryObjectSchema().dump(polygon) + + +def test_polygon_fail(): + invalid_input = { + "type": "Polygon", + "coordinates": [[[2.38, 57.322], [23.194, -20.28], [2.38, 57.322]]] + } + pytest.raises(ValidationError, GeometryObjectSchema().load, invalid_input) + + +@pytest.mark.parametrize("geotype", [("Point"), ("MultiPoint")]) +def test_point_no_coordinates_fail(geotype): + invalid_point = {"type": geotype} + + pytest.raises(ValidationError, GeometryObjectSchema().load, invalid_point) + + +def test_not_supported_type_fail(): + invalid_type = {"type": "invalid"} + + pytest.raises(ValidationError, GeometryObjectSchema().load, invalid_type) + + +def test_no_type_fail(): + invalid_no_type = { + "coordinates": [-32.94682, -60.63932] + } + + pytest.raises( + ValidationError, GeometryObjectSchema().load, invalid_no_type) diff --git a/tests/schemas/test_multipoint.py b/tests/schemas/test_multipoint.py new file mode 100644 index 0000000..c311781 --- /dev/null +++ b/tests/schemas/test_multipoint.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Tests for marshmallow GeoJSON MultiPoint schema.""" + +import pytest +from marshmallow import ValidationError + +from marshmallow_utils.schemas import MultiPointSchema + + +@pytest.mark.parametrize("coordinates", [ + ([[-32.94682, -60.63932], [-32.94682, -60.63932, 10.0]]), + ([[-32.99, -60.63], [-32.94, -60.63], [-32.92, -60.32, 10.0]]), +]) +def test_multipoint(coordinates): + valid_full = { + "type": "MultiPoint", + "coordinates": coordinates + } + loaded = MultiPointSchema().load(valid_full) + # NOTE: Since the schemas return the dict itself, the loaded object + # is the same than the input and dumped objects (dicts) + assert valid_full == loaded == MultiPointSchema().dump(loaded) + + +@pytest.mark.parametrize("coordinates", [ + ("-32.94682,-60.63932, -32.94682, -60.63932, 10.0"), + (["-32.94682,-60.63932", "-32.94682, -60.63932, 10.0"]), + ([-32.94682, -60.63932, 10.0, 10.0]), + ([[-32.99, -60.63], [-32.94], [-32.92, -60.32, 10.0]]) +]) +def test_multipoint_fail(coordinates): + invalid_point = { + "type": "MultiPoint", + "coordinates": coordinates + } + pytest.raises(ValidationError, MultiPointSchema().load, invalid_point) diff --git a/tests/schemas/test_point.py b/tests/schemas/test_point.py new file mode 100644 index 0000000..b31e21e --- /dev/null +++ b/tests/schemas/test_point.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Tests for marshmallow GeoJSON Point schema.""" + +import pytest +from marshmallow import ValidationError + +from marshmallow_utils.schemas import PointSchema + + +@pytest.mark.parametrize("coordinates", [ + ([-32.94682, -60.63932]), + ([-32.94682, -60.63932, 10.0]), +]) +def test_point(coordinates): + valid_full = { + "type": "Point", + "coordinates": coordinates + } + + loaded = PointSchema().load(valid_full) + # NOTE: Since the schemas return the dict itself, the loaded object + # is the same than the input and dumped objects (dicts) + assert valid_full == loaded == PointSchema().dump(loaded) + + +# NOTE: ["-32.94682", "-60.63932"] is valid because the Float field +# deserializes properly the string into float, so it becomes +# [-32.94682, -60.63932] +@pytest.mark.parametrize("coordinates", [ + ("-32.94682,-60.63932"), + (["-32.94682,-60.63932"]), + ([-32.94682, -60.63932, 10.0, 10.0]), + ([-32.94682]) +]) +def test_point_fail(coordinates): + invalid_point = { + "type": "Point", + "coordinates": coordinates + } + pytest.raises(ValidationError, PointSchema().load, invalid_point) diff --git a/tests/schemas/test_polygon.py b/tests/schemas/test_polygon.py new file mode 100644 index 0000000..885b759 --- /dev/null +++ b/tests/schemas/test_polygon.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Tests for marshmallow GeoJSON Polygon schema.""" + +import pytest +from marshmallow import ValidationError + +from marshmallow_utils.schemas import PolygonSchema + + +@pytest.mark.parametrize("coordinates", [ + ([[[2.38, 57.322], [23.194, -20.28], [-120.43, 19.15], [2.38, 57.322]]]), +]) +def test_polygon(coordinates): + print(coordinates) + valid_full = { + "type": "Polygon", + "coordinates": coordinates + } + + loaded = PolygonSchema().load(valid_full) + # NOTE: Since the schemas return the dict itself, the loaded object + # is the same than the input and dumped objects (dicts) + assert valid_full == loaded == PolygonSchema().dump(loaded) + + +@pytest.mark.parametrize("coordinates", [ + ([2.38, 57.322, -120.43, 19.15]), + ([[2.38, 57.322], [-120.43, 19.15]]), + ([[[2.38, 57.322], [23.194, -20.28], [24.194, -19.2]]]), + ([[[2.38, 57.322], [2.38, 57.322]]]), + ([[[2.38, 57.322], [23.194, -20.28], [2.38, 57.322]]]), +]) +def test_polygon_fail(coordinates): + invalid_point = { + "type": "Polygon", + "coordinates": coordinates + } + pytest.raises(ValidationError, PolygonSchema().load, invalid_point)