diff --git a/.github/workflows/id_tests.yml b/.github/workflows/id_tests.yml index 0aa803120d..d0c2fd309a 100644 --- a/.github/workflows/id_tests.yml +++ b/.github/workflows/id_tests.yml @@ -1,7 +1,9 @@ name: Test ID Packages -# `@e2b/id` is a pure codec with no network and no API key, so unlike the SDK -# suites this one needs no secrets and no staging leg. +# `@e2b/id` and `e2b_id` are pure codecs with no network and no API key, so +# unlike the SDK suites this one needs no secrets and no staging leg. Both run in +# one job because their point is that they agree: the two suites pin the same +# golden vectors and the same corpus digest. on: workflow_call: @@ -49,18 +51,31 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # The interop suite drives python3 to prove the format really is - # "b32encode, lowercase, strip padding, rotate", so an interpreter has to - # be on PATH even though the package itself has no Python component. - - name: Setup Python - uses: actions/setup-python@v5 + # Also gives js-id's interop suite the interpreter it drives to prove the + # format really is "b32encode, lowercase, strip padding, rotate". + - name: Install uv + uses: astral-sh/setup-uv@v6 with: + version: '${{ env.TOOL_VERSION_UV }}' python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Install Python dependencies + working-directory: packages/python-id + run: uv sync --locked - name: Test build (JS) working-directory: packages/js-id run: pnpm build + - name: Test build (Python) + working-directory: packages/python-id + run: uv build + - name: Run tests (JS) working-directory: packages/js-id run: pnpm test + + - name: Run tests (Python) + working-directory: packages/python-id + run: uv run pytest --verbose diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 06e8c1e355..a848cb8e1e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -81,6 +81,11 @@ jobs: run: | uv sync --locked + - name: Install Python dependencies (id) + working-directory: packages/python-id + run: | + uv sync --locked + - name: Run linting run: | pnpm run lint diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml index 35577b0b63..229a0a6fb4 100644 --- a/.github/workflows/sdk_tests.yml +++ b/.github/workflows/sdk_tests.yml @@ -56,6 +56,7 @@ jobs: - *shared - '.github/workflows/id_tests.yml' - 'packages/js-id/**/!(*.md)' + - 'packages/python-id/**/!(*.md)' js-tests: name: Production / JS SDK Tests diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 4ae251189d..586c5fe503 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -77,6 +77,11 @@ jobs: run: | uv sync --locked + - name: Install Python dependencies (id) + working-directory: packages/python-id + run: | + uv sync --locked + - name: Run typecheck run: | pnpm run typecheck diff --git a/.gitignore b/.gitignore index 7ab229f280..3edcba0e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -182,6 +182,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ cover/ # Translations diff --git a/packages/js-id/README.md b/packages/js-id/README.md index 5de708fc16..897c6da72c 100644 --- a/packages/js-id/README.md +++ b/packages/js-id/README.md @@ -25,8 +25,9 @@ isId('project', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g') // false ``` -The format is deliberately portable: any language can read these IDs with its -standard library. A Python port lands alongside this one. +[`e2b_id`](../python-id) is the same format in Python, name for name — its +README lists [the one place they differ](../python-id/README.md#where-this-differs-from-e2bid) +(Python moves `uuid.UUID` objects where JS moves hex strings). ## The format @@ -126,7 +127,8 @@ pnpm test pnpm lint && pnpm typecheck ``` -`tests/vectors.ts` holds the golden encodings, a seeded 1303-value corpus and a -`CORPUS_DIGEST` over it. Every port of this format pins the same three, so a -change to the format on one side alone cannot pass on the other. `pnpm test` also -pipes the whole corpus through `python3` to check the six-line snippet above. +`tests/vectors.ts` and `packages/python-id/tests/vectors.py` are the same file in +two languages: the same golden encodings, the same seeded corpus, and the same +`CORPUS_DIGEST` over it. Changing the format on one side alone fails on both. +`pnpm test` also pipes the whole corpus through `python3` to check the six-line +snippet above. diff --git a/packages/js-id/tests/codec.test.ts b/packages/js-id/tests/codec.test.ts index 5666d0a655..1ad5a3f8f5 100644 --- a/packages/js-id/tests/codec.test.ts +++ b/packages/js-id/tests/codec.test.ts @@ -76,8 +76,8 @@ describe('the format', () => { } }) - // The one constant every port holds: same corpus, same format, same digest. - test('the corpus digest is the cross-implementation contract', () => { + // The one constant both suites hold: same corpus, same format, same digest. + test('the corpus is the same corpus e2b_id checks', () => { expect(corpusDigest(encodeBytes)).toBe(CORPUS_DIGEST) }) diff --git a/packages/js-id/tests/interop.test.ts b/packages/js-id/tests/interop.test.ts index c759dd54ff..1c58a50eec 100644 --- a/packages/js-id/tests/interop.test.ts +++ b/packages/js-id/tests/interop.test.ts @@ -12,8 +12,8 @@ import { corpus } from './vectors' * The snippet below is the one in `src/codec.ts`'s documentation and in * `@e2b/id`'s README, verbatim: six lines of `base64` and no tables. If this * passes, the format really is "b32encode, lowercase, strip padding, rotate", - * so any port tested the same way against the same corpus decodes what this - * package emits. + * and `e2b_id` — which is tested the same way against the same corpus — decodes + * what this package emits. */ const SNIPPET = ` import base64, sys @@ -67,8 +67,8 @@ describe.skipIf(process.env.E2B_ID_SKIP_INTEROP === '1')( () => { test('in both directions, over the whole corpus', () => { // Not a skip: on CI this suite is the only thing standing between a - // format change and a silent fork from the other ports, so a missing - // interpreter has to be loud. + // format change and a silent fork from e2b_id, so a missing interpreter + // has to be loud. expect( python, 'no python3 on PATH; set E2B_ID_SKIP_INTEROP=1 to skip this suite deliberately' diff --git a/packages/js-id/tests/vectors.ts b/packages/js-id/tests/vectors.ts index 5873228831..86559d5948 100644 --- a/packages/js-id/tests/vectors.ts +++ b/packages/js-id/tests/vectors.ts @@ -1,11 +1,11 @@ /** * The shared test material: golden encodings, and a deterministic corpus. * - * Every port of this format carries this file value for value — the same - * goldens, the same LCG, the same corpus in the same order. That is what keeps - * implementations from drifting: each checks the same inputs against the same - * fixed outputs and against its own independent reference encoder, so agreeing - * with this file means agreeing with each other. + * `packages/python-id/tests/vectors.py` is the same file in Python, value for + * value — the same goldens, the same LCG, the same corpus in the same order. + * That is what keeps the two implementations from drifting: both check the same + * inputs against the same fixed outputs and against their own independent + * reference encoder, so agreeing with this file means agreeing with each other. */ import { createHash } from 'node:crypto' @@ -87,8 +87,8 @@ export const GOLDEN_IDS: ReadonlyArray< /** * sha256 of every encoding in {@link corpus}, newline-joined, in order. The same - * constant is pinned by every port; if two implementations ever disagree about - * the corpus or the format, one of them fails on it. + * constant appears in `vectors.py`; if the two implementations ever disagree + * about the corpus or the format, one of them fails on it. */ export const CORPUS_DIGEST = 'a83c455e0a4ffd39e51c69d832c544b6809bf836f6459e2a9797005dddc71ec0' diff --git a/packages/python-id/LICENSE b/packages/python-id/LICENSE new file mode 100644 index 0000000000..4aa6529d10 --- /dev/null +++ b/packages/python-id/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 FOUNDRYLABS, INC. + +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/packages/python-id/Makefile b/packages/python-id/Makefile new file mode 100644 index 0000000000..18d8f25f07 --- /dev/null +++ b/packages/python-id/Makefile @@ -0,0 +1,13 @@ +typecheck: + ty check + +lint: + ruff check . + ruff format --check . + +format: + ruff format . + +.PHONY: test +test: + uv run pytest --verbose diff --git a/packages/python-id/README.md b/packages/python-id/README.md new file mode 100644 index 0000000000..0d665cdd34 --- /dev/null +++ b/packages/python-id/README.md @@ -0,0 +1,139 @@ +# `e2b_id` + +Prefixed, human-legible IDs for E2B resources. Standard library only. + +> **Not on PyPI yet.** Publishing needs a PyPI project and a scoped token first; +> until then use it from this repo (`uv add ../python-id`). See +> [Releasing](#releasing). + +```python +from e2b_id import create_id, decode_id, encode_id, is_id, parse_id + +create_id("project") +# 'prj_uk75vf2v7iagp2kgn7pfze3car' + +encode_id("volume", "019fa519-bfc5-784d-9386-a5d7a93a692a") +# 'vol_uxl2sotjfiagp2kgn7yv4e3e4g' + +decode_id("volume", "vol_uxl2sotjfiagp2kgn7yv4e3e4g") +# UUID('019fa519-bfc5-784d-9386-a5d7a93a692a') + +parse_id("vol_uxl2sotjfiagp2kgn7yv4e3e4g") +# ParsedId(kind='volume', uuid=UUID('019fa519-bfc5-784d-9386-a5d7a93a692a')) + +is_id("project", "vol_uxl2sotjfiagp2kgn7yv4e3e4g") +# False +``` + +[`@e2b/id`](../js-id) is the same format in TypeScript, name for name — see +[the one place they differ](#where-this-differs-from-e2bid). + +## The format + +An ID is a three-character kind prefix, an underscore, and 26 characters of +encoded UUID — 30 characters in all: + +```text +prj_uk75vf2v7iagp2kgn7pfze3car +wrk_imkhttdroeagp2kgn7t53cvkiw +vol_uxl2sotjfiagp2kgn7yv4e3e4g +sbx_blo7looa3eagp2kgn75n47fkrk +usr_zm52rsumcyagp2kgoacf6i5ibz +grp_byblfq6upe6r5mcc2yzrbxfjlh +``` + +| kind | prefix | +| ----------- | ------ | +| `project` | `prj` | +| `workspace` | `wrk` | +| `volume` | `vol` | +| `sandbox` | `sbx` | +| `user` | `usr` | +| `group` | `grp` | + +The UUID's 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet +(`a-z2-7`), lowercased, unpadded — 26 characters — and then **rotated left by +16**, so what was the first character ends up 11th. + +The rotation is the whole trick. A UUIDv7 leads with a 48-bit millisecond +timestamp, so unrotated encodings of IDs minted around the same time share a +long common prefix, and the leading characters barely move for months. Rotating +puts 10 characters of random bits in front, the timestamp from index 10, and the +rest of the random bits behind it. The trade is that encoded order no longer +follows time — don't build an index expecting it to. + +The alphabet is exactly the one `base64.b32encode` uses, so any language can +read these IDs with its standard library and no tables: + +```python +import base64 + +def encode(b: bytes) -> str: + s = base64.b32encode(b).decode().rstrip("=").lower() + return s[16:] + s[:16] + +def decode(s: str) -> bytes: + s = s[10:] + s[:10] + return base64.b32decode(s.upper() + "======") +``` + +### One spelling per ID + +26 base32 digits carry 130 bits and a UUID has 128, so two bits of the encoding +are always zero and every UUID has four strings a permissive decoder maps to it. +`decode_id` accepts only the one `encode_id` produces and raises +`InvalidIdException` for the other three, along with uppercase, padding and +anything outside the alphabet. UUID arguments are held to the canonical +8-4-4-4-12 hex form for the same reason, even though `uuid.UUID` itself would +take braced and unhyphenated spellings. + +## API + +| | | +| --- | --- | +| `create_id(kind)` | mint an ID for a new resource, from a fresh UUIDv7 | +| `encode_id(kind, uuid)` | encode a UUID you already have | +| `decode_id(kind, id)` | the UUID an ID carries; raises if the kind is wrong | +| `parse_id(id)` | `ParsedId(kind, uuid)`, when the kind is what you want to find out | +| `is_id(kind, value)` | `decode_id` without the raise; `False` for non-strings | +| `create_uuid()` | mint a UUIDv7 (`uuid.uuid7` needs Python 3.14) | +| `encode_bytes(raw)` / `decode_bytes(encoded)` | the prefix-free codec, over any 16 bytes | +| `ID_PREFIXES`, `ID_LENGTH`, `ENCODED_LENGTH`, `DECODED_LENGTH`, `ALPHABET` | the constants above | +| `IdKind`, `ParsedId`, `InvalidIdException`, `InvalidIdReason` | the types | + +### Where this differs from `@e2b/id` + +Everything above is named identically in both packages. The only divergence is +that JavaScript has no UUID type, so `@e2b/id` moves UUIDs as canonical hex +strings where this package moves `uuid.UUID` objects: + +- `@e2b/id` also exports `uuidToBytes`/`bytesToUuid`; here `u.bytes` and + `uuid.UUID(bytes=…)` already are those. +- `create_uuid()`, `decode_id()` and `ParsedId.uuid` give you a `uuid.UUID`, + where their JS counterparts give a string — so a round trip is string-to-string + in JS but not here. +- `encode_id()` takes either a `uuid.UUID` or the hex form. + +## Development + +```sh +uv sync +uv run pytest +uv run make lint typecheck +``` + +`tests/vectors.py` and `packages/js-id/tests/vectors.ts` are the same file in +two languages: the same golden encodings, the same seeded corpus, and the same +`CORPUS_DIGEST` over it. Changing the format on one side alone fails on both. + +## Releasing + +This package is deliberately **not** wired into the release pipeline yet. +`packages/python-sdk/package.json` publishes via a `postPublish` script +(`uv build && uv publish --token ${PYPI_TOKEN}`); `packages/python-id` has no +such script, because pointing one at a PyPI project that does not exist — with a +token that may not be scoped for it — would fail `pnpm run publish` and take the +whole monorepo's release down with it. + +To publish for the first time: register the `e2b_id` project on PyPI, confirm +`PYPI_TOKEN` covers it, then add the same `postPublish` line python-sdk has. diff --git a/packages/python-id/e2b_id/__init__.py b/packages/python-id/e2b_id/__init__.py new file mode 100644 index 0000000000..8e259fb028 --- /dev/null +++ b/packages/python-id/e2b_id/__init__.py @@ -0,0 +1,58 @@ +"""``e2b_id`` — prefixed, human-legible IDs for E2B resources. + +:: + + from e2b_id import create_id, decode_id, is_id, parse_id + + id = create_id("project") # 'prj_uk75vf2v7iagp2kgn7pfze3car' + decode_id("project", id) # UUID('019fa519-bf79-724d-8811-a2bfda9755fa') + is_id("volume", id) # False + parse_id(id) # ParsedId(kind='project', uuid=UUID('019fa519-…')) + +An ID is a kind prefix and the 26-character rotated base32 encoding of a UUID. +See :mod:`e2b_id.ids` for what the prefix buys and :mod:`e2b_id.codec` for what +the rotation buys. ``@e2b/id`` is the same format in TypeScript, name for name. +""" + +from .codec import ( + ALPHABET, + DECODED_LENGTH, + ENCODED_LENGTH, + decode_bytes, + encode_bytes, +) +from .errors import InvalidIdException, InvalidIdReason +from .ids import ( + ID_LENGTH, + ID_PREFIXES, + PREFIX_LENGTH, + IdKind, + ParsedId, + create_id, + decode_id, + encode_id, + is_id, + parse_id, +) +from .uuids import create_uuid + +__all__ = [ + "ALPHABET", + "DECODED_LENGTH", + "ENCODED_LENGTH", + "ID_LENGTH", + "ID_PREFIXES", + "PREFIX_LENGTH", + "IdKind", + "InvalidIdException", + "InvalidIdReason", + "ParsedId", + "create_id", + "create_uuid", + "decode_bytes", + "decode_id", + "encode_bytes", + "encode_id", + "is_id", + "parse_id", +] diff --git a/packages/python-id/e2b_id/codec.py b/packages/python-id/e2b_id/codec.py new file mode 100644 index 0000000000..89a3d20cb4 --- /dev/null +++ b/packages/python-id/e2b_id/codec.py @@ -0,0 +1,193 @@ +"""The rotated base32 codec: 16 bytes as 26 lowercase characters. + +The 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet +("A-Z2-7"), lowercased, unpadded: 26 characters. The string is then rotated +left by :data:`ROTATION`, so what was the first character is now the 11th. + +Rotation is the whole trick. A UUIDv7 leads with a 48-bit big-endian +millisecond timestamp, so unrotated encodings of IDs minted together share a +long common prefix and the leading characters are nearly constant for months. +Rotating moves those characters inward: the string now leads with 10 characters +of random bits, the timestamp reads out from :data:`TIMESTAMP_INDEX`, and its +9.6 digits are followed by the remaining random bits. + +Decoding undoes it by rotating left by the other 10. The two amounts differ, so +unlike a half-length rotation this one is not its own inverse; ``_rotate`` and +``_unrotate`` are separate functions and the tests hold them together. + +The alphabet is exactly the one ``base64.b32encode`` uses, so this module needs +no tables of its own — only the rotation. That is also the whole of the interop +contract, and it is short enough to restate:: + + import base64 + + def encode(b: bytes) -> str: + s = base64.b32encode(b).decode().rstrip("=").lower() + return s[16:] + s[:16] + + def decode(s: str) -> bytes: + s = s[10:] + s[:10] + return base64.b32decode(s.upper() + "======") + +26 base32 digits carry 130 bits and 16 bytes are 128, so the final digit of the +unrotated string holds 2 bits that are always zero. ``b32decode`` discards those +bits without looking, so every value has four spellings that decode to it. +:func:`decode_bytes` accepts only the one :func:`encode_bytes` produces and +rejects the other three. After rotation that digit sits at +:data:`SLACK_INDEX`, not at the end. +""" + +from __future__ import annotations + +import base64 + +from typing import Optional, Tuple + +from .errors import InvalidIdException, InvalidIdReason + +#: RFC 4648 section 6, lowercased: what ``base64.b32encode`` produces once +#: ``.lower()`` is applied. The index of a character is its digit value. +ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" + +#: The number of bytes every encoded value carries: a UUID's worth. +DECODED_LENGTH = 16 + +#: ``ceil(128 / 5)``: the digits needed for 16 bytes. Every encoding is exactly +#: this wide; there is no padding to add or strip. +ENCODED_LENGTH = 26 + +#: How far :func:`encode_bytes` rotates the string left, chosen so a UUIDv7's +#: timestamp starts at :data:`TIMESTAMP_INDEX`: far enough in that the leading +#: characters are random, near enough the front that the timestamp begins in the +#: first half of the string. Decoding rotates left by the remainder. +#: +#: The timestamp's ~10 digits then run from index 10 to 19, so most of it in fact +#: lands in the second half — what the rotation buys is that none of it lands at +#: the *front*, which is the part people read, sort by and truncate. +ROTATION = 16 + +#: Where the unrotated string's first character lands: byte 0 of the value, and +#: so bit 0 of a UUIDv7's timestamp, reads out here. +TIMESTAMP_INDEX = ENCODED_LENGTH - ROTATION + +#: ``26 * 5 - 128``: the always-zero bits in the final unrotated digit, and the +#: mask that selects them out of that digit's value. +SLACK_BITS = ENCODED_LENGTH * 5 - DECODED_LENGTH * 8 +SLACK_MASK = (1 << SLACK_BITS) - 1 + +#: Where that digit lands after rotation: the character a decoder must check +#: for canonical form is inside the string, just before the timestamp, not at +#: the end. +SLACK_INDEX = TIMESTAMP_INDEX - 1 + +# Character to digit value. Membership in this map is the alphabet check, and +# case is part of it: uppercase is simply not in it. +_VALUES = {character: value for value, character in enumerate(ALPHABET)} + + +def _rotate(s: str) -> str: + """Rotate a 26-character string left by :data:`ROTATION`.""" + return s[ROTATION:] + s[:ROTATION] + + +def _unrotate(s: str) -> str: + """Rotate left by the remainder, which undoes :func:`_rotate`.""" + return s[ENCODED_LENGTH - ROTATION :] + s[: ENCODED_LENGTH - ROTATION] + + +def encode_bytes(raw: bytes) -> str: + """Encode 16 bytes as 26 lowercase base32 characters, rotated so the + leading bytes read out from the middle of the string. + + It cannot fail for any 16 bytes: nothing here reads a UUID's version or + variant, so an arbitrary 128-bit value encodes just as well as a UUID. + + :param raw: exactly 16 bytes. + :return: the 26-character encoding. + :raises InvalidIdException: if ``raw`` is not 16 bytes long. + """ + if len(raw) != DECODED_LENGTH: + raise InvalidIdException( + "length", + f"Cannot encode {len(raw)} bytes: expected exactly {DECODED_LENGTH}.", + ) + + return _rotate(base64.b32encode(raw).decode().rstrip("=").lower()) + + +def fault_in_encoded(encoded: str) -> Optional[Tuple[InvalidIdReason, int]]: + """Why :func:`decode_bytes` would reject a string, or ``None`` if it would + accept it. + + Reported without building an exception, so :func:`e2b_id.is_id` can answer a + boolean without paying for a message and a traceback. + + :param encoded: the string to check. + :return: the reason and the offending index, or ``None``. + """ + if len(encoded) != ENCODED_LENGTH: + return ("length", -1) + + # Membership is checked here rather than left to b32decode, which would see + # only the upper-cased string below and so would accept uppercase input. + # Accepting it would give every value millions of spellings. + for index, character in enumerate(encoded): + if character not in _VALUES: + return ("alphabet", index) + + # The slack digit is checked before decoding because b32decode will not check + # it at all: it drops the low SLACK_BITS of the final digit unread, so all + # four spellings of a value decode identically and the difference is only + # visible here. That digit is the last one of the unrotated string, which + # rotation has moved to SLACK_INDEX. + if _VALUES[encoded[SLACK_INDEX]] & SLACK_MASK: + return ("canonical", SLACK_INDEX) + + return None + + +def invalid_encoding( + encoded: str, fault: Tuple[InvalidIdReason, int] +) -> InvalidIdException: + """Build the exception for a fault. + + Separate from :func:`fault_in_encoded` so the message — the expensive part — + is only interpolated when something is about to be raised. + """ + reason, index = fault + if reason == "length": + return InvalidIdException( + "length", + f'"{encoded}" is {len(encoded)} characters long: ' + f"expected exactly {ENCODED_LENGTH}.", + ) + if reason == "alphabet": + return InvalidIdException( + "alphabet", + f'"{encoded}" holds {encoded[index]!r} at index {index}, which is not ' + f'one of the lowercase base32 characters "{ALPHABET}".', + ) + return InvalidIdException( + "canonical", + f'"{encoded}" is not the canonical spelling of its value: ' + f"{encoded[index]!r} at index {index} sets {SLACK_BITS} " + f"bits no 128-bit value reaches.", + ) + + +def decode_bytes(encoded: str) -> bytes: + """Decode what :func:`encode_bytes` produced, and only that: 26 + characters, lowercase RFC 4648 base32, rotated, with the two slack bits + zero. + + :param encoded: the 26-character encoding. + :return: the 16 bytes it carries. + :raises InvalidIdException: if ``encoded`` is the wrong length, holds a + character outside the lowercase alphabet, or is one of the three + non-canonical spellings of its value. + """ + fault = fault_in_encoded(encoded) + if fault is not None: + raise invalid_encoding(encoded, fault) + + return base64.b32decode(_unrotate(encoded).upper() + "=" * 6) diff --git a/packages/python-id/e2b_id/errors.py b/packages/python-id/e2b_id/errors.py new file mode 100644 index 0000000000..4b74c036e9 --- /dev/null +++ b/packages/python-id/e2b_id/errors.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Literal, Optional, TypeAlias + +#: Why a string was rejected. Callers that need to react differently per cause — +#: hint at the right kind, count non-canonical spellings, plain-400 the rest — +#: branch on this rather than on the message, which is prose and may be reworded. +#: +#: - ``kind``: the kind asked for is not one this package knows. +#: - ``prefix``: the ID names a different kind, or none. +#: - ``length``: the encoding is not ``ENCODED_LENGTH`` characters. +#: - ``alphabet``: it holds a character outside the lowercase base32 alphabet. +#: - ``canonical``: it decodes, but is one of the three non-canonical spellings. +#: - ``uuid``: the UUID given was not in canonical hex form. +InvalidIdReason: TypeAlias = Literal[ + "kind", "prefix", "length", "alphabet", "canonical", "uuid" +] + + +class InvalidIdException(ValueError): + """Raised when a string is not a well-formed ID, encoding or UUID. + + Every failure mode of this package is one of these; :attr:`reason` says + which. Use :func:`e2b_id.is_id` when a boolean is what you want — it does not + construct an exception at all. + + It subclasses ``ValueError`` so that code which already guards parsing with + ``except ValueError`` keeps working. + + :param reason: why the string was rejected. + :param message: the human-facing explanation. + :param actual_kind: the kind the ID's prefix actually names, when it names a + known one — set only for ``reason="prefix"``, so ``expected X, got Y`` is + available without parsing the message. + """ + + def __init__( + self, + reason: InvalidIdReason, + message: str, + actual_kind: Optional[str] = None, + ) -> None: + super().__init__(message) + self.reason: InvalidIdReason = reason + self.actual_kind = actual_kind diff --git a/packages/python-id/e2b_id/ids.py b/packages/python-id/e2b_id/ids.py new file mode 100644 index 0000000000..2f2b02d979 --- /dev/null +++ b/packages/python-id/e2b_id/ids.py @@ -0,0 +1,259 @@ +"""Prefixed, human-legible IDs for E2B resources. + +An ID is a three-character kind prefix, an underscore, and the 26-character +rotated base32 encoding of a UUID:: + + prj_uk75vf2v7iagp2kgn7pfze3car + ^^^ ^ + | the encoding of the resource's UUID + the kind + +The prefix is what makes an ID readable in a log line, a URL or a support +ticket: you can tell at a glance which resource it points at, and a project ID +pasted where a volume ID belongs fails loudly instead of looking up a row that +happens to exist. +""" + +from __future__ import annotations + +import re +import uuid as uuidlib +from dataclasses import dataclass +from typing import Dict, Literal, Mapping, Optional, TypeAlias, Union, get_args + +from .codec import ENCODED_LENGTH, decode_bytes, encode_bytes, fault_in_encoded +from .errors import InvalidIdException +from .uuids import create_uuid + +#: A resource kind that has IDs, e.g. ``"project"``. +IdKind: TypeAlias = Literal[ + "project", "workspace", "volume", "sandbox", "user", "group" +] + +#: The E2B resources that have IDs, and the prefix each one carries. +#: +#: This map is the single source of truth for the prefixes; adding a kind is +#: adding a line here and a member to :data:`IdKind`. +ID_PREFIXES: Mapping[IdKind, str] = { + "project": "prj", + "workspace": "wrk", + "volume": "vol", + "sandbox": "sbx", + "user": "usr", + "group": "grp", +} + +# `IdKind` has to be spelled out — a Literal cannot be derived from a dict the +# way TypeScript's `keyof typeof` derives it — so the two are checked against each +# other at import. Without this, adding a kind to the Literal and forgetting the +# map typechecks clean and raises in production. +if set(get_args(IdKind)) != set(ID_PREFIXES): + raise RuntimeError( + "e2b_id: IdKind and ID_PREFIXES disagree: " + f"{sorted(set(get_args(IdKind)) ^ set(ID_PREFIXES))} is in one but not the other." + ) + +# What separates the prefix from the encoding. +_SEPARATOR = "_" + +#: How wide every prefix is. The parsing paths do not depend on it — they find +#: the separator and slice by the matched prefix's own length — but IDs are a +#: fixed width only as long as this holds, so it is checked rather than assumed. +PREFIX_LENGTH = 3 + +for _prefix in ID_PREFIXES.values(): + if len(_prefix) != PREFIX_LENGTH: + raise RuntimeError( + f'e2b_id: the prefix "{_prefix}" is {len(_prefix)} characters, not ' + f"{PREFIX_LENGTH}, so ID_LENGTH would be wrong for its kind." + ) + +#: The width of every ID: a three-character prefix, an underscore and 26 +#: characters of encoding. +#: +#: Useful for sizing a database column or aligning a log. It is not a validator — +#: a string of this length can still be nonsense — so reach for :func:`is_id` +#: when the question is whether an ID is well formed. +ID_LENGTH = PREFIX_LENGTH + len(_SEPARATOR) + ENCODED_LENGTH + +# Prefix to kind, for reading an ID's prefix back. +_KINDS_BY_PREFIX: Dict[str, IdKind] = { + prefix: kind for kind, prefix in ID_PREFIXES.items() +} + +# The canonical 8-4-4-4-12 hex form, accepted in either case. UUIDs are held to +# one spelling for the same reason encodings are: `uuid.UUID` would also take +# unhyphenated, braced and `urn:uuid:`-prefixed strings, and accepting four +# spellings of an argument is how two systems end up disagreeing about whether +# they hold the same value. +_UUID_PATTERN = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class ParsedId: + """A parsed ID: which kind it names, and the UUID it carries.""" + + #: The resource kind its prefix names. + kind: IdKind + #: The UUID it encodes. + uuid: uuidlib.UUID + + +def _prefix_of(kind: IdKind) -> str: + prefix: Optional[str] = ID_PREFIXES.get(kind) + if prefix is None: + known = ", ".join(f"'{name}'" for name in sorted(ID_PREFIXES)) + raise InvalidIdException( + "kind", + f"'{kind}' is not a resource kind that has IDs. Expected one of {known}.", + ) + return prefix + + +def _bytes_of(uuid: Union[uuidlib.UUID, str]) -> bytes: + if isinstance(uuid, uuidlib.UUID): + return uuid.bytes + if isinstance(uuid, str) and _UUID_PATTERN.match(uuid): + return uuidlib.UUID(uuid).bytes + raise InvalidIdException( + "uuid", + f'"{uuid}" is not a UUID: expected 32 hex digits grouped 8-4-4-4-12, ' + 'like "019fa519-bf79-724d-8811-a2bfda9755fa".', + ) + + +def encode_id(kind: IdKind, uuid: Union[uuidlib.UUID, str]) -> str: + """Encode a UUID as an ID of the given kind. + + :param kind: the resource the UUID belongs to. + :param uuid: the resource's UUID, as a :class:`uuid.UUID` or in canonical + hex form. + :return: the ID. + :raises InvalidIdException: if ``kind`` is not a known kind, or ``uuid`` is + not a UUID. + + Example:: + + encode_id("project", "019fa519-bf79-724d-8811-a2bfda9755fa") + # 'prj_uk75vf2v7iagp2kgn7pfze3car' + """ + return f"{_prefix_of(kind)}{_SEPARATOR}{encode_bytes(_bytes_of(uuid))}" + + +def decode_id(kind: IdKind, id: str) -> uuidlib.UUID: + """Decode an ID of the given kind back to its UUID. + + The kind is required rather than inferred so that a mismatch is an error: + this is the check that stops one resource's ID from being used as another's. + Use :func:`parse_id` when the kind is what you are trying to find out. + + :param kind: the resource the ID must name. + :param id: the ID. + :return: the UUID it carries. + :raises InvalidIdException: if ``id`` names a different kind, or is not a + well-formed ID. + + Example:: + + decode_id("project", "prj_uk75vf2v7iagp2kgn7pfze3car") + # UUID('019fa519-bf79-724d-8811-a2bfda9755fa') + """ + prefix = _prefix_of(kind) + if not id.startswith(prefix + _SEPARATOR): + named = _KINDS_BY_PREFIX.get(id.split(_SEPARATOR)[0]) + if named is not None: + raise InvalidIdException( + "prefix", f'"{id}" is a {named} ID, not a {kind} ID.', named + ) + raise InvalidIdException( + "prefix", + f'"{id}" is not a {kind} ID: ' + f'expected it to start with "{prefix}{_SEPARATOR}".', + ) + return uuidlib.UUID(bytes=decode_bytes(id[len(prefix) + len(_SEPARATOR) :])) + + +def create_id(kind: IdKind) -> str: + """Mint an ID for a new resource of the given kind, from a fresh + :func:`create_uuid` UUIDv7. + + :param kind: the resource being created. + :return: the ID. + :raises InvalidIdException: if ``kind`` is not a known kind. + + Example:: + + create_id("sandbox") # 'sbx_blo7looa3eagp2kgn75n47fkrk' + """ + return encode_id(kind, create_uuid()) + + +def parse_id(id: str) -> ParsedId: + """Read an ID without knowing its kind up front. + + :param id: the ID. + :return: the kind its prefix names and the UUID it carries. + :raises InvalidIdException: if ``id`` carries no known prefix, or the rest + of it is not a well-formed encoding. + + Example:: + + parse_id("vol_uxl2sotjfiagp2kgn7yv4e3e4g") + # ParsedId(kind='volume', uuid=UUID('019fa519-bfc5-784d-9386-a5d7a93a692a')) + """ + prefix, separator, _ = id.partition(_SEPARATOR) + if not separator: + raise InvalidIdException( + "prefix", + f'"{id}" is not an ID: expected a kind prefix and an underscore, ' + f'like "prj{_SEPARATOR}".', + ) + + kind = _KINDS_BY_PREFIX.get(prefix) + if kind is None: + known = ", ".join(f'"{name}"' for name in sorted(ID_PREFIXES.values())) + raise InvalidIdException( + "prefix", + f'"{id}" carries the unknown prefix "{prefix}". Expected one of {known}.', + ) + + return ParsedId(kind=kind, uuid=decode_id(kind, id)) + + +def is_id(kind: IdKind, value: str) -> bool: + """Whether a string is a well-formed ID of the given kind. + + This is :func:`decode_id` without the raise, for validating input you did + not mint: it checks the prefix, the width, the alphabet and the canonical + spelling. + + It answers without building an exception or constructing a + :class:`uuid.UUID`, so it is cheap enough to run over a whole request — + which matters, because rejection is the common case for untrusted input and + raising costs far more than the check itself. + + Anything that is not a :class:`str` is simply not an ID, so ``None``, an + ``int`` and ``bytes`` all return ``False`` rather than raising + ``AttributeError`` — this is usually the first thing a handler calls on a + JSON payload, and it matches ``isId`` in ``@e2b/id``. + + :param kind: the resource the ID must name. + :param value: the string to check. + :return: whether ``value`` is an ID of that kind. + + Example:: + + if not is_id("project", untrusted): + raise ValueError(f"{untrusted} is not a project ID") + """ + if not isinstance(value, str): + return False + + prefix = ID_PREFIXES.get(kind) + if prefix is None or not value.startswith(prefix + _SEPARATOR): + return False + + return fault_in_encoded(value[len(prefix) + len(_SEPARATOR) :]) is None diff --git a/packages/python-id/e2b_id/py.typed b/packages/python-id/e2b_id/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/python-id/e2b_id/uuids.py b/packages/python-id/e2b_id/uuids.py new file mode 100644 index 0000000000..c9dd3f101c --- /dev/null +++ b/packages/python-id/e2b_id/uuids.py @@ -0,0 +1,57 @@ +"""Minting the UUIDs that IDs are made from. + +UUIDs cross this package's boundary as :class:`uuid.UUID`, and the conversion to +and from bytes is the standard library's (``u.bytes`` and ``uuid.UUID(bytes=…)``) +rather than something this package wraps. + +This is where the two surfaces diverge, and it is all downstream of one fact: +JavaScript has no UUID type, so ``@e2b/id`` moves UUIDs as canonical hex strings +where this package moves :class:`uuid.UUID` objects. Concretely: + +- ``@e2b/id`` exports ``uuidToBytes``/``bytesToUuid``; there is no equivalent + here, because the standard library already is the equivalent. +- :func:`create_uuid` returns a :class:`uuid.UUID`; ``createUuid`` returns a + string. +- :func:`e2b_id.decode_id` returns a :class:`uuid.UUID` and + :attr:`e2b_id.ParsedId.uuid` is one, where ``decodeId`` and ``ParsedId.uuid`` + are strings — so a round trip is string-to-string in JS but not in Python. +- :func:`e2b_id.encode_id` accepts either a :class:`uuid.UUID` or the hex form, + since a UUID arriving as JSON is a string either way. + +Everything else — the names, the argument order, the failure modes, the wire +format — is the same on both sides. +""" + +from __future__ import annotations + +import secrets +import time +import uuid + +_MILLISECOND_BITS = 48 + + +def create_uuid() -> uuid.UUID: + """Mint a UUIDv7: a 48-bit big-endian millisecond timestamp followed by 74 + random bits, with the version and variant nibbles set per RFC 9562. + + IDs are minted from v7 rather than v4 so that the bytes a database stores + sort chronologically, even though :func:`e2b_id.encode_id` deliberately + hides that order in the string it produces. + + ``uuid.uuid7`` covers this from Python 3.14 on, but the floor here is 3.10. + + :return: the UUID. + """ + ms = time.time_ns() // 1_000_000 + value = ms << (128 - _MILLISECOND_BITS) + value |= secrets.randbits(128 - _MILLISECOND_BITS) + + # Version 7 in the high nibble of byte 6, variant 10 in the top two bits of + # byte 8. `uuid.UUID(int=…, version=7)` would do this, but only from 3.14. + value &= ~(0xF << 76) + value |= 0x7 << 76 + value &= ~(0b11 << 62) + value |= 0b10 << 62 + + return uuid.UUID(int=value) diff --git a/packages/python-id/package.json b/packages/python-id/package.json new file mode 100644 index 0000000000..7d6b4c16bc --- /dev/null +++ b/packages/python-id/package.json @@ -0,0 +1,12 @@ +{ + "name": "@e2b/python-id", + "private": true, + "version": "0.1.0", + "scripts": { + "test": "uv run pytest --verbose", + "postVersion": "uv version $(pnpm pkg get version --workspaces=false | tr -d \\\")", + "typecheck": "uv run make typecheck", + "lint": "uv run make lint", + "format": "uv run make format" + } +} diff --git a/packages/python-id/pyproject.toml b/packages/python-id/pyproject.toml new file mode 100644 index 0000000000..e69adb2f63 --- /dev/null +++ b/packages/python-id/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "e2b_id" +version = "0.1.0" +description = "Prefixed, human-legible IDs for E2B resources" +authors = [{ name = "e2b", email = "hello@e2b.dev" }] +license = "MIT" +readme = "README.md" +requires-python = ">=3.10" +# Standard library only, on purpose: an ID codec that every service and script +# depends on should never be the reason a dependency resolution fails. +dependencies = [] + +[project.urls] +Homepage = "https://e2b.dev/" +Repository = "https://github.com/e2b-dev/e2b/tree/main/packages/python-id" +"Bug Tracker" = "https://github.com/e2b-dev/e2b/issues" + +[dependency-groups] +dev = [ + "pytest>=9.0.3,<10", + "ruff>=0.11.12,<0.12", + "ty>=0.0.15,<0.0.16", +] + +[build-system] +requires = ["uv_build>=0.10.0,<0.11.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = ["e2b_id"] +module-root = "" diff --git a/packages/python-id/pytest.ini b/packages/python-id/pytest.ini new file mode 100644 index 0000000000..716e245816 --- /dev/null +++ b/packages/python-id/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = "--import-mode=importlib" +# Makes the shared vectors module importable under importlib mode. +pythonpath = tests diff --git a/packages/python-id/tests/test_codec.py b/packages/python-id/tests/test_codec.py new file mode 100644 index 0000000000..844fdd3ee0 --- /dev/null +++ b/packages/python-id/tests/test_codec.py @@ -0,0 +1,266 @@ +import base64 +import uuid as uuidlib + +import pytest +from vectors import ( + CORPUS_DIGEST, + GOLDEN, + Lcg, + bytes_from, + corpus, + corpus_digest, + ref_encode, + v7, +) + +from e2b_id import InvalidIdException +from e2b_id.codec import ( + ALPHABET, + DECODED_LENGTH, + ENCODED_LENGTH, + ROTATION, + SLACK_BITS, + SLACK_INDEX, + SLACK_MASK, + TIMESTAMP_INDEX, + decode_bytes, + encode_bytes, +) + + +def test_the_format_is_these_exact_numbers(): + # Pinned as literals, not as their own definitions: asserting + # `TIMESTAMP_INDEX == ENCODED_LENGTH - ROTATION` restates the line that + # computes it and so can never fail — it would stay green with ROTATION + # changed to 20, which is a different wire format. These numbers ARE the + # format; every ID ever minted depends on them, so changing one must break a + # test. The same literals are pinned in js-id's codec.test.ts. + assert ALPHABET == "abcdefghijklmnopqrstuvwxyz234567" + assert DECODED_LENGTH == 16 + assert ENCODED_LENGTH == 26 + assert ROTATION == 16 + assert TIMESTAMP_INDEX == 10 + assert SLACK_INDEX == 9 + assert SLACK_BITS == 2 + assert SLACK_MASK == 3 + + +def test_and_those_numbers_are_the_ones_the_width_forces(): + # The relations that make those the right numbers: 25 digits cannot hold 128 + # bits, 26 hold 130, and the slack is the difference. Unlike the pins above + # these are derivations, so they are stated against the arithmetic. + assert ENCODED_LENGTH * 5 >= DECODED_LENGTH * 8 + assert (ENCODED_LENGTH - 1) * 5 < DECODED_LENGTH * 8 + assert SLACK_BITS == ENCODED_LENGTH * 5 - DECODED_LENGTH * 8 + assert SLACK_MASK == (1 << SLACK_BITS) - 1 + assert TIMESTAMP_INDEX == ENCODED_LENGTH - ROTATION + assert SLACK_INDEX == TIMESTAMP_INDEX - 1 + assert len(ALPHABET) == 32 + assert len(set(ALPHABET)) == 32 + # The front must be random, which needs the rotation past a v7's 10 + # non-random leading digits (48 timestamp bits plus 2 of the version nibble). + assert ROTATION >= 14 + assert ROTATION < ENCODED_LENGTH # not a full turn + + +@pytest.mark.parametrize("hex_uuid, encoded", GOLDEN) +def test_golden_encodings(hex_uuid: str, encoded: str): + raw = uuidlib.UUID(hex_uuid).bytes + assert encode_bytes(raw) == encoded + assert decode_bytes(encoded) == raw + + +def test_the_string_is_the_bits_rotated(): + # The claim of the format: the string is the value's bits in 5-bit groups, + # rotated. Checked against ref_encode, which computes it a different way. + for raw in corpus(): + assert encode_bytes(raw) == ref_encode(raw), raw.hex() + + +def test_every_encoding_round_trips(): + for raw in corpus(): + encoded = encode_bytes(raw) + assert len(encoded) == ENCODED_LENGTH, raw.hex() + assert encoded == encoded.lower() + assert decode_bytes(encoded) == raw + + +def test_the_corpus_is_the_one_js_id_checks(): + # The one constant both suites hold: same corpus, same format, same digest. + assert corpus_digest(encode_bytes) == CORPUS_DIGEST + + +@pytest.mark.parametrize("value", range(1, 8)) +def test_the_smallest_values_are_one_character_from_zero(value: int): + # The seven smallest nonzero values fit entirely in the slack digit: + # encoding shifts a value up by the two slack bits, so 1 through 7 become + # digit values 4 through 28, always a multiple of 4 and so always canonical. + # Seven strings differing from the encoding of zero in exactly one character + # say where the low bits live, that the shift happened, and where the + # rotation put it. Value 8 is the first to spill into a second digit. + raw = bytes_from(value) + want = ( + "a" * SLACK_INDEX + + ALPHABET[value << SLACK_BITS] + + "a" * (ENCODED_LENGTH - SLACK_INDEX - 1) + ) + + assert encode_bytes(raw) == want + assert decode_bytes(want) == raw + + +def test_three_of_the_four_spellings_of_every_value_are_rejected(): + # The cost of 128 not dividing by 5. Every value has exactly four strings a + # permissive base32 decoder maps to it, differing only in the slack digit, + # which rotation has moved to SLACK_INDEX. Exactly one is canonical, and + # b32decode reports nothing about the other three. + for raw in corpus()[:200]: + encoded = encode_bytes(raw) + digit = ALPHABET.index(encoded[SLACK_INDEX]) + assert digit & SLACK_MASK == 0, encoded + + accepted = 0 + for slack in range(0, SLACK_MASK + 1): + alternative = ( + encoded[:SLACK_INDEX] + + ALPHABET[digit | slack] + + encoded[SLACK_INDEX + 1 :] + ) + + # This is what makes the canonical check necessary rather than + # incidental: a permissive decoder — including the six-line + # b32decode snippet this package advertises — maps all four + # spellings to the same 16 bytes and reports nothing. Unrotating by + # hand (`s[10:] + s[:10]`, exactly the snippet) keeps this + # independent of the module's own _unrotate. + unrotated = ( + alternative[ENCODED_LENGTH - ROTATION :] + + alternative[: ENCODED_LENGTH - ROTATION] + ) + assert base64.b32decode(unrotated.upper() + "=" * 6) == raw, alternative + + if slack == 0: + assert decode_bytes(alternative) == raw + accepted += 1 + continue + with pytest.raises(InvalidIdException, match="canonical"): + decode_bytes(alternative) + + assert accepted == 1, encoded + + +def test_only_multiples_of_four_appear_in_the_slack_digit(): + # The same fact from the outside: only 8 of the 32 characters can ever + # appear at SLACK_INDEX, and over enough values every one of them does. + allowed = {ALPHABET[value] for value in range(0, 32, SLACK_MASK + 1)} + + seen = set() + for raw in corpus(): + digit = encode_bytes(raw)[SLACK_INDEX] + assert digit in allowed, f"{raw.hex()} has {digit!r} at index {SLACK_INDEX}" + seen.add(digit) + assert seen == allowed + + +_VALID = encode_bytes(uuidlib.UUID("019fa41f-41cc-761e-8868-daa906581007").bytes) +# The first character may be a digit, which has no upper case, so the one to +# raise is the first letter. +_FIRST_LETTER = next(i for i, c in enumerate(_VALID) if c.isalpha()) + + +@pytest.mark.parametrize( + "reason, encoded", + [ + ("nothing at all", ""), + ("one character short", _VALID[:-1]), + ("one character long", _VALID + "a"), + ("base32 padding", _VALID[:-1] + "="), + # Nothing here ever emits uppercase, so accepting it would give every + # value millions of spellings. + ("uppercase", _VALID.upper()), + ( + "a single uppercase letter", + _VALID[:_FIRST_LETTER] + + _VALID[_FIRST_LETTER].upper() + + _VALID[_FIRST_LETTER + 1 :], + ), + ('"0", which is not in the alphabet', "0" + _VALID[1:]), + ('"1", which is not in the alphabet', "1" + _VALID[1:]), + ('"8", which is not in the alphabet', "8" + _VALID[1:]), + ('"9", which is not in the alphabet', "9" + _VALID[1:]), + ('"-", which is not a digit', "-" + _VALID[1:]), + ("a non-ascii character", _VALID[:-1] + "é"), + ("whitespace", " " + _VALID[1:]), + ], +) +def test_decoding_rejects(reason: str, encoded: str): + with pytest.raises(InvalidIdException): + decode_bytes(encoded) + + +@pytest.mark.parametrize("length", [0, 15, 17, 32]) +def test_encoding_rejects_anything_that_is_not_16_bytes(length: int): + with pytest.raises(InvalidIdException): + encode_bytes(bytes(length)) + + +def test_but_not_the_extremes(): + # All zeros decodes to the zero value rather than failing. + assert decode_bytes("a" * ENCODED_LENGTH) == bytes(16) + largest = bytes_from((1 << 128) - 1) + assert decode_bytes(encode_bytes(largest)) == largest + + +def test_the_timestamp_reads_out_from_the_middle(): + # Two v7s minted in the same millisecond share their first 52 bits + # (timestamp plus version nibble), which is their first 10 digits; rotation + # moves those to indices 10 through 19. So the insides must match and, over + # enough samples, the fronts must not. + source = Lcg(7) + + same_front = 0 + for _ in range(1000): + a = v7(source) + b = bytearray(v7(source)) + b[:7] = a[:7] # same millisecond, same version nibble + + encoded_a, encoded_b = encode_bytes(a), encode_bytes(bytes(b)) + assert ( + encoded_a[TIMESTAMP_INDEX : TIMESTAMP_INDEX + 10] + == encoded_b[TIMESTAMP_INDEX : TIMESTAMP_INDEX + 10] + ) + if encoded_a[:4] == encoded_b[:4]: + same_front += 1 + + # The first 4 characters are 20 random bits; collisions are ~1e-6. + assert same_front <= 2 + + +def test_ids_minted_together_do_not_share_a_prefix(): + # The same point as the user sees it: a batch minted together would have + # shared a long prefix unrotated, and must not now. + source = Lcg(11) + at = 1758000000000 + + fronts = {encode_bytes(v7(source, at + i))[0] for i in range(200)} + + # 200 draws over 32 first characters: fewer than 10 distinct would be wildly + # improbable for uniform bits. + assert len(fronts) >= 10 + + +def test_and_what_it_costs_sort_order_is_gone(): + # The trade, stated plainly so no one builds an index on these strings + # expecting v7's chronology to survive: the front is random, so encoded + # order and timestamp order are unrelated. + source = Lcg(15) + values = [v7(source) for _ in range(5000)] + + inversions = 0 + for previous, current in zip(values, values[1:]): + low, high = sorted((previous, current)) + if encode_bytes(low) > encode_bytes(high): + inversions += 1 + + # Random fronts mean about half of all pairs invert. + assert inversions > len(values) / 5 diff --git a/packages/python-id/tests/test_ids.py b/packages/python-id/tests/test_ids.py new file mode 100644 index 0000000000..c85fbd05a3 --- /dev/null +++ b/packages/python-id/tests/test_ids.py @@ -0,0 +1,247 @@ +import re +import time +import uuid as uuidlib +from typing import List, cast + +import pytest +from vectors import GOLDEN, GOLDEN_IDS, corpus + +from e2b_id import ( + ID_LENGTH, + ID_PREFIXES, + IdKind, + InvalidIdException, + ParsedId, + create_id, + create_uuid, + decode_id, + encode_id, + is_id, + parse_id, +) +from e2b_id.codec import ENCODED_LENGTH + +KINDS: List[IdKind] = list(ID_PREFIXES) + +_PROJECT_ID = GOLDEN_IDS[0][2] +_PROJECT_UUID = GOLDEN_IDS[0][1] + + +def test_every_kind_has_a_distinct_three_character_prefix(): + prefixes = list(ID_PREFIXES.values()) + assert len(set(prefixes)) == len(prefixes) + for prefix in prefixes: + assert re.fullmatch(r"[a-z]{3}", prefix) + assert ID_LENGTH == 3 + 1 + ENCODED_LENGTH + + +def test_every_kind_is_covered_here(): + assert KINDS == ["project", "workspace", "volume", "sandbox", "user", "group"] + assert sorted(kind for kind, _, _ in GOLDEN_IDS) == sorted(KINDS) + + +@pytest.mark.parametrize("kind, hex_uuid, id", GOLDEN_IDS) +def test_golden_ids(kind: IdKind, hex_uuid: str, id: str): + expected = uuidlib.UUID(hex_uuid) + assert encode_id(kind, expected) == id + assert encode_id(kind, hex_uuid) == id + assert decode_id(kind, id) == expected + assert parse_id(id) == ParsedId(kind=kind, uuid=expected) + assert is_id(kind, id) + + +def test_every_id_is_id_length_characters_wide(): + for kind in KINDS: + for hex_uuid, _ in GOLDEN: + assert len(encode_id(kind, hex_uuid)) == ID_LENGTH + + +def test_every_value_round_trips_through_every_kind(): + for raw in corpus(): + expected = uuidlib.UUID(bytes=raw) + for kind in KINDS: + id = encode_id(kind, expected) + assert id.startswith(ID_PREFIXES[kind] + "_"), id + assert decode_id(kind, id) == expected, raw.hex() + assert parse_id(id) == ParsedId(kind=kind, uuid=expected) + + +def test_uuids_are_accepted_in_either_case(): + assert encode_id("project", _PROJECT_UUID.upper()) == encode_id( + "project", _PROJECT_UUID + ) + + +def test_create_id_produces_an_id_of_the_kind_asked_for(): + for kind in KINDS: + id = create_id(kind) + assert len(id) == ID_LENGTH + assert is_id(kind, id) + assert parse_id(id).kind == kind + + +def test_create_uuid_mints_v7s_that_carry_the_current_time(): + before = time.time_ns() // 1_000_000 + minted = create_uuid() + after = time.time_ns() // 1_000_000 + + assert minted.version == 7 + assert minted.variant == uuidlib.RFC_4122 + assert before <= int.from_bytes(minted.bytes[:6], "big") <= after + + +def test_create_uuid_does_not_repeat_itself(): + assert len({create_uuid() for _ in range(1000)}) == 1000 + + +def test_uuids_minted_in_order_carry_timestamps_in_order(): + # The point of minting from v7: the bytes a database stores sort by time. + # Only to the millisecond — within one, the rest of the UUID is random and + # nothing here adds a counter — so the timestamp field is what is ordered. + timestamps = [create_uuid().bytes[:6] for _ in range(1000)] + assert sorted(timestamps) == timestamps + + +def test_decode_id_names_both_kinds(): + with pytest.raises(InvalidIdException, match="is a project ID, not a volume ID"): + decode_id("volume", _PROJECT_ID) + + +def test_is_id_is_false_for_every_other_kind(): + for kind in KINDS: + assert is_id(kind, _PROJECT_ID) == (kind == "project") + + +def test_an_unknown_prefix_says_what_is_accepted(): + id = "tpl_" + _PROJECT_ID[4:] + with pytest.raises(InvalidIdException, match='unknown prefix "tpl"'): + parse_id(id) + with pytest.raises(InvalidIdException, match='expected it to start with "prj_"'): + decode_id("project", id) + assert not is_id("project", id) + + +def test_an_unknown_kind_says_what_is_accepted(): + # Reachable from untyped callers, so the check has to be a runtime one. + with pytest.raises(InvalidIdException, match="is not a resource kind"): + encode_id(cast(IdKind, "template"), _PROJECT_UUID) + + +@pytest.mark.parametrize( + "reason, value", + [ + ("nothing at all", ""), + ("a bare encoding with no prefix", _PROJECT_ID[4:]), + ("a prefix with no encoding", "prj_"), + ("no separator", _PROJECT_ID.replace("_", "")), + ("a hyphen instead of an underscore", _PROJECT_ID.replace("_", "-")), + ("one character short", _PROJECT_ID[:-1]), + ("one character long", _PROJECT_ID + "a"), + ("uppercase", _PROJECT_ID.upper()), + ("an uppercase prefix", "PRJ_" + _PROJECT_ID[4:]), + ("a character outside the alphabet", _PROJECT_ID[:-1] + "0"), + ("surrounding whitespace", f" {_PROJECT_ID} "), + ("a non-canonical spelling", _PROJECT_ID[:13] + "j" + _PROJECT_ID[14:]), + ], +) +def test_malformed_ids_are_rejected(reason: str, value: str): + assert not is_id("project", value) + with pytest.raises(InvalidIdException): + decode_id("project", value) + + +def test_the_non_canonical_case_is_really_the_only_bit_flipped(): + # Index 13 of the id is index 9 of the encoding, the slack digit. Setting + # its low bits leaves a string that a permissive base32 decoder would + # happily map to the same UUID. + non_canonical = _PROJECT_ID[:13] + "j" + _PROJECT_ID[14:] + assert len(non_canonical) == ID_LENGTH + assert non_canonical != _PROJECT_ID + with pytest.raises(InvalidIdException, match="canonical"): + decode_id("project", non_canonical) + + +@pytest.mark.parametrize( + "reason, value", + [ + ("not hex", "not-a-uuid"), + # These three are spellings uuid.UUID would accept and this package does + # not, so that both SDKs take exactly one form. + ("unhyphenated", "019fa519bf79724d8811a2bfda9755fa"), + ("braced", "{019fa519-bf79-724d-8811-a2bfda9755fa}"), + ("urn-prefixed", "urn:uuid:019fa519-bf79-724d-8811-a2bfda9755fa"), + ("one digit short", "019fa519-bf79-724d-8811-a2bfda9755f"), + ("empty", ""), + ], +) +def test_encode_id_rejects_a_uuid_that_is(reason: str, value: str): + with pytest.raises(InvalidIdException): + encode_id("project", value) + + +@pytest.mark.parametrize( + "value", [None, 123, b"prj_uk75vf2v7iagp2kgn7pfze3car", ["x"], {}] +) +def test_is_id_is_false_for_anything_that_is_not_a_string(value): + # It used to raise AttributeError from `id.startswith`, which is the worst + # possible answer for the documented use — validating a JSON payload, where + # a null or a number is exactly what shows up. `isId` in @e2b/id returns + # false for all of these, so this is a parity test as much as a guard. + assert is_id("project", value) is False + + +def test_an_unknown_kind_is_false_rather_than_a_raise(): + assert is_id(cast(IdKind, "template"), _PROJECT_ID) is False + + +@pytest.mark.parametrize( + "reason, kind, value", + [ + ("prefix", "volume", _PROJECT_ID), + ("prefix", "project", "tpl_" + _PROJECT_ID[4:]), + ("prefix", "project", _PROJECT_ID[4:]), + ("length", "project", _PROJECT_ID[:-1]), + ("alphabet", "project", _PROJECT_ID[:-1] + "0"), + ("canonical", "project", _PROJECT_ID[:13] + "j" + _PROJECT_ID[14:]), + ], +) +def test_the_reason_says_which_failure_it_was(reason: str, kind: IdKind, value: str): + # Callers branch on `reason`, never on the message text, so rewording a + # message stays a cosmetic change. + with pytest.raises(InvalidIdException) as caught: + decode_id(kind, value) + assert caught.value.reason == reason + + +def test_the_reason_for_a_bad_uuid_and_an_unknown_kind(): + with pytest.raises(InvalidIdException) as caught: + encode_id("project", "not-a-uuid") + assert caught.value.reason == "uuid" + + with pytest.raises(InvalidIdException) as caught: + encode_id(cast(IdKind, "template"), _PROJECT_UUID) + assert caught.value.reason == "kind" + + +def test_a_kind_mismatch_reports_the_kind_it_actually_names(): + with pytest.raises(InvalidIdException) as caught: + decode_id("volume", _PROJECT_ID) + assert caught.value.actual_kind == "project" + + +def test_id_kind_and_id_prefixes_cannot_diverge(): + # ids.py asserts this at import; if the Literal and the map ever disagree the + # package refuses to load rather than failing at whatever call site first + # mints the missing kind. + from typing import get_args + + assert set(get_args(IdKind)) == set(ID_PREFIXES) + + +def test_the_public_surface_is_what_all_advertises(): + import e2b_id + + for name in e2b_id.__all__: + assert hasattr(e2b_id, name), name + assert "PREFIX_LENGTH" in e2b_id.__all__ + assert e2b_id.PREFIX_LENGTH == 3 diff --git a/packages/python-id/tests/vectors.py b/packages/python-id/tests/vectors.py new file mode 100644 index 0000000000..76c8c6d3b9 --- /dev/null +++ b/packages/python-id/tests/vectors.py @@ -0,0 +1,193 @@ +"""The shared test material: golden encodings, and a deterministic corpus. + +``packages/js-id/tests/vectors.ts`` is the same file in TypeScript, value for +value — the same goldens, the same LCG, the same corpus in the same order. That +is what keeps the two implementations from drifting: both check the same inputs +against the same fixed outputs and against their own independent reference +encoder, and both pin :data:`CORPUS_DIGEST`, which no corpus or codec change can +survive on one side alone. +""" + +from __future__ import annotations + +import hashlib +from typing import Callable, List, Optional, Tuple + +from e2b_id.codec import ALPHABET, ENCODED_LENGTH, ROTATION, SLACK_BITS +from e2b_id.ids import IdKind + +# UUIDs and the encodings they must produce. These strings were computed with +# base64.b32encode rather than captured from this package, so they check the +# implementation rather than record it, and they are the compatibility contract: +# change the alphabet, the width or the rotation and they stop matching. +# +# The five v7s were minted 37ms apart, so note what the rotation did: they share +# "agp2kg" from index 10, which is the common timestamp showing through, and +# their fronts differ. +GOLDEN: List[Tuple[str, str]] = [ + # v7, minted 37ms apart. + ("019fa519-bf79-724d-8811-a2bfda9755fa", "uk75vf2v7iagp2kgn7pfze3car"), + ("019fa519-bf9f-762a-a916-431479cc7171", "imkhttdroeagp2kgn7t53cvkiw"), + ("019fa519-bfc5-784d-9386-a5d7a93a692a", "uxl2sotjfiagp2kgn7yv4e3e4g"), + ("019fa519-bfeb-79f2-aa2a-0addf5b9c0d9", "blo7looa3eagp2kgn75n47fkrk"), + ("019fa519-c011-7c8e-a039-cb3ba8ca8c16", "zm52rsumcyagp2kgoacf6i5ibz"), + # v3 and v5 of "e2b.dev" in the DNS namespace: the only versions that are + # deterministic, and so the only ones that can be pinned by name. + ("a3692b74-8ded-3329-af53-cc23b4d7dc27", "zqr3jv64e4unusw5en5uzstl2t"), + ("fbe1337a-dac0-53d8-805c-905bca106f3e", "sbn4uedphy7pqtg6w2ybj5rac4"), + # A real v4. + ("f47ac10b-58cc-4372-a567-0e02b2c3d479", "byblfq6upe6r5mcc2yzrbxfjlh"), + # The extremes, and the value whose only set bit is the lowest one: it lands + # in the slack digit, which rotation has moved to index 9. + ("00000000-0000-0000-0000-000000000000", "aaaaaaaaaaaaaaaaaaaaaaaaaa"), + ("00000000-0000-0000-0000-000000000001", "aaaaaaaaaeaaaaaaaaaaaaaaaa"), + ("ffffffff-ffff-ffff-ffff-ffffffffffff", "77777777747777777777777777"), + # Not UUIDs at all: two IPv6 addresses, which are the same 16 bytes and so + # encode in the same column. Nothing in the codec reads version bits, and + # these have none to read. + ("26064700-4700-0000-0000-000000001111", "aaaaaaarceeydeoachaaaaaaaa"), + ("20010db8-85a3-0000-0000-8a2e03707334", "rixag4dtgqeaaq3oefumaaaaaa"), +] + +# The same contract for the prefixed form, one per kind. +GOLDEN_IDS: List[Tuple[IdKind, str, str]] = [ + ( + "project", + "019fa519-bf79-724d-8811-a2bfda9755fa", + "prj_uk75vf2v7iagp2kgn7pfze3car", + ), + ( + "workspace", + "019fa519-bf9f-762a-a916-431479cc7171", + "wrk_imkhttdroeagp2kgn7t53cvkiw", + ), + ( + "volume", + "019fa519-bfc5-784d-9386-a5d7a93a692a", + "vol_uxl2sotjfiagp2kgn7yv4e3e4g", + ), + ( + "sandbox", + "019fa519-bfeb-79f2-aa2a-0addf5b9c0d9", + "sbx_blo7looa3eagp2kgn75n47fkrk", + ), + ( + "user", + "019fa519-c011-7c8e-a039-cb3ba8ca8c16", + "usr_zm52rsumcyagp2kgoacf6i5ibz", + ), + ( + "group", + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "grp_byblfq6upe6r5mcc2yzrbxfjlh", + ), +] + +# sha256 of every encoding in `corpus()`, newline-joined, in order. The same +# constant appears in vectors.ts; if the two implementations ever disagree +# about the corpus or the format, one of them fails here. +CORPUS_DIGEST = "a83c455e0a4ffd39e51c69d832c544b6809bf836f6459e2a9797005dddc71ec0" + +MASK_64 = (1 << 64) - 1 +MASK_128 = (1 << 128) - 1 + + +class Lcg: + """A 64-bit linear congruential generator, so that the random half of the + corpus is reproducible and identical in every implementation. The multiplier + and increment are PCG's; the output byte is the top one, since an LCG's low + bits are the weak ones. + """ + + def __init__(self, seed: int) -> None: + self.state = seed & MASK_64 + + def byte(self) -> int: + self.state = (self.state * 6364136223846793005 + 1442695040888963407) & MASK_64 + return (self.state >> 56) & 0xFF + + def bytes(self, count: int) -> bytearray: + return bytearray(self.byte() for _ in range(count)) + + +def v4(source: Lcg) -> bytes: + """16 bytes with a v4's version and variant nibbles.""" + raw = source.bytes(16) + raw[6] = (raw[6] & 0x0F) | 0x40 + raw[8] = (raw[8] & 0x3F) | 0x80 + return bytes(raw) + + +def v7(source: Lcg, ms: Optional[int] = None) -> bytes: + """16 bytes shaped like a v7: a 48-bit millisecond timestamp somewhere + between 2020 and 2089, then the version and variant nibbles. + """ + # Exactly 16 bytes are drawn either way, so the corpus does not shift when a + # caller pins the timestamp. + raw = source.bytes(16) + at = ( + ms + if ms is not None + else 1577836800000 + int.from_bytes(raw[:6], "big") % (1 << 41) + ) + for i in range(6): + raw[i] = (at >> (8 * (5 - i))) & 0xFF + raw[6] = (raw[6] & 0x0F) | 0x70 + raw[8] = (raw[8] & 0x3F) | 0x80 + return bytes(raw) + + +def bytes_from(value: int) -> bytes: + return (value & MASK_128).to_bytes(16, "big") + + +def corpus() -> List[bytes]: + """The shared input: the extremes, the smallest values, every power of two + either side — a digit boundary falls every 5 bits and that is where a packing + mistake shows — the goldens, and a reproducible random tail of v4s, v7s and + values that are neither. + """ + values = [bytes_from(0), bytes_from(MASK_128)] + + for value in range(1, 8): + values.append(bytes_from(value)) + + for bit in range(1, 128): + power = 1 << bit + for delta in (-1, 0, 1): + values.append(bytes_from(power + delta)) + + for hex_uuid, _ in GOLDEN: + values.append(bytes_from(int(hex_uuid.replace("-", ""), 16))) + + random = Lcg(1) + for _ in range(300): + values.append(v4(random)) + values.append(v7(random)) + values.append(bytes(random.bytes(16))) + + return values + + +def ref_encode(raw: bytes) -> str: + """An independent implementation of the format, stated as arithmetic rather + than as bit shuffling: the 128-bit value, moved up by the two slack bits, + written in 26 base32 digits, then rotated. ``encode_bytes`` is checked + against this rather than against itself, so a change in what ``b32encode`` + packs would show up. + """ + value = int.from_bytes(raw, "big") << SLACK_BITS + + digits = [] + for _ in range(ENCODED_LENGTH): + digits.append(ALPHABET[value & 31]) + value >>= 5 + + unrotated = "".join(reversed(digits)) + return unrotated[ROTATION:] + unrotated[:ROTATION] + + +def corpus_digest(encode: Callable[[bytes], str]) -> str: + """The digest :data:`CORPUS_DIGEST` pins.""" + joined = "\n".join(encode(raw) for raw in corpus()) + return hashlib.sha256(joined.encode()).hexdigest() diff --git a/packages/python-id/uv.lock b/packages/python-id/uv.lock new file mode 100644 index 0000000000..3797560c19 --- /dev/null +++ b/packages/python-id/uv.lock @@ -0,0 +1,211 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[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 = "e2b-id" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3,<10" }, + { name = "ruff", specifier = ">=0.11.12,<0.12" }, + { name = "ty", specifier = ">=0.0.15,<0.0.16" }, +] + +[[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 = "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 = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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 = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[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 = "ty" +version = "0.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/25/257602d316b9333089b688a7a11b33ebc660b74e8dacf400dc3dfdea1594/ty-0.0.15.tar.gz", hash = "sha256:4f9a5b8df208c62dba56e91b93bed8b5bb714839691b8cff16d12c983bfa1174", size = 5101936, upload-time = "2026-02-05T01:06:34.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c5/35626e732b79bf0e6213de9f79aff59b5f247c0a1e3ce0d93e675ab9b728/ty-0.0.15-py3-none-linux_armv6l.whl", hash = "sha256:68e092458516c61512dac541cde0a5e4e5842df00b4e81881ead8f745ddec794", size = 10138374, upload-time = "2026-02-05T01:07:03.804Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8a/48fd81664604848f79d03879b3ca3633762d457a069b07e09fb1b87edd6e/ty-0.0.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79f2e75289eae3cece94c51118b730211af4ba5762906f52a878041b67e54959", size = 9947858, upload-time = "2026-02-05T01:06:47.453Z" }, + { url = "https://files.pythonhosted.org/packages/b6/85/c1ac8e97bcd930946f4c94db85b675561d590b4e72703bf3733419fc3973/ty-0.0.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112a7b26e63e48cc72c8c5b03227d1db280cfa57a45f2df0e264c3a016aa8c3c", size = 9443220, upload-time = "2026-02-05T01:06:44.98Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56", size = 9949976, upload-time = "2026-02-05T01:07:01.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ab/3a0daad66798c91a33867a3ececf17d314ac65d4ae2bbbd28cbfde94da63/ty-0.0.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e48b42be2d257317c85b78559233273b655dd636fc61e7e1d69abd90fd3cba4", size = 9965918, upload-time = "2026-02-05T01:06:54.283Z" }, + { url = "https://files.pythonhosted.org/packages/39/4e/e62b01338f653059a7c0cd09d1a326e9a9eedc351a0f0de9db0601658c3d/ty-0.0.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27dd5b52a421e6871c5bfe9841160331b60866ed2040250cb161886478ab3e4f", size = 10424943, upload-time = "2026-02-05T01:07:08.777Z" }, + { url = "https://files.pythonhosted.org/packages/65/b5/7aa06655ce69c0d4f3e845d2d85e79c12994b6d84c71699cfb437e0bc8cf/ty-0.0.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76b85c9ec2219e11c358a7db8e21b7e5c6674a1fb9b6f633836949de98d12286", size = 10964692, upload-time = "2026-02-05T01:06:37.103Z" }, + { url = "https://files.pythonhosted.org/packages/13/04/36fdfe1f3c908b471e246e37ce3d011175584c26d3853e6c5d9a0364564c/ty-0.0.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e8204c61d8ede4f21f2975dce74efdb80fafb2fae1915c666cceb33ea3c90b", size = 10692225, upload-time = "2026-02-05T01:06:49.714Z" }, + { url = "https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d", size = 10516776, upload-time = "2026-02-05T01:06:52.047Z" }, + { url = "https://files.pythonhosted.org/packages/56/75/66852d7e004f859839c17ffe1d16513c1e7cc04bcc810edb80ca022a9124/ty-0.0.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50dccf7398505e5966847d366c9e4c650b8c225411c2a68c32040a63b9521eea", size = 9928828, upload-time = "2026-02-05T01:06:56.647Z" }, + { url = "https://files.pythonhosted.org/packages/65/72/96bc16c7b337a3ef358fd227b3c8ef0c77405f3bfbbfb59ee5915f0d9d71/ty-0.0.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bd797b8f231a4f4715110259ad1ad5340a87b802307f3e06d92bfb37b858a8f3", size = 9978960, upload-time = "2026-02-05T01:06:29.567Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/d2e316a35b626de2227f832cd36d21205e4f5d96fd036a8af84c72ecec1b/ty-0.0.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9deb7f20e18b25440a9aa4884f934ba5628ef456dbde91819d5af1a73da48af3", size = 10135903, upload-time = "2026-02-05T01:06:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/02/d3/b617a79c9dad10c888d7c15cd78859e0160b8772273637b9c4241a049491/ty-0.0.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7b31b3de031255b90a5f4d9cb3d050feae246067c87130e5a6861a8061c71754", size = 10615879, upload-time = "2026-02-05T01:07:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b0/2652a73c71c77296a6343217063f05745da60c67b7e8a8e25f2064167fce/ty-0.0.15-py3-none-win32.whl", hash = "sha256:9362c528ceb62c89d65c216336d28d500bc9f4c10418413f63ebc16886e16cc1", size = 9578058, upload-time = "2026-02-05T01:06:42.928Z" }, + { url = "https://files.pythonhosted.org/packages/84/6e/08a4aedebd2a6ce2784b5bc3760e43d1861f1a184734a78215c2d397c1df/ty-0.0.15-py3-none-win_amd64.whl", hash = "sha256:4db040695ae67c5524f59cb8179a8fa277112e69042d7dfdac862caa7e3b0d9c", size = 10457112, upload-time = "2026-02-05T01:06:39.885Z" }, + { url = "https://files.pythonhosted.org/packages/b3/be/1991f2bc12847ae2d4f1e3ac5dcff8bb7bc1261390645c0755bb55616355/ty-0.0.15-py3-none-win_arm64.whl", hash = "sha256:e5a98d4119e77d6136461e16ae505f8f8069002874ab073de03fbcb1a5e8bf25", size = 9937490, upload-time = "2026-02-05T01:06:32.388Z" }, +] + +[[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" }, +] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e0aea807b..9a4c7cdb96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,8 @@ importers: specifier: ^4.113.0 version: 4.113.0(@types/node@20.19.43)(bufferutil@4.0.8)(utf-8-validate@6.0.3) + packages/python-id: {} + packages/python-sdk: {} packages: