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.
Files changed (36) hide show
  1. easier_acumatica/__init__.py +5 -0
  2. easier_acumatica/client.py +425 -0
  3. easier_acumatica/envelope.py +286 -0
  4. easier_acumatica/exceptions.py +385 -0
  5. easier_acumatica/odata.py +237 -0
  6. easier_acumatica/pagination.py +39 -0
  7. easier_acumatica/profiles/__init__.py +1 -0
  8. easier_acumatica/profiles/laborde/__init__.py +164 -0
  9. easier_acumatica/profiles/laborde/appends.py +398 -0
  10. easier_acumatica/profiles/laborde/attribute_whitelist.py +470 -0
  11. easier_acumatica/profiles/laborde/branches.py +59 -0
  12. easier_acumatica/profiles/laborde/execute.py +374 -0
  13. easier_acumatica/profiles/laborde/field_aliases.py +155 -0
  14. easier_acumatica/profiles/laborde/models/__init__.py +13 -0
  15. easier_acumatica/profiles/laborde/models/activity.py +81 -0
  16. easier_acumatica/profiles/laborde/models/appointment.py +347 -0
  17. easier_acumatica/profiles/laborde/models/contact.py +307 -0
  18. easier_acumatica/profiles/laborde/models/customer.py +432 -0
  19. easier_acumatica/profiles/laborde/models/opportunity.py +239 -0
  20. easier_acumatica/profiles/laborde/models/sales_order.py +474 -0
  21. easier_acumatica/profiles/laborde/models/service_order.py +302 -0
  22. easier_acumatica/profiles/laborde/models/stock_item.py +276 -0
  23. easier_acumatica/profiles/laborde/models/warehouse.py +93 -0
  24. easier_acumatica/profiles/laborde/owners.py +344 -0
  25. easier_acumatica/profiles/laborde/quirks.py +185 -0
  26. easier_acumatica/profiles/laborde/refs.py +331 -0
  27. easier_acumatica/profiles/laborde/registry.py +34 -0
  28. easier_acumatica/query.py +305 -0
  29. easier_acumatica/registry.py +161 -0
  30. easier_acumatica/transport.py +226 -0
  31. easier_acumatica/types.py +35 -0
  32. easier_acumatica/verbs.py +286 -0
  33. easier_acumatica-0.1.0.dist-info/METADATA +391 -0
  34. easier_acumatica-0.1.0.dist-info/RECORD +36 -0
  35. easier_acumatica-0.1.0.dist-info/WHEEL +4 -0
  36. easier_acumatica-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """easier-acumatica — a typed, predicate-based, ergonomic Python SDK for Laborde's Acumatica tenant."""
2
+
3
+ from easier_acumatica.client import Acumatica, AcumaticaConfig
4
+
5
+ __all__ = ["Acumatica", "AcumaticaConfig"]
@@ -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)