Metadata-Version: 2.5
Name: easier-acumatica
Version: 0.1.0
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) · [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** — 700+ tests on a mocked transport and recorded-fixture files; no tenant needed to develop or run CI
- 🛰️ **Live capture CLI** — read-only probes of real server behavior by default; write probes only behind explicit opt-in flags

## How it works

```mermaid
flowchart TD
    A["Your code"] --> B["Typed entity accessors<br/>acu.sales_orders, acu.contacts, ..."]
    B --> C["Query builder<br/>filters, select, expand, top"]
    B --> D["Envelope-free pydantic models"]
    C --> E["One pooled httpx client<br/>rate limited, 401-aware"]
    D --> E
    E --> F["Acumatica REST API"]
    subgraph BT["Build time"]
        G["Committed OpenAPI snapshots"] --> H["Codegen pipeline"]
        H --> I["Generated models + entity registry"]
    end
    I -.-> B
```

Your code talks to typed entity accessors (`acu.sales_orders`, `acu.contacts`, ...);
accessors compile queries and writes through one pooled, rate-limited client; and the
models plus the entity registry are generated at build time from committed OpenAPI schema
snapshots — nothing is introspected at runtime.

Full tour → [`docs/architecture.md`](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).

## 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, including the 401 fork:

```mermaid
sequenceDiagram
    participant App as Your code
    participant T as Transport
    participant S as Acumatica

    App->>T: GET SalesOrder
    T->>T: wait for a rate-limit token
    T->>S: send GET
    S-->>T: 401 idle session
    T->>S: POST auth/login
    S-->>T: 204 + fresh cookie
    T->>S: replay the same GET once
    S-->>T: 200
    T-->>App: rows

    App->>T: PUT SalesOrder
    T->>S: send PUT
    S-->>T: 401
    T-->>App: Auth error — writes are never replayed
```

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:

```mermaid
flowchart LR
    A["Live tenant"] -- "one-time authenticated fetch" --> B["Committed schema snapshots<br/>schema/*.json"]
    B --> C["Wrapper-collapse pre-pass"]
    C --> D["datamodel-code-generator"]
    D --> E["Alias + writability stamping"]
    E --> F["Committed models + registry"]
    F -- "drift gate: --check" --> B
```

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`](docs/laborde-profile.md)

## Testing

The whole suite runs **offline** — 700+ 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 capture CLI
(`python -m codegen.capture_h2`): 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
│   └── profiles/laborde/  # the shipped reference tenant profile
│       └── models/        # generated pydantic models (committed)
├── codegen/               # build-time only: schema fetch, preprocess, generation, capture CLI
├── 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`](docs/architecture.md) | How the machine works: transport, envelope, queries, verbs, errors, codegen, testing |
| [`docs/laborde-profile.md`](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

No license has been chosen for this repository yet.

## 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>
