django-gisserver 1.2.7__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/METADATA +12 -12
  2. django_gisserver-1.4.0.dist-info/RECORD +54 -0
  3. {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/conf.py +9 -12
  6. gisserver/db.py +6 -10
  7. gisserver/exceptions.py +1 -0
  8. gisserver/features.py +18 -29
  9. gisserver/geometries.py +11 -25
  10. gisserver/operations/base.py +19 -40
  11. gisserver/operations/wfs20.py +8 -20
  12. gisserver/output/__init__.py +7 -2
  13. gisserver/output/base.py +4 -13
  14. gisserver/output/csv.py +15 -19
  15. gisserver/output/geojson.py +16 -20
  16. gisserver/output/gml32.py +310 -283
  17. gisserver/output/gml32_lxml.py +612 -0
  18. gisserver/output/results.py +107 -22
  19. gisserver/output/utils.py +15 -5
  20. gisserver/output/xmlschema.py +7 -8
  21. gisserver/parsers/base.py +2 -4
  22. gisserver/parsers/fes20/expressions.py +6 -13
  23. gisserver/parsers/fes20/filters.py +6 -5
  24. gisserver/parsers/fes20/functions.py +4 -4
  25. gisserver/parsers/fes20/identifiers.py +1 -0
  26. gisserver/parsers/fes20/operators.py +16 -43
  27. gisserver/parsers/fes20/query.py +1 -3
  28. gisserver/parsers/fes20/sorting.py +1 -3
  29. gisserver/parsers/gml/__init__.py +1 -0
  30. gisserver/parsers/gml/base.py +1 -0
  31. gisserver/parsers/values.py +1 -3
  32. gisserver/queries/__init__.py +1 -0
  33. gisserver/queries/adhoc.py +3 -6
  34. gisserver/queries/base.py +2 -6
  35. gisserver/queries/stored.py +3 -6
  36. gisserver/types.py +59 -46
  37. gisserver/views.py +7 -10
  38. django_gisserver-1.2.7.dist-info/RECORD +0 -54
  39. gisserver/output/buffer.py +0 -64
  40. {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/LICENSE +0 -0
  41. {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/top_level.txt +0 -0
@@ -3,18 +3,19 @@
3
3
  The "SimpleFeatureCollection" and "FeatureCollection" and their
4
4
  properties match the WFS 2.0 spec closely.
5
5
  """
6
+
6
7
  from __future__ import annotations
7
8
 
8
9
  import math
9
10
  import operator
10
- from functools import reduce
11
- from typing import Iterable
11
+ from collections.abc import Iterable
12
+ from datetime import timezone
13
+ from functools import cached_property, reduce
12
14
 
13
- import django
14
15
  from django.db import models
15
- from django.utils.functional import cached_property
16
- from django.utils.timezone import now, utc
16
+ from django.utils.timezone import now
17
17
 
18
+ from gisserver import conf
18
19
  from gisserver.features import FeatureType
19
20
  from gisserver.geometries import BoundingBox
20
21
 
@@ -41,6 +42,7 @@ class SimpleFeatureCollection:
41
42
  self.stop = stop
42
43
  self._result_cache = None
43
44
  self._result_iterator = None
45
+ self._has_more = None
44
46
 
45
47
  def __iter__(self) -> Iterable[models.Model]:
46
48
  """Iterate through all results.
@@ -78,8 +80,14 @@ class SimpleFeatureCollection:
78
80
  # resulttype=hits
79
81
  return iter([])
80
82
  else:
81
- model_iter = self._paginated_queryset().iterator()
82
- self._result_iterator = CountingIterator(model_iter)
83
+ if self._use_sentinel_record:
84
+ model_iter = self._paginated_queryset(add_sentinel=True).iterator()
85
+ self._result_iterator = CountingIterator(
86
+ model_iter, max_results=(self.stop - self.start)
87
+ )
88
+ else:
89
+ model_iter = self._paginated_queryset().iterator()
90
+ self._result_iterator = CountingIterator(model_iter)
83
91
  return iter(self._result_iterator)
84
92
 
85
93
  def _chunked_iterator(self):
@@ -88,7 +96,7 @@ class SimpleFeatureCollection:
88
96
  self._result_iterator = ChunkedQuerySetIterator(self._paginated_queryset())
89
97
  return iter(self._result_iterator)
90
98
 
91
- def _paginated_queryset(self) -> models.QuerySet:
99
+ def _paginated_queryset(self, add_sentinel=True) -> models.QuerySet:
92
100
  """Apply the pagination to the queryset."""
93
101
  if self.stop == math.inf:
94
102
  # Infinite page requested
@@ -97,7 +105,7 @@ class SimpleFeatureCollection:
97
105
  else:
98
106
  return self.queryset
99
107
  else:
100
- return self.queryset[self.start : self.stop]
108
+ return self.queryset[self.start : self.stop + (1 if add_sentinel else 0)]
101
109
 
102
110
  def first(self):
103
111
  try:
@@ -121,15 +129,37 @@ class SimpleFeatureCollection:
121
129
  # This still allows prefetch_related() to work,
122
130
  # since QuerySet.iterator() is avoided.
123
131
  if self.stop == math.inf:
124
- # Infinite page requested
125
- if self.start:
126
- qs = self.queryset[self.start :]
127
- else:
128
- qs = self.queryset.all()
132
+ # Infinite page requested, see if start is still requested
133
+ qs = self.queryset[self.start :] if self.start else self.queryset.all()
134
+ self._result_cache = list(qs)
135
+ elif self._use_sentinel_record:
136
+ # No counting, but instead fetch an extra item as sentinel to see if there are more results.
137
+ qs = self.queryset[self.start : self.stop + 1]
138
+ page_results = list(qs)
139
+
140
+ # The stop + 1 sentinel allows checking if there is a next page.
141
+ # This means no COUNT() is needed to detect that.
142
+ page_size = self.stop - self.start
143
+ self._has_more = len(page_results) > page_size
144
+ if self._has_more:
145
+ # remove extra element
146
+ page_results.pop()
147
+
148
+ self._result_cache = page_results
129
149
  else:
150
+ # Fetch exactly the page size, no more is needed.
151
+ # Will use a COUNT on the total table, so it can be used to see if there are more pages.
130
152
  qs = self.queryset[self.start : self.stop]
153
+ self._result_cache = list(qs)
131
154
 
132
- self._result_cache = list(qs)
155
+ @cached_property
156
+ def _use_sentinel_record(self) -> bool:
157
+ """Tell whether a sentinel record should be included in the result set.
158
+ This is used to determine whether there are more results, without having to perform a COUNT query
159
+ """
160
+ return conf.GISSERVER_COUNT_NUMBER_MATCHED == 0 or (
161
+ conf.GISSERVER_COUNT_NUMBER_MATCHED == 2 and self.start
162
+ )
133
163
 
134
164
  @cached_property
135
165
  def number_returned(self) -> int:
@@ -149,8 +179,7 @@ class SimpleFeatureCollection:
149
179
  @cached_property
150
180
  def number_matched(self) -> int:
151
181
  """Return the total number of matches across all pages."""
152
- page_size = self.stop - self.start
153
- if page_size and self.number_returned < page_size:
182
+ if self._is_surely_last_page:
154
183
  # For resulttype=results, an expensive COUNT query can be avoided
155
184
  # when this is the first and only page or the last page.
156
185
  return self.start + self.number_returned
@@ -165,13 +194,46 @@ class SimpleFeatureCollection:
165
194
  }
166
195
  if clean_annotations != qs.query.annotations:
167
196
  qs = self.queryset.all() # make a clone to allow editing
168
- if django.VERSION >= (3, 0):
169
- qs.query.annotations = clean_annotations
170
- else:
171
- qs.query._annotations = clean_annotations
197
+ qs.query.annotations = clean_annotations
172
198
 
173
199
  return qs.count()
174
200
 
201
+ @property
202
+ def _is_surely_last_page(self):
203
+ """Return true when it's totally clear this is the last page."""
204
+ # Optimization to avoid making COUNT() queries when we can already know the answer.
205
+ if self.stop == math.inf:
206
+ return True # Infinite page requested
207
+ elif self._use_sentinel_record:
208
+ if self._has_more is not None:
209
+ # did page+1 record check here, answer is known.
210
+ return not self._has_more
211
+ elif (
212
+ isinstance(self._result_iterator, CountingIterator)
213
+ and self._result_iterator.has_more is not None
214
+ ):
215
+ # did page+1 record check via the CountingIterator.
216
+ return not self._result_iterator.has_more
217
+
218
+ # Here different things will happen.
219
+ # For GeoJSON output, the iterator was read first, and `number_returned` is already filled in.
220
+ # For GML output, the pagination details are requested first, and will fetch all data.
221
+ # Hence, reading `number_returned` here can be quite an intensive operation.
222
+ page_size = self.stop - self.start # is 0 for resulttype=hits
223
+ return page_size and (self.number_returned < page_size or self._has_more is False)
224
+
225
+ @property
226
+ def has_next(self):
227
+ if self.stop == math.inf:
228
+ return False
229
+ elif self._has_more is not None:
230
+ return self._has_more # did page+1 record check, answer is known.
231
+ elif self._is_surely_last_page:
232
+ return False # Less results then expected, answer is known.
233
+
234
+ # This will perform an slow COUNT() query...
235
+ return self.stop < self.number_matched
236
+
175
237
  def get_bounding_box(self) -> BoundingBox:
176
238
  """Determine bounding box of all items."""
177
239
  self.fetch_results() # Avoid querying results twice
@@ -218,7 +280,7 @@ class FeatureCollection:
218
280
  self.next = next
219
281
  self.previous = previous
220
282
  self.date = now()
221
- self.timestamp = self.date.astimezone(utc).isoformat()
283
+ self.timestamp = self.date.astimezone(timezone.utc).isoformat()
222
284
 
223
285
  def get_bounding_box(self) -> BoundingBox:
224
286
  """Determine bounding box of all items."""
@@ -239,10 +301,33 @@ class FeatureCollection:
239
301
  # By making the number_matched lazy, the calling method has a chance to
240
302
  # decorate the results with extra annotations before they are evaluated.
241
303
  # This is needed since SimpleCollection.number_matched already evaluates the queryset.
304
+ if conf.GISSERVER_COUNT_NUMBER_MATCHED == 0 or (
305
+ conf.GISSERVER_COUNT_NUMBER_MATCHED == 2 and self.results[0].start > 0
306
+ ):
307
+ # Report "unknown" for either all pages, or the second page.
308
+ # Most clients don't need this metadata and thus we avoid a COUNT query.
309
+ return None
310
+
242
311
  return sum(c.number_matched for c in self.results)
243
312
  else:
244
313
  # Evaluate any lazy attributes
245
314
  return int(self._number_matched)
246
315
 
316
+ @property
317
+ def has_next(self) -> bool:
318
+ """Efficient way to see if a next link needs to be written.
319
+
320
+ This method will show up in profiling through
321
+ as it will be the first moment where queries are executed.
322
+ """
323
+ if all(c._is_surely_last_page for c in self.results):
324
+ return False
325
+
326
+ for c in self.results: # noqa: SIM110
327
+ # This may perform an COUNT query or read the results and detect the sentinel object:
328
+ if c.has_next:
329
+ return True
330
+ return False
331
+
247
332
  def __iter__(self):
248
333
  return iter(self.results)
gisserver/output/utils.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections import defaultdict
4
+ from collections.abc import Iterable
4
5
  from itertools import islice
5
- from typing import Iterable, TypeVar
6
+ from typing import TypeVar
6
7
 
7
8
  from django.db import models
8
9
  from lru import LRU
@@ -15,10 +16,12 @@ DEFAULT_SQL_CHUNK_SIZE = 2000 # allow unit tests to alter this.
15
16
  class CountingIterator(Iterable[M]):
16
17
  """A simple iterator that counts how many results are given."""
17
18
 
18
- def __init__(self, iterator: Iterable[M]):
19
+ def __init__(self, iterator: Iterable[M], max_results=0):
19
20
  self._iterator = iterator
20
21
  self._number_returned = 0
21
22
  self._in_iterator = False
23
+ self._max_results = max_results
24
+ self._has_more = None
22
25
 
23
26
  def __iter__(self):
24
27
  # Count the number of returned items while reading them.
@@ -27,9 +30,14 @@ class CountingIterator(Iterable[M]):
27
30
  try:
28
31
  self._number_returned = 0
29
32
  for instance in self._iterator:
33
+ if self._max_results and self._number_returned == self._max_results:
34
+ self._has_more = True
35
+ break
30
36
  self._number_returned += 1
31
37
  yield instance
32
38
  finally:
39
+ if self._max_results and self._has_more is None:
40
+ self._has_more = False # ignored the sentinel item
33
41
  self._in_iterator = False
34
42
 
35
43
  @property
@@ -39,6 +47,10 @@ class CountingIterator(Iterable[M]):
39
47
  raise RuntimeError("Can't read number of returned results during iteration")
40
48
  return self._number_returned
41
49
 
50
+ @property
51
+ def has_more(self) -> bool | None:
52
+ return self._has_more
53
+
42
54
 
43
55
  class ChunkedQuerySetIterator(Iterable[M]):
44
56
  """An optimal strategy to perform ``prefetch_related()`` on large datasets.
@@ -113,9 +125,7 @@ class ChunkedQuerySetIterator(Iterable[M]):
113
125
 
114
126
  # Reuse the Django machinery for retrieving missing sub objects.
115
127
  # and analyse the ForeignKey caches to allow faster prefetches next time
116
- models.prefetch_related_objects(
117
- instances, *self.queryset._prefetch_related_lookups
118
- )
128
+ models.prefetch_related_objects(instances, *self.queryset._prefetch_related_lookups)
119
129
  self._persist_prefetch_cache(instances)
120
130
 
121
131
  def _persist_prefetch_cache(self, instances):
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections import deque
4
- from typing import Iterable, cast
4
+ from collections.abc import Iterable
5
+ from io import StringIO
6
+ from typing import cast
5
7
 
6
8
  from gisserver.features import FeatureType
7
9
  from gisserver.operations.base import WFSMethod
8
10
  from gisserver.types import XsdComplexType
9
11
 
10
12
  from .base import OutputRenderer
11
- from .buffer import StringBuffer
12
13
 
13
14
 
14
15
  class XMLSchemaRenderer(OutputRenderer):
@@ -30,12 +31,10 @@ class XMLSchemaRenderer(OutputRenderer):
30
31
  # the prefixes to be different.
31
32
  xmlns_features = "\n ".join(
32
33
  f'xmlns:{p}="{self.app_xml_namespace}"'
33
- for p in sorted(
34
- {feature_type.xml_prefix for feature_type in self.feature_types}
35
- )
34
+ for p in sorted({feature_type.xml_prefix for feature_type in self.feature_types})
36
35
  )
37
36
 
38
- output = StringBuffer()
37
+ output = StringIO()
39
38
  output.write(
40
39
  f"""<?xml version='1.0' encoding="UTF-8" ?>
41
40
  <schema
@@ -63,7 +62,7 @@ class XMLSchemaRenderer(OutputRenderer):
63
62
  )
64
63
 
65
64
  def render_feature_type(self, feature_type: FeatureType):
66
- output = StringBuffer()
65
+ output = StringIO()
67
66
  xsd_type: XsdComplexType = feature_type.xsd_type
68
67
 
69
68
  # This declares the that a top-level <app:featureName> is a class of a type.
@@ -83,7 +82,7 @@ class XMLSchemaRenderer(OutputRenderer):
83
82
 
84
83
  def render_complex_type(self, complex_type: XsdComplexType):
85
84
  """Write the definition of a single class."""
86
- output = StringBuffer()
85
+ output = StringIO()
87
86
  output.write(
88
87
  f' <complexType name="{complex_type.name}">\n'
89
88
  " <complexContent>\n"
gisserver/parsers/base.py CHANGED
@@ -23,9 +23,7 @@ class TagNameEnum(Enum):
23
23
 
24
24
  @classmethod
25
25
  def _missing_(cls, value):
26
- raise NotImplementedError(
27
- f"<{value}> is not registered as valid {cls.__name__}"
28
- )
26
+ raise NotImplementedError(f"<{value}> is not registered as valid {cls.__name__}")
29
27
 
30
28
  def __repr__(self):
31
29
  # Make repr(filter) easier to copy-paste
@@ -108,7 +106,7 @@ class TagRegistry:
108
106
 
109
107
  def _dec(sub_class: type[BaseNode]):
110
108
  # Looping over _member_names_ will skip aliased items (like BBOX/Within)
111
- for member_name in names.__members__.keys():
109
+ for member_name in names.__members__:
112
110
  self.register(name=member_name, namespace=namespace)(sub_class)
113
111
  return sub_class
114
112
 
@@ -1,12 +1,14 @@
1
1
  """These classes map to the FES 2.0 specification for expressions.
2
2
  The class names are identical to those in the FES spec.
3
3
  """
4
+
4
5
  from __future__ import annotations
5
6
 
6
7
  import operator
7
8
  from dataclasses import dataclass
8
9
  from datetime import date, datetime
9
10
  from decimal import Decimal as D
11
+ from functools import cached_property
10
12
  from typing import Union
11
13
  from xml.etree.ElementTree import Element
12
14
 
@@ -14,7 +16,6 @@ from django.contrib.gis.geos import GEOSGeometry
14
16
  from django.db import models
15
17
  from django.db.models import Func, Q, Value
16
18
  from django.db.models.expressions import Combinable
17
- from django.utils.functional import cached_property
18
19
 
19
20
  from gisserver.exceptions import ExternalParsingError
20
21
  from gisserver.parsers.base import BaseNode, TagNameEnum, tag_registry
@@ -31,12 +32,8 @@ from gisserver.parsers.values import auto_cast
31
32
  from gisserver.types import FES20, ORMPath, XsdTypes, split_xml_name
32
33
 
33
34
  NoneType = type(None)
34
- RhsTypes = Union[
35
- Combinable, Func, Q, GEOSGeometry, bool, int, str, date, datetime, tuple
36
- ]
37
- ParsedValue = Union[
38
- int, str, date, D, datetime, GM_Object, GM_Envelope, TM_Object, NoneType
39
- ]
35
+ RhsTypes = Union[Combinable, Func, Q, GEOSGeometry, bool, int, str, date, datetime, tuple]
36
+ ParsedValue = Union[int, str, date, D, datetime, GM_Object, GM_Envelope, TM_Object, NoneType]
40
37
 
41
38
  OUTPUT_FIELDS = {
42
39
  bool: models.BooleanField(),
@@ -148,9 +145,7 @@ class Literal(Expression):
148
145
  By aliasing the value using an annotation,
149
146
  it can be queried like a regular field name.
150
147
  """
151
- return compiler.add_annotation(
152
- Value(self.value, output_field=self.get_output_field())
153
- )
148
+ return compiler.add_annotation(Value(self.value, output_field=self.get_output_field()))
154
149
 
155
150
  def get_output_field(self):
156
151
  # When the value is used a left-hand-side, Django needs to know the output type.
@@ -169,9 +164,7 @@ class Literal(Expression):
169
164
 
170
165
  @dataclass(repr=False)
171
166
  @tag_registry.register("ValueReference")
172
- @tag_registry.register(
173
- "PropertyName", hidden=True
174
- ) # FES 1.0 name that old clients still use.
167
+ @tag_registry.register("PropertyName", hidden=True) # FES 1.0 name that old clients still use.
175
168
  class ValueReference(Expression):
176
169
  """The <fes:ValueReference> element that holds an XPath string.
177
170
  In the fes XSD, this is declared as a subclass of xsd:string.
@@ -42,11 +42,12 @@ class Filter:
42
42
  if isinstance(text, str):
43
43
  end_first = text.index(">")
44
44
  first_tag = text[:end_first].lstrip()
45
- if "xmlns" not in first_tag:
46
- # Allow KVP requests without a namespace
47
- # Both geoserver and mapserver support this.
48
- if first_tag == "<Filter" or first_tag.startswith("<Filter "):
49
- text = f'{first_tag} xmlns="{FES20}" xmlns:gml="{GML32}"{text[end_first:]}'
45
+ # Allow KVP requests without a namespace
46
+ # Both geoserver and mapserver support this.
47
+ if "xmlns" not in first_tag and (
48
+ first_tag == "<Filter" or first_tag.startswith("<Filter ")
49
+ ):
50
+ text = f'{first_tag} xmlns="{FES20}" xmlns:gml="{GML32}"{text[end_first:]}'
50
51
 
51
52
  try:
52
53
  root_element = fromstring(text)
@@ -1,8 +1,10 @@
1
1
  """Functions to be callable from fes."""
2
+
2
3
  from __future__ import annotations
3
4
 
5
+ from collections.abc import Callable
4
6
  from dataclasses import dataclass
5
- from typing import Callable, Union
7
+ from typing import Union
6
8
 
7
9
  from django.contrib.gis.db.models import functions as gis
8
10
  from django.db import models
@@ -40,9 +42,7 @@ class FesFunction:
40
42
  """Build the query expression for the function."""
41
43
  if len(expressions) != len(self.arguments):
42
44
  # Avoid passing extra parameters to the function if those are not defined.
43
- raise TypeError(
44
- f'Invalid number of arguments for <fes:Function name="{self.name}">'
45
- )
45
+ raise TypeError(f'Invalid number of arguments for <fes:Function name="{self.name}">')
46
46
 
47
47
  # Keyword arguments are avoided, since some Django SQL functions had
48
48
  # SQL-injection issues with those arguments. Only passing expressions for now.
@@ -1,6 +1,7 @@
1
1
  """These classes map to the FES 2.0 specification for identifiers.
2
2
  The class names are identical to those in the FES spec.
3
3
  """
4
+
4
5
  from __future__ import annotations
5
6
 
6
7
  from dataclasses import dataclass
@@ -1,13 +1,14 @@
1
1
  """These classes map to the FES 2.0 specification for operators.
2
2
  The class names and attributes are identical to those in the FES spec.
3
3
  """
4
+
4
5
  from __future__ import annotations
5
6
 
6
7
  import operator
7
8
  from dataclasses import dataclass, field
8
9
  from decimal import Decimal
9
10
  from enum import Enum
10
- from functools import reduce
11
+ from functools import cached_property, reduce
11
12
  from itertools import groupby
12
13
  from typing import Any, Union
13
14
  from xml.etree.ElementTree import Element, QName
@@ -15,7 +16,6 @@ from xml.etree.ElementTree import Element, QName
15
16
  from django.conf import settings
16
17
  from django.contrib.gis import measure
17
18
  from django.db.models import Q
18
- from django.utils.functional import cached_property
19
19
 
20
20
  from gisserver.exceptions import ExternalParsingError, OperationProcessingFailed
21
21
  from gisserver.parsers import gml
@@ -39,8 +39,7 @@ except ImportError:
39
39
  else:
40
40
 
41
41
  class HasBuildRhs(Protocol):
42
- def build_rhs(self, compiler) -> RhsTypes:
43
- ...
42
+ def build_rhs(self, compiler) -> RhsTypes: ...
44
43
 
45
44
 
46
45
  if "django.contrib.postgres" in settings.INSTALLED_APPS:
@@ -169,9 +168,7 @@ class Operator(BaseNode):
169
168
  xml_ns = FES20
170
169
 
171
170
  def build_query(self, compiler: CompiledQuery) -> Q | None:
172
- raise NotImplementedError(
173
- f"Using {self.__class__.__name__} is not supported yet."
174
- )
171
+ raise NotImplementedError(f"Using {self.__class__.__name__} is not supported yet.")
175
172
 
176
173
 
177
174
  @dataclass
@@ -183,9 +180,7 @@ class IdOperator(Operator):
183
180
  @property
184
181
  def type_names(self) -> list[str]:
185
182
  """Provide a list of all type names accessed by this operator"""
186
- return [
187
- type_name for type_name in self.grouped_ids.keys() if type_name is not None
188
- ]
183
+ return [type_name for type_name in self.grouped_ids if type_name is not None]
189
184
 
190
185
  @cached_property
191
186
  def grouped_ids(self) -> dict[str, list[Id]]:
@@ -209,9 +204,7 @@ class IdOperator(Operator):
209
204
  return
210
205
 
211
206
  for type_name, items in self.grouped_ids.items():
212
- ids_subset = reduce(
213
- operator.or_, [id.build_query(compiler=None) for id in items]
214
- )
207
+ ids_subset = reduce(operator.or_, [id.build_query(compiler=None) for id in items])
215
208
  compiler.add_lookups(ids_subset, type_name=type_name)
216
209
 
217
210
 
@@ -349,14 +342,10 @@ class DistanceOperator(SpatialOperator):
349
342
  if not geometries:
350
343
  raise ExternalParsingError(f"Missing gml element in <{element.tag}>")
351
344
  elif len(geometries) > 1:
352
- raise ExternalParsingError(
353
- f"Multiple gml elements found in <{element.tag}>"
354
- )
345
+ raise ExternalParsingError(f"Multiple gml elements found in <{element.tag}>")
355
346
 
356
347
  return cls(
357
- valueReference=ValueReference.from_xml(
358
- get_child(element, FES20, "ValueReference")
359
- ),
348
+ valueReference=ValueReference.from_xml(get_child(element, FES20, "ValueReference")),
360
349
  operatorType=DistanceOperatorName.from_xml(element),
361
350
  geometry=gml.parse_gml_node(geometries[0]),
362
351
  distance=Measure.from_xml(get_child(element, FES20, "Distance")),
@@ -470,17 +459,13 @@ class BinaryComparisonOperator(ComparisonOperator):
470
459
  Expression.from_child_xml(element[1]),
471
460
  ),
472
461
  matchCase=element.get("matchCase", True),
473
- matchAction=MatchAction(
474
- element.get("matchAction", default=MatchAction.Any)
475
- ),
462
+ matchAction=MatchAction(element.get("matchAction", default=MatchAction.Any)),
476
463
  _source=element.tag,
477
464
  )
478
465
 
479
466
  def build_query(self, compiler: CompiledQuery) -> Q:
480
467
  lhs, rhs = self.expression
481
- return self.build_compare(
482
- compiler, lhs=lhs, lookup=self.operatorType.value, rhs=rhs
483
- )
468
+ return self.build_compare(compiler, lhs=lhs, lookup=self.operatorType.value, rhs=rhs)
484
469
 
485
470
 
486
471
  @dataclass
@@ -508,13 +493,9 @@ class BetweenComparisonOperator(ComparisonOperator):
508
493
  upper = get_child(element, FES20, "UpperBoundary")
509
494
 
510
495
  if len(lower) != 1:
511
- raise ExternalParsingError(
512
- f"{lower.tag} should have 1 expression child node"
513
- )
496
+ raise ExternalParsingError(f"{lower.tag} should have 1 expression child node")
514
497
  if len(upper) != 1:
515
- raise ExternalParsingError(
516
- f"{upper.tag} should have 1 expression child node"
517
- )
498
+ raise ExternalParsingError(f"{upper.tag} should have 1 expression child node")
518
499
 
519
500
  return cls(
520
501
  expression=Expression.from_child_xml(element[0]),
@@ -573,9 +554,7 @@ class LikeOperator(ComparisonOperator):
573
554
 
574
555
  rhs = Literal(raw_value=value)
575
556
  else:
576
- raise ExternalParsingError(
577
- f"Expected a literal value for the {self.tag} operator."
578
- )
557
+ raise ExternalParsingError(f"Expected a literal value for the {self.tag} operator.")
579
558
 
580
559
  # Use the FesLike lookup
581
560
  return self.build_compare(compiler, lhs=lhs, lookup="fes_like", rhs=rhs)
@@ -603,9 +582,7 @@ class NilOperator(ComparisonOperator):
603
582
 
604
583
  def build_query(self, compiler: CompiledQuery) -> Q:
605
584
  # Any value that evaluates to None is returned as 'xs:nil' in our output.
606
- return self.build_compare(
607
- compiler, lhs=self.expression, lookup="isnull", rhs=True
608
- )
585
+ return self.build_compare(compiler, lhs=self.expression, lookup="isnull", rhs=True)
609
586
 
610
587
 
611
588
  @dataclass
@@ -621,18 +598,14 @@ class NullOperator(ComparisonOperator):
621
598
  @classmethod
622
599
  @expect_children(1, Expression)
623
600
  def from_xml(cls, element: Element):
624
- return cls(
625
- expression=Expression.from_child_xml(element[0]), _source=element.tag
626
- )
601
+ return cls(expression=Expression.from_child_xml(element[0]), _source=element.tag)
627
602
 
628
603
  def build_query(self, compiler: CompiledQuery) -> Q:
629
604
  # For now, the implementation is identical to PropertyIsNil.
630
605
  # According to the WFS spec, this should only be true when the element
631
606
  # is not returned at all (minOccurs=0).
632
607
  # TODO: this happens for maxOccurs=unbounded with a null value.
633
- return self.build_compare(
634
- compiler, lhs=self.expression, lookup="isnull", rhs=True
635
- )
608
+ return self.build_compare(compiler, lhs=self.expression, lookup="isnull", rhs=True)
636
609
 
637
610
 
638
611
  class LogicalOperator(NonIdOperator):
@@ -115,9 +115,7 @@ class CompiledQuery:
115
115
  """Mark as returning no results."""
116
116
  self.is_empty = True
117
117
 
118
- def filter_queryset(
119
- self, queryset: QuerySet, feature_type: FeatureType
120
- ) -> QuerySet:
118
+ def filter_queryset(self, queryset: QuerySet, feature_type: FeatureType) -> QuerySet:
121
119
  """Apply the filters and lookups to the queryset.
122
120
 
123
121
  :param queryset: The queryset to filter.
@@ -20,9 +20,7 @@ class SortOrder(Enum):
20
20
  try:
21
21
  return cls[direction]
22
22
  except KeyError:
23
- raise InvalidParameterValue(
24
- "sortby", "Expect ASC/DESC ordering direction"
25
- ) from None
23
+ raise InvalidParameterValue("sortby", "Expect ASC/DESC ordering direction") from None
26
24
 
27
25
 
28
26
  @dataclass
@@ -2,6 +2,7 @@
2
2
 
3
3
  These functions locate GML objects, and redirect to the proper parser.
4
4
  """
5
+
5
6
  from __future__ import annotations
6
7
 
7
8
  from xml.etree.ElementTree import Element
@@ -3,6 +3,7 @@
3
3
  See "Table D.2" in the GML 3.2.1 spec, showing how the UML names
4
4
  map to the GML implementations. These names are referenced by the FES spec.
5
5
  """
6
+
6
7
  from gisserver.parsers.base import BaseNode
7
8
 
8
9
 
@@ -27,9 +27,7 @@ def auto_cast(value: str):
27
27
  def parse_iso_datetime(raw_value: str) -> datetime:
28
28
  value = parse_datetime(raw_value)
29
29
  if value is None:
30
- raise ExternalParsingError(
31
- "Date must be in YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format."
32
- )
30
+ raise ExternalParsingError("Date must be in YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format.")
33
31
  return value
34
32
 
35
33