Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions .github/workflows/id_tests.yml
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -49,18 +51,31 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# The interop suite drives python3 to prove the format really is
# "b32encode, lowercase, strip padding, rotate", so an interpreter has to
# be on PATH even though the package itself has no Python component.
- name: Setup Python
uses: actions/setup-python@v5
# Also gives js-id's interop suite the interpreter it drives to prove the
# format really is "b32encode, lowercase, strip padding, rotate".
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: '${{ env.TOOL_VERSION_UV }}'
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
enable-cache: true

- name: Install Python dependencies
working-directory: packages/python-id
run: uv sync --locked

- name: Test build (JS)
working-directory: packages/js-id
run: pnpm build

- name: Test build (Python)
working-directory: packages/python-id
run: uv build

- name: Run tests (JS)
working-directory: packages/js-id
run: pnpm test

- name: Run tests (Python)
working-directory: packages/python-id
run: uv run pytest --verbose
5 changes: 5 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ jobs:
run: |
uv sync --locked

- name: Install Python dependencies (id)
working-directory: packages/python-id
run: |
uv sync --locked

- name: Run linting
run: |
pnpm run lint
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/sdk_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
- *shared
- '.github/workflows/id_tests.yml'
- 'packages/js-id/**/!(*.md)'
- 'packages/python-id/**/!(*.md)'

js-tests:
name: Production / JS SDK Tests
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
.ruff_cache/
cover/

# Translations
Expand Down
14 changes: 8 additions & 6 deletions packages/js-id/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ isId('project', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g')
// false
```

The format is deliberately portable: any language can read these IDs with its
standard library. A Python port lands alongside this one.
[`e2b_id`](../python-id) is the same format in Python, name for name — its
README lists [the one place they differ](../python-id/README.md#where-this-differs-from-e2bid)
(Python moves `uuid.UUID` objects where JS moves hex strings).

## The format

Expand Down Expand Up @@ -126,7 +127,8 @@ pnpm test
pnpm lint && pnpm typecheck
```

`tests/vectors.ts` holds the golden encodings, a seeded 1303-value corpus and a
`CORPUS_DIGEST` over it. Every port of this format pins the same three, so a
change to the format on one side alone cannot pass on the other. `pnpm test` also
pipes the whole corpus through `python3` to check the six-line snippet above.
`tests/vectors.ts` and `packages/python-id/tests/vectors.py` are the same file in
two languages: the same golden encodings, the same seeded corpus, and the same
`CORPUS_DIGEST` over it. Changing the format on one side alone fails on both.
`pnpm test` also pipes the whole corpus through `python3` to check the six-line
snippet above.
4 changes: 2 additions & 2 deletions packages/js-id/tests/codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ describe('the format', () => {
}
})

