django-gisserver 2.0__py3-none-any.whl → 2.1__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 (55) hide show
  1. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +26 -10
  2. django_gisserver-2.1.dist-info/RECORD +68 -0
  3. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/crs.py +401 -0
  6. gisserver/db.py +71 -5
  7. gisserver/exceptions.py +106 -2
  8. gisserver/extensions/functions.py +122 -28
  9. gisserver/extensions/queries.py +15 -10
  10. gisserver/features.py +44 -36
  11. gisserver/geometries.py +64 -306
  12. gisserver/management/commands/loadgeojson.py +41 -21
  13. gisserver/operations/base.py +11 -7
  14. gisserver/operations/wfs20.py +31 -93
  15. gisserver/output/__init__.py +6 -2
  16. gisserver/output/base.py +28 -13
  17. gisserver/output/csv.py +18 -6
  18. gisserver/output/geojson.py +7 -6
  19. gisserver/output/gml32.py +43 -23
  20. gisserver/output/results.py +25 -39
  21. gisserver/output/utils.py +9 -2
  22. gisserver/parsers/ast.py +171 -65
  23. gisserver/parsers/fes20/__init__.py +76 -4
  24. gisserver/parsers/fes20/expressions.py +97 -27
  25. gisserver/parsers/fes20/filters.py +9 -6
  26. gisserver/parsers/fes20/identifiers.py +27 -7
  27. gisserver/parsers/fes20/lookups.py +8 -6
  28. gisserver/parsers/fes20/operators.py +101 -49
  29. gisserver/parsers/fes20/sorting.py +14 -6
  30. gisserver/parsers/gml/__init__.py +10 -19
  31. gisserver/parsers/gml/base.py +32 -14
  32. gisserver/parsers/gml/geometries.py +48 -21
  33. gisserver/parsers/ows/kvp.py +10 -2
  34. gisserver/parsers/ows/requests.py +6 -4
  35. gisserver/parsers/query.py +6 -2
  36. gisserver/parsers/values.py +61 -4
  37. gisserver/parsers/wfs20/__init__.py +2 -0
  38. gisserver/parsers/wfs20/adhoc.py +25 -17
  39. gisserver/parsers/wfs20/base.py +12 -7
  40. gisserver/parsers/wfs20/projection.py +3 -3
  41. gisserver/parsers/wfs20/requests.py +1 -0
  42. gisserver/parsers/wfs20/stored.py +3 -2
  43. gisserver/parsers/xml.py +12 -0
  44. gisserver/projection.py +17 -7
  45. gisserver/static/gisserver/index.css +8 -3
  46. gisserver/templates/gisserver/base.html +12 -0
  47. gisserver/templates/gisserver/index.html +9 -15
  48. gisserver/templates/gisserver/service_description.html +12 -6
  49. gisserver/templates/gisserver/wfs/feature_field.html +1 -1
  50. gisserver/templates/gisserver/wfs/feature_type.html +35 -13
  51. gisserver/types.py +150 -81
  52. gisserver/views.py +47 -24
  53. django_gisserver-2.0.dist-info/RECORD +0 -66
  54. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
  55. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/top_level.txt +0 -0
gisserver/features.py CHANGED
@@ -1,14 +1,15 @@
1
- """The configuration of a "feature type" in the WFS server.
1
+ """The main configuration for exposing model data in the WFS server.
2
2
 
3
3
  The "feature type" definitions define what models and attributes are exposed in the WFS server.
4
4
  When a model attribute is mentioned in the feature type, it can be exposed and queried against.
5
5
  Any field that is not mentioned in a definition, will therefore not be available, nor queryable.
6
6
  This metadata is used in the ``GetCapabilities`` call to advertise all available feature types.
7
7
 
8
- To handle other WFS request types besides ``GetCapabilities``, the "feature type" definition
9
- is translated internally into an internal XML Schema Definition (:mod:`gisserver.types`).
8
+ The "feature type" definitions ares translated internally into
9
+ an internal XML Schema Definition (made from :mod:`gisserver.types`).
10
10
  That schema maps all model attributes to a specific XML layout, and includes
