maxml 1.0.2__tar.gz → 1.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxml
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A streamlined pure Python XML serializer.
5
5
  Author: Daniel Sissman
6
6
  License-Expression: MIT
@@ -115,10 +115,10 @@ The `Element` class constructor `Element(...)` takes the following arguments:
115
115
 
116
116
  The `Element` class provides the following methods:
117
117
 
118
- * `register_namespace(prefix: str, uri: str)` The `register_namespace()` method
119
- supports registering namespaces globally for the module or per instance depending
120
- on whether the method is called on the class directly or whether it is called on a
121
- specific instance of the class.
118
+ * `register_namespace(prefix: str, uri: str, promoted: bool = False)`
119
+ The `register_namespace()` method supports registering namespaces globally for the
120
+ module or per instance depending on whether the method is called on the class
121
+ directly or whether it is called on a specific instance of the class.
122
122
 
123
123
  If a namespace is registered globally for the module, the registered namespaces
124
124
  become available for use by any instance of the class created within the program
@@ -132,6 +132,12 @@ The `Element` class provides the following methods:
132
132
 
133
133
  Each namespace consists of a prefix which can be used to prefix element names
134
134
  and the URI associated with that namespace prefix.
135
+
136
+ Optionally, a namespace can be marked as promoted during registration, which will
137
+ result in the namespace being serialized into the XML before any attributes on the
138
+ element. Namespaces that are not marked as promoted will appear after attributes.
139
+ Namespace promotion can be enabled for a given namespace during registration by
140
+ passing the optional `promoted` keyword argument with the value of `True`.
135
141
 
136
142
  For example, the 'rdf' prefix is associated with the following canonical URI:
137
143
  "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
@@ -84,10 +84,10 @@ The `Element` class constructor `Element(...)` takes the following arguments:
84
84
 
85
85
  The `Element` class provides the following methods:
86
86
 
87
- * `register_namespace(prefix: str, uri: str)` The `register_namespace()` method
88
- supports registering namespaces globally for the module or per instance depending
89
- on whether the method is called on the class directly or whether it is called on a
90
- specific instance of the class.
87
+ * `register_namespace(prefix: str, uri: str, promoted: bool = False)`
88
+ The `register_namespace()` method supports registering namespaces globally for the
89
+ module or per instance depending on whether the method is called on the class
90
+ directly or whether it is called on a specific instance of the class.
91
91
 
92
92
  If a namespace is registered globally for the module, the registered namespaces
93
93
  become available for use by any instance of the class created within the program
@@ -101,6 +101,12 @@ The `Element` class provides the following methods:
101
101
 
102
102
  Each namespace consists of a prefix which can be used to prefix element names
103
103
  and the URI associated with that namespace prefix.
104
+
105
+ Optionally, a namespace can be marked as promoted during registration, which will
106
+ result in the namespace being serialized into the XML before any attributes on the
107
+ element. Namespaces that are not marked as promoted will appear after attributes.
108
+ Namespace promotion can be enabled for a given namespace during registration by
109
+ passing the optional `promoted` keyword argument with the value of `True`.
104
110
 
105
111
  For example, the 'rdf' prefix is associated with the following canonical URI:
106
112
  "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
@@ -0,0 +1,4 @@
1
+ from maxml.element import Element
2
+ from maxml.namespace import Namespace
3
+ from maxml.enumerations import Escape
4
+ from maxml.exceptions import MaXMLError
@@ -2,9 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from maxml.namespace import Namespace
4
4
  from maxml.logging import logger
5
+ from maxml.enumerations import (
6
+ Context,
7
+ Escape,
8
+ )
9
+ from maxml.exceptions import MaXMLError
5
10
 
6
11
  from classicist import hybridmethod
7
12
 
13
+ import re
8
14
 
9
15
  logger = logger.getChild(__name__)
10
16
 
@@ -22,7 +28,7 @@ class Element(object):
22
28
  _mixed: bool = False
23
29
 
24
30
  @hybridmethod
