From a296af4007daf7cdc7744e39a8de817ec9bf493c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:15:25 +0000 Subject: [PATCH 1/3] Add weftsh: the Python SDK for Weft The Python counterpart of @weftsh/sdk, with the same surface and the same semantics, shaped after code.storage's pierre-storage: a client, a repository handle, keyword-only arguments, httpx underneath. - `Weft` (sync) and `AsyncWeft` (asyncio) with identical methods. Every method is one request, so the sync client is generated from the async one by scripts/unasync.py; a test fails when the committed copy is stale, so the two cannot drift. - Repositories, commits through a builder, reads with ETag caching, history and diffs, branches and tags, reset/revert, tokens, webhooks, export, mirrors, and `get_remote_url()` minting a repo-bound token. - `expected_parent` keeps the server's three cases apart with an UNSET sentinel: left out omits the key, `None` sends JSON null ("the branch must not exist yet"), a SHA must match. `update()` uses the same rule so `None` clears a field and a missing argument leaves it alone. - The 404 rule from the TypeScript SDK: a missing path or revision reads as None, a missing repository (a bare `not found`) raises. - `verify_webhook` with hmac.compare_digest. - Results are frozen dataclasses; mypy --strict clean; one dependency. Tested the same two ways as the TypeScript SDK. 36 unit tests pin the wire through httpx.MockTransport. 18 e2e tests run against a real Weft server (skipped unless WEFT_E2E_* are set; the client is built inside a fixture so a skipped run constructs nothing): real `git` clone, push and `fsck --full --strict` through get_remote_url, a read-only URL that cannot push, a scoped credential that cannot reach a sibling repo, a webhook delivery the server signed, and the async client end to end. Breaking the expected_parent mapping turns 9 tests red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYuyBuroUvxh32x9SCbx13 --- .github/workflows/ci.yml | 29 ++ .gitignore | 12 + CHANGELOG.md | 15 + LICENSE | 21 ++ README.md | 563 +++++++++++++++++++++++++++- examples/agent_session.py | 64 ++++ pyproject.toml | 61 ++++ scripts/unasync.py | 66 ++++ src/weftsh/__init__.py | 87 +++++ src/weftsh/_async_client.py | 708 ++++++++++++++++++++++++++++++++++++ src/weftsh/_errors.py | 49 +++ src/weftsh/_sync_client.py | 708 ++++++++++++++++++++++++++++++++++++ src/weftsh/_types.py | 372 +++++++++++++++++++ src/weftsh/_version.py | 1 + src/weftsh/_wire.py | 148 ++++++++ src/weftsh/py.typed | 0 src/weftsh/webhooks.py | 46 +++ tests/test_e2e.py | 364 ++++++++++++++++++ tests/test_unasync.py | 34 ++ tests/test_unit.py | 471 ++++++++++++++++++++++++ uv.lock | 568 +++++++++++++++++++++++++++++ 21 files changed, 4386 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 examples/agent_session.py create mode 100644 pyproject.toml create mode 100644 scripts/unasync.py create mode 100644 src/weftsh/__init__.py create mode 100644 src/weftsh/_async_client.py create mode 100644 src/weftsh/_errors.py create mode 100644 src/weftsh/_sync_client.py create mode 100644 src/weftsh/_types.py create mode 100644 src/weftsh/_version.py create mode 100644 src/weftsh/_wire.py create mode 100644 src/weftsh/py.typed create mode 100644 src/weftsh/webhooks.py create mode 100644 tests/test_e2e.py create mode 100644 tests/test_unasync.py create mode 100644 tests/test_unit.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1c7859 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python }} + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + - run: uv run pytest + - name: The built wheel installs and imports on its own + run: | + uv build + uv venv /tmp/wheel-check --python ${{ matrix.python }} + uv pip install --python /tmp/wheel-check dist/*.whl + /tmp/wheel-check/bin/python -c "from weftsh import Weft, AsyncWeft, verify_webhook; print('ok')" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98c1cbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +coverage/ +*.tgz +__pycache__/ +*.pyc +.venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +build/ +*.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..36fea23 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.1.0 + +First release. + +- `Weft` and `AsyncWeft`: create, find, list, iterate, batch-create and + batch-delete repositories; mirrors; tokens. +- `Repo` / `AsyncRepo`: commits through a builder + (`create_commit().put().delete().send()`) with optimistic concurrency and + an audit `context`; file, tree, history and diff reads with ETag caching; + branches and tags; `reset` and `revert`; repository-scoped git remote URLs; + webhooks; bundle export. +- `verify_webhook` for `X-Weft-Signature-256` deliveries. +- `WeftError` and `WeftConflictError` (with `current_tip`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36acfc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Weft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ce0e362..445d405 100644 --- a/README.md +++ b/README.md @@ -1 +1,562 @@ -# python-SDK \ No newline at end of file +# weftsh + +The Python SDK for [Weft](https://weft.sh): a real git repository per user, +session or agent, created in under 100 ms and written entirely over HTTP. + +```python +import os +from weftsh import Weft + +weft = Weft(token=os.environ["WEFT_TOKEN"], org="acme") + +repo = weft.create_repo() + +repo.create_commit(message="agent step 1").put("src/app.py", "print('hello')\n").put( + "README.md", "# session\n" +).send() + +print(repo.read_file("src/app.py")) +print(repo.get_remote_url()) # https://x:weft_…@api.weft.sh/acme/repo-….git +``` + +No checkout, no clone, no disk. Every repository is still a stock git remote +you can clone, push to and export. + +- A sync client, `Weft`, and an identical `asyncio` one, `AsyncWeft`. +- One dependency: [`httpx`](https://www.python-httpx.org/). +- Fully typed (`py.typed`, checked with `mypy --strict`); results are frozen + dataclasses. +- Python 3.10+. + +## Contents + +- [Install](#install) +- [Set up the client](#set-up-the-client) +- [Repositories](#repositories) +- [Commits](#commits) +- [Reading](#reading) +- [Branches and tags](#branches-and-tags) +- [Undo](#undo) +- [Git remotes](#git-remotes) +- [Tokens](#tokens) +- [Webhooks](#webhooks) +- [Export](#export) +- [Mirrors](#mirrors) +- [Errors](#errors) +- [asyncio](#asyncio) +- [Retries, proxies and timeouts](#retries-proxies-and-timeouts) +- [API reference](#api-reference) + +## Install + +```bash +pip install weftsh +# or +uv add weftsh +``` + +## Set up the client + +You need an organization and a token. [Sign up](https://weft.sh/login?mode=signup), +create an organization, and mint a token under **Settings → Tokens** (or see +[authentication](https://weft.sh/docs/authentication/)). + +```python +from weftsh import Weft + +weft = Weft( + token=os.environ["WEFT_TOKEN"], # weft__ + org="acme", # your organization, or your personal namespace +) +``` + +| Argument | Default | What it is | +| ------------- | --------------------- | -------------------------------------------------------- | +| `token` | — | A Weft API token. Required. | +| `org` | — | The namespace every call works in. Required. | +| `base_url` | `https://api.weft.sh` | The API origin — change it for a self-hosted deployment. | +| `http_client` | a new `httpx.Client` | Your own client, for retries, proxies or logging. | +| `timeout` | `30.0` | Seconds, for the client the SDK makes. | + +`Weft` is a context manager (`with Weft(...) as weft:`) and has `close()`. + +Keep the token on the server. To give a sandbox, a subprocess or a user +access to one repository, hand it a [remote URL](#git-remotes) or a +[repo-scoped token](#tokens) instead. + +## Repositories + +```python +# A generated name — the usual choice for one repository per session. +repo = weft.create_repo() + +# Or choose everything. +docs = weft.create_repo( + name="docs-site", # letters, digits, - _ . — up to 100 characters + default_branch="main", + public=False, + description="Generated documentation", +) + +repo.name # 'repo-4f7c…' +repo.info.clone_url # 'https://api.weft.sh/acme/repo-4f7c….git' +``` + +Find one by name — `None` when there is none you can see: + +```python +found = weft.find_one(name="docs-site") +``` + +Or get a handle with **no request at all**, when you already know it exists: + +```python +repo = weft.repo("session-8412") +repo.create_commit(message="resume").put("state.json", "{}").send() +``` + +List them a page at a time, or iterate over all of them: + +```python +repos, next_cursor = weft.list_repos(limit=100) + +for repo in weft.iterate_repos(): + print(repo.name, repo.info.stored_bytes) +``` + +Fleets are one call. Up to 1,000 per request; each item reports its own +outcome, in request order: + +```python +results = weft.create_repos(["agent-1", "agent-2"]) +failed = [r for r in results if not r.ok] # BatchResult(name, ok=False, error=…) + +weft.delete_repos(["agent-1", "agent-2"]) +``` + +Edit, delete, fork: + +```python +repo.update(description="Session 8412", homepage="https://example.com") +repo.update(description=None) # None clears; arguments left out are left alone + +repo.delete() # instant; storage is swept later, forks survive + +fork = repo.fork(org="my-team", name="experiment") +fork.info.fork_state # 'pending', then 'ready' +``` + +A dormant repository costs storage and nothing else, so creating one per +session and keeping it is the normal pattern, not a cleanup problem. + +## Commits + +Build a commit from changes and send it. The tree is built server-side; +nothing is checked out anywhere. + +```python +from weftsh import Identity + +result = ( + repo.create_commit( + message="agent step 12", + branch="main", # the default; created if missing + author=Identity(name="Build Agent", email="agent@acme.dev"), # optional + context={"run": "r-42", "prompt": "p-991"}, # optional audit record + ) + .put("src/app.py", source) # str → UTF-8 text + .put("assets/logo.png", png) # bytes → binary-safe + .delete("notes.txt") + .send() +) + +result.commit # the new commit's SHA +result.parent # the commit it was made on — None for a branch's first +``` + +Commits are durable when `send()` returns. `branch` defaults to `"main"` +whatever the repository's default branch is, so pass it if yours differs. + +**`context`** is any JSON you like. It is written to the organization's +immutable audit trail beside the commit and the token that made it — how you +answer "what did the agent change, and why" months later. + +**Concurrency.** Pass `expected_parent` with the commit you built against. +If the branch has moved, the commit is refused with a +[`WeftConflictError`](#errors) carrying the current tip: + +```python +from weftsh import WeftConflictError + +try: + repo.create_commit(message="step 13", expected_parent=last_seen).put("a.txt", "x").send() +except WeftConflictError as e: + last_seen = e.current_tip # rebase your change onto this and retry +``` + +| `expected_parent` | Meaning | +| ----------------- | ----------------------------------------------------------- | +| left out | Commit on top of whatever the branch points at now. | +| `"3f2a…"` | The branch must point exactly here, or 409. | +| `None` | The branch must not exist yet — create it with this commit. | + +A commit holds up to 10,000 operations. + +## Reading + +Any file, at any revision. A revision is a full 40-character commit SHA, a +branch, a tag, a full ref name (`refs/heads/main`) or `HEAD`; git's `main~3` +and short SHAs are not understood — walk `list_commits` instead. + +```python +text = repo.read_file("src/app.py") # str, or None if absent +old = repo.read_file("src/app.py", ref="3f2a…") +``` + +`get_file` gives you the bytes and what the server knows about them: + +```python +file = repo.get_file("assets/logo.png") +if file: + file.content # bytes + file.binary # True + file.commit # the commit the content came from + file.etag # the content hash +``` + +Cache with the ETag — an unchanged file costs a 304 and no bytes: + +```python +again = repo.get_file("src/app.py", if_none_match=file.etag) +if again and again.not_modified: + ... # use what you have +``` + +Directories, one level at a time or all at once: + +```python +tree = repo.get_tree(path="src", ref="main") +tree.entries # [TreeEntry(name, kind='blob' | 'tree', mode, oid, size, last_commit)] + +repo.get_tree(sizes=True) # measure each blob +repo.get_tree(history=True) # the last commit to touch each entry + +repo.list_files().paths # ['README.md', 'src/', 'src/app.py', …] +``` + +History, newest first — a page at a time, or iterated. Optionally only the +commits that touched one path: + +```python +page = repo.list_commits(limit=50) +page.commits[0] # CommitEntry(sha, parents, author, committer, message, tree, change) +page.next_cursor # pass back as cursor=… + +for commit in repo.iterate_commits(path="src/app.py"): + print(commit.sha, commit.change) # 'added' | 'modified' | 'deleted' +``` + +A path-filtered page examines at most 500 commits, so a long search is several +bounded requests rather than one unbounded scan; `iterate_commits` keeps +going for you. + +What changed between two revisions (`from` is a keyword in Python, hence +`from_`): + +```python +diff = repo.get_diff(from_="v1.0.0", to="main") +diff.changes # [DiffChange(path, status, old_oid, new_oid, old_mode, new_mode)] +``` + +## Branches and tags + +```python +repo.create_branch(name="feature/login", from_="main") # returns the commit +repo.list_branches() # [Ref(name, full, oid, default)] +repo.delete_branch("feature/login") + +repo.create_tag(name="v1.0.0", target="main") +repo.list_tags() +repo.delete_tag("v1.0.0") + +repo.list_refs() # ListRefsResult(head='refs/heads/main', refs=[RefTip(name, oid), …]) +``` + +## Undo + +Undo is a primitive, not a project. + +```python +# Put the branch back where it was before the agent went sideways. +repo.reset(branch="main", to=good_commit, expected_head=bad_commit) + +# Or append a commit that undoes the head, keeping the history. +repo.revert(branch="main") +``` + +A reset never erases anything: the commits it leaves behind stay reachable by +SHA until garbage collection, so the audit trail survives the undo. Both take +`expected_head` and raise `WeftConflictError` if the branch has moved. + +## Git remotes + +Every repository is a real git remote. `get_remote_url()` returns one with a +**fresh credential scoped to that one repository**, so you can hand it to a +sandbox, a CI job or an agent without handing over your own token: + +```python +url = repo.get_remote_url() # can push; dies in an hour +read_only = repo.get_remote_url(access="read", ttl=600) +``` + +```bash +git clone "$url" && cd session-8412 +git commit -am "from a sandbox" && git push +``` + +| Argument | Default | What it is | +| -------- | --------------- | --------------------------------------------------- | +| `access` | `"write"` | `"write"` clones and pushes; `"read"` only clones. | +| `ttl` | `3600` | Seconds until the credential dies. At most a year. | +| `label` | `remote:` | Shown in the token list and in the audit trail. | + +Each call mints a new token, which needs a client token allowed to mint: +`org:admin` for a service token, or a personal token whose owner can write to +the repository. `repo.clone_url` is the same URL with no credential in it. + +## Tokens + +```python +minted = weft.create_token( + scopes=["repo:write"], # org:admin · org:read · repo:read · repo:write · repo:cache + repo="session-8412", # optional: this repository only + label="sandbox-8412", + ttl=3600, # optional: seconds until it dies on its own +) +minted.token # the secret — shown exactly once + +weft.list_tokens() # without secrets +weft.revoke_token(minted.id) # dead on the next request +``` + +## Webhooks + +Subscribe a URL to a repository's events: + +```python +hook = repo.create_webhook(url="https://app.example.com/hooks/weft") +hook.secret # shown once — store it +``` + +Verify every delivery before trusting it. `verify_webhook` checks the +`X-Weft-Signature-256` header against the raw body and returns the event, or +`None`: + +```python +from weftsh import verify_webhook + + +# Flask; any framework that gives you the raw body works the same way. +@app.post("/hooks/weft") +def weft_hook(): + event = verify_webhook( + payload=request.get_data(), # the raw body — not re-serialized JSON + signature=request.headers.get("X-Weft-Signature-256"), + secret=os.environ["WEFT_WEBHOOK_SECRET"], + ) + if event is None: + return "bad signature", 401 + if event["event"] == "push": + # Something moved. Only commits made over REST carry `branch` and + # `commit`; a `git push` says only that something changed — fetch + # to find out what. + ... + return "ok" +``` + +| Event | Fires when | +| ---------------- | ------------------------------------------------------------------------------ | +| `push` | Anything moves a ref: `git push` over HTTPS or SSH, or a commit made over REST | +| `change.landed` | A change lands through the land queue | +| `change.ejected` | The lander refused a change | + +`repo.list_webhooks()` and `repo.delete_webhook(id)` manage subscriptions. + +## Export + +Any repository, any time, as a standard git bundle — adopting Weft is not a +lock-in decision. + +```python +job = repo.start_export() +while job.state not in ("done", "failed"): + time.sleep(0.5) + job = repo.get_export(job.job) + +Path("repo.bundle").write_bytes(repo.download_export(job.job)) +``` + +```bash +git clone repo.bundle my-repo +``` + +## Mirrors + +Mirror a repository from GitHub or any git host; the first sync runs in the +background. + +```python +weft.create_mirror(name="linux", origin="torvalds/linux", public=True) +weft.create_mirror(name="tool", provider="generic", origin="https://git.example.com/acme/tool.git") +``` + +Private GitHub origins need the Weft GitHub App — see +[the mirror quickstart](https://weft.sh/docs/quickstart-mirror/). + +## Errors + +Every failed request raises a `WeftError`: + +```python +from weftsh import WeftError + +try: + weft.create_repo(name="taken") +except WeftError as e: + e.status # 409 + e.message # the server's own sentence, written to be shown to a person + e.body # the parsed response body +``` + +| Class | When | +| ------------------- | ---------------------------------------------------------------------------------------- | +| `WeftConflictError` | `409` from a concurrency check. `current_tip` is where the branch is. Subclass of `WeftError`. | +| `WeftError` | Anything else. `status` is `0` when the request never got an answer. | + +Lookups that commonly miss return `None` instead of raising: `find_one` for a +repository, and `get_file` and `read_file` for a path or revision that is not +there. A file read from a repository that does not exist still raises — a typo +in a repository name is not a missing file. + +A `404` also covers "exists, but not for you" — Weft does not tell a caller +about repositories it cannot see. + +## asyncio + +`AsyncWeft` is the same client with `await`. Every method, argument and +result is identical; iterators are `async for`. + +```python +from weftsh import AsyncWeft + +async with AsyncWeft(token=os.environ["WEFT_TOKEN"], org="acme") as weft: + repo = await weft.create_repo() + await repo.create_commit(message="step 1").put("a.txt", "a").send() + print(await repo.read_file("a.txt")) + async for commit in repo.iterate_commits(): + print(commit.sha) +``` + +## Retries, proxies and timeouts + +The SDK never retries on its own: a write that failed on the way back may +still have happened. Pass your own `httpx` client to decide — transport-level +retries for connection failures, for example: + +```python +import httpx + +http = httpx.Client(transport=httpx.HTTPTransport(retries=2), timeout=60) +weft = Weft(token=token, org="acme", http_client=http) +``` + +A client you pass is yours to close. A commit with `expected_parent` set is +safe to retry: if the first attempt landed, the retry answers `409` with your +own commit as `current_tip`. + +## API reference + +### `Weft` / `AsyncWeft` + +| Method | Returns | +| --- | --- | +| `Weft(token=, org=, base_url=, http_client=, timeout=)` | | +| `create_repo(name=, public=, default_branch=, description=)` | `Repo` | +| `find_one(name=)` | `Repo \| None` | +| `repo(name, info=None)` | `Repo` — no request | +| `list_repos(limit=, cursor=)` | `(list[Repo], next_cursor)` | +| `iterate_repos(page_size=)` | iterator of `Repo` | +| `delete_repo(name)` | `None` | +| `create_repos(names, public=)` / `delete_repos(names)` | `list[BatchResult]` | +| `create_mirror(name=, origin=, provider=, installation_id=, public=)` | `Repo` | +| `create_token(scopes=, repo=, label=, ttl=)` | `CreatedToken` | +| `list_tokens()` | `list[TokenInfo]` | +| `revoke_token(id)` | `None` | +| `close()` / `aclose()` | | + +### `Repo` / `AsyncRepo` + +| Member | Returns | +| --- | --- | +| `name`, `org`, `id`, `default_branch`, `clone_url`, `info` | | +| `refresh()` | `RepoInfo` | +| `update(description=, homepage=, public=, default_branch=)` | `RepoInfo` | +| `delete()` | `None` | +| `fork(org=, name=)` | `Repo` | +| `get_remote_url(access=, ttl=, label=)` | `str` | +| `create_commit(message=, branch=, author=, expected_parent=, context=)` → `.put(path, content)` · `.delete(path)` · `.send()` | `CommitResult` | +| `read_file(path, ref=)` | `str \| None` | +| `get_file(path, ref=, if_none_match=)` | `FileResult \| None` | +| `get_tree(path=, ref=, sizes=, history=)` | `TreeResult` | +| `list_files(path=, ref=)` | `ListFilesResult` | +| `list_commits(ref=, path=, limit=, cursor=)` | `ListCommitsResult` | +| `iterate_commits(ref=, path=)` | iterator of `CommitEntry` | +| `get_diff(from_=, to=)` | `DiffResult` | +| `list_refs()` | `ListRefsResult` | +| `list_branches()` / `list_tags()` | `list[Ref]` | +| `create_branch(name=, from_=)` / `create_tag(name=, target=)` | `str` (the commit) | +| `delete_branch(name)` / `delete_tag(name)` | `None` | +| `reset(to=, branch=, expected_head=)` | `str` (the new tip) | +| `revert(branch=, expected_head=)` | `str` (the new commit) | +| `create_webhook(url=)` | `CreatedWebhook` | +| `list_webhooks()` / `delete_webhook(id)` | | +| `start_export()` / `get_export(job)` | `ExportJob` | +| `download_export(job)` | `bytes` | + +### Functions + +| Function | Returns | +| --- | --- | +| `verify_webhook(payload=, signature=, secret=)` | `dict \| None` | + +Everything is typed; your editor has the rest. The full REST API is described +at [weft.sh/openapi.json](https://weft.sh/openapi.json). + +## Development + +```bash +uv sync +uv run ruff check . && uv run ruff format --check . +uv run mypy +uv run pytest +``` + +The sync client is generated from the async one. Edit +`src/weftsh/_async_client.py`, then run `uv run python scripts/unasync.py`; a +test fails if the committed `_sync_client.py` is stale. + +The unit tests check what the SDK sends. The end-to-end suite checks that a +real server agrees — run it against any Weft deployment with an `org:admin` +token: + +```bash +WEFT_E2E_URL=http://127.0.0.1:8080 WEFT_E2E_ORG=acme WEFT_E2E_TOKEN=weft_… uv run pytest tests/test_e2e.py +``` + +It creates repositories, clones and pushes with the real `git` CLI through +`get_remote_url`, runs `git fsck --full --strict` on the clone, and deletes +everything it made. + +## License + +MIT diff --git a/examples/agent_session.py b/examples/agent_session.py new file mode 100644 index 0000000..cf18ff2 --- /dev/null +++ b/examples/agent_session.py @@ -0,0 +1,64 @@ +"""One repository per agent session: every step is a commit, a bad step is one +reset away, and the whole session is a git repository at the end. + + WEFT_TOKEN=weft_… WEFT_ORG=acme python examples/agent_session.py +""" + +from __future__ import annotations + +import os + +from weftsh import DEFAULT_BASE_URL, Weft, WeftConflictError + + +def main() -> None: + with Weft( + token=os.environ["WEFT_TOKEN"], + org=os.environ.get("WEFT_ORG", "acme"), + base_url=os.environ.get("WEFT_URL", DEFAULT_BASE_URL), + ) as weft: + repo = weft.create_repo(description="agent session") + print("session repository:", repo.name) + + # Step 1: the agent writes a first draft. + step1 = ( + repo.create_commit( + message="step 1: scaffold", expected_parent=None, context={"step": 1} + ) + .put("greet.py", 'def greet():\n return "hello"\n') + .put("README.md", "# greeter\n") + .send() + ) + + # Step 2: the agent goes sideways. + step2 = ( + repo.create_commit( + message="step 2: rewrite everything", + expected_parent=step1.commit, + context={"step": 2}, + ) + .delete("greet.py") + .put("greet.js", 'console.log("hello")\n') + .send() + ) + + # Review what step 2 did. + diff = repo.get_diff(from_=step1.commit, to=step2.commit) + print("step 2 changed:", ", ".join(f"{c.status} {c.path}" for c in diff.changes)) + + # Undo it. Step 2 stays reachable by SHA for the audit trail. + repo.reset(to=step1.commit, expected_head=step2.commit) + print("after undo:", repo.read_file("greet.py")) + + # A stale writer is told where the branch really is. + try: + repo.create_commit(message="late", expected_parent=step2.commit).put("x", "y").send() + except WeftConflictError as e: + print("conflict; branch is at", e.current_tip) + + # Hand a sandbox a read-only remote for ten minutes. + print("git clone", repo.get_remote_url(access="read", ttl=600)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f76b803 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "weftsh" +version = "0.1.0" +description = "Python SDK for Weft: a real git repository per user, session or agent, created and written over REST." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Weft" }] +keywords = ["weft", "git", "repositories", "agents", "sdk", "storage"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = ["httpx>=0.27"] + +[project.urls] +Homepage = "https://weft.sh/docs/sdk/python/" +Source = "https://github.com/weftsh/python-sdk" +Issues = "https://github.com/weftsh/python-sdk/issues" + +[dependency-groups] +dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.11", "ruff>=0.6"] + +[tool.hatch.build.targets.wheel] +packages = ["src/weftsh"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.mypy] +strict = true +files = ["src", "tests", "scripts", "examples"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] + +[tool.ruff.lint.per-file-ignores] +# Generated from the async client, where `async for … yield` has no `yield from`. +"src/weftsh/_sync_client.py" = ["UP028"] + +[tool.ruff.format] +# Generated; `scripts/unasync.py --check` holds it to the async source instead. +exclude = ["src/weftsh/_sync_client.py"] diff --git a/scripts/unasync.py b/scripts/unasync.py new file mode 100644 index 0000000..df3bc18 --- /dev/null +++ b/scripts/unasync.py @@ -0,0 +1,66 @@ +"""Generate ``_sync_client.py`` from ``_async_client.py``. + +Every SDK method is one request (or a loop of them), so the sync client is +the async one with the ``async``/``await`` taken out. Generating it keeps +the two from drifting: ``tests/test_unasync.py`` fails when the committed +file is not what this script produces. + + python scripts/unasync.py # rewrite src/weftsh/_sync_client.py + python scripts/unasync.py --check # exit 1 if it is stale +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT / "src" / "weftsh" / "_async_client.py" +TARGET = ROOT / "src" / "weftsh" / "_sync_client.py" + +HEADER = '''"""The sync client. GENERATED from ``_async_client.py`` by ``scripts/unasync.py`` +— do not edit by hand; edit the async client and run ``python scripts/unasync.py``.""" +''' + +# Order matters: the longer names first. +REPLACEMENTS: list[tuple[str, str]] = [ + (r"\bAsyncCommitBuilder\b", "CommitBuilder"), + (r"\bAsyncRepo\b", "Repo"), + (r"\bAsyncWeft\b", "Weft"), + (r"\bAsyncIterator\b", "Iterator"), + (r"\bhttpx\.AsyncClient\b", "httpx.Client"), + (r"\basync def __aenter__\b", "def __enter__"), + (r"\basync def __aexit__\b", "def __exit__"), + (r"\baclose\b", "close"), + (r"\basync def\b", "def"), + (r"\basync with\b", "with"), + (r"\basync for\b", "for"), + (r"\bawait ", ""), +] + + +def render() -> str: + text = SOURCE.read_text() + # Drop the async module docstring; the generated file gets its own. + text = re.sub(r'\A""".*?"""\n', HEADER, text, count=1, flags=re.S) + for pattern, repl in REPLACEMENTS: + text = re.sub(pattern, repl, text) + # `await (` left behind by a parenthesised builder chain in a docstring. + return text + + +def main() -> int: + rendered = render() + if "--check" in sys.argv: + if TARGET.read_text() != rendered: + print(f"{TARGET.relative_to(ROOT)} is stale: run python scripts/unasync.py") + return 1 + return 0 + TARGET.write_text(rendered) + print(f"wrote {TARGET.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/weftsh/__init__.py b/src/weftsh/__init__.py new file mode 100644 index 0000000..f1035df --- /dev/null +++ b/src/weftsh/__init__.py @@ -0,0 +1,87 @@ +"""The Python SDK for Weft: a real git repository per user, session or agent. + +:: + + from weftsh import Weft + + weft = Weft(token=os.environ["WEFT_TOKEN"], org="acme") + repo = weft.create_repo() + repo.create_commit(message="agent step 1").put("src/app.py", "print(1)\\n").send() + print(repo.read_file("src/app.py")) + +``AsyncWeft`` is the same client for ``asyncio``. +""" + +from ._async_client import AsyncCommitBuilder, AsyncRepo, AsyncWeft +from ._errors import WeftConflictError, WeftError +from ._sync_client import CommitBuilder, Repo, Weft +from ._types import ( + UNSET, + BatchResult, + CommitEntry, + CommitResult, + CreatedToken, + CreatedWebhook, + DiffChange, + DiffResult, + ExportJob, + FileContent, + FileResult, + Identity, + LastCommit, + ListCommitsResult, + ListFilesResult, + ListRefsResult, + Ref, + RefTip, + RepoInfo, + TokenInfo, + TokenScope, + TreeEntry, + TreeResult, + WebhookSubscription, +) +from ._version import VERSION +from ._wire import DEFAULT_BASE_URL +from .webhooks import SIGNATURE_HEADER, verify_webhook + +__version__ = VERSION + +__all__ = [ + "DEFAULT_BASE_URL", + "SIGNATURE_HEADER", + "UNSET", + "VERSION", + "AsyncCommitBuilder", + "AsyncRepo", + "AsyncWeft", + "BatchResult", + "CommitBuilder", + "CommitEntry", + "CommitResult", + "CreatedToken", + "CreatedWebhook", + "DiffChange", + "DiffResult", + "ExportJob", + "FileContent", + "FileResult", + "Identity", + "LastCommit", + "ListCommitsResult", + "ListFilesResult", + "ListRefsResult", + "Ref", + "RefTip", + "Repo", + "RepoInfo", + "TokenInfo", + "TokenScope", + "TreeEntry", + "TreeResult", + "Weft", + "WeftConflictError", + "WeftError", + "WebhookSubscription", + "verify_webhook", +] diff --git a/src/weftsh/_async_client.py b/src/weftsh/_async_client.py new file mode 100644 index 0000000..cbcefd4 --- /dev/null +++ b/src/weftsh/_async_client.py @@ -0,0 +1,708 @@ +"""The async client. ``_sync_client.py`` is generated from this file by +``scripts/unasync.py`` — edit here, then run ``python scripts/unasync.py``.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from types import TracebackType +from typing import Any + +import httpx + +from . import _wire +from ._errors import WeftError +from ._types import ( + UNSET, + BatchResult, + CommitEntry, + CommitResult, + CreatedToken, + CreatedWebhook, + DiffResult, + ExportJob, + FileContent, + FileResult, + Identity, + ListCommitsResult, + ListFilesResult, + ListRefsResult, + Ref, + RepoInfo, + TokenInfo, + TokenScope, + TreeResult, + Unset, + WebhookSubscription, + Wire, +) + +__all__ = ["AsyncWeft", "AsyncRepo", "AsyncCommitBuilder"] + + +class AsyncWeft: + """The Weft client. One per token and organization. + + :: + + async with AsyncWeft(token=os.environ["WEFT_TOKEN"], org="acme") as weft: + repo = await weft.create_repo() + """ + + def __init__( + self, + *, + token: str, + org: str, + base_url: str = _wire.DEFAULT_BASE_URL, + http_client: httpx.AsyncClient | None = None, + timeout: float = 30.0, + ) -> None: + """ + Args: + token: A Weft API token, ``weft__``. + org: The organization (or personal namespace) every call works in. + base_url: API origin. Defaults to ``https://api.weft.sh``. + http_client: Your own ``httpx`` client, for retries, proxies or + logging. The SDK never retries by itself, because a write that + failed on the way back may still have happened. A client you + pass is yours to close. + timeout: Seconds, for the client the SDK makes when you pass none. + """ + _wire.check_options(token, org) + self.org = org + self._base_url = base_url.rstrip("/") + self._headers = _wire.headers(token) + self._owns_client = http_client is None + self._http = http_client or httpx.AsyncClient(timeout=timeout) + + async def aclose(self) -> None: + """Closes the HTTP client, if the SDK made it.""" + if self._owns_client: + await self._http.aclose() + + async def __aenter__(self) -> AsyncWeft: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + @property + def base_url(self) -> str: + return self._base_url + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, str] | None = None, + json: Any = None, + headers: dict[str, str] | None = None, + allow: tuple[int, ...] = (), + ) -> httpx.Response: + url = f"{self._base_url}/{path}" + try: + response = await self._http.request( + method, + url, + params=params, + json=json, + headers={**self._headers, **(headers or {})}, + ) + except httpx.HTTPError as exc: + raise _wire.transport_error(exc, method, url) from exc + _wire.raise_for(response, allow) + return response + + async def _json(self, method: str, path: str, **kwargs: Any) -> Any: + return _wire.json_of(await self._request(method, path, **kwargs)) + + def _org_path(self, suffix: str, org: str | None = None) -> str: + return f"v1/orgs/{_wire.seg(org or self.org)}{suffix}" + + # ------------------------------------------------------------ repositories + + async def create_repo( + self, + *, + name: str | None = None, + public: bool | None = None, + default_branch: str | None = None, + description: str | None = None, + ) -> AsyncRepo: + """Creates a repository — a real git remote, typically in under 100 ms. + + With no ``name``, a unique one is generated: the usual choice for a + repository per session. Names are letters, digits, ``-``, ``_`` and + ``.``, up to 100 characters. + """ + body = _wire.create_body(name or _wire.unique_name(), public, default_branch, description) + info = RepoInfo._from_wire(await self._json("POST", self._org_path("/repos"), json=body)) + return AsyncRepo(self, info.name, info) + + async def find_one(self, *, name: str) -> AsyncRepo | None: + """Looks a repository up by name. ``None`` if there is none you can see.""" + repo = self.repo(name) + try: + await repo.refresh() + except WeftError as exc: + if exc.status == 404: + return None + raise + return repo + + def repo(self, name: str, info: RepoInfo | None = None) -> AsyncRepo: + """A handle on a repository you know exists. Makes no request.""" + return AsyncRepo(self, name, info) + + async def list_repos( + self, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[AsyncRepo], str | None]: + """One page of repositories, and the cursor for the next (``None`` on the last).""" + w = await self._json( + "GET", self._org_path("/repos"), params=_wire.query(limit=limit, after=cursor) + ) + repos = [ + AsyncRepo(self, info.name, info) + for info in (RepoInfo._from_wire(r) for r in w["repos"]) + ] + return repos, w.get("next_after") + + async def iterate_repos(self, *, page_size: int | None = None) -> AsyncIterator[AsyncRepo]: + """Every repository in the organization, fetching pages as you iterate.""" + cursor: str | None = None + while True: + repos, cursor = await self.list_repos(limit=page_size, cursor=cursor) + for repo in repos: + yield repo + if not cursor: + return + + async def delete_repo(self, name: str) -> None: + """Deletes a repository by name.""" + await self.repo(name).delete() + + async def create_repos( + self, names: list[str], *, public: bool | None = None + ) -> list[BatchResult]: + """Creates up to 1,000 repositories in one call. + + One failing does not stop the rest: check ``ok`` on each result, + which come back in request order. + """ + body = {"repos": [_wire.create_body(n, public, None, None) for n in names]} + w = await self._json("POST", self._org_path("/repos/batch/create"), json=body) + return [BatchResult._from_wire(r) for r in w["results"]] + + async def delete_repos(self, names: list[str]) -> list[BatchResult]: + """Deletes up to 1,000 repositories by name.""" + w = await self._json("POST", self._org_path("/repos/batch/delete"), json={"names": names}) + return [BatchResult._from_wire(r) for r in w["results"]] + + async def create_mirror( + self, + *, + name: str, + origin: str, + provider: str | None = None, + installation_id: str | None = None, + public: bool | None = None, + ) -> AsyncRepo: + """Mirrors a repository from GitHub (``owner/name``) or any git host (a URL, + with ``provider="generic"``). The first sync runs in the background.""" + body: Wire = {"name": name, "origin": origin} + if provider is not None: + body["provider"] = provider + if installation_id is not None: + body["installation_id"] = installation_id + if public is not None: + body["public"] = public + w = await self._json("POST", self._org_path("/mirrors"), json=body) + info = RepoInfo._from_wire({**w["repo"], "org": self.org, "clone_url": w["clone_url"]}) + return AsyncRepo(self, info.name, info) + + # ------------------------------------------------------------------ tokens + + async def create_token( + self, + *, + scopes: list[TokenScope], + repo: str | None = None, + label: str | None = None, + ttl: int | None = None, + ) -> CreatedToken: + """Mints an API token. The secret is in the result and nowhere else, ever. + + ``repo`` restricts it to one repository; ``ttl`` (seconds, at most a + year) makes it die on its own. + """ + return await self._mint(self.org, scopes=scopes, repo=repo, label=label, ttl=ttl) + + async def _mint( + self, + org: str, + *, + scopes: list[TokenScope], + repo: str | None, + label: str | None, + ttl: int | None, + ) -> CreatedToken: + body: Wire = {"scopes": scopes} + if repo is not None: + body["repo"] = repo + if label is not None: + body["label"] = label + if ttl is not None: + body["expires_in_secs"] = int(ttl) + return CreatedToken._from_wire( + await self._json("POST", self._org_path("/tokens", org), json=body) + ) + + async def list_tokens(self) -> list[TokenInfo]: + """Tokens, without their secrets: every token for an admin, your own for a member.""" + w = await self._json("GET", self._org_path("/tokens")) + return [TokenInfo._from_wire(t) for t in w["tokens"]] + + async def revoke_token(self, id: str) -> None: + """Revokes a token. It stops working on the next request.""" + await self._json("DELETE", self._org_path(f"/tokens/{_wire.seg(id)}")) + + +class AsyncRepo: + """One repository. Get one from ``create_repo``, ``find_one`` or ``repo``.""" + + def __init__(self, client: AsyncWeft, name: str, info: RepoInfo | None = None) -> None: + self._client = client + self.name = name + """The repository's name.""" + self.org = info.org if info else client.org + """The namespace it lives in.""" + self._info = info + + def __repr__(self) -> str: + return f"{type(self).__name__}(org={self.org!r}, name={self.name!r})" + + @property + def info(self) -> RepoInfo | None: + """What the API last said about this repository; ``None`` until fetched.""" + return self._info + + @property + def id(self) -> str | None: + return self._info.id if self._info else None + + @property + def default_branch(self) -> str | None: + return self._info.default_branch if self._info else None + + @property + def clone_url(self) -> str: + """The HTTPS git remote, without credentials.""" + if self._info: + return self._info.clone_url + return f"{self._client.base_url}/{self.org}/{self.name}.git" + + def _path(self, suffix: str = "") -> str: + return f"v1/orgs/{_wire.seg(self.org)}/repos/{_wire.seg(self.name)}{suffix}" + + # -------------------------------------------------------------- metadata + + async def refresh(self) -> RepoInfo: + """Fetches the repository's metadata, stores it on ``info`` and returns it.""" + self._info = RepoInfo._from_wire(await self._client._json("GET", self._path())) + return self._info + + async def update( + self, + *, + description: str | None | Unset = UNSET, + homepage: str | None | Unset = UNSET, + public: bool | Unset = UNSET, + default_branch: str | Unset = UNSET, + ) -> RepoInfo: + """Edits the repository. Arguments left out are left alone; ``None`` clears + ``description`` or ``homepage``. ``public`` and ``default_branch`` need + ``org:admin``.""" + body: Wire = {} + if description is not UNSET: + body["description"] = description + if homepage is not UNSET: + body["homepage"] = homepage + if public is not UNSET: + body["public"] = public + if default_branch is not UNSET: + body["default_branch"] = default_branch + self._info = RepoInfo._from_wire(await self._client._json("PATCH", self._path(), json=body)) + return self._info + + async def delete(self) -> None: + """Deletes the repository. Instant; the storage is swept later, forks survive.""" + await self._client._json("DELETE", self._path()) + + async def fork(self, *, org: str | None = None, name: str | None = None) -> AsyncRepo: + """Forks the repository. The fork is readable once ``info.fork_state`` is + ``ready``. Needs a token that acts for a person.""" + body: Wire = {} + if org is not None: + body["org"] = org + if name is not None: + body["name"] = name + info = RepoInfo._from_wire( + await self._client._json("POST", self._path("/forks"), json=body) + ) + return AsyncRepo(self._client, info.name, info) + + # ------------------------------------------------------------------ git + + async def get_remote_url( + self, *, access: str = "write", ttl: int = 3600, label: str | None = None + ) -> str: + """A git remote URL with a fresh credential scoped to this repository only: + ``https://x:weft_…@api.weft.sh/acme/session-8412.git``. + + Hand it to a sandbox or a ``git clone`` without handing over your own + token. ``access`` is ``"write"`` (the default) or ``"read"``; the + credential dies after ``ttl`` seconds (an hour by default). + """ + if access not in ("read", "write"): + raise ValueError('access must be "read" or "write"') + minted = await self._client._mint( + self.org, + scopes=["repo:read" if access == "read" else "repo:write"], + repo=self.name, + label=label or f"remote:{self.name}", + ttl=ttl, + ) + return _wire.remote_url(self.clone_url, minted.token) + + # --------------------------------------------------------------- writing + + def create_commit( + self, + *, + message: str, + branch: str | None = None, + author: Identity | None = None, + expected_parent: str | None | Unset = UNSET, + context: Any = None, + ) -> AsyncCommitBuilder: + """Starts a commit. Add changes with ``.put()`` and ``.delete()``, then ``.send()``. + + Args: + message: The commit message. + branch: Defaults to ``"main"`` — not the repository's default + branch, so pass it if yours differs. Created if missing. + author: Who ``git log`` shows. Defaults to the person or token acting. + expected_parent: Optimistic concurrency. A SHA: the branch must + point exactly there, or ``send()`` raises ``WeftConflictError`` + carrying the current tip. ``None``: the branch must not exist + yet. Leave it out to commit on whatever the tip is now. + context: Anything JSON. Recorded immutably in the audit trail + beside the commit and the token that made it. + """ + return AsyncCommitBuilder( + self, + message=message, + branch=branch, + author=author, + expected_parent=expected_parent, + context=context, + ) + + async def _send_commit( + self, + *, + message: str, + branch: str | None, + author: Identity | None, + expected_parent: str | None | Unset, + context: Any, + operations: list[Wire], + ) -> CommitResult: + if not operations: + raise ValueError("a commit needs at least one operation") + body: Wire = {"message": message, "operations": operations} + if branch is not None: + body["branch"] = branch + if author is not None: + body["author"] = {"name": author.name, "email": author.email} + if expected_parent is not UNSET: + body["expected_parent"] = expected_parent + if context is not None: + body["context"] = context + return CommitResult._from_wire( + await self._client._json("POST", self._path("/commits"), json=body) + ) + + # --------------------------------------------------------------- reading + + async def get_file( + self, path: str, *, ref: str | None = None, if_none_match: str | None = None + ) -> FileResult | None: + """Reads a file at any revision: a full commit SHA, a branch, a tag, a full + ref name or ``HEAD`` (the default). + + Returns ``None`` when the path or the revision does not exist. A + repository that does not exist (or that you cannot see) raises a 404 + ``WeftError``. Pass an earlier result's ``etag`` as ``if_none_match`` + and an unchanged file answers ``not_modified=True`` with no bytes. + """ + extra = {"Accept": "*/*"} + if if_none_match: + extra["If-None-Match"] = if_none_match + try: + response = await self._client._request( + "GET", + self._path(f"/files/{_wire.path_of(path)}"), + params=_wire.query(at=ref), + headers=extra, + allow=(304,), + ) + except WeftError as exc: + if _wire.is_missing_in_repo(exc): + return None + raise + return _wire.file_result(response, if_none_match) + + async def read_file(self, path: str, *, ref: str | None = None) -> str | None: + """A file as UTF-8 text, or ``None`` when it is not there. See ``get_file``.""" + file = await self.get_file(path, ref=ref) + return file.text if file else None + + async def get_tree( + self, + *, + path: str | None = None, + ref: str | None = None, + sizes: bool = False, + history: bool = False, + ) -> TreeResult: + """One directory: names, modes, kinds and object ids. + + ``sizes=True`` measures each blob; ``history=True`` adds the commit + that last touched each entry (a bounded walk). + """ + suffix = f"/tree/{_wire.path_of(path)}" if path else "/tree" + w = await self._client._json( + "GET", self._path(suffix), params=_wire.query(at=ref, sizes=sizes, history=history) + ) + return TreeResult._from_wire(w) + + async def list_files( + self, *, path: str | None = None, ref: str | None = None + ) -> ListFilesResult: + """Every path under a directory, flat, in one request.""" + suffix = f"/tree/{_wire.path_of(path)}" if path else "/tree" + w = await self._client._json( + "GET", self._path(suffix), params=_wire.query(at=ref, recursive=True) + ) + return ListFilesResult._from_wire(w) + + async def list_commits( + self, + *, + ref: str | None = None, + path: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> ListCommitsResult: + """First-parent history, newest first, one page at a time. + + With ``path``, only commits that changed it — each with a ``change``. + A filtered page examines at most 500 commits, so keep paging while + ``next_cursor`` is set, even when a page comes back empty. + """ + w = await self._client._json( + "GET", + self._path("/log"), + params=_wire.query(rev=ref, path=path, limit=limit, after=cursor), + ) + return ListCommitsResult._from_wire(w) + + async def iterate_commits( + self, *, ref: str | None = None, path: str | None = None + ) -> AsyncIterator[CommitEntry]: + """Every commit in first-parent history, fetching pages as you iterate.""" + cursor: str | None = None + while True: + page = await self.list_commits(ref=ref, path=path, cursor=cursor) + for entry in page.commits: + yield entry + cursor = page.next_cursor + if not cursor: + return + + async def get_diff(self, *, from_: str, to: str) -> DiffResult: + """Every file that differs between two revisions.""" + w = await self._client._json( + "GET", self._path("/diff"), params=_wire.query(**{"from": from_, "to": to}) + ) + return DiffResult._from_wire(w) + + # ------------------------------------------------------------------ refs + + async def list_refs(self) -> ListRefsResult: + """Every branch and tag at once.""" + return ListRefsResult._from_wire(await self._client._json("GET", self._path("/refs"))) + + async def list_branches(self) -> list[Ref]: + """Branches, sorted, with the default marked.""" + w = await self._client._json("GET", self._path("/branches")) + return [Ref._from_wire(r) for r in w["branches"]] + + async def create_branch(self, *, name: str, from_: str) -> str: + """Creates a branch at a revision. Returns the commit it points at.""" + body = {"name": name, "from": from_} + w = await self._client._json("POST", self._path("/branches"), json=body) + oid: str = w["oid"] + return oid + + async def delete_branch(self, name: str) -> None: + await self._client._json("DELETE", self._path(f"/branches/{_wire.seg(name)}")) + + async def list_tags(self) -> list[Ref]: + """Tags, sorted.""" + w = await self._client._json("GET", self._path("/tags")) + return [Ref._from_wire(r) for r in w["tags"]] + + async def create_tag(self, *, name: str, target: str) -> str: + """Creates a lightweight tag at a revision. Returns the commit it points at.""" + body = {"name": name, "target": target} + w = await self._client._json("POST", self._path("/tags"), json=body) + oid: str = w["oid"] + return oid + + async def delete_tag(self, name: str) -> None: + await self._client._json("DELETE", self._path(f"/tags/{_wire.seg(name)}")) + + # ------------------------------------------------------------------ undo + + async def reset( + self, *, to: str, branch: str | None = None, expected_head: str | None = None + ) -> str: + """Moves a branch to another commit — the undo primitive. Returns the new tip. + + Commits left behind stay reachable by SHA until garbage collection, + so nothing is erased from the record. With ``expected_head``, a branch + that has moved raises ``WeftConflictError``. + """ + body: Wire = {"to": to} + if branch is not None: + body["branch"] = branch + if expected_head is not None: + body["expected_head"] = expected_head + w = await self._client._json("POST", self._path("/reset"), json=body) + oid: str = w["oid"] + return oid + + async def revert(self, *, branch: str | None = None, expected_head: str | None = None) -> str: + """Appends a commit that undoes the branch head, keeping history. Returns it.""" + body: Wire = {} + if branch is not None: + body["branch"] = branch + if expected_head is not None: + body["expected_head"] = expected_head + w = await self._client._json("POST", self._path("/revert"), json=body) + commit: str = w["commit"] + return commit + + # -------------------------------------------------------------- webhooks + + async def create_webhook(self, *, url: str) -> CreatedWebhook: + """Subscribes a URL to this repository's events. Check deliveries with + ``verify_webhook`` and the returned ``secret``.""" + w = await self._client._json("POST", self._path("/webhooks"), json={"url": url}) + return CreatedWebhook(id=w["id"], url=w["url"], secret=w["secret"]) + + async def list_webhooks(self) -> list[WebhookSubscription]: + w = await self._client._json("GET", self._path("/webhooks")) + return [ + WebhookSubscription(id=s["id"], url=s["url"], created_at=s["created_at"]) + for s in w["subscriptions"] + ] + + async def delete_webhook(self, id: str) -> None: + await self._client._json("DELETE", self._path(f"/webhooks/{_wire.seg(id)}")) + + # ---------------------------------------------------------------- export + + async def start_export(self) -> ExportJob: + """Starts exporting the repository as a standard git bundle.""" + w = await self._client._json("POST", self._path("/export")) + return ExportJob(job=w["job"], state=w["state"], error=None, download=None) + + async def get_export(self, job: str) -> ExportJob: + return ExportJob._from_wire( + await self._client._json("GET", self._path(f"/export/{_wire.seg(job)}")) + ) + + async def download_export(self, job: str) -> bytes: + """A finished export's bundle. ``git clone repo.bundle`` reads it.""" + response = await self._client._request( + "GET", + self._path(f"/export/{_wire.seg(job)}/download"), + headers={"Accept": "application/octet-stream"}, + ) + return response.content + + +class AsyncCommitBuilder: + """Collects file changes and sends them as one commit. + + :: + + result = await ( + repo.create_commit(message="agent step 12") + .put("src/app.py", source) + .delete("notes.txt") + .send() + ) + """ + + def __init__( + self, + repo: AsyncRepo, + *, + message: str, + branch: str | None, + author: Identity | None, + expected_parent: str | None | Unset, + context: Any, + ) -> None: + self._repo = repo + self._message = message + self._branch = branch + self._author = author + self._expected_parent = expected_parent + self._context = context + self._operations: list[Wire] = [] + + def put(self, path: str, content: FileContent) -> AsyncCommitBuilder: + """Writes a file, creating it or replacing what is there.""" + self._operations.append(_wire.put_operation(path, content)) + return self + + def delete(self, path: str) -> AsyncCommitBuilder: + """Removes a file.""" + self._operations.append({"op": "delete", "path": path}) + return self + + def __len__(self) -> int: + return len(self._operations) + + async def send(self) -> CommitResult: + """Makes the commit. Raises ``WeftConflictError`` when ``expected_parent`` + no longer matches the branch. A commit holds up to 10,000 operations.""" + return await self._repo._send_commit( + message=self._message, + branch=self._branch, + author=self._author, + expected_parent=self._expected_parent, + context=self._context, + operations=list(self._operations), + ) diff --git a/src/weftsh/_errors.py b/src/weftsh/_errors.py new file mode 100644 index 0000000..27e1643 --- /dev/null +++ b/src/weftsh/_errors.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + + +class WeftError(Exception): + """Every failed request raises a ``WeftError``. + + Weft answers errors as JSON ``{"error": "…"}`` with a matching status, and + that sentence is the error's message — it is written to be shown to a + person. + """ + + status: int + """HTTP status, e.g. ``404``. ``0`` when the request never got an answer.""" + method: str + url: str + body: Any + """The parsed response body (a dict for JSON, a string otherwise), or ``None``.""" + + def __init__(self, message: str, *, status: int, method: str, url: str, body: Any = None): + super().__init__(message) + self.message = message + self.status = status + self.method = method + self.url = url + self.body = body + + def __repr__(self) -> str: + return f"{type(self).__name__}(status={self.status}, message={self.message!r})" + + +class WeftConflictError(WeftError): + """A ``409`` from an optimistic-concurrency check. + + The branch was not where you said it was (``expected_parent`` on a commit, + ``expected_head`` on a reset or revert). ``current_tip`` is where it + actually is — rebase onto it and retry. + """ + + current_tip: str | None + + def __init__(self, message: str, *, status: int, method: str, url: str, body: Any = None): + super().__init__(message, status=status, method=method, url=url, body=body) + tip = None + if isinstance(body, dict): + # Commits answer `current_tip`; reset and revert answer `current`. + tip = body.get("current_tip", body.get("current")) + self.current_tip = tip if isinstance(tip, str) else None diff --git a/src/weftsh/_sync_client.py b/src/weftsh/_sync_client.py new file mode 100644 index 0000000..92a25bc --- /dev/null +++ b/src/weftsh/_sync_client.py @@ -0,0 +1,708 @@ +"""The sync client. GENERATED from ``_async_client.py`` by ``scripts/unasync.py`` +— do not edit by hand; edit the async client and run ``python scripts/unasync.py``.""" + +from __future__ import annotations + +from collections.abc import Iterator +from types import TracebackType +from typing import Any + +import httpx + +from . import _wire +from ._errors import WeftError +from ._types import ( + UNSET, + BatchResult, + CommitEntry, + CommitResult, + CreatedToken, + CreatedWebhook, + DiffResult, + ExportJob, + FileContent, + FileResult, + Identity, + ListCommitsResult, + ListFilesResult, + ListRefsResult, + Ref, + RepoInfo, + TokenInfo, + TokenScope, + TreeResult, + Unset, + WebhookSubscription, + Wire, +) + +__all__ = ["Weft", "Repo", "CommitBuilder"] + + +class Weft: + """The Weft client. One per token and organization. + + :: + + with Weft(token=os.environ["WEFT_TOKEN"], org="acme") as weft: + repo = weft.create_repo() + """ + + def __init__( + self, + *, + token: str, + org: str, + base_url: str = _wire.DEFAULT_BASE_URL, + http_client: httpx.Client | None = None, + timeout: float = 30.0, + ) -> None: + """ + Args: + token: A Weft API token, ``weft__``. + org: The organization (or personal namespace) every call works in. + base_url: API origin. Defaults to ``https://api.weft.sh``. + http_client: Your own ``httpx`` client, for retries, proxies or + logging. The SDK never retries by itself, because a write that + failed on the way back may still have happened. A client you + pass is yours to close. + timeout: Seconds, for the client the SDK makes when you pass none. + """ + _wire.check_options(token, org) + self.org = org + self._base_url = base_url.rstrip("/") + self._headers = _wire.headers(token) + self._owns_client = http_client is None + self._http = http_client or httpx.Client(timeout=timeout) + + def close(self) -> None: + """Closes the HTTP client, if the SDK made it.""" + if self._owns_client: + self._http.close() + + def __enter__(self) -> Weft: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + @property + def base_url(self) -> str: + return self._base_url + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, str] | None = None, + json: Any = None, + headers: dict[str, str] | None = None, + allow: tuple[int, ...] = (), + ) -> httpx.Response: + url = f"{self._base_url}/{path}" + try: + response = self._http.request( + method, + url, + params=params, + json=json, + headers={**self._headers, **(headers or {})}, + ) + except httpx.HTTPError as exc: + raise _wire.transport_error(exc, method, url) from exc + _wire.raise_for(response, allow) + return response + + def _json(self, method: str, path: str, **kwargs: Any) -> Any: + return _wire.json_of(self._request(method, path, **kwargs)) + + def _org_path(self, suffix: str, org: str | None = None) -> str: + return f"v1/orgs/{_wire.seg(org or self.org)}{suffix}" + + # ------------------------------------------------------------ repositories + + def create_repo( + self, + *, + name: str | None = None, + public: bool | None = None, + default_branch: str | None = None, + description: str | None = None, + ) -> Repo: + """Creates a repository — a real git remote, typically in under 100 ms. + + With no ``name``, a unique one is generated: the usual choice for a + repository per session. Names are letters, digits, ``-``, ``_`` and + ``.``, up to 100 characters. + """ + body = _wire.create_body(name or _wire.unique_name(), public, default_branch, description) + info = RepoInfo._from_wire(self._json("POST", self._org_path("/repos"), json=body)) + return Repo(self, info.name, info) + + def find_one(self, *, name: str) -> Repo | None: + """Looks a repository up by name. ``None`` if there is none you can see.""" + repo = self.repo(name) + try: + repo.refresh() + except WeftError as exc: + if exc.status == 404: + return None + raise + return repo + + def repo(self, name: str, info: RepoInfo | None = None) -> Repo: + """A handle on a repository you know exists. Makes no request.""" + return Repo(self, name, info) + + def list_repos( + self, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[Repo], str | None]: + """One page of repositories, and the cursor for the next (``None`` on the last).""" + w = self._json( + "GET", self._org_path("/repos"), params=_wire.query(limit=limit, after=cursor) + ) + repos = [ + Repo(self, info.name, info) + for info in (RepoInfo._from_wire(r) for r in w["repos"]) + ] + return repos, w.get("next_after") + + def iterate_repos(self, *, page_size: int | None = None) -> Iterator[Repo]: + """Every repository in the organization, fetching pages as you iterate.""" + cursor: str | None = None + while True: + repos, cursor = self.list_repos(limit=page_size, cursor=cursor) + for repo in repos: + yield repo + if not cursor: + return + + def delete_repo(self, name: str) -> None: + """Deletes a repository by name.""" + self.repo(name).delete() + + def create_repos( + self, names: list[str], *, public: bool | None = None + ) -> list[BatchResult]: + """Creates up to 1,000 repositories in one call. + + One failing does not stop the rest: check ``ok`` on each result, + which come back in request order. + """ + body = {"repos": [_wire.create_body(n, public, None, None) for n in names]} + w = self._json("POST", self._org_path("/repos/batch/create"), json=body) + return [BatchResult._from_wire(r) for r in w["results"]] + + def delete_repos(self, names: list[str]) -> list[BatchResult]: + """Deletes up to 1,000 repositories by name.""" + w = self._json("POST", self._org_path("/repos/batch/delete"), json={"names": names}) + return [BatchResult._from_wire(r) for r in w["results"]] + + def create_mirror( + self, + *, + name: str, + origin: str, + provider: str | None = None, + installation_id: str | None = None, + public: bool | None = None, + ) -> Repo: + """Mirrors a repository from GitHub (``owner/name``) or any git host (a URL, + with ``provider="generic"``). The first sync runs in the background.""" + body: Wire = {"name": name, "origin": origin} + if provider is not None: + body["provider"] = provider + if installation_id is not None: + body["installation_id"] = installation_id + if public is not None: + body["public"] = public + w = self._json("POST", self._org_path("/mirrors"), json=body) + info = RepoInfo._from_wire({**w["repo"], "org": self.org, "clone_url": w["clone_url"]}) + return Repo(self, info.name, info) + + # ------------------------------------------------------------------ tokens + + def create_token( + self, + *, + scopes: list[TokenScope], + repo: str | None = None, + label: str | None = None, + ttl: int | None = None, + ) -> CreatedToken: + """Mints an API token. The secret is in the result and nowhere else, ever. + + ``repo`` restricts it to one repository; ``ttl`` (seconds, at most a + year) makes it die on its own. + """ + return self._mint(self.org, scopes=scopes, repo=repo, label=label, ttl=ttl) + + def _mint( + self, + org: str, + *, + scopes: list[TokenScope], + repo: str | None, + label: str | None, + ttl: int | None, + ) -> CreatedToken: + body: Wire = {"scopes": scopes} + if repo is not None: + body["repo"] = repo + if label is not None: + body["label"] = label + if ttl is not None: + body["expires_in_secs"] = int(ttl) + return CreatedToken._from_wire( + self._json("POST", self._org_path("/tokens", org), json=body) + ) + + def list_tokens(self) -> list[TokenInfo]: + """Tokens, without their secrets: every token for an admin, your own for a member.""" + w = self._json("GET", self._org_path("/tokens")) + return [TokenInfo._from_wire(t) for t in w["tokens"]] + + def revoke_token(self, id: str) -> None: + """Revokes a token. It stops working on the next request.""" + self._json("DELETE", self._org_path(f"/tokens/{_wire.seg(id)}")) + + +class Repo: + """One repository. Get one from ``create_repo``, ``find_one`` or ``repo``.""" + + def __init__(self, client: Weft, name: str, info: RepoInfo | None = None) -> None: + self._client = client + self.name = name + """The repository's name.""" + self.org = info.org if info else client.org + """The namespace it lives in.""" + self._info = info + + def __repr__(self) -> str: + return f"{type(self).__name__}(org={self.org!r}, name={self.name!r})" + + @property + def info(self) -> RepoInfo | None: + """What the API last said about this repository; ``None`` until fetched.""" + return self._info + + @property + def id(self) -> str | None: + return self._info.id if self._info else None + + @property + def default_branch(self) -> str | None: + return self._info.default_branch if self._info else None + + @property + def clone_url(self) -> str: + """The HTTPS git remote, without credentials.""" + if self._info: + return self._info.clone_url + return f"{self._client.base_url}/{self.org}/{self.name}.git" + + def _path(self, suffix: str = "") -> str: + return f"v1/orgs/{_wire.seg(self.org)}/repos/{_wire.seg(self.name)}{suffix}" + + # -------------------------------------------------------------- metadata + + def refresh(self) -> RepoInfo: + """Fetches the repository's metadata, stores it on ``info`` and returns it.""" + self._info = RepoInfo._from_wire(self._client._json("GET", self._path())) + return self._info + + def update( + self, + *, + description: str | None | Unset = UNSET, + homepage: str | None | Unset = UNSET, + public: bool | Unset = UNSET, + default_branch: str | Unset = UNSET, + ) -> RepoInfo: + """Edits the repository. Arguments left out are left alone; ``None`` clears + ``description`` or ``homepage``. ``public`` and ``default_branch`` need + ``org:admin``.""" + body: Wire = {} + if description is not UNSET: + body["description"] = description + if homepage is not UNSET: + body["homepage"] = homepage + if public is not UNSET: + body["public"] = public + if default_branch is not UNSET: + body["default_branch"] = default_branch + self._info = RepoInfo._from_wire(self._client._json("PATCH", self._path(), json=body)) + return self._info + + def delete(self) -> None: + """Deletes the repository. Instant; the storage is swept later, forks survive.""" + self._client._json("DELETE", self._path()) + + def fork(self, *, org: str | None = None, name: str | None = None) -> Repo: + """Forks the repository. The fork is readable once ``info.fork_state`` is + ``ready``. Needs a token that acts for a person.""" + body: Wire = {} + if org is not None: + body["org"] = org + if name is not None: + body["name"] = name + info = RepoInfo._from_wire( + self._client._json("POST", self._path("/forks"), json=body) + ) + return Repo(self._client, info.name, info) + + # ------------------------------------------------------------------ git + + def get_remote_url( + self, *, access: str = "write", ttl: int = 3600, label: str | None = None + ) -> str: + """A git remote URL with a fresh credential scoped to this repository only: + ``https://x:weft_…@api.weft.sh/acme/session-8412.git``. + + Hand it to a sandbox or a ``git clone`` without handing over your own + token. ``access`` is ``"write"`` (the default) or ``"read"``; the + credential dies after ``ttl`` seconds (an hour by default). + """ + if access not in ("read", "write"): + raise ValueError('access must be "read" or "write"') + minted = self._client._mint( + self.org, + scopes=["repo:read" if access == "read" else "repo:write"], + repo=self.name, + label=label or f"remote:{self.name}", + ttl=ttl, + ) + return _wire.remote_url(self.clone_url, minted.token) + + # --------------------------------------------------------------- writing + + def create_commit( + self, + *, + message: str, + branch: str | None = None, + author: Identity | None = None, + expected_parent: str | None | Unset = UNSET, + context: Any = None, + ) -> CommitBuilder: + """Starts a commit. Add changes with ``.put()`` and ``.delete()``, then ``.send()``. + + Args: + message: The commit message. + branch: Defaults to ``"main"`` — not the repository's default + branch, so pass it if yours differs. Created if missing. + author: Who ``git log`` shows. Defaults to the person or token acting. + expected_parent: Optimistic concurrency. A SHA: the branch must + point exactly there, or ``send()`` raises ``WeftConflictError`` + carrying the current tip. ``None``: the branch must not exist + yet. Leave it out to commit on whatever the tip is now. + context: Anything JSON. Recorded immutably in the audit trail + beside the commit and the token that made it. + """ + return CommitBuilder( + self, + message=message, + branch=branch, + author=author, + expected_parent=expected_parent, + context=context, + ) + + def _send_commit( + self, + *, + message: str, + branch: str | None, + author: Identity | None, + expected_parent: str | None | Unset, + context: Any, + operations: list[Wire], + ) -> CommitResult: + if not operations: + raise ValueError("a commit needs at least one operation") + body: Wire = {"message": message, "operations": operations} + if branch is not None: + body["branch"] = branch + if author is not None: + body["author"] = {"name": author.name, "email": author.email} + if expected_parent is not UNSET: + body["expected_parent"] = expected_parent + if context is not None: + body["context"] = context + return CommitResult._from_wire( + self._client._json("POST", self._path("/commits"), json=body) + ) + + # --------------------------------------------------------------- reading + + def get_file( + self, path: str, *, ref: str | None = None, if_none_match: str | None = None + ) -> FileResult | None: + """Reads a file at any revision: a full commit SHA, a branch, a tag, a full + ref name or ``HEAD`` (the default). + + Returns ``None`` when the path or the revision does not exist. A + repository that does not exist (or that you cannot see) raises a 404 + ``WeftError``. Pass an earlier result's ``etag`` as ``if_none_match`` + and an unchanged file answers ``not_modified=True`` with no bytes. + """ + extra = {"Accept": "*/*"} + if if_none_match: + extra["If-None-Match"] = if_none_match + try: + response = self._client._request( + "GET", + self._path(f"/files/{_wire.path_of(path)}"), + params=_wire.query(at=ref), + headers=extra, + allow=(304,), + ) + except WeftError as exc: + if _wire.is_missing_in_repo(exc): + return None + raise + return _wire.file_result(response, if_none_match) + + def read_file(self, path: str, *, ref: str | None = None) -> str | None: + """A file as UTF-8 text, or ``None`` when it is not there. See ``get_file``.""" + file = self.get_file(path, ref=ref) + return file.text if file else None + + def get_tree( + self, + *, + path: str | None = None, + ref: str | None = None, + sizes: bool = False, + history: bool = False, + ) -> TreeResult: + """One directory: names, modes, kinds and object ids. + + ``sizes=True`` measures each blob; ``history=True`` adds the commit + that last touched each entry (a bounded walk). + """ + suffix = f"/tree/{_wire.path_of(path)}" if path else "/tree" + w = self._client._json( + "GET", self._path(suffix), params=_wire.query(at=ref, sizes=sizes, history=history) + ) + return TreeResult._from_wire(w) + + def list_files( + self, *, path: str | None = None, ref: str | None = None + ) -> ListFilesResult: + """Every path under a directory, flat, in one request.""" + suffix = f"/tree/{_wire.path_of(path)}" if path else "/tree" + w = self._client._json( + "GET", self._path(suffix), params=_wire.query(at=ref, recursive=True) + ) + return ListFilesResult._from_wire(w) + + def list_commits( + self, + *, + ref: str | None = None, + path: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> ListCommitsResult: + """First-parent history, newest first, one page at a time. + + With ``path``, only commits that changed it — each with a ``change``. + A filtered page examines at most 500 commits, so keep paging while + ``next_cursor`` is set, even when a page comes back empty. + """ + w = self._client._json( + "GET", + self._path("/log"), + params=_wire.query(rev=ref, path=path, limit=limit, after=cursor), + ) + return ListCommitsResult._from_wire(w) + + def iterate_commits( + self, *, ref: str | None = None, path: str | None = None + ) -> Iterator[CommitEntry]: + """Every commit in first-parent history, fetching pages as you iterate.""" + cursor: str | None = None + while True: + page = self.list_commits(ref=ref, path=path, cursor=cursor) + for entry in page.commits: + yield entry + cursor = page.next_cursor + if not cursor: + return + + def get_diff(self, *, from_: str, to: str) -> DiffResult: + """Every file that differs between two revisions.""" + w = self._client._json( + "GET", self._path("/diff"), params=_wire.query(**{"from": from_, "to": to}) + ) + return DiffResult._from_wire(w) + + # ------------------------------------------------------------------ refs + + def list_refs(self) -> ListRefsResult: + """Every branch and tag at once.""" + return ListRefsResult._from_wire(self._client._json("GET", self._path("/refs"))) + + def list_branches(self) -> list[Ref]: + """Branches, sorted, with the default marked.""" + w = self._client._json("GET", self._path("/branches")) + return [Ref._from_wire(r) for r in w["branches"]] + + def create_branch(self, *, name: str, from_: str) -> str: + """Creates a branch at a revision. Returns the commit it points at.""" + body = {"name": name, "from": from_} + w = self._client._json("POST", self._path("/branches"), json=body) + oid: str = w["oid"] + return oid + + def delete_branch(self, name: str) -> None: + self._client._json("DELETE", self._path(f"/branches/{_wire.seg(name)}")) + + def list_tags(self) -> list[Ref]: + """Tags, sorted.""" + w = self._client._json("GET", self._path("/tags")) + return [Ref._from_wire(r) for r in w["tags"]] + + def create_tag(self, *, name: str, target: str) -> str: + """Creates a lightweight tag at a revision. Returns the commit it points at.""" + body = {"name": name, "target": target} + w = self._client._json("POST", self._path("/tags"), json=body) + oid: str = w["oid"] + return oid + + def delete_tag(self, name: str) -> None: + self._client._json("DELETE", self._path(f"/tags/{_wire.seg(name)}")) + + # ------------------------------------------------------------------ undo + + def reset( + self, *, to: str, branch: str | None = None, expected_head: str | None = None + ) -> str: + """Moves a branch to another commit — the undo primitive. Returns the new tip. + + Commits left behind stay reachable by SHA until garbage collection, + so nothing is erased from the record. With ``expected_head``, a branch + that has moved raises ``WeftConflictError``. + """ + body: Wire = {"to": to} + if branch is not None: + body["branch"] = branch + if expected_head is not None: + body["expected_head"] = expected_head + w = self._client._json("POST", self._path("/reset"), json=body) + oid: str = w["oid"] + return oid + + def revert(self, *, branch: str | None = None, expected_head: str | None = None) -> str: + """Appends a commit that undoes the branch head, keeping history. Returns it.""" + body: Wire = {} + if branch is not None: + body["branch"] = branch + if expected_head is not None: + body["expected_head"] = expected_head + w = self._client._json("POST", self._path("/revert"), json=body) + commit: str = w["commit"] + return commit + + # -------------------------------------------------------------- webhooks + + def create_webhook(self, *, url: str) -> CreatedWebhook: + """Subscribes a URL to this repository's events. Check deliveries with + ``verify_webhook`` and the returned ``secret``.""" + w = self._client._json("POST", self._path("/webhooks"), json={"url": url}) + return CreatedWebhook(id=w["id"], url=w["url"], secret=w["secret"]) + + def list_webhooks(self) -> list[WebhookSubscription]: + w = self._client._json("GET", self._path("/webhooks")) + return [ + WebhookSubscription(id=s["id"], url=s["url"], created_at=s["created_at"]) + for s in w["subscriptions"] + ] + + def delete_webhook(self, id: str) -> None: + self._client._json("DELETE", self._path(f"/webhooks/{_wire.seg(id)}")) + + # ---------------------------------------------------------------- export + + def start_export(self) -> ExportJob: + """Starts exporting the repository as a standard git bundle.""" + w = self._client._json("POST", self._path("/export")) + return ExportJob(job=w["job"], state=w["state"], error=None, download=None) + + def get_export(self, job: str) -> ExportJob: + return ExportJob._from_wire( + self._client._json("GET", self._path(f"/export/{_wire.seg(job)}")) + ) + + def download_export(self, job: str) -> bytes: + """A finished export's bundle. ``git clone repo.bundle`` reads it.""" + response = self._client._request( + "GET", + self._path(f"/export/{_wire.seg(job)}/download"), + headers={"Accept": "application/octet-stream"}, + ) + return response.content + + +class CommitBuilder: + """Collects file changes and sends them as one commit. + + :: + + result = ( + repo.create_commit(message="agent step 12") + .put("src/app.py", source) + .delete("notes.txt") + .send() + ) + """ + + def __init__( + self, + repo: Repo, + *, + message: str, + branch: str | None, + author: Identity | None, + expected_parent: str | None | Unset, + context: Any, + ) -> None: + self._repo = repo + self._message = message + self._branch = branch + self._author = author + self._expected_parent = expected_parent + self._context = context + self._operations: list[Wire] = [] + + def put(self, path: str, content: FileContent) -> CommitBuilder: + """Writes a file, creating it or replacing what is there.""" + self._operations.append(_wire.put_operation(path, content)) + return self + + def delete(self, path: str) -> CommitBuilder: + """Removes a file.""" + self._operations.append({"op": "delete", "path": path}) + return self + + def __len__(self) -> int: + return len(self._operations) + + def send(self) -> CommitResult: + """Makes the commit. Raises ``WeftConflictError`` when ``expected_parent`` + no longer matches the branch. A commit holds up to 10,000 operations.""" + return self._repo._send_commit( + message=self._message, + branch=self._branch, + author=self._author, + expected_parent=self._expected_parent, + context=self._context, + operations=list(self._operations), + ) diff --git a/src/weftsh/_types.py b/src/weftsh/_types.py new file mode 100644 index 0000000..a8a6859 --- /dev/null +++ b/src/weftsh/_types.py @@ -0,0 +1,372 @@ +"""Result types. Every one is a frozen dataclass; timestamps are epoch milliseconds.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Any, Literal + +Wire = dict[str, Any] + + +class _Unset(enum.Enum): + UNSET = "UNSET" + + def __repr__(self) -> str: + return "UNSET" + + +UNSET = _Unset.UNSET +"""Marks an argument as not given, where ``None`` means something of its own. + +``expected_parent=None`` says "the branch must not exist yet"; leaving it +``UNSET`` (the default) says "commit on whatever the tip is now". +""" + +Unset = Literal[_Unset.UNSET] + +FileContent = str | bytes | bytearray | memoryview +"""File content. ``str`` is sent as UTF-8 text; bytes are sent as-is (base64 on the wire).""" + +TokenScope = Literal["org:admin", "org:read", "repo:read", "repo:write", "repo:cache"] + + +@dataclass(frozen=True) +class RepoInfo: + """What a repository is, as the API reports it.""" + + id: str + org: str + """The namespace it lives in, e.g. ``"acme"``.""" + org_id: str + name: str + description: str | None + homepage: str | None + kind: Literal["native", "mirror"] + public: bool + default_branch: str + clone_url: str + """HTTPS git remote, without credentials.""" + ssh_clone_url: str | None + stored_bytes: int + created_at: int + origin_url: str | None + last_sync_at: int | None + last_synced_commit: str | None + sync_error: str | None + fork_state: str | None + """``pending``, ``ready`` or ``failed`` for a fork; ``None`` otherwise.""" + fork_parent: str | None + fork_count: int + + @classmethod + def _from_wire(cls, w: Wire) -> RepoInfo: + return cls( + id=w["id"], + org=w["org"], + org_id=w["org_id"], + name=w["name"], + description=w.get("description"), + homepage=w.get("homepage"), + kind=w["kind"], + public=w["public"], + default_branch=w["default_branch"], + clone_url=w["clone_url"], + ssh_clone_url=w.get("ssh_clone_url"), + stored_bytes=w.get("stored_bytes") or 0, + created_at=w["created_at"], + origin_url=w.get("origin_url"), + last_sync_at=w.get("last_sync_at"), + last_synced_commit=w.get("last_synced_commit"), + sync_error=w.get("sync_error"), + fork_state=w.get("fork_state"), + fork_parent=w.get("fork_parent"), + fork_count=w.get("fork_count") or 0, + ) + + +@dataclass(frozen=True) +class BatchResult: + """One item of a batch create or delete, in request order.""" + + name: str + ok: bool + id: str | None + error: str | None + + @classmethod + def _from_wire(cls, w: Wire) -> BatchResult: + return cls(name=w["name"], ok=w["ok"], id=w.get("id"), error=w.get("error")) + + +@dataclass(frozen=True) +class Identity: + """A commit author.""" + + name: str + email: str + + +@dataclass(frozen=True) +class CommitResult: + """What a commit made. Durable once returned.""" + + commit: str + tree: str + parent: str | None + """The commit it was made on; ``None`` for a branch's first commit.""" + branch: str + + @classmethod + def _from_wire(cls, w: Wire) -> CommitResult: + return cls(commit=w["commit"], tree=w["tree"], parent=w.get("parent"), branch=w["branch"]) + + +@dataclass(frozen=True) +class FileResult: + """A file read at some revision.""" + + not_modified: bool + """``True`` when ``if_none_match`` matched: nothing changed, ``content`` is empty.""" + content: bytes + etag: str + """The content hash. Pass it back as ``if_none_match``.""" + commit: str | None + mode: str | None + binary: bool + content_type: str | None + + @property + def text(self) -> str: + """The content decoded as UTF-8.""" + return self.content.decode("utf-8") + + +@dataclass(frozen=True) +class LastCommit: + sha: str + message: str + author: str + + +@dataclass(frozen=True) +class TreeEntry: + name: str + mode: str + kind: Literal["blob", "tree"] + oid: str + size: int | None + """Bytes, for a blob when ``sizes=True`` was asked for; otherwise ``None``.""" + last_commit: LastCommit | None + """With ``history=True``: the last commit that touched this entry, if the walk reached it.""" + + @classmethod + def _from_wire(cls, w: Wire) -> TreeEntry: + lc = w.get("last_commit") + return cls( + name=w["name"], + mode=w["mode"], + kind=w["kind"], + oid=w["oid"], + size=w.get("size"), + last_commit=LastCommit(lc["commit"], lc["message"], lc["author"]) if lc else None, + ) + + +@dataclass(frozen=True) +class TreeResult: + commit: str + entries: list[TreeEntry] + history_truncated: bool | None + """With ``history=True``: whether the walk ran out of budget. ``None`` otherwise.""" + + @classmethod + def _from_wire(cls, w: Wire) -> TreeResult: + return cls( + commit=w["commit"], + entries=[TreeEntry._from_wire(e) for e in w["entries"]], + history_truncated=w.get("history_truncated"), + ) + + +@dataclass(frozen=True) +class ListFilesResult: + commit: str + paths: list[str] + """Every path under the directory, flat. Directories end in ``/``.""" + truncated: bool + + @classmethod + def _from_wire(cls, w: Wire) -> ListFilesResult: + return cls(commit=w["commit"], paths=w["paths"], truncated=w["truncated"]) + + +@dataclass(frozen=True) +class CommitEntry: + sha: str + tree: str + parents: list[str] + author: str + """Raw git ident line: ``Name ``.""" + committer: str + message: str + change: Literal["added", "modified", "deleted"] | None + """With a ``path`` filter: what this commit did to that path.""" + + @classmethod + def _from_wire(cls, w: Wire) -> CommitEntry: + return cls( + sha=w["commit"], + tree=w["tree"], + parents=w["parents"], + author=w["author"], + committer=w["committer"], + message=w["message"], + change=w.get("change"), + ) + + +@dataclass(frozen=True) +class ListCommitsResult: + commits: list[CommitEntry] + next_cursor: str | None + """Pass as ``cursor`` for the next page; ``None`` when history is exhausted.""" + + @classmethod + def _from_wire(cls, w: Wire) -> ListCommitsResult: + return cls( + commits=[CommitEntry._from_wire(e) for e in w["entries"]], + next_cursor=w.get("next_after"), + ) + + +@dataclass(frozen=True) +class DiffChange: + status: str + path: str + old_oid: str | None + new_oid: str | None + old_mode: str | None + new_mode: str | None + + +@dataclass(frozen=True) +class DiffResult: + from_: str + """The resolved base commit (``from`` is a keyword in Python).""" + to: str + changes: list[DiffChange] + + @classmethod + def _from_wire(cls, w: Wire) -> DiffResult: + return cls( + from_=w["from"], + to=w["to"], + changes=[ + DiffChange( + status=c["status"], + path=c["path"], + old_oid=c.get("old_oid"), + new_oid=c.get("new_oid"), + old_mode=c.get("old_mode"), + new_mode=c.get("new_mode"), + ) + for c in w["changes"] + ], + ) + + +@dataclass(frozen=True) +class Ref: + name: str + """Short name, e.g. ``main`` or ``v1.0.0``.""" + full: str + """Full ref name, e.g. ``refs/heads/main``.""" + oid: str + default: bool + + @classmethod + def _from_wire(cls, w: Wire) -> Ref: + return cls(name=w["name"], full=w["full"], oid=w["oid"], default=w["default"]) + + +@dataclass(frozen=True) +class RefTip: + name: str + """Full ref name.""" + oid: str + + +@dataclass(frozen=True) +class ListRefsResult: + head: str | None + refs: list[RefTip] + + @classmethod + def _from_wire(cls, w: Wire) -> ListRefsResult: + return cls(head=w.get("head"), refs=[RefTip(r["name"], r["oid"]) for r in w["refs"]]) + + +@dataclass(frozen=True) +class CreatedToken: + id: str + token: str + """The secret. Shown exactly once — store it now.""" + expires_at: int | None + + @classmethod + def _from_wire(cls, w: Wire) -> CreatedToken: + return cls(id=w["id"], token=w["token"], expires_at=w.get("expires_at")) + + +@dataclass(frozen=True) +class TokenInfo: + id: str + label: str | None + scopes: list[str] + repo_id: str | None + user_id: str | None + created_at: int + revoked_at: int | None + expires_at: int | None + + @classmethod + def _from_wire(cls, w: Wire) -> TokenInfo: + return cls( + id=w["id"], + label=w.get("label"), + scopes=w["scopes"], + repo_id=w.get("repo_id"), + user_id=w.get("user_id"), + created_at=w["created_at"], + revoked_at=w.get("revoked_at"), + expires_at=w.get("expires_at"), + ) + + +@dataclass(frozen=True) +class CreatedWebhook: + id: str + url: str + secret: str + """The signing secret. Shown exactly once — store it now.""" + + +@dataclass(frozen=True) +class WebhookSubscription: + id: str + url: str + created_at: int + + +@dataclass(frozen=True) +class ExportJob: + job: str + state: str + """``queued``, ``running``, ``done`` or ``failed``.""" + error: str | None + download: str | None + + @classmethod + def _from_wire(cls, w: Wire) -> ExportJob: + return cls(job=w["job"], state=w["state"], error=w.get("error"), download=w.get("download")) diff --git a/src/weftsh/_version.py b/src/weftsh/_version.py new file mode 100644 index 0000000..1cf6267 --- /dev/null +++ b/src/weftsh/_version.py @@ -0,0 +1 @@ +VERSION = "0.1.0" diff --git a/src/weftsh/_wire.py b/src/weftsh/_wire.py new file mode 100644 index 0000000..ca87c6b --- /dev/null +++ b/src/weftsh/_wire.py @@ -0,0 +1,148 @@ +"""What the sync and async clients share: request building, error mapping, encoding.""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import uuid +from typing import Any +from urllib.parse import quote + +import httpx + +from ._errors import WeftConflictError, WeftError +from ._types import FileContent, FileResult, Wire +from ._version import VERSION + +DEFAULT_BASE_URL = "https://api.weft.sh" +USER_AGENT = f"weft-python-sdk/{VERSION}" + + +def check_options(token: str, org: str) -> None: + if not isinstance(token, str) or not token.strip(): + raise ValueError("Weft: `token` must be a non-empty string (a weft_… API token)") + if not isinstance(org, str) or not org.strip(): + raise ValueError("Weft: `org` must be a non-empty string (your organization or namespace)") + + +def headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + + +def seg(value: str) -> str: + """One path segment, with every reserved character escaped (a `/` included).""" + return quote(value, safe="") + + +def path_of(path: str) -> str: + """A repository path: each segment escaped, the slashes kept.""" + return "/".join(quote(s, safe="") for s in path.split("/") if s) + + +def query(**params: Any) -> dict[str, str]: + """Drops ``None`` and ``False``; sends ``True`` as ``1``, which is what the API reads.""" + out: dict[str, str] = {} + for k, v in params.items(): + if v is None or v is False: + continue + out[k] = "1" if v is True else str(v) + return out + + +def unique_name() -> str: + return f"repo-{uuid.uuid4()}" + + +def put_operation(path: str, content: FileContent) -> dict[str, str]: + """A string goes as text; bytes go as base64, so binary content survives JSON.""" + if isinstance(content, str): + return {"op": "put", "path": path, "content": content} + if isinstance(content, (bytes, bytearray, memoryview)): + encoded = base64.b64encode(bytes(content)).decode("ascii") + return {"op": "put_base64", "path": path, "content": encoded} + raise TypeError(f"unsupported content for {path}: pass str, bytes, bytearray or memoryview") + + +def raise_for(response: httpx.Response, allow: tuple[int, ...] = ()) -> None: + """Raises a ``WeftError`` (or ``WeftConflictError`` for a 409) for a non-2xx answer.""" + if response.is_success or response.status_code in allow: + return + text = response.text + body: Any = text or None + with contextlib.suppress(ValueError): + body = json.loads(text) if text else None + message = None + if isinstance(body, dict) and isinstance(body.get("error"), str): + message = body["error"] + method = response.request.method + url = str(response.request.url) + if message is None: + message = f"{method} {response.request.url.path} answered {response.status_code}" + cls = WeftConflictError if response.status_code == 409 else WeftError + raise cls(message, status=response.status_code, method=method, url=url, body=body) + + +def transport_error(exc: httpx.HTTPError, method: str, url: str) -> WeftError: + err = WeftError(f"{method} {url} failed: {exc}", status=0, method=method, url=url) + err.__cause__ = exc + return err + + +def json_of(response: httpx.Response) -> Any: + if response.status_code == 204 or not response.content: + return None + return response.json() + + +def is_missing_in_repo(exc: WeftError) -> bool: + """A 404 about something *inside* a repository — a path or a revision. + + The server tells the two apart by shape: inside, a JSON ``{"error": …}`` + naming what was missing; the repository itself, a bare ``not found`` that + says nothing about whether it exists. A typo in a repository name must + not read as "this file does not exist". + """ + return ( + exc.status == 404 and isinstance(exc.body, dict) and isinstance(exc.body.get("error"), str) + ) + + +def file_result(response: httpx.Response, if_none_match: str | None) -> FileResult: + h = response.headers + not_modified = response.status_code == 304 + return FileResult( + not_modified=not_modified, + content=b"" if not_modified else response.content, + etag=h.get("etag") or if_none_match or "", + commit=h.get("x-weft-commit"), + mode=h.get("x-weft-mode"), + binary=h.get("x-weft-binary") == "true", + content_type=h.get("content-type"), + ) + + +def create_body( + name: str, public: bool | None, default_branch: str | None, description: str | None +) -> Wire: + body: Wire = {"name": name} + if public is not None: + body["public"] = public + if default_branch is not None: + body["default_branch"] = default_branch + if description is not None: + body["description"] = description + return body + + +def remote_url(clone_url: str, token: str) -> str: + """Puts a credential into an HTTPS clone URL as ``x:@``.""" + scheme, sep, rest = clone_url.partition("://") + if not sep: + raise ValueError(f"not a URL: {clone_url!r}") + host_and_path = rest.split("@", 1)[-1] + return f"{scheme}://x:{quote(token, safe='')}@{host_and_path}" diff --git a/src/weftsh/py.typed b/src/weftsh/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/weftsh/webhooks.py b/src/weftsh/webhooks.py new file mode 100644 index 0000000..cceadb3 --- /dev/null +++ b/src/weftsh/webhooks.py @@ -0,0 +1,46 @@ +"""Verifying webhook deliveries.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import re +from typing import Any + +SIGNATURE_HEADER = "X-Weft-Signature-256" +"""The header carrying a delivery's signature: ``sha256=``.""" + +_SIGNATURE = re.compile(r"^sha256=([0-9a-fA-F]{64})$") + + +def verify_webhook( + *, payload: bytes | str, signature: str | None, secret: str +) -> dict[str, Any] | None: + """Checks a delivery's signature and, if it is genuine, returns the event. + + Returns ``None`` for anything unsigned, mis-signed or malformed — answer + those with a ``401`` and do not act on them. The event is + ``{"event": ..., "repo_id": ..., "payload": {...}}``. + + Args: + payload: The raw request body, exactly as received — not re-serialized JSON. + signature: The ``X-Weft-Signature-256`` header. + secret: The secret returned when the webhook was created. + """ + if not signature or not secret: + return None + match = _SIGNATURE.match(signature.strip()) + if not match: + return None + body = payload.encode("utf-8") if isinstance(payload, str) else payload + expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, match.group(1).lower()): + return None + try: + event = json.loads(body) + except ValueError: + return None + if not isinstance(event, dict) or not isinstance(event.get("event"), str): + return None + return event diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..3571d48 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,364 @@ +"""The SDK against a real Weft server. The unit tests check what we send; +these check that the server agrees, which a mocked transport cannot. + + WEFT_E2E_URL=http://127.0.0.1:8080 WEFT_E2E_TOKEN=weft_… WEFT_E2E_ORG=acme \ + pytest tests/test_e2e.py + +Skipped unless all three are set. The token needs org:admin (it mints +repo-scoped tokens and creates webhooks). Every repository it makes is +deleted at the end. +""" + +from __future__ import annotations + +import http.server +import os +import shutil +import subprocess +import threading +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import pytest + +from weftsh import AsyncWeft, Identity, Repo, Weft, WeftConflictError, WeftError, verify_webhook + +URL = os.environ.get("WEFT_E2E_URL") +TOKEN = os.environ.get("WEFT_E2E_TOKEN") +ORG = os.environ.get("WEFT_E2E_ORG") + +pytestmark = pytest.mark.skipif( + not (URL and TOKEN and ORG), reason="set WEFT_E2E_URL, WEFT_E2E_TOKEN and WEFT_E2E_ORG" +) + +HAS_GIT = shutil.which("git") is not None +LOOPBACK = URL is not None and urlparse(URL).hostname in ("127.0.0.1", "localhost", "::1") + + +def git(*args: str, cwd: Path | None = None) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +# Built inside fixtures, never at import: a skipped module must not construct +# a client from unset variables. +@pytest.fixture(scope="module") +def weft() -> Iterator[Weft]: + assert URL and TOKEN and ORG + client = Weft(token=TOKEN, org=ORG, base_url=URL) + made: list[str] = [] + client.made = made # type: ignore[attr-defined] + yield client + if made: + client.delete_repos(made) + client.close() + + +def track(weft: Weft, repo: Repo) -> Repo: + weft.made.append(repo.name) # type: ignore[attr-defined] + return repo + + +@pytest.fixture(scope="module") +def state(weft: Weft) -> dict[str, Any]: + """One repository, two commits: the shared ground every read test stands on.""" + repo = track(weft, weft.create_repo(description="python sdk e2e")) + first = ( + repo.create_commit( + message="first", + expected_parent=None, + context={"run": "e2e"}, + author=Identity(name="SDK", email="sdk@weft.test"), + ) + .put("README.md", "# hello\n") + .put("src/app.py", "x = 1\n") + .put("bin/blob", b"\x00\x01\x02\xff\x00") + .send() + ) + second = ( + repo.create_commit(message="second", expected_parent=first.commit) + .put("src/app.py", "x = 2\n") + .delete("bin/blob") + .send() + ) + assert second.parent == first.commit + return {"repo": repo, "first": first.commit, "second": second.commit} + + +def test_creates_a_repository_with_a_generated_name(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + assert repo.name.startswith("repo-") + assert repo.info is not None + assert (repo.info.kind, repo.info.default_branch, repo.info.description) == ( + "native", + "main", + "python sdk e2e", + ) + assert repo.clone_url.endswith(f"/{ORG}/{repo.name}.git") + assert len(state["first"]) == 40 + + +def test_refuses_a_stale_expected_parent_with_the_current_tip(state: dict[str, Any]) -> None: + with pytest.raises(WeftConflictError) as err: + state["repo"].create_commit(message="stale", expected_parent=state["first"]).put( + "a", "b" + ).send() + assert err.value.status == 409 + assert err.value.current_tip == state["second"] + + +def test_reads_files_at_any_revision_with_etags_and_binary_intact(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + assert repo.read_file("src/app.py") == "x = 2\n" + assert repo.read_file("src/app.py", ref=state["first"]) == "x = 1\n" + assert repo.read_file("bin/blob") is None + assert repo.read_file("README.md", ref="no-such-branch") is None + + blob = repo.get_file("bin/blob", ref=state["first"]) + assert blob is not None + assert blob.content == b"\x00\x01\x02\xff\x00" and blob.binary and blob.commit == state["first"] + + readme = repo.get_file("README.md") + assert readme is not None + again = repo.get_file("README.md", if_none_match=readme.etag) + assert again is not None and again.not_modified and again.content == b"" + + +def test_a_missing_repository_raises(weft: Weft) -> None: + with pytest.raises(WeftError) as err: + weft.repo("no-such-repo-anywhere").read_file("README.md") + assert err.value.status == 404 + assert weft.find_one(name="no-such-repo-anywhere") is None + + +def test_lists_trees_and_files(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + root = repo.get_tree(sizes=True) + assert root.commit == state["second"] + by_name = {e.name: e for e in root.entries} + assert (by_name["README.md"].kind, by_name["README.md"].size) == ("blob", 8) + assert (by_name["src"].kind, by_name["src"].size) == ("tree", None) + + hist = repo.get_tree(history=True) + assert hist.history_truncated is False + src = next(e for e in hist.entries if e.name == "src") + assert src.last_commit is not None and src.last_commit.sha == state["second"] + + assert [e.name for e in repo.get_tree(path="src").entries] == ["app.py"] + listing = repo.list_files() + assert {"README.md", "src/", "src/app.py"} <= set(listing.paths) + assert listing.truncated is False + + +def test_pages_history_and_filters_it_by_path(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + page1 = repo.list_commits(limit=1) + assert [c.sha for c in page1.commits] == [state["second"]] + assert page1.next_cursor == state["second"] + page2 = repo.list_commits(limit=1, cursor=page1.next_cursor) + assert [c.sha for c in page2.commits] == [state["first"]] + assert page2.commits[0].author.startswith("SDK ") + + assert [(c.sha, c.change) for c in repo.list_commits(path="README.md").commits] == [ + (state["first"], "added") + ] + assert [c.sha for c in repo.iterate_commits()][:2] == [state["second"], state["first"]] + + +def test_diffs_two_revisions(state: dict[str, Any]) -> None: + diff = state["repo"].get_diff(from_=state["first"], to=state["second"]) + assert {c.path: c.status for c in diff.changes} == { + "bin/blob": "deleted", + "src/app.py": "modified", + } + + +def test_branches_and_tags_slashes_included(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + first, second = state["first"], state["second"] + assert repo.create_branch(name="feature/x", from_=first) == first + assert repo.create_tag(name="v1", target=second) == second + assert repo.create_tag(name="at-main", target="main") == second + assert repo.create_branch(name="from-tag", from_="v1") == second + assert repo.get_diff(from_="feature/x", to="v1").from_ == first + + assert [(b.name, b.default) for b in repo.list_branches()] == [ + ("feature/x", False), + ("from-tag", False), + ("main", True), + ] + assert [t.name for t in repo.list_tags()] == ["at-main", "v1"] + refs = repo.list_refs() + assert refs.head == "refs/heads/main" + assert "refs/heads/feature/x" in {r.name for r in refs.refs} + + for b in ("feature/x", "from-tag"): + repo.delete_branch(b) + for t in ("v1", "at-main"): + repo.delete_tag(t) + assert [b.name for b in repo.list_branches()] == ["main"] + + +def test_undoes_with_revert_and_reset_guarded_by_expected_head(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + reverted = repo.revert(expected_head=state["second"]) + assert repo.read_file("src/app.py") == "x = 1\n" + + with pytest.raises(WeftConflictError) as err: + repo.reset(to=state["first"], expected_head=state["second"]) + assert err.value.current_tip == reverted + + assert repo.reset(to=state["second"], expected_head=reverted) == state["second"] + assert repo.read_file("src/app.py") == "x = 2\n" + + +def test_updates_metadata_and_finds_it_again(weft: Weft, state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + repo.update(description="renamed", homepage="https://example.com") + found = weft.find_one(name=repo.name) + assert found is not None and found.info is not None + assert (found.info.description, found.info.homepage) == ("renamed", "https://example.com") + repo.update(homepage=None) + assert repo.refresh().homepage is None + assert repo.refresh().description == "renamed" + + +@pytest.mark.skipif(not HAS_GIT, reason="needs the git CLI") +def test_hands_out_a_working_git_remote_scoped_to_one_repo( + weft: Weft, state: dict[str, Any], tmp_path: Path +) -> None: + repo: Repo = state["repo"] + remote = repo.get_remote_url(ttl=300) + assert remote.startswith(("http://x:weft_", "https://x:weft_")) + work = tmp_path / "clone" + git("clone", "--quiet", remote, str(work)) + git("fsck", "--full", "--strict", cwd=work) + assert (work / "src" / "app.py").read_text() == "x = 2\n" + + ident = ["-c", "user.name=Git", "-c", "user.email=git@weft.test"] + git(*ident, "commit", "--quiet", "--allow-empty", "-m", "from git", cwd=work) + git("push", "--quiet", "origin", "main", cwd=work) + assert repo.list_commits(limit=1).commits[0].sha == git("rev-parse", "HEAD", cwd=work) + + read_only = repo.get_remote_url(access="read", ttl=300) + ro = tmp_path / "ro" + git("clone", "--quiet", read_only, str(ro)) + git(*ident, "commit", "--quiet", "--allow-empty", "-m", "nope", cwd=ro) + with pytest.raises(subprocess.CalledProcessError): + git("push", "--quiet", "origin", "main", cwd=ro) + + other = track(weft, weft.create_repo()) + with pytest.raises(subprocess.CalledProcessError): + git("ls-remote", remote.replace(f"/{repo.name}.git", f"/{other.name}.git")) + + +def test_mints_lists_and_revokes_tokens(weft: Weft, state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + minted = weft.create_token(scopes=["repo:read"], repo=repo.name, label="sdk-e2e", ttl=60) + assert minted.token.startswith("weft_") + assert minted.expires_at is not None and minted.expires_at > time.time() * 1000 + listed = next(t for t in weft.list_tokens() if t.id == minted.id) + assert (listed.label, listed.scopes, listed.repo_id) == ("sdk-e2e", ["repo:read"], repo.id) + + assert URL and ORG + with Weft(token=minted.token, org=ORG, base_url=URL) as scoped: + assert scoped.repo(repo.name).read_file("README.md") == "# hello\n" + weft.revoke_token(minted.id) + with pytest.raises(WeftError) as err: + scoped.repo(repo.name).read_file("README.md") + assert err.value.status in (401, 404) + + +def test_manages_webhook_subscriptions(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + hook = repo.create_webhook(url="https://hooks.example.com/weft") + assert len(hook.secret) > 10 + assert hook.id in {h.id for h in repo.list_webhooks()} + repo.delete_webhook(hook.id) + assert hook.id not in {h.id for h in repo.list_webhooks()} + + +@pytest.mark.skipif(not LOOPBACK, reason="the server must reach a listener in this process") +def test_verifies_a_delivery_the_server_really_signed(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + got: dict[str, Any] = {} + arrived = threading.Event() + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 — the stdlib's name + body = self.rfile.read(int(self.headers["Content-Length"])) + got.update(body=body, signature=self.headers.get("X-Weft-Signature-256")) + self.send_response(200) + self.end_headers() + arrived.set() + + def log_message(self, *args: Any) -> None: + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + hook = repo.create_webhook(url=f"http://127.0.0.1:{server.server_port}/weft") + made = repo.create_commit(message="webhook").put("hook.txt", "x").send() + assert arrived.wait(10), "no delivery arrived" + event = verify_webhook(payload=got["body"], signature=got["signature"], secret=hook.secret) + assert event is not None + assert (event["event"], event["repo_id"]) == ("push", repo.id) + assert event["payload"]["commit"] == made.commit + assert ( + verify_webhook(payload=got["body"], signature=got["signature"], secret="not-it") is None + ) + repo.delete_webhook(hook.id) + finally: + server.shutdown() + + +def test_creates_and_deletes_in_batches_and_iterates(weft: Weft) -> None: + names = [f"sdk-py-batch-{s}-{int(time.time() * 1000)}" for s in "abc"] + assert [(r.name, r.ok) for r in weft.create_repos(names)] == [(n, True) for n in names] + dup = weft.create_repos(names[:1]) + assert not dup[0].ok and dup[0].error + seen = {r.name for r in weft.iterate_repos(page_size=2)} + assert set(names) <= seen + assert all(r.ok for r in weft.delete_repos(names)) + + +def test_exports_a_bundle_git_can_read(state: dict[str, Any]) -> None: + repo: Repo = state["repo"] + job = repo.start_export() + for _ in range(100): + if job.state in ("done", "failed"): + break + time.sleep(0.1) + job = repo.get_export(job.job) + assert job.state == "done" + assert ( + repo.download_export(job.job)[:16] + .decode() + .startswith(("# v2 git bundle", "# v3 git bundle")) + ) + + +def test_deletes_a_repository(weft: Weft) -> None: + doomed = weft.create_repo() + doomed.delete() + assert weft.find_one(name=doomed.name) is None + + +async def test_the_async_client_against_the_same_server() -> None: + assert URL and TOKEN and ORG + async with AsyncWeft(token=TOKEN, org=ORG, base_url=URL) as weft: + repo = await weft.create_repo() + try: + one = await repo.create_commit(message="a", expected_parent=None).put("f", "1").send() + with pytest.raises(WeftConflictError) as err: + await repo.create_commit(message="b", expected_parent=None).put("f", "2").send() + assert err.value.current_tip == one.commit + assert await repo.read_file("f") == "1" + assert [c.sha async for c in repo.iterate_commits()] == [one.commit] + finally: + await repo.delete() diff --git a/tests/test_unasync.py b/tests/test_unasync.py new file mode 100644 index 0000000..ca04432 --- /dev/null +++ b/tests/test_unasync.py @@ -0,0 +1,34 @@ +"""The sync client is generated; the committed copy must match the async source.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def test_the_sync_client_is_generated_from_the_async_one() -> None: + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "unasync.py"), "--check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_sync_client_has_no_async_left_in_it() -> None: + text = (ROOT / "src" / "weftsh" / "_sync_client.py").read_text() + for word in ( + "async def", + "async for", + "async with", + "await ", + "AsyncClient", + "AsyncIterator", + "AsyncRepo", + "AsyncWeft", + "aclose", + ): + assert word not in text, word diff --git a/tests/test_unit.py b/tests/test_unit.py new file mode 100644 index 0000000..c3c1042 --- /dev/null +++ b/tests/test_unit.py @@ -0,0 +1,471 @@ +"""What the SDK sends and how it reads the answer, against a recorded transport. + +These pin the wire. ``test_e2e.py`` checks that a real server agrees. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +import httpx +import pytest + +from weftsh import ( + AsyncWeft, + Identity, + RepoInfo, + Weft, + WeftConflictError, + WeftError, + verify_webhook, +) + +REPO_WIRE: dict[str, Any] = { + "id": "r1", + "org_id": "o1", + "org": "acme", + "name": "session-1", + "description": None, + "homepage": None, + "kind": "native", + "public": False, + "default_branch": "main", + "clone_url": "https://api.weft.sh/acme/session-1.git", + "ssh_clone_url": None, + "stored_bytes": 0, + "created_at": 1766000000000, + "origin_url": None, + "last_sync_at": None, + "last_synced_commit": None, + "sync_error": None, + "fork_state": None, + "fork_parent": None, + "fork_count": 0, +} + + +@dataclass +class Recorder: + """Answers requests from a queue and remembers what was sent.""" + + answers: list[httpx.Response] + calls: list[httpx.Request] = field(default_factory=list) + + def __call__(self, request: httpx.Request) -> httpx.Response: + request.read() + self.calls.append(request) + if not self.answers: + raise AssertionError(f"unexpected request {request.method} {request.url}") + return self.answers.pop(0) + + def body(self, i: int = 0) -> Any: + return json.loads(self.calls[i].content) + + +def js(status: int, body: Any, headers: dict[str, str] | None = None) -> httpx.Response: + return httpx.Response(status, json=body, headers=headers) + + +def client( + *answers: httpx.Response, base_url: str = "https://api.weft.sh" +) -> tuple[Weft, Recorder]: + rec = Recorder(list(answers)) + http = httpx.Client(transport=httpx.MockTransport(rec)) + return Weft(token="weft_a_b", org="acme", base_url=base_url, http_client=http), rec + + +def aclient(*answers: httpx.Response) -> tuple[AsyncWeft, Recorder]: + rec = Recorder(list(answers)) + http = httpx.AsyncClient(transport=httpx.MockTransport(rec)) + return AsyncWeft(token="weft_a_b", org="acme", http_client=http), rec + + +# ------------------------------------------------------------------ client + + +def test_refuses_to_construct_without_a_token_or_an_org() -> None: + with pytest.raises(ValueError, match="token"): + Weft(token="", org="acme") + with pytest.raises(ValueError, match="org"): + Weft(token="weft_a_b", org=" ") + + +def test_creates_a_repo_with_the_bearer_token_and_snake_case_wire() -> None: + weft, rec = client(js(201, REPO_WIRE)) + repo = weft.create_repo(name="session-1", default_branch="main", public=False) + req = rec.calls[0] + assert req.method == "POST" + assert str(req.url) == "https://api.weft.sh/v1/orgs/acme/repos" + assert req.headers["authorization"] == "Bearer weft_a_b" + assert req.headers["user-agent"].startswith("weft-python-sdk/") + assert rec.body() == {"name": "session-1", "default_branch": "main", "public": False} + assert repo.name == "session-1" + assert repo.info is not None and repo.info.clone_url.endswith("/acme/session-1.git") + assert repo.default_branch == "main" + + +def test_generates_a_valid_name_when_none_is_given() -> None: + weft, rec = client(js(201, REPO_WIRE)) + weft.create_repo() + name = rec.body()["name"] + assert name.startswith("repo-") and len(name) <= 100 + assert all(c.isalnum() or c in "-_." for c in name) + + +def test_find_one_is_none_on_404_and_raises_anything_else() -> None: + weft, _ = client(httpx.Response(404, text="not found"), js(500, {"error": "boom"})) + assert weft.find_one(name="nope") is None + with pytest.raises(WeftError) as err: + weft.find_one(name="x") + assert err.value.status == 500 and err.value.message == "boom" + + +def test_repo_makes_no_request() -> None: + weft, rec = client() + repo = weft.repo("session-1") + assert repo.clone_url == "https://api.weft.sh/acme/session-1.git" + assert rec.calls == [] + + +def test_honours_a_base_url_with_a_trailing_slash() -> None: + weft, rec = client( + js(200, {"repos": [], "next_after": None}), base_url="http://localhost:8080/" + ) + weft.list_repos(limit=5) + assert str(rec.calls[0].url) == "http://localhost:8080/v1/orgs/acme/repos?limit=5" + + +def test_iterates_every_page() -> None: + weft, rec = client( + js(200, {"repos": [REPO_WIRE], "next_after": "r1"}), + js(200, {"repos": [{**REPO_WIRE, "id": "r2", "name": "session-2"}], "next_after": None}), + ) + assert [r.name for r in weft.iterate_repos(page_size=1)] == ["session-1", "session-2"] + assert rec.calls[1].url.params["after"] == "r1" + + +# ----------------------------------------------------------------- commits + + +def test_text_goes_as_put_bytes_as_put_base64_with_wire_names() -> None: + weft, rec = client(js(201, {"commit": "c2", "tree": "t2", "parent": "c1", "branch": "main"})) + result = ( + weft.repo("session-1") + .create_commit( + message="step", + expected_parent="c1", + context={"run": "r-42"}, + author=Identity(name="A", email="a@x"), + ) + .put("a.txt", "hi\n") + .put("b.bin", b"\x00\x01\xff") + .put("c.bin", bytearray(b"\x00")) + .delete("old.txt") + .send() + ) + assert (result.commit, result.parent, result.branch) == ("c2", "c1", "main") + assert rec.calls[0].url.path == "/v1/orgs/acme/repos/session-1/commits" + assert rec.body() == { + "message": "step", + "expected_parent": "c1", + "context": {"run": "r-42"}, + "author": {"name": "A", "email": "a@x"}, + "operations": [ + {"op": "put", "path": "a.txt", "content": "hi\n"}, + {"op": "put_base64", "path": "b.bin", "content": "AAH/"}, + {"op": "put_base64", "path": "c.bin", "content": "AA=="}, + {"op": "delete", "path": "old.txt"}, + ], + } + + +def test_expected_parent_none_is_an_explicit_null() -> None: + weft, rec = client(js(201, {"commit": "c1", "tree": "t", "parent": None, "branch": "b"})) + weft.repo("r").create_commit(message="m", branch="b", expected_parent=None).put("a", "b").send() + assert "expected_parent" in rec.body() and rec.body()["expected_parent"] is None + + +def test_expected_parent_left_out_is_omitted() -> None: + weft, rec = client(js(201, {"commit": "c1", "tree": "t", "parent": None, "branch": "main"})) + weft.repo("r").create_commit(message="m").put("a", "b").send() + assert "expected_parent" not in rec.body() + assert "branch" not in rec.body() + + +def test_a_409_is_a_conflict_error_with_the_current_tip() -> None: + weft, _ = client( + js( + 409, + {"error": "expected_parent does not match the current branch tip", "current_tip": "c9"}, + ) + ) + with pytest.raises(WeftConflictError) as err: + weft.repo("r").create_commit(message="m", expected_parent="c1").put("a", "b").send() + assert isinstance(err.value, WeftError) + assert err.value.status == 409 + assert err.value.current_tip == "c9" + assert "expected_parent" in str(err.value) + + +def test_reads_the_reset_conflict_shape_too() -> None: + weft, _ = client(js(409, {"error": "precondition failed on refs/heads/main", "current": "c7"})) + with pytest.raises(WeftConflictError) as err: + weft.repo("r").reset(to="c1", expected_head="c2") + assert err.value.current_tip == "c7" + + +def test_refuses_an_empty_commit_before_sending() -> None: + weft, rec = client() + with pytest.raises(ValueError, match="at least one"): + weft.repo("r").create_commit(message="m").send() + assert rec.calls == [] + + +def test_refuses_content_it_cannot_encode() -> None: + weft, _ = client() + with pytest.raises(TypeError): + weft.repo("r").create_commit(message="m").put("a", 42) # type: ignore[arg-type] + + +# ------------------------------------------------------------------- reads + + +def test_reads_a_file_with_its_headers_and_a_304_as_not_modified() -> None: + weft, rec = client( + httpx.Response( + 200, + content=b"hello\n", + headers={ + "etag": '"abc"', + "x-weft-commit": "c1", + "x-weft-mode": "100644", + "x-weft-binary": "false", + "content-type": "text/plain; charset=utf-8", + }, + ), + httpx.Response(304, headers={"etag": '"abc"'}), + ) + repo = weft.repo("r") + file = repo.get_file("src/a b.ts", ref="main") + assert file is not None + assert (file.text, file.etag, file.commit, file.binary) == ("hello\n", '"abc"', "c1", False) + assert rec.calls[0].url.raw_path.decode() == "/v1/orgs/acme/repos/r/files/src/a%20b.ts?at=main" + + again = repo.get_file("src/a b.ts", if_none_match=file.etag) + assert again is not None and again.not_modified and again.content == b"" + assert rec.calls[1].headers["if-none-match"] == '"abc"' + + +def test_a_missing_path_or_revision_reads_as_none() -> None: + weft, _ = client( + js(404, {"error": '"nope" not in this layout at "HEAD"'}), + js(404, {"error": 'unknown rev "gone"'}), + ) + assert weft.repo("r").read_file("nope") is None + assert weft.repo("r").read_file("a", ref="gone") is None + + +def test_a_missing_repository_raises_rather_than_reading_as_none() -> None: + # The server answers a missing (or invisible) repository with a bare + # `not found`, not a JSON error: a typo in the repo name must not read + # as "this file does not exist". + weft, _ = client(httpx.Response(404, text="not found")) + with pytest.raises(WeftError) as err: + weft.repo("typo").read_file("a") + assert err.value.status == 404 and err.value.body == "not found" + + +def test_maps_log_entries_and_the_cursor() -> None: + weft, rec = client( + js( + 200, + { + "entries": [ + { + "commit": "c2", + "tree": "t", + "parents": ["c1"], + "author": "A 1 +0000", + "committer": "A 1 +0000", + "message": "m\n", + "change": "modified", + } + ], + "next_after": "c2", + }, + ) + ) + page = weft.repo("r").list_commits(path="a.txt", limit=1) + assert (page.commits[0].sha, page.commits[0].parents, page.commits[0].change) == ( + "c2", + ["c1"], + "modified", + ) + assert page.next_cursor == "c2" + assert dict(rec.calls[0].url.params) == {"path": "a.txt", "limit": "1"} + + +def test_booleans_go_as_1_and_false_is_left_out() -> None: + weft, rec = client( + js(200, {"commit": "c", "paths": ["a", "src/"], "truncated": False}), + js(200, {"commit": "c", "entries": []}), + ) + assert weft.repo("r").list_files(path="src").paths == ["a", "src/"] + assert rec.calls[0].url.path == "/v1/orgs/acme/repos/r/tree/src" + assert dict(rec.calls[0].url.params) == {"recursive": "1"} + weft.repo("r").get_tree(sizes=True, history=False) + assert dict(rec.calls[1].url.params) == {"sizes": "1"} + + +def test_maps_a_diff_and_sends_from() -> None: + weft, rec = client( + js( + 200, + { + "from": "a", + "to": "b", + "changes": [ + { + "status": "added", + "path": "x", + "old_oid": None, + "new_oid": "n", + "old_mode": None, + "new_mode": "100644", + } + ], + }, + ) + ) + diff = weft.repo("r").get_diff(from_="a", to="b") + assert diff.from_ == "a" and diff.changes[0].new_oid == "n" + assert dict(rec.calls[0].url.params) == {"from": "a", "to": "b"} + + +def test_a_branch_with_a_slash_is_one_segment() -> None: + weft, rec = client(httpx.Response(204)) + weft.repo("r").delete_branch("feature/x") + assert rec.calls[0].url.raw_path.decode() == "/v1/orgs/acme/repos/r/branches/feature%2Fx" + + +def test_update_sends_only_what_was_given_and_none_clears() -> None: + weft, rec = client(js(200, REPO_WIRE), js(200, REPO_WIRE)) + weft.repo("r").update(description=None) + assert rec.body(0) == {"description": None} + weft.repo("r").update(homepage="https://example.com", public=True) + assert rec.body(1) == {"homepage": "https://example.com", "public": True} + + +# ---------------------------------------------------------------- remotes + + +def test_get_remote_url_mints_a_short_lived_repo_bound_token() -> None: + weft, rec = client(js(201, {"id": "tk", "token": "weft_tk_secret", "expires_at": 1})) + info = RepoInfo._from_wire(REPO_WIRE) + url = weft.repo("session-1", info).get_remote_url(access="read", ttl=600) + assert url == "https://x:weft_tk_secret@api.weft.sh/acme/session-1.git" + assert rec.calls[0].url.path == "/v1/orgs/acme/tokens" + assert rec.body() == { + "scopes": ["repo:read"], + "repo": "session-1", + "label": "remote:session-1", + "expires_in_secs": 600, + } + + +def test_a_forked_repo_mints_in_its_own_org() -> None: + weft, rec = client( + js(202, {**REPO_WIRE, "org": "elsewhere", "name": "fork"}), + js(201, {"id": "tk", "token": "t", "expires_at": 1}), + ) + fork = weft.repo("r").fork(org="elsewhere") + fork.get_remote_url() + assert rec.calls[1].url.path == "/v1/orgs/elsewhere/tokens" + + +# ------------------------------------------------------------------ errors + + +def test_a_network_failure_is_status_0_with_the_cause() -> None: + def boom(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("ECONNREFUSED", request=request) + + weft = Weft( + token="t", org="acme", http_client=httpx.Client(transport=httpx.MockTransport(boom)) + ) + with pytest.raises(WeftError) as err: + weft.list_repos() + assert err.value.status == 0 and "ECONNREFUSED" in str(err.value) + assert isinstance(err.value.__cause__, httpx.ConnectError) + + +def test_a_non_json_error_body_is_kept_as_text() -> None: + weft, _ = client(httpx.Response(502, text="upstream went away")) + with pytest.raises(WeftError) as err: + weft.list_repos() + assert err.value.status == 502 and err.value.body == "upstream went away" + + +# ------------------------------------------------------------------- async + + +async def test_the_async_client_speaks_the_same_wire() -> None: + weft, rec = aclient( + js(201, REPO_WIRE), + js(201, {"commit": "c1", "tree": "t", "parent": None, "branch": "main"}), + js(409, {"error": "stale", "current_tip": "c9"}), + js(404, {"error": "unknown rev"}), + js(200, {"repos": [REPO_WIRE], "next_after": None}), + ) + async with weft: + repo = await weft.create_repo(name="session-1") + result = ( + await repo.create_commit(message="m", expected_parent=None).put("a", b"\x00").send() + ) + assert result.commit == "c1" + assert rec.body(1)["expected_parent"] is None + assert rec.body(1)["operations"] == [{"op": "put_base64", "path": "a", "content": "AA=="}] + with pytest.raises(WeftConflictError) as err: + await repo.create_commit(message="m", expected_parent="c1").put("a", "b").send() + assert err.value.current_tip == "c9" + assert await repo.read_file("a", ref="gone") is None + assert [r.name async for r in weft.iterate_repos()] == ["session-1"] + + +# ---------------------------------------------------------------- webhooks + +SECRET = "whsec" +BODY = json.dumps( + {"event": "push", "repo_id": "r1", "payload": {"via": "api", "commit": "c1", "branch": "main"}} +) + + +def sign(body: str, secret: str = SECRET) -> str: + return "sha256=" + hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + + +def test_verify_webhook_accepts_a_genuine_delivery() -> None: + event = verify_webhook(payload=BODY, signature=sign(BODY), secret=SECRET) + assert event is not None and event["event"] == "push" and event["repo_id"] == "r1" + assert verify_webhook(payload=BODY.encode(), signature=sign(BODY), secret=SECRET) is not None + + +@pytest.mark.parametrize( + "mutate", + [ + lambda: {"payload": BODY + " ", "signature": sign(BODY), "secret": SECRET}, + lambda: {"payload": BODY, "signature": sign(BODY, "other"), "secret": SECRET}, + lambda: {"payload": BODY, "signature": None, "secret": SECRET}, + lambda: {"payload": BODY, "signature": "sha1=abc", "secret": SECRET}, + lambda: {"payload": BODY, "signature": sign(BODY), "secret": ""}, + lambda: {"payload": "not json", "signature": sign("not json"), "secret": SECRET}, + ], + ids=["tampered", "wrong-secret", "missing", "malformed", "no-secret", "not-json"], +) +def test_verify_webhook_rejects(mutate: Callable[[], dict[str, Any]]) -> None: + assert verify_webhook(**mutate()) is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4858bf3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,568 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "weftsh" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "httpx", specifier = ">=0.27" }] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.11" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.6" }, +] From 35b0ff90763811b9e7dfced565ee9d59e5809bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:18:12 +0000 Subject: [PATCH 2/3] pyproject: point Homepage at /docs/sdk-python/, where the guide lives Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYuyBuroUvxh32x9SCbx13 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f76b803..e3ed0b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ dependencies = ["httpx>=0.27"] [project.urls] -Homepage = "https://weft.sh/docs/sdk/python/" +Homepage = "https://weft.sh/docs/sdk-python/" Source = "https://github.com/weftsh/python-sdk" Issues = "https://github.com/weftsh/python-sdk/issues" From 5500b14abec994557b363cbb6df7f5283e13b319 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:42:46 +0000 Subject: [PATCH 3/3] README: a real quickstart, run by the tests; fix the mangled first example Same as the TypeScript SDK: a Quickstart section (token with the scopes it needs, install, run examples/quickstart.py, expected output) whose script is embedded verbatim, held to examples/quickstart.py by tests/test_readme.py, and run as a subprocess by the e2e suite. The first example in the README was mangled: ruff 0.16 formats Python code blocks inside Markdown, and `ruff format .` had rewritten a readable builder chain into one split across a call's parentheses. Markdown is now excluded from formatting, as is the quickstart script (it keeps the README's hand layout), and the example is rewritten. get_remote_url() now explains a 400/403/404 from the mint in words naming the scopes needed (org:read plus repo:write, or org:admin) instead of passing on the server's masked 404, and the README's note on the scopes is corrected; both checked with real personal tokens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYuyBuroUvxh32x9SCbx13 --- README.md | 106 ++++++++++++++++++++++++++++++++++-- examples/quickstart.py | 34 ++++++++++++ pyproject.toml | 8 ++- src/weftsh/_async_client.py | 32 ++++++++--- src/weftsh/_sync_client.py | 32 ++++++++--- tests/test_e2e.py | 22 ++++++++ tests/test_readme.py | 21 +++++++ tests/test_unit.py | 25 +++++++++ 8 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 examples/quickstart.py create mode 100644 tests/test_readme.py diff --git a/README.md b/README.md index 445d405..e876ace 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,12 @@ weft = Weft(token=os.environ["WEFT_TOKEN"], org="acme") repo = weft.create_repo() -repo.create_commit(message="agent step 1").put("src/app.py", "print('hello')\n").put( - "README.md", "# session\n" -).send() +( + repo.create_commit(message="agent step 1") + .put("src/app.py", "print('hello')\n") + .put("README.md", "# session\n") + .send() +) print(repo.read_file("src/app.py")) print(repo.get_remote_url()) # https://x:weft_…@api.weft.sh/acme/repo-….git @@ -28,8 +31,98 @@ you can clone, push to and export. dataclasses. - Python 3.10+. +## Quickstart + +From nothing to a repository you have committed to over HTTP and cloned with +`git`, in about five minutes. + +**1. Get a token.** [Create an account](https://weft.sh/login?mode=signup) +(free, no card) and an organization, then mint a token under +**Settings → Tokens** with `org:read` and `repo:write`. `repo:write` creates and commits; +`org:read` lets the SDK mint the short-lived clone credential in step 4. An +`org:admin` token does both. + +```bash +export WEFT_TOKEN=weft_… # the token you just minted +export WEFT_ORG=acme # your organization's name +``` + +**2. Install.** + +```bash +pip install weftsh +``` + +**3. Save this as `quickstart.py` and run it.** It needs `git` on your `PATH` for +the last step. + + +```python +import os +import subprocess +import tempfile +from pathlib import Path + +from weftsh import DEFAULT_BASE_URL, Weft + +weft = Weft( + token=os.environ["WEFT_TOKEN"], + org=os.environ["WEFT_ORG"], + base_url=os.environ.get("WEFT_URL", DEFAULT_BASE_URL), # optional +) + +# 1. A repository of its own: a real git remote, made in well under a second. +repo = weft.create_repo() +print("created ", repo.name) + +# 2. A commit, straight over HTTP. No clone, no checkout, no disk. +result = ( + repo.create_commit(message="first commit") + .put("hello.txt", "hello from the Weft SDK\n") + .send() +) +print("committed ", result.commit[:7]) + +# 3. Read it back, at the branch tip or at any commit. +print("read back ", repr(repo.read_file("hello.txt"))) + +# 4. It is still git. This URL carries a credential for this repository +# only, and it expires in an hour. +url = repo.get_remote_url() +clone = Path(tempfile.mkdtemp(prefix="weft-")) / repo.name +subprocess.run(["git", "clone", "--quiet", url, str(clone)], check=True) +print("cloned ", (clone / "hello.txt").read_text().strip()) +``` + + +```bash +python quickstart.py +``` + +**4. See what it did.** You should get something like this (your +repository name and commit will differ): + +```text +created repo-75f21a56-2e6d-445f-bcde-09a7f20d0bfb +committed 889ba6d +read back 'hello from the Weft SDK\n' +cloned hello from the Weft SDK +``` + +That repository is yours: it is in the dashboard, you can `git push` to it, +and it costs nothing while it sits there. Run the script again and you get a +second one. + +**Where next:** + +- [Commits](#commits): branches, concurrency with `expected_parent`, and the audit `context` +- [Reading](#reading): any file at any revision, history, diffs +- [Git remotes](#git-remotes): read-only URLs, lifetimes, and what the credential can reach +- [asyncio](#asyncio): the same client with `await` + ## Contents +- [Quickstart](#quickstart) - [Install](#install) - [Set up the client](#set-up-the-client) - [Repositories](#repositories) @@ -320,9 +413,10 @@ git commit -am "from a sandbox" && git push | `ttl` | `3600` | Seconds until the credential dies. At most a year. | | `label` | `remote:` | Shown in the token list and in the audit trail. | -Each call mints a new token, which needs a client token allowed to mint: -`org:admin` for a service token, or a personal token whose owner can write to -the repository. `repo.clone_url` is the same URL with no credential in it. +Each call mints a new token, so the client's own token must be allowed to +mint one: an `org:admin` token, or a personal token carrying `org:read` (to +mint) and `repo:write` (to grant write access; `repo:read` is enough for +`access="read"`). `repo.clone_url` is the same URL with no credential in it. ## Tokens diff --git a/examples/quickstart.py b/examples/quickstart.py new file mode 100644 index 0000000..d0dc294 --- /dev/null +++ b/examples/quickstart.py @@ -0,0 +1,34 @@ +import os +import subprocess +import tempfile +from pathlib import Path + +from weftsh import DEFAULT_BASE_URL, Weft + +weft = Weft( + token=os.environ["WEFT_TOKEN"], + org=os.environ["WEFT_ORG"], + base_url=os.environ.get("WEFT_URL", DEFAULT_BASE_URL), # optional +) + +# 1. A repository of its own: a real git remote, made in well under a second. +repo = weft.create_repo() +print("created ", repo.name) + +# 2. A commit, straight over HTTP. No clone, no checkout, no disk. +result = ( + repo.create_commit(message="first commit") + .put("hello.txt", "hello from the Weft SDK\n") + .send() +) +print("committed ", result.commit[:7]) + +# 3. Read it back, at the branch tip or at any commit. +print("read back ", repr(repo.read_file("hello.txt"))) + +# 4. It is still git. This URL carries a credential for this repository +# only, and it expires in an hour. +url = repo.get_remote_url() +clone = Path(tempfile.mkdtemp(prefix="weft-")) / repo.name +subprocess.run(["git", "clone", "--quiet", url, str(clone)], check=True) +print("cloned ", (clone / "hello.txt").read_text().strip()) diff --git a/pyproject.toml b/pyproject.toml index e3ed0b8..34c0c3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,5 +57,9 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"] "src/weftsh/_sync_client.py" = ["UP028"] [tool.ruff.format] -# Generated; `scripts/unasync.py --check` holds it to the async source instead. -exclude = ["src/weftsh/_sync_client.py"] +# The sync client is generated; `scripts/unasync.py --check` holds it to the +# async source instead. Markdown is excluded because ruff reformats the Python +# inside its code blocks, which rewrote the README's hand-laid examples into +# something correct and hard to read. The quickstart is embedded in the README +# verbatim, so it keeps the same hand layout. +exclude = ["src/weftsh/_sync_client.py", "*.md", "examples/quickstart.py"] diff --git a/src/weftsh/_async_client.py b/src/weftsh/_async_client.py index cbcefd4..a702f00 100644 --- a/src/weftsh/_async_client.py +++ b/src/weftsh/_async_client.py @@ -371,13 +371,31 @@ async def get_remote_url( """ if access not in ("read", "write"): raise ValueError('access must be "read" or "write"') - minted = await self._client._mint( - self.org, - scopes=["repo:read" if access == "read" else "repo:write"], - repo=self.name, - label=label or f"remote:{self.name}", - ttl=ttl, - ) + scope: TokenScope = "repo:read" if access == "read" else "repo:write" + try: + minted = await self._client._mint( + self.org, + scopes=[scope], + repo=self.name, + label=label or f"remote:{self.name}", + ttl=ttl, + ) + except WeftError as exc: + # The server answers a token that may not mint with a bare 404 (it + # does not say what exists to a caller who cannot see it), which + # reads as nonsense halfway through a quickstart. Say what is needed. + if exc.status not in (400, 403, 404): + raise + raise WeftError( + f"get_remote_url could not mint a credential for {self.org}/{self.name} " + f"({exc.status}: {exc.message}). It mints a token scoped to this " + f"repository, so the client's token needs org:read and {scope}, " + "or org:admin — and the repository must exist.", + status=exc.status, + method=exc.method, + url=exc.url, + body=exc.body, + ) from exc return _wire.remote_url(self.clone_url, minted.token) # --------------------------------------------------------------- writing diff --git a/src/weftsh/_sync_client.py b/src/weftsh/_sync_client.py index 92a25bc..a71159f 100644 --- a/src/weftsh/_sync_client.py +++ b/src/weftsh/_sync_client.py @@ -371,13 +371,31 @@ def get_remote_url( """ if access not in ("read", "write"): raise ValueError('access must be "read" or "write"') - minted = self._client._mint( - self.org, - scopes=["repo:read" if access == "read" else "repo:write"], - repo=self.name, - label=label or f"remote:{self.name}", - ttl=ttl, - ) + scope: TokenScope = "repo:read" if access == "read" else "repo:write" + try: + minted = self._client._mint( + self.org, + scopes=[scope], + repo=self.name, + label=label or f"remote:{self.name}", + ttl=ttl, + ) + except WeftError as exc: + # The server answers a token that may not mint with a bare 404 (it + # does not say what exists to a caller who cannot see it), which + # reads as nonsense halfway through a quickstart. Say what is needed. + if exc.status not in (400, 403, 404): + raise + raise WeftError( + f"get_remote_url could not mint a credential for {self.org}/{self.name} " + f"({exc.status}: {exc.message}). It mints a token scoped to this " + f"repository, so the client's token needs org:read and {scope}, " + "or org:admin — and the repository must exist.", + status=exc.status, + method=exc.method, + url=exc.url, + body=exc.body, + ) from exc return _wire.remote_url(self.clone_url, minted.token) # --------------------------------------------------------------- writing diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 3571d48..9eaa37b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -13,8 +13,10 @@ import http.server import os +import re import shutil import subprocess +import sys import threading import time from collections.abc import Iterator @@ -343,6 +345,26 @@ def test_exports_a_bundle_git_can_read(state: dict[str, Any]) -> None: ) +@pytest.mark.skipif(not HAS_GIT, reason="needs the git CLI") +def test_runs_the_readme_quickstart_exactly_as_written(weft: Weft) -> None: + # A subprocess, importing `weftsh` the way somebody who copied it would. + assert URL and TOKEN and ORG + root = Path(__file__).resolve().parent.parent + out = subprocess.run( + [sys.executable, str(root / "examples" / "quickstart.py")], + env={**os.environ, "WEFT_TOKEN": TOKEN, "WEFT_ORG": ORG, "WEFT_URL": URL}, + check=True, + capture_output=True, + text=True, + ).stdout + name = re.search(r"^created\s+(\S+)$", out, re.M) + assert name and name.group(1).startswith("repo-"), out + track(weft, weft.repo(name.group(1))) + assert re.search(r"^committed\s+[0-9a-f]{7}$", out, re.M), out + assert re.search(r"^read back\s+'hello from the Weft SDK\\n'$", out, re.M), out + assert re.search(r"^cloned\s+hello from the Weft SDK$", out, re.M), out + + def test_deletes_a_repository(weft: Weft) -> None: doomed = weft.create_repo() doomed.delete() diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..1717f36 --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,21 @@ +"""The README's quickstart is what people copy; examples/quickstart.py is what +the e2e suite runs. If the two drift, the one people copy is untested.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def test_the_readme_quickstart_is_examples_quickstart_py() -> None: + readme = (ROOT / "README.md").read_text() + example = (ROOT / "examples" / "quickstart.py").read_text() + block = re.search( + r"\n```python\n(.*?)```\n", + readme, + re.S, + ) + assert block, "quickstart markers missing from README.md" + assert block.group(1) == example diff --git a/tests/test_unit.py b/tests/test_unit.py index c3c1042..db81023 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -388,6 +388,31 @@ def test_a_forked_repo_mints_in_its_own_org() -> None: assert rec.calls[1].url.path == "/v1/orgs/elsewhere/tokens" +@pytest.mark.parametrize("status", [404, 403, 400]) +def test_get_remote_url_explains_a_refused_mint(status: int) -> None: + weft, _ = client(js(status, {"error": "not found"})) + with pytest.raises(WeftError) as err: + weft.repo("session-1").get_remote_url() + assert err.value.status == status + assert "could not mint a credential for acme/session-1" in err.value.message + assert "org:read and repo:write, or org:admin" in err.value.message + assert isinstance(err.value.__cause__, WeftError) + + +def test_get_remote_url_names_repo_read_for_a_read_only_url() -> None: + weft, _ = client(js(404, {"error": "not found"})) + with pytest.raises(WeftError) as err: + weft.repo("r").get_remote_url(access="read") + assert "org:read and repo:read" in err.value.message + + +def test_get_remote_url_passes_anything_else_through() -> None: + weft, _ = client(js(500, {"error": "boom"})) + with pytest.raises(WeftError) as err: + weft.repo("r").get_remote_url() + assert err.value.message == "boom" + + # ------------------------------------------------------------------ errors