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.
Files changed (92) hide show
  1. easier_acumatica-0.1.0/.github/workflows/publish.yml +35 -0
  2. easier_acumatica-0.1.0/.github/workflows/tests.yml +28 -0
  3. easier_acumatica-0.1.0/.gitignore +11 -0
  4. easier_acumatica-0.1.0/LICENSE +21 -0
  5. easier_acumatica-0.1.0/PKG-INFO +391 -0
  6. easier_acumatica-0.1.0/README.md +376 -0
  7. easier_acumatica-0.1.0/codegen/__init__.py +8 -0
  8. easier_acumatica-0.1.0/codegen/alias_map.py +128 -0
  9. easier_acumatica-0.1.0/codegen/capture_h2.py +1817 -0
  10. easier_acumatica-0.1.0/codegen/entity_allowlist.py +265 -0
  11. easier_acumatica-0.1.0/codegen/gen_laborde.py +599 -0
  12. easier_acumatica-0.1.0/codegen/gen_models.py +717 -0
  13. easier_acumatica-0.1.0/codegen/key_overrides.py +145 -0
  14. easier_acumatica-0.1.0/codegen/preprocess.py +324 -0
  15. easier_acumatica-0.1.0/codegen/record_fixtures.py +413 -0
  16. easier_acumatica-0.1.0/codegen/sync_schema.py +165 -0
  17. easier_acumatica-0.1.0/docs/architecture.md +573 -0
  18. easier_acumatica-0.1.0/docs/laborde-profile.md +265 -0
  19. easier_acumatica-0.1.0/pyproject.toml +27 -0
  20. easier_acumatica-0.1.0/schema/Default@24.200.001.json +102005 -0
  21. easier_acumatica-0.1.0/schema/LabordeCustom@1.0.json +102008 -0
  22. easier_acumatica-0.1.0/scripts/check_pydantic_floor.sh +62 -0
  23. easier_acumatica-0.1.0/src/easier_acumatica/__init__.py +5 -0
  24. easier_acumatica-0.1.0/src/easier_acumatica/client.py +425 -0
  25. easier_acumatica-0.1.0/src/easier_acumatica/envelope.py +286 -0
  26. easier_acumatica-0.1.0/src/easier_acumatica/exceptions.py +385 -0
  27. easier_acumatica-0.1.0/src/easier_acumatica/odata.py +237 -0
  28. easier_acumatica-0.1.0/src/easier_acumatica/pagination.py +39 -0
  29. easier_acumatica-0.1.0/src/easier_acumatica/profiles/__init__.py +1 -0
  30. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/__init__.py +164 -0
  31. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/appends.py +398 -0
  32. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/attribute_whitelist.py +470 -0
  33. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/branches.py +59 -0
  34. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/execute.py +374 -0
  35. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/field_aliases.py +155 -0
  36. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/__init__.py +13 -0
  37. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/activity.py +81 -0
  38. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/appointment.py +347 -0
  39. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/contact.py +307 -0
  40. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/customer.py +432 -0
  41. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/opportunity.py +239 -0
  42. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/sales_order.py +474 -0
  43. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/service_order.py +302 -0
  44. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/stock_item.py +276 -0
  45. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/models/warehouse.py +93 -0
  46. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/owners.py +344 -0
  47. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/quirks.py +185 -0
  48. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/refs.py +331 -0
  49. easier_acumatica-0.1.0/src/easier_acumatica/profiles/laborde/registry.py +34 -0
  50. easier_acumatica-0.1.0/src/easier_acumatica/query.py +305 -0
  51. easier_acumatica-0.1.0/src/easier_acumatica/registry.py +161 -0
  52. easier_acumatica-0.1.0/src/easier_acumatica/transport.py +226 -0
  53. easier_acumatica-0.1.0/src/easier_acumatica/types.py +35 -0
  54. easier_acumatica-0.1.0/src/easier_acumatica/verbs.py +286 -0
  55. easier_acumatica-0.1.0/tests/__init__.py +0 -0
  56. easier_acumatica-0.1.0/tests/conftest.py +51 -0
  57. easier_acumatica-0.1.0/tests/fixtures/__init__.py +1 -0
  58. easier_acumatica-0.1.0/tests/fixtures/legacy_attribute_whitelist.py +435 -0
  59. easier_acumatica-0.1.0/tests/fixtures/recorded/appointment_append_new_only.json +47 -0
  60. easier_acumatica-0.1.0/tests/fixtures/recorded/customer_put_422_field_errors.json +24 -0
  61. easier_acumatica-0.1.0/tests/fixtures/recorded/idle_session_401_relogin_replay.json +42 -0
  62. easier_acumatica-0.1.0/tests/fixtures/recorded/rate_limit_429_retry_after.json +19 -0
  63. easier_acumatica-0.1.0/tests/fixtures/recorded/sales_order_append_three_call.json +84 -0
  64. easier_acumatica-0.1.0/tests/fixtures/recorded/service_order_get_or_none_marker_500.json +18 -0
  65. easier_acumatica-0.1.0/tests/fixtures/recorded/service_order_list_single_page.json +29 -0
  66. easier_acumatica-0.1.0/tests/fixtures/sample_alias_map.py +31 -0
  67. easier_acumatica-0.1.0/tests/fixtures/synthetic_swagger.json +180 -0
  68. easier_acumatica-0.1.0/tests/test_accessors.py +292 -0
  69. easier_acumatica-0.1.0/tests/test_alias_lint.py +198 -0
  70. easier_acumatica-0.1.0/tests/test_appends.py +537 -0
  71. easier_acumatica-0.1.0/tests/test_attribute_whitelist.py +215 -0
  72. easier_acumatica-0.1.0/tests/test_capture_h2.py +1064 -0
  73. easier_acumatica-0.1.0/tests/test_client.py +581 -0
  74. easier_acumatica-0.1.0/tests/test_codegen_e2e.py +772 -0
  75. easier_acumatica-0.1.0/tests/test_codegen_preprocess.py +280 -0
  76. easier_acumatica-0.1.0/tests/test_d1_lint.py +26 -0
  77. easier_acumatica-0.1.0/tests/test_envelope.py +294 -0
  78. easier_acumatica-0.1.0/tests/test_exceptions.py +327 -0
  79. easier_acumatica-0.1.0/tests/test_execute_plan.py +343 -0
  80. easier_acumatica-0.1.0/tests/test_gen_laborde.py +665 -0
  81. easier_acumatica-0.1.0/tests/test_laborde_models.py +238 -0
  82. easier_acumatica-0.1.0/tests/test_odata.py +300 -0
  83. easier_acumatica-0.1.0/tests/test_owners.py +225 -0
  84. easier_acumatica-0.1.0/tests/test_package_init.py +22 -0
  85. easier_acumatica-0.1.0/tests/test_putbody.py +210 -0
  86. easier_acumatica-0.1.0/tests/test_query.py +350 -0
  87. easier_acumatica-0.1.0/tests/test_quirks.py +295 -0
  88. easier_acumatica-0.1.0/tests/test_record_fixtures.py +350 -0
  89. easier_acumatica-0.1.0/tests/test_recorded_suite.py +399 -0
  90. easier_acumatica-0.1.0/tests/test_refs.py +400 -0
  91. easier_acumatica-0.1.0/tests/test_transport.py +431 -0
  92. 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,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .DS_Store
9
+ .claude/settings.local.json
10
+ .env
11
+ captures_h2/
@@ -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
+ [![Tests](https://github.com/ponderrr/easier-acumatica/actions/workflows/tests.yml/badge.svg)](https://github.com/ponderrr/easier-acumatica/actions/workflows/tests.yml)
23
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/)
24
+ [![pydantic](https://img.shields.io/badge/pydantic-v2-e92063)](https://docs.pydantic.dev/)
25
+ [![httpx](https://img.shields.io/badge/transport-httpx-0e7c7b)](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>