From 189a86661b64067799f287a5e0ad0043fada630e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:11:47 +0200 Subject: [PATCH 1/3] Add e2b_id: the Python half of the prefixed-ID format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same format as @e2b/id, name for name: create_id, encode_id, decode_id, parse_id, is_id, create_uuid, encode_bytes/decode_bytes. Standard library only — no dependencies at all, so an ID codec that every service depends on can never be the reason a resolution fails. The codec is base64.b32encode behind a translation table: four zero bytes in front make 20, which is 32 RFC 4648 digits with no padding, of which the last 26 are the TypeID suffix once mapped onto the Crockford alphabet. The tests check that against an arithmetic encoder stated independently of it, and against the Go suite's own vectors. Agreement with the JS half is enforced rather than asserted. Both suites carry the same golden vectors — the Go side's among them — both build the same 1306-value corpus from the same seeded LCG, and both pin the same CORPUS_DIGEST over it, so a format change on one side alone fails on both. UUIDs are read and minted the way github.com/google/uuid does it, as in @e2b/id: encode_id takes exactly the four spellings Go's Parse takes — narrower than uuid.UUID(), which also strips hyphens from anywhere — and create_uuid is NewV7 line for line, the sequence being the clock's sub-millisecond bits bumped so every UUID a process mints is strictly greater than the last. The one place the surfaces differ is downstream of JavaScript having no UUID type: this package moves uuid.UUID objects where @e2b/id moves canonical hex strings, so decode_id and ParsedId.uuid return UUIDs and there is no uuid_to_bytes/bytes_to_uuid — the standard library already is those. uuids.py and the README both enumerate it. Also wires the two together: the id_tests workflow grows a Python leg, lint and typecheck sync the new uv env, and js-id's docs now name their counterpart. Wired into the release pipeline as e2b-id on PyPI: versioned from 0.0.0 with an "Initial release" changeset so the next release mints 0.1.0, gated on the id_tests workflow in release.yml, and published by the same postPublish step python-sdk uses — `uv publish` over PyPI's OIDC trusted publishing, no token. The e2b-id project already exists on PyPI as a 0.0.0 placeholder with the trusted publisher attached, so 0.1.0 is the first real version and publishing needs no manual step. Co-Authored-By: Claude Co-Authored-By: Claude Fable 5.1 --- .changeset/python-id-package.md | 5 + .github/scripts/build_release_itinerary.cjs | 1 + .github/workflows/id_tests.yml | 29 +- .github/workflows/lint.yml | 5 + .github/workflows/pkg_artifacts.yml | 19 ++ .github/workflows/release.yml | 11 +- .github/workflows/sdk_tests.yml | 1 + .github/workflows/typecheck.yml | 5 + .gitignore | 1 + packages/js-id/README.md | 15 +- packages/js-id/tests/codec.test.ts | 4 +- packages/js-id/tests/interop.test.ts | 12 +- packages/js-id/tests/vectors.ts | 14 +- packages/python-id/LICENSE | 201 +++++++++++ packages/python-id/Makefile | 13 + packages/python-id/README.md | 159 +++++++++ packages/python-id/e2b_id/__init__.py | 59 ++++ packages/python-id/e2b_id/codec.py | 192 +++++++++++ packages/python-id/e2b_id/errors.py | 47 +++ packages/python-id/e2b_id/ids.py | 254 ++++++++++++++ packages/python-id/e2b_id/py.typed | 0 packages/python-id/e2b_id/uuids.py | 144 ++++++++ packages/python-id/package.json | 14 + packages/python-id/pyproject.toml | 32 ++ packages/python-id/pytest.ini | 4 + packages/python-id/tests/test_codec.py | 238 +++++++++++++ packages/python-id/tests/test_ids.py | 356 ++++++++++++++++++++ packages/python-id/tests/vectors.py | 222 ++++++++++++ packages/python-id/uv.lock | 211 ++++++++++++ pnpm-lock.yaml | 2 + 30 files changed, 2240 insertions(+), 30 deletions(-) create mode 100644 .changeset/python-id-package.md create mode 100644 packages/python-id/LICENSE create mode 100644 packages/python-id/Makefile create mode 100644 packages/python-id/README.md create mode 100644 packages/python-id/e2b_id/__init__.py create mode 100644 packages/python-id/e2b_id/codec.py create mode 100644 packages/python-id/e2b_id/errors.py create mode 100644 packages/python-id/e2b_id/ids.py create mode 100644 packages/python-id/e2b_id/py.typed create mode 100644 packages/python-id/e2b_id/uuids.py create mode 100644 packages/python-id/package.json create mode 100644 packages/python-id/pyproject.toml create mode 100644 packages/python-id/pytest.ini create mode 100644 packages/python-id/tests/test_codec.py create mode 100644 packages/python-id/tests/test_ids.py create mode 100644 packages/python-id/tests/vectors.py create mode 100644 packages/python-id/uv.lock 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..5246e52670 --- /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. +#: - ``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..fb3eaa304c --- /dev/null +++ b/packages/python-id/e2b_id/ids.py @@ -0,0 +1,254 @@ +"""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 _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 id.startswith(prefix + _SEPARATOR): + named = _KINDS_BY_PREFIX.get(id.split(_SEPARATOR)[0]) + if named is not None: + raise InvalidIdException( + "prefix", f'"{id}" is a {named} ID, not a {kind} ID.', named + ) + raise InvalidIdException( + "prefix", + f'"{id}" is not a {kind} ID: ' + f'expected it to start with "{prefix}{_SEPARATOR}".', + ) + return uuidlib.UUID(bytes=decode_bytes(id[len(prefix) + len(_SEPARATOR) :])) + + +def create_id(kind: IdKind) -> str: + """Mint an ID for a new resource of the given kind, from a fresh + :func:`create_uuid` UUIDv7. + + :param kind: the resource being created. + :return: the ID. + :raises InvalidIdException: if ``kind`` is not a known kind. + + Example:: + + create_id("sandbox") # 'sbx_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')) + """ + 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..da7eafe86e --- /dev/null +++ b/packages/python-id/tests/test_ids.py @@ -0,0 +1,356 @@ +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 + + +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/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': From bc731a10cf8795ca7f09730a01dc0af29a31e3f4 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:00:25 +0100 Subject: [PATCH 2/3] Reject non-string IDs with InvalidIdException in e2b_id decode_id and parse_id raise InvalidIdException("prefix") for a non-string id (None, a number, bytes, ...) instead of an AttributeError, matching @e2b/id. decode_id also names no kind for an id with no separator, as parse_id and @e2b/id already do, so "prj" is no longer reported as a project ID. Co-Authored-By: Claude Opus 5.5 --- packages/python-id/e2b_id/errors.py | 2 +- packages/python-id/e2b_id/ids.py | 16 +++++++++++++++- packages/python-id/tests/test_ids.py | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/python-id/e2b_id/errors.py b/packages/python-id/e2b_id/errors.py index 5246e52670..f3e94054a1 100644 --- a/packages/python-id/e2b_id/errors.py +++ b/packages/python-id/e2b_id/errors.py @@ -7,7 +7,7 @@ #: 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. +#: - ``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 diff --git a/packages/python-id/e2b_id/ids.py b/packages/python-id/e2b_id/ids.py index fb3eaa304c..f0b7b36f57 100644 --- a/packages/python-id/e2b_id/ids.py +++ b/packages/python-id/e2b_id/ids.py @@ -94,6 +94,14 @@ class ParsedId: 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: @@ -157,8 +165,12 @@ def decode_id(kind: IdKind, id: str) -> uuidlib.UUID: # 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): - named = _KINDS_BY_PREFIX.get(id.split(_SEPARATOR)[0]) + # 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 @@ -199,6 +211,8 @@ def parse_id(id: str) -> ParsedId: 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( diff --git a/packages/python-id/tests/test_ids.py b/packages/python-id/tests/test_ids.py index da7eafe86e..7d7f1eea7d 100644 --- a/packages/python-id/tests/test_ids.py +++ b/packages/python-id/tests/test_ids.py @@ -299,6 +299,29 @@ def test_is_id_is_false_for_anything_that_is_not_a_string(value): 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 From 20103e3fc8899b275c4c2e807c5d39275d635317 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:29:54 +0100 Subject: [PATCH 3/3] Test e2b_id's UUID module directly Adds tests/test_uuids.py, the counterpart of @e2b/id's uuid.test.ts: hex_digits_of against the standard library over the shared corpus, each of Go's four forms probed one character short, one long and with a bad digit (plus the spellings uuid.UUID accepts and Go does not), non-string UUIDs, and create_uuid on fresh module state: version and variant on every mint, the 48-bit timestamp at its boundaries, random bits confined to rand_b, the clock-derived sequence counting up, resetting and carrying. Co-Authored-By: Claude Opus 5.5 --- packages/python-id/tests/test_uuids.py | 195 +++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 packages/python-id/tests/test_uuids.py 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