Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Integrify — project guide

Integrify is a family of Python libraries that wrap Azerbaijani service APIs
(payments, SMS, POS, customs) behind one small, consistent client abstraction.
This repo is a **uv workspace monorepo**: every integration is its own
distribution under `packages/*`, published independently to PyPI, all sharing
the `integrify` PEP 420 namespace.

## Layout

```
pyproject.toml # umbrella `integrify` dist + workspace + tool config (ruff, ty, pytest, coverage)
justfile # every dev command lives here
scripts/run_tests.py # runs each package's suite in its own pytest process
docs/{az,en}/ # mkdocs (zensical) sites; ALL integration docs live here, not in packages/*
packages/core/ # integrify-core: APIClient, APIPayloadHandler, APIExecutor, schemas
packages/<integration>/ # one distribution per integration
```

Each integration package looks like this — **add every one of these files when
creating a new integration**:

```
packages/<name>/
├── CHANGELOG.md # Keep a Changelog format
├── CITATION.cff
├── LICENSE # copy from a sibling package
├── README.md # badges, request table, examples, AUTO-UPDATE SECTION table at the end
├── py.typed
├── pyproject.toml # name = "integrify-<name>", depends on integrify-core
├── src/integrify/<name>/
│ ├── __init__.py # docstring with the official doc link; exports Client, AsyncClient, ClientClass, VERSION
│ ├── client.py # <Name>ClientClass + module-level <Name>Client / <Name>AsyncClient singletons
│ ├── env.py # VERSION, env vars, `class API(str, Enum)` of endpoints, __all__
│ ├── handlers.py # one APIPayloadHandler subclass per endpoint
│ ├── py.typed
│ └── schemas/{__init__.py,request.py,response.py,enums.py,utils.py}
└── tests/{__init__.py,conftest.py,mocks.py,test_*.py}
```

Documentation pages do **not** live inside the package. They go under the root
docs site:

```
docs/az/docs/integrations/<name>/
├── index.md # official-doc link, request table, flow, notes
├── env.md # env-var table + .env template
└── api-reference/ # mkdocstrings stubs: client.md, request.md, response.md, enums.md
```

Then wire it up in three places:

1. `pyproject.toml` — `[project.optional-dependencies]` extra, the `all` list, and `[tool.uv.sources]`.
2. `docs/az/mkdocs.yml` — the mkdocstrings `paths` list **and** the `nav` tree.
3. `docs/az/partial.yml` — the matching nav fragment.

(English pages, where they exist, mirror this under `docs/en/`.)

## Commands

Always go through `just` (it wraps `uv run --no-sync`):

```
just setup # uv sync --all-packages + install pre-commit hooks
just format # ruff check --fix-only + ruff format
just lint # ruff check + ruff format --check
just type-check # ty check packages
just test # scripts/run_tests.py (per-package pytest processes, with coverage)
just coverage # combine + report + html
just docs # zensical build -f docs/az/mkdocs.yml --strict
just secure # bandit
just all # format + lint + test + docs
```

Tests must run per-package: every package ships a top-level `tests` package, so
collecting them under one root causes `ImportPathMismatchError`. Use
`just test` or `pytest packages/<name>/tests` from inside that package.

Coverage gate is `fail_under = 95`.

## The client abstraction (packages/core)

