easier-acumatica 0.1.0__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.
- easier_acumatica-0.1.0/.github/workflows/publish.yml +35 -0
- easier_acumatica-0.1.0/.github/workflows/tests.yml +28 -0
- easier_acumatica-0.1.0/.gitignore +11 -0
- easier_acumatica-0.1.0/LICENSE +21 -0
- easier_acumatica-0.1.0/PKG-INFO +391 -0
- easier_acumatica-0.1.0/README.md +376 -0
- easier_acumatica-0.1.0/codegen/__init__.py +8 -0
- easier_acumatica-0.1.0/codegen/alias_map.py +128 -0
- easier_acumatica-0.1.0/codegen/capture_h2.py +1817 -0
- easier_acumatica-0.1.0/codegen/entity_allowlist.py +265 -0
- easier_acumatica-0.1.0/codegen/gen_laborde.py +599 -0
- easier_acumatica-0.1.0/codegen/gen_models.py +717 -0
- easier_acumatica-0.1.0/codegen/key_overrides.py +145 -0
- easier_acumatica-0.1.0/codegen/preprocess.py +324 -0
- easier_acumatica-0.1.0/codegen/record_fixtures.py +413 -0
- easier_acumatica-0.1.0/codegen/sync_schema.py +165 -0
- easier_acumatica-0.1.0/docs/architecture.md +573 -0
- easier_acumatica-0.1.0/docs/laborde-profile.md +265 -0
- easier_acumatica-0.1.0/pyproject.toml +27 -0
- easier_acumatica-0.1.0/schema/Default@24.200.001.json +102005 -0
- easier_acumatica-0.1.0/schema/LabordeCustom@1.0.json +102008 -0
- easier_acumatica-0.1.0/scripts/check_pydantic_floor.sh +62 -0
- easier_acumatica-0.1.0/src/easier_acumatica/__init__.py +5 -0
- easier_acumatica-0.1.0/src/easier_acumatica/client.py +425 -0
- easier_acumatica-0.1.0/src/easier_acumatica/envelope.py +286 -0
- easier_acumatica-0.1.0/src/easier_acumatica/exceptions.py +385 -0
- easier_acumatica-0.1.0/src/easier_acumatica/odata.py +237 -0
- easier_acumatica-0.1.0/src/easier_acumatica/pagination.py +39 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/__init__.py +1 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/__init__.py +164 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/appends.py +398 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/attribute_whitelist.py +470 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/branches.py +59 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/execute.py +374 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/field_aliases.py +155 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/__init__.py +13 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/activity.py +81 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/appointment.py +347 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/contact.py +307 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/customer.py +432 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/opportunity.py +239 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/sales_order.py +474 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/service_order.py +302 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/stock_item.py +276 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/warehouse.py +93 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/owners.py +344 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/quirks.py +185 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/refs.py +331 -0
- easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/registry.py +34 -0
- easier_acumatica-0.1.0/src/easier_acumatica/query.py +305 -0
- easier_acumatica-0.1.0/src/easier_acumatica/registry.py +161 -0
- easier_acumatica-0.1.0/src/easier_acumatica/transport.py +226 -0
- easier_acumatica-0.1.0/src/easier_acumatica/types.py +35 -0
- easier_acumatica-0.1.0/src/easier_acumatica/verbs.py +286 -0
- easier_acumatica-0.1.0/tests/__init__.py +0 -0
- easier_acumatica-0.1.0/tests/conftest.py +51 -0
- easier_acumatica-0.1.0/tests/fixtures/__init__.py +1 -0
- easier_acumatica-0.1.0/tests/fixtures/legacy_attribute_whitelist.py +435 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/appointment_append_new_only.json +47 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/customer_put_422_field_errors.json +24 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/idle_session_401_relogin_replay.json +42 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/rate_limit_429_retry_after.json +19 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/sales_order_append_three_call.json +84 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/service_order_get_or_none_marker_500.json +18 -0
- easier_acumatica-0.1.0/tests/fixtures/recorded/service_order_list_single_page.json +29 -0
- easier_acumatica-0.1.0/tests/fixtures/sample_alias_map.py +31 -0
- easier_acumatica-0.1.0/tests/fixtures/synthetic_swagger.json +180 -0
- easier_acumatica-0.1.0/tests/test_accessors.py +292 -0
- easier_acumatica-0.1.0/tests/test_alias_lint.py +198 -0
- easier_acumatica-0.1.0/tests/test_appends.py +537 -0
- easier_acumatica-0.1.0/tests/test_attribute_whitelist.py +215 -0
- easier_acumatica-0.1.0/tests/test_capture_h2.py +1064 -0
- easier_acumatica-0.1.0/tests/test_client.py +581 -0
- easier_acumatica-0.1.0/tests/test_codegen_e2e.py +772 -0
- easier_acumatica-0.1.0/tests/test_codegen_preprocess.py +280 -0
- easier_acumatica-0.1.0/tests/test_d1_lint.py +26 -0
- easier_acumatica-0.1.0/tests/test_envelope.py +294 -0
- easier_acumatica-0.1.0/tests/test_exceptions.py +327 -0
- easier_acumatica-0.1.0/tests/test_execute_plan.py +343 -0
- easier_acumatica-0.1.0/tests/test_gen_laborde.py +665 -0
- easier_acumatica-0.1.0/tests/test_laborde_models.py +238 -0
- easier_acumatica-0.1.0/tests/test_odata.py +300 -0
- easier_acumatica-0.1.0/tests/test_owners.py +225 -0
- easier_acumatica-0.1.0/tests/test_package_init.py +22 -0
- easier_acumatica-0.1.0/tests/test_putbody.py +210 -0
- easier_acumatica-0.1.0/tests/test_query.py +350 -0
- easier_acumatica-0.1.0/tests/test_quirks.py +295 -0
- easier_acumatica-0.1.0/tests/test_record_fixtures.py +350 -0
- easier_acumatica-0.1.0/tests/test_recorded_suite.py +399 -0
- easier_acumatica-0.1.0/tests/test_refs.py +400 -0
- easier_acumatica-0.1.0/tests/test_transport.py +431 -0
- easier_acumatica-0.1.0/tests/test_verbs.py +691 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes the package to PyPI when a GitHub Release is published.
|
|
4
|
+
# Uses PyPI Trusted Publishing (OpenID Connect) — no API token or password is
|
|
5
|
+
# stored anywhere. One-time setup on PyPI: add a "pending publisher" for the
|
|
6
|
+
# project `easier-acumatica` with owner `ponderrr`, repository `easier-acumatica`,
|
|
7
|
+
# workflow `publish.yml`, and environment `pypi`.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
release:
|
|
11
|
+
types: [published]
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build-and-publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
environment: pypi
|
|
20
|
+
permissions:
|
|
21
|
+
id-token: write # required for Trusted Publishing (OIDC)
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.13"
|
|
28
|
+
|
|
29
|
+
- name: Build sdist and wheel
|
|
30
|
+
run: |
|
|
31
|
+
python -m pip install --upgrade build
|
|
32
|
+
python -m build
|
|
33
|
+
|
|
34
|
+
- name: Publish to PyPI
|
|
35
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
tests:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
|
|
21
|
+
- name: Install package and test dependencies
|
|
22
|
+
run: pip install -e . pytest respx datamodel-code-generator==0.82.0
|
|
23
|
+
|
|
24
|
+
- name: Run the offline test suite
|
|
25
|
+
run: pytest -q
|
|
26
|
+
|
|
27
|
+
- name: Codegen drift gate
|
|
28
|
+
run: python -m codegen.gen_laborde --check
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ponderrr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: easier-acumatica
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A typed, predicate-based, ergonomic Python SDK for the Acumatica REST API.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx<1,>=0.27
|
|
9
|
+
Requires-Dist: pydantic<3,>=2.9
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: datamodel-code-generator==0.82.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
<div align="center">
|
|
17
|
+
|
|
18
|
+
# easier-acumatica
|
|
19
|
+
|
|
20
|
+
*A typed, safe, offline-testable Python client for the Acumatica ERP REST API.*
|
|
21
|
+
|
|
22
|
+
[](https://github.com/ponderrr/easier-acumatica/actions/workflows/tests.yml)
|
|
23
|
+
[](https://www.python.org/)
|
|
24
|
+
[](https://docs.pydantic.dev/)
|
|
25
|
+
[](https://www.python-httpx.org/)
|
|
26
|
+
|
|
27
|
+
[Why](#why-this-exists) · [Quickstart](#quickstart) · [How it works](#how-it-works) · [Safety](#the-safety-model) · [Codegen](#bring-your-own-tenant) · [Docs](#documentation)
|
|
28
|
+
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
## Why this exists
|
|
32
|
+
|
|
33
|
+
Acumatica's contract-based REST API is workable, but it is full of traps: every scalar
|
|
34
|
+
arrives wrapped in a `{"value": ...}` envelope, field names change from entity to entity,
|
|
35
|
+
some perfectly valid filters are silently ignored, and "not found" sometimes arrives as
|
|
36
|
+
an HTTP 500. Teams that integrate against it tend to rediscover each trap the hard way,
|
|
37
|
+
one production incident at a time.
|
|
38
|
+
|
|
39
|
+
**easier-acumatica** encodes those traps once — in a small, typed core plus a per-tenant
|
|
40
|
+
"profile" of generated models and declared server quirks — so your integration code
|
|
41
|
+
reads and writes plain Python objects and the library absorbs the chaos.
|
|
42
|
+
|
|
43
|
+
| What Acumatica does | What easier-acumatica does about it |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| Wraps every scalar as `{"value": x}` on the wire | Envelope-free pydantic models — a validator strips the envelope on read, a serializer re-applies it on write (`src/easier_acumatica/envelope.py`) |
|
|
46
|
+
| Names the same concept differently per entity (`OrderQty` vs `EstimatedQty`, `WarehouseID` vs `Warehouse`) | Codegen stamps read/write aliases so every model exposes one consistent snake_case name |
|
|
47
|
+
| Silently ignores some valid filters, returning unfiltered rows that look filtered | A known-quirk registry moves those predicates client-side instead of sending them |
|
|
48
|
+
| Substitutes a branch-default warehouse when a write names an unknown one — without an error | Allow-list validation turns the silent substitution into a loud pre-write failure |
|
|
49
|
+
| Reports some "not found" cases as HTTP 500 with a marker string in the body | `get_or_none()` recognizes the marker and returns `None`; every other 500 still raises |
|
|
50
|
+
| Expires idle sessions mid-run with a 401 | The transport re-logs-in once and replays the request — **reads only, never writes** |
|
|
51
|
+
| Silently assigns unowned records to the API user's own contact | An owner guard refuses (or tags) writes whose owner cannot be resolved |
|
|
52
|
+
|
|
53
|
+
> [!NOTE]
|
|
54
|
+
> The core philosophy in one line: **reads are made convenient; writes are made safe.**
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
|
|
58
|
+
- 🔒 **Rate-limited transport** — a thread-safe token bucket (default 10 req/s, burst 10) in front of one pooled `httpx.Client`; connect-only retries, never status-code retries
|
|
59
|
+
- 🔁 **Method-aware 401 recovery** — an idle-session GET is replayed exactly once after re-login; a PUT/POST/DELETE that 401s surfaces immediately, with zero replays
|
|
60
|
+
- 📦 **Envelope-free wire models** — pydantic-v2 models generated from your tenant's committed OpenAPI schema, no runtime introspection
|
|
61
|
+
- 🔍 **Typed query builder** — `where(status="Open", date__ge=since)` with model-checked field names and OData literal escaping owned by the library
|
|
62
|
+
- ✍️ **Safe writes** — a partial-PUT serializer that drops read-only fields, per-entity owner guards, and declarative detail-append strategies
|
|
63
|
+
- 🧪 **Fully offline test suite** — 700+ tests on a mocked transport and recorded-fixture files; no tenant needed to develop or run CI
|
|
64
|
+
- 🛰️ **Live capture CLI** — read-only probes of real server behavior by default; write probes only behind explicit opt-in flags
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
```mermaid
|
|
69
|
+
flowchart TD
|
|
70
|
+
A["Your code"] --> B["Typed entity accessors<br/>acu.sales_orders, acu.contacts, ..."]
|
|
71
|
+
B --> C["Query builder<br/>filters, select, expand, top"]
|
|
72
|
+
B --> D["Envelope-free pydantic models"]
|
|
73
|
+
C --> E["One pooled httpx client<br/>rate limited, 401-aware"]
|
|
74
|
+
D --> E
|
|
75
|
+
E --> F["Acumatica REST API"]
|
|
76
|
+
subgraph BT["Build time"]
|
|
77
|
+
G["Committed OpenAPI snapshots"] --> H["Codegen pipeline"]
|
|
78
|
+
H --> I["Generated models + entity registry"]
|
|
79
|
+
end
|
|
80
|
+
I -.-> B
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Your code talks to typed entity accessors (`acu.sales_orders`, `acu.contacts`, ...);
|
|
84
|
+
accessors compile queries and writes through one pooled, rate-limited client; and the
|
|
85
|
+
models plus the entity registry are generated at build time from committed OpenAPI schema
|
|
86
|
+
snapshots — nothing is introspected at runtime.
|
|
87
|
+
|
|
88
|
+
Full tour → [`docs/architecture.md`](docs/architecture.md)
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install git+https://github.com/ponderrr/easier-acumatica.git
|
|
94
|
+
# or, from a clone:
|
|
95
|
+
pip install -e .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Requirement | Version |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| Python | ≥ 3.11 |
|
|
101
|
+
| Runtime dependencies | `httpx` and `pydantic` only |
|
|
102
|
+
|
|
103
|
+
> [!NOTE]
|
|
104
|
+
> Codegen extras (`datamodel-code-generator`) are needed only to regenerate models from
|
|
105
|
+
> your own tenant's schema — never at runtime.
|
|
106
|
+
|
|
107
|
+
## Quickstart
|
|
108
|
+
|
|
109
|
+
Configuration comes from `ACUMATICA_*` environment variables (or construct an
|
|
110
|
+
`AcumaticaConfig` directly):
|
|
111
|
+
|
|
112
|
+
| Variable | Required | Notes |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `ACUMATICA_URL` | yes | Base URL of the instance; `ACUMATICA_SITE_URL` is accepted as a fallback name. Setting both to different values is a configuration error. |
|
|
115
|
+
| `ACUMATICA_USERNAME` / `ACUMATICA_PASSWORD` / `ACUMATICA_TENANT` | yes | Login credentials. A missing-variable error names *every* missing key at once. |
|
|
116
|
+
| `ACUMATICA_BRANCH` / `ACUMATICA_LOCALE` | no | Passed through to the login call when set. |
|
|
117
|
+
| `ACUMATICA_ENDPOINT_NAME` / `ACUMATICA_ENDPOINT_VERSION` | no | Default endpoint for requests (defaults: `Default` / `24.200.001`). |
|
|
118
|
+
| `ACUMATICA_TIMEOUT` | no | Request timeout in seconds (default 60). |
|
|
119
|
+
| `ACUMATICA_RATE_LIMIT` | no | Requests per second for the token bucket (default 10). |
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from datetime import datetime, timezone
|
|
123
|
+
|
|
124
|
+
from easier_acumatica import Acumatica
|
|
125
|
+
from easier_acumatica.odata import Raw
|
|
126
|
+
from easier_acumatica.profiles.laborde.models.opportunity import Opportunity
|
|
127
|
+
from easier_acumatica.profiles.laborde.registry import REGISTRY
|
|
128
|
+
|
|
129
|
+
since = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
130
|
+
|
|
131
|
+
with Acumatica.from_env() as acu: # logs in once; logs out on exit
|
|
132
|
+
acu.bind_registry(REGISTRY) # turns on acu.<entity> accessors
|
|
133
|
+
|
|
134
|
+
# Fluent, typed reads — snake_case fields, no {"value": ...} envelopes.
|
|
135
|
+
recent = (
|
|
136
|
+
acu.service_orders
|
|
137
|
+
.where(status="Open", last_modified_date_time__ge=since)
|
|
138
|
+
.order_by("date desc")
|
|
139
|
+
.limit(50)
|
|
140
|
+
.all()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# get_or_none() absorbs a real 404 AND the 500-as-not-found server quirk.
|
|
144
|
+
order = acu.service_orders.get_or_none(
|
|
145
|
+
service_order_type="IN", service_order_nbr="000123"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# OR-filters go through the explicit Raw escape hatch; kwargs stay AND-joined.
|
|
149
|
+
quotes = acu.sales_orders.where(
|
|
150
|
+
Raw("Status eq 'Open' or Status eq 'On Hold'"),
|
|
151
|
+
order_type="QT",
|
|
152
|
+
).all()
|
|
153
|
+
|
|
154
|
+
# Writes pass through the owner guard: pass an email from the profile's
|
|
155
|
+
# owner map, or the write raises before any HTTP happens — records are
|
|
156
|
+
# never silently attributed to the API user.
|
|
157
|
+
opp = Opportunity(subject="Replacement engine quote")
|
|
158
|
+
created = acu.opportunities.put(opp, owner_email="rep@example.com")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> [!TIP]
|
|
162
|
+
> Everything above also runs against the offline test suite's mocked transport —
|
|
163
|
+
> you can develop and test integration code without a tenant. See [Testing](#testing).
|
|
164
|
+
|
|
165
|
+
## The safety model
|
|
166
|
+
|
|
167
|
+
Convenience features are easy to add; the reason this library exists is what it
|
|
168
|
+
*refuses* to do on your behalf.
|
|
169
|
+
|
|
170
|
+
> [!WARNING]
|
|
171
|
+
> **No write is ever automatically retried or replayed** — not on a 5xx, not on a
|
|
172
|
+
> timeout, not on a 401. There is no constructor flag to turn write-retries on.
|
|
173
|
+
> Redelivery of a business operation belongs to your queue, where it can be made
|
|
174
|
+
> idempotent — not to a transport that cannot know whether the first attempt landed.
|
|
175
|
+
|
|
176
|
+
The request lifecycle, including the 401 fork:
|
|
177
|
+
|
|
178
|
+
```mermaid
|
|
179
|
+
sequenceDiagram
|
|
180
|
+
participant App as Your code
|
|
181
|
+
participant T as Transport
|
|
182
|
+
participant S as Acumatica
|
|
183
|
+
|
|
184
|
+
App->>T: GET SalesOrder
|
|
185
|
+
T->>T: wait for a rate-limit token
|
|
186
|
+
T->>S: send GET
|
|
187
|
+
S-->>T: 401 idle session
|
|
188
|
+
T->>S: POST auth/login
|
|
189
|
+
S-->>T: 204 + fresh cookie
|
|
190
|
+
T->>S: replay the same GET once
|
|
191
|
+
S-->>T: 200
|
|
192
|
+
T-->>App: rows
|
|
193
|
+
|
|
194
|
+
App->>T: PUT SalesOrder
|
|
195
|
+
T->>S: send PUT
|
|
196
|
+
S-->>T: 401
|
|
197
|
+
T-->>App: Auth error — writes are never replayed
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Three guards stand between your code and a damaging write:
|
|
201
|
+
|
|
202
|
+
| Guard | What it prevents | Where |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| **Owner guard** (`on_unresolved="raise"` \| `"tag"` \| `"allow"`) | A record with no resolvable owner being silently attributed to the API user | `src/easier_acumatica/verbs.py` + the profile's owner map |
|
|
205
|
+
| **Shallow writability filter** (`to_put_body`) | Read-only fields (computed totals, audit timestamps) leaking into a read-modify-write PUT and breaking it | `src/easier_acumatica/envelope.py` |
|
|
206
|
+
| **Warehouse allow-list** | Acumatica silently replacing an unknown warehouse with the branch default | `src/easier_acumatica/profiles/laborde/branches.py` |
|
|
207
|
+
|
|
208
|
+
## Bring your own tenant
|
|
209
|
+
|
|
210
|
+
The core is tenant-agnostic. Everything that is true about *one* Acumatica instance —
|
|
211
|
+
generated models, the entity registry, field-alias overrides, known quirks, append
|
|
212
|
+
strategies, owner maps — lives in a **profile** package. The repository ships one
|
|
213
|
+
complete profile as a worked example (see below), and the codegen pipeline that
|
|
214
|
+
produced it is the same one you would run against your own tenant:
|
|
215
|
+
|
|
216
|
+
```mermaid
|
|
217
|
+
flowchart LR
|
|
218
|
+
A["Live tenant"] -- "one-time authenticated fetch" --> B["Committed schema snapshots<br/>schema/*.json"]
|
|
219
|
+
B --> C["Wrapper-collapse pre-pass"]
|
|
220
|
+
C --> D["datamodel-code-generator"]
|
|
221
|
+
D --> E["Alias + writability stamping"]
|
|
222
|
+
E --> F["Committed models + registry"]
|
|
223
|
+
F -- "drift gate: --check" --> B
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
1. **Fetch your schemas once** — `python -m codegen.sync_schema` logs in with the same
|
|
227
|
+
`ACUMATICA_*` env vars, downloads each endpoint's `swagger.json`, validates it, and
|
|
228
|
+
writes it under `schema/`. Commit the result; it is the source of truth from here on.
|
|
229
|
+
2. **Declare what you need** — an entity allow-list (`codegen/entity_allowlist.py`),
|
|
230
|
+
composite-key declarations (`codegen/key_overrides.py`), and any field-alias
|
|
231
|
+
overrides for names that vary across entities (the profile's `field_aliases.py`).
|
|
232
|
+
3. **Generate** — `python -m codegen.gen_laborde` (the shipped reference driver) slices
|
|
233
|
+
each allow-listed entity plus its transitively-referenced sub-schemas out of the
|
|
234
|
+
snapshot, collapses the `{"value": ...}` wrapper schemas so the generator emits
|
|
235
|
+
`str | None` instead of wrapper classes, runs `datamodel-code-generator`, then stamps
|
|
236
|
+
read aliases, write aliases, writability flags, and key tuples onto the output.
|
|
237
|
+
4. **Keep it honest** — `python -m codegen.gen_laborde --check` regenerates into a
|
|
238
|
+
temporary directory and diffs against what is committed; CI fails on drift.
|
|
239
|
+
|
|
240
|
+
## The reference profile
|
|
241
|
+
|
|
242
|
+
The repository ships a complete, production-derived profile for one real tenant under
|
|
243
|
+
`src/easier_acumatica/profiles/laborde/`: an entity registry covering 9 entities across
|
|
244
|
+
two endpoints, per-entity detail-append strategies, an owner-resolution write guard, an
|
|
245
|
+
idempotent-upsert scheme, and a registry of verified server quirks. It is presented as
|
|
246
|
+
**a complete reference profile** — read it to learn the pattern, copy its shape (not its
|
|
247
|
+
data) for your own tenant.
|
|
248
|
+
|
|
249
|
+
Details → [`docs/laborde-profile.md`](docs/laborde-profile.md)
|
|
250
|
+
|
|
251
|
+
## Testing
|
|
252
|
+
|
|
253
|
+
The whole suite runs **offline** — 700+ tests against a respx-mocked transport plus a
|
|
254
|
+
recorded-fixture format (one JSON file per HTTP exchange, or an ordered exchange array
|
|
255
|
+
for sequences like the 401-replay cycle). No tenant, no credentials, no network:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
pip install -e . pytest respx datamodel-code-generator==0.82.0
|
|
259
|
+
pytest -q
|
|
260
|
+
python -m codegen.gen_laborde --check # codegen drift gate
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
For verifying behavior against a *real* tenant there is a capture CLI
|
|
264
|
+
(`python -m codegen.capture_h2`): by default it runs **read-only probes** (endpoint
|
|
265
|
+
discovery, filter-acceptance checks, datetime-literal variants, the silent filter-drop,
|
|
266
|
+
the 500-as-not-found marker). **Gated write probes** require both an explicit
|
|
267
|
+
`--enable-writes` flag *and* per-target arguments naming the exact record to touch —
|
|
268
|
+
targets alone are refused. Every exchange is recorded through a scrubbing recorder that
|
|
269
|
+
strips cookies, auth headers, and sensitive params, refuses to write anything matching a
|
|
270
|
+
secret denylist, and never records request bodies unless explicitly asked (the login
|
|
271
|
+
body carries a password).
|
|
272
|
+
|
|
273
|
+
> [!NOTE]
|
|
274
|
+
> The recorded fixtures committed under `tests/fixtures/recorded/` are currently
|
|
275
|
+
> placeholders shaped like real exchanges; the recorder's scrubbing runs before anything
|
|
276
|
+
> is ever written to disk. Some documented behaviors (the 401 replay, the datetime
|
|
277
|
+
> filter literal, the silent filter-drop) have been verified against a live tenant;
|
|
278
|
+
> others are grounded in recorded evidence from production integrations. Where a doc
|
|
279
|
+
> makes that distinction, it says so in plain words.
|
|
280
|
+
|
|
281
|
+
## Project layout
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
easier-acumatica/
|
|
285
|
+
├── src/easier_acumatica/
|
|
286
|
+
│ ├── client.py # config, login lifecycle, request seam, accessors
|
|
287
|
+
│ ├── transport.py # token-bucket rate limit + method-aware 401 handling
|
|
288
|
+
│ ├── envelope.py # AcumaticaModel base + to_put_body partial-PUT serializer
|
|
289
|
+
│ ├── odata.py # kwargs predicates, wire literals, the Raw escape hatch
|
|
290
|
+
│ ├── query.py # EntitySet: the immutable, chainable query builder
|
|
291
|
+
│ ├── verbs.py # get / get_or_none / get_list / put / delete
|
|
292
|
+
│ ├── pagination.py # honest single-page $top clamping
|
|
293
|
+
│ ├── exceptions.py # one exception hierarchy + the response classifier
|
|
294
|
+
│ ├── registry.py # EntityBinding + the client/profile seams
|
|
295
|
+
│ ├── types.py # Line — the uniform append-input DTO
|
|
296
|
+
│ └── profiles/laborde/ # the shipped reference tenant profile
|
|
297
|
+
│ └── models/ # generated pydantic models (committed)
|
|
298
|
+
├── codegen/ # build-time only: schema fetch, preprocess, generation, capture CLI
|
|
299
|
+
├── schema/ # committed OpenAPI snapshots — codegen's source of truth
|
|
300
|
+
├── tests/ # fully offline suite (respx + recorded fixtures)
|
|
301
|
+
└── .github/workflows/ # CI: offline tests + codegen drift gate
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Design decisions
|
|
305
|
+
|
|
306
|
+
<details>
|
|
307
|
+
<summary><b>Why kwargs predicates instead of operator overloading?</b></summary>
|
|
308
|
+
|
|
309
|
+
Some client libraries build filters as `F.Status == "Open"`. Overloading `__eq__`
|
|
310
|
+
defeats type checking — a type checker cannot verify the operands of `==`, and the
|
|
311
|
+
expression's type is a filter object no matter what you compare. Kwargs with operator
|
|
312
|
+
suffixes (`status="Open"`, `date__ge=since`) keep field names checkable against the
|
|
313
|
+
model at call time: a typo raises immediately, listing the model's known fields, before
|
|
314
|
+
any HTTP request is built.
|
|
315
|
+
</details>
|
|
316
|
+
|
|
317
|
+
<details>
|
|
318
|
+
<summary><b>Why sync-first on httpx?</b></summary>
|
|
319
|
+
|
|
320
|
+
Every consumer this library was extracted from is synchronous at the Acumatica boundary,
|
|
321
|
+
and the failure modes that matter (rate limits, session expiry, duplicate writes) are
|
|
322
|
+
easier to reason about on one code path. httpx keeps the door open: the transport layer
|
|
323
|
+
is a thin `httpx.BaseTransport` wrapper, and an async variant can follow the same design
|
|
324
|
+
without rewriting the model or query layers.
|
|
325
|
+
</details>
|
|
326
|
+
|
|
327
|
+
<details>
|
|
328
|
+
<summary><b>Why single-page pagination only?</b></summary>
|
|
329
|
+
|
|
330
|
+
Acumatica ignores `$skip` server-side. A paginator built on `$skip` *looks* like it
|
|
331
|
+
works and silently returns page one forever. Rather than fake deep pagination, the
|
|
332
|
+
query builder never emits `$skip`, and `.limit(n)` clamps `$top` to 1–100 — one page,
|
|
333
|
+
honestly. Real keyset pagination is a possible follow-up, not a hidden half-feature.
|
|
334
|
+
</details>
|
|
335
|
+
|
|
336
|
+
<details>
|
|
337
|
+
<summary><b>Why are models committed rather than generated at runtime?</b></summary>
|
|
338
|
+
|
|
339
|
+
Runtime schema introspection means your integration's behavior depends on whatever the
|
|
340
|
+
tenant's schema says *today*, and a schema change reaches production without review.
|
|
341
|
+
Committed snapshots plus committed generated models make every schema change a visible
|
|
342
|
+
diff, and the `--check` drift gate makes CI fail when the committed output no longer
|
|
343
|
+
matches what the pipeline would produce.
|
|
344
|
+
</details>
|
|
345
|
+
|
|
346
|
+
## Limitations
|
|
347
|
+
|
|
348
|
+
> [!WARNING]
|
|
349
|
+
> Datetime filter literals are rendered as `datetimeoffset'<utc-iso>Z'`. This form was
|
|
350
|
+
> verified by a live probe against the standard endpoint's SalesOrder entity (the other
|
|
351
|
+
> candidate forms were rejected with HTTP 500); custom endpoints are assumed to accept
|
|
352
|
+
> the same dialect but were not probed.
|
|
353
|
+
|
|
354
|
+
- `.limit(n)` is single-page only (`$top`, clamped to 1–100). There is no deep
|
|
355
|
+
pagination — see the design note above.
|
|
356
|
+
- `__contains` renders the OData v3 form `substringof('<v>',Field)`. The endpoint reads
|
|
357
|
+
as v3-flavoured (quoted datetime literals are accepted, bare ones rejected), but
|
|
358
|
+
`substringof` itself has not been live-probed.
|
|
359
|
+
- `invoke_action` and file attachment are not built yet — no consumer has needed them.
|
|
360
|
+
- The committed recorded fixtures are placeholders shaped like real exchanges, pending
|
|
361
|
+
a capture pass against a live tenant.
|
|
362
|
+
|
|
363
|
+
## Documentation
|
|
364
|
+
|
|
365
|
+
| Document | What it covers |
|
|
366
|
+
| --- | --- |
|
|
367
|
+
| [`docs/architecture.md`](docs/architecture.md) | How the machine works: transport, envelope, queries, verbs, errors, codegen, testing |
|
|
368
|
+
| [`docs/laborde-profile.md`](docs/laborde-profile.md) | The shipped reference profile — a worked example of encoding one tenant |
|
|
369
|
+
|
|
370
|
+
## Contributing
|
|
371
|
+
|
|
372
|
+
- The test suite must pass **offline**: `pytest -q` with no tenant configured.
|
|
373
|
+
- Regenerated models must be drift-free: `python -m codegen.gen_laborde --check`.
|
|
374
|
+
- Never import the third-party `easy-acumatica` library from runtime code — it is a
|
|
375
|
+
build-time reference only, and a lint test enforces this.
|
|
376
|
+
|
|
377
|
+
## License
|
|
378
|
+
|
|
379
|
+
No license has been chosen for this repository yet.
|
|
380
|
+
|
|
381
|
+
## Acknowledgments
|
|
382
|
+
|
|
383
|
+
The third-party [`easy-acumatica`](https://github.com/Nioron07/Easy-Acumatica) library
|
|
384
|
+
(MIT, by Nioron07) served as a build-time schema and codegen reference for this design.
|
|
385
|
+
It is never imported at runtime.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
<div align="center">
|
|
390
|
+
<i>Built to make Acumatica integrations boring.</i>
|
|
391
|
+
</div>
|