11
11
  all XSD Complex Types, elements and attributes linked to the Django model metadata.
12
+
12
13
  The feature type classes (and field types) offer a flexible translation
13
14
  from attribute listings into a schema definition.
14
15
  For example, model relationships can be modelled to a different XML layout.
@@ -21,19 +22,20 @@ import itertools
21
22
  import logging
22
23
  from dataclasses import dataclass
23
24
  from functools import cached_property, lru_cache
24
- from typing import TYPE_CHECKING, Literal, Union
25
+ from typing import TYPE_CHECKING, Literal
25
26
 
26
27
  from django.contrib.gis.db import models as gis_models
27
- from django.contrib.gis.db.models import Extent, GeometryField
28
+ from django.contrib.gis.db.models import GeometryField
28
29
  from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
29
30
  from django.db import models
30
31
  from django.http import HttpRequest
31
32
 
32
33
  from gisserver import conf
33
34
  from gisserver.compat import ArrayField, GeneratedField
34
- from gisserver.db import get_db_geometry_target
35
+ from gisserver.crs import CRS
36
+ from gisserver.db import get_wgs84_bounding_box
35
37
  from gisserver.exceptions import ExternalValueError, InvalidParameterValue
36
- from gisserver.geometries import CRS, WGS84, BoundingBox
38
+ from gisserver.geometries import WGS84BoundingBox
37
39
  from gisserver.parsers.xml import parse_qname, xmlns