- `APIClient` holds a `base_url`, a `urls` map and a `handlers` map. Endpoints are
registered in `__init__` with `add_url(route_name, url, verb)` +
`add_handler(route_name, HandlerClass)`. There are **no explicit request methods
at runtime** — `APIClient.__getattr__` looks the route name up and builds the
call. Public typing/docs come from `@overload` stubs inside `if TYPE_CHECKING:`.
- `APIPayloadHandler` owns one endpoint's payload. Declare `req_model`,
`resp_model` (and `dry`) as **class attributes**; override
`pre_handle_payload` / `handle_payload` / `post_handle_payload` / `headers` /
`req_args` / `handle_response` as needed. Handlers are **stateless** — never
stash per-request state on `self`, they are shared across concurrent calls.
- `PayloadBaseModel.from_args` maps positional args onto model fields **in field
declaration order**, so a client method's parameter order must match its
request schema's field order.
- `URL_PARAM_FIELDS` (a `ClassVar[set[str]]` on the request schema) marks fields
that are interpolated into the URL instead of the body; the endpoint string in
`env.API` uses those exact snake_case names as `{placeholders}`.
- `APIResponse[T]` wraps the httpx response: `.ok`, `.status_code`, `.headers`,
`.body` (validated as `T`). `T` may be a `BaseModel`, a `dict`, or a `list[...]`.
- `dry=True` on a client returns a `DryResponse` dict (`url`, `verb`, `headers`,
`data`, `request_args`) instead of sending — the cheapest way to unit-test
payload construction.
- Every integration exposes both a sync and an async singleton
(`XClient` / `XAsyncClient`), typed via `Generic[_Mode]` with `_Sync`/`_Async`
markers and paired `@overload`s so both share one docstring.
- `_build_request_lambda` is the subclass hook for injecting per-request headers
(see `clopos` for auth tokens).

## Conventions

- **Language**: docstrings, comments and docs are written in **Azerbaijani**;
CHANGELOG entries and this file are in English. Keep it that way.
- **Style**: ruff, line length 100, **single quotes**, target py310. `just format`
before committing; pre-commit enforces it.
- Every public class, function and pydantic field gets a docstring — the docs
site is generated from them via mkdocstrings + griffe-pydantic.
- Client method docstrings follow a fixed shape: one-line summary, `**Endpoint:**`,
an `Example:` block, `**Cavab formatı**:` with a mkdocstrings cross-reference,
a prose note on the flow, then `Args:`.
- **Ordering**: endpoints and everything derived from them — the `env.API` enum,
`add_url`/`add_handler` calls, the `@overload` stubs, handler classes, request
schemas, the docs/README request tables, the `members:` list and the
api-reference stubs — follow the order the endpoints appear in the **official
documentation**, never alphabetically. Where the upstream spec lists its schemas
alphabetically, ignore that ordering and place each schema by the endpoint that
uses it (nested objects just before the model that references them).
- Schemas: use an alias generator (`to_camel` / `to_pascal`) in
`schemas/utils.py:BaseSchema` when the API is internally consistent; use
**explicit per-field aliases** when it is not.
- Response schemas should keep enum-ish fields as `str` and expose the enum
separately for reference, so a newly added upstream value can't break validation.
- Optional arguments use the `UNSET` sentinel (`Unsettable[T] = _UNSET`), not
`None`, so "not passed" is distinguishable from "explicitly null".
- Env vars are read once in `env.py`, warn (don't raise) when missing, and are
documented in `docs/.../env.md` as a table plus a `.env` template.

## Releasing

Publishing is tag-driven: `<package>-<version>` (e.g. `epoint-1.3.0`,
`core-1.2.1`, `integrify-3.0.0` for the umbrella). The workflow verifies the tag
is an ancestor of `main` and that the version matches that package's
`pyproject.toml`. So: bump `version` in the package's `pyproject.toml`, add a
CHANGELOG entry with its compare link, then tag.

Changing `packages/core` means a core release plus a dependency floor bump in
any package that relies on the new behaviour.

## Private integrations

Not every integration lives here. `integrify-ecustoms` (State Customs Committee,
Carriers V4) is maintained in a **private** sibling repository,
`integrify-sdk/integrify-ecustoms`, because that API is provided under a carrier
contract. It contributes `integrify.ecustoms` to the same PEP 420 namespace and
follows these same conventions, but is installed from git rather than PyPI.

This repo keeps only a stub page at
`docs/az/docs/integrations/ecustoms/index.md`. Never add mkdocstrings (`:::`)
directives for it here — that would render the exact API surface the private repo
exists to protect. For the same reason `packages/ecustoms/` must not reappear
under `packages/*`.
Loading
Loading