25
- def register_namespace(self, prefix: str, uri: str):
31
+ def register_namespace(self, prefix: str, uri: str, promoted: bool = False):
26
32
  """Supports registering namespaces globally for the module or per instance
27
33
  depending on whether the method is called on the class directly or whether it is
28
34
  called on a specific instance of the class.
@@ -68,6 +74,9 @@ class Element(object):
68
74
  if not isinstance(uri, str):
69
75
  raise TypeError("The 'uri' argument must have a string value!")
70
76
 
77
+ if not isinstance(promoted, bool):
78
+ raise TypeError("The 'promoted' argument must have a boolean value!")
79
+
71
80
  for namespace in self._namespaces:
72
81
  if namespace.prefix == prefix:
73
82
  if namespace.uri == uri:
@@ -77,12 +86,12 @@ class Element(object):
77
86
  )
78
87
  break
79
88
  else:
80
- raise ValueError(
81
- "The '%s' namespace has already been registered with a different URI!"
82
- % (prefix)
89
+ raise MaXMLError(
90
+ "The '%s' namespace, %s, has already been registered with a different URI: %s!"
91
+ % (prefix, uri, namespace.uri)
83
92
  )
84
93
  else:
85
- if namespace := Namespace(prefix=prefix, uri=uri):
94
+ if namespace := Namespace(prefix=prefix, uri=uri, promoted=promoted):
86
95
  self._namespaces.add(namespace)
87
96
 
88
97
  def __init__(
@@ -170,7 +179,7 @@ class Element(object):
170
179
 
171
180
  break
172
181
  else:
173
- raise ValueError(
182
+ raise MaXMLError(
174
183
  f"No namespace has been registered for the '{prefix}' prefix associated with the '{name}' element!"
175
184
  )
176
185
 
@@ -180,7 +189,7 @@ class Element(object):
180
189
  if namespace.uri == uri:
181
190
  break
182
191
  else:
183
- namespace = Namespace(prefix=prefix, uri=uri)
192
+ namespace = Namespace(prefix=prefix, uri=uri, promoted=False)
184
193
 
185
194
  self.__class__._namespaces.add(namespace)
186
195
 
@@ -557,6 +566,7 @@ class Element(object):
557
566
  pretty: bool = False,
558
567
  indent: str | int = None,
559
568
  encoding: str = None,
569
+ escape: Escape = Escape.All,
560
570
  **kwargs,
561
571
  ) -> str | bytes:
562
572
  """Supports serializing the current Element tree to a string or to a bytes array
@@ -565,6 +575,11 @@ class Element(object):
565
575
  if not isinstance(pretty, bool):
566
576
  raise TypeError("The 'pretty' argument must have a boolean value!")
567
577
 
578
+ if not isinstance(escape, Escape):
579
+ raise TypeError(
580
+ "The 'escape' argument must reference an Escape enumeration option!"
581
+ )
582
+
568
583
  if indent is None:
569
584
  indent = 2
570
585
 
@@ -585,10 +600,54 @@ class Element(object):
585
600
  elif not isinstance(encoding, str):
586
601
  raise TypeError("The 'encoding' argument must have a string value!")
587
602
 
