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.
Files changed (56) hide show
  1. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/METADATA +27 -10
  2. django_gisserver-2.1.1.dist-info/RECORD +68 -0
  3. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/conf.py +23 -1
  6. gisserver/crs.py +452 -0
  7. gisserver/db.py +78 -6
  8. gisserver/exceptions.py +106 -2
  9. gisserver/extensions/functions.py +122 -28
  10. gisserver/extensions/queries.py +15 -10
  11. gisserver/features.py +46 -33
  12. gisserver/geometries.py +64 -306
  13. gisserver/management/commands/loadgeojson.py +41 -21
  14. gisserver/operations/base.py +11 -7
  15. gisserver/operations/wfs20.py +31 -93
  16. gisserver/output/__init__.py +6 -2
  17. gisserver/output/base.py +28 -13
  18. gisserver/output/csv.py +18 -6
  19. gisserver/output/geojson.py +7 -6
  20. gisserver/output/gml32.py +86 -27
  21. gisserver/output/results.py +25 -39
  22. gisserver/output/utils.py +9 -2
  23. gisserver/parsers/ast.py +177 -68
  24. gisserver/parsers/fes20/__init__.py +76 -4
  25. gisserver/parsers/fes20/expressions.py +97 -27
  26. gisserver/parsers/fes20/filters.py +9 -6
  27. gisserver/parsers/fes20/identifiers.py +27 -7
  28. gisserver/parsers/fes20/lookups.py +8 -6
  29. gisserver/parsers/fes20/operators.py +101 -49
  30. gisserver/parsers/fes20/sorting.py +14 -6
  31. gisserver/parsers/gml/__init__.py +10 -19
  32. gisserver/parsers/gml/base.py +32 -14
  33. gisserver/parsers/gml/geometries.py +54 -21
  34. gisserver/parsers/ows/kvp.py +10 -2
  35. gisserver/parsers/ows/requests.py +6 -4
  36. gisserver/parsers/query.py +6 -2
  37. gisserver/parsers/values.py +61 -4
  38. gisserver/parsers/wfs20/__init__.py +2 -0
  39. gisserver/parsers/wfs20/adhoc.py +28 -18
  40. gisserver/parsers/wfs20/base.py +12 -7
  41. gisserver/parsers/wfs20/projection.py +3 -3
  42. gisserver/parsers/wfs20/requests.py +1 -0
  43. gisserver/parsers/wfs20/stored.py +3 -2
  44. gisserver/parsers/xml.py +12 -0
  45. gisserver/projection.py +17 -7
  46. gisserver/static/gisserver/index.css +27 -6
  47. gisserver/templates/gisserver/base.html +15 -0
  48. gisserver/templates/gisserver/index.html +10 -16
  49. gisserver/templates/gisserver/service_description.html +12 -6
  50. gisserver/templates/gisserver/wfs/feature_field.html +1 -1
  51. gisserver/templates/gisserver/wfs/feature_type.html +44 -13
  52. gisserver/types.py +152 -82
  53. gisserver/views.py +47 -24
  54. django_gisserver-2.0.dist-info/RECORD +0 -66
  55. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info/licenses}/LICENSE +0 -0
  56. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/top_level.txt +0 -0
gisserver/output/gml32.py CHANGED
@@ -11,6 +11,7 @@ much extra method calls per field. Some bits are non-DRY inlined for this reason
11
11
  from __future__ import annotations
12
12
 
13
13
  import itertools
14
+ import re
14
15
  from collections import defaultdict
15
16
  from datetime import date, datetime, time, timezone
16
17
  from decimal import Decimal as D
@@ -23,6 +24,7 @@ from django.core.exceptions import ImproperlyConfigured
23
24
  from django.db import models
24
25
  from django.http import HttpResponse
25
26
 
