Skip to content

Commit

Permalink
[Done] GeoJSON IO for GEOS 3.10 (#413)
Browse files Browse the repository at this point in the history
  • Loading branch information
caspervdw committed Oct 31, 2021
1 parent b23bd31 commit ed4eb01
Show file tree
Hide file tree
Showing 6 changed files with 477 additions and 17 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Version 0.12 (unreleased)

**Major enhancements**

* ...
* Added GeoJSON input/output capabilities (``pygeos.from_geojson``,
``pygeos.to_geojson``) (#413).

**API Changes**

Expand Down
122 changes: 117 additions & 5 deletions pygeos/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,21 @@

from . import Geometry # noqa
from . import geos_capi_version_string, lib
from .decorators import requires_geos
from .enum import ParamEnum

__all__ = [
"from_geojson",
"from_shapely",
"from_wkb",
"from_wkt",
"to_geojson",
"to_shapely",
"to_wkb",
"to_wkt",
]


# Allowed options for handling WKB/WKT decoding errors
# Note: cannot use standard constructor since "raise" is a keyword
DecodingErrorOptions = ParamEnum(
Expand Down Expand Up @@ -82,16 +95,13 @@ def check_shapely_version():
_shapely_checked = True


__all__ = ["from_shapely", "from_wkb", "from_wkt", "to_shapely", "to_wkb", "to_wkt"]


def to_wkt(
geometry,
rounding_precision=6,
trim=True,
output_dimension=3,
old_3d=False,
**kwargs
**kwargs,
):
"""
Converts to the Well-Known Text (WKT) representation of a Geometry.
Expand Down Expand Up @@ -231,6 +241,55 @@ def to_wkb(
)


@requires_geos("3.10.0")
def to_geojson(geometry, indent=None, **kwargs):
"""Converts to the GeoJSON representation of a Geometry.
The GeoJSON format is defined in the `RFC 7946 <https://geojson.org/>`__.
NaN (not-a-number) coordinates will be written as 'null'.
The following are currently unsupported:
- Geometries of type LINEARRING: these are output as 'null'.
- Three-dimensional geometries: the third dimension is ignored.
Parameters
----------
geometry : str, bytes or array_like
indent : int, optional
If indent is a non-negative integer, then GeoJSON will be formatted.
An indent level of 0 will only insert newlines. None (the default)
selects the most compact representation.
**kwargs
For other keyword-only arguments, see the
`NumPy ufunc docs <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
Examples
--------
>>> to_geojson(Geometry("POINT (1 1)"))
'{"type":"Point","coordinates":[1.0,1.0]}'
>>> print(to_geojson(Geometry("POINT (1 1)"), indent=2))
{
"type": "Point",
"coordinates": [
1.0,
1.0
]
}
"""
# GEOS Tickets:
# - handle linearrings: https://trac.osgeo.org/geos/ticket/1140
# - support 3D: https://trac.osgeo.org/geos/ticket/1141
if indent is None:
indent = -1
elif not np.isscalar(indent):
raise TypeError("indent only accepts scalar values")
elif indent < 0:
raise ValueError("indent cannot be negative")

return lib.to_geojson(geometry, np.intc(indent), **kwargs)


def to_shapely(geometry):
"""
Converts PyGEOS geometries to Shapely.
Expand Down Expand Up @@ -322,7 +381,7 @@ def from_wkb(geometry, on_invalid="raise", **kwargs):
geometry : str or array_like
The WKB byte object(s) to convert.
on_invalid : {"raise", "warn", "ignore"}, default "raise"
- raise: an exception will be raised if WKB input geometries are invalid.
- raise: an exception will be raised if a WKB input geometry is invalid.
- warn: a warning will be raised and invalid WKB geometries will be
returned as ``None``.
- ignore: invalid WKB geometries will be returned as ``None`` without a warning.
Expand All @@ -348,6 +407,59 @@ def from_wkb(geometry, on_invalid="raise", **kwargs):
return lib.from_wkb(geometry, invalid_handler, **kwargs)


@requires_geos("3.10.0")
def from_geojson(geometry, on_invalid="raise", **kwargs):
"""Creates geometries from GeoJSON representations (strings).
If a GeoJSON is a FeatureCollection, it is read as a single geometry
(with type GEOMETRYCOLLECTION). This may be unpacked using the ``pygeos.get_parts``.
Properties are not read.
The GeoJSON format is defined in `RFC 7946 <https://geojson.org/>`__.
The following are currently unsupported:
- Three-dimensional geometries: the third dimension is ignored.
- Geometries having 'null' in the coordinates.
Parameters
----------
geometry : str, bytes or array_like
The GeoJSON string or byte object(s) to convert.
on_invalid : {"raise", "warn", "ignore"}, default "raise"
- raise: an exception will be raised if an input GeoJSON is invalid.
- warn: a warning will be raised and invalid input geometries will be
returned as ``None``.
- ignore: invalid input geometries will be returned as ``None`` without a warning.
**kwargs
For other keyword-only arguments, see the
`NumPy ufunc docs <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
See also
--------
get_parts
Examples
--------
>>> from_geojson('{"type": "Point","coordinates": [1, 2]}')
<pygeos.Geometry POINT (1 2)>
"""
# GEOS Tickets:
# - support 3D: https://trac.osgeo.org/geos/ticket/1141
# - handle null coordinates: https://trac.osgeo.org/geos/ticket/1142
if not np.isscalar(on_invalid):
raise TypeError("on_invalid only accepts scalar values")

invalid_handler = np.uint8(DecodingErrorOptions.get_value(on_invalid))

# ensure the input has object dtype, to avoid numpy inferring it as a
# fixed-length string dtype (which removes trailing null bytes upon access
# of array elements)
geometry = np.asarray(geometry, dtype=object)

return lib.from_geojson(geometry, invalid_handler, **kwargs)


def from_shapely(geometry, **kwargs):
"""
Creates geometries from shapely Geometry objects.
Expand Down
192 changes: 190 additions & 2 deletions pygeos/tests/test_io.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import pickle
import struct
from unittest import mock
Expand Down Expand Up @@ -25,6 +26,68 @@
# fmt: on


GEOJSON_GEOMETRY = json.dumps({"type": "Point", "coordinates": [125.6, 10.1]}, indent=4)
GEOJSON_FEATURE = json.dumps(
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [125.6, 10.1]},
"properties": {"name": "Dinagat Islands"},
},
indent=4,
)
GEOJSON_FEATURECOLECTION = json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.6]},
"properties": {"prop0": "value0"},
},
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[102.0, 0.0],
[103.0, 1.0],
[104.0, 0.0],
[105.0, 1.0],
],
},
"properties": {"prop1": 0.0, "prop0": "value0"},
},
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[100.0, 0.0],
[101.0, 0.0],
[101.0, 1.0],
[100.0, 1.0],
[100.0, 0.0],
]
],
},
"properties": {"prop1": {"this": "that"}, "prop0": "value0"},
},
],
},
indent=4,
)

GEOJSON_GEOMETRY_EXPECTED = pygeos.points(125.6, 10.1)
GEOJSON_COLLECTION_EXPECTED = [
pygeos.points([102.0, 0.6]),
pygeos.linestrings([[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]]),
pygeos.polygons(
[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]]
),
]


class ShapelyGeometryMock:
def __init__(self, g):
self.g = g
Expand Down Expand Up @@ -85,7 +148,7 @@ def test_from_wkt_none():


def test_from_wkt_exceptions():
with pytest.raises(TypeError, match="Expected bytes, got int"):
with pytest.raises(TypeError, match="Expected bytes or string, got int"):
pygeos.from_wkt(1)

with pytest.raises(
Expand Down Expand Up @@ -157,7 +220,7 @@ def test_from_wkb_none():


def test_from_wkb_exceptions():
with pytest.raises(TypeError, match="Expected bytes, got int"):
with pytest.raises(TypeError, match="Expected bytes or string, got int"):
pygeos.from_wkb(1)

# invalid WKB
Expand Down Expand Up @@ -676,3 +739,128 @@ def test_pickle_with_srid():
geom = pygeos.set_srid(point, 4326)
pickled = pickle.dumps(geom)
assert pygeos.get_srid(pickle.loads(pickled)) == 4326


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize(
"geojson,expected",
[
(GEOJSON_GEOMETRY, GEOJSON_GEOMETRY_EXPECTED),
(GEOJSON_FEATURE, GEOJSON_GEOMETRY_EXPECTED),
(
GEOJSON_FEATURECOLECTION,
pygeos.geometrycollections(GEOJSON_COLLECTION_EXPECTED),
),
([GEOJSON_GEOMETRY] * 2, [GEOJSON_GEOMETRY_EXPECTED] * 2),
(None, None),
([GEOJSON_GEOMETRY, None], [GEOJSON_GEOMETRY_EXPECTED, None]),
],
)
def test_from_geojson(geojson, expected):
actual = pygeos.from_geojson(geojson)
assert_geometries_equal(actual, expected)


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
def test_from_geojson_exceptions():
with pytest.raises(TypeError, match="Expected bytes or string, got int"):
pygeos.from_geojson(1)

with pytest.raises(pygeos.GEOSException, match="Error parsing JSON"):
pygeos.from_geojson("")

with pytest.raises(pygeos.GEOSException, match="Unknown geometry type"):
pygeos.from_geojson('{"type": "NoGeometry", "coordinates": []}')

with pytest.raises(pygeos.GEOSException, match="type must be array, but is null"):
pygeos.from_geojson('{"type": "LineString", "coordinates": null}')

# Note: The two below tests make GEOS 3.10.0 crash if it is compiled in Debug mode
# https://trac.osgeo.org/geos/ticket/1138
with pytest.raises(pygeos.GEOSException, match="ParseException"):
pygeos.from_geojson('{"geometry": null, "properties": []}')

with pytest.raises(pygeos.GEOSException, match="ParseException"):
pygeos.from_geojson('{"no": "geojson"}')


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
def test_from_geojson_warn_on_invalid():
with pytest.warns(Warning, match="Invalid GeoJSON"):
assert pygeos.from_geojson("", on_invalid="warn") is None

with pytest.warns(Warning, match="Invalid GeoJSON"):
assert pygeos.from_geojson('{"no": "geojson"}', on_invalid="warn") is None


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
def test_from_geojson_ignore_on_invalid():
with pytest.warns(None):
assert pygeos.from_geojson("", on_invalid="ignore") is None

with pytest.warns(None):
assert pygeos.from_geojson('{"no": "geojson"}', on_invalid="ignore") is None


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
def test_from_geojson_on_invalid_unsupported_option():
with pytest.raises(ValueError, match="not a valid option"):
pygeos.from_geojson(GEOJSON_GEOMETRY, on_invalid="unsupported_option")


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize(
"expected,geometry",
[
(GEOJSON_GEOMETRY, GEOJSON_GEOMETRY_EXPECTED),
([GEOJSON_GEOMETRY] * 2, [GEOJSON_GEOMETRY_EXPECTED] * 2),
(None, None),
([GEOJSON_GEOMETRY, None], [GEOJSON_GEOMETRY_EXPECTED, None]),
],
)
def test_to_geojson(geometry, expected):
actual = pygeos.to_geojson(geometry, indent=4)
assert np.all(actual == np.asarray(expected))


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize("indent", [None, 0, 4])
def test_to_geojson_indent(indent):
separators = (",", ":") if indent is None else (",", ": ")
expected = json.dumps(
json.loads(GEOJSON_GEOMETRY), indent=indent, separators=separators
)
actual = pygeos.to_geojson(GEOJSON_GEOMETRY_EXPECTED, indent=indent)
assert actual == expected


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
def test_to_geojson_exceptions():
with pytest.raises(TypeError):
pygeos.to_geojson(1)


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize(
"geom",
[
empty_point,
pygeos.multipoints([empty_point, point]),
pygeos.geometrycollections([empty_point, point]),
pygeos.geometrycollections([pygeos.geometrycollections([empty_point]), point]),
],
)
def test_to_geojson_point_empty(geom):
# Pending GEOS ticket: https://trac.osgeo.org/geos/ticket/1139
with pytest.raises(ValueError):
assert pygeos.to_geojson(geom)


@pytest.mark.skipif(pygeos.geos_version < (3, 10, 0), reason="GEOS < 3.10")
@pytest.mark.parametrize("geom", all_types)
def test_geojson_all_types(geom):
if pygeos.get_type_id(geom) == pygeos.GeometryType.LINEARRING:
pytest.skip("Linearrings are not preserved in GeoJSON")
geojson = pygeos.to_geojson(geom)
actual = pygeos.from_geojson(geojson)
assert_geometries_equal(actual, geom)

0 comments on commit ed4eb01

Please sign in to comment.