603
+ def escaper(value: str, context: Context, escape: Escape) -> str:
604
+ """Helper method to escape special characters in XML attributes and text."""
605
+
606
+ if isinstance(value, str):
607
+ pass
608
+ elif hasattr(value, "__str__"):
609
+ value = str(value)
610
+ else:
611
+ raise TypeError(
612
+ "The 'value' argument must have a string value or a value that can be cast to a string!"
613
+ )
614
+
615
+ replacements: dict[str, str] = {
616
+ "&": "&",
617
+ "<": "&lt;",
618
+ ">": "&gt;",
619
+ '"': "&quot;",
620
+ "'": "&apos;",
621
+ }
622
+
623
+ if context is Context.Attribute:
624
+ # Required replacements for element attribute values
625
+ required: list[str] = ["&", "<", '"']
626
+ elif context is Context.Text:
627
+ # Required replacements for element text
628
+ required: list[str] = ["&", "<"]
629
+ else:
630
+ # Require all replacements for other contexts
631
+ required: list[str] = [key for key in replacements]
632
+
633
+ for search, replacement in replacements.items():
634
+ if (escape is Escape.All) or (search in required):
635
+ if search == "&":
636
+ # Ensure only standalone "&" characters are replaced, ignoring
637
+ # any that are part of an XML special character escape sequence
638
+ value = re.sub(
639
+ r"&(?!(([a-z]+|#x?[0-9a-fA-F]+);))", replacement, value
640
+ )
641
+ else:
642
+ value = value.replace(search, replacement)
643
+
644
+ return value
645
+
588
646
  def stringify(
589
647
  element: Element,
590
648
  depth: int,
591
649
  pretty: bool = False,
650
+ escape: Escape = Escape.All,
592
651
  indent: str = None,
593
652
  **kwargs,
594
653
  ) -> str:
@@ -606,7 +665,7 @@ class Element(object):
606
665
  # Add any promoted namespaces (those which should proceed any attributes)
607
666
  count = len(element.namespaced)
608
667
  for index, namespace in enumerate(element.namespaced, start=1):
609
- if not namespace.promoted:
668
+ if namespace.promoted is False:
610
669
  continue
611
670
 
612
671
  if pretty and count > 1 and (newline or (index > 1 and index <= count)):
@@ -624,6 +683,8 @@ class Element(object):
624
683
  string += f"\n{indent * (depth + 2)}"
625
684
  newline = True
626
685
 
686
+ value = escaper(value, context=Context.Attribute, escape=escape)
687
+
627
688
  string += f' {key}="{value}"'
628
689
 
629
690
  # Add any non-promoted namespaces (those which can follow any attributes)
@@ -633,7 +694,7 @@ class Element(object):
633
694
  newline = True
634
695
 
635
696
  for index, namespace in enumerate(element.namespaced, start=1):
636
- if namespace.promoted:
697
+ if namespace.promoted is True:
637
698
  continue
638
699
 
639
700
  if pretty and count > 1 and (newline or (index > 1 and index <= count)):
@@ -656,7 +717,7 @@ class Element(object):
656
717
 
657
718
  # Include the element's text content, if any
658
719
  if element.text and (element.mixed or not element.children):
659
- string += element.text
720
+ string += escaper(element.text, context=Context.Text, escape=escape)
660
721
 
661
722
  # Include the element's children, if any
662
723
  if element.children and (element.mixed or not element.text):
@@ -669,6 +730,7 @@ class Element(object):
669
730
  depth=(depth + 1),
670
731
  pretty=pretty,
671
732
  indent=indent,
733
+ escape=escape,
672
734
  **kwargs,
673
735
  )
674
736
 
@@ -684,6 +746,7 @@ class Element(object):
684
746
  depth=0,
685
747
  pretty=pretty,
686
748
  indent=indent,
749
+ escape=escape,
687
750
  **kwargs,
688
751
  )
689
752
 
@@ -0,0 +1,25 @@
1
+ from enumerific import Enumeration, auto
2
+
3
+
4
+ class Context(Enumeration):
5
+ """List of XML contexts, to denote the XML context currently being processed."""
6
+
7
+ Unknown = auto()
8
+ Root = auto(description="Document root node")
9
+ Prolog = auto(description="Document declaration (prolog)")
10
+ Instruction = auto(description="Document processing instruction")
11
+ DocType = auto(description="Document type")
12
+ Element = auto(description="Element comprising opening/closing or self-closing tag")
13
+ TagOpen = auto(description="Opening tag of an element (maybe self-closing)")
14
+ TagClose = auto(description="Closing tag of an element")
15
+ Attribute = auto(description="Attribute held by an element's tag")
16
+ Text = auto(description="Text held between an element's opening and closing tags")
17
+ Data = auto(description="Data held in a CDATA section")
18
+ Comment = auto(description="Comment held in a comment section")
19
+
20
+
21
+ class Escape(Enumeration):
22
+ """List of XML special character escape modes."""
23
+
24
+ All = auto(description="Escape all XML special characters")
25
+ Required = auto(description="Escpae only required XML special characters")
@@ -0,0 +1,2 @@
1
+ class MaXMLError(Exception):
2
+ pass
@@ -14,7 +14,7 @@ class Namespace(object):
14
14
  _uri: str = None