38
40
  from gisserver.types import (
39
41
  GeometryXsdElement,
@@ -50,14 +52,11 @@ from gisserver.types import (
50
52
  if TYPE_CHECKING:
51
53
  from gisserver.projection import FeatureRelation
52
54
 
53
- _all_ = Literal["__all__"]
54
-
55
55
  __all__ = [
56
56
  "FeatureType",
57
57
  "field",
58
58
  "FeatureField",
59
59
  "ComplexFeatureField",
60
- "get_basic_field_type",
61
60
  ]
62
61
 
63
62
  logger = logging.getLogger(__name__)
@@ -67,12 +66,16 @@ XSD_TYPES = {
67
66
  models.TextField: XsdTypes.string,
68
67
  models.BooleanField: XsdTypes.boolean,
69
68
  models.IntegerField: XsdTypes.integer,
69
+ models.PositiveIntegerField: XsdTypes.nonNegativeInteger,
70
+ models.PositiveBigIntegerField: XsdTypes.nonNegativeInteger,
71
+ models.PositiveSmallIntegerField: XsdTypes.nonNegativeInteger,
70
72
  models.AutoField: XsdTypes.integer, # Only as of Django 3.0 this extends from IntegerField
71
73
  models.FloatField: XsdTypes.double,
72
74
  models.DecimalField: XsdTypes.decimal,
73
75
  models.TimeField: XsdTypes.time,
74
76
  models.DateTimeField: XsdTypes.dateTime, # note: DateTimeField extends DateField!
75
77
  models.DateField: XsdTypes.date,
78
+ models.DurationField: XsdTypes.duration,
76
79
  models.URLField: XsdTypes.anyURI,
77
80
  gis_models.PointField: XsdTypes.gmlPointPropertyType,
78
81
  gis_models.PolygonField: XsdTypes.gmlSurfacePropertyType,
@@ -87,7 +90,7 @@ XSD_TYPES = {
87
90
  DEFAULT_XSD_TYPE = XsdTypes.anyType
88
91
 
89
92
 
90
- def get_basic_field_type(
93
+ def _get_basic_field_type(
91
94
  field_name: str, model_field: models.Field | models.ForeignObjectRel
92
95
  ) -> XsdAnyType:
93
96
  """Determine the XSD field type for a Django field."""
@@ -108,10 +111,10 @@ def get_basic_field_type(
108
111
 
109
112
  if isinstance(model_field, models.ForeignKey):
110
113
  # Don't let it query on the relation value yet
111
- return get_basic_field_type(field_name, model_field.target_field)
114
+ return _get_basic_field_type(field_name, model_field.target_field)
112
115
  elif isinstance(model_field, models.ForeignObjectRel):
113
116
  # e.g. ManyToOneRel descriptor of a foreignkey_id field.
114
- return get_basic_field_type(field_name, model_field.remote_field.target_field)
117
+ return _get_basic_field_type(field_name, model_field.remote_field.target_field)
115
118
  else:
116
119
  # Subclass checks:
117
120
  for field_cls, xsd_type in XSD_TYPES.items():
@@ -128,7 +131,7 @@ def get_basic_field_type(
128
131
 
129
132
  def _get_model_fields(
130
133
  model: type[models.Model],
131
- fields: _all_ | list[str],
134
+ fields: list[str] | Literal["__all__"],
132
135
  parent: ComplexFeatureField | None = None,
133
136
  feature_type: FeatureType | None = None,
134
137
  ):
@@ -230,7 +233,7 @@ class FeatureField:
230
233
  )
231
234
 
232
235
  def _get_xsd_type(self):
233
- return get_basic_field_type(self.name, self.model_field)
236
+ return _get_basic_field_type(self.name, self.model_field)
234
237
 
235
238
  def bind(
236
239
  self,
@@ -349,10 +352,6 @@ class FeatureField:
349
352
  )
350
353
 
351
354
 
352
- _FieldDefinition = Union[str, FeatureField]
353
- _FieldDefinitions = Union[_all_, list[_FieldDefinition]]
354
-
355
-
356
355
  class ComplexFeatureField(FeatureField):
357
356
  """The configuration for an embedded relation field.
358
357
 
@@ -364,7 +363,7 @@ class ComplexFeatureField(FeatureField):
364
363
  def __init__(
365
364
  self,
366
365
  name: str,
367
- fields: _FieldDefinitions,
366
+ fields: list[str | FeatureField] | Literal["__all__"],
368
367
  model_attribute=None,
369
368
  model=None,
370
369
  abstract=None,
@@ -439,7 +438,7 @@ def field(
439
438
  *,
440
439
  model_attribute=None,
441
440
  abstract: str | None = None,
442
- fields: _FieldDefinitions | None = None,
441
+ fields: list[str | FeatureField] | Literal["__all__"] | None = None,
443
442
  xsd_class: type[XsdElement] | None = None,
444
443
  ) -> FeatureField:
445
444
  """Shortcut to define a WFS field.
@@ -490,7 +489,7 @@ class FeatureType:
490
489
  self,
491
490
  queryset: models.QuerySet,
492
491
  *,
493
- fields: _FieldDefinitions | None = None,
492
+ fields: list[str | FeatureField] | Literal["__all__"] | None = None,
494
493
  display_field_name: str | None = None,
495
494
  geometry_field_name: str | None = None,
496
495
  name: str | None = None,
@@ -553,6 +552,13 @@ class FeatureType:
553
552
 
554
553
  self._cached_resolver = lru_cache(200)(self._inner_resolve_element)
555
554
 
555
+ def __repr__(self):
556
+ return (
557
+ f"<{self.__class__.__qualname__}: {self.xml_name},"
558
+ f" fields={self.fields!r},"
559
+ f" geometry_field_name={self.main_geometry_element.absolute_model_attribute!r}>"
560
+ )
561
+
556
562
  def bind_namespace(self, default_xml_namespace: str):
557
563
  """Make sure the feature type receives the settings from the parent view."""
558
564
  if not self.xml_namespace:
@@ -709,19 +715,20 @@ class FeatureType:
709
715
  """When a related object returns a queryset, this hook allows extra filtering."""
710
716
  return queryset
711
717
 
712
- def get_bounding_box(self) -> BoundingBox | None:
718
+ def get_bounding_box(self) -> WGS84BoundingBox | None:
713
719
  """Returns a WGS84 BoundingBox for the complete feature.
714
720
 
715
721
  This is used by the GetCapabilities request. It may return ``None``
716
722
  when the database table is empty, or the custom queryset doesn't
717
723
  return any results.
724
+
725
+ Note that the ``<ows:WGS84BoundingBox>`` element always uses longitude/latitude,
726
+ as it doesn't describe a CRS.
718
727
  """
719
728
  if not self.main_geometry_element:
720
729
  return None
721
730
 
722
- geo_expression = get_db_geometry_target(self.main_geometry_element, WGS84)
723
- bbox = self.get_queryset().aggregate(a=Extent(geo_expression))["a"]
724
- return BoundingBox(*bbox, crs=WGS84) if bbox else None
731
+ return get_wgs84_bounding_box(self.get_queryset(), self.main_geometry_element)
725
732
 
726
733
  def get_display_value(self, instance: models.Model) -> str:
727
734
  """Generate the display name value"""
@@ -839,18 +846,19 @@ class FeatureType:
839
846
 
840
847
  def resolve_crs(self, crs: CRS, locator="") -> CRS:
841
848
  """Check a parsed CRS against the list of supported types."""
842
- pos = self.supported_crs.index(crs)
843
- if pos != -1:
849
+ try:
850
+ pos = self.supported_crs.index(crs)
851
+
844
852
  # Replace the parsed CRS with the declared one, which may have a 'backend' configured.
845
853
  return self.supported_crs[pos]
846
-
847
- if conf.GISSERVER_SUPPORTED_CRS_ONLY:
848
- raise InvalidParameterValue(
849
- f"Feature '{self.name}' does not support SRID {crs.srid}.",
850
- locator=locator,
851
- )
852
- else:
853
- return crs
854
+ except ValueError:
855
+ if conf.GISSERVER_SUPPORTED_CRS_ONLY:
856
+ raise InvalidParameterValue(
857
+ f"Feature '{self.name}' does not support SRID {crs.srid}.",
858
+ locator=locator,
859
+ ) from None
860
+ else:
861
+ return crs
854
862
 
855
863
 
856
864
  class HDict(dict):
gisserver/geometries.py CHANGED
@@ -1,340 +1,86 @@
1
1
  """Helper classes to handle geometry data types.
2
2
 
3
- This includes the CRS parsing, coordinate transforms and bounding box object.
4
3
  The bounding box can be calculated within Python, or read from a database result.
5
4
  """
6
5
 
7
6
  from __future__ import annotations
8
7
 
9
- import logging
10
- import re
11
- from dataclasses import dataclass, field
12
- from decimal import Decimal
13
- from functools import cached_property, lru_cache
8
+ import math
9
+ from dataclasses import dataclass
14
10
 
15
- from django.contrib.gis.gdal import AxisOrder, CoordTransform, SpatialReference
16
11
  from django.contrib.gis.geos import GEOSGeometry
17
12
 
18
- from gisserver.exceptions import ExternalValueError
13
+ from gisserver.crs import CRS, CRS84, WEB_MERCATOR, WGS84 # noqa: F401 (keep old exports)
19
14
 
20
- CRS_URN_REGEX = re.compile(
21
- r"^urn:(?P<domain>[a-z]+)"
22
- r":def:crs:(?P<authority>[a-z]+)"
23
- r":(?P<version>[0-9]+\.[0-9]+(\.[0-9]+)?)?"
24
- r":(?P<id>[0-9]+|crs84)"
25
- r"$",
26
- re.IGNORECASE,
27
- )
15
+ #: The CRS for the ``<ows:WGS84BoundingBox>`` element:
16
+ WGS84_BOUNDING_BOX_CRS = CRS.from_string("urn:ogc:def:crs:OGC:2:84")
28
17
 
29
18
  __all__ = [
30
- "CRS",
31
- "WEB_MERCATOR",
32
- "WGS84",
33
19
  "BoundingBox",
20
+ "WGS84BoundingBox",
34
21
  ]
35
22
 
36
- logger = logging.getLogger(__name__)
37
23
 
24
+ @dataclass
25
+ class BoundingBox:
26
+ """A bounding box.
27
+ Due to the overlap between 2 types, this element is used for 2 cases:
38
28
 
39
- @lru_cache(maxsize=200) # Using lru-cache to avoid repeated GDAL c-object construction
40
- def _get_spatial_reference(srs_input: str | int, srs_type="user", axis_order=None):
41
- """Construct an GDAL object reference"""
42
- if axis_order is None:
43
- # WFS 1.0 used x/y coordinates, WFS 2.0 uses the ordering from the CRS authority.
44
- axis_order = AxisOrder.AUTHORITY
45
-
46
- logger.debug(
47
- "Constructed GDAL SpatialReference(%r, srs_type=%r, axis_order=%s)",
48
- srs_input,
49
- srs_type,
50
- axis_order,
51
- )
52
- return SpatialReference(srs_input, srs_type=srs_type, axis_order=axis_order)
53
-
54
-
55
- @lru_cache(maxsize=100)
56
- def _get_coord_transform(
57
- source: int | SpatialReference, target: int | SpatialReference
58
- ) -> CoordTransform:
59
- """Get an efficient coordinate transformation object.
60
-
61
- The CoordTransform should be used when performing the same
62
- coordinate transformation repeatedly on different geometries.
63
-
64
- NOTE that the cache could be busted when CRS objects are
65
- repeatedly created with a custom 'backend' object.
66
- """
67
- if isinstance(source, int):
68
- source = _get_spatial_reference(source, srs_type="epsg")
69
- if isinstance(target, int):
70
- target = _get_spatial_reference(target, srs_type="epsg")
29
+ * The ``<ows:WGS84BoundingBox>`` element for ``GetCapabilities``.
30
+ * The ``<gml:Envelope>`` inside an``<gml:boundedBy>`` single feature.
71
31
 
72
- return CoordTransform(source, target)
32
+ While both classes have no common base class (and exist in different schema's),
33
+ their properties are identical.
73
34
 
35
+ The X/Y coordinates can be either latitude or longitude, depending on the CRS.
74
36
 
75
- @dataclass(frozen=True)
76
- class CRS:
77
- """
78
- Represents a CRS (Coordinate Reference System), which preferably follows the URN format
79
- as specified by `the OGC consortium <http://www.opengeospatial.org/ogcUrnPolicy>`_.
37
+ Note this isn't using the GDAL/OGR "Envelope" object, as that doesn't expose the CRS,
38
+ and requires constant copies to merge geometries.
80
39
  """
81
40
 
82
- # CRS logic, based upon https://github.com/wglas85/django-wfs/blob/master/wfs/helpers.py
83
- # Copyright (c) 2006 Wolfgang Glas - Apache 2.0 licensed
84
- # Ported to Python 3.6 style.
85
-
86
- #: Either "ogc" or "opengis", whereas "ogc" is highly recommended.
87
- domain: str
88
-
89
- #: Either "OGC" or "EPSG".
90
- authority: str
91
-
92
- #: The version of the authorities' SRS registry, which is empty or
93
- #: contains two or three numeric components separated by dots like "6.9" or "6.11.9".
94
- #: For WFS 2.0 this is typically empty.
95
- version: str
96
-
97
- #: A string representation of the coordinate system reference ID.
98
- #: For OGC, only "CRS84" is supported as crsid. For EPSG, this is the formatted CRSID.
99
- crsid: str
100
-
101
- #: The integer representing the numeric spatial reference ID as
102
- #: used by the EPSG and GIS database backends.
103
- srid: int
104
-
105
- #: GDAL SpatialReference with PROJ.4 / WKT content to describe the exact transformation.
106
- backend: SpatialReference | None = None
107
-
108
- #: Original input
109
- origin: str = field(init=False, default=None)
110
-
111
- has_custom_backend: bool = field(init=False)
112
-
113
- def __post_init__(self):
114
- # Using __dict__ because of frozen=True
115
- self.__dict__["has_custom_backend"] = self.backend is not None
116
-
117
- @classmethod
118
- def from_string(cls, uri: str | int, backend: SpatialReference | None = None) -> CRS:
119
- """
120
- Parse an CRS (Coordinate Reference System) URI, which preferably follows the URN format
121
- as specified by `the OGC consortium <http://www.opengeospatial.org/ogcUrnPolicy>`_
122
- and construct a new CRS instance.
123
-
124
- The value can be 3 things:
125
-
126
- * A URI in OGC URN format.
127
- * A legacy CRS URI ("epsg:<SRID>", or "http://www.opengis.net/...").
128
- * A numeric SRID (which calls `from_srid()`)
129
- """
130
- if isinstance(uri, int) or uri.isdigit():
131
- return cls.from_srid(int(uri), backend=backend)
132
- elif uri.startswith("urn:"):
133
- return cls._from_urn(uri, backend=backend)
134
- else:
135
- return cls._from_legacy(uri, backend=backend)
136
-
137
- @classmethod
138
- def from_srid(cls, srid: int, backend=None):
139
- """Instantiate this class using a numeric spatial reference ID
140
-
141
- This is logically identical to calling::
142
-
143
- CRS.from_string("urn:ogc:def:crs:EPSG:6.9:<SRID>")
144
- """
145
- crs = cls(
146
- domain="ogc",
147
- authority="EPSG",
148
- version="",
149
- crsid=str(srid),
150
- srid=int(srid),
151
- backend=backend,
152
- )
153
- crs.__dict__["origin"] = srid
154
- return crs
41
+ min_x: float
42
+ min_y: float
43
+ max_x: float
44
+ max_y: float
45
+ crs: CRS | None = None
155
46
 
156
47
  @classmethod
157
- def _from_urn(cls, urn, backend=None): # noqa: C901
158
- """Instantiate this class using a URN format."""
159
- urn_match = CRS_URN_REGEX.match(urn)
160
- if not urn_match:
161
- raise ExternalValueError(f"Unknown CRS URN [{urn}] specified: {CRS_URN_REGEX.pattern}")
162
-
163
- domain = urn_match.group("domain")
164
- authority = urn_match.group("authority").upper()
165
-
166
- if domain not in ("ogc", "opengis"):
167
- raise ExternalValueError(f"CRS URI [{urn}] contains unknown domain [{domain}]")
168
-
169
- if authority == "EPSG":
170
- crsid = urn_match.group("id")
171
- try:
172
- srid = int(crsid)
173
- except ValueError:
174
- raise ExternalValueError(
175
- f"CRS URI [{urn}] should contain a numeric SRID value."
176
- ) from None
177
- elif authority == "OGC":
178
- # urn:ogc:def:crs:OGC::CRS84 has x/y ordering (longitude/latitude)
179
- crsid = urn_match.group("id").upper()
180
- if crsid != "CRS84":
181
- raise ExternalValueError(f"OGC CRS URI from [{urn}] contains unknown id [{id}]")
182
- srid = 4326
48
+ def from_geometries(cls, geometries: list[GEOSGeometry], crs: CRS) -> BoundingBox | None:
49
+ """Calculate the extent of a collection of geometries."""
50
+ if not geometries:
51
+ return None
52
+ elif len(geometries) == 1:
53
+ # Common case: feature has a single geometry
54
+ ogr_geometry = geometries[0].ogr
55
+ crs.apply_to(ogr_geometry, clone=False)
56
+ return cls(*ogr_geometry.extent, crs=crs)
183
57
  else:
184
- raise ExternalValueError(f"CRS URI [{urn}] contains unknown authority [{authority}]")
58
+ # Feature has multiple geometries.
59
+ # Start with an obviously invalid bbox,
60
+ # which corrects at the first extend_to_geometry call.
61
+ result = cls(math.inf, math.inf, -math.inf, -math.inf, crs=crs)
185
62
 
186
- crs = cls(
187
- domain=domain,
188
- authority=authority,
189
- version=urn_match.group(3),
190
- crsid=crsid,
191
- srid=srid,
192
- backend=backend,
193
- )
194
- crs.__dict__["origin"] = urn
195
- return crs
196
-
197
- @classmethod
198
- def _from_legacy(cls, uri, backend=None):
199
- """Instantiate this class from a legacy URL"""
200
- luri = uri.lower()
201
- for head in (
202
- "epsg:",
203
- "http://www.opengis.net/def/crs/epsg/0/",
204
- "http://www.opengis.net/gml/srs/epsg.xml#",
205
- ):
206
- if luri.startswith(head):
207
- crsid = luri[len(head) :]
208
- try:
209
- srid = int(crsid)
210
- except ValueError:
211
- raise ExternalValueError(
212
- f"CRS URI [{uri}] should contain a numeric SRID value."
213
- ) from None
214
-
215
- crs = cls(
216
- domain="ogc",
217
- authority="EPSG",
218
- version="",
219
- crsid=crsid,
220
- srid=srid,
221
- backend=backend,
222
- )
223
- crs.__dict__["origin"] = uri
224
- return crs
63
+ for geometry in geometries:
64
+ ogr_geometry = geometry.ogr
65
+ crs.apply_to(ogr_geometry, clone=False)
66
+ result.extend_to(*ogr_geometry.extent)
225
67
 
226
- raise ExternalValueError(f"Unknown CRS URI [{uri}] specified")
227
-
228
- @property
229
- def legacy(self):
230
- """Return a legacy string in the format "EPSG:<srid>"""
231
- return f"EPSG:{self.srid:d}"
232
-
233
- @cached_property
234
- def urn(self):
235
- """Return The OGC URN corresponding to this CRS."""
236
- return f"urn:{self.domain}:def:crs:{self.authority}:{self.version or ''}:{self.crsid}"
237
-
238
- def __str__(self):
239
- return self.urn
240
-
241
- def __eq__(self, other):
242
- if isinstance(other, CRS):
243
- # CRS84 is NOT equivalent to EPSG:4326.
244
- # EPSG:4326 specifies coordinates in lat/long order and CRS84 in long/lat order.
245
- return self.authority == other.authority and self.srid == other.srid
246
- else:
247
- return NotImplemented
248
-
249
- def __hash__(self):
250
- """Used to match objects in a set."""
251
- return hash((self.authority, self.srid))
252
-
253
- def _as_gdal(self) -> SpatialReference:
254
- """Generate the GDAL Spatial Reference object"""
255
- if self.backend is None:
256
- # Avoid repeated construction, reuse the object from cache if possible.
257
- # Note that the original data is used, as it also defines axis orientation.
258
- if self.origin:
259
- self.__dict__["backend"] = _get_spatial_reference(self.origin)
260
- else:
261
- self.__dict__["backend"] = _get_spatial_reference(self.srid, srs_type="epsg")
262
- return self.backend
263
-
264
- def apply_to(self, geometry: GEOSGeometry, clone=False) -> GEOSGeometry | None:
265
- """Transform the geometry using this coordinate reference.
266
-
267
- This method caches the used CoordTransform object
268
-
269
- Every transformation within this package happens through this method,
270
- giving full control over coordinate transformations.
271
- """
272
- if self.srid == geometry.srid:
273
- # Avoid changes if spatial reference system is identical.
274
- if clone:
275
- return geometry.clone()
276
- else:
277
- return None
278
- else:
279
- # Convert using GDAL / proj
280
- transform = _get_coord_transform(geometry.srid, self._as_gdal())
281
- return geometry.transform(transform, clone=clone)
282
-
283
-
284
- # Worldwide GPS, latitude/longitude (y/x). https://epsg.io/4326
285
- WGS84 = CRS.from_string("urn:ogc:def:crs:EPSG::4326")
286
-
287
- # GeoJSON default. This is like WGS84 but with longitude/latitude (x/y).
288
- CRS84 = CRS.from_string("urn:ogc:def:crs:OGC::CRS84")
289
-
290
- #: Spherical Mercator (Google Maps, Bing Maps, OpenStreetMap, ...), see https://epsg.io/3857
291
- WEB_MERCATOR = CRS.from_string("urn:ogc:def:crs:EPSG::3857")
292
-
293
-
294
- @dataclass
295
- class BoundingBox:
296
- """A bounding box (or "envelope") that describes the extent of a map layer"""
297
-
298
- south: Decimal # longitude
299
- west: Decimal # latitude
300
- north: Decimal # longitude
301
- east: Decimal # latitude
302
- crs: CRS | None = None
303
-
304
- @classmethod
305
- def from_geometry(cls, geometry: GEOSGeometry, crs: CRS | None = None):
306
- """Construct the bounding box for a geometry"""
307
- if crs is None:
308
- crs = CRS.from_srid(geometry.srid)
309
- elif geometry.srid != crs.srid:
310
- geometry = crs.apply_to(geometry, clone=True)
311
-
312
- return cls(*geometry.extent, crs=crs)
68
+ return result
313
69
 
314
70
  @property
315
71
  def lower_corner(self):
316
- return [self.south, self.west]
72
+ return [self.min_x, self.min_y]
317
73
 
318
74
  @property
319
75
  def upper_corner(self):
320
- return [self.north, self.east]
76
+ return [self.max_x, self.max_y]
321
77
 
322
- def __repr__(self):
323
- return f"BoundingBox({self.south}, {self.west}, {self.north}, {self.east})"
324
-
325
- def extend_to(self, lower_lon: float, lower_lat: float, upper_lon: float, upper_lat: float):
78
+ def extend_to(self, min_x: float, min_y: float, max_x: float, max_y: float):
326
79
  """Expand the bounding box in-place"""
327
- self.south = min(self.south, lower_lon)
328
- self.west = min(self.west, lower_lat)
329
- self.north = max(self.north, upper_lon)
330
- self.east = max(self.east, upper_lat)
331
-
332
- def extend_to_geometry(self, geometry: GEOSGeometry):
333
- """Extend this bounding box with the coordinates of a given geometry."""
334
- if self.crs is not None and geometry.srid != self.crs.srid:
335
- geometry = self.crs.apply_to(geometry, clone=True)
336
-
337
- self.extend_to(*geometry.extent)
80
+ self.min_x = min(self.min_x, min_x)
81
+ self.min_y = min(self.min_y, min_y)
82
+ self.max_x = max(self.max_x, max_x)
83
+ self.max_y = max(self.max_y, max_y)
338
84
 
339
85
  def __add__(self, other):
340
86
  """Combine both extents into a larger box."""
@@ -343,11 +89,23 @@ class BoundingBox:
343
89
  raise ValueError(
344
90
  "Can't combine instances with different spatial reference systems"
345
91
  )
346
- return BoundingBox(
347
- min(self.south, other.south),
348
- min(self.west, other.west),
349
- max(self.north, other.north),
350
- max(self.east, other.east),
92
+ return self.__class__(
93
+ min(self.min_x, other.min_x),
94
+ min(self.min_y, other.min_y),
95
+ max(self.max_x, other.max_x),
96
+ max(self.max_y, other.max_y),
97
+ crs=self.crs,
351
98
  )
352
99
  else:
353
100
  return NotImplemented
101
+
102
+
103
+ class WGS84BoundingBox(BoundingBox):
104
+ """The ``<ows:WGS84BoundingBox>`` element for the ``GetCapabilities`` element.
105
+
106
+ This always has coordinates are always in longitude/latitude axis ordering,
107
+ the CRS is fixed to ``urn:ogc:def:crs:OGC:2:84``.
108
+ """
109
+
110
+ def __init__(self, min_x: float, min_y: float, max_x: float, max_y: float):
111
+ super().__init__(min_x, min_y, max_x, max_y, crs=WGS84_BOUNDING_BOX_CRS)