Skip to content

Commit 0335fd8

Browse files
committed
Require EPSG when requesting geometry return fields too.
1 parent 748c539 commit 0335fd8

File tree

8 files changed

+155
-65
lines changed

8 files changed

+155
-65
lines changed

docs/output_fields.rst

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,37 +53,23 @@ Finding geometry columns
5353
from pydov.search.boring import BoringSearch
5454

5555
bs = BoringSearch()
56-
print([f for f in bs.get_fields().values() if f['type'] == 'geometry'])
56+
print(bs.get_fields(type='geometry'))
5757

58-
[{'name': 'geom', 'definition': None, 'type': 'geometry', 'notnull': False, 'query': False, 'cost': 1}]
58+
{'geom': {'name': 'geom', 'definition': None, 'type': 'geometry', 'list': False, 'notnull': False, 'query': False, 'cost': 1}}
5959

6060

61-
Default coordinate reference system
62-
When adding the geometry field as return field, you will get the corresponding geometry in the default coordinate reference system (CRS) of the layer - most of our layers use the Belgian Lambert 72 CRS (EPSG:31370) by default::
61+
Adding geometry return fields
62+
To add geometry columns as return field, you can add an instance of :class:`pydov.search.fields.GeometryReturnField` specifying both the geometry field name and the desired CRS::
6363

6464
df = bs.search(
65-
return_fields=['pkey_boring', 'geom'],
65+
return_fields=['pkey_boring', GeometryReturnField('geom', epsg=31370)],
6666
max_features=1
6767
)
6868
print(df)
6969

7070
pkey_boring geom
7171
0 https://www.dov.vlaanderen.be/data/boring/2016... POINT (92424 170752)
7272

73-
Custom coordinate reference systems
74-
To get the geometry in another CRS, instead of adding just the fieldname as return field, you can add an instance of :class:`pydov.search.fields.GeometryReturnField` specifying both the field name and the desired CRS. If you'd like to receive the geometries in GPS coordinates (lon/lat, or EPSG:4326) instead of Belgian Lambert 72, you could::
75-
76-
from pydov.search.fields import GeometryReturnField
77-
78-
df = bs.search(
79-
return_fields=['pkey_boring', GeometryReturnField('geom', epsg=4326)],
80-
max_features=1
81-
)
82-
print(df)
83-
84-
pkey_boring geom
85-
0 https://www.dov.vlaanderen.be/data/boring/2016... POINT (3.5512 50.8443)
86-
8773

8874
Turning the result into a GeoPandas GeoDataFrame
8975
pydov result dataframes which include a geometry column can easily be transformed from a normal Pandas DataFrame into a GeoPandas GeoDataFrame for further (geo) analysis, exporting or use in a new query using a :class:`pydov.util.location.GeopandasFilter`::

docs/reference.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ Generic
226226
Fields
227227
------
228228

229+
.. automodule:: pydov.search.fields
230+
:members:
231+
:show-inheritance:
232+
229233
.. automodule:: pydov.types.fields
230234
:members:
231235
:show-inheritance:

