diff --git a/.changeset/python-id-package.md b/.changeset/python-id-package.md new file mode 100644 index 0000000000..9b8ba88cb6 --- /dev/null +++ b/.changeset/python-id-package.md @@ -0,0 +1,5 @@ +--- +'@e2b/python-id': minor +--- + +Initial release: prefixed, human-legible IDs for E2B resources (`prj_…`, `wrk_…`, `sec_…`, …) in the TypeID encoding the Go services use, as a standard-library-only package (`e2b-id` on PyPI). diff --git a/.github/scripts/build_release_itinerary.cjs b/.github/scripts/build_release_itinerary.cjs index 2bb5f2a6d9..d8e63303f4 100644 --- a/.github/scripts/build_release_itinerary.cjs +++ b/.github/scripts/build_release_itinerary.cjs @@ -14,6 +14,7 @@ const labels = { '@e2b/dockerfile-utils': 'Dockerfile Utils JS (@e2b/dockerfile-utils)', '@e2b/dockerfile-utils-python': 'Dockerfile Utils Python (e2b-dockerfile-utils)', '@e2b/id': 'ID JS (@e2b/id)', + '@e2b/python-id': 'ID Python (e2b-id)', } const statusFile = process.argv[2] diff --git a/.github/workflows/id_tests.yml b/.github/workflows/id_tests.yml index 0aa803120d..5c8d9ee50a 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: an + # independent arithmetic encoder has to agree over the whole corpus. + - 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 a2039314ba..cf40a28645 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -82,6 +82,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/pkg_artifacts.yml b/.github/workflows/pkg_artifacts.yml index 887dd650eb..d747d7f41e 100644 --- a/.github/workflows/pkg_artifacts.yml +++ b/.github/workflows/pkg_artifacts.yml @@ -175,6 +175,19 @@ jobs: name: e2b-dockerfile-utils-python path: packages/dockerfile-utils-python/dist/* + - name: Build ID Python + working-directory: packages/python-id + run: | + BASE_VERSION=$(uv version --short) + uv version "${BASE_VERSION}+${BRANCH_ID}" + uv build + + - name: Upload ID Python artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2b-id-python + path: packages/python-id/dist/* + - name: Build Python SDK working-directory: packages/python-sdk run: | @@ -250,6 +263,8 @@ jobs: CODE_INTERPRETER_PY_WHL=$(artifact_basename packages/code-interpreter-python/dist/*.whl) DESKTOP_PY_VERSION=$(grep '^version' packages/desktop-python/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') DESKTOP_PY_WHL=$(artifact_basename packages/desktop-python/dist/*.whl) + ID_PY_VERSION=$(grep '^version' packages/python-id/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + ID_PY_WHL=$(artifact_basename packages/python-id/dist/*.whl) BODY=""$'\n' BODY+="### Package Artifacts"$'\n\n' @@ -285,6 +300,10 @@ jobs: BODY+="**Desktop Python SDK** (\`e2b-desktop==${DESKTOP_PY_VERSION}\`):"$'\n' BODY+='```sh'$'\n' BODY+="pip install ./${DESKTOP_PY_WHL}"$'\n' + BODY+='```'$'\n\n' + BODY+="**ID Python** (\`e2b-id==${ID_PY_VERSION}\`):"$'\n' + BODY+='```sh'$'\n' + BODY+="pip install ./${ID_PY_WHL}"$'\n' BODY+='```'$'\n' COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef4254cf9e..99dc9f5852 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,7 @@ jobs: dockerfile_utils_js: ${{ steps.dockerfile_utils_js.outputs.release }} dockerfile_utils_python: ${{ steps.dockerfile_utils_python.outputs.release }} id_js: ${{ steps.id_js.outputs.release }} + id_python: ${{ steps.id_python.outputs.release }} itinerary: ${{ steps.itinerary.outputs.itinerary }} steps: - name: Check the ref @@ -156,6 +157,13 @@ jobs: IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/id" "$RUNNER_TEMP/release-plan.json") echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Check ID Python Release + id: id_python + if: steps.version.outputs.release == 'true' + run: | + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/python-id" "$RUNNER_TEMP/release-plan.json") + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Build release itinerary id: itinerary if: steps.version.outputs.release == 'true' @@ -234,7 +242,8 @@ jobs: id-tests: name: ID Package Tests needs: [preflight] - if: needs.preflight.outputs.id_js == 'true' + # One workflow tests both ID packages, since their point is that they agree. + if: needs.preflight.outputs.id_js == 'true' || needs.preflight.outputs.id_python == 'true' uses: ./.github/workflows/id_tests.yml publish: diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml index cf06732875..63e340b9da 100644 --- a/.github/workflows/sdk_tests.yml +++ b/.github/workflows/sdk_tests.yml @@ -116,6 +116,7 @@ jobs: - *shared - '.github/workflows/id_tests.yml' - 'packages/js-id/**/!(*.md)' + - 'packages/python-id/**/!(*.md)' js-tests: name: JS SDK Tests diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e17126ad64..7503ee8257 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 007ea35f22..cfae8bfdcb 100644 --- a/packages/js-id/README.md +++ b/packages/js-id/README.md @@ -25,8 +25,9 @@ isId('project', 'vol_01kyjhkfy5f16s71n5tymkmt9a') // false ``` -The format is deliberately portable: any TypeID library reads it, and so does -any language with integers. 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 @@ -146,8 +147,8 @@ pnpm test pnpm lint && pnpm typecheck ``` -`tests/vectors.ts` holds the golden encodings (the Go suite's among them), a -seeded 1306-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 -snippet above. +`tests/vectors.ts` and `packages/python-id/tests/vectors.py` are the same file in +two languages: the same golden encodings (the Go suite's among them), 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 snippet above. diff --git a/packages/js-id/tests/codec.test.ts b/packages/js-id/tests/codec.test.ts index c226a67234..74c83c70e0 100644 --- a/packages/js-id/tests/codec.test.ts +++ b/packages/js-id/tests/codec.test.ts @@ -70,8 +70,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 d95a963a53..f7d5f595f0 100644 --- a/packages/js-id/tests/interop.test.ts +++ b/packages/js-id/tests/interop.test.ts @@ -12,10 +12,10 @@ import { corpus } from './vectors' * The snippet below is the one in `src/codec.ts`'s documentation and in * `@e2b/id`'s README, verbatim: the alphabet and integer arithmetic, no bit * shuffling. If this passes, the format really is "the 128-bit value in 26 - * Crockford base32 digits, most significant first", so any port tested the - * same way against the same corpus decodes what this package emits. - * `int.to_bytes(16)` raising on 129 bits is also Python's own statement of the - * leading-digit rule. + * Crockford base32 digits, most significant first", and `e2b_id` — which is + * tested the same way against the same corpus — decodes what this package + * emits. `int.to_bytes(16)` raising on 129 bits is also Python's own statement + * of the leading-digit rule. */ const SNIPPET = ` import sys @@ -73,8 +73,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 a400b5e469..dbabddc74b 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' @@ -101,8 +101,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 = 'e58522dfc30f44687edca477cc58f37015d7c9e1c46311a354654db07b2f6bdc' diff --git a/packages/python-id/LICENSE b/packages/python-id/LICENSE new file mode 100644 index 0000000000..a6e6efd786 --- /dev/null +++ b/packages/python-id/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 FoundryLabs, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. 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..58f7ca909d --- /dev/null +++ b/packages/python-id/README.md @@ -0,0 +1,159 @@ +# `e2b_id` + +Prefixed, human-legible IDs for E2B resources. Standard library only — +`e2b-id` on PyPI, `import e2b_id` in code. + +```sh +pip install e2b-id +``` + +```python +from e2b_id import create_id, decode_id, encode_id, is_id, parse_id + +create_id("project") +# 'prj_01kyjhkfvse96rg4d2qzd9enft' + +encode_id("volume", "019fa519-bfc5-784d-9386-a5d7a93a692a") +# 'vol_01kyjhkfy5f16s71n5tymkmt9a' + +decode_id("volume", "vol_01kyjhkfy5f16s71n5tymkmt9a") +# UUID('019fa519-bfc5-784d-9386-a5d7a93a692a') + +parse_id("vol_01kyjhkfy5f16s71n5tymkmt9a") +# ParsedId(kind='volume', uuid=UUID('019fa519-bfc5-784d-9386-a5d7a93a692a')) + +is_id("project", "vol_01kyjhkfy5f16s71n5tymkmt9a") +# 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_7mfb0gpp6c8dsaasre0asc7n3s +wrk_7mfb0gpp6c8dsaasre0asc7n3s +sec_01kyjhkfvse96rg4d2qzd9enft +vol_01kyjhkfy5f16s71n5tymkmt9a +sbx_01kyjhkfzbf7samagavqtvkg6s +usr_01kyjhkg0hfj7a0eeb7emcn30p +grp_2n1t201rmv87aae5j4csam8000 +``` + +| kind | prefix | +| ----------- | ------ | +| `project` | `prj` | +| `workspace` | `wrk` | +| `secret` | `sec` | +| `volume` | `vol` | +| `sandbox` | `sbx` | +| `user` | `usr` | +| `group` | `grp` | + +The encoding is [TypeID](https://github.com/jetify-com/typeid)'s: the UUID's +128 bits written in base 32 with the Crockford alphabet (`0-9a-z` minus `i`, +`l`, `o` and `u`), lowercase, most significant digit first, left-padded to 26 +digits. 26 digits hold 130 bits, so the first character carries two zero bits +and is always `0` to `7`. + +This is the format E2B's services emit and accept — the Go side is +[`packages/shared/pkg/id`](https://github.com/e2b-dev/infra/tree/main/packages/shared/pkg/id) +in e2b-dev/infra, built on `github.com/sumup/typeid`, and its test vectors are +pinned in this package's suite. `project`, `workspace` and `secret` are the +kinds it defines, with the same prefixes; the other four are ahead of it. Any +TypeID library reads these IDs, and so does any language with integers: + +```python +ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + +def encode(b: bytes) -> str: + n = int.from_bytes(b, "big") + return "".join(ALPHABET[(n >> (5 * i)) & 31] for i in range(25, -1, -1)) + +def decode(s: str) -> bytes: + n = 0 + for c in s: + n = n * 32 + ALPHABET.index(c) + return n.to_bytes(16, "big") +``` + +The digits are the bits in order, so IDs sort the way their UUIDs do. A UUIDv7 +leads with a millisecond timestamp, and so its IDs are ordered by mint time — +which also means IDs minted together share a long prefix. The tail is the +distinctive part to read. + +### One spelling per ID + +A leading character above `7` spells a value no 16 bytes can hold, so every +UUID has four strings a careless 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 — exactly +what the Go side rejects. + +### UUIDs, the way Go reads and mints them + +`encode_id` takes a `uuid.UUID`, or a string in any of the four spellings +`google/uuid`'s `Parse` accepts, and only those: hyphenated `8-4-4-4-12`, the +same after `urn:uuid:` or in braces, or 32 bare hex digits, in either case. +That is narrower than `uuid.UUID()` itself, which would also take hyphens +anywhere and a bare `uuid:` prefix that Go would not. Nothing checks the version +or variant bits, because Go does not. + +`create_uuid` mints a UUIDv7 the way `NewV7` does, line for line: a millisecond +timestamp, then a 12-bit sequence taken from the clock's sub-millisecond bits +and bumped so that every UUID this process mints is strictly greater than the +last, even within a millisecond and even if the clock steps back, then 62 random +bits. So `create_id` output sorts in call order. + +## 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, monotonic per process like `google/uuid`'s | +| `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 one divergence in +the surface is that JavaScript has no UUID type, so `@e2b/id` moves UUIDs as +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 a string; `encodeId` takes only + the string. Both accept the same four string spellings. + +And one in what `create_uuid()` puts in the 12-bit sequence: here it is the +clock's sub-millisecond bits, as in Go, because `time.time_ns()` has them. +JavaScript's `Date.now()` is whole milliseconds, so `createUuid()` counts up +from zero within each millisecond instead. The guarantee is the same on both +sides — every UUID a process mints is greater than the last — but two processes +minting in the same millisecond interleave by sub-millisecond time in Python +and Go, and arbitrarily in JavaScript. + +## 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 Go suite's among them), the same +seeded corpus, and the same `CORPUS_DIGEST` over it. Changing the format on one +side alone fails on both. diff --git a/packages/python-id/e2b_id/__init__.py b/packages/python-id/e2b_id/__init__.py new file mode 100644 index 0000000000..55bb8a6331 --- /dev/null +++ b/packages/python-id/e2b_id/__init__.py @@ -0,0 +1,59 @@ +"""``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_01kyjhkfvse96rg4d2qzd9enft' + 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 TypeID encoding of a UUID — the +same format E2B's Go services emit. See :mod:`e2b_id.ids` for what the prefix +buys and :mod:`e2b_id.codec` for the encoding. ``@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..ed159ab79a --- /dev/null +++ b/packages/python-id/e2b_id/codec.py @@ -0,0 +1,192 @@ +"""The TypeID codec: 16 bytes as 26 lowercase Crockford base32 characters. + +The 128-bit value is written in base 32, most significant digit first, with the +Crockford alphabet (``0-9a-z`` minus ``i``, ``l``, ``o`` and ``u``), left-padded +to 26 digits. 26 digits carry 130 bits, so the leading digit holds the top three +bits of the value under two bits that are always zero: every encoding starts +with a character from ``0`` to ``7``. + +This is the encoding of the TypeID specification and of +``github.com/sumup/typeid``, which is what E2B's Go services use +(``packages/shared/pkg/id`` in e2b-dev/infra). Agreeing with it is the whole +requirement: an ID minted here must decode there to the same UUID, and the +other way round. The Go suite's vectors are pinned in ``tests/vectors.py``. + +The digits are the bits in order and the alphabet is in ASCII order, so the +string sorts the way the bytes do. A UUIDv7 leads with its 48-bit millisecond +timestamp, and so does its ID: IDs minted together share a long prefix, and +lexical order is mint order to the millisecond. That is what makes a TypeID +k-sortable — and why the front of an ID is the least distinctive part to read; +look at the tail. + +Reading one in any language takes the alphabet and arithmetic:: + + ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + + def encode(b: bytes) -> str: + n = int.from_bytes(b, "big") + return "".join(ALPHABET[(n >> (5 * i)) & 31] for i in range(25, -1, -1)) + + def decode(s: str) -> bytes: + n = 0 + for c in s: + n = n * 32 + ALPHABET.index(c) + return n.to_bytes(16, "big") + +This module does it with ``base64.b32encode`` instead: four zero bytes in front +make 20 bytes, which is 32 RFC 4648 digits with no padding, of which the first +six are zero and the rest are the 26 wanted — then a character translation from +the RFC 4648 alphabet to Crockford's. The tests check that against the arithmetic +above. + +Because 128 is not a multiple of 5, a leading digit of 8 or more spells a value +no 16 bytes can hold. A decoder that masked those bits away would map four +strings to every value; :func:`decode_bytes` accepts only the one +:func:`encode_bytes` produces and rejects the other three as ``overflow``, which +is also what the Go side does. +""" + +from __future__ import annotations + +import base64 + +from typing import Optional, Tuple + +from .errors import InvalidIdException, InvalidIdReason + +#: Crockford base32, lowercased: the digits, then the letters with ``i``, ``l``, +#: ``o`` and ``u`` left out so nothing reads as ``1`` or ``0``. The index of a +#: character is its digit value, and the characters are in ASCII order, which is +#: what makes encodings sort like the bytes they carry. +ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + +#: 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 + +#: ``26 * 5 - 128``: the bits the width has spare. They are the two most +#: significant bits of the leading digit, and they are always zero. +SLACK_BITS = ENCODED_LENGTH * 5 - DECODED_LENGTH * 8 + +#: The largest digit value the leading character may hold: the three bits under +#: the slack, so ``7``. Anything above it spells a 129- or 130-bit value. +MAX_LEADING_DIGIT = (1 << (5 - SLACK_BITS)) - 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)} + +# What b32encode emits, in the order of its digit values, so a translation +# table maps it onto the Crockford alphabet digit for digit. +_RFC4648 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +_TO_CROCKFORD = str.maketrans(_RFC4648, ALPHABET) +_FROM_CROCKFORD = str.maketrans(ALPHABET, _RFC4648) + +# Four zero bytes in front of the 16 make 20, which b32encode turns into 32 +# digits with no padding. The first 6 digits are the 30 leading zero bits, and +# the 7th starts with the other 2 — the slack bits — so it is 0 to 7. +_FRONT_PAD = bytes(4) +_FRONT_PAD_DIGITS = len(_FRONT_PAD) * 8 // 5 + + +def encode_bytes(raw: bytes) -> str: + """Encode 16 bytes as 26 lowercase Crockford base32 characters, most + significant first. + + 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}.", + ) + + digits = base64.b32encode(_FRONT_PAD + raw).decode()[_FRONT_PAD_DIGITS:] + return digits.translate(_TO_CROCKFORD) + + +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 translated string below. Case is part of it: uppercase is simply + # not in the alphabet, and 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 bits are checked before decoding because b32decode will not + # check them: it would hand back 20 bytes whose first four are not all zero, + # and slicing them off would map all four spellings of a value to the same + # 16 bytes. The difference is only visible here. + if _VALUES[encoded[0]] > MAX_LEADING_DIGIT: + return ("overflow", 0) + + 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 Crockford base32 characters "{ALPHABET}".', + ) + return InvalidIdException( + "overflow", + f'"{encoded}" starts with {encoded[index]!r}, which spells a value above ' + f"{DECODED_LENGTH * 8} bits: the first character must be " + f'"{ALPHABET[0]}" to "{ALPHABET[MAX_LEADING_DIGIT]}".', + ) + + +def decode_bytes(encoded: str) -> bytes: + """Decode what :func:`encode_bytes` produced, and only that: 26 + characters, lowercase Crockford base32, with a leading character from + ``0`` to ``7``. + + :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 leads with a character + above ``7``. + """ + fault = fault_in_encoded(encoded) + if fault is not None: + raise invalid_encoding(encoded, fault) + + digits = _RFC4648[0] * _FRONT_PAD_DIGITS + encoded.translate(_FROM_CROCKFORD) + return base64.b32decode(digits)[len(_FRONT_PAD) :] diff --git a/packages/python-id/e2b_id/errors.py b/packages/python-id/e2b_id/errors.py new file mode 100644 index 0000000000..f3e94054a1 --- /dev/null +++ b/packages/python-id/e2b_id/errors.py @@ -0,0 +1,47 @@ +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 overflowing 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, or is not a string. +#: - ``length``: the encoding is not ``ENCODED_LENGTH`` characters. +#: - ``alphabet``: it holds a character outside the lowercase Crockford alphabet. +#: - ``overflow``: its leading character is above ``7``, so it spells a value no +#: 16 bytes can hold — one of the three non-canonical spellings of a value. +#: - ``uuid``: the UUID given was not in one of the four forms ``google/uuid`` +#: accepts. +InvalidIdReason: TypeAlias = Literal[ + "kind", "prefix", "length", "alphabet", "overflow", "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..f0b7b36f57 --- /dev/null +++ b/packages/python-id/e2b_id/ids.py @@ -0,0 +1,268 @@ +"""Prefixed, human-legible IDs for E2B resources. + +An ID is a three-character kind prefix, an underscore, and the 26-character +TypeID encoding of a UUID:: + + prj_01kyjhkfvse96rg4d2qzd9enft + ^^^ ^ + | 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 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, hex_digits_of + +#: A resource kind that has IDs, e.g. ``"project"``. +IdKind: TypeAlias = Literal[ + "project", "workspace", "secret", "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`. ``project``, ``workspace`` +#: and ``secret`` are the kinds the Go side defines (``packages/shared/pkg/id`` +#: in e2b-dev/infra), with the same prefixes; the rest are ahead of it. +ID_PREFIXES: Mapping[IdKind, str] = { + "project": "prj", + "workspace": "wrk", + "secret": "sec", + "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() +} + + +@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 _not_a_string(id: object) -> InvalidIdException: + # A None or a number out of a JSON payload. Callers that turn + # InvalidIdException into a 400 would otherwise get an AttributeError, and a + # 500. Matches @e2b/id, which raises the same reason for the same input. + got = "None" if id is None else type(id).__name__ + return InvalidIdException("prefix", f"Expected an ID string, got {got}.") + + +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 + digits = hex_digits_of(uuid) if isinstance(uuid, str) else None + if digits is None: + raise InvalidIdException( + "uuid", + f'"{uuid}" is not a UUID: expected 32 hex digits, hyphenated 8-4-4-4-12 ' + 'like "019fa519-bf79-724d-8811-a2bfda9755fa" (optionally in braces or ' + 'after "urn:uuid:"), or unhyphenated.', + ) + return uuidlib.UUID(hex=digits).bytes + + +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 any of the + four string forms ``google/uuid`` accepts: hyphenated 8-4-4-4-12, the + same with a ``urn:uuid:`` prefix or in braces, or 32 bare hex digits. + :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_01kyjhkfvse96rg4d2qzd9enft' + """ + 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_01kyjhkfvse96rg4d2qzd9enft") + # UUID('019fa519-bf79-724d-8811-a2bfda9755fa') + """ + prefix = _prefix_of(kind) + if not isinstance(id, str): + raise _not_a_string(id) + if not id.startswith(prefix + _SEPARATOR): + # As in parse_id, an ID with no separator has no prefix to name. + named_prefix, separator, _ = id.partition(_SEPARATOR) + named = _KINDS_BY_PREFIX.get(named_prefix) if separator else None + 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_01kyjhkfzbf7samagavqtvkg6s' + """ + 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_01kyjhkfy5f16s71n5tymkmt9a") + # ParsedId(kind='volume', uuid=UUID('019fa519-bfc5-784d-9386-a5d7a93a692a')) + """ + if not isinstance(id, str): + raise _not_a_string(id) + 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 leading + character. + + 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..24b79093c3 --- /dev/null +++ b/packages/python-id/e2b_id/uuids.py @@ -0,0 +1,144 @@ +"""Parsing and minting the UUIDs that IDs are made from — both mirroring +``github.com/google/uuid``, which is what E2B's Go services parse and mint +UUIDs with, so that a UUID string Go accepts is one this package accepts, and +the UUIDs minted here have the shape of the ones minted there. + +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 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 a string, + since a UUID arriving as JSON is a string either way. + +Everything else — the names, the argument order, the failure modes, the wire +format, the strings accepted — is the same on both sides, with one difference +underneath :func:`create_uuid`: its 12-bit sequence is the clock's +sub-millisecond bits here, as in Go, and a per-millisecond counter in +JavaScript, whose clock has no such bits. The ordering guarantee is the same. +""" + +from __future__ import annotations + +import re +import secrets +import threading +import time +import uuid +from typing import Optional, Tuple + +# The hyphenated 8-4-4-4-12 form and 32 bare hex digits, in either case. +_HYPHENATED = 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, +) +_BARE = re.compile(r"[0-9a-f]{32}\Z", re.IGNORECASE) + +# The prefix of the URN form, matched case-insensitively like Go does. +_URN_PREFIX = "urn:uuid:" + + +def hex_digits_of(value: str) -> Optional[str]: + """The 32 hex digits of a UUID string, or ``None`` if it is not one. + + Exactly the four spellings ``google/uuid``'s ``Parse`` takes, chosen by + length as it does: hyphenated, ``urn:uuid:``-prefixed, braced, and bare. + Nothing is checked about the version or variant bits, as nothing is there — + a value that is 128 bits but not an RFC 9562 UUID still parses, and encodes. + + Held to those four on purpose: :class:`uuid.UUID` itself would also take + hyphens anywhere and a bare ``uuid:`` prefix, which Go would not, and the + point of this function is that both sides agree on what is a UUID. + + Go does not look at the braces themselves: it drops the first character and + never reads the last, so ``[…]`` and ``x…y`` parse there. That is an + oversight rather than a contract, and it is not mirrored — the braces have + to be braces here. + """ + length = len(value) + if length == 36: + return value.replace("-", "") if _HYPHENATED.match(value) else None + if length == 36 + len(_URN_PREFIX): + if value[: len(_URN_PREFIX)].lower() != _URN_PREFIX: + return None + return hex_digits_of(value[len(_URN_PREFIX) :]) + if length == 36 + 2: + if value[0] != "{" or value[-1] != "}": + return None + return hex_digits_of(value[1:-1]) + if length == 32: + return value if _BARE.match(value) else None + return None + + +_MILLISECOND_BITS = 48 +_SEQUENCE_BITS = 12 +_SEQUENCE_MASK = (1 << _SEQUENCE_BITS) - 1 +_NANOS_PER_MILLI = 1_000_000 + +# The last (timestamp << 12 | sequence) handed out, so the next is always +# greater: ``google/uuid`` keeps the same number behind a mutex. Module state, +# so it is per process, like Go's. +_time_lock = threading.Lock() +_last_v7_time = -1 + + +def _next_v7_time() -> Tuple[int, int]: + """The timestamp and sequence for the next UUIDv7, strictly greater than + the previous pair even if the clock stalls or steps back. + + ``google/uuid``'s ``getV7Time``, line for line: the sequence is the + sub-millisecond part of the clock in units of 256 nanoseconds (0 to 3906), + and when that fails to move past the last value the pair is bumped by one + instead, carrying into the millisecond on overflow. + """ + global _last_v7_time + with _time_lock: + nano = time.time_ns() + ms = nano // _NANOS_PER_MILLI + sequence = (nano - ms * _NANOS_PER_MILLI) >> 8 + now = (ms << _SEQUENCE_BITS) + sequence + if now <= _last_v7_time: + now = _last_v7_time + 1 + ms = now >> _SEQUENCE_BITS + sequence = now & _SEQUENCE_MASK + _last_v7_time = now + return ms, sequence + + +def create_uuid() -> uuid.UUID: + """Mint a UUIDv7 the way ``google/uuid``'s ``NewV7`` does: a 48-bit + big-endian millisecond timestamp, the version nibble, a 12-bit sequence + that makes every UUID minted by this process strictly greater than the one + before, the variant bits, and 62 random bits. + + IDs are minted from v7 rather than v4 so that the bytes a database stores + sort chronologically — and, since :func:`e2b_id.encode_id` keeps the bits + in order, so do the IDs, down to the order of two calls in the same + millisecond. The Go side mints project and workspace UUIDs as v4; the codec + does not care either way. + + ``uuid.uuid7`` covers the layout from Python 3.14 on, but the floor here is + 3.10, and its sequence is a counter rather than Go's clock bits. + + :return: the UUID. + """ + ms, sequence = _next_v7_time() + + value = (ms & ((1 << _MILLISECOND_BITS) - 1)) << (128 - _MILLISECOND_BITS) + value |= 0x7 << 76 # version 7 + value |= sequence << 64 # rand_a: the sequence + value |= 0b10 << 62 # variant 10 + value |= secrets.randbits(62) # rand_b + + 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..f2c86adb4d --- /dev/null +++ b/packages/python-id/package.json @@ -0,0 +1,14 @@ +{ + "name": "@e2b/python-id", + "private": true, + "version": "0.0.0", + "scripts": { + "test": "uv run pytest --verbose", + "postVersion": "uv version --frozen $(pnpm pkg get version --workspaces=false | tr -d \\\")", + "lock": "uv lock", + "postPublish": "uv build && uv publish --trusted-publishing always --check-url https://pypi.org/simple/", + "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..f4871f7ede --- /dev/null +++ b/packages/python-id/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "e2b-id" +version = "0.0.0" +description = "Prefixed, human-legible IDs for E2B resources" +authors = [{ name = "e2b", email = "hello@e2b.dev" }] +license = "Apache-2.0" +license-files = ["LICENSE"] +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..0c01e1b969 --- /dev/null +++ b/packages/python-id/tests/test_codec.py @@ -0,0 +1,238 @@ +import uuid as uuidlib + +import pytest +from vectors import ( + CORPUS_DIGEST, + GOLDEN, + SLACK_STEP, + Lcg, + bytes_from, + corpus, + corpus_digest, + ref_decode, + ref_encode, + v7, +) + +from e2b_id import InvalidIdException +from e2b_id.codec import ( + ALPHABET, + DECODED_LENGTH, + ENCODED_LENGTH, + MAX_LEADING_DIGIT, + SLACK_BITS, + decode_bytes, + encode_bytes, +) + + +def test_the_format_is_these_exact_numbers(): + # Pinned as literals, not as their own definitions: asserting + # `SLACK_BITS == ENCODED_LENGTH * 5 - DECODED_LENGTH * 8` restates the line + # that computes it and so can never fail. 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 == "0123456789abcdefghjkmnpqrstvwxyz" + assert DECODED_LENGTH == 16 + assert ENCODED_LENGTH == 26 + assert SLACK_BITS == 2 + assert MAX_LEADING_DIGIT == 7 + + +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 MAX_LEADING_DIGIT == (1 << (5 - SLACK_BITS)) - 1 + assert SLACK_STEP == MAX_LEADING_DIGIT + 1 + assert len(ALPHABET) == 32 + assert len(set(ALPHABET)) == 32 + # Crockford: no i, l, o or u, so nothing reads as a 1 or a 0. + assert not set("ilou") & set(ALPHABET) + # In ASCII order, which is what lets encoded order follow byte order. + assert "".join(sorted(ALPHABET)) == ALPHABET + + +@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_in_order(): + # The claim of the format: the string is the value's bits in 5-bit groups, + # most significant first. 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", [1, 2, 7, 8, 31]) +def test_the_smallest_values_are_one_character_from_zero_at_the_end(value: int): + # The 31 smallest nonzero values fit in the last digit, so they are one + # character from the encoding of zero; 32 is the first to spill into the + # next one. That pins where the low bits live and that no shift happened. + raw = bytes_from(value) + want = ALPHABET[0] * (ENCODED_LENGTH - 1) + ALPHABET[value] + + assert encode_bytes(raw) == want + assert decode_bytes(want) == raw + + +def test_the_top_bit_is_the_leading_digit(): + # Bit 127 lands in the first digit, under the two slack bits: it is the + # leading digit's value 4, so the largest leading digit is 7. + assert encode_bytes(bytes_from(1 << 127)) == ALPHABET[4] + ALPHABET[0] * ( + ENCODED_LENGTH - 1 + ) + assert encode_bytes(bytes_from((1 << 128) - 1))[0] == ALPHABET[MAX_LEADING_DIGIT] + + +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 decoder maps to it, differing only in the two high bits of the + # leading digit. Exactly one — the one with those bits zero — is accepted. + for raw in corpus()[:200]: + encoded = encode_bytes(raw) + digit = ALPHABET.index(encoded[0]) + assert digit <= MAX_LEADING_DIGIT, encoded + + accepted = 0 + for slack in range(1 << SLACK_BITS): + alternative = ALPHABET[digit | (slack * SLACK_STEP)] + encoded[1:] + + # This is what makes the check necessary rather than incidental: a + # decoder that keeps the low 128 bits maps all four spellings to the + # same 16 bytes and reports nothing. Checked against ref_decode, + # which unpacks without consulting the leading digit at all. + assert ref_decode(alternative) == raw, alternative + + if slack == 0: + assert decode_bytes(alternative) == raw + accepted += 1 + continue + with pytest.raises(InvalidIdException, match="above 128 bits"): + decode_bytes(alternative) + + assert accepted == 1, encoded + + +def test_only_0_to_7_appear_first(): + # The same fact from the outside: only 8 of the 32 characters can ever + # appear first, and over enough values every one of them does. + allowed = set(ALPHABET[: MAX_LEADING_DIGIT + 1]) + assert allowed == set("01234567") + + seen = set() + for raw in corpus(): + digit = encode_bytes(raw)[0] + assert digit in allowed, f"{raw.hex()} starts with {digit!r}" + seen.add(digit) + assert seen == allowed + + +_VALID = encode_bytes(uuidlib.UUID("019fa41f-41cc-761e-8868-daa906581007").bytes) +# The first characters are digits, which have 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 + "0"), + ("base32 padding", _VALID[:-1] + "="), + # Nothing here ever emits uppercase, so accepting it would give every + # value millions of spellings. Go rejects it too. + ("uppercase", _VALID.upper()), + ( + "a single uppercase letter", + _VALID[:_FIRST_LETTER] + + _VALID[_FIRST_LETTER].upper() + + _VALID[_FIRST_LETTER + 1 :], + ), + # The four letters Crockford leaves out. Go's "bad alphabet" case is "i". + ('"i", which is not in the alphabet', _VALID[:-1] + "i"), + ('"l", which is not in the alphabet', _VALID[:-1] + "l"), + ('"o", which is not in the alphabet', _VALID[:-1] + "o"), + ('"u", which is not in the alphabet', _VALID[:-1] + "u"), + ('"-", which is not a digit', "-" + _VALID[1:]), + ("a non-ascii character", _VALID[:-1] + "é"), + ("whitespace", " " + _VALID[1:]), + ('a leading "8"', "8" + _VALID[1:]), + ('a leading "z"', "z" + _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("0" * 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_front(): + # Two v7s minted in the same millisecond share their first 52 bits + # (timestamp plus version nibble), which is their first 10 digits. The + # encoding keeps them where they are, so the fronts match. + source = Lcg(7) + + for _ in range(1000): + a = v7(source) + b = bytearray(v7(source)) + b[:7] = a[:7] # same millisecond, same version nibble + + assert encode_bytes(a)[:10] == encode_bytes(bytes(b))[:10] + + +def test_ids_minted_together_share_a_prefix(): + # The same point as the user sees it: a batch minted together shares a long + # prefix, so the tail is the part to read. + source = Lcg(11) + at = 1758000000000 + + fronts = {encode_bytes(v7(source, at + i))[:8] for i in range(200)} + + # 200 consecutive milliseconds move only the last of the first 10 digits or + # so; the first 8 are constant over the batch. + assert len(fronts) == 1 + + +def test_and_so_sort_order_survives(): + # What the user gets for that: encoded order is byte order, so a v7's + # chronology survives into the string and an index on it sorts by time. + source = Lcg(15) + values = [v7(source) for _ in range(5000)] + + for previous, current in zip(values, values[1:]): + low, high = sorted((previous, current)) + assert encode_bytes(low) < encode_bytes(high), low.hex() diff --git a/packages/python-id/tests/test_ids.py b/packages/python-id/tests/test_ids.py new file mode 100644 index 0000000000..7d7f1eea7d --- /dev/null +++ b/packages/python-id/tests/test_ids.py @@ -0,0 +1,379 @@ +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 uuids +from e2b_id.uuids import hex_digits_of + +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 ALPHABET, 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", + "secret", + "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 + ) + + +@pytest.mark.parametrize( + "form, value", + [ + ("hyphenated", "019fa519-bf79-724d-8811-a2bfda9755fa"), + ("urn-prefixed", "urn:uuid:019fa519-bf79-724d-8811-a2bfda9755fa"), + ("urn-prefixed in upper case", "URN:UUID:019FA519-BF79-724D-8811-A2BFDA9755FA"), + ("braced", "{019fa519-bf79-724d-8811-a2bfda9755fa}"), + ("bare", "019fa519bf79724d8811a2bfda9755fa"), + ], +) +def test_a_uuid_in_any_of_gos_four_forms_is_accepted(form: str, value: str): + # The four spellings google/uuid's Parse takes, so a UUID string the Go side + # accepts is one this package accepts, and both encode it the same. + assert encode_id("project", value) == "prj_01kyjhkfvse96rg4d2qzd9enft" + assert (hex_digits_of(value) or "").lower() == "019fa519bf79724d8811a2bfda9755fa" + + +def test_a_non_rfc_value_still_parses(): + # Nothing reads the version or variant: Go does not, so a 128-bit value + # that is not an RFC 9562 UUID still encodes, and to the same string. + assert encode_id("project", "26064700-4700-0000-0000-000000001111") == encode_id( + "project", "26064700470000000000000000001111" + ) + + +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, + # and so do the IDs made from them. 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_and_so_do_the_ids_minted_from_them(): + fronts = [create_id("sandbox")[: 4 + 10] for _ in range(1000)] + assert sorted(fronts) == fronts + + +def test_every_uuid_is_greater_than_the_one_before(): + # The first 8 bytes are the timestamp, the version and the sequence; the + # sequence is what makes two calls in the same millisecond ordered, so the + # whole UUID strictly increases call by call, not just its timestamp. + minted = [create_uuid() for _ in range(5000)] + for previous, current in zip(minted, minted[1:]): + assert current.int > previous.int, (previous, current) + + +# The mocked clocks below are in 2100, past anything already handed out, and +# each test's clock is later than the previous test's: the state is per process +# and never goes backwards, so a mock in the past would just be another stalled +# clock. +def test_even_when_the_clock_stalls(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(uuids.time, "time_ns", lambda: 4102444800000 * 1_000_000) + # The clock's sub-millisecond bits are zero, so the sequence starts at 0 + # and more than the 4096 it holds have to carry into the timestamp, which + # Go's getV7Time does by adding one to the packed value. + minted = [create_uuid() for _ in range(5000)] + assert len(set(minted)) == len(minted) + for previous, current in zip(minted, minted[1:]): + assert current.int > previous.int + assert {m.bytes[:6] for m in minted} == { + (4102444800000).to_bytes(6, "big"), + (4102444800001).to_bytes(6, "big"), + } + sequences = [int.from_bytes(m.bytes[6:8], "big") & 0xFFF for m in minted[:4096]] + assert sequences == list(range(4096)) + + +def test_and_when_it_steps_back(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(uuids.time, "time_ns", lambda: 4102444900000 * 1_000_000) + before = create_uuid() + monkeypatch.setattr(uuids.time, "time_ns", lambda: 4102444800000 * 1_000_000) + after = create_uuid() + assert after.int > before.int + assert after.bytes[:6] == before.bytes[:6] + + +def test_the_sequence_is_the_clocks_sub_millisecond_bits( + monkeypatch: pytest.MonkeyPatch, +): + # google/uuid fills rand_a with nanoseconds-within-the-millisecond / 256, + # so two UUIDs from the same millisecond carry its sub-millisecond order. + monkeypatch.setattr( + uuids.time, "time_ns", lambda: 4102445000000 * 1_000_000 + 999_999 + ) + minted = create_uuid() + assert minted.version == 7 + assert minted.bytes[:6] == (4102445000000).to_bytes(6, "big") + assert int.from_bytes(minted.bytes[6:8], "big") & 0xFFF == 999_999 >> 8 + + +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] + "i"), + ("surrounding whitespace", f" {_PROJECT_ID} "), + ("an overflowing leading character", "prj_z" + _PROJECT_ID[5:]), + ], +) +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_overflow_case_is_really_the_only_bits_flipped(): + # Index 4 of the id is the leading digit of the encoding. Setting its two + # high bits leaves a string that a decoder keeping the low 128 bits would + # happily map to the same UUID; the Go side rejects it as this one does. + digit = ALPHABET.index(_PROJECT_ID[4]) + overflowing = "prj_" + ALPHABET[digit | 24] + _PROJECT_ID[5:] + assert len(overflowing) == ID_LENGTH + assert overflowing != _PROJECT_ID + with pytest.raises(InvalidIdException, match="above 128 bits"): + decode_id("project", overflowing) + + +@pytest.mark.parametrize( + "reason, value", + [ + ("not hex", "not-a-uuid"), + ("one digit short", "019fa519-bf79-724d-8811-a2bfda9755f"), + ("one digit long", "019fa519-bf79-724d-8811-a2bfda9755faa"), + ("empty", ""), + # Hyphens anywhere but 8-4-4-4-12: Go checks the four positions, where + # uuid.UUID would strip them from anywhere. + ("hyphenated in the wrong places", "019fa519bf79-724d-8811-a2bf-da9755fa"), + ("bare but with one hyphen", "019fa519-bf79724d8811a2bfda9755fa"), + # The prefix has to be the URN one — uuid.UUID takes a bare "uuid:" too + # — and the braces have to be braces. Go would take "[…]" since it never + # looks at them, but that is not a spelling anyone writes on purpose. + ( + "prefixed with something else", + "uuid:uuid:019fa519-bf79-724d-8811-a2bfda9755fa", + ), + ("bare-prefixed", "uuid:019fa519-bf79-724d-8811-a2bfda9755fa"), + ("bracketed", "[019fa519-bf79-724d-8811-a2bfda9755fa]"), + ("half braced", "{019fa519-bf79-724d-8811-a2bfda9755fa "), + ("braced and urn-prefixed", "{urn:uuid:019fa519-bf79-724d-8811-a2bfda9755fa}"), + ("surrounded by whitespace", " 019fa519-bf79-724d-8811-a2bfda9755fa "), + ], +) +def test_encode_id_rejects_a_uuid_that_is(reason: str, value: str): + with pytest.raises(InvalidIdException) as caught: + encode_id("project", value) + assert caught.value.reason == "uuid" + + +@pytest.mark.parametrize( + "value", [None, 123, b"prj_01kyjhkfvse96rg4d2qzd9enft", ["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 + + +@pytest.mark.parametrize( + "value", [None, 123, b"prj_01kyjhkfvse96rg4d2qzd9enft", ["x"], {}] +) +def test_decode_id_and_parse_id_reject_anything_that_is_not_a_string(value): + # The same payload handed to decode_id or parse_id has to be an + # InvalidIdException, which callers turn into a 400, not an AttributeError. + for call in (lambda: decode_id("project", value), lambda: parse_id(value)): + with pytest.raises(InvalidIdException) as caught: + call() + assert caught.value.reason == "prefix" + + +@pytest.mark.parametrize("value", ["prjx", "volx", "prj"]) +def test_an_id_with_no_separator_names_no_kind(value: str): + # Pinned on both sides: @e2b/id once sliced to indexOf's -1 here and blamed + # "prjx" on the project kind. + with pytest.raises(InvalidIdException) as caught: + decode_id("volume", value) + assert caught.value.reason == "prefix" + assert caught.value.actual_kind is None + assert 'expected it to start with "vol_"' in str(caught.value) + + +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] + "i"), + ("overflow", "project", "prj_z" + _PROJECT_ID[5:]), + ], +) +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/test_uuids.py b/packages/python-id/tests/test_uuids.py new file mode 100644 index 0000000000..56644079ee --- /dev/null +++ b/packages/python-id/tests/test_uuids.py @@ -0,0 +1,195 @@ +"""The UUID half of the package on its own: what ``hex_digits_of`` takes, and +how ``create_uuid`` lays out a v7. ``test_ids.py`` reaches both only through +``encode_id`` and ``create_id``. + +Every ``create_uuid`` test starts from fresh module state: the last timestamp +and sequence handed out are per process and never go backwards, so without the +reset a test's mocked clock would be measured against whatever an earlier test +left behind. +""" + +import uuid as uuidlib +from typing import List + +import pytest +from vectors import corpus + +from e2b_id import InvalidIdException, create_uuid, encode_id +from e2b_id import uuids +from e2b_id.uuids import hex_digits_of + +_UUID = "019fa519-bf79-724d-8811-a2bfda9755fa" +_BARE = _UUID.replace("-", "") + + +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch): + """Fresh state and a settable clock, in nanoseconds.""" + monkeypatch.setattr(uuids, "_last_v7_time", -1) + now = [0] + monkeypatch.setattr(uuids.time, "time_ns", lambda: now[0]) + + def set_ns(ns: int) -> None: + now[0] = ns + + return set_ns + + +def _ms(ms: int, sub_ms_ns: int = 0) -> int: + return ms * 1_000_000 + sub_ms_ns + + +def _timestamp_of(value: uuidlib.UUID) -> int: + return int.from_bytes(value.bytes[:6], "big") + + +def _sequence_of(value: uuidlib.UUID) -> int: + return int.from_bytes(value.bytes[6:8], "big") & 0xFFF + + +# hex_digits_of + + +def test_hex_digits_of_reads_every_hex_digit_in_either_case(): + assert ( + hex_digits_of("0123456789abcdefABCDEF0123456789") + == "0123456789abcdefABCDEF0123456789" + ) + + +def test_hex_digits_of_agrees_with_the_standard_library_over_the_corpus(): + # uuid.UUID is the independent reference for the canonical and bare forms; + # the stricter forms it would also accept are pinned below. + for raw in corpus(): + canonical = str(uuidlib.UUID(bytes=raw)) + assert hex_digits_of(canonical) == raw.hex(), raw.hex() + assert hex_digits_of(canonical.upper()) == raw.hex().upper(), raw.hex() + assert hex_digits_of(raw.hex()) == raw.hex(), raw.hex() + assert encode_id("project", canonical) == encode_id( + "project", uuidlib.UUID(bytes=raw) + ) + + +# google/uuid's Parse dispatches on length, so each form is probed one +# character either side of its length and with one bad character inside it. +@pytest.mark.parametrize( + "case, value", + [ + ("hyphenated, one short", _UUID[:-1]), + ("hyphenated, one long", _UUID + "0"), + ("hyphenated, a non-hex digit", _UUID[:-1] + "g"), + ("hyphenated, a hyphen where a digit goes", _UUID[:-1] + "-"), + ("urn, one short", "urn:uuid:" + _UUID[:-1]), + ("urn, one long", "urn:uuid:" + _UUID + "0"), + ("urn, the wrong prefix", "urn:uid::" + _UUID), + ("urn, a non-hex digit", "urn:uuid:" + _UUID[:-1] + "g"), + ("braced, one short", "{" + _UUID[:-1] + "}"), + ("braced, one long", "{" + _UUID + "0}"), + ("braced, the wrong closing brace", "{" + _UUID + ")"), + ("braced, a non-hex digit", "{" + _UUID[:-1] + "g}"), + ("bare, one short", _BARE[:-1]), + ("bare, one long", _BARE + "0"), + ("bare, a non-hex digit", _BARE[:-1] + "g"), + # uuid.UUID would take each of these; Go does not. + ("hyphens anywhere", "019f-a519bf79724d8811a2bfda9755fa"), + ("the bare uuid: prefix", "uuid:" + _UUID), + ("a trailing newline", _UUID + "\n"), + ], +) +def test_hex_digits_of_rejects(case: str, value: str): + assert hex_digits_of(value) is None + with pytest.raises(InvalidIdException) as caught: + encode_id("project", value) + assert caught.value.reason == "uuid" + + +# A UUID column read out of JSON can be anything; the answer still has to be +# an InvalidIdException with the uuid reason, as in @e2b/id. +@pytest.mark.parametrize("value", [None, 123, b"0" * 16, bytes(16), [], {}]) +def test_encode_id_rejects_a_uuid_that_is_not_a_string(value): + with pytest.raises(InvalidIdException) as caught: + encode_id("project", value) + assert caught.value.reason == "uuid" + + +# create_uuid + + +def test_every_uuid_is_a_v7_with_the_rfc_9562_variant(): + # The variant overwrites two random bits, so it takes many draws to be sure + # no random value leaks through. + for _ in range(2000): + minted = create_uuid() + assert minted.version == 7 + assert minted.variant == uuidlib.RFC_4122 + + +# The 48-bit timestamp is shifted into place, so the values either side of +# 2^32 and the top of the range are the ones to pin. +@pytest.mark.parametrize( + "ms", [0, 1, 0xFFFFFFFF, 0x100000000, 0x03C0A519BF79, 2**48 - 1] +) +def test_the_timestamp_is_written_big_endian_into_the_first_six_bytes(clock, ms): + clock(_ms(ms)) + minted = create_uuid() + assert _timestamp_of(minted) == ms + assert _sequence_of(minted) == 0 + + +# Random bits only fill rand_b: with a generator of all ones or all zeros the +# timestamp, version and sequence are unchanged and the variant byte is exactly +# 0xbf or 0x80. +@pytest.mark.parametrize( + "bits, expected", + [ + ((1 << 62) - 1, "03c0a519-bf79-7000-bfff-ffffffffffff"), + (0, "03c0a519-bf79-7000-8000-000000000000"), + ], +) +def test_random_bits_only_reach_rand_b( + clock, monkeypatch: pytest.MonkeyPatch, bits: int, expected: str +): + clock(_ms(0x03C0A519BF79)) + monkeypatch.setattr(uuids.secrets, "randbits", lambda k: bits & ((1 << k) - 1)) + assert str(create_uuid()) == expected + + +def test_a_stalled_clock_counts_up_and_a_new_millisecond_starts_over(clock): + clock(_ms(1_000_000)) + sequences: List[int] = [_sequence_of(create_uuid()) for _ in range(3)] + assert sequences == [0, 1, 2] + + clock(_ms(1_000_001)) + minted = create_uuid() + assert _timestamp_of(minted) == 1_000_001 + assert _sequence_of(minted) == 0 + + +def test_a_full_sequence_carries_into_the_next_millisecond(clock): + # The clock's sub-millisecond bits start the sequence at 3906, its highest + # value, so 189 stalled mints reach 4095 and the next carries. + clock(_ms(1_000_000, 999_999)) + minted = [create_uuid() for _ in range(4096 - 3906)] + assert [_sequence_of(m) for m in minted] == list(range(3906, 4096)) + + carried = create_uuid() + assert _timestamp_of(carried) == 1_000_001 + assert _sequence_of(carried) == 0 + + # The clock still reads the old millisecond, which is now behind: the + # carried one keeps counting rather than going back. + assert _sequence_of(create_uuid()) == 1 + + # Once the clock moves past the carried value, its own bits take over. + clock(_ms(1_000_002, 256 * 7)) + assert _sequence_of(create_uuid()) == 7 + + +def test_a_clock_that_steps_back_keeps_the_last_millisecond(clock): + clock(_ms(1_000_000)) + before = create_uuid() + clock(_ms(500_000)) + after = create_uuid() + assert _timestamp_of(after) == 1_000_000 + assert _sequence_of(after) == _sequence_of(before) + 1 + assert after.int > before.int diff --git a/packages/python-id/tests/vectors.py b/packages/python-id/tests/vectors.py new file mode 100644 index 0000000000..f18fee395a --- /dev/null +++ b/packages/python-id/tests/vectors.py @@ -0,0 +1,222 @@ +"""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, SLACK_BITS +from e2b_id.ids import IdKind + +# UUIDs and the encodings they must produce. The first four are copied from the +# Go suite (packages/shared/pkg/id/object_test.go in e2b-dev/infra), so they are +# the compatibility contract with the services rather than a record of this +# package; the rest were computed with the arithmetic snippet in codec.py. +# Change the alphabet, the width or the bit order and they stop matching. +# +# The five v7s were minted 37ms apart, so note what the encoding keeps: they +# share "01kyjhkf" at the front, which is the common timestamp showing through, +# and differ from there. +GOLDEN: List[Tuple[str, str]] = [ + # From the Go suite: a v4, the team UUID, a v7 secret, and the "legacy body" + # test — a string in the old rotated format that happens to be a well-formed + # TypeID too, which Go pins as reading to this UUID and nothing else. + ("f47ac10b-58cc-4372-a567-0e02b2c3d479", "7mfb0gpp6c8dsaasre0asc7n3s"), + ("550e8400-e29b-41d4-a716-446655440000", "2n1t201rmv87aae5j4csam8000"), + ("019fa519-bf79-724d-8811-a2bfda9755fa", "01kyjhkfvse96rg4d2qzd9enft"), + ("df8aa05e-b25e-7932-a6aa-33a2cddc3eef", "6zhag5xcjyf4sadahkmb6xrfqf"), + # v7, minted 37ms after the one above and after each other. + ("019fa519-bf9f-762a-a916-431479cc7171", "01kyjhkfwzernaj5j32hwwrwbh"), + ("019fa519-bfc5-784d-9386-a5d7a93a692a", "01kyjhkfy5f16s71n5tymkmt9a"), + ("019fa519-bfeb-79f2-aa2a-0addf5b9c0d9", "01kyjhkfzbf7samagavqtvkg6s"), + ("019fa519-c011-7c8e-a039-cb3ba8ca8c16", "01kyjhkg0hfj7a0eeb7emcn30p"), + # 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", "53d4nq93fd6cmtymyc4etdfq17"), + ("fbe1337a-dac0-53d8-805c-905bca106f3e", "7vw4sqnpp0afc80q4gbf510vsy"), + # The extremes: the leading digit tops out at 7 because it holds only three + # bits of the value, and the lowest bit lands in the last character. + ("00000000-0000-0000-0000-000000000000", "00000000000000000000000000"), + ("00000000-0000-0000-0000-000000000001", "00000000000000000000000001"), + ("80000000-0000-0000-0000-000000000000", "40000000000000000000000000"), + ("ffffffff-ffff-ffff-ffff-ffffffffffff", "7zzzzzzzzzzzzzzzzzzzzzzzzz"), + # 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", "160s3g0hr0000000000000048h"), + ("20010db8-85a3-0000-0000-8a2e03707334", "10046vh1d30000004a5r1q0wsm"), +] + +# The same contract for the prefixed form, one per kind. The project, workspace +# and secret IDs are the Go suite's own, prefix and all. +GOLDEN_IDS: List[Tuple[IdKind, str, str]] = [ + ( + "project", + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "prj_7mfb0gpp6c8dsaasre0asc7n3s", + ), + ( + "workspace", + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "wrk_7mfb0gpp6c8dsaasre0asc7n3s", + ), + ( + "secret", + "019fa519-bf79-724d-8811-a2bfda9755fa", + "sec_01kyjhkfvse96rg4d2qzd9enft", + ), + ( + "volume", + "019fa519-bfc5-784d-9386-a5d7a93a692a", + "vol_01kyjhkfy5f16s71n5tymkmt9a", + ), + ( + "sandbox", + "019fa519-bfeb-79f2-aa2a-0addf5b9c0d9", + "sbx_01kyjhkfzbf7samagavqtvkg6s", + ), + ( + "user", + "019fa519-c011-7c8e-a039-cb3ba8ca8c16", + "usr_01kyjhkg0hfj7a0eeb7emcn30p", + ), + ( + "group", + "550e8400-e29b-41d4-a716-446655440000", + "grp_2n1t201rmv87aae5j4csam8000", + ), +] + +# 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 = "e58522dfc30f44687edca477cc58f37015d7c9e1c46311a354654db07b2f6bdc" + +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 a base64 call: the 128-bit value written in 26 base32 digits, most + significant first. ``encode_bytes`` is checked against this rather than + against itself, so a slip in the padding or the translation table would + show up. + """ + value = int.from_bytes(raw, "big") + + digits = [] + for _ in range(ENCODED_LENGTH): + digits.append(ALPHABET[value & 31]) + value >>= 5 + + return "".join(reversed(digits)) + + +def ref_decode(encoded: str) -> bytes: + """The permissive counterpart to :func:`ref_encode`: unpack 26 digits into + 130 bits and keep the low 128. It does not look at the slack bits, which is + the point — it is what a decoder that forgets the leading-digit rule does, + so the tests can show that all four spellings of a value reach it. + """ + value = 0 + for character in encoded: + value = (value << 5) | ALPHABET.index(character) + return bytes_from(value & MASK_128) + + +#: The slack bits, as they sit in the leading digit: the high ``SLACK_BITS`` of +#: its five. OR-ing any nonzero multiple of this into the first digit's value +#: gives one of the three overflowing spellings. +SLACK_STEP = 1 << (5 - SLACK_BITS) + + +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..6565daf944 --- /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.0.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 b2f3c30274..7ae4ca0e79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,8 @@ importers: specifier: ^4.156.1 version: 4.156.1(@types/node@22.20.5)(bufferutil@4.0.8)(utf-8-validate@6.0.3) + packages/python-id: {} + packages/python-sdk: dependencies: '@e2b/dockerfile-utils-python':