// The one constant every port holds: same corpus, same format, same digest.
test('the corpus digest is the cross-implementation contract', () => {
// The one constant both suites hold: same corpus, same format, same digest.
test('the corpus is the same corpus e2b_id checks', () => {
expect(corpusDigest(encodeBytes)).toBe(CORPUS_DIGEST)
})

Expand Down
8 changes: 4 additions & 4 deletions packages/js-id/tests/interop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { corpus } from './vectors'
* The snippet below is the one in `src/codec.ts`'s documentation and in
* `@e2b/id`'s README, verbatim: six lines of `base64` and no tables. If this
* passes, the format really is "b32encode, lowercase, strip padding, rotate",
* so any port tested the same way against the same corpus decodes what this
* package emits.
* and `e2b_id` — which is tested the same way against the same corpus — decodes
* what this package emits.
*/
const SNIPPET = `
import base64, sys
Expand Down Expand Up @@ -67,8 +67,8 @@ describe.skipIf(process.env.E2B_ID_SKIP_INTEROP === '1')(
() => {
test('in both directions, over the whole corpus', () => {
// Not a skip: on CI this suite is the only thing standing between a
// format change and a silent fork from the other ports, so a missing
// interpreter has to be loud.
// format change and a silent fork from e2b_id, so a missing interpreter
// has to be loud.
expect(
python,
'no python3 on PATH; set E2B_ID_SKIP_INTEROP=1 to skip this suite deliberately'
Expand Down
14 changes: 7 additions & 7 deletions packages/js-id/tests/vectors.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -87,8 +87,8 @@ export const GOLDEN_IDS: ReadonlyArray<

/**
* sha256 of every encoding in {@link corpus}, newline-joined, in order. The same
* constant is pinned by every port; if two implementations ever disagree about
* the corpus or the format, one of them fails on it.
* constant appears in `vectors.py`; if the two implementations ever disagree
* about the corpus or the format, one of them fails on it.
*/
export const CORPUS_DIGEST =
'a83c455e0a4ffd39e51c69d832c544b6809bf836f6459e2a9797005dddc71ec0'
Expand Down
9 changes: 9 additions & 0 deletions packages/python-id/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright (c) 2025 FOUNDRYLABS, INC.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
13 changes: 13 additions & 0 deletions packages/python-id/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
typecheck:
ty check

lint:
ruff check .
ruff format --check .

format:
ruff format .

.PHONY: test
test:
uv run pytest --verbose
139 changes: 139 additions & 0 deletions packages/python-id/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# `e2b_id`

Prefixed, human-legible IDs for E2B resources. Standard library only.

> **Not on PyPI yet.** Publishing needs a PyPI project and a scoped token first;
> until then use it from this repo (`uv add ../python-id`). See
> [Releasing](#releasing).

```python
from e2b_id import create_id, decode_id, encode_id, is_id, parse_id

create_id("project")
# 'prj_uk75vf2v7iagp2kgn7pfze3car'

encode_id("volume", "019fa519-bfc5-784d-9386-a5d7a93a692a")
# 'vol_uxl2sotjfiagp2kgn7yv4e3e4g'

decode_id("volume", "vol_uxl2sotjfiagp2kgn7yv4e3e4g")
# UUID('019fa519-bfc5-784d-9386-a5d7a93a692a')

parse_id("vol_uxl2sotjfiagp2kgn7yv4e3e4g")
# ParsedId(kind='volume', uuid=UUID('019fa519-bfc5-784d-9386-a5d7a93a692a'))

is_id("project", "vol_uxl2sotjfiagp2kgn7yv4e3e4g")
# False
```

[`@e2b/id`](../js-id) is the same format in TypeScript, name for name — see
[the one place they differ](#where-this-differs-from-e2bid).

## The format

An ID is a three-character kind prefix, an underscore, and 26 characters of
encoded UUID — 30 characters in all:

```text
prj_uk75vf2v7iagp2kgn7pfze3car
wrk_imkhttdroeagp2kgn7t53cvkiw
vol_uxl2sotjfiagp2kgn7yv4e3e4g
sbx_blo7looa3eagp2kgn75n47fkrk
usr_zm52rsumcyagp2kgoacf6i5ibz
grp_byblfq6upe6r5mcc2yzrbxfjlh
```

| kind | prefix |
| ----------- | ------ |
| `project` | `prj` |
| `workspace` | `wrk` |
| `volume` | `vol` |
| `sandbox` | `sbx` |
| `user` | `usr` |
| `group` | `grp` |

The UUID's 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet
(`a-z2-7`), lowercased, unpadded — 26 characters — and then **rotated left by
16**, so what was the first character ends up 11th.

The rotation is the whole trick. A UUIDv7 leads with a 48-bit millisecond
timestamp, so unrotated encodings of IDs minted around the same time share a
long common prefix, and the leading characters barely move for months. Rotating
puts 10 characters of random bits in front, the timestamp from index 10, and the
rest of the random bits behind it. The trade is that encoded order no longer
follows time — don't build an index expecting it to.

The alphabet is exactly the one `base64.b32encode` uses, so any language can
read these IDs with its standard library and no tables:

```python
import base64

def encode(b: bytes) -> str:
s = base64.b32encode(b).decode().rstrip("=").lower()
return s[16:] + s[:16]

def decode(s: str) -> bytes:
s = s[10:] + s[:10]
return base64.b32decode(s.upper() + "======")
```

### One spelling per ID

26 base32 digits carry 130 bits and a UUID has 128, so two bits of the encoding
are always zero and every UUID has four strings a permissive decoder maps to it.
`decode_id` accepts only the one `encode_id` produces and raises
`InvalidIdException` for the other three, along with uppercase, padding and
anything outside the alphabet. UUID arguments are held to the canonical
8-4-4-4-12 hex form for the same reason, even though `uuid.UUID` itself would
take braced and unhyphenated spellings.

## API

| | |
| --- | --- |
| `create_id(kind)` | mint an ID for a new resource, from a fresh UUIDv7 |
| `encode_id(kind, uuid)` | encode a UUID you already have |
| `decode_id(kind, id)` | the UUID an ID carries; raises if the kind is wrong |
| `parse_id(id)` | `ParsedId(kind, uuid)`, when the kind is what you want to find out |
| `is_id(kind, value)` | `decode_id` without the raise; `False` for non-strings |
| `create_uuid()` | mint a UUIDv7 (`uuid.uuid7` needs Python 3.14) |
| `encode_bytes(raw)` / `decode_bytes(encoded)` | the prefix-free codec, over any 16 bytes |
| `ID_PREFIXES`, `ID_LENGTH`, `ENCODED_LENGTH`, `DECODED_LENGTH`, `ALPHABET` | the constants above |
| `IdKind`, `ParsedId`, `InvalidIdException`, `InvalidIdReason` | the types |

### Where this differs from `@e2b/id`

Everything above is named identically in both packages. The only divergence is
that JavaScript has no UUID type, so `@e2b/id` moves UUIDs as canonical hex
strings where this package moves `uuid.UUID` objects:

- `@e2b/id` also exports `uuidToBytes`/`bytesToUuid`; here `u.bytes` and
`uuid.UUID(bytes=…)` already are those.
- `create_uuid()`, `decode_id()` and `ParsedId.uuid` give you a `uuid.UUID`,
where their JS counterparts give a string — so a round trip is string-to-string
in JS but not here.
- `encode_id()` takes either a `uuid.UUID` or the hex form.

## Development

```sh
uv sync
uv run pytest
uv run make lint typecheck
```

`tests/vectors.py` and `packages/js-id/tests/vectors.ts` are the same file in
two languages: the same golden encodings, the same seeded corpus, and the same
`CORPUS_DIGEST` over it. Changing the format on one side alone fails on both.

## Releasing

This package is deliberately **not** wired into the release pipeline yet.
`packages/python-sdk/package.json` publishes via a `postPublish` script
(`uv build && uv publish --token ${PYPI_TOKEN}`); `packages/python-id` has no
such script, because pointing one at a PyPI project that does not exist — with a
token that may not be scoped for it — would fail `pnpm run publish` and take the
whole monorepo's release down with it.

To publish for the first time: register the `e2b_id` project on PyPI, confirm
`PYPI_TOKEN` covers it, then add the same `postPublish` line python-sdk has.
58 changes: 58 additions & 0 deletions packages/python-id/e2b_id/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""``e2b_id`` — prefixed, human-legible IDs for E2B resources.

::

from e2b_id import create_id, decode_id, is_id, parse_id

id = create_id("project") # 'prj_uk75vf2v7iagp2kgn7pfze3car'
decode_id("project", id) # UUID('019fa519-bf79-724d-8811-a2bfda9755fa')
is_id("volume", id) # False
parse_id(id) # ParsedId(kind='project', uuid=UUID('019fa519-…'))

An ID is a kind prefix and the 26-character rotated base32 encoding of a UUID.
See :mod:`e2b_id.ids` for what the prefix buys and :mod:`e2b_id.codec` for what
the rotation buys. ``@e2b/id`` is the same format in TypeScript, name for name.
"""

from .codec import (
ALPHABET,
DECODED_LENGTH,
ENCODED_LENGTH,
decode_bytes,
encode_bytes,
)
from .errors import InvalidIdException, InvalidIdReason
from .ids import (
ID_LENGTH,
ID_PREFIXES,
PREFIX_LENGTH,
IdKind,
ParsedId,
create_id,
decode_id,
encode_id,
is_id,
parse_id,
)
from .uuids import create_uuid

__all__ = [
"ALPHABET",
"DECODED_LENGTH",
"ENCODED_LENGTH",
"ID_LENGTH",
"ID_PREFIXES",
"PREFIX_LENGTH",
"IdKind",
"InvalidIdException",
"InvalidIdReason",
"ParsedId",
"create_id",
"create_uuid",
"decode_bytes",
"decode_id",
"encode_bytes",
"encode_id",
"is_id",
"parse_id",
]
Loading
Loading