radiankore 0.1.0__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.
Files changed (56) hide show
  1. radiankore-0.1.0/.gitignore +37 -0
  2. radiankore-0.1.0/LICENSE +21 -0
  3. radiankore-0.1.0/PKG-INFO +122 -0
  4. radiankore-0.1.0/README.md +94 -0
  5. radiankore-0.1.0/THIRD_PARTY_NOTICES +7 -0
  6. radiankore-0.1.0/pyproject.toml +74 -0
  7. radiankore-0.1.0/src/radiankore/__init__.py +24 -0
  8. radiankore-0.1.0/src/radiankore/_soap/__init__.py +1 -0
  9. radiankore-0.1.0/src/radiankore/_soap/acquirer_response.py +18 -0
  10. radiankore-0.1.0/src/radiankore/_soap/dian_response.py +40 -0
  11. radiankore-0.1.0/src/radiankore/_soap/document_info_response.py +326 -0
  12. radiankore-0.1.0/src/radiankore/_soap/document_status_response.py +24 -0
  13. radiankore-0.1.0/src/radiankore/_soap/envelope.py +76 -0
  14. radiankore-0.1.0/src/radiankore/_soap/namespaces.py +25 -0
  15. radiankore-0.1.0/src/radiankore/_soap/parsing.py +82 -0
  16. radiankore-0.1.0/src/radiankore/_soap/reference_notes_response.py +122 -0
  17. radiankore-0.1.0/src/radiankore/_soap/response.py +98 -0
  18. radiankore-0.1.0/src/radiankore/_soap/signing.py +84 -0
  19. radiankore-0.1.0/src/radiankore/_soap/transport.py +54 -0
  20. radiankore-0.1.0/src/radiankore/_soap/ubl.py +72 -0
  21. radiankore-0.1.0/src/radiankore/_soap/xml_document_response.py +49 -0
  22. radiankore-0.1.0/src/radiankore/catalogs/__init__.py +6 -0
  23. radiankore-0.1.0/src/radiankore/catalogs/events.py +99 -0
  24. radiankore-0.1.0/src/radiankore/catalogs/identification.py +60 -0
  25. radiankore-0.1.0/src/radiankore/certificate.py +90 -0
  26. radiankore-0.1.0/src/radiankore/client.py +185 -0
  27. radiankore-0.1.0/src/radiankore/environment.py +21 -0
  28. radiankore-0.1.0/src/radiankore/exceptions.py +30 -0
  29. radiankore-0.1.0/src/radiankore/models/__init__.py +1 -0
  30. radiankore-0.1.0/src/radiankore/models/responses/__init__.py +57 -0
  31. radiankore-0.1.0/src/radiankore/models/responses/acquirer.py +17 -0
  32. radiankore-0.1.0/src/radiankore/models/responses/document_info.py +166 -0
  33. radiankore-0.1.0/src/radiankore/models/responses/document_status.py +22 -0
  34. radiankore-0.1.0/src/radiankore/models/responses/invoice_events.py +84 -0
  35. radiankore-0.1.0/src/radiankore/models/responses/reference_notes.py +68 -0
  36. radiankore-0.1.0/src/radiankore/models/responses/xml_document.py +18 -0
  37. radiankore-0.1.0/src/radiankore/py.typed +0 -0
  38. radiankore-0.1.0/tests/conftest.py +202 -0
  39. radiankore-0.1.0/tests/fixtures/document_info.xml +90 -0
  40. radiankore-0.1.0/tests/fixtures/document_status.xml +22 -0
  41. radiankore-0.1.0/tests/fixtures/invoice_events.xml +286 -0
  42. radiankore-0.1.0/tests/fixtures/reference_notes.xml +23 -0
  43. radiankore-0.1.0/tests/fixtures/reference_notes_application.xml +38 -0
  44. radiankore-0.1.0/tests/fixtures/xml_document.xml +10 -0
  45. radiankore-0.1.0/tests/test_acquirer.py +246 -0
  46. radiankore-0.1.0/tests/test_catalogs.py +138 -0
  47. radiankore-0.1.0/tests/test_certificate.py +147 -0
  48. radiankore-0.1.0/tests/test_client.py +72 -0
  49. radiankore-0.1.0/tests/test_document_info.py +654 -0
  50. radiankore-0.1.0/tests/test_document_status.py +330 -0
  51. radiankore-0.1.0/tests/test_reference_notes.py +331 -0
  52. radiankore-0.1.0/tests/test_reference_notes_payload.py +337 -0
  53. radiankore-0.1.0/tests/test_response.py +556 -0
  54. radiankore-0.1.0/tests/test_signing.py +183 -0
  55. radiankore-0.1.0/tests/test_soap_client.py +495 -0
  56. radiankore-0.1.0/tests/test_xml_document.py +260 -0