27
+ from gisserver.crs import CRS84
26
28
  from gisserver.db import (
27
29
  AsGML,
28
30
  get_db_geometry_target,
@@ -46,6 +48,7 @@ from .utils import (
46
48
  )
47
49
 
48
50
  GML_RENDER_FUNCTIONS = {}
51
+ RE_SRS_NAME = re.compile(r'srsName="([^"]+)"')
49
52
 
50
53
 
51
54
  def register_geos_type(geos_type):
@@ -170,7 +173,7 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
170
173
  def render_exception(self, exception: Exception):
171
174
  """Render the exception in a format that fits with the output.
172
175
 
173
- The WSF XSD spec has a hidden gem: an <wfs:truncatedResponse> element
176
+ The WSF XSD spec has a hidden gem: an ``<wfs:truncatedResponse>`` element
174
177
  can be rendered at the end of a feature collection
175
178
  to inform the client an error happened during rendering.
176
179
  """
@@ -182,15 +185,14 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
182
185
  exception = WFSException(message, code=exception.__class__.__name__, status_code=500)
183
186
  exception.debug_hint = False
184
187
 
185
- # Only at the top-level, an exception report can be rendered
186
- closing_child = (
187
- f"</wfs:{self.xml_sub_collection_tag}></wfs:member>\n"
188
- if len(self.collection.results) > 1
189
- else ""
190
- )
188
+ # Only at the top-level, an exception report can be rendered, close any remaining tags.
189
+ if len(self.collection.results) > 1:
190
+ sub_closing = f"</wfs:{self.xml_sub_collection_tag}>\n</wfs:member>\n"
191
+ if not buffer.endswith(sub_closing):
192
+ buffer += sub_closing
193
+
191
194
  return (
192
195
  f"{buffer}"
193
- f"{closing_child}"
194
196
  " <wfs:truncatedResponse>"
195
197
  f"{exception.as_xml()}"
196
198
  " </wfs:truncatedResponse>\n"
@@ -204,9 +206,14 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
204
206
  collection = self.collection
205
207
  self.output = output = StringIO()
206
208
  self._write = self.output.write
209
+
210
+ # The base class peaks the generator and handles early exceptions.
211
+ # Any database exceptions during calculating the number of results
212
+ # are all handled by the main WFS view.
207
213
  number_matched = collection.number_matched
208
214
  number_matched = int(number_matched) if number_matched is not None else "unknown"
209
215
  number_returned = collection.number_returned
216
+
210
217
  next = previous = ""
211
218
  if collection.next:
212
219
  next = f' next="{attr_escape(collection.next)}"'
@@ -228,6 +235,15 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
228
235
 
229
236
  for sub_collection in collection.results:
230
237
  projection = sub_collection.projection
238
+ if projection.output_crs.force_xy and projection.output_crs.is_north_east_order:
239
+ self._write(
240
+ "<!--\n"
241
+ f" NOTE: you are requesting the legacy projection notation '{tag_escape(projection.output_crs.origin)}'."
242
+ f" Please use '{tag_escape(projection.output_crs.urn)}' instead.\n\n"
243
+ " This also means output coordinates are ordered in legacy the 'west, north' axis ordering.\n"
244
+ "\n-->\n"
245
+ )
246
+
231
247
  self.start_collection(sub_collection)
232
248
  if has_multiple_collections:
233
249
  self._write(
@@ -238,7 +254,7 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
238
254
  f' numberReturned="{int(sub_collection.number_returned)}">\n'
239
255
  )
240
256
 
241
- for instance in sub_collection:
257
+ for instance in self.read_features(sub_collection):
242
258
  self.gml_seq = 0 # need to increment this between write_xml_field calls
243
259
  self._write("<wfs:member>\n")
244
260
  self.write_feature(projection, instance)
@@ -368,11 +384,11 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
368
384
  if geo_element.type is XsdTypes.gmlBoundingShapeType:
369
385
  # Special case for <gml:boundedBy>, which doesn't need xsd:nil values, nor a gml:id.
370
386
  # The value is not a GEOSGeometry either, as that is not exposed by django.contrib.gis.
371
- value = cast(GmlBoundedByElement, geo_element).get_value(
387
+ envelope = cast(GmlBoundedByElement, geo_element).get_value(
372
388
  instance, crs=projection.output_crs
373
389
  )
374
- if value is not None:
375
- self._write(self.render_gml_bounds(value))
390
+ if envelope is not None:
391
+ self._write(self.render_gml_bounds(envelope))
376
392
  else:
377
393
  # Regular geometry elements.
378
394
  xml_qname = self.xml_qnames[geo_element]
@@ -398,12 +414,12 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
398
414
 
399
415
  def render_gml_bounds(self, envelope: BoundingBox) -> str:
400
416
  """Render the gml:boundedBy element that contains an Envelope.
401
- Note this uses an internal object type,
402
- as :mod:`django.contrib.gis.geos` only provides a Point/Polygon or 4-tuple envelope.
417
+ This uses an internal object type,
418
+ as :mod:`django.contrib.gis.geos` only provides a 4-tuple envelope.
403
419
  """
404
- return f"""<gml:boundedBy><gml:Envelope srsDimension="2" srsName="{attr_escape(envelope.crs.urn)}">
405
- <gml:lowerCorner>{envelope.south} {envelope.west}</gml:lowerCorner>
406
- <gml:upperCorner>{envelope.north} {envelope.east}</gml:upperCorner>
420
+ return f"""<gml:boundedBy><gml:Envelope srsDimension="2" srsName="{attr_escape(str(envelope.crs))}">
421
+ <gml:lowerCorner>{envelope.min_x} {envelope.min_y}</gml:lowerCorner>
422
+ <gml:upperCorner>{envelope.max_x} {envelope.max_y}</gml:upperCorner>
407
423
  </gml:Envelope></gml:boundedBy>\n"""
408
424
 
409
425
  def render_gml_value(
@@ -415,7 +431,7 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
415
431
  ) -> str:
416
432
  """Normal case: 'value' is raw geometry data.."""
417
433
  # In case this is a standalone response, this will be the top-level element, hence includes the xmlns.
418
- base_attrs = f' gml:id="{attr_escape(gml_id)}" srsName="{attr_escape(projection.output_crs.urn)}"{extra_xmlns}'
434
+ base_attrs = f' gml:id="{attr_escape(gml_id)}" srsName="{attr_escape(str(projection.output_crs))}"{extra_xmlns}'
419
435
  projection.output_crs.apply_to(value)
420
436
  return self._render_gml_type(value, base_attrs=base_attrs)
421
437
 
@@ -517,11 +533,11 @@ class DBGMLRenderingMixin:
517
533
  id_pos = gml_tag.find("gml:id=")
518
534
  if id_pos == -1:
519
535
  # Inject
520
- return f'{gml_tag} gml:id="{attr_escape(gml_id)}"{extra_xmlns}{value[end_pos:]}'
536
+ gml = f'{gml_tag} gml:id="{attr_escape(gml_id)}"{extra_xmlns}{value[end_pos:]}'
521
537
  else:
522
538
  # Replace
523
539
  end_pos1 = gml_tag.find('"', id_pos + 8)
524
- return (
540
+ gml = (
525
541
  f"{gml_tag[:id_pos]}"
526
542
  f'gml:id="{attr_escape(gml_id)}'
527
543
  f"{value[end_pos1:end_pos]}" # from " right until >
@@ -529,6 +545,23 @@ class DBGMLRenderingMixin:
529
545
  f"{value[end_pos:]}" # from > and beyond
530
546
  )
531
547
 
548
+ return self._fix_db_crs_name(projection, gml)
549
+
550
+ def _fix_db_crs_name(self, projection: FeatureProjection, gml: str) -> str:
551
+ if projection.output_crs.force_xy:
552
+ # When legacy output is used, make sure the srsName matches the input.
553
+ # PostgreSQL will still generate the EPSG:xxxx notation.
554
+ return gml.replace(
555
+ 'srsName="EPSG:', 'srsName="http://www.opengis.net/gml/srs/epsg.xml#', 1
556
+ )
557
+ elif projection.output_crs == CRS84:
558
+ # Fix PostgreSQL not knowing it's CRS84 or WGS84 (both srid 4326)
559
+ return gml.replace(
560
+ 'srsName="urn:ogc:def:crs:EPSG::4326"', 'srsName="urn:ogc:def:crs:OGC::CRS84"', 1
561
+ )
562
+ else:
563
+ return gml
564
+
532
565
 
533
566
  class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
534
567
  """Faster GetFeature renderer that uses the database to render GML 3.2"""
@@ -549,8 +582,14 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
549
582
  # Retrieve geometries as pre-rendered instead.
550
583
  # Only take the geometries of the current level.
551
584
  # The annotations for relations will be handled by prefetches and get_prefetch_queryset()
585
+ use_modern = not projection.output_crs.force_xy
552
586
  return replace_queryset_geometries(
553
- queryset, projection.geometry_elements, projection.output_crs, AsGML
587
+ queryset,
588
+ projection.geometry_elements,
589
+ projection.output_crs,
590
+ AsGML,
591
+ is_latlon=use_modern and projection.output_crs.is_north_east_order,
592
+ long_urn=use_modern,
554
593
  )
555
594
 
556
595
  def get_prefetch_queryset(
@@ -564,8 +603,14 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
564
603
  return None
565
604
 
566
605
  # Find which fields are GML elements
606
+ use_modern = not projection.output_crs.force_xy
567
607
  return replace_queryset_geometries(
568
- queryset, feature_relation.geometry_elements, projection.output_crs, AsGML
608
+ queryset,
609
+ feature_relation.geometry_elements,
610
+ projection.output_crs,
611
+ AsGML,
612
+ is_latlon=use_modern and projection.output_crs.is_north_east_order,
613
+ long_urn=use_modern,
569
614
  )
570
615
 
571
616
  def get_db_envelope_as_gml(self, projection: FeatureProjection, queryset) -> AsGML:
@@ -574,7 +619,13 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
574
619
  This also avoids offloads the geometry union calculation to the DB.
575
620
  """
576
621
  geo_fields_union = self._get_geometries_union(projection, queryset)
577
- return AsGML(geo_fields_union, envelope=True)
622
+ use_modern = not projection.output_crs.force_xy
623
+ return AsGML(
624
+ geo_fields_union,
625
+ envelope=True,
626
+ is_latlon=use_modern and projection.output_crs.is_north_east_order,
627
+ long_urn=use_modern,
628
+ )
578
629
 
579
630
  def _get_geometries_union(self, projection: FeatureProjection, queryset):
580
631
  """Combine all geometries of the model in a single SQL function."""
@@ -604,6 +655,8 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
604
655
  gml = instance._as_envelope_gml
605
656
  if gml is None:
606
657
  return
658
+
659
+ gml = self._fix_db_crs_name(projection, gml)
607
660
  else:
608
661
  value = get_db_rendered_geometry(instance, geo_element, AsGML)
609
662
  if value is None:
@@ -621,9 +674,9 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
621
674
  class GML32ValueRenderer(GML32Renderer):
622
675
  """Render the GetPropertyValue XML output in GML 3.2 format.
623
676
 
624
- Geoserver seems to generate the element tag inside each <wfs:member> element. We've applied this one.
625
- The GML standard demonstrates to render only their content inside a <wfs:member> element
626
- (either plain text or an <gml:...> tag). Not sure what is right here.
677
+ Geoserver seems to generate the element tag inside each ``<wfs:member>`` element. We've applied this one.
678
+ The GML standard demonstrates to render only their content inside a ``<wfs:member>`` element
679
+ (either plain text or an ``<gml:...>`` tag). Not sure what is right here.
627
680
  """
628
681
 
629
682
  content_type = "text/xml; charset=utf-8"
@@ -734,8 +787,14 @@ class DBGML32ValueRenderer(DBGMLRenderingMixin, GML32ValueRenderer):
734
787
  if element.type.is_geometry:
735
788
  # Add 'gml_member' to point to the pre-rendered GML version.
736
789
  geo_element = cast(GeometryXsdElement, element)
790
+ use_modern = not projection.output_crs.force_xy
737
791
  return queryset.values(
738
- "pk", gml_member=AsGML(get_db_geometry_target(geo_element, projection.output_crs))
792
+ "pk",
793
+ gml_member=AsGML(
794
+ get_db_geometry_target(geo_element, projection.output_crs),
795
+ is_latlon=use_modern and projection.output_crs.is_north_east_order,
796
+ long_urn=use_modern,
797
+ ),
739
798
  )
740
799
  else:
741
800
  return queryset
@@ -7,18 +7,17 @@ properties match the WFS 2.0 spec closely.
7
7
  from __future__ import annotations
8
8
 
9
9
  import math
10
- import operator
11
10
  import typing
12
11
  from collections.abc import Iterable
13
12
  from datetime import timezone
14
- from functools import cached_property, reduce
13
+ from functools import cached_property
15
14
 
16
15
  from django.db import models
17
16
  from django.utils.timezone import now
18
17
 
19
18
  from gisserver import conf
19
+ from gisserver.exceptions import wrap_filter_errors
20
20
  from gisserver.features import FeatureType
21
- from gisserver.geometries import BoundingBox
22
21
 
23
22
  from .iters import ChunkedQuerySetIterator, CountingIterator
24
23
 
@@ -33,7 +32,7 @@ class SimpleFeatureCollection:
33
32
  """Wrapper to read a result set.
34
33
 
35
34
  This object type is defined in the WFS spec.
36
- It holds a collection of "wfs:member" objects.
35
+ It holds a collection of ``<wfs:member>`` objects.
37
36
  """
38
37
 
39
38
  def __init__(
@@ -86,7 +85,8 @@ class SimpleFeatureCollection:
86
85
  """Explicitly request the results to be streamed.
87
86
 
88
87
  This can be used by output formats that stream results, and don't
89
- access `number_returned`. Note this is not compatible with prefetch_related().
88
+ access :attr:`number_returned`.
89
+ Note this is not compatible with ``prefetch_related()``.
90
90
  """
91
91
  if self._result_iterator is not None:
92
92
  raise RuntimeError("Results for feature collection are read twice.")
@@ -126,12 +126,13 @@ class SimpleFeatureCollection:
126
126
  return self.queryset[self.start : self.stop + (1 if add_sentinel else 0)]
127
127
 
128
128
  def first(self):
129
- try:
130
- # Don't query a full page, return only one instance (for GetFeatureById)
131
- # This also preserves the extra added annotations (like _as_gml_FIELD)
132
- return self.queryset[self.start]
133
- except IndexError:
134
- return None
129
+ with wrap_filter_errors(self.source_query):
130
+ try:
131
+ # Don't query a full page, return only one instance (for GetFeatureById)
132
+ # This also preserves the extra added annotations (like _as_gml_FIELD)
133
+ return self.queryset[self.start]
134
+ except IndexError:
135
+ return None
135
136
 
136
137
  def fetch_results(self):
137
138
  """Forcefully read the results early."""
@@ -148,11 +149,15 @@ class SimpleFeatureCollection:
148
149
  if self.stop == math.inf:
149
150
  # Infinite page requested, see if start is still requested
150
151
  qs = self.queryset[self.start :] if self.start else self.queryset.all()
151
- self._result_cache = list(qs)
152
+
153
+ with wrap_filter_errors(self.source_query):
154
+ self._result_cache = list(qs)
152
155
  elif self._use_sentinel_record:
153
156
  # No counting, but instead fetch an extra item as sentinel to see if there are more results.
154
157
  qs = self.queryset[self.start : self.stop + 1]
155
- page_results = list(qs)
158
+
159
+ with wrap_filter_errors(self.source_query):
160
+ page_results = list(qs)
156
161
 
157
162
  # The stop + 1 sentinel allows checking if there is a next page.
158
163
  # This means no COUNT() is needed to detect that.
@@ -167,7 +172,9 @@ class SimpleFeatureCollection:
167
172
  # Fetch exactly the page size, no more is needed.
168
173
  # Will use a COUNT on the total table, so it can be used to see if there are more pages.
169
174
  qs = self.queryset[self.start : self.stop]
170
- self._result_cache = list(qs)
175
+
176
+ with wrap_filter_errors(self.source_query):
177
+ self._result_cache = list(qs)
171
178
 
172
179
  @cached_property
173
180
  def _use_sentinel_record(self) -> bool:
@@ -224,7 +231,8 @@ class SimpleFeatureCollection:
224
231
  qs.query.annotations = clean_annotations
225
232
 
226
233
  # Calculate, cache and return
227
- self._number_matched = qs.count()
234
+ with wrap_filter_errors(self.source_query):
235
+ self._number_matched = qs.count()
228
236
  return self._number_matched
229
237
 
230
238
  @property
@@ -278,28 +286,11 @@ class SimpleFeatureCollection:
278
286
  # but since the projection needs to be calculated once, it's stored here for convenience.
279
287
  return self.source_query.get_projection()
280
288
 
281
- def get_bounding_box(self) -> BoundingBox:
282
- """Determine bounding box of all items."""
283
- self.fetch_results() # Avoid querying results twice
284
-
285
- # Start with an obviously invalid bbox,
286
- # which corrects at the first extend_to_geometry call.
287
- bbox = BoundingBox(math.inf, math.inf, -math.inf, -math.inf)
288
-
289
- # Allow the geometry to exist in a dotted relationship.
290
- for instance in self:
291
- geometry_value = self.projection.get_main_geometry_value(instance)
292
- if geometry_value is None:
293
- continue
294
-
295
- bbox.extend_to_geometry(geometry_value)
296
-
297
- return bbox
298
-
299
289
 
300
290
  class FeatureCollection:
301
- """WFS object that holds the result type for GetFeature.
291
+ """WFS object that holds the result type for ``GetFeature``.
302
292
  This object type is defined in the WFS spec.
293
+ It holds a collection of :class:`SimpleFeatureCollection` results.
303
294
  """
304
295
 
305
296
  def __init__(
@@ -323,11 +314,6 @@ class FeatureCollection:
323
314
  self.date = now()
324
315
  self.timestamp = self.date.astimezone(timezone.utc).isoformat()
325
316
 
326
- def get_bounding_box(self) -> BoundingBox:
327
- """Determine bounding box of all items."""
328
- # Combine the bounding box of all collections
329
- return reduce(operator.add, [c.get_bounding_box() for c in self.results])
330
-
331
317
  @cached_property
332
318
  def number_returned(self) -> int:
333
319
  """Return the total number of returned features"""
gisserver/output/utils.py CHANGED
@@ -23,16 +23,20 @@ __all__ = (
23
23
 
24
24
 
25
25
  def tag_escape(s: str):
26
+ """Escape a value for usage in XML text."""
26
27
  return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
27
28
 
28
29
 
29
30
  def attr_escape(s: str):
30
- # Slightly faster then html.escape() as it doesn't replace single quotes.
31
+ """Escape a value for usage in an XML attribute.
32
+ This is slightly faster than ``html.escape()`` as it doesn't replace single quotes.
33
+ """
31
34
  # Having tried all possible variants, this code still outperforms other forms of escaping.
32
35
  return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
33
36
 
34
37
 
35
38
  def value_to_xml_string(value):
39
+ """Format a Python value for usage in XML text."""
36
40
  # Simple scalar value
37
41
  if isinstance(value, str): # most cases
38
42
  return tag_escape(value)
@@ -47,6 +51,9 @@ def value_to_xml_string(value):
47
51
 
48
52
 
49
53
  def value_to_text(value):
54
+ """Format a Python value for usage in plain text output.
55
+ This doesn't do any XML escaping.
56
+ """
50
57
  # Simple scalar value, no XML escapes
51
58
  if isinstance(value, str): # most cases
52
59
  return value
@@ -59,7 +66,7 @@ def value_to_text(value):
59
66
 
60
67
 
61
68
  def render_xmlns_attributes(xml_namespaces: dict[str, str]):
62
- """Render XML Namespace declaration attributes"""
69
+ """Render XML Namespace declaration attributes, i.e. ``xmlns:prefix="uri"`` for each dict item."""
63
70
  return " ".join(
64
71
  f'xmlns:{prefix}="{xml_namespace}"' if prefix else f'xmlns="{xml_namespace}"'
65
72
  for xml_namespace, prefix in xml_namespaces.items()