15
15
  _promoted: bool = False
16
16
 
17
- def __init__(self, prefix: str, uri: str):
17
+ def __init__(self, prefix: str, uri: str, promoted: bool = False):
18
18
  """Initialize the Namespace class"""
19
19
 
20
20
  if not isinstance(prefix, str):
@@ -27,6 +27,11 @@ class Namespace(object):
27
27
 
28
28
  self._uri = uri
29
29
 
30
+ if not isinstance(promoted, bool):
31
+ raise TypeError("The 'promoted' argument must have a boolean value!")
32
+
33
+ self._promoted = promoted
34
+
30
35
  def __str__(self) -> str:
31
36
  """Return a string representation of the class for debugging purposes."""
32
37
 
@@ -0,0 +1 @@
1
+ 1.0.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxml
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A streamlined pure Python XML serializer.
5
5
  Author: Daniel Sissman
6
6
  License-Expression: MIT
@@ -115,10 +115,10 @@ The `Element` class constructor `Element(...)` takes the following arguments:
115
115
 
116
116
  The `Element` class provides the following methods:
117
117
 
118
- * `register_namespace(prefix: str, uri: str)` The `register_namespace()` method
119
- supports registering namespaces globally for the module or per instance depending
120
- on whether the method is called on the class directly or whether it is called on a
121
- specific instance of the class.
118
+ * `register_namespace(prefix: str, uri: str, promoted: bool = False)`
119
+ The `register_namespace()` method supports registering namespaces globally for the
120
+ module or per instance depending on whether the method is called on the class
121
+ directly or whether it is called on a specific instance of the class.
122
122
 
123
123
  If a namespace is registered globally for the module, the registered namespaces
124
124
  become available for use by any instance of the class created within the program
@@ -132,6 +132,12 @@ The `Element` class provides the following methods:
132
132
 
133
133
  Each namespace consists of a prefix which can be used to prefix element names
134
134
  and the URI associated with that namespace prefix.
135
+
136
+ Optionally, a namespace can be marked as promoted during registration, which will
137
+ result in the namespace being serialized into the XML before any attributes on the
138
+ element. Namespaces that are not marked as promoted will appear after attributes.
139
+ Namespace promotion can be enabled for a given namespace during registration by
140
+ passing the optional `promoted` keyword argument with the value of `True`.
135
141
 
136
142
  For example, the 'rdf' prefix is associated with the following canonical URI:
137
143
  "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
@@ -12,6 +12,8 @@ source/maxml.egg-info/requires.txt
12
12
  source/maxml.egg-info/top_level.txt
13
13
  source/maxml.egg-info/zip-safe
14
14
  source/maxml/element/__init__.py
15
+ source/maxml/enumerations/__init__.py
16
+ source/maxml/exceptions/__init__.py
15
17
  source/maxml/logging/__init__.py
16
18
  source/maxml/namespace/__init__.py
17
19
  tests/test_element.py
@@ -391,3 +391,119 @@ def test_maxml_fragment_tostring(element: maxml.Element, data: callable):
391
391
  assert isinstance(compare, str)
392
392
 
393
393
  assert string == compare
