django-gisserver 2.0__py3-none-any.whl → 2.1.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.
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/METADATA +27 -10
- django_gisserver-2.1.1.dist-info/RECORD +68 -0
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/conf.py +23 -1
- gisserver/crs.py +452 -0
- gisserver/db.py +78 -6
- gisserver/exceptions.py +106 -2
- gisserver/extensions/functions.py +122 -28
- gisserver/extensions/queries.py +15 -10
- gisserver/features.py +46 -33
- gisserver/geometries.py +64 -306
- gisserver/management/commands/loadgeojson.py +41 -21
- gisserver/operations/base.py +11 -7
- gisserver/operations/wfs20.py +31 -93
- gisserver/output/__init__.py +6 -2
- gisserver/output/base.py +28 -13
- gisserver/output/csv.py +18 -6
- gisserver/output/geojson.py +7 -6
- gisserver/output/gml32.py +86 -27
- gisserver/output/results.py +25 -39
- gisserver/output/utils.py +9 -2
- gisserver/parsers/ast.py +177 -68
- gisserver/parsers/fes20/__init__.py +76 -4
- gisserver/parsers/fes20/expressions.py +97 -27
- gisserver/parsers/fes20/filters.py +9 -6
- gisserver/parsers/fes20/identifiers.py +27 -7
- gisserver/parsers/fes20/lookups.py +8 -6
- gisserver/parsers/fes20/operators.py +101 -49
- gisserver/parsers/fes20/sorting.py +14 -6
- gisserver/parsers/gml/__init__.py +10 -19
- gisserver/parsers/gml/base.py +32 -14
- gisserver/parsers/gml/geometries.py +54 -21
- gisserver/parsers/ows/kvp.py +10 -2
- gisserver/parsers/ows/requests.py +6 -4
- gisserver/parsers/query.py +6 -2
- gisserver/parsers/values.py +61 -4
- gisserver/parsers/wfs20/__init__.py +2 -0
- gisserver/parsers/wfs20/adhoc.py +28 -18
- gisserver/parsers/wfs20/base.py +12 -7
- gisserver/parsers/wfs20/projection.py +3 -3
- gisserver/parsers/wfs20/requests.py +1 -0
- gisserver/parsers/wfs20/stored.py +3 -2
- gisserver/parsers/xml.py +12 -0
- gisserver/projection.py +17 -7
- gisserver/static/gisserver/index.css +27 -6
- gisserver/templates/gisserver/base.html +15 -0
- gisserver/templates/gisserver/index.html +10 -16
- gisserver/templates/gisserver/service_description.html +12 -6
- gisserver/templates/gisserver/wfs/feature_field.html +1 -1
- gisserver/templates/gisserver/wfs/feature_type.html +44 -13
- gisserver/types.py +152 -82
- gisserver/views.py +47 -24
- django_gisserver-2.0.dist-info/RECORD +0 -66
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info/licenses}/LICENSE +0 -0
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/top_level.txt +0 -0
gisserver/features.py
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
"""The configuration
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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) ->
|
|
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
|
-
|
|
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,16 +846,22 @@ 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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
849
|
+
for candidate in self.supported_crs:
|
|
850
|
+
# Not using self.supported_crs.index(crs), as that depends on CRS.__eq__():
|
|
851
|
+
if candidate.matches(crs, compare_legacy=False):
|
|
852
|
+
if candidate.force_xy != crs.force_xy:
|
|
853
|
+
# user provided legacy CRS, allow output in legacy CRS
|
|
854
|
+
return crs
|
|
855
|
+
else:
|
|
856
|
+
# Replace the parsed CRS with the declared one.
|
|
857
|
+
return candidate
|
|
858
|
+
|
|
859
|
+
# No match found
|
|
847
860
|
if conf.GISSERVER_SUPPORTED_CRS_ONLY:
|
|
848
861
|
raise InvalidParameterValue(
|
|
849
|
-
f"Feature '{self.name}' does not support
|
|
862
|
+
f"Feature '{self.name}' does not support CRS '{crs}'.",
|
|
850
863
|
locator=locator,
|
|
851
|
-
)
|
|
864
|
+
) from None
|
|
852
865
|
else:
|
|
853
866
|
return crs
|
|
854
867
|
|
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
|
|
10
|
-
import
|
|
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.
|
|
13
|
+
from gisserver.crs import CRS, CRS84, WEB_MERCATOR, WGS84 # noqa: F401 (keep old exports)
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
158
|
-
"""
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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.
|
|
72
|
+
return [self.min_x, self.min_y]
|
|
317
73
|
|
|
318
74
|
@property
|
|
319
75
|
def upper_corner(self):
|
|
320
|
-
return [self.
|
|
76
|
+
return [self.max_x, self.max_y]
|
|
321
77
|
|
|
322
|
-
def
|
|
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.
|
|
328
|
-
self.
|
|
329
|
-
self.
|
|
330
|
-
self.
|
|
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
|
|
347
|
-
min(self.
|
|
348
|
-
min(self.
|
|
349
|
-
max(self.
|
|
350
|
-
max(self.
|
|
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)
|