easier-acumatica 0.1.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.
- easier_acumatica/__init__.py +5 -0
- easier_acumatica/client.py +425 -0
- easier_acumatica/envelope.py +286 -0
- easier_acumatica/exceptions.py +385 -0
- easier_acumatica/odata.py +237 -0
- easier_acumatica/pagination.py +39 -0
- easier_acumatica/profiles/__init__.py +1 -0
- easier_acumatica/profiles/laborde/__init__.py +164 -0
- easier_acumatica/profiles/laborde/appends.py +398 -0
- easier_acumatica/profiles/laborde/attribute_whitelist.py +470 -0
- easier_acumatica/profiles/laborde/branches.py +59 -0
- easier_acumatica/profiles/laborde/execute.py +374 -0
- easier_acumatica/profiles/laborde/field_aliases.py +155 -0
- easier_acumatica/profiles/laborde/models/__init__.py +13 -0
- easier_acumatica/profiles/laborde/models/activity.py +81 -0
- easier_acumatica/profiles/laborde/models/appointment.py +347 -0
- easier_acumatica/profiles/laborde/models/contact.py +307 -0
- easier_acumatica/profiles/laborde/models/customer.py +432 -0
- easier_acumatica/profiles/laborde/models/opportunity.py +239 -0
- easier_acumatica/profiles/laborde/models/sales_order.py +474 -0
- easier_acumatica/profiles/laborde/models/service_order.py +302 -0
- easier_acumatica/profiles/laborde/models/stock_item.py +276 -0
- easier_acumatica/profiles/laborde/models/warehouse.py +93 -0
- easier_acumatica/profiles/laborde/owners.py +344 -0
- easier_acumatica/profiles/laborde/quirks.py +185 -0
- easier_acumatica/profiles/laborde/refs.py +331 -0
- easier_acumatica/profiles/laborde/registry.py +34 -0
- easier_acumatica/query.py +305 -0
- easier_acumatica/registry.py +161 -0
- easier_acumatica/transport.py +226 -0
- easier_acumatica/types.py +35 -0
- easier_acumatica/verbs.py +286 -0
- easier_acumatica-0.1.0.dist-info/METADATA +391 -0
- easier_acumatica-0.1.0.dist-info/RECORD +36 -0
- easier_acumatica-0.1.0.dist-info/WHEEL +4 -0
- easier_acumatica-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Config, login lifecycle, and the pooled session (see docs/architecture.md).
|
|
2
|
+
|
|
3
|
+
``AcumaticaConfig`` is a frozen, env-driven configuration object.
|
|
4
|
+
``Acumatica`` owns one pooled ``httpx.Client``, logs in exactly once at
|
|
5
|
+
construction, and exposes the frozen ``request_json`` seam that the
|
|
6
|
+
query/verb layers call against.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import exceptions
|
|
19
|
+
from .registry import EntityBinding
|
|
20
|
+
from .transport import RateLimitedTransport
|
|
21
|
+
from .verbs import EntityAccessor
|
|
22
|
+
|
|
23
|
+
#: The two endpoints this tenant has schemas for. A
|
|
24
|
+
#: per-call ``endpoint=`` override in :meth:`Acumatica.request_json` may only
|
|
25
|
+
#: name one of these — each name pins its own fixed version, independent of
|
|
26
|
+
#: whatever the client's own configured default endpoint/version is.
|
|
27
|
+
_ENDPOINT_VERSIONS: dict[str, str] = {
|
|
28
|
+
"Default": "24.200.001",
|
|
29
|
+
"LabordeCustom": "1.0",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
33
|
+
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
34
|
+
_DEFAULT_RATE_LIMIT = 10.0
|
|
35
|
+
_DEFAULT_ENDPOINT_NAME = "Default"
|
|
36
|
+
_DEFAULT_ENDPOINT_VERSION = "24.200.001"
|
|
37
|
+
|
|
38
|
+
_REQUIRED_ENV_LABELS: dict[str, str] = {
|
|
39
|
+
"username": "ACUMATICA_USERNAME",
|
|
40
|
+
"password": "ACUMATICA_PASSWORD",
|
|
41
|
+
"tenant": "ACUMATICA_TENANT",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_timeout() -> httpx.Timeout:
|
|
46
|
+
return httpx.Timeout(_DEFAULT_TIMEOUT_SECONDS, connect=_DEFAULT_CONNECT_TIMEOUT_SECONDS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_positive_float(raw: str, *, var_name: str) -> float:
|
|
50
|
+
"""Parse ``raw`` as a finite, positive float, or raise
|
|
51
|
+
:class:`~easier_acumatica.exceptions.ConfigError` naming ``var_name``
|
|
52
|
+
(so bad config gets "a clear error").
|
|
53
|
+
|
|
54
|
+
A bare ``float(raw)`` would let a malformed ``ACUMATICA_TIMEOUT``/
|
|
55
|
+
``ACUMATICA_RATE_LIMIT`` escape ``from_env`` as a raw ``ValueError``
|
|
56
|
+
instead of this package's own hierarchy. Non-finite values need their
|
|
57
|
+
own check even after a successful parse: ``nan`` and ``inf`` both parse
|
|
58
|
+
fine, but ``nan <= 0`` is ``False`` (NaN compares unequal/false against
|
|
59
|
+
everything), so a bare non-positive check alone would silently accept
|
|
60
|
+
``ACUMATICA_RATE_LIMIT=nan`` — which then disables the token bucket's
|
|
61
|
+
rate limiting entirely, because ``min(burst, tokens + elapsed*nan)``
|
|
62
|
+
always evaluates to ``burst`` (the bucket reports itself permanently
|
|
63
|
+
full).
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
value = float(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise exceptions.ConfigError(f"{var_name}={raw!r} is not a valid number") from None
|
|
69
|
+
if not math.isfinite(value) or value <= 0:
|
|
70
|
+
raise exceptions.ConfigError(f"{var_name}={raw!r} must be a finite number greater than 0")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class AcumaticaConfig:
|
|
76
|
+
"""Frozen client configuration.
|
|
77
|
+
|
|
78
|
+
Construct directly, or via :meth:`from_env` for the ``ACUMATICA_*``
|
|
79
|
+
environment-variable contract.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
url: str
|
|
83
|
+
username: str
|
|
84
|
+
password: str
|
|
85
|
+
tenant: str
|
|
86
|
+
branch: str | None = None
|
|
87
|
+
locale: str | None = None
|
|
88
|
+
endpoint_name: str = _DEFAULT_ENDPOINT_NAME
|
|
89
|
+
endpoint_version: str = _DEFAULT_ENDPOINT_VERSION
|
|
90
|
+
timeout: httpx.Timeout = field(default_factory=_default_timeout)
|
|
91
|
+
rate_limit: float = _DEFAULT_RATE_LIMIT
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> "AcumaticaConfig":
|
|
95
|
+
"""Build a config from ``ACUMATICA_*`` environment variables.
|
|
96
|
+
|
|
97
|
+
Base URL: ``ACUMATICA_URL``, falling back to ``ACUMATICA_SITE_URL``
|
|
98
|
+
(some of the migrated integrations use the ``_SITE_URL`` name). Both
|
|
99
|
+
set and differing is a :class:`~easier_acumatica.exceptions.ConfigError`;
|
|
100
|
+
neither set is also a :class:`~easier_acumatica.exceptions.ConfigError`.
|
|
101
|
+
``USERNAME``, ``PASSWORD``, ``TENANT`` are required — a
|
|
102
|
+
:class:`~easier_acumatica.exceptions.ConfigError` raised for missing
|
|
103
|
+
keys names *every* missing key, not just the first.
|
|
104
|
+
|
|
105
|
+
``env`` defaults to ``os.environ``; pass an explicit mapping for
|
|
106
|
+
tests that want isolation from the real process environment.
|
|
107
|
+
"""
|
|
108
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
109
|
+
|
|
110
|
+
url = source.get("ACUMATICA_URL") or None
|
|
111
|
+
site_url = source.get("ACUMATICA_SITE_URL") or None
|
|
112
|
+
if url and site_url and url != site_url:
|
|
113
|
+
raise exceptions.ConfigError(
|
|
114
|
+
"ACUMATICA_URL and ACUMATICA_SITE_URL are both set but differ "
|
|
115
|
+
f"({url!r} vs {site_url!r}); set only one, or make them match."
|
|
116
|
+
)
|
|
117
|
+
resolved_url = url or site_url
|
|
118
|
+
|
|
119
|
+
values = {key: (source.get(label) or None) for key, label in _REQUIRED_ENV_LABELS.items()}
|
|
120
|
+
|
|
121
|
+
missing: list[str] = []
|
|
122
|
+
if not resolved_url:
|
|
123
|
+
missing.append("ACUMATICA_URL (or ACUMATICA_SITE_URL)")
|
|
124
|
+
missing.extend(label for key, label in _REQUIRED_ENV_LABELS.items() if not values[key])
|
|
125
|
+
if missing:
|
|
126
|
+
raise exceptions.ConfigError("Missing required Acumatica configuration: " + ", ".join(missing))
|
|
127
|
+
|
|
128
|
+
timeout_raw = source.get("ACUMATICA_TIMEOUT")
|
|
129
|
+
timeout = (
|
|
130
|
+
httpx.Timeout(
|
|
131
|
+
_parse_positive_float(timeout_raw, var_name="ACUMATICA_TIMEOUT"),
|
|
132
|
+
connect=_DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
|
133
|
+
)
|
|
134
|
+
if timeout_raw
|
|
135
|
+
else _default_timeout()
|
|
136
|
+
)
|
|
137
|
+
rate_limit_raw = source.get("ACUMATICA_RATE_LIMIT")
|
|
138
|
+
rate_limit = (
|
|
139
|
+
_parse_positive_float(rate_limit_raw, var_name="ACUMATICA_RATE_LIMIT")
|
|
140
|
+
if rate_limit_raw
|
|
141
|
+
else _DEFAULT_RATE_LIMIT
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
assert resolved_url is not None # `missing` would have caught this above
|
|
145
|
+
return cls(
|
|
146
|
+
url=resolved_url,
|
|
147
|
+
username=values["username"], # type: ignore[arg-type]
|
|
148
|
+
password=values["password"], # type: ignore[arg-type]
|
|
149
|
+
tenant=values["tenant"], # type: ignore[arg-type]
|
|
150
|
+
branch=source.get("ACUMATICA_BRANCH") or None,
|
|
151
|
+
locale=source.get("ACUMATICA_LOCALE") or None,
|
|
152
|
+
endpoint_name=source.get("ACUMATICA_ENDPOINT_NAME") or _DEFAULT_ENDPOINT_NAME,
|
|
153
|
+
endpoint_version=source.get("ACUMATICA_ENDPOINT_VERSION") or _DEFAULT_ENDPOINT_VERSION,
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
rate_limit=rate_limit,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_endpoint(config: AcumaticaConfig, endpoint: str | None) -> tuple[str, str]:
|
|
160
|
+
"""Resolve a per-call ``endpoint=`` override to ``(name, version)``.
|
|
161
|
+
|
|
162
|
+
``None`` uses the client's own configured default. ``"Default"``/
|
|
163
|
+
``"LabordeCustom"`` are fixed overrides to their own pinned version
|
|
164
|
+
regardless of the client's configured default — anything else
|
|
165
|
+
is a caller error, not a server/config problem, so it raises
|
|
166
|
+
``ValueError`` rather than :class:`~easier_acumatica.exceptions.ConfigError`.
|
|
167
|
+
"""
|
|
168
|
+
if endpoint is None:
|
|
169
|
+
return config.endpoint_name, config.endpoint_version
|
|
170
|
+
try:
|
|
171
|
+
return endpoint, _ENDPOINT_VERSIONS[endpoint]
|
|
172
|
+
except KeyError:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Unknown endpoint override {endpoint!r}; expected one of {sorted(_ENDPOINT_VERSIONS)}"
|
|
175
|
+
) from None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Acumatica:
|
|
179
|
+
"""One pooled ``httpx.Client``, one login, one place requests flow through.
|
|
180
|
+
|
|
181
|
+
Construction performs exactly one login POST. Idle-401
|
|
182
|
+
recovery on GET is handled by :class:`~easier_acumatica.transport.RateLimitedTransport`,
|
|
183
|
+
which calls back into :meth:`_relogin` — a *second* login triggered by
|
|
184
|
+
session expiry, distinct from (and not a violation of) "one login at
|
|
185
|
+
construction".
|
|
186
|
+
|
|
187
|
+
Entity accessors (``acu.service_orders``) are OPT-IN via
|
|
188
|
+
:meth:`bind_registry` — see that method. Until a registry is bound there
|
|
189
|
+
are none, which is what keeps `client.py` free of any import of
|
|
190
|
+
``profiles/**`` or of generated models.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
#: Class-level default so :meth:`__getattr__` is safe to call at any
|
|
194
|
+
#: point during ``__init__`` (before the instance attribute exists) and
|
|
195
|
+
#: on a partially-constructed instance — otherwise a failed attribute
|
|
196
|
+
#: lookup there would recurse.
|
|
197
|
+
_registry: "Mapping[str, EntityBinding]" = {}
|
|
198
|
+
|
|
199
|
+
def __init__(self, config: AcumaticaConfig) -> None:
|
|
200
|
+
self._config = config
|
|
201
|
+
self._transport = RateLimitedTransport(
|
|
202
|
+
rate=config.rate_limit,
|
|
203
|
+
relogin=self._relogin,
|
|
204
|
+
)
|
|
205
|
+
# httpx joins `base_url` + a relative request path by straight
|
|
206
|
+
# concatenation of raw paths (not RFC 3986 "replace last segment"
|
|
207
|
+
# semantics) — so `base_url` must end with "/" and request paths
|
|
208
|
+
# must not start with "/", or the join silently mangles the URL.
|
|
209
|
+
# This also makes a tenant hosted under a sub-path (e.g.
|
|
210
|
+
# ".../AcumaticaERP/") join correctly, not just a bare-domain one.
|
|
211
|
+
base_url = config.url if config.url.endswith("/") else config.url + "/"
|
|
212
|
+
self._http = httpx.Client(
|
|
213
|
+
base_url=base_url,
|
|
214
|
+
transport=self._transport,
|
|
215
|
+
timeout=config.timeout,
|
|
216
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
217
|
+
)
|
|
218
|
+
# httpx.Client's own `cookies=` constructor
|
|
219
|
+
# argument COPIES a `Cookies` object into a fresh jar rather than
|
|
220
|
+
# aliasing it (confirmed against httpx 0.28.1) — so building one jar
|
|
221
|
+
# ourselves and handing the same object to both the transport and
|
|
222
|
+
# the Client (the old approach) leaves the transport holding a jar
|
|
223
|
+
# that never receives a single `Set-Cookie`, and every idle-401
|
|
224
|
+
# replay goes out anonymous. The Client must be constructed first
|
|
225
|
+
# (it needs `self._transport` already built); only then does its
|
|
226
|
+
# own `.cookies` — the actual live jar httpx writes `Set-Cookie`
|
|
227
|
+
# into — exist to hand to the transport, via the late-bind setter.
|
|
228
|
+
self._transport.cookies = self._http.cookies
|
|
229
|
+
self._login()
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> "Acumatica":
|
|
233
|
+
"""``Acumatica(AcumaticaConfig.from_env(env))`` — logs in immediately."""
|
|
234
|
+
return cls(AcumaticaConfig.from_env(env))
|
|
235
|
+
|
|
236
|
+
def _login_payload(self) -> dict[str, Any]:
|
|
237
|
+
payload: dict[str, Any] = {
|
|
238
|
+
"name": self._config.username,
|
|
239
|
+
"password": self._config.password,
|
|
240
|
+
"tenant": self._config.tenant,
|
|
241
|
+
}
|
|
242
|
+
if self._config.branch:
|
|
243
|
+
payload["branch"] = self._config.branch
|
|
244
|
+
if self._config.locale:
|
|
245
|
+
payload["locale"] = self._config.locale
|
|
246
|
+
return payload
|
|
247
|
+
|
|
248
|
+
def _login(self) -> None:
|
|
249
|
+
"""``POST entity/auth/login``; expect 204. Never raises :class:`Auth`
|
|
250
|
+
for a transport failure — only for a real
|
|
251
|
+
401 on this call.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
response = self._http.post("entity/auth/login", json=self._login_payload())
|
|
255
|
+
except httpx.HTTPError as exc:
|
|
256
|
+
# `httpx.HTTPError` (not just its
|
|
257
|
+
# `httpx.TransportError` subclass) so a login-time
|
|
258
|
+
# `httpx.DecodingError` (malformed content-encoding) or
|
|
259
|
+
# `httpx.TooManyRedirects` also classifies as `Server` rather
|
|
260
|
+
# than escaping this method raw — the intent is that
|
|
261
|
+
# *no* login-time failure escapes the exception hierarchy.
|
|
262
|
+
raise exceptions.classify_login_failure(transport_error=exc) from exc
|
|
263
|
+
if response.status_code != 204:
|
|
264
|
+
raise exceptions.classify_login_failure(response=response)
|
|
265
|
+
|
|
266
|
+
def _relogin(self) -> None:
|
|
267
|
+
"""The callable :class:`~easier_acumatica.transport.RateLimitedTransport`
|
|
268
|
+
invokes on an idle-session GET 401. Same classification rules as the
|
|
269
|
+
initial login — a transient failure here is still never :class:`Auth`.
|
|
270
|
+
"""
|
|
271
|
+
self._login()
|
|
272
|
+
|
|
273
|
+
def close(self) -> None:
|
|
274
|
+
"""Best-effort ``POST entity/auth/logout``, then clear the cookie
|
|
275
|
+
jar and close the pooled client (logout "clears cookies"). The jar is cleared whether
|
|
276
|
+
or not the logout POST itself succeeded — "best-effort" describes
|
|
277
|
+
the POST, not the cleanup that follows it.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
self._http.post("entity/auth/logout")
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
finally:
|
|
284
|
+
self._http.cookies.clear()
|
|
285
|
+
self._http.close()
|
|
286
|
+
|
|
287
|
+
def __enter__(self) -> "Acumatica":
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
291
|
+
self.close()
|
|
292
|
+
|
|
293
|
+
def request_json(
|
|
294
|
+
self,
|
|
295
|
+
method: str,
|
|
296
|
+
entity_path: str,
|
|
297
|
+
*,
|
|
298
|
+
endpoint: str | None = None,
|
|
299
|
+
params: dict[str, Any] | None = None,
|
|
300
|
+
json: dict[str, Any] | None = None,
|
|
301
|
+
) -> Any:
|
|
302
|
+
"""The frozen seam — the one entry point the query/verb layers call.
|
|
303
|
+
|
|
304
|
+
Builds ``{base}/entity/{endpoint_name}/{endpoint_version}/{entity_path}``,
|
|
305
|
+
sends ``Accept``/``Content-Type: application/json`` (client
|
|
306
|
+
defaults), and returns decoded JSON — ``dict | list | None``
|
|
307
|
+
(``None`` for any 2xx with an empty body, not just ``204``). Raises
|
|
308
|
+
the :mod:`easier_acumatica.exceptions` hierarchy for every other
|
|
309
|
+
case a completed response can be in:
|
|
310
|
+
|
|
311
|
+
- Any non-2xx/3xx status — via
|
|
312
|
+
:func:`~easier_acumatica.exceptions.classify_response`.
|
|
313
|
+
- A 3xx — this client never sets ``follow_redirects=True``, so a
|
|
314
|
+
completed redirect means something upstream is wrong (e.g. an
|
|
315
|
+
idle session bounced to a login page); also classified by
|
|
316
|
+
:func:`~easier_acumatica.exceptions.classify_response`, as
|
|
317
|
+
:class:`~easier_acumatica.exceptions.Server`.
|
|
318
|
+
- A 2xx body that isn't valid JSON (and isn't empty) — raises
|
|
319
|
+
:class:`~easier_acumatica.exceptions.Server` with a short body
|
|
320
|
+
snippet, rather than letting a raw ``json.JSONDecodeError``
|
|
321
|
+
escape this package's hierarchy.
|
|
322
|
+
|
|
323
|
+
Does not itself catch a transport-level failure (connect
|
|
324
|
+
error/timeout) — that propagates as a raw ``httpx`` exception,
|
|
325
|
+
by design ("raises a transport error");
|
|
326
|
+
only the *login* path wraps transport failures into
|
|
327
|
+
:class:`~easier_acumatica.exceptions.Server`.
|
|
328
|
+
"""
|
|
329
|
+
endpoint_name, endpoint_version = _resolve_endpoint(self._config, endpoint)
|
|
330
|
+
url = f"entity/{endpoint_name}/{endpoint_version}/{entity_path.lstrip('/')}"
|
|
331
|
+
response = self._http.request(method, url, params=params, json=json)
|
|
332
|
+
|
|
333
|
+
error = exceptions.classify_response(response)
|
|
334
|
+
if error is not None:
|
|
335
|
+
raise error
|
|
336
|
+
|
|
337
|
+
# Every remaining status here is a genuine 2xx (classify_response
|
|
338
|
+
# now also classifies 3xx, above). A 204 — or any other 2xx an
|
|
339
|
+
# Acumatica endpoint answers with an empty body, e.g. 200/205 — has
|
|
340
|
+
# nothing to decode; anything else must be valid JSON, or this
|
|
341
|
+
# raises rather than letting `response.json()`'s raw
|
|
342
|
+
# `json.JSONDecodeError` escape the package hierarchy.
|
|
343
|
+
if not response.content:
|
|
344
|
+
return None
|
|
345
|
+
try:
|
|
346
|
+
return response.json()
|
|
347
|
+
except ValueError as exc:
|
|
348
|
+
try:
|
|
349
|
+
snippet = response.text[:200]
|
|
350
|
+
except Exception:
|
|
351
|
+
snippet = "<unreadable body>"
|
|
352
|
+
raise exceptions.Server(
|
|
353
|
+
f"Expected a JSON response body but could not decode it "
|
|
354
|
+
f"(status {response.status_code}): {snippet!r}"
|
|
355
|
+
) from exc
|
|
356
|
+
|
|
357
|
+
# --- accessor mechanism -------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def bind_registry(self, registry: Mapping[str, "EntityBinding"]) -> "Acumatica":
|
|
360
|
+
"""Bind an accessor-name -> :class:`EntityBinding` registry.
|
|
361
|
+
|
|
362
|
+
After this, ``acu.<accessor_name>`` returns an
|
|
363
|
+
:class:`~easier_acumatica.verbs.EntityAccessor` for that binding —
|
|
364
|
+
the verbs (`get`/`get_or_none`/`get_list`/`put`/`delete`) and the
|
|
365
|
+
query builders (`where`/`select`/`expand`/`order_by`/`limit`/`all`/
|
|
366
|
+
`first`) on one object::
|
|
367
|
+
|
|
368
|
+
acu = Acumatica(cfg).bind_registry(REGISTRY)
|
|
369
|
+
acu.service_orders.get(type="IN", nbr="000123")
|
|
370
|
+
acu.service_orders.where(status="Open").limit(50).all()
|
|
371
|
+
|
|
372
|
+
Accessor names are snake_case plural; the CONTENTS are a
|
|
373
|
+
profile concern (the real Laborde registry ships with the generated
|
|
374
|
+
models), which is why this takes the registry as an argument
|
|
375
|
+
rather than importing one — `client.py` never imports `profiles/**`.
|
|
376
|
+
|
|
377
|
+
Returns `self`, so it chains off the constructor. Binding twice
|
|
378
|
+
REPLACES the registry rather than merging, and a name that would
|
|
379
|
+
shadow a real attribute/method on this class (`close`, `request_json`,
|
|
380
|
+
anything starting with `_`) is refused loudly here, at bind time,
|
|
381
|
+
rather than silently never resolving: `__getattr__` only runs after
|
|
382
|
+
normal lookup FAILS, so such a name would return the method instead
|
|
383
|
+
of the accessor.
|
|
384
|
+
"""
|
|
385
|
+
for name in registry:
|
|
386
|
+
if name.startswith("_"):
|
|
387
|
+
raise ValueError(
|
|
388
|
+
f"bind_registry: accessor name {name!r} may not start with '_' "
|
|
389
|
+
"(reserved for internals)"
|
|
390
|
+
)
|
|
391
|
+
if hasattr(type(self), name):
|
|
392
|
+
raise ValueError(
|
|
393
|
+
f"bind_registry: accessor name {name!r} shadows an existing "
|
|
394
|
+
f"{type(self).__name__} attribute and would never resolve to the "
|
|
395
|
+
"binding — rename the accessor"
|
|
396
|
+
)
|
|
397
|
+
self._registry = dict(registry)
|
|
398
|
+
return self
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def accessors(self) -> tuple[str, ...]:
|
|
402
|
+
"""The bound accessor names, sorted. Empty until `bind_registry`."""
|
|
403
|
+
return tuple(sorted(self._registry))
|
|
404
|
+
|
|
405
|
+
def __getattr__(self, name: str) -> Any:
|
|
406
|
+
"""Resolve ``acu.<accessor_name>`` against the bound registry.
|
|
407
|
+
|
|
408
|
+
Only ever called when normal attribute lookup has already failed, so
|
|
409
|
+
this can never shadow a real method (and `bind_registry` refuses any
|
|
410
|
+
name that would try). An unknown name raises `AttributeError` naming
|
|
411
|
+
the accessors that ARE known — a plain "no attribute" message is
|
|
412
|
+
useless when the whole surface is registry-driven.
|
|
413
|
+
"""
|
|
414
|
+
# Dunder/private probes (copy, pickle, IPython, pytest assertion
|
|
415
|
+
# rewriting) must fail fast and never be answered from the registry.
|
|
416
|
+
if name.startswith("_"):
|
|
417
|
+
raise AttributeError(name)
|
|
418
|
+
registry = type(self)._registry if "_registry" not in self.__dict__ else self._registry
|
|
419
|
+
binding = registry.get(name)
|
|
420
|
+
if binding is None:
|
|
421
|
+
known = ", ".join(sorted(registry)) or "<none — call bind_registry(...) first>"
|
|
422
|
+
raise AttributeError(
|
|
423
|
+
f"{type(self).__name__} has no accessor {name!r}; known accessors: {known}"
|
|
424
|
+
)
|
|
425
|
+
return EntityAccessor(self, binding)
|