py-dgii-ecf 0.2.1__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.
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .pytest_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Secrets — never commit keys, certs, or credentials
15
+ *.p12
16
+ *.pfx
17
+ *.pem
18
+ *.key
19
+ *.crt
20
+ .env
21
+ .env.*
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [0.2.1](https://github.com/deuriib/dgii-ecf/compare/python-v0.2.0...python-v0.2.1) (2026-11-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **python:** surface authentication failures in the status CLI command ([4005634](https://github.com/deuriib/dgii-ecf/commit/400563462d14d965a521c2275fc8707d24801a3a))
9
+ * surface CLI auth failures and fix release dispatch ([779d0e7](https://github.com/deuriib/dgii-ecf/commit/779d0e7cc0b4e0ec9ffc69d3410990d930734e80))
10
+
11
+ ## [0.2.0](https://github.com/deuriib/dgii-ecf/compare/python-v0.1.0...python-v0.2.0) (2026-11-02)
12
+
13
+
14
+ ### Features
15
+
16
+ * **python:** add py-dgii-ecf SDK ([3a4796b](https://github.com/deuriib/dgii-ecf/commit/3a4796b9471f2999a109cc3f69fb464b71804161))
17
+ * scaffold dgii-ecf monorepo with TypeScript and Python SDKs ([41ce340](https://github.com/deuriib/dgii-ecf/commit/41ce34089949c1d38e70fe95d2aa281fa8b8fe87))
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: py-dgii-ecf
3
+ Version: 0.2.1
4
+ Summary: DGII e-CF electronic invoicing SDK for the Dominican Republic — authenticate, sign (XMLDSig/C14N) and transmit electronic tax documents.
5
+ Author: Deuri Vasquez
6
+ License: MIT
7
+ Keywords: dgii,ecf,electronica,facturacion,invoice,ncf,republica-dominicana,xml
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: cryptography>=43.0
19
+ Requires-Dist: httpx2<3,>=2.13
20
+ Requires-Dist: lxml>=5.0
21
+ Requires-Dist: pybreaker>=1.0
22
+ Requires-Dist: result<1,>=0.17
23
+ Requires-Dist: tenacity>=9.0
24
+ Requires-Dist: typer>=0.15
25
+ Description-Content-Type: text/markdown
26
+
27
+ # py-dgii-ecf
28
+
29
+ > DGII e-CF electronic invoicing SDK for the Dominican Republic (Python).
30
+
31
+ Result-pattern (`result`), resilient HTTP (`httpx2` + `tenacity` + `pybreaker`),
32
+ and XMLDSig signing (`lxml` + `cryptography`). No hidden exceptions — every
33
+ fallible call returns a `Result`.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ uv add py-dgii-ecf
39
+ # or
40
+ pip install py-dgii-ecf
41
+ ```
42
+
43
+ > Server-side only. Never load in a context that exposes private keys.
44
+
45
+ ## Quick start
46
+
47
+ ```python
48
+ from py_dgii_ecf import ECF, Environment, P12Reader, Signature
49
+
50
+ reader = P12Reader(password)
51
+ credentials = reader.get_key_from_file("/secure/identity.p12")
52
+ if credentials.is_err():
53
+ raise SystemExit(credentials.unwrap_err())
54
+
55
+ ecf = ECF(credentials.unwrap(), Environment.CERT)
56
+ auth = ecf.authenticate()
57
+ if auth.is_err():
58
+ raise SystemExit(auth.unwrap_err())
59
+
60
+ signature = Signature(credentials.unwrap().key, credentials.unwrap().cert)
61
+ signed = signature.sign_xml(xml)
62
+ if signed.is_err():
63
+ raise SystemExit(signed.unwrap_err())
64
+
65
+ response = ecf.send_electronic_document(signed.unwrap(), "101672919E3100000001.xml")
66
+ response.map(lambda invoice: print(invoice.get("trackId"))).map_err(
67
+ lambda error: print(error.code, error)
68
+ )
69
+ ```
70
+
71
+ ## Result pattern
72
+
73
+ ```python
74
+ result = ecf.status_track_id("some-track-id")
75
+ if result.is_ok():
76
+ print(result.unwrap().get("estado"))
77
+ else:
78
+ error = result.unwrap_err()
79
+ print(error.code, error.message)
80
+ ```
81
+
82
+ ## Resilience & idempotency
83
+
84
+ - Transient failures (network, timeout, 408/429/5xx) are retried with
85
+ exponential backoff and guarded by a `pybreaker` circuit breaker.
86
+ - `send_electronic_document` / `send_summary` are keyed by
87
+ `RNC + eNCF + SHA-256(signed XML)`. Provide a persistent `IdempotencyStore`
88
+ in production.
89
+
90
+ ```python
91
+ ecf = ECF(credentials, Environment.PROD, idempotency_store=my_redis_store)
92
+ ```
93
+
94
+ ## CLI
95
+
96
+ ```bash
97
+ py-dgii-ecf sign invoice.xml --p12 identity.p12 --output signed.xml
98
+ py-dgii-ecf authenticate --p12 identity.p12 --environment CertEcf
99
+ py-dgii-ecf status <track-id> --p12 identity.p12
100
+ ```
101
+
102
+ ## Modules
103
+
104
+ `environment`, `types`, `errors`, `result`, `resilience`, `http`, `certificate`,
105
+ `signing`, `transformer`, `utils`, `client`, `cli`.
106
+
107
+ ## License
108
+
109
+ MIT © 2026 Deuri Vasquez
@@ -0,0 +1,83 @@
1
+ # py-dgii-ecf
2
+
3
+ > DGII e-CF electronic invoicing SDK for the Dominican Republic (Python).
4
+
5
+ Result-pattern (`result`), resilient HTTP (`httpx2` + `tenacity` + `pybreaker`),
6
+ and XMLDSig signing (`lxml` + `cryptography`). No hidden exceptions — every
7
+ fallible call returns a `Result`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ uv add py-dgii-ecf
13
+ # or
14
+ pip install py-dgii-ecf
15
+ ```
16
+
17
+ > Server-side only. Never load in a context that exposes private keys.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from py_dgii_ecf import ECF, Environment, P12Reader, Signature
23
+
24
+ reader = P12Reader(password)
25
+ credentials = reader.get_key_from_file("/secure/identity.p12")
26
+ if credentials.is_err():
27
+ raise SystemExit(credentials.unwrap_err())
28
+
29
+ ecf = ECF(credentials.unwrap(), Environment.CERT)
30
+ auth = ecf.authenticate()
31
+ if auth.is_err():
32
+ raise SystemExit(auth.unwrap_err())
33
+
34
+ signature = Signature(credentials.unwrap().key, credentials.unwrap().cert)
35
+ signed = signature.sign_xml(xml)
36
+ if signed.is_err():
37
+ raise SystemExit(signed.unwrap_err())
38
+
39
+ response = ecf.send_electronic_document(signed.unwrap(), "101672919E3100000001.xml")
40
+ response.map(lambda invoice: print(invoice.get("trackId"))).map_err(
41
+ lambda error: print(error.code, error)
42
+ )
43
+ ```
44
+
45
+ ## Result pattern
46
+
47
+ ```python
48
+ result = ecf.status_track_id("some-track-id")
49
+ if result.is_ok():
50
+ print(result.unwrap().get("estado"))
51
+ else:
52
+ error = result.unwrap_err()
53
+ print(error.code, error.message)
54
+ ```
55
+
56
+ ## Resilience & idempotency
57
+
58
+ - Transient failures (network, timeout, 408/429/5xx) are retried with
59
+ exponential backoff and guarded by a `pybreaker` circuit breaker.
60
+ - `send_electronic_document` / `send_summary` are keyed by
61
+ `RNC + eNCF + SHA-256(signed XML)`. Provide a persistent `IdempotencyStore`
62
+ in production.
63
+
64
+ ```python
65
+ ecf = ECF(credentials, Environment.PROD, idempotency_store=my_redis_store)
66
+ ```
67
+
68
+ ## CLI
69
+
70
+ ```bash
71
+ py-dgii-ecf sign invoice.xml --p12 identity.p12 --output signed.xml
72
+ py-dgii-ecf authenticate --p12 identity.p12 --environment CertEcf
73
+ py-dgii-ecf status <track-id> --p12 identity.p12
74
+ ```
75
+
76
+ ## Modules
77
+
78
+ `environment`, `types`, `errors`, `result`, `resilience`, `http`, `certificate`,
79
+ `signing`, `transformer`, `utils`, `client`, `cli`.
80
+
81
+ ## License
82
+
83
+ MIT © 2026 Deuri Vasquez
@@ -0,0 +1,86 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "py-dgii-ecf"
7
+ version = "0.2.1"
8
+ description = "DGII e-CF electronic invoicing SDK for the Dominican Republic — authenticate, sign (XMLDSig/C14N) and transmit electronic tax documents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Deuri Vasquez" }]
13
+ keywords = [
14
+ "dgii",
15
+ "ecf",
16
+ "facturacion",
17
+ "electronica",
18
+ "republica-dominicana",
19
+ "invoice",
20
+ "ncf",
21
+ "xml",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Typing :: Typed",
32
+ "Topic :: Office/Business :: Financial :: Accounting",
33
+ ]
34
+ dependencies = [
35
+ "httpx2>=2.13,<3",
36
+ "tenacity>=9.0",
37
+ "pybreaker>=1.0",
38
+ "result>=0.17,<1",
39
+ "lxml>=5.0",
40
+ "cryptography>=43.0",
41
+ "typer>=0.15",
42
+ ]
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "ruff>=0.9",
47
+ "mypy>=1.13",
48
+ "pytest>=8.3",
49
+ "pytest-cov>=6.0",
50
+ ]
51
+
52
+ [project.scripts]
53
+ py-dgii-ecf = "py_dgii_ecf.cli:app"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/py_dgii_ecf"]
57
+
58
+ [tool.ruff]
59
+ line-length = 88
60
+ target-version = "py311"
61
+ src = ["src", "tests"]
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF", "C4", "PTH", "PLC", "PLE"]
65
+ ignore = ["E501"]
66
+
67
+ [tool.ruff.lint.isort]
68
+ known-first-party = ["py_dgii_ecf"]
69
+
70
+ [tool.mypy]
71
+ python_version = "3.11"
72
+ strict = true
73
+ warn_unused_ignores = true
74
+ disallow_any_explicit = true
75
+ ignore_missing_imports = true
76
+
77
+ [[tool.mypy.overrides]]
78
+ module = ["pybreaker.*", "lxml.*", "result.*", "tenacity.*"]
79
+ ignore_missing_imports = true
80
+
81
+ [tool.pytest.ini_options]
82
+ testpaths = ["tests"]
83
+ addopts = "-q"
84
+
85
+ [tool.coverage.run]
86
+ source = ["py_dgii_ecf"]
@@ -0,0 +1,104 @@
1
+ """DGII e-CF electronic invoicing SDK for the Dominican Republic.
2
+
3
+ Result-pattern (``result``), resilient HTTP (``httpx2`` + ``tenacity`` +
4
+ ``pybreaker``), and XMLDSig signing (``lxml`` + ``cryptography``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._json import JsonObject, JsonValue
10
+ from .certificate import CertificateInfo, P12Reader, P12ReaderData
11
+ from .client import ECF
12
+ from .environment import (
13
+ BaseUrl,
14
+ Endpoint,
15
+ Environment,
16
+ SenderReceiverEndpoint,
17
+ build_sender_receiver_url,
18
+ build_url,
19
+ )
20
+ from .errors import (
21
+ DgiiError,
22
+ DgiiErrorCode,
23
+ DgiiErrorContext,
24
+ extract_dgii_error_message,
25
+ is_retryable_status,
26
+ to_dgii_error,
27
+ )
28
+ from .http import (
29
+ HttpClient,
30
+ HttpClientOptions,
31
+ IdempotencyStore,
32
+ InMemoryIdempotencyStore,
33
+ )
34
+ from .resilience import ResilienceOptions, ResiliencePolicy, is_retryable_error
35
+ from .result import DgiiResult, Err, Ok, Result
36
+ from .signing import Signature, XmlCertificateValidation, validate_xml_certificate
37
+ from .transformer import Transformer
38
+ from .types import (
39
+ AuthToken,
40
+ Mensaje,
41
+ Operation,
42
+ StatusOperation,
43
+ TrackStatus,
44
+ )
45
+ from .utils import (
46
+ convert_ecf32_to_rfce,
47
+ generate_ecf_qr_code_url,
48
+ generate_fc_qr_code_url,
49
+ get_code_six_digit_from_signature,
50
+ get_current_formatted_date,
51
+ get_current_formatted_datetime,
52
+ get_xml_from_body_response,
53
+ )
54
+
55
+ __version__ = "0.2.1"
56
+
57
+ __all__ = [
58
+ "ECF",
59
+ "AuthToken",
60
+ "BaseUrl",
61
+ "CertificateInfo",
62
+ "DgiiError",
63
+ "DgiiErrorCode",
64
+ "DgiiErrorContext",
65
+ "DgiiResult",
66
+ "Endpoint",
67
+ "Environment",
68
+ "Err",
69
+ "HttpClient",
70
+ "HttpClientOptions",
71
+ "IdempotencyStore",
72
+ "InMemoryIdempotencyStore",
73
+ "JsonObject",
74
+ "JsonValue",
75
+ "Mensaje",
76
+ "Ok",
77
+ "Operation",
78
+ "P12Reader",
79
+ "P12ReaderData",
80
+ "ResilienceOptions",
81
+ "ResiliencePolicy",
82
+ "Result",
83
+ "SenderReceiverEndpoint",
84
+ "Signature",
85
+ "StatusOperation",
86
+ "TrackStatus",
87
+ "Transformer",
88
+ "XmlCertificateValidation",
89
+ "__version__",
90
+ "build_sender_receiver_url",
91
+ "build_url",
92
+ "convert_ecf32_to_rfce",
93
+ "extract_dgii_error_message",
94
+ "generate_ecf_qr_code_url",
95
+ "generate_fc_qr_code_url",
96
+ "get_code_six_digit_from_signature",
97
+ "get_current_formatted_date",
98
+ "get_current_formatted_datetime",
99
+ "get_xml_from_body_response",
100
+ "is_retryable_error",
101
+ "is_retryable_status",
102
+ "to_dgii_error",
103
+ "validate_xml_certificate",
104
+ ]
@@ -0,0 +1,13 @@
1
+ """Typing helpers shared across the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypeAlias
6
+
7
+ #: A JSON value as returned by the DGII web services.
8
+ JsonValue: TypeAlias = (
9
+ str | int | float | bool | list["JsonValue"] | dict[str, "JsonValue"] | None
10
+ )
11
+
12
+ #: A JSON object as returned by the DGII web services.
13
+ JsonObject: TypeAlias = dict[str, JsonValue]
@@ -0,0 +1,172 @@
1
+ """PKCS#12 (.p12) certificate reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from cryptography import x509
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
14
+ from cryptography.hazmat.primitives.serialization import pkcs12
15
+
16
+ from .errors import DgiiError, DgiiErrorCode
17
+ from .result import DgiiResult, Err, Ok
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class P12ReaderData:
22
+ """PEM-encoded private key, certificate and public key."""
23
+
24
+ key: str | None = None
25
+ cert: str | None = None
26
+ public_key: str | None = None
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class CertificateInfo:
31
+ """Basic metadata about an X.509 certificate."""
32
+
33
+ subject: str
34
+ issuer: str
35
+ valid_from: datetime
36
+ valid_to: datetime
37
+ serial_number: str
38
+
39
+
40
+ class P12Reader:
41
+ """Read a PKCS#12 bundle and extract the private key + certificate."""
42
+
43
+ def __init__(self, passphrase: str) -> None:
44
+ self._passphrase = passphrase
45
+
46
+ def _load(self, data: bytes) -> tuple[PrivateKeyTypes, x509.Certificate]:
47
+ password = self._passphrase.encode("utf-8") if self._passphrase else None
48
+ key, certificate, _ = pkcs12.load_key_and_certificates(data, password)
49
+ if key is None or certificate is None:
50
+ raise ValueError("PKCS#12 must contain a private key and certificate")
51
+ return key, certificate
52
+
53
+ def get_key_from_file(self, file_name: str | Path) -> DgiiResult[P12ReaderData]:
54
+ """Read a .p12 file from disk."""
55
+ try:
56
+ return self.get_key_from_bytes(Path(file_name).read_bytes())
57
+ except OSError as exc:
58
+ return Err(
59
+ DgiiError(
60
+ f"Unable to read .p12 file: {file_name}",
61
+ code=DgiiErrorCode.CERTIFICATE,
62
+ cause=exc,
63
+ )
64
+ )
65
+
66
+ def get_key_from_base64(self, base64_data: str) -> DgiiResult[P12ReaderData]:
67
+ """Read a base64-encoded .p12 payload."""
68
+ try:
69
+ decoded = base64.b64decode(base64_data)
70
+ except (ValueError, binascii.Error) as exc:
71
+ return Err(
72
+ DgiiError(
73
+ f"Invalid base64 .p12 payload: {exc}",
74
+ code=DgiiErrorCode.CERTIFICATE,
75
+ cause=exc,
76
+ )
77
+ )
78
+ return self.get_key_from_bytes(decoded)
79
+
80
+ def get_key_from_bytes(self, data: bytes) -> DgiiResult[P12ReaderData]:
81
+ """Read a raw PKCS#12 byte string."""
82
+ try:
83
+ key, certificate = self._load(data)
84
+ key_pem = key.private_bytes(
85
+ serialization.Encoding.PEM,
86
+ serialization.PrivateFormat.PKCS8,
87
+ serialization.NoEncryption(),
88
+ ).decode("utf-8")
89
+ cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode(
90
+ "utf-8"
91
+ )
92
+ public_key_pem = (
93
+ key.public_key()
94
+ .public_bytes(
95
+ serialization.Encoding.PEM,
96
+ serialization.PublicFormat.SubjectPublicKeyInfo,
97
+ )
98
+ .decode("utf-8")
99
+ )
100
+ return Ok(
101
+ P12ReaderData(
102
+ key=key_pem,
103
+ cert=cert_pem,
104
+ public_key=public_key_pem,
105
+ )
106
+ )
107
+ except ValueError as exc:
108
+ return Err(
109
+ DgiiError(
110
+ f"Unable to parse .p12: {exc}",
111
+ code=DgiiErrorCode.CERTIFICATE,
112
+ cause=exc,
113
+ )
114
+ )
115
+
116
+ def get_certificate_info(
117
+ self,
118
+ file_name: str | Path,
119
+ ) -> DgiiResult[CertificateInfo]:
120
+ """Read certificate metadata from a .p12 file."""
121
+ try:
122
+ return self.get_certificate_info_from_bytes(Path(file_name).read_bytes())
123
+ except OSError as exc:
124
+ return Err(
125
+ DgiiError(
126
+ f"Unable to read .p12 file: {file_name}",
127
+ code=DgiiErrorCode.CERTIFICATE,
128
+ cause=exc,
129
+ )
130
+ )
131
+
132
+ def get_certificate_info_from_base64(
133
+ self,
134
+ base64_data: str,
135
+ ) -> DgiiResult[CertificateInfo]:
136
+ """Read certificate metadata from a base64-encoded .p12 payload."""
137
+ try:
138
+ decoded = base64.b64decode(base64_data)
139
+ except (ValueError, binascii.Error) as exc:
140
+ return Err(
141
+ DgiiError(
142
+ f"Invalid base64 .p12 payload: {exc}",
143
+ code=DgiiErrorCode.CERTIFICATE,
144
+ cause=exc,
145
+ )
146
+ )
147
+ return self.get_certificate_info_from_bytes(decoded)
148
+
149
+ def get_certificate_info_from_bytes(
150
+ self,
151
+ data: bytes,
152
+ ) -> DgiiResult[CertificateInfo]:
153
+ """Read certificate metadata from raw PKCS#12 bytes."""
154
+ try:
155
+ _, certificate = self._load(data)
156
+ except ValueError as exc:
157
+ return Err(
158
+ DgiiError(
159
+ f"Unable to extract certificate info: {exc}",
160
+ code=DgiiErrorCode.CERTIFICATE,
161
+ cause=exc,
162
+ )
163
+ )
164
+ return Ok(
165
+ CertificateInfo(
166
+ subject=certificate.subject.rfc4514_string(),
167
+ issuer=certificate.issuer.rfc4514_string(),
168
+ valid_from=certificate.not_valid_before_utc,
169
+ valid_to=certificate.not_valid_after_utc,
170
+ serial_number=format(certificate.serial_number, "x"),
171
+ )
172
+ )