394
+
395
+
396
+ def test_maxml_special_tostring(data: callable):
397
+ """Check serialization functionality works as expected with special characters"""
398
+
399
+ element = maxml.Element(name="my:test", namespace="http://namespace.example.org/my")
400
+
401
+ # Ensure that the element object's type is as expected
402
+ assert isinstance(element, maxml.Element)
403
+
404
+ sub = element.subelement("my:sub")
405
+
406
+ # Ensure that the subelement's type is as expected
407
+ assert isinstance(sub, maxml.Element)
408
+
409
+ # Ensure that the subelement's name prefix was parsed correctly
410
+ assert sub.prefix == "my"
411
+
412
+ # Ensure that the subelement's name was parsed correctly
413
+ assert sub.name == "sub"
414
+
415
+ # Ensure that the element's fullname was parsed correctly
416
+ assert sub.fullname == "my:sub"
417
+
418
+ # Ensure that the element's namespace prefix was registered correctly
419
+ assert sub.namespace.prefix == "my"
420
+
421
+ # Ensure that the element's namespace URI was registered correctly
422
+ assert sub.namespace.uri == "http://namespace.example.org/my"
423
+
424
+ # Ensure that the root has the expected depth
425
+ assert sub.depth == 1
426
+
427
+ sub.set("another", 'Test\'s all of the special characters "2 > 3 < 1" & more!')
428
+
429
+ values = [
430
+ "This & That",
431
+ "1 < 2",
432
+ "3 > 2",
433
+ "That's looking good!",
434
+ 'Yes, I\'d agree, it is "looking good"!',
435
+ "This &gt; is already encoded &#129;!",
436
+ ]
437
+
438
+ sequence = sub.subelement("my:seq")
439
+
440
+ for index, value in enumerate(values, start=1):
441
+ item = sequence.subelement("my:li")
442
+ item.set("my:index", str(index))
443
+ item.text = value
444
+
445
+ string: str = element.tostring(pretty=True)
446
+
447
+ assert isinstance(string, str)
448
+
449
+ compare: str = data("examples/example03special-all.xml")
450
+
451
+ assert isinstance(compare, str)
452
+
453
+ assert string == compare
454
+
455
+ string: str = element.tostring(pretty=True, escape=maxml.Escape.Required)
456
+
457
+ assert isinstance(string, str)
458
+
459
+ compare: str = data("examples/example03special-required.xml")
460
+
461
+ assert isinstance(compare, str)
462
+
463
+ assert string == compare
464
+
465
+
466
+ def test_maxml_namespace_promotion_non_promoted_namespace(data: callable):
467
+ """Check promotion of registered namespaces works as expected"""
468
+
469
+ maxml.Element.register_namespace(
470
+ prefix="my1", uri="http://namespace.example.org/my1", promoted=False
471
+ )
472
+
473
+ element = maxml.Element(name="my1:test")
474
+
475
+ # Ensure that the element object's type is as expected
476
+ assert isinstance(element, maxml.Element)
477
+
478
+ element.set("my1:attribute", "1234")
479
+
480
+ string: str = element.tostring(pretty=True)
481
+
482
+ assert isinstance(string, str)
483
+
484
+ compare: str = data("examples/example04namespace-unpromoted.xml")
485
+
486
+ assert string == compare
487
+
488
+
489
+ def test_maxml_namespace_promotion_promoted_namespace(data: callable):
490
+ """Check promotion of registered namespaces works as expected"""
491
+
492
+ maxml.Element.register_namespace(
493
+ prefix="my2", uri="http://namespace.example.org/my2", promoted=True
494
+ )
495
+
496
+ element = maxml.Element(name="my2:test")
497
+
498
+ # Ensure that the element object's type is as expected
499
+ assert isinstance(element, maxml.Element)
500
+
501
+ element.set("my2:attribute", "1234")
502
+
503
+ string: str = element.tostring(pretty=True)
504
+
505
+ assert isinstance(string, str)
506
+
507
+ compare: str = data("examples/example04namespace-promoted.xml")
508
+
509
+ assert string == compare
@@ -1,2 +0,0 @@
1
- from maxml.element import Element
2
- from maxml.namespace import Namespace
@@ -1 +0,0 @@
1
- 1.0.2
File without changes
File without changes
File without changes
File without changes