@@ -0,0 +1,37 @@
1
+ # Python and local environments
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .uv-cache/
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+
10
+ # Quality reports and caches
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ .coverage.*
16
+ coverage.xml
17
+ htmlcov/
18
+
19
+ # Local configuration and signing material
20
+ .env
21
+ .env.*
22
+ !.env.example
23
+ *.p12
24
+ *.pfx
25
+ *.pem
26
+ *.key
27
+
28
+ # Editor and environment metadata
29
+ .idea/
30
+ .vscode/
31
+ .DS_Store
32
+ .agents/
33
+ .codex/
34
+ .aws/
35
+
36
+ # Generated documentation
37
+ docs/_build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Deikore SAS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: radiankore
3
+ Version: 0.1.0
4
+ Summary: Python library for RADIAN events and invoice queries through DIAN.
5
+ Project-URL: Homepage, https://github.com/Deikore/radiankore
6
+ Project-URL: Documentation, https://radiankore.readthedocs.io/
7
+ Project-URL: Repository, https://github.com/Deikore/radiankore
8
+ Project-URL: Issues, https://github.com/Deikore/radiankore/issues
9
+ Project-URL: Changelog, https://github.com/Deikore/radiankore/blob/HEAD/CHANGELOG.md
10
+ Author: Deikore SAS
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ License-File: THIRD_PARTY_NOTICES
14
+ Keywords: colombia,dian,electronic-invoicing,radian,soap
15
+ Classifier: Development Status :: 3 - Alpha
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: cryptography>=46
24
+ Requires-Dist: httpx>=0.28
25
+ Requires-Dist: lxml>=5
26
+ Requires-Dist: xmlsec>=1.3.17
27
+ Description-Content-Type: text/markdown
28
+
29
+ # radiankore
30
+
31
+ Librería Python de Deikore SAS para consultar servicios de DIAN y eventos RADIAN
32
+ con certificado digital PFX/P12. Python >=3.13, API síncrona y licencia MIT.
33
+
34
+ | | Estado |
35
+ | --- | --- |
36
+ | Documentación | [![Read the Docs](https://readthedocs.org/projects/radiankore/badge/?version=latest)](https://radiankore.readthedocs.io/) |
37
+ | Calidad y pruebas | [![CI](https://github.com/Deikore/radiankore/actions/workflows/ci.yml/badge.svg)](https://github.com/Deikore/radiankore/actions/workflows/ci.yml) |
38
+ | Paquete | [![PyPI](https://img.shields.io/pypi/v/radiankore)](https://pypi.org/project/radiankore/) [![Python](https://img.shields.io/pypi/pyversions/radiankore)](https://pypi.org/project/radiankore/) [![MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/Deikore/radiankore/blob/HEAD/LICENSE) |
39
+
40
+ ## Instalación
41
+
42
+ ```bash
43
+ pip install radiankore
44
+ # Con uv:
45
+ uv add radiankore
46
+ ```
47
+
48
+ Disponible tras la primera publicación en PyPI. Si necesitas compilar las
49
+ dependencias de firma, consulta la
50
+ [guía de instalación](https://radiankore.readthedocs.io/).
51
+
52
+ ## Métodos disponibles
53
+
54
+ | Método | Operación DIAN | Resultado |
55
+ | --- | --- | --- |
56
+ | `get_status(track_id)` | `GetStatus` | Estado de validación y payloads decodificados |
57
+ | `get_invoice_events(cufe)` | `GetStatusEvent` | Eventos asociados a una factura |
58
+ | `get_acquirer(identification_type=..., identification_number=...)` | `GetAcquirer` | Nombre y correo del adquiriente |
59
+ | `get_document_info(uuid)` | `GetDocumentInfo` | Información, estados, eventos y notas del documento |
60
+ | `get_reference_notes(track_id)` | `GetReferenceNotes` | Notas referenciadas y motivos por línea |
61
+ | `get_xml_by_document_key(document_key)` | `GetXmlByDocumentKey` | XML original del documento como `bytes` |
62
+
63
+ No genera facturas ni envía eventos. Los resultados utilizan dataclasses
64
+ inmutables y campos opcionales cuando corresponda.
65
+
66
+ ## Consultar eventos
67
+
68
+ ```python
69
+ from radiankore import DianClient, Environment
70
+
71
+ client = DianClient(
72
+ certificate_path="/ruta/segura/certificado.pfx",
73
+ password="password-de-ejemplo",
74
+ environment=Environment.PRODUCTION,
75
+ )
76
+
77
+ result = client.get_invoice_events("CUFE-DE-LA-FACTURA")
78
+ for event in result.events:
79
+ print(event.id, event.code, event.description, event.date, event.time)
80
+ ```
81
+
82
+ Producción es el ambiente predeterminado; usa `Environment.HABILITATION` para
83
+ habilitación. Cada consulta cierra sus conexiones automáticamente y puedes
84
+ reutilizar el cliente sin volver a cargar el certificado.
85
+
86
+ ## Otras consultas
87
+
88
+ ```python
89
+ from pathlib import Path
90
+
91
+ from radiankore.catalogs import IdentificationType
92
+
93
+ status = client.get_status("CUFE-O-TRACK-ID")
94
+ if status is not None:
95
+ print(status.is_valid, status.status_code, status.messages)
96
+
97
+ acquirer = client.get_acquirer(
98
+ identification_type=IdentificationType.NIT,
99
+ identification_number="000123456",
100
+ )
101
+ if acquirer is not None:
102
+ print(acquirer.name, acquirer.email)
103
+
104
+ document = client.get_document_info("UUID-DEL-DOCUMENTO")
105
+ if document is not None:
106
+ for item in document.documents:
107
+ if item is not None:
108
+ print(item.uuid, item.totals)
109
+
110
+ notes = client.get_reference_notes("CUFE-O-TRACK-ID")
111
+ if notes is not None:
112
+ for note in notes.notes:
113
+ print(note.code, note.description)
114
+
115
+ xml = client.get_xml_by_document_key("CLAVE-DEL-DOCUMENTO")
116
+ if xml is not None and xml.document_xml is not None:
117
+ Path("documento.xml").write_bytes(xml.document_xml)
118
+ ```
119
+
120
+ La [documentación completa](https://radiankore.readthedocs.io/) incluye contratos,
121
+ modelos, errores y ejemplos por operación. Para contribuir, consulta
122
+ [CONTRIBUTING](https://github.com/Deikore/radiankore/blob/HEAD/CONTRIBUTING.md).
@@ -0,0 +1,94 @@
1
+ # radiankore
2
+
3
+ Librería Python de Deikore SAS para consultar servicios de DIAN y eventos RADIAN
4
+ con certificado digital PFX/P12. Python >=3.13, API síncrona y licencia MIT.
5
+
6
+ | | Estado |
7
+ | --- | --- |
8
+ | Documentación | [![Read the Docs](https://readthedocs.org/projects/radiankore/badge/?version=latest)](https://radiankore.readthedocs.io/) |
9
+ | Calidad y pruebas | [![CI](https://github.com/Deikore/radiankore/actions/workflows/ci.yml/badge.svg)](https://github.com/Deikore/radiankore/actions/workflows/ci.yml) |
10
+ | Paquete | [![PyPI](https://img.shields.io/pypi/v/radiankore)](https://pypi.org/project/radiankore/) [![Python](https://img.shields.io/pypi/pyversions/radiankore)](https://pypi.org/project/radiankore/) [![MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/Deikore/radiankore/blob/HEAD/LICENSE) |
11
+
12
+ ## Instalación
13
+
14
+ ```bash
15
+ pip install radiankore
16
+ # Con uv:
17
+ uv add radiankore
18
+ ```
19
+
20
+ Disponible tras la primera publicación en PyPI. Si necesitas compilar las
21
+ dependencias de firma, consulta la
22
+ [guía de instalación](https://radiankore.readthedocs.io/).
23
+
24
+ ## Métodos disponibles
25
+
26
+ | Método | Operación DIAN | Resultado |
27
+ | --- | --- | --- |
28
+ | `get_status(track_id)` | `GetStatus` | Estado de validación y payloads decodificados |
29
+ | `get_invoice_events(cufe)` | `GetStatusEvent` | Eventos asociados a una factura |
30
+ | `get_acquirer(identification_type=..., identification_number=...)` | `GetAcquirer` | Nombre y correo del adquiriente |
31
+ | `get_document_info(uuid)` | `GetDocumentInfo` | Información, estados, eventos y notas del documento |
32
+ | `get_reference_notes(track_id)` | `GetReferenceNotes` | Notas referenciadas y motivos por línea |
33
+ | `get_xml_by_document_key(document_key)` | `GetXmlByDocumentKey` | XML original del documento como `bytes` |
34
+
35
+ No genera facturas ni envía eventos. Los resultados utilizan dataclasses
36
+ inmutables y campos opcionales cuando corresponda.
37
+
38
+ ## Consultar eventos
39
+
40
+ ```python
41
+ from radiankore import DianClient, Environment
42
+
43
+ client = DianClient(
44
+ certificate_path="/ruta/segura/certificado.pfx",
45
+ password="password-de-ejemplo",
46
+ environment=Environment.PRODUCTION,
47
+ )
48
+
49
+ result = client.get_invoice_events("CUFE-DE-LA-FACTURA")
50
+ for event in result.events:
51
+ print(event.id, event.code, event.description, event.date, event.time)
52
+ ```
53
+
54
+ Producción es el ambiente predeterminado; usa `Environment.HABILITATION` para
55
+ habilitación. Cada consulta cierra sus conexiones automáticamente y puedes
56
+ reutilizar el cliente sin volver a cargar el certificado.
57
+
58
+ ## Otras consultas
59
+
60
+ ```python
61
+ from pathlib import Path
62
+
63
+ from radiankore.catalogs import IdentificationType
64
+
65
+ status = client.get_status("CUFE-O-TRACK-ID")
66
+ if status is not None:
67
+ print(status.is_valid, status.status_code, status.messages)
68
+
69
+ acquirer = client.get_acquirer(
70
+ identification_type=IdentificationType.NIT,
71
+ identification_number="000123456",
72
+ )
73
+ if acquirer is not None:
74
+ print(acquirer.name, acquirer.email)
75
+
76
+ document = client.get_document_info("UUID-DEL-DOCUMENTO")
77
+ if document is not None:
78
+ for item in document.documents:
79
+ if item is not None:
80
+ print(item.uuid, item.totals)
81
+
82
+ notes = client.get_reference_notes("CUFE-O-TRACK-ID")
83
+ if notes is not None:
84
+ for note in notes.notes:
85
+ print(note.code, note.description)
86
+
87
+ xml = client.get_xml_by_document_key("CLAVE-DEL-DOCUMENTO")
88
+ if xml is not None and xml.document_xml is not None:
89
+ Path("documento.xml").write_bytes(xml.document_xml)
90
+ ```
91
+
92
+ La [documentación completa](https://radiankore.readthedocs.io/) incluye contratos,
93
+ modelos, errores y ejemplos por operación. Para contribuir, consulta
94
+ [CONTRIBUTING](https://github.com/Deikore/radiankore/blob/HEAD/CONTRIBUTING.md).
@@ -0,0 +1,7 @@
1
+ Technical reference material
2
+
3
+ The files under docs/references/dian are third-party reference material from
4
+ DIAN and the organizations credited in the original documents and schemas.
5
+ The project's MIT license does not relicense those files. Their original
6
+ notices and applicable terms remain in effect. They are excluded from both
7
+ the wheel and source distributions published to PyPI.
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "radiankore"
7
+ version = "0.1.0"
8
+ description = "Python library for RADIAN events and invoice queries through DIAN."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "THIRD_PARTY_NOTICES"]
13
+ authors = [{ name = "Deikore SAS" }]
14
+ keywords = ["dian", "radian", "colombia", "soap", "electronic-invoicing"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = ["cryptography>=46", "httpx>=0.28", "lxml>=5", "xmlsec>=1.3.17"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/Deikore/radiankore"
28
+ Documentation = "https://radiankore.readthedocs.io/"
29
+ Repository = "https://github.com/Deikore/radiankore"
30
+ Issues = "https://github.com/Deikore/radiankore/issues"
31
+ Changelog = "https://github.com/Deikore/radiankore/blob/HEAD/CHANGELOG.md"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "mypy>=1.15,<2",
36
+ "pytest>=8.3,<10",
37
+ "pytest-cov>=6,<8",
38
+ "ruff>=0.11,<1",
39
+ "lxml-stubs>=0.5.1",
40
+ ]
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/radiankore"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = ["/src", "/tests", "/README.md", "/LICENSE", "/THIRD_PARTY_NOTICES", "/pyproject.toml"]
47
+
48
+ [tool.ruff]
49
+ target-version = "py313"
50
+ line-length = 88
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
54
+
55
+ [tool.mypy]
56
+ python_version = "3.13"
57
+ strict = true
58
+ files = ["src", "tests"]
59
+ mypy_path = "src"
60
+
61
+ [tool.pytest.ini_options]
62
+ minversion = "8.3"
63
+ testpaths = ["tests"]
64
+ addopts = "--strict-config --strict-markers --import-mode=importlib"
65
+ filterwarnings = ["error"]
66
+
67
+ [tool.coverage.run]
68
+ branch = true
69
+ source = ["radiankore"]
70
+ relative_files = true
71
+
72
+ [tool.coverage.report]
73
+ show_missing = true
74
+ skip_covered = true
@@ -0,0 +1,24 @@
1
+ """Public interface for the radiankore library."""
2
+
3
+ from radiankore.certificate import DigitalCertificate, DigitalCertificateError
4
+ from radiankore.client import DianClient
5
+ from radiankore.environment import Environment
6
+ from radiankore.exceptions import (
7
+ DianError,
8
+ DianResponseError,
9
+ DianSignatureError,
10
+ DianSoapFaultError,
11
+ DianTransportError,
12
+ )
13
+
14
+ __all__ = [
15
+ "DianClient",
16
+ "DianError",
17
+ "DianResponseError",
18
+ "DianSignatureError",
19
+ "DianSoapFaultError",
20
+ "DianTransportError",
21
+ "DigitalCertificate",
22
+ "DigitalCertificateError",
23
+ "Environment",
24
+ ]
@@ -0,0 +1 @@
1
+ """Internal SOAP implementation; consumers should use DianClient."""
@@ -0,0 +1,18 @@
1
+ """Map GetAcquirer results independently of invoice event response processing."""
2
+
3
+ from radiankore._soap.namespaces import ACQUIRER
4
+ from radiankore._soap.parsing import element_text, soap_result
5
+ from radiankore.models.responses import AcquirerResult
6
+
7
+
8
+ def parse_acquirer_response(data: bytes) -> AcquirerResult | None:
9
+ """Return optional acquirer data, preserving any returned business status."""
10
+ result = soap_result(data, "GetAcquirer")
11
+ if result is None:
12
+ return None
13
+ return AcquirerResult(
14
+ name=element_text(result, f"{{{ACQUIRER}}}ReceiverName"),
15
+ email=element_text(result, f"{{{ACQUIRER}}}ReceiverEmail"),
16
+ status_code=element_text(result, f"{{{ACQUIRER}}}StatusCode"),
17
+ message=element_text(result, f"{{{ACQUIRER}}}Message"),
18
+ )
@@ -0,0 +1,40 @@
1
+ """Scalar and binary readers shared by DianResponse operation parsers."""
2
+
3
+ from lxml import etree
4
+
5
+ from radiankore._soap.namespaces import ARRAY, RESPONSE
6
+ from radiankore._soap.parsing import decode_base64_text, is_nil
7
+ from radiankore.exceptions import DianResponseError
8
+
9
+
10
+ def read_is_valid(result: etree._Element, *, operation: str) -> bool | None:
11
+ """Read an optional, non-nillable DianResponse validity flag."""
12
+ node = result.find(f"{{{RESPONSE}}}IsValid")
13
+ if node is None:
14
+ return None
15
+ if is_nil(node):
16
+ raise DianResponseError(f"IsValid must not be nil in {operation}.")
17
+ value = (node.text or "").strip()
18
+ if value not in {"true", "false", "1", "0"}:
19
+ cause = ValueError("Invalid xs:boolean lexical value.")
20
+ raise DianResponseError(f"Invalid IsValid in {operation}.") from cause
21
+ return value in {"true", "1"}
22
+
23
+
24
+ def read_messages(result: etree._Element) -> tuple[str | None, ...]:
25
+ """Preserve message order, repetitions, empty strings and nullable items."""
26
+ container = result.find(f"{{{RESPONSE}}}ErrorMessage")
27
+ if container is None or is_nil(container):
28
+ return ()
29
+ return tuple(
30
+ None if is_nil(node) else (node.text or "").strip()
31
+ for node in container.findall(f"{{{ARRAY}}}string")
32
+ )
33
+
34
+
35
+ def read_binary(result: etree._Element, name: str) -> bytes | None:
36
+ """Decode one binary field independently, distinguishing absent from empty."""
37
+ node = result.find(f"{{{RESPONSE}}}{name}")
38
+ if node is None or is_nil(node):
39
+ return None
40
+ return decode_base64_text(node.text or "", field_name=name)