Metadata-Version: 2.5
Name: iis-access
Version: 0.3.0
Summary: Python auth library for IIS tools ecosystem
Project-URL: Homepage, https://iis.tools
Author-email: IIS Labs <dev@iis.tools>
License-Expression: MIT
Keywords: api-key,auth,iis,provider-resolution
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.9
Requires-Dist: pyjwt[crypto]>=2.8
Provides-Extra: dev
Requires-Dist: aioresponses-ng>=0.8.1; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: keyring
Requires-Dist: keyring>=25.0; extra == 'keyring'
Description-Content-Type: text/markdown

# iis-access

Python auth library for IIS tools ecosystem. Provides optional authentication, provider resolution, managed key retrieval, and usage reporting for Python-based IIS CLI tools (aria, sonar, iris).

## Installation

```bash
pip install iis-access
```

## Quick Start

```python
from iis_access import resolve_provider, report_usage

# Resolve the best available provider for a task
resolution = await resolve_provider("tts", preferred="elevenlabs")
print(resolution.provider, resolution.method)

# Report usage (fire-and-forget, never raises)
await report_usage("aria", credits=1.5)
```

`report_usage` sends the logged-in user's access token. The account service accepts it only for
that user's own account and only for local-runtime capabilities (`runtimeTag` `local` or
`local_gpu` in the capability registry, e.g. `aria`, `sonar`, `iris`); any other service is refused, and a refused report is logged as a
WARNING on the `iis_access` logger (account#135).

## Quota and credit decisions (0.3.0)

Turn an account answer into one of eight decisions, then into a status, headers, an RFC 9457 body, a
CLI line, an exit code or an MCP tool error. The contract is `docs/consumers/quotas-and-credits.md`;
its fixture `docs/consumers/fixtures/access-decisions.json` is run against this package and against
`@iistools/access`.

```python
from iis_access import (
    classify_access, status_for, retry_after_seconds, quota_headers, denial_body,
    cli_message, exit_code_for, mcp_error,
)

body = ...  # the JSON of POST /access/check, as a dict
decision = classify_access(body)  # ok | quota_exceeded | not_entitled | account_suspended | ...
if decision.decision != "ok":
    print(cli_message(decision, "de", service="aria"), file=sys.stderr)
    sys.exit(exit_code_for(decision))
```

| Helper | Returns |
|---|---|
| `classify_access(body_or_exc, now=None)` | `AccessDecision`: `decision`, `retryable`, `binding` (the window headers and messages speak about), `windows`, `exceeded`, `violated_policies`, `credits`, `credits_low`, `reason` |
| `status_for(decision)` | HTTP status: ok 200, quota_exceeded 429, insufficient_credits 402, not_entitled / account_suspended / account_unavailable 403, session_revoked 401, unreachable 503 |
| `retry_after_seconds(decision, now=None)` | `quota_exceeded`: seconds to the reset, at least 1; `unreachable`: 30; else `None` |
| `quota_headers(decision, header_style='both', *, now=None)` | `X-Quota-*`, `X-Credits-Balance`, `Retry-After` (429 and 503), and the IETF `RateLimit-Policy` (gating windows only) / `RateLimit` pair with `q` and `r` floored (a draft that expires 2026-11-24; no binding, no pair; `'legacy'` drops it, `'ietf'` drops `X-Quota-*`) |
| `denial_body(decision, locale='en', *, now=None)` | `{status, headers, body}`; `body` is an RFC 9457 problem, `title` / `detail` in `en` or `de`; `retryAfter` is the reset (quota) or `now` + 30 s (unreachable), else `None` |
| `cli_message(decision, locale='en', *, service=None, tz=None)` | one stderr line in `en` or `de`; the reset time is local unless `tz` is given |
| `exit_code_for(decision)` | ok 0, quota_exceeded 4, unreachable 5, not_entitled / account_suspended / session_revoked / account_unavailable 3, insufficient_credits 1 |
| `mcp_error(decision, locale='en', *, service=None)` | `{isError: True, content: [...], structuredContent: {code, retryable, quota?, credits?}}`; `ok` raises `ValueError` |

Rules worth knowing: classification is by `reason`, never by the deprecated `suspended`; an unknown
`allowed: false` reason is `not_entitled` with the raw `reason` kept; a pre-v2 body (no `windows`) is
classified from its `quota` block; a soft window never binds. `insufficient_credits` exits 1 because
the IIS CLI contract has no "payment required" code: a script keys on `code: INSUFFICIENT_CREDITS`.
Call `quota_headers` / `denial_body` when the response is written (the reset countdown is computed
from the clock then) and send the headers with `Vary: Authorization`: they describe one caller.

### Charging credits

```python
from iis_access import deduct_credits, refund_credits, InsufficientCredits, CreditsRequestError

result = await deduct_credits(
    service_key=KEY, account_id=account_id, service="aria", operation="tts",
    amount=1.5, idempotency_key=job_id,
)
await refund_credits(
    service_key=KEY, account_id=account_id, service="aria", operation="tts",
    amount=0.5, related_transaction_id=result.transaction_id, idempotency_key=f"{job_id}-refund",
)
```

**The key must be a service key bound to `service` (or holding `service:any`) and holding the
`credits:write` scope**; the balance is account-global, so the binding alone is not enough. Mint the
scope with `iis-account keys tag --add-scope credits:write`. `amount` is required and positive on both
calls (a replay with the same `idempotency_key` answers `duplicate=True`).

Money calls fail loudly; nothing is swallowed (`report_usage` is the fire-and-forget one):

| account answers | raises | `classify_access` |
|---|---|---|
| 402 | `InsufficientCredits(balance_after)` | `insufficient_credits` (exit 1, `credits_low`) |
| 429, 5xx, timeout, network failure, unparseable 200 | `CreditsUnreachable` | `unreachable` (`retryable`, 503, `Retry-After: 30`) |
| 400, 401, 403 (key not bound or no `credits:write`), 404 | `CreditsRequestError(status, code)` | re-raised: the consumer's key or request is wrong, so it is no decision. Answer it with your generic error path (a CLI exits 1, an MCP tool reports its generic tool error) |

`report_usage` is unchanged.

## Development

Use [uv](https://docs.astral.sh/uv/) — do not `pip install` into an ad-hoc venv, it drifts out
of sync with `pyproject.toml` (missing deps like PyJWT after account#53 added it).

```bash
cd packages/access-py
uv sync --locked --extra dev   # creates .venv/, installs the package + dev deps from uv.lock
PYTHONPATH=src uv run pytest tests/ -v
```

`uv sync --locked` reads `uv.lock` (committed) for reproducible resolution and fails if it is stale
— the same command CI runs (`.github/workflows/test-access-py.yml`).

CI pins uv 0.10.10 in `.github/workflows/test-access-py.yml`; bump it when re-locking with a newer uv.

**aioresponses fork (account#69):** the dev extra pins `aioresponses-ng`, not upstream
`aioresponses`. Upstream 0.7.9 (the latest release) does not intercept aiohttp>=3.14 —
`aiohttp.ClientResponse.__init__()` gained a required `stream_writer` keyword that upstream
doesn't pass, so every `aioresponses()`-mocked test fails with
`TypeError: ClientResponse.__init__() missing 1 required keyword-only argument: 'stream_writer'`
instead of intercepting the request. The fix exists as an unmerged upstream PR
([pnuckowski/aioresponses#288](https://github.com/pnuckowski/aioresponses/pull/288));
`aioresponses-ng` is a maintained fork that ships it. It installs under the same `aioresponses`
import path, so no test code changes are needed — only the dependency in `pyproject.toml`. If
upstream ever merges and releases the fix, this pin can be dropped back to `aioresponses`.

## Release

No workflow publishes `iis-access` to PyPI; publishing is a manual step. The `Test access-py`
workflow (`.github/workflows/test-access-py.yml`, job `test`) is otherwise informational: `develop`
is unprotected and no job `needs:` it, so a red run blocks nothing by itself (account#139). The
release gate is therefore this check, made by whoever publishes:

**The release SHA must have a green `Test access-py` run.** Before building and uploading, run:

```bash
SHA=$(git rev-parse HEAD)   # the commit you are about to publish
gh run list --workflow test-access-py.yml --commit "$SHA" --json conclusion,status --jq '.[0]'
```

Proceed only if it prints `{"conclusion":"success","status":"completed"}`. Anything else (`failure`,
`in_progress`, `cancelled`, or no output) means do not publish.

**Empty output is not a pass.** The workflow runs only on changes under `packages/access-py/**` or
the workflow file, and a push triggers it for the tip commit only. A release SHA that did not touch
the package, or that sat in the middle of a multi-commit push, has no run of its own. Then gate on the
newest run whose commit is an ancestor of the release SHA, provided nothing under the package changed
between that commit and the release SHA:

```bash
RUN=$(gh run list --workflow test-access-py.yml --branch develop --json headSha,conclusion --jq '.[0]')   # use --branch main for a main release
RUN_SHA=$(jq -r .headSha <<<"$RUN")
git merge-base --is-ancestor "$RUN_SHA" "$SHA" \
  && git diff --quiet "$RUN_SHA" "$SHA" -- packages/access-py .github/workflows/test-access-py.yml \
  && jq -r .conclusion <<<"$RUN"   # must print: success
```

(If `$RUN_SHA` is not an ancestor, or the diff is non-empty, trigger a run by pushing the change
through a PR or branch push and use the first command on that SHA.)

**Build into a fresh directory and upload explicit files.** `packages/access-py/dist/` in the main
tree can hold artifacts of earlier releases (`iis_access-0.1.1`, `iistools_access-0.1.1`). PyPI
filenames can never be reused, so a glob upload would re-send them and fail or mislead.
**Never run `twine upload dist/*`.** From `packages/access-py`:

```bash
OUT=$(mktemp -d)
uv build --out-dir "$OUT"
twine check "$OUT"/iis_access-<version>.tar.gz "$OUT"/iis_access-<version>-py3-none-any.whl
twine upload "$OUT"/iis_access-<version>.tar.gz "$OUT"/iis_access-<version>-py3-none-any.whl
```

`<version>` is the `pyproject.toml` version (for example `0.2.1`); the directory must hold exactly
these two files. The sdist ships only `src/iis_access`, `README.md` and `pyproject.toml`, plus the `PKG-INFO` and `.gitignore` that hatchling always adds; `tar tzf "$OUT"/iis_access-<version>.tar.gz` after the build above lists exactly those. No test pins the sdist contents, because publishing is manual; if publishing ever moves to CI, add a pin test modelled on `packages/cli/tests/pack-contents.test.ts`.
