pyefris 1.0.0__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 (47) hide show
  1. pyefris/__init__.py +88 -0
  2. pyefris/__main__.py +77 -0
  3. pyefris/_client_base.py +173 -0
  4. pyefris/_runner.py +479 -0
  5. pyefris/aclient.py +77 -0
  6. pyefris/client.py +148 -0
  7. pyefris/clock.py +81 -0
  8. pyefris/config.py +134 -0
  9. pyefris/constants.py +85 -0
  10. pyefris/crypto/__init__.py +40 -0
  11. pyefris/crypto/aes.py +76 -0
  12. pyefris/crypto/keys.py +66 -0
  13. pyefris/crypto/rsa.py +137 -0
  14. pyefris/crypto/session.py +112 -0
  15. pyefris/envelope.py +333 -0
  16. pyefris/errors.py +177 -0
  17. pyefris/interfaces.py +241 -0
  18. pyefris/logging.py +38 -0
  19. pyefris/models/__init__.py +12 -0
  20. pyefris/models/base.py +118 -0
  21. pyefris/models/batch_models.py +35 -0
  22. pyefris/models/dictionary_models.py +144 -0
  23. pyefris/models/goods_models.py +171 -0
  24. pyefris/models/invoice_models.py +554 -0
  25. pyefris/models/session_models.py +156 -0
  26. pyefris/models/stock_models.py +153 -0
  27. pyefris/models/taxpayer_models.py +119 -0
  28. pyefris/outbox.py +126 -0
  29. pyefris/serialization.py +57 -0
  30. pyefris/services/__init__.py +17 -0
  31. pyefris/services/base.py +53 -0
  32. pyefris/services/dictionary.py +79 -0
  33. pyefris/services/goods.py +61 -0
  34. pyefris/services/invoicing.py +262 -0
  35. pyefris/services/session.py +35 -0
  36. pyefris/services/stock.py +115 -0
  37. pyefris/services/taxpayer.py +43 -0
  38. pyefris/store/__init__.py +29 -0
  39. pyefris/store/base.py +132 -0
  40. pyefris/store/memory.py +174 -0
  41. pyefris/store/schema.sql +73 -0
  42. pyefris/store/sqlite.py +307 -0
  43. pyefris/transport.py +118 -0
  44. pyefris-1.0.0.dist-info/METADATA +140 -0
  45. pyefris-1.0.0.dist-info/RECORD +47 -0
  46. pyefris-1.0.0.dist-info/WHEEL +4 -0
  47. pyefris-1.0.0.dist-info/licenses/LICENSE +21 -0
