Metadata-Version: 2.5
Name: easier-acumatica
Version: 0.3.3
Summary: A typed, predicate-based, ergonomic Python SDK for the Acumatica REST API.
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.9
Provides-Extra: dev
Requires-Dist: datamodel-code-generator==0.82.0; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: respx; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">

# easier-acumatica

*A typed, safe, offline-testable Python client for the Acumatica ERP REST API.*

[![Tests](https://github.com/ponderrr/easier-acumatica/actions/workflows/tests.yml/badge.svg)](https://github.com/ponderrr/easier-acumatica/actions/workflows/tests.yml)
[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/)
[![pydantic](https://img.shields.io/badge/pydantic-v2-e92063)](https://docs.pydantic.dev/)
[![httpx](https://img.shields.io/badge/transport-httpx-0e7c7b)](https://www.python-httpx.org/)

[Why](#why-this-exists) · [Quickstart](#quickstart) · [CLI](#command-line) · [How it works](#how-it-works) · [Safety](#the-safety-model) · [Codegen](#bring-your-own-tenant) · [Docs](#documentation)

</div>

## Why this exists

Acumatica's contract-based REST API is workable, but it is full of traps: every scalar
arrives wrapped in a `{"value": ...}` envelope, field names change from entity to entity,
some perfectly valid filters are silently ignored, and "not found" sometimes arrives as
an HTTP 500. Teams that integrate against it tend to rediscover each trap the hard way,
one production incident at a time.

**easier-acumatica** encodes those traps once — in a small, typed core plus a per-tenant
"profile" of generated models and declared server quirks — so your integration code
reads and writes plain Python objects and the library absorbs the chaos.

| What Acumatica does | What easier-acumatica does about it |
| --- | --- |
| 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`) |
| 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 |
| Silently ignores some valid filters, returning unfiltered rows that look filtered | A known-quirk registry moves those predicates client-side instead of sending them |
| 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 |
| 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 |
| Expires idle sessions mid-run with a 401 | The transport re-logs-in once and replays the request — **reads only, never writes** |
| Silently assigns unowned records to the API user's own contact | An owner guard refuses (or tags) writes whose owner cannot be resolved |

> **Note** — The core philosophy in one line: **reads are made convenient; writes are
> made safe.**

## Features

- 🔒 **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
- 🔁 **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
- 📦 **Envelope-free wire models** — pydantic-v2 models generated from your tenant's committed OpenAPI schema, no runtime introspection
- 🔍 **Typed query builder** — `where(status="Open", date__ge=since)` with model-checked field names and OData literal escaping owned by the library
- ✍️ **Safe writes** — a partial-PUT serializer that drops read-only fields, per-entity owner guards, and declarative detail-append strategies
- 🧪 **Fully offline test suite** — 900+ tests on a mocked transport and recorded-fixture files; no tenant needed to develop or run CI
- 🖥️ **A read-only `easier-acumatica` command** — installed with the package; inspects a registry offline and reads a tenant from the shell. No subcommand writes. See [Command line](#command-line)
- 🛰️ **A separate build-time capture tool** — `python -m codegen.capture_h2`, available only from a clone, for probing real server behavior; read-only by default, write probes behind explicit opt-in flags

## How it works

There are four layers and one build-time input:

1. **Your code** talks to typed entity accessors — `acu.sales_orders`, `acu.contacts`, ...
2. Each accessor offers a **query builder** (filters, select, expand, top) and validates
   rows into **envelope-free pydantic models**.
3. Both funnel through **one pooled `httpx` client** — rate limited and 401-aware — which
   is the only thing that reaches the **Acumatica REST API**.
4. Feeding the accessors from the side: the models and the entity registry are generated
   at **build time** from committed OpenAPI snapshots. Nothing is introspected at runtime.

![Layer diagram. Your code calls typed entity accessors, which fan out to a query builder for filters, select, expand and top, and to envelope-free pydantic models. Both funnel into one pooled httpx client that is rate limited and 401-aware, and that client is the only component reaching the Acumatica REST API. A separate build-time track turns committed OpenAPI snapshots into generated models and an entity registry, which feed the accessors.](https://raw.githubusercontent.com/ponderrr/easier-acumatica/main/docs/assets/overview.svg)

Diagrams of all of this, down to the module level →
[`docs/architecture.md`](https://github.com/ponderrr/easier-acumatica/blob/main/docs/architecture.md)

## Installation

```bash
pip install git+https://github.com/ponderrr/easier-acumatica.git
# or, from a clone:
pip install -e .
```

| Requirement | Version |
| --- | --- |
| Python | ≥ 3.11 |
| Runtime dependencies | `httpx` and `pydantic` only |

> **Note** — Codegen extras (`datamodel-code-generator`) are needed only to regenerate
> models from your own tenant's schema — never at runtime.

## Quickstart

Configuration comes from `ACUMATICA_*` environment variables (or construct an
`AcumaticaConfig` directly):

| Variable | Required | Notes |
| --- | --- | --- |
| `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. |
| `ACUMATICA_USERNAME` / `ACUMATICA_PASSWORD` / `ACUMATICA_TENANT` | yes | Login credentials. A missing-variable error names *every* missing key at once. |
| `ACUMATICA_BRANCH` / `ACUMATICA_LOCALE` | no | Passed through to the login call when set. |
| `ACUMATICA_ENDPOINT_NAME` / `ACUMATICA_ENDPOINT_VERSION` | no | Default endpoint for requests (defaults: `Default` / `24.200.001`). |
| `ACUMATICA_TIMEOUT` | no | Request timeout in seconds (default 60). |
| `ACUMATICA_RATE_LIMIT` | no | Requests per second for the token bucket (default 10). |

```python
from datetime import datetime, timezone

from easier_acumatica import Acumatica
from easier_acumatica.odata import Raw
from easier_acumatica.profiles.laborde.models.opportunity import Opportunity
from easier_acumatica.profiles.laborde.registry import REGISTRY

since = datetime(2026, 1, 1, tzinfo=timezone.utc)

with Acumatica.from_env() as acu:          # logs in once; logs out on exit
    acu.bind_registry(REGISTRY)            # turns on acu.<entity> accessors

    # Fluent, typed reads — snake_case fields, no {"value": ...} envelopes.
    recent = (
        acu.service_orders
        .where(status="Open", last_modified_date_time__ge=since)
        .order_by("date desc")
        .limit(50)
        .all()
    )

    # get_or_none() absorbs a real 404 AND the 500-as-not-found server quirk.
    order = acu.service_orders.get_or_none(
        service_order_type="IN", service_order_nbr="000123"
    )

    # OR-filters go through the explicit Raw escape hatch; kwargs stay AND-joined.
    quotes = acu.sales_orders.where(
        Raw("Status eq 'Open' or Status eq 'On Hold'"),
        order_type="QT",
    ).all()

    # Writes pass through the owner guard: pass an email from the profile's
    # owner map, or the write raises before any HTTP happens — records are
    # never silently attributed to the API user.
    opp = Opportunity(subject="Replacement engine quote")
    created = acu.opportunities.put(opp, owner_email="rep@example.com")
```

> **Tip** — Everything above also runs against the offline test suite's mocked transport
> — you can develop and test integration code without a tenant. See [Testing](#testing).

## Command line

`pip install easier-acumatica` also puts an **`easier-acumatica`** command on your PATH.
It is installed unconditionally — there is no extra to opt into — and
`python -m easier_acumatica` runs exactly the same program if you would rather not rely
on PATH:

```console
$ easier-acumatica --version
easier-acumatica 0.3.0
```

Every subcommand is **read-only**. There is no `put`, no `delete`, and the one escape
hatch that bypasses the typed layer (`raw`) accepts `GET` and nothing else. Three
subcommands — `doctor`, `entities`, `describe` — never log in at all, and neither does
`query --dry-run`.

### Start offline

These need no credentials and touch no network, so they are the sensible first thing to
run after installing.

**`doctor`** reports which `ACUMATICA_*` variables this shell has. It prints presence
only, never a value — one of those variables is a password, and a diagnostic command's
output is exactly what ends up pasted into a ticket. It builds no client, because
constructing one would log in:

```console
$ easier-acumatica doctor
easier-acumatica doctor — offline; no login is attempted.

environment (presence only — values are never printed):
VARIABLE                    PRESENT  REQUIREMENT  NOTE
ACUMATICA_URL               not set  required     base URL (or ACUMATICA_SITE_URL)
ACUMATICA_SITE_URL          not set  fallback     alternative spelling of ACUMATICA_URL
ACUMATICA_USERNAME          not set  required
ACUMATICA_PASSWORD          not set  required
ACUMATICA_TENANT            not set  required
ACUMATICA_BRANCH            not set  optional
...
ACUMATICA_REGISTRY          not set  optional     default <module>:<ATTR> for --registry

missing required: ACUMATICA_URL, ACUMATICA_USERNAME, ACUMATICA_PASSWORD, ACUMATICA_TENANT
  these are only needed by commands that talk to a tenant;
  entities/describe run without any of them.

registry: none requested — pass --registry <module>:<ATTR> or set ACUMATICA_REGISTRY
          to have doctor check that a registry loads.
```

**`entities`** lists every accessor a registry declares, with the wire entity it binds
to, the endpoint that owns it, its REST key fields and its field count. The banner on
the first line names the registry the numbers came from, because this reads a static
generated file rather than introspecting anything live:

```console
$ easier-acumatica entities --registry easier_acumatica.profiles.laborde.registry:REGISTRY
registry: easier_acumatica.profiles.laborde.registry:REGISTRY  (bundled example — Laborde tenant)

ACCESSOR        ENTITY        ENDPOINT       KEYS                                   FIELDS
activities      Activity      Default        note_id                                21
appointments    Appointment   LabordeCustom  appointment_nbr                        53
contacts        Contact       Default        contact_id                             74
customers       Customer      Default        customer_id                            68
opportunities   Opportunity   Default        opportunity_id                         46
sales_orders    SalesOrder    LabordeCustom  order_type, order_nbr                  73
service_orders  ServiceOrder  LabordeCustom  service_order_type, service_order_nbr  46
stock_items     StockItem     Default        inventory_id                           96
warehouses      Warehouse     Default        warehouse_id                           39

9 accessor(s)
```

**`describe`** answers "what may I put in `--where` and `--select` for this entity"
without making you read generated model source. It prints the key fields *in the order
`get` requires them*, whether an owner guard is attached, any declared server quirks,
and then every field as python name → wire alias → type → writable:

```console
$ easier-acumatica describe activities --registry easier_acumatica.profiles.laborde.registry:REGISTRY
registry: easier_acumatica.profiles.laborde.registry:REGISTRY  (bundled example — Laborde tenant)

accessor:     activities
entity:       Activity
endpoint:     Default
keys:         note_id   (the order get() requires)
owner guard:  yes
quirks:       date eq
  - date eq: Acumatica silently ignores `Date eq` on Activity (verified empirically
    against production 2026-05-14). ...

fields (21):
FIELD                       WIRE ALIAS                TYPE                                      WRITABLE  KEY
...
```

Both accept `-o json` for the same content as a machine-readable document.

### Why `--registry` is not optional

`entities`, `describe`, `get` and `query` all require `--registry <module>:<ATTR>`, and
omitting it is an error rather than a fallback:

```console
$ easier-acumatica entities
easier-acumatica: entities needs a registry. Entity access is tenant-specific: entity
names, key fields and field vocabularies come from pydantic models
generated from YOUR tenant's OpenAPI schema, so there is no default.

  --registry <module>:<ATTR>      e.g. --registry myco.acu_profile:REGISTRY
  ACUMATICA_REGISTRY=<module>:<ATTR>

This package bundles ONE example profile, generated from Laborde's tenant.
It will not match your entities unless you are Laborde:
  --registry easier_acumatica.profiles.laborde.registry:REGISTRY
```

The reason is that none of what these commands print is generic. An accessor's name,
its wire entity, its key fields and the vocabulary you may filter on all come from
pydantic models generated against **one tenant's** OpenAPI schema. This package does
bundle a profile, but it is Laborde's — an example of the shape, not a default. Applying
it silently would mean `describe` confidently listing fields your tenant does not have,
so it is only ever used when you name it:

```bash
# opt into the bundled example explicitly
easier-acumatica entities --registry easier_acumatica.profiles.laborde.registry:REGISTRY

# or set it once for the shell, instead of repeating the flag
export ACUMATICA_REGISTRY=myco.acu_profile:REGISTRY
easier-acumatica entities
```

The `:<ATTR>` half is required too — a profile package does not necessarily re-export
its registry, and the bundled one does not. See
[Bring your own tenant](#bring-your-own-tenant) for generating your own.

### Credentials come from the environment, only

The commands that reach a tenant read `ACUMATICA_URL`, `ACUMATICA_USERNAME`,
`ACUMATICA_PASSWORD` and `ACUMATICA_TENANT` from the environment — the same variables
`Acumatica.from_env()` uses, documented in the [Quickstart](#quickstart) table.

There is deliberately **no `--password`, `--username`, `--url` or `--tenant` flag, and
there will not be one.** Anything passed in argv reaches shell history, anyone running
`ps`, and the log of every CI job that echoes its commands. A missing variable is
reported as a configuration error (exit 3) that names every missing key at once and
points you at `doctor`:

```console
$ easier-acumatica get sales_orders SO 000123 \
    --registry easier_acumatica.profiles.laborde.registry:REGISTRY
easier-acumatica: Missing required Acumatica configuration: ACUMATICA_URL (or ACUMATICA_SITE_URL), ACUMATICA_USERNAME, ACUMATICA_PASSWORD, ACUMATICA_TENANT
  run `easier-acumatica doctor` to see which variables are set.
```

The registry is resolved *before* any credential is read, so a typo in `--registry`
costs you a usage error rather than a login.

### Compose a request without sending it

`query --dry-run` runs the whole composition path — predicate parsing, type coercion,
field-name validation, quirk detection, the `$top` clamp — against a recording stand-in
instead of a socket. No request, no login, no credential is read:

```console
$ easier-acumatica query sales_orders \
    --registry easier_acumatica.profiles.laborde.registry:REGISTRY \
    -w status=Open -w date__ge=2026-01-01 \
    --select order_nbr,status,order_total --order-by 'date desc' --limit 5 --dry-run
GET  SalesOrder
  endpoint  LabordeCustom
  $filter   (Status eq 'Open') and (Date ge datetimeoffset'2026-01-01T00:00:00Z')
  $select   OrderNbr,Status,OrderTotal
  $orderby  Date desc
  $top      5

(no request sent; no login performed; no credentials read)
```

The remaining examples set the registry once rather than repeating the flag:

```bash
export ACUMATICA_REGISTRY=easier_acumatica.profiles.laborde.registry:REGISTRY
```

Because the real builder runs, a bad field name is caught here rather than by the
server, and the error lists what the entity does have (trimmed below — it prints all 73):

```console
$ easier-acumatica query sales_orders -w order_date__ge=2026-01-01 --dry-run
easier-acumatica: SalesOrder has no field 'order_date'; known fields: approved,
base_currency_id, ..., date, ..., order_nbr, order_total, order_type, ...
```

A declared tenant quirk shows up here too — a predicate the server accepts and then
ignores is dropped from `$filter` and re-applied to the fetched page instead, and the
note says so before you run it for real:

```console
$ easier-acumatica query activities -w date=2026-01-01 --limit 5 --dry-run
GET  Activity
  endpoint  Default
  $top      5

note: [date eq] is a known tenant quirk — the server accepts the predicate,
      ignores it, and returns unfiltered rows that look filtered. It is
      therefore dropped from $filter and applied client-side, over the
      5 fetched row(s) ONLY. A genuine match further down the
      result set will be missed. Widen with --limit.

(no request sent; no login performed; no credentials read)
```

### Reading a tenant

With the environment set, four subcommands make real requests. All four read; none
writes.

| Command | What it does |
| --- | --- |
| `ping` | Logs in and logs out — the cheapest proof that this shell's credentials work. `--probe-endpoints` additionally issues one `$top=1` read per *distinct* endpoint the registry names, which catches "this tenant does not publish endpoint X" in one command instead of as a subset of accessors failing later. |
| `get <accessor> <KEY>...` | Fetches exactly one record by natural key. Keys are positional, in the order `describe` prints them. It takes no query parameters at all, so it cannot `$select` or `$expand` — use `query -w <key>=<value> --limit 1` when you need those. |
| `query <accessor>` | Reads a collection. `-w/--where` is repeatable and AND-joined, with `__ge`/`__gt`/`__le`/`__lt`/`__ne`/`__contains`/`__startswith`/`__endswith` suffixes; `--filter-raw` passes an OData fragment through verbatim for OR, grouping and null comparisons; `-s/--select`, `-e/--expand`, `--order-by` and `-n/--limit` do what they look like. |
| `raw GET <ENTITY-PATH>` | Bypasses the typed layer and GETs a path as typed, with repeatable `--param 'KEY=VALUE'`. `GET` is the only accepted method. `-o json` keeps Acumatica's `{"value": ...}` envelopes intact, which is the point of the command. |

Three flags are shared by `get`, `query` and `raw`: `-o table|json|jsonl` (table is the
default, and is byte-identical whether or not stdout is a terminal), `--wire-names` to
emit Acumatica's PascalCase aliases instead of the python names, and `--endpoint
NAME[@VERSION]` to read through an endpoint other than the one the binding declares.

`query` issues **exactly one request**. This client never emits `$skip`, so a result is
one page and not a complete result set; `--limit` is capped, and asking for more is
refused rather than silently truncated by the server. The footer repeats this on every
run.

### Exit codes

Failures are classified, not lumped into 1, so a script can branch on *why* something
failed without parsing stderr:

| Code | Meaning |
| --- | --- |
| `0` | Success. An empty result is still success — absence is an answer |
| `1` | Unexpected failure (last resort) |
| `2` | Usage, or a `--registry` problem |
| `3` | Configuration error — a missing or malformed `ACUMATICA_*` variable |
| `4` | Authentication failure |
| `5` | Not found |
| `6` | Refused by the server — validation or a business rule |
| `7` | Rate limited |
| `8` | Server or transport error |
| `9` | Concurrency conflict |

`2` is usage rather than configuration because `argparse` hardcodes exit 2 for its own
parse errors; reusing it would make the two indistinguishable. The offline subcommands
can only ever return `0`, `1` or `2`.

```bash
if ! easier-acumatica ping; then
  case $? in
    3) echo "check the ACUMATICA_* variables" ;;
    4) echo "credentials rejected" ;;
    8) echo "tenant unreachable — retry later" ;;
  esac
fi
```

### Tracing, without leaking

`-v` traces each request: method, path, query parameter **keys**, status and duration.
The keys only — never their values, which carry customer names and order numbers. `-vv`
adds allowlisted response headers and up to 500 characters of an *error* body. No
response body is ever printed at any verbosity, and the resolved password is registered
with a redactor that scrubs it from anything on its way to stderr.

## The safety model

Convenience features are easy to add; the reason this library exists is what it
*refuses* to do on your behalf.

> **Warning** — **No write is ever automatically retried or replayed.** Not on a 5xx,
> not on a timeout, not on a 401. There is no constructor flag to turn write-retries on.
> Redelivery of a business operation belongs to your queue, where it can be made
> idempotent — not to a transport that cannot know whether the first attempt landed.

The request lifecycle forks on the HTTP method. A **GET** waits for a rate-limit token,
goes out, and — if the tenant answers `401` because the session went idle — triggers one
`auth/login`, then one replay of the same request with the fresh cookie; the caller sees
only the rows. A **PUT** that gets the same `401` is handed back as an `Auth` error
immediately: no re-login, no replay, no second chance for a write to land twice.

![Sequence diagram of the 401 fork, with three participants: your code, the transport, and Acumatica. A GET waits for a rate-limit token, goes out, comes back 401 for an idle session, triggers one POST to auth/login that returns 204 and a fresh cookie, is replayed once, returns 200, and hands rows back to the caller. Below it, a PUT goes out, comes back 401, and is handed straight back as an Auth error, because writes are never replayed.](https://raw.githubusercontent.com/ponderrr/easier-acumatica/main/docs/assets/request-lifecycle.svg)

Sequence diagrams for both, plus the single-flight re-login under concurrency →
[`docs/architecture.md`](https://github.com/ponderrr/easier-acumatica/blob/main/docs/architecture.md)

Three guards stand between your code and a damaging write:

| Guard | What it prevents | Where |
| --- | --- | --- |
| **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 |
| **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` |
| **Warehouse allow-list** | Acumatica silently replacing an unknown warehouse with the branch default | `src/easier_acumatica/profiles/laborde/branches.py` |

## Bring your own tenant

The core is tenant-agnostic. Everything that is true about *one* Acumatica instance —
generated models, the entity registry, field-alias overrides, known quirks, append
strategies, owner maps — lives in a **profile** package. The repository ships one
complete profile as a worked example (see below), and the codegen pipeline that
produced it is the same one you would run against your own tenant:

The pipeline runs in one direction — live tenant → committed `schema/*.json` snapshots →
wrapper-collapse pre-pass → `datamodel-code-generator` → alias and writability stamping →
committed models and registry — and then closes the loop: the `--check` drift gate
re-runs the whole chain against the committed snapshot and fails if the output moved.
Only the very first edge ever touches a tenant, and only when a human runs it.

![Pipeline diagram. A one-time authenticated fetch from a live tenant produces committed schema snapshots under schema/*.json. Those flow down through a wrapper-collapse pre-pass, then datamodel-code-generator, then alias and writability stamping, into committed models and a registry. A drift gate labelled --check loops from that output back to the snapshots.](https://raw.githubusercontent.com/ponderrr/easier-acumatica/main/docs/assets/codegen-pipeline.svg)

Each stage is broken down module by module in
[`docs/architecture.md`](https://github.com/ponderrr/easier-acumatica/blob/main/docs/architecture.md).

1. **Fetch your schemas once** — `python -m codegen.sync_schema` logs in with the same
   `ACUMATICA_*` env vars, downloads each endpoint's `swagger.json`, validates it, and
   writes it under `schema/`. Commit the result; it is the source of truth from here on.
2. **Declare what you need** — an entity allow-list (`codegen/entity_allowlist.py`),
   composite-key declarations (`codegen/key_overrides.py`), and any field-alias
   overrides for names that vary across entities (the profile's `field_aliases.py`).
3. **Generate** — `python -m codegen.gen_laborde` (the shipped reference driver) slices
   each allow-listed entity plus its transitively-referenced sub-schemas out of the
   snapshot, collapses the `{"value": ...}` wrapper schemas so the generator emits
   `str | None` instead of wrapper classes, runs `datamodel-code-generator`, then stamps
   read aliases, write aliases, writability flags, and key tuples onto the output.
4. **Keep it honest** — `python -m codegen.gen_laborde --check` regenerates into a
   temporary directory and diffs against what is committed; CI fails on drift.

## The reference profile

The repository ships a complete, production-derived profile for one real tenant under
`src/easier_acumatica/profiles/laborde/`: an entity registry covering 9 entities across
two endpoints, per-entity detail-append strategies, an owner-resolution write guard, an
idempotent-upsert scheme, and a registry of verified server quirks. It is presented as
**a complete reference profile** — read it to learn the pattern, copy its shape (not its
data) for your own tenant.

Details →
[`docs/laborde-profile.md`](https://github.com/ponderrr/easier-acumatica/blob/main/docs/laborde-profile.md)

## Testing

The whole suite runs **offline** — 900+ tests against a respx-mocked transport plus a
recorded-fixture format (one JSON file per HTTP exchange, or an ordered exchange array
for sequences like the 401-replay cycle). No tenant, no credentials, no network:

```bash
pip install -e . pytest respx datamodel-code-generator==0.82.0
pytest -q
python -m codegen.gen_laborde --check   # codegen drift gate
```

For verifying behavior against a *real* tenant there is a second, separate tool: the
**capture CLI**, `python -m codegen.capture_h2`. It lives under `codegen/`, which
`pip install easier-acumatica` does not install — the wheel ships `easier_acumatica`
only, so the capture CLI is runnable from a clone of this repository and nowhere else.
It is unrelated to the `easier-acumatica` command described above, and is a development
instrument for recording how a tenant actually behaves rather than something an
integration ever calls.

By default it runs **read-only probes** (endpoint
discovery, filter-acceptance checks, datetime-literal variants, the silent filter-drop,
the 500-as-not-found marker). **Gated write probes** require both an explicit
`--enable-writes` flag *and* per-target arguments naming the exact record to touch —
targets alone are refused. Every exchange is recorded through a scrubbing recorder that
strips cookies, auth headers, and sensitive params, refuses to write anything matching a
secret denylist, and never records request bodies unless explicitly asked (the login
body carries a password).

> **Note** — The recorded fixtures committed under `tests/fixtures/recorded/` are
> currently
> placeholders shaped like real exchanges; the recorder's scrubbing runs before anything
> is ever written to disk. Some documented behaviors (the 401 replay, the datetime
> filter literal, the silent filter-drop) have been verified against a live tenant;
> others are grounded in recorded evidence from production integrations. Where a doc
> makes that distinction, it says so in plain words.

## Project layout

```
easier-acumatica/
├── src/easier_acumatica/
│   ├── client.py          # config, login lifecycle, request seam, accessors
│   ├── transport.py       # token-bucket rate limit + method-aware 401 handling
│   ├── envelope.py        # AcumaticaModel base + to_put_body partial-PUT serializer
│   ├── odata.py           # kwargs predicates, wire literals, the Raw escape hatch
│   ├── query.py           # EntitySet: the immutable, chainable query builder
│   ├── verbs.py           # get / get_or_none / get_list / put / delete
│   ├── pagination.py      # honest single-page $top clamping
│   ├── exceptions.py      # one exception hierarchy + the response classifier
│   ├── registry.py        # EntityBinding + the client/profile seams
│   ├── types.py           # Line — the uniform append-input DTO
│   ├── cli/               # the installed `easier-acumatica` command (read-only)
│   └── profiles/laborde/  # the shipped reference tenant profile
│       └── models/        # generated pydantic models (committed)
├── codegen/               # clone-only, never installed: schema fetch, preprocess,
│                          #   generation, and the capture_h2 probe tool
├── schema/                # committed OpenAPI snapshots — codegen's source of truth
├── tests/                 # fully offline suite (respx + recorded fixtures)
└── .github/workflows/     # CI: offline tests + codegen drift gate
```

## Design decisions

<details>
<summary><b>Why kwargs predicates instead of operator overloading?</b></summary>

Some client libraries build filters as `F.Status == "Open"`. Overloading `__eq__`
defeats type checking — a type checker cannot verify the operands of `==`, and the
expression's type is a filter object no matter what you compare. Kwargs with operator
suffixes (`status="Open"`, `date__ge=since`) keep field names checkable against the
model at call time: a typo raises immediately, listing the model's known fields, before
any HTTP request is built.
</details>

<details>
<summary><b>Why sync-first on httpx?</b></summary>

Every consumer this library was extracted from is synchronous at the Acumatica boundary,
and the failure modes that matter (rate limits, session expiry, duplicate writes) are
easier to reason about on one code path. httpx keeps the door open: the transport layer
is a thin `httpx.BaseTransport` wrapper, and an async variant can follow the same design
without rewriting the model or query layers.
</details>

<details>
<summary><b>Why single-page pagination only?</b></summary>

Acumatica ignores `$skip` server-side. A paginator built on `$skip` *looks* like it
works and silently returns page one forever. Rather than fake deep pagination, the
query builder never emits `$skip`, and `.limit(n)` clamps `$top` to 1–100 — one page,
honestly. Real keyset pagination is a possible follow-up, not a hidden half-feature.
</details>

<details>
<summary><b>Why are models committed rather than generated at runtime?</b></summary>

Runtime schema introspection means your integration's behavior depends on whatever the
tenant's schema says *today*, and a schema change reaches production without review.
Committed snapshots plus committed generated models make every schema change a visible
diff, and the `--check` drift gate makes CI fail when the committed output no longer
matches what the pipeline would produce.
</details>

## Limitations

> **Warning** — Datetime filter literals are rendered as `datetimeoffset'<utc-iso>Z'`.
> This form was
> verified by a live probe against the standard endpoint's SalesOrder entity (the other
> candidate forms were rejected with HTTP 500); custom endpoints are assumed to accept
> the same dialect but were not probed.

- `.limit(n)` is single-page only (`$top`, clamped to 1–100). There is no deep
  pagination — see the design note above.
- `__contains` renders the OData v3 form `substringof('<v>',Field)`. The endpoint reads
  as v3-flavoured (quoted datetime literals are accepted, bare ones rejected), but
  `substringof` itself has not been live-probed.
- `invoke_action` and file attachment are not built yet — no consumer has needed them.
- The committed recorded fixtures are placeholders shaped like real exchanges, pending
  a capture pass against a live tenant.

## Documentation

| Document | What it covers |
| --- | --- |
| [`docs/architecture.md`](https://github.com/ponderrr/easier-acumatica/blob/main/docs/architecture.md) | How the machine works: transport, envelope, queries, verbs, errors, codegen, testing |
| [`docs/laborde-profile.md`](https://github.com/ponderrr/easier-acumatica/blob/main/docs/laborde-profile.md) | The shipped reference profile — a worked example of encoding one tenant |

## Contributing

- The test suite must pass **offline**: `pytest -q` with no tenant configured.
- Regenerated models must be drift-free: `python -m codegen.gen_laborde --check`.
- Never import the third-party `easy-acumatica` library from runtime code — it is a
  build-time reference only, and a lint test enforces this.

## License

Released under the [MIT License](https://github.com/ponderrr/easier-acumatica/blob/main/LICENSE).

## Acknowledgments

The third-party [`easy-acumatica`](https://github.com/Nioron07/Easy-Acumatica) library
(MIT, by Nioron07) served as a build-time schema and codegen reference for this design.
It is never imported at runtime.

---

<div align="center">
<i>Built to make Acumatica integrations boring.</i>
</div>
