Skip to content

Commit c13af05

Browse files
authored
feat(geoarrow-types): Implement non-PROJJSON crses and crs_type metadata (#58)
1 parent 9e6e518 commit c13af05

File tree

7 files changed

+207
-7
lines changed

7 files changed

+207
-7
lines changed

geoarrow-pandas/tests/test_geoarrow_pandas.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ def test_dtype_strings():
3434
assert dtype2 == dtype
3535

3636
dtype = gapd.GeoArrowExtensionDtype(ga.point().with_crs(ga.OGC_CRS84))
37-
assert str(dtype) == 'geoarrow.point{"crs": ' + ga.OGC_CRS84.to_json() + "}"
37+
assert (
38+
str(dtype)
39+
== 'geoarrow.point{"crs": '
40+
+ ga.OGC_CRS84.to_json()
41+
+ ', "crs_type": "projjson"}'
42+
)
3843
dtype2 = gapd.GeoArrowExtensionDtype.construct_from_string(str(dtype))
3944
assert dtype2 == dtype
4045

geoarrow-pyarrow/tests/test_compute.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ def test_with_crs():
350350
assert isinstance(crsified.type, ga.WktType)
351351
assert crsified.type.crs.to_json_dict() == types.OGC_CRS84.to_json_dict()
352352

353+
crsified2 = _compute.with_crs(crsified, "OGC:CRS84")
354+
assert repr(crsified2.type.crs) == "StringCrs(OGC:CRS84)"
355+
353356
crsnope = _compute.with_crs(crsified, None)
354357
assert crsnope.type.crs is None
355358

geoarrow-pyarrow/tests/test_pyarrow.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,19 @@ def test_geometry_type_with():
4848
type_spherical = type_obj.with_edge_type(ga.EdgeType.SPHERICAL)
4949
assert type_spherical.edge_type == ga.EdgeType.SPHERICAL
5050

51-
# Explicit type
5251
type_crs = type_obj.with_crs(types.OGC_CRS84)
5352
assert type_crs.crs == types.OGC_CRS84
5453

54+
type_crs = type_obj.with_crs("OGC:CRS84")
55+
assert repr(type_crs.crs) == "StringCrs(OGC:CRS84)"
56+
5557

5658
def test_type_with_crs_pyproj():
5759
pyproj = pytest.importorskip("pyproj")
5860
type_obj = ga.wkb()
5961

60-
# Implicit type
6162
type_crs = type_obj.with_crs(pyproj.CRS("EPSG:32620"))
63+
assert isinstance(type_crs.crs, pyproj.CRS)
6264
crs_dict = type_crs.crs.to_json_dict()
6365
assert crs_dict["id"]["code"] == 32620
6466

geoarrow-types/src/geoarrow/types/crs.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ def to_json_dict(self) -> Mapping:
102102

103103
return deepcopy(self._obj)
104104

105+
def to_wkt(self) -> str:
106+
# This could in theory be written to not use pyproj; however, the
107+
# main purpose of this method is to enable pyproj.CRS(self) so it
108+
# may not matter.
109+
import pyproj
110+
111+
return pyproj.CRS(self.to_json_dict()).to_wkt()
112+
105113
def __repr__(self) -> str:
106114
try:
107115
crs_dict = self.to_json_dict()
@@ -116,6 +124,70 @@ def __repr__(self) -> str:
116124
return f"ProjJsonCrs({self.to_json()[:80]})"
117125

118126

127+
class StringCrs(Crs):
128+
def __init__(self, crs: Union[str, bytes]):
129+
if isinstance(crs, bytes):
130+
self._crs = crs.decode()
131+
else:
132+
self._crs = str(crs)
133+
134+
def __geoarrow_crs_json_values__(self):
135+
# Try to avoid escaping valid JSON into a JSON string
136+
try:
137+
return {"crs": json.loads(self._crs)}
138+
except ValueError:
139+
return {"crs": self._crs}
140+
141+
def __eq__(self, value):
142+
if isinstance(value, UnspecifiedCrs):
143+
return False
144+
elif isinstance(value, StringCrs) and self._crs == value._crs:
145+
return True
146+
elif hasattr(value, "to_json_dict"):
147+
return self.to_json_dict() == value.to_json_dict()
148+
else:
149+
return False
150+
151+
@classmethod
152+
def from_json(cls, crs_json: str) -> "StringCrs":
153+
return StringCrs(crs_json)
154+
155+
@classmethod
156+
def from_json_dict(cls, crs_dict: Mapping) -> "Crs":
157+
return StringCrs(json.dumps(crs_dict))
158+
159+
def to_json(self) -> str:
160+
out = self._try_parse_json_object()
161+
if out:
162+
return self._crs
163+
164+
# Fall back on pyproj
165+
import pyproj
166+
167+
return pyproj.CRS(self._crs).to_json()
168+
169+
def to_json_dict(self) -> Mapping:
170+
return json.loads(self.to_json())
171+
172+
def to_wkt(self) -> str:
173+
import pyproj
174+
175+
crs_repr = self.__geoarrow_crs_json_values__()["crs"]
176+
return pyproj.CRS(crs_repr).to_wkt()
177+
178+
def __repr__(self) -> str:
179+
crs_repr = self.__geoarrow_crs_json_values__()["crs"]
180+
return f"StringCrs({crs_repr})"
181+
182+
def _try_parse_json_object(self) -> Optional[dict]:
183+
try:
184+
obj = json.loads(self._crs)
185+
if isinstance(obj, dict):
186+
return obj
187+
except ValueError:
188+
return None
189+
190+
119191
_CRS_LONLAT_DICT = {
120192
"$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
121193
"type": "GeographicCRS",
@@ -233,8 +305,12 @@ def create(obj) -> Optional[Crs]:
233305
return None
234306
elif hasattr(obj, "to_json_dict"):
235307
return obj
236-
else:
308+
elif isinstance(obj, dict):
237309
return ProjJsonCrs(obj)
310+
elif isinstance(obj, (str, bytes)):
311+
return StringCrs(obj)
312+
else:
313+
raise ValueError(f"Can't create geoarrow.types.Crs from {obj}")
238314

239315

240316
def _coalesce2(value, default):

geoarrow-types/src/geoarrow/types/type_spec.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,20 @@ def extension_metadata(self) -> str:
8080
if self.edge_type == EdgeType.SPHERICAL:
8181
metadata["edges"] = "spherical"
8282

83-
if self.crs is not None:
83+
if self.crs is None:
84+
pass
85+
elif hasattr(self.crs, "__geoarrow_crs_json_values__"):
86+
metadata.update(self.crs.__geoarrow_crs_json_values__())
87+
elif hasattr(self.crs, "to_json_dict"):
8488
metadata["crs"] = self.crs.to_json_dict()
89+
metadata["crs_type"] = "projjson"
90+
elif isinstance(self.crs, (str, bytes)):
91+
string_crs = crs.StringCrs(self.crs)
92+
metadata.update(string_crs.__geoarrow_crs_json_values__())
93+
else:
94+
raise ValueError(
95+
f"Can't serialize crs object {self.crs} to extension metadata"
96+
)
8597

8698
return json.dumps(metadata)
8799

@@ -262,7 +274,10 @@ def from_extension_metadata(extension_metadata: str):
262274
out_edges = EdgeType.create(metadata["edges"])
263275

264276
if "crs" in metadata and metadata["crs"] is not None:
265-
out_crs = crs.ProjJsonCrs(metadata["crs"])
277+
if "crs_type" in metadata and metadata["crs_type"] == "projjson":
278+
out_crs = crs.ProjJsonCrs(metadata["crs"])
279+
else:
280+
out_crs = crs.StringCrs(metadata["crs"])
266281

267282
return TypeSpec(edge_type=out_edges, crs=out_crs)
268283

geoarrow-types/tests/test_crs.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,33 @@ def test_projjson_crs_repr():
3838
assert repr(crs_invalid_json) == 'ProjJsonCrs({"this is not valid json)'
3939

4040

41+
def test_string_crs():
42+
crs_obj = crs.StringCrs("arbitrary string")
43+
assert crs_obj.__geoarrow_crs_json_values__() == {"crs": "arbitrary string"}
44+
assert repr(crs_obj) == "StringCrs(arbitrary string)"
45+
46+
47+
def test_string_crs_quoted_json_string():
48+
crs_obj = crs.StringCrs('"this is json"')
49+
assert crs_obj.__geoarrow_crs_json_values__() == {"crs": "this is json"}
50+
assert repr(crs_obj) == "StringCrs(this is json)"
51+
52+
53+
def test_string_crs_json_object():
54+
crs_obj = crs.StringCrs('{"valid": "object"}')
55+
assert crs_obj.to_json() == '{"valid": "object"}'
56+
assert crs_obj.to_json_dict() == {"valid": "object"}
57+
58+
59+
def test_string_crs_pyproj():
60+
pyproj = pytest.importorskip("pyproj")
61+
62+
crs_obj = crs.StringCrs("OGC:CRS84")
63+
assert crs_obj.to_json_dict() == pyproj.CRS("OGC:CRS84").to_json_dict()
64+
assert crs_obj.to_json() == pyproj.CRS("OGC:CRS84").to_json()
65+
assert crs_obj.to_wkt() == pyproj.CRS("OGC:CRS84").to_wkt()
66+
67+
4168
def test_crs_coalesce():
4269
assert crs._coalesce2(crs.UNSPECIFIED, crs.OGC_CRS84) is crs.OGC_CRS84
4370
assert crs._coalesce2(None, crs.OGC_CRS84) is None

geoarrow-types/tests/test_type_spec.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
import json
23

34
import geoarrow.types as gt
45
from geoarrow.types.constants import (
@@ -9,7 +10,12 @@
910
CoordType,
1011
)
1112
from geoarrow.types.type_spec import TypeSpec
12-
from geoarrow.types.crs import OGC_CRS84, UNSPECIFIED as UNSPECIFIED_CRS
13+
from geoarrow.types.crs import (
14+
OGC_CRS84,
15+
UNSPECIFIED as UNSPECIFIED_CRS,
16+
StringCrs,
17+
ProjJsonCrs,
18+
)
1319

1420

1521
def test_type_spec_repr():
@@ -54,6 +60,72 @@ def test_type_spec_extension_metadata():
5460
TypeSpec().extension_metadata()
5561

5662

63+
def test_type_spec_metadata_crs():
64+
# StringCrs
65+
spec = TypeSpec(edge_type=EdgeType.PLANAR, crs=StringCrs("EPSG:32620"))
66+
assert spec.extension_metadata() == '{"crs": "EPSG:32620"}'
67+
68+
# ProjJsonCrs
69+
spec = spec.override(crs=OGC_CRS84)
70+
assert json.loads(spec.extension_metadata())["crs"] == OGC_CRS84.to_json_dict()
71+
assert json.loads(spec.extension_metadata())["crs_type"] == "projjson"
72+
73+
# Raw string
74+
spec = TypeSpec(edge_type=EdgeType.PLANAR, crs="EPSG:32620")
75+
assert spec.extension_metadata() == '{"crs": "EPSG:32620"}'
76+
77+
# Raw bytes
78+
spec = TypeSpec(edge_type=EdgeType.PLANAR, crs="EPSG:32620".encode())
79+
assert spec.extension_metadata() == '{"crs": "EPSG:32620"}'
80+
81+
# Accidentally JSON-encoded string
82+
spec = TypeSpec(edge_type=EdgeType.PLANAR, crs='"EPSG:32620"')
83+
assert spec.extension_metadata() == '{"crs": "EPSG:32620"}'
84+
85+
# UnspecifiedCrs
86+
with pytest.raises(ValueError, match="edge_type or crs is unspecified"):
87+
TypeSpec(crs=UNSPECIFIED_CRS).extension_metadata()
88+
89+
90+
def test_type_spec_metadata_crs_load():
91+
spec = TypeSpec.from_extension_metadata('{"crs": "EPSG:32620"}')
92+
assert isinstance(spec.crs, StringCrs)
93+
94+
spec = TypeSpec.from_extension_metadata('{"crs": {}, "crs_type": "projjson"}')
95+
assert isinstance(spec.crs, ProjJsonCrs)
96+
assert spec.crs.to_json_dict() == {}
97+
98+
99+
def test_type_spec_metadata_crs_sanitize():
100+
crs_obj = TypeSpec().override(crs="EPSG:32620").crs
101+
assert isinstance(crs_obj, StringCrs)
102+
assert crs_obj._crs == "EPSG:32620"
103+
assert TypeSpec().override(crs=crs_obj).crs is crs_obj
104+
105+
crs_obj = TypeSpec().override(crs=ProjJsonCrs({})).crs
106+
assert isinstance(crs_obj, ProjJsonCrs)
107+
assert TypeSpec().override(crs=crs_obj).crs is crs_obj
108+
109+
110+
def test_type_spec_metadata_crs_pyproj():
111+
pyproj = pytest.importorskip("pyproj")
112+
113+
spec = TypeSpec(edge_type=EdgeType.PLANAR, crs=pyproj.CRS("EPSG:32620"))
114+
metadata_obj = json.loads(spec.extension_metadata())
115+
assert metadata_obj["crs"] == pyproj.CRS("EPSG:32620").to_json_dict()
116+
assert metadata_obj["crs_type"] == "projjson"
117+
118+
spec2 = TypeSpec.from_extension_metadata(spec.extension_metadata())
119+
assert isinstance(spec2.crs, ProjJsonCrs)
120+
assert pyproj.CRS(spec2.crs) == pyproj.CRS("EPSG:32620")
121+
assert spec2.crs == pyproj.CRS("EPSG:32620")
122+
assert pyproj.CRS("EPSG:32620") == spec2.crs
123+
124+
crs_obj = TypeSpec().override(crs=pyproj.CRS("EPSG:32620")).crs
125+
assert isinstance(crs_obj, pyproj.CRS)
126+
assert TypeSpec().override(crs=crs_obj).crs is crs_obj
127+
128+
57129
def test_type_spec_create():
58130
# From TypeSpec
59131
spec = TypeSpec()

0 commit comments

Comments
 (0)