pyefris/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """pyefris — URA EFRIS system-to-system integration client.
2
+
3
+ Quick start::
4
+
5
+ from pyefris import EfrisClient, EfrisConfig
6
+
7
+ cfg = EfrisConfig(
8
+ tin="1000029771",
9
+ device_no="TCS9e0df01728335239",
10
+ private_key_path="keys/taxpayer_private.pem",
11
+ environment="test",
12
+ )
13
+ with EfrisClient(cfg) as client:
14
+ client.sync_clock()
15
+ result = client.invoicing.upload_invoice(invoice, reference_no="INV-001")
16
+ """
17
+
18
+ from ._runner import SubmitResult
19
+ from .aclient import AsyncEfrisClient
20
+ from .client import EfrisClient
21
+ from .config import EfrisConfig, RetryPolicy
22
+ from .constants import Environment, Mode
23
+ from .crypto.keys import KeyStore
24
+ from .crypto.rsa import generate_rsa_keypair, private_key_to_pem, public_key_to_pem
25
+ from .errors import (
26
+ ClockSkewError,
27
+ CryptoError,
28
+ DuplicateSubmissionError,
29
+ EfrisError,
30
+ EfrisReturnCodeError,
31
+ EnvelopeError,
32
+ KeyLoadError,
33
+ SignatureError,
34
+ StorageError,
35
+ TransportError,
36
+ UnknownInterfaceError,
37
+ ValidationError,
38
+ )
39
+ from .interfaces import InterfaceSpec, lookup
40
+ from .outbox import RelayWorker
41
+ from .store import (
42
+ AuditRecord,
43
+ InMemoryStorage,
44
+ OutboxRecord,
45
+ RelaySummary,
46
+ SQLiteStorage,
47
+ Storage,
48
+ UnitOfWork,
49
+ )
50
+
51
+ __version__ = "1.0.0"
52
+
53
+ __all__ = [
54
+ "AsyncEfrisClient",
55
+ "AuditRecord",
56
+ "ClockSkewError",
57
+ "CryptoError",
58
+ "DuplicateSubmissionError",
59
+ "EfrisClient",
60
+ "EfrisConfig",
61
+ "EfrisError",
62
+ "EfrisReturnCodeError",
63
+ "EnvelopeError",
64
+ "Environment",
65
+ "InMemoryStorage",
66
+ "InterfaceSpec",
67
+ "KeyLoadError",
68
+ "KeyStore",
69
+ "Mode",
70
+ "OutboxRecord",
71
+ "RelaySummary",
72
+ "RelayWorker",
73
+ "RetryPolicy",
74
+ "SQLiteStorage",
75
+ "SignatureError",
76
+ "Storage",
77
+ "StorageError",
78
+ "SubmitResult",
79
+ "TransportError",
80
+ "UnitOfWork",
81
+ "UnknownInterfaceError",
82
+ "ValidationError",
83
+ "__version__",
84
+ "generate_rsa_keypair",
85
+ "lookup",
86
+ "private_key_to_pem",
87
+ "public_key_to_pem",
88
+ ]
pyefris/__main__.py ADDED
@@ -0,0 +1,77 @@
1
+ """Minimal CLI: ``python -m pyefris <command>``.
2
+
3
+ Commands read config from PYEFRIS_* env vars (see EfrisConfig.from_env).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+
11
+ from .client import EfrisClient
12
+ from .config import EfrisConfig
13
+ from .errors import EfrisError
14
+
15
+
16
+ def _client(args: argparse.Namespace) -> EfrisClient:
17
+ cfg = EfrisConfig.from_env()
18
+ if args.endpoint:
19
+ cfg = cfg.model_copy(update={"endpoint": args.endpoint})
20
+ if args.store:
21
+ cfg = cfg.model_copy(update={"store_path": args.store})
22
+ return EfrisClient(cfg)
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ parser = argparse.ArgumentParser(prog="pyefris")
27
+ parser.add_argument("--endpoint", help="override API endpoint")
28
+ parser.add_argument("--store", help="sqlite store path")
29
+ sub = parser.add_subparsers(dest="cmd", required=True)
30
+
31
+ sub.add_parser("server-time", help="T101 server time check")
32
+ sub.add_parser("login", help="T103 login")
33
+ sub.add_parser("sync-dictionaries", help="T115 dictionary fetch")
34
+ relay = sub.add_parser("relay", help="replay pending outbox rows")
35
+ relay.add_argument("--limit", type=int, default=100)
36
+ call = sub.add_parser("call", help="generic interface call")
37
+ call.add_argument("interface_code")
38
+ call.add_argument("payload", nargs="?", default=None,
39
+ help="JSON payload (or @file)")
40
+
41
+ args = parser.parse_args(argv)
42
+
43
+ try:
44
+ with _client(args) as client:
45
+ if args.cmd == "server-time":
46
+ result = client.session.get_server_time()
47
+ elif args.cmd == "login":
48
+ result = client.session.login()
49
+ elif args.cmd == "sync-dictionaries":
50
+ print(client.sync_dictionaries())
51
+ return 0
52
+ elif args.cmd == "relay":
53
+ summary = client.relay_pending(limit=args.limit)
54
+ print(summary)
55
+ return 0
56
+ elif args.cmd == "call":
57
+ payload = args.payload
58
+ if payload and payload.startswith("@"):
59
+ payload = open(payload[1:], encoding="utf-8").read()
60
+ result = client.call(args.interface_code, payload)
61
+ else: # pragma: no cover
62
+ parser.error(f"unknown command {args.cmd}")
63
+ return 2
64
+ except EfrisError as exc:
65
+ print(f"error: {exc}", file=sys.stderr)
66
+ return 1
67
+
68
+ print(f"returnCode={result.return_code} returnMessage={result.return_message}")
69
+ if result.content is not None:
70
+ from .serialization import canonical_dumps
71
+
72
+ print(canonical_dumps(result.content))
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,173 @@
1
+ """Shared wiring for sync/async clients: keys, store, session keys, services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from ._runner import AsyncOperationRunner, OperationRunner, SubmitResult
8
+ from .clock import MutableClock, OffsetClock, SystemClock, measure_offset, parse_server_time
9
+ from .config import EfrisConfig
10
+ from .crypto.keys import KeyStore
11
+ from .crypto.session import SessionKey, SessionKeyManager, decode_t104
12
+ from .errors import EnvelopeError
13
+ from .outbox import AsyncOutboxProcessor, OutboxProcessor
14
+ from .store.base import Storage
15
+ from .store.sqlite import SQLiteStorage
16
+ from .transport import AsyncHttpxTransport, AsyncTransport, HttpxTransport, SyncTransport
17
+
18
+
19
+ def _default_store(config: EfrisConfig) -> Storage:
20
+ return SQLiteStorage(config.store_path)
21
+
22
+
23
+ class _ClientBase:
24
+ """Common state for EfrisClient and AsyncEfrisClient."""
25
+
26
+ def _init_common(
27
+ self,
28
+ config: EfrisConfig,
29
+ store: Storage | None,
30
+ clock: MutableClock,
31
+ key_store: KeyStore | None,
32
+ ) -> None:
33
+ self.config = config
34
+ self.store = store or _default_store(config)
35
+ self.clock = clock
36
+ self.key_store = key_store or KeyStore.from_config(config)
37
+
38
+ def _make_session_manager(
39
+ self, fetch: Callable[[], SessionKey]
40
+ ) -> SessionKeyManager:
41
+ kv_key = f"session_key:{self.config.tin}:{self.config.device_no}"
42
+ return SessionKeyManager(
43
+ fetch,
44
+ persist=lambda k: self.store.set_kv(kv_key, k.to_json()),
45
+ load_persisted=lambda: (
46
+ SessionKey.from_json(v) if (v := self.store.get_kv(kv_key)) else None
47
+ ),
48
+ )
49
+
50
+ def _decode_t104(self, result: SubmitResult) -> SessionKey:
51
+ content = result.content
52
+ if not isinstance(content, dict) or "passowrdDes" not in content:
53
+ raise EnvelopeError("T104 response missing 'passowrdDes'")
54
+ if self.key_store.private_key is None:
55
+ raise EnvelopeError("a private key is required to unwrap the session key")
56
+ return decode_t104(
57
+ content["passowrdDes"], content.get("sign", ""),
58
+ self.key_store.private_key,
59
+ )
60
+
61
+ def _apply_server_time(self, result: SubmitResult) -> None:
62
+ content = result.content
63
+ if isinstance(content, dict) and content.get("currentTime"):
64
+ server_now = parse_server_time(content["currentTime"])
65
+ local_now = self.clock.now()
66
+ self.clock.inner = OffsetClock(
67
+ SystemClock(), measure_offset(server_now, local_now)
68
+ )
69
+
70
+
71
+ class EfrisClientCore(_ClientBase):
72
+ """Wires the synchronous pipeline."""
73
+
74
+ def __init__(
75
+ self,
76
+ config: EfrisConfig,
77
+ *,
78
+ store: Storage | None = None,
79
+ transport: SyncTransport | None = None,
80
+ clock: MutableClock | None = None,
81
+ key_store: KeyStore | None = None,
82
+ ) -> None:
83
+ clock = clock or MutableClock()
84
+ self._init_common(config, store, clock, key_store)
85
+ self.transport = transport or HttpxTransport()
86
+ self.session_keys = self._make_session_manager(self._fetch_session_key)
87
+ self.runner = OperationRunner(
88
+ config=self.config,
89
+ key_store=self.key_store,
90
+ session_keys=self.session_keys,
91
+ transport=self.transport,
92
+ store=self.store,
93
+ clock=self.clock,
94
+ )
95
+ self.outbox = OutboxProcessor(self.runner, self.store)
96
+
97
+ from .services import (
98
+ DictionaryService,
99
+ GoodsService,
100
+ InvoicingService,
101
+ SessionService,
102
+ StockService,
103
+ TaxpayerService,
104
+ )
105
+
106
+ self.session = SessionService(self.runner)
107
+ self.taxpayer = TaxpayerService(self.runner)
108
+ self.dictionary = DictionaryService(self.runner)
109
+ self.goods = GoodsService(self.runner)
110
+ self.stock = StockService(self.runner)
111
+ self.invoicing = InvoicingService(self.runner)
112
+
113
+ def _fetch_session_key(self) -> SessionKey:
114
+ result = self.runner.submit("T104", None, _is_key_refresh=True)
115
+ return self._decode_t104(result)
116
+
117
+
118
+ class AsyncEfrisClientCore(_ClientBase):
119
+ """Wires the asynchronous pipeline."""
120
+
121
+ def __init__(
122
+ self,
123
+ config: EfrisConfig,
124
+ *,
125
+ store: Storage | None = None,
126
+ transport: AsyncTransport | None = None,
127
+ clock: MutableClock | None = None,
128
+ key_store: KeyStore | None = None,
129
+ ) -> None:
130
+ clock = clock or MutableClock()
131
+ self._init_common(config, store, clock, key_store)
132
+ self.transport = transport or AsyncHttpxTransport()
133
+ self.session_keys = self._make_session_manager(self._fetch_session_key_sync)
134
+ self.runner = AsyncOperationRunner(
135
+ config=self.config,
136
+ key_store=self.key_store,
137
+ session_keys=self.session_keys,
138
+ transport=self.transport,
139
+ store=self.store,
140
+ clock=self.clock,
141
+ )
142
+ self.outbox = AsyncOutboxProcessor(self.runner, self.store)
143
+
144
+ from .services import (
145
+ DictionaryService,
146
+ GoodsService,
147
+ InvoicingService,
148
+ SessionService,
149
+ StockService,
150
+ TaxpayerService,
151
+ )
152
+
153
+ self.session = SessionService(self.runner)
154
+ self.taxpayer = TaxpayerService(self.runner)
155
+ self.dictionary = DictionaryService(self.runner)
156
+ self.goods = GoodsService(self.runner)
157
+ self.stock = StockService(self.runner)
158
+ self.invoicing = InvoicingService(self.runner)
159
+
160
+ def _fetch_session_key_sync(self) -> SessionKey:
161
+ """T104 fetch used by SessionKeyManager (called from worker thread).
162
+
163
+ Routes through the sync inner runner of AsyncOperationRunner so the
164
+ POST executes on the adapter's one-shot event loop.
165
+ """
166
+ from .interfaces import lookup
167
+
168
+ result = self.runner._sync._submit(
169
+ lookup("T104"),
170
+ None, reference_no=None, extend_field_extra=None,
171
+ on_duplicate="raise", _is_key_refresh=True,
172
+ )
173
+ return self._decode_t104(result)