pydov/search/abstract.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,9 @@ def _search(self, location=None, query=None, return_fields=None,
650650
When a field that can only be used as a query parameter is used as
651651
a return field.
652652
653+
When a geometry field is referenced by name in the return fields,
654+
instead of as instance of GeometryReturnField.
655+
653656
"""
654657
self._pre_search_validation(location, query, sort_by, return_fields,
655658
max_features)
@@ -693,6 +696,16 @@ def _search(self, location=None, query=None, return_fields=None,
693696
else:
694697
geom_return_crs = None
695698

699+
geom_fields = self.get_fields(type='geometry')
700+
for f in return_fields:
701+
if not isinstance(f, GeometryReturnField) \
702+
and f.name in geom_fields:
703+
raise InvalidFieldError(
704+
f"Cannot use field '{f.name}' of type 'geometry' in "
705+
"return_fields by name, use GeometryReturnField "
706+
f"instead, e.g. GeometryReturnField('{f.name}', "
707+
"epsg=31370).")
708+
696709
extra_custom_fields = set()
697710
for custom_field in self._type.get_fields(
698711
source=('custom_wfs',)).values():

pydov/search/fields.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
from pydov.util.notebook import HtmlFormatter
33
from pydov.util.wrappers import AbstractDictLike
4+
from pydov.util.location import EpsgValidator
45

56

67
class ReturnFieldList(list):
@@ -121,24 +122,33 @@ def __init__(self, name):
121122
super().__init__(name)
122123

123124

124-
class GeometryReturnField(AbstractReturnField):
125+
class GeometryReturnField(AbstractReturnField, EpsgValidator):
125126
def __init__(self, geometry_field, epsg=None):
126127
"""Initialise a geometry return field.
127128
128129
Parameters
129130
----------
130131
geometry_field : str
131132
Name of the geometry field.
132-
epsg : int, optional
133+
epsg : int
133134
EPSG code of the CRS of the geometries that will be returned.
134-
Defaults to None, which means the default CRS of the WFS layer.
135+
136+
Raises
137+
------
138+
TypeError
139+
If `epsg` is None, missing or not an integer.
140+
141+
ValueError
142+
If `epsg` invalid.
143+
144+
Notes
145+
-----
146+
See https://epsg.io for a list of valid EPSG codes.
147+
135148
"""
136149
super().__init__(geometry_field)
137150

138-
if epsg is not None:
139-
if not isinstance(epsg, int):
140-
raise TypeError('epsg should be an integer value')
141-
151+
self._validate_epsg(epsg)
142152
self.epsg = epsg
143153

144154

pydov/util/location.py

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,10 @@
1616
from owslib.fes2 import Or
1717

1818

19-
class AbstractLocation(object):
20-
"""Abstract base class for location types (f.ex. point, box, polygon).
21-
22-
Locations are GML elements, for inclusion in the WFS GetFeature request.
23-
As described in the Filter Encoding 2.0 standard, locations are expressed
24-
using GML 3.2.
25-
26-
The initialisation should require all necessary parameters to construct
27-
a valid location of this type: i.e. all locations should be valid after
28-
initialisation.
29-
30-
"""
31-
32-
def _get_id_seed(self):
33-
"""Get the seed for generating a random but stable GML ID for this
34-
location.
35-
36-
Should return the same value for locations considered equal.
37-
"""
38-
raise NotImplementedError('This should be implemented in a subclass.')
19+
class EpsgValidator(object):
20+
"""Class grouping methods for enforcing and validating EPSG parameters."""
3921

40-
def _is_valid_epsg(self, epsg):
22+
def _is_existing_epsg(self, epsg):
4123
"""Check whether the provided EPSG code is a valid EPSG code according
4224
to pyproj. Pass if pyproj is not installed.
4325
@@ -62,7 +44,8 @@ def _is_valid_epsg(self, epsg):
6244

6345
def _validate_epsg(self, epsg):
6446
"""
65-
Validate the provided EPSG code.
47+
Validate the provided EPSG code, raising an exception if the EPSG
48+
code is either missing or invalid.
6649
6750
Parameters
6851
----------
@@ -80,8 +63,9 @@ def _validate_epsg(self, epsg):
8063
how to provide a valid code.
8164
8265
Notes
83-
--------
66+
-----
8467
https://epsg.io for a list of valid EPSG codes.
68+
8569
"""
8670

8771
generic_error = (f"Example usage: {self.__class__.__name__}"
@@ -101,11 +85,33 @@ def _validate_epsg(self, epsg):
10185
f"got {type(epsg).__name__}.\n" + generic_error)
10286

10387
try:
104-
self._is_valid_epsg(epsg)
88+
self._is_existing_epsg(epsg)
10589
except ValueError:
10690
raise ValueError(f"Invalid EPSG code: {epsg}.\n" + generic_error) \
10791
from None
10892

93+
94+
class AbstractLocation(EpsgValidator):
95+
"""Abstract base class for location types (f.ex. point, box, polygon).
96+
97+
Locations are GML elements, for inclusion in the WFS GetFeature request.
98+
As described in the Filter Encoding 2.0 standard, locations are expressed
99+
using GML 3.2.
100+
101+
The initialisation should require all necessary parameters to construct
102+
a valid location of this type: i.e. all locations should be valid after
103+
initialisation.
104+
105+
"""
106+
107+
def _get_id_seed(self):
108+
"""Get the seed for generating a random but stable GML ID for this
109+
location.
110+
111+
Should return the same value for locations considered equal.
112+
"""
113+
raise NotImplementedError('This should be implemented in a subclass.')
114+
109115
def _get_id(self):
110116
random.seed(self._get_id_seed())
111117
random_id = ''.join(random.choice(
@@ -246,14 +252,17 @@ def __init__(self, minx, miny, maxx, maxy, epsg=None):
246252
247253
Raises
248254
------
255+
TypeError
256+
If `epsg` is None, missing or not an integer.
257+
249258
ValueError
250259
If `maxx` is lower than or equal to `minx`.
251260
If `maxy` is lower than or equal to `miny`.
252-
If `epsg` is None or invalid
261+
If `epsg` is invalid
253262
254263
Notes
255-
--------
256-
https://epsg.io for a list of valid EPSG codes.
264+
-----
265+
See https://epsg.io for a list of valid EPSG codes.
257266
258267
"""
259268

@@ -319,12 +328,15 @@ def __init__(self, x, y, epsg=None):
319328
320329
Raises
321330
------
331+
TypeError
332+
If `epsg` is None, missing or not an integer.
333+
322334
ValueError
323-
If `epsg` is None or invalid
335+
If `epsg` invalid.
324336
325337
Notes
326-
--------
327-
https://epsg.io for a list of valid EPSG codes.
338+
-----
339+
See https://epsg.io for a list of valid EPSG codes.
328340
329341
"""
330342

@@ -392,7 +404,7 @@ def __init__(self, gml_element):
392404

393405
try:
394406
epsg = int(self.element.attrib.get("srsName").split(':')[-1])
395-
self._is_valid_epsg(epsg)
407+
self._is_existing_epsg(epsg)
396408
except ValueError:
397409
raise ValueError("GML element has an invalid attribute srsName")
398410

tests/abstract.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,29 @@ def test_search_wrongreturnfieldstype(self):
618618
query=self.valid_query_single,
619619
return_fields=self.valid_returnfields[0])
620620

621+
def test_search_geometry_field_without_class(self):
622+
"""Test the search method with the query parameter and a geometry
623+
return field.
624+
625+
Test whether an InvalidFieldError is raised, as geometry fields need
626+
to be specified using GeometryReturnField (with CRS).
627+
628+
"""
629+
geom_fields = [
630+
f for f in self.search_instance.get_fields(type='geometry')]
631+
632+
if len(geom_fields) < 1:
633+
return
634+
635+
return_fields = list(self.valid_returnfields)
636+
return_fields.append(geom_fields[0])
637+
638+
with pytest.raises(InvalidFieldError):
639+
self.search_instance.search(
640+
query=self.valid_query_single,
641+
return_fields=return_fields
642+
)
643+
621644
def test_search_query_wrongfield(self):
622645
"""Test the search method with the query parameter using an
623646
inexistent query field.

tests/test_search_fields.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,12 @@ class TestGeometryReturnField():
109109
"""Class grouping tests for the GeometryReturnField class."""
110110

111111
def test_no_srs(self):
112-
"""Test initialisation of a GeometryReturnField without an SRS."""
113-
rf = GeometryReturnField('shape')
112+
"""Test initialisation of a GeometryReturnField without an SRS.
114113
115-
assert isinstance(rf, GeometryReturnField)
116-
assert rf.name == 'shape'
117-
assert rf.epsg is None
114+
Test whether a TypeError is raised.
115+
"""
116+
with pytest.raises(TypeError):
117+
GeometryReturnField('shape')
118118

119119
def test_srs_31370(self):
120120
"""Test initialisation of a GeometryReturnField with CRS set to Belgian Lambert 72."""
@@ -132,6 +132,14 @@ def test_wrong_srs_type(self):
132132
with pytest.raises(TypeError):
133133
GeometryReturnField('shape', 'EPSG:31370')
134134

135+
def test_wrong_srs_value(self):
136+
"""Test initialisation of a GeometryReturnField with a wrong CRS type.
137+
138+
Test whether a TypeError is raised.
139+
"""
140+
with pytest.raises(ValueError):
141+
GeometryReturnField('shape', 404000)
142+
135143

136144
class TestFieldMetadata():
137145
"""Class grouping tests for the FieldMetadata class."""

0 commit comments

Comments
 (0)