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.
- pyefris/__init__.py +88 -0
- pyefris/__main__.py +77 -0
- pyefris/_client_base.py +173 -0
- pyefris/_runner.py +479 -0
- pyefris/aclient.py +77 -0
- pyefris/client.py +148 -0
- pyefris/clock.py +81 -0
- pyefris/config.py +134 -0
- pyefris/constants.py +85 -0
- pyefris/crypto/__init__.py +40 -0
- pyefris/crypto/aes.py +76 -0
- pyefris/crypto/keys.py +66 -0
- pyefris/crypto/rsa.py +137 -0
- pyefris/crypto/session.py +112 -0
- pyefris/envelope.py +333 -0
- pyefris/errors.py +177 -0
- pyefris/interfaces.py +241 -0
- pyefris/logging.py +38 -0
- pyefris/models/__init__.py +12 -0
- pyefris/models/base.py +118 -0
- pyefris/models/batch_models.py +35 -0
- pyefris/models/dictionary_models.py +144 -0
- pyefris/models/goods_models.py +171 -0
- pyefris/models/invoice_models.py +554 -0
- pyefris/models/session_models.py +156 -0
- pyefris/models/stock_models.py +153 -0
- pyefris/models/taxpayer_models.py +119 -0
- pyefris/outbox.py +126 -0
- pyefris/serialization.py +57 -0
- pyefris/services/__init__.py +17 -0
- pyefris/services/base.py +53 -0
- pyefris/services/dictionary.py +79 -0
- pyefris/services/goods.py +61 -0
- pyefris/services/invoicing.py +262 -0
- pyefris/services/session.py +35 -0
- pyefris/services/stock.py +115 -0
- pyefris/services/taxpayer.py +43 -0
- pyefris/store/__init__.py +29 -0
- pyefris/store/base.py +132 -0
- pyefris/store/memory.py +174 -0
- pyefris/store/schema.sql +73 -0
- pyefris/store/sqlite.py +307 -0
- pyefris/transport.py +118 -0
- pyefris-1.0.0.dist-info/METADATA +140 -0
- pyefris-1.0.0.dist-info/RECORD +47 -0
- pyefris-1.0.0.dist-info/WHEEL +4 -0
- 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())
|
pyefris/_client_base.py
ADDED
|
@@ -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)
|