├── tests ├── __init__.py ├── explain │ ├── __init__.py │ ├── test_formatters.py │ ├── test_cli_yaml.py │ ├── test_discovery.py │ ├── test_cli.py │ ├── test_registry.py │ ├── test_registry_yaml.py │ └── test_engine.py ├── test_base32.py ├── test_spec.py ├── conftest.py ├── test_factory.py ├── test_validation.py └── test_typeid.py ├── typeid ├── py.typed ├── constants.py ├── errors.py ├── __init__.py ├── validation.py ├── factory.py ├── explain │ ├── __init__.py │ ├── discovery.py │ ├── model.py │ ├── registry.py │ ├── formatters.py │ └── engine.py ├── cli.py ├── base32.py └── typeid.py ├── examples ├── explain │ ├── __init__.py │ ├── sample_ids.txt │ ├── schemas │ │ ├── typeid.schema.yaml │ │ └── typeid.schema.json │ ├── explain_complex.py │ └── explain_report.py ├── sqlalchemy.py └── README.md ├── docs ├── index.md ├── contributing.md ├── reference │ ├── cli.md │ ├── explain.md │ ├── factory.md │ └── typeid.md ├── quickstart.md ├── concepts.md └── explain.md ├── pytest.ini ├── .github └── workflows │ ├── docs.yml │ ├── setup.yml │ └── ci.yml ├── Makefile ├── LICENSE ├── mkdocs.yml ├── CONTRIBUTING.md ├── pyproject.toml ├── CODE_OF_CONDUCT.md ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typeid/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/explain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/explain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --8<-- "CONTRIBUTING.md" 2 | -------------------------------------------------------------------------------- /docs/reference/cli.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | ::: typeid.cli 4 | -------------------------------------------------------------------------------- /docs/reference/explain.md: -------------------------------------------------------------------------------- 1 | # Explain 2 | 3 | ::: typeid.explain 4 | -------------------------------------------------------------------------------- /docs/reference/factory.md: -------------------------------------------------------------------------------- 1 | # Factory 2 | 3 | ::: typeid.factory 4 | -------------------------------------------------------------------------------- /typeid/constants.py: -------------------------------------------------------------------------------- 1 | SUFFIX_LEN = 26 2 | 3 | PREFIX_MAX_LEN = 63 4 | -------------------------------------------------------------------------------- /docs/reference/typeid.md: -------------------------------------------------------------------------------- 1 | # TypeID 2 | 3 | ::: typeid.typeid.TypeID 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = 3 | tests 4 | pythonpath = src -------------------------------------------------------------------------------- /examples/explain/sample_ids.txt: -------------------------------------------------------------------------------- 1 | # Valid 2 | user_01h45ytscbebyvny4gc8cr8ma2 3 | order_01h2xcejqtf2nbrexx3vqjhp41 4 | 5 | # Unknown prefix (still valid TypeID) 6 | mystery_01h2xcejqtf2nbrexx3vqjhp41 7 | 8 | # Invalid 9 | user_NOT_A_SUFFIX 10 | not_a_typeid 11 | -------------------------------------------------------------------------------- /typeid/errors.py: -------------------------------------------------------------------------------- 1 | class TypeIDException(Exception): 2 | ... 3 | 4 | 5 | class PrefixValidationException(TypeIDException): 6 | ... 7 | 8 | 9 | class SuffixValidationException(TypeIDException): 10 | ... 11 | 12 | 13 | class InvalidTypeIDStringException(TypeIDException): 14 | ... 15 | -------------------------------------------------------------------------------- /typeid/__init__.py: -------------------------------------------------------------------------------- 1 | from .factory import TypeIDFactory, cached_typeid_factory, typeid_factory 2 | from .typeid import TypeID, from_string, from_uuid, get_prefix_and_suffix 3 | 4 | __all__ = ( 5 | "TypeID", 6 | "from_string", 7 | "from_uuid", 8 | "get_prefix_and_suffix", 9 | "TypeIDFactory", 10 | "typeid_factory", 11 | "cached_typeid_factory", 12 | ) 13 | -------------------------------------------------------------------------------- /tests/test_base32.py: -------------------------------------------------------------------------------- 1 | from typeid.base32 import decode, encode 2 | 3 | 4 | def test_encode_decode_logic() -> None: 5 | original_data = list(range(0, 16)) 6 | 7 | encoded_data = encode(original_data) 8 | 9 | assert isinstance(encoded_data, str) 10 | 11 | assert encoded_data == "00041061050r3gg28a1c60t3gf" 12 | 13 | decoded_data = decode(encoded_data) 14 | 15 | assert decoded_data == original_data 16 | -------------------------------------------------------------------------------- /tests/test_spec.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from uuid6 import UUID 3 | 4 | from typeid import TypeID 5 | from typeid.errors import TypeIDException 6 | 7 | 8 | def test_invalid_spec(invalid_spec: list) -> None: 9 | for spec in invalid_spec: 10 | with pytest.raises(TypeIDException): 11 | TypeID.from_string(spec["typeid"]) 12 | 13 | 14 | def test_valid_spec(valid_spec: list) -> None: 15 | for spec in valid_spec: 16 | prefix = spec["prefix"] 17 | uuid = UUID(spec["uuid"]) 18 | 19 | typeid = TypeID.from_uuid(prefix=prefix, suffix=uuid) 20 | assert str(typeid) == spec["typeid"] 21 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | 24 | - name: Sync dependencies (locked) 25 | run: | 26 | uv sync --locked --all-groups 27 | 28 | - name: Deploy 29 | run: uv run mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import yaml 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def invalid_spec() -> list: 8 | url = "https://raw.githubusercontent.com/jetpack-io/typeid/main/spec/invalid.yml" 9 | response = requests.get(url, timeout=5) 10 | invalid_yaml = response.content 11 | return yaml.safe_load(invalid_yaml) 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def valid_spec() -> list: 16 | url = "https://raw.githubusercontent.com/jetpack-io/typeid/main/spec/valid.yml" 17 | response = requests.get(url, timeout=5) 18 | invalid_yaml = response.content 19 | return yaml.safe_load(invalid_yaml) 20 | -------------------------------------------------------------------------------- /tests/explain/test_formatters.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from typeid import TypeID 4 | from typeid.explain.engine import explain 5 | from typeid.explain.formatters import format_explanation_json, format_explanation_pretty 6 | 7 | 8 | def test_pretty_formatter_contains_sections(): 9 | tid = str(TypeID(prefix="usr")) 10 | exp = explain(tid) 11 | 12 | out = format_explanation_pretty(exp) 13 | assert "parsed:" in out 14 | assert "schema:" in out 15 | assert "links:" in out 16 | 17 | 18 | def test_json_formatter_is_valid_json(): 19 | tid = str(TypeID(prefix="usr")) 20 | exp = explain(tid) 21 | 22 | out = format_explanation_json(exp) 23 | json.loads(out) # should not raise 24 | -------------------------------------------------------------------------------- /examples/explain/schemas/typeid.schema.yaml: -------------------------------------------------------------------------------- 1 | schema_version: 1 2 | types: 3 | user: 4 | name: User 5 | description: End-user account 6 | owner_team: identity-platform 7 | pii: true 8 | retention: 7y 9 | services: [user-service, auth-service] 10 | storage: 11 | primary: 12 | kind: postgres 13 | table: users 14 | shard_by: tenant_id 15 | events: [user.created, user.updated, user.deleted] 16 | policies: 17 | delete: 18 | allowed: false 19 | reason: GDPR retention policy 20 | links: 21 | docs: "https://docs.company/entities/user" 22 | logs: "https://logs.company/search?q={id}" 23 | trace: "https://traces.company/?q={id}" 24 | admin: "https://admin.company/users/{id}" 25 | -------------------------------------------------------------------------------- /tests/test_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from typeid import TypeID, cached_typeid_factory, typeid_factory 4 | from typeid.errors import PrefixValidationException 5 | 6 | 7 | def test_typeid_factory_generates_typeid_with_prefix(): 8 | gen = typeid_factory("user") 9 | tid = gen() 10 | 11 | assert isinstance(tid, TypeID) 12 | assert tid.prefix == "user" 13 | 14 | 15 | def test_typeid_factory_returns_new_ids_each_time(): 16 | gen = typeid_factory("user") 17 | a = gen() 18 | b = gen() 19 | 20 | assert a != b 21 | 22 | 23 | def test_cached_typeid_factory_is_cached(): 24 | a = cached_typeid_factory("user") 25 | b = cached_typeid_factory("user") 26 | c = cached_typeid_factory("order") 27 | 28 | assert a is b 29 | assert a is not c 30 | 31 | 32 | def test_factory_invalid_prefix_propagates(): 33 | gen = typeid_factory("BAD PREFIX") 34 | with pytest.raises(PrefixValidationException): 35 | gen() 36 | -------------------------------------------------------------------------------- /typeid/validation.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from typeid import base32 4 | from typeid.constants import SUFFIX_LEN 5 | from typeid.errors import PrefixValidationException, SuffixValidationException 6 | 7 | 8 | def validate_prefix(prefix: str) -> None: 9 | # See https://github.com/jetify-com/typeid/tree/main/spec 10 | if not re.match("^([a-z]([a-z_]{0,61}[a-z])?)?$", prefix): 11 | raise PrefixValidationException(f"Invalid prefix: {prefix}.") 12 | 13 | 14 | def validate_suffix(suffix: str) -> None: 15 | if ( 16 | len(suffix) != SUFFIX_LEN 17 | or suffix == "" 18 | or " " in suffix 19 | or (not suffix.isdigit() and not suffix.islower()) 20 | or any([symbol not in base32.ALPHABET for symbol in suffix]) 21 | or suffix[0] > "7" 22 | ): 23 | raise SuffixValidationException(f"Invalid suffix: {suffix}.") 24 | try: 25 | base32.decode(suffix) 26 | except Exception as exc: 27 | raise SuffixValidationException(f"Invalid suffix: {suffix}.") from exc 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check-linting: 2 | uv run ruff check typeid/ tests/ 3 | uv run black --check --diff typeid/ tests/ --line-length 119 4 | uv run mypy typeid/ --pretty 5 | 6 | 7 | fix-linting: 8 | uv run ruff check --fix typeid/ tests/ 9 | uv run black typeid/ tests/ --line-length 119 10 | 11 | 12 | # Build sdist + wheel using the configured PEP517 backend 13 | artifacts: test 14 | uv build 15 | 16 | 17 | clean: 18 | rm -rf dist build *.egg-info .venv 19 | 20 | 21 | # Ensure local dev env is ready (installs deps according to uv.lock / pyproject) 22 | prepforbuild: 23 | uv sync --all-groups 24 | 25 | 26 | # Alias if you still want a 'build' target name 27 | build: 28 | uv build 29 | 30 | 31 | test-release: 32 | uv run twine upload --repository testpypi dist/* --verbose 33 | 34 | 35 | release: 36 | uv run twine upload --repository pypi dist/* --verbose 37 | 38 | 39 | test: 40 | uv run pytest -v 41 | 42 | 43 | test-docs: 44 | uv run pytest README.md docs/ --markdown-docs 45 | 46 | 47 | docs: 48 | mkdocs serve 49 | 50 | 51 | docs-build: 52 | mkdocs build 53 | 54 | -------------------------------------------------------------------------------- /typeid/factory.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import lru_cache 3 | from typing import Callable 4 | 5 | from .typeid import TypeID 6 | 7 | 8 | @dataclass(frozen=True, slots=True) 9 | class TypeIDFactory: 10 | """ 11 | Callable object that generates TypeIDs with a fixed prefix. 12 | 13 | Example: 14 | user_id = TypeIDFactory("user")() 15 | """ 16 | 17 | prefix: str 18 | 19 | def __call__(self) -> TypeID: 20 | return TypeID(self.prefix) 21 | 22 | 23 | def typeid_factory(prefix: str) -> Callable[[], TypeID]: 24 | """ 25 | Return a zero-argument callable that generates TypeIDs with a fixed prefix. 26 | 27 | Example: 28 | user_id = typeid_factory("user")() 29 | """ 30 | return TypeIDFactory(prefix) 31 | 32 | 33 | @lru_cache(maxsize=256) 34 | def cached_typeid_factory(prefix: str) -> Callable[[], TypeID]: 35 | """ 36 | Same as typeid_factory, but caches factories by prefix. 37 | 38 | Use this if you create factories repeatedly at runtime. 39 | """ 40 | return TypeIDFactory(prefix) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Murad Akhundov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/setup.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | release: 5 | types: [published] 6 | push: 7 | branches: 8 | - main 9 | paths-ignore: 10 | - README.md 11 | - CHANGELOG.md 12 | 13 | env: 14 | PROJECT_NAME: typeid-python 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 9 29 | submodules: false 30 | 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Install uv 37 | uses: astral-sh/setup-uv@v3 38 | 39 | - name: Sync dependencies (locked) 40 | run: | 41 | uv sync --locked --all-groups 42 | 43 | - name: Run tests 44 | run: | 45 | make test 46 | 47 | - name: Run linters 48 | run: | 49 | make check-linting 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.11" 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v3 22 | 23 | - name: Sync dependencies (locked) 24 | run: | 25 | uv sync --locked --all-groups 26 | 27 | - name: Run linters 28 | run: | 29 | make check-linting 30 | 31 | test: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Set up Python 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: "3.11" 41 | 42 | - name: Install uv 43 | uses: astral-sh/setup-uv@v3 44 | 45 | - name: Sync dependencies (locked) 46 | run: | 47 | uv sync --locked --all-groups 48 | 49 | - name: Run tests 50 | run: | 51 | make test 52 | 53 | - name: Run doc tests 54 | run: | 55 | make test-docs 56 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: TypeID 2 | site_description: Explainable, prefix-based identifiers for Python 3 | 4 | repo_name: akhundMurad/typeid-python 5 | repo_url: https://github.com/akhundMurad/typeid-python 6 | 7 | theme: 8 | name: material 9 | language: en 10 | features: 11 | - navigation.tabs 12 | - navigation.top 13 | - content.code.copy 14 | - search.highlight 15 | - search.share 16 | icon: 17 | repo: fontawesome/brands/github 18 | 19 | plugins: 20 | - search 21 | - git-revision-date-localized 22 | - mkdocstrings: 23 | handlers: 24 | python: 25 | options: 26 | docstring_style: google 27 | show_source: false 28 | show_signature_annotations: true 29 | merge_init_into_class: true 30 | 31 | markdown_extensions: 32 | - admonition 33 | - pymdownx.details 34 | - pymdownx.superfences 35 | - pymdownx.highlight 36 | - pymdownx.inlinehilite 37 | - pymdownx.snippets 38 | - pymdownx.tabbed: 39 | alternate_style: true 40 | 41 | nav: 42 | - Index: index.md 43 | - Quickstart: quickstart.md 44 | - Reference: 45 | - TypeID: reference/typeid.md 46 | - Explain: reference/explain.md 47 | - CLI: reference/cli.md 48 | - Factory: reference/factory.md 49 | - Concepts: concepts.md 50 | - Explain: explain.md 51 | - Contributing: contributing.md 52 | -------------------------------------------------------------------------------- /tests/explain/test_cli_yaml.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from click.testing import CliRunner 6 | 7 | from typeid import TypeID 8 | from typeid.cli import cli 9 | 10 | yaml = pytest.importorskip("yaml") # skip if PyYAML not installed 11 | 12 | 13 | def test_cli_explain_with_yaml_schema(tmp_path: Path): 14 | runner = CliRunner() 15 | tid = str(TypeID(prefix="usr")) 16 | 17 | p = tmp_path / "typeid.schema.yaml" 18 | p.write_text( 19 | """ 20 | schema_version: 1 21 | types: 22 | usr: 23 | name: User 24 | owner_team: identity-platform 25 | links: 26 | logs: "https://logs?q={id}" 27 | """, 28 | encoding="utf-8", 29 | ) 30 | 31 | result = runner.invoke(cli, ["explain", tid, "--schema", str(p)]) 32 | assert result.exit_code == 0 33 | out = result.output 34 | 35 | assert "schema:" in out 36 | assert "found: true" in out 37 | assert "name: User" in out 38 | assert "owner_team: identity-platform" in out 39 | assert "logs:" in out 40 | 41 | 42 | def test_cli_explain_with_yaml_schema_json_output(tmp_path: Path): 43 | runner = CliRunner() 44 | tid = str(TypeID(prefix="usr")) 45 | 46 | p = tmp_path / "typeid.schema.yaml" 47 | p.write_text( 48 | """ 49 | schema_version: 1 50 | types: 51 | usr: 52 | name: User 53 | """, 54 | encoding="utf-8", 55 | ) 56 | 57 | result = runner.invoke(cli, ["explain", tid, "--schema", str(p), "--json"]) 58 | assert result.exit_code == 0 59 | 60 | payload = json.loads(result.output) 61 | assert payload["valid"] is True 62 | assert payload["schema"] is not None 63 | assert payload["schema"]["name"] == "User" 64 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from uuid6 import uuid7 3 | 4 | from typeid import base32 5 | from typeid.errors import PrefixValidationException, SuffixValidationException 6 | from typeid.validation import validate_prefix, validate_suffix 7 | 8 | 9 | def test_validate_correct_prefix() -> None: 10 | prefix = "plov" 11 | 12 | try: 13 | validate_prefix(prefix) 14 | except PrefixValidationException as exc: 15 | pytest.fail(str(exc)) 16 | 17 | 18 | def test_validate_correct_prefix_with_underscores() -> None: 19 | prefix = "plov_good" 20 | 21 | try: 22 | validate_prefix(prefix) 23 | except PrefixValidationException as exc: 24 | pytest.fail(str(exc)) 25 | 26 | 27 | def test_validate_invalid_prefix_with_trailing_underscore() -> None: 28 | prefix = "plov_bad_" 29 | 30 | with pytest.raises(PrefixValidationException): 31 | validate_prefix(prefix) 32 | 33 | 34 | def test_validate_uppercase_prefix() -> None: 35 | prefix = "Plov" 36 | 37 | with pytest.raises(PrefixValidationException): 38 | validate_prefix(prefix) 39 | 40 | 41 | def test_validate_not_ascii_prefix() -> None: 42 | prefix = "∞¥₤€" 43 | 44 | with pytest.raises(PrefixValidationException): 45 | validate_prefix(prefix) 46 | 47 | 48 | def test_validate_correct_suffix() -> None: 49 | suffix = base32.encode(list(uuid7().bytes)) 50 | 51 | try: 52 | validate_suffix(suffix) 53 | except SuffixValidationException as exc: 54 | pytest.fail(str(exc)) 55 | 56 | 57 | def test_validate_wrong_suffix() -> None: 58 | suffix = "asd" 59 | 60 | with pytest.raises(SuffixValidationException): 61 | validate_suffix(suffix) 62 | -------------------------------------------------------------------------------- /examples/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy import types 4 | from sqlalchemy.util import generic_repr 5 | from typeid import TypeID 6 | 7 | 8 | class TypeIDType(types.TypeDecorator): 9 | """ 10 | A SQLAlchemy TypeDecorator that allows storing TypeIDs in the database. 11 | The prefix will not be persisted, instead the database-native UUID field will be used. 12 | At retrieval time a TypeID will be constructed based on the configured prefix and the 13 | UUID value from the database. 14 | 15 | Usage: 16 | # will result in TypeIDs such as "user_01h45ytscbebyvny4gc8cr8ma2" 17 | id = mapped_column( 18 | TypeIDType("user"), 19 | primary_key=True, 20 | default=lambda: TypeID("user") 21 | ) 22 | """ 23 | impl = types.Uuid 24 | 25 | cache_ok = True 26 | 27 | prefix: Optional[str] = None 28 | 29 | def __init__(self, prefix: Optional[str], *args, **kwargs): 30 | self.prefix = prefix 31 | super().__init__(*args, **kwargs) 32 | 33 | def __repr__(self) -> str: 34 | # Customize __repr__ to ensure that auto-generated code e.g. from alembic includes 35 | # the right __init__ params (otherwise by default prefix will be omitted because 36 | # uuid.__init__ does not have such an argument). 37 | return generic_repr( 38 | self, 39 | to_inspect=TypeID(self.prefix), 40 | ) 41 | 42 | def process_bind_param(self, value: TypeID, dialect): 43 | if self.prefix is None: 44 | assert value.prefix is None 45 | else: 46 | assert value.prefix == self.prefix 47 | 48 | return value.uuid 49 | 50 | def process_result_value(self, value, dialect): 51 | return TypeID.from_uuid(value, self.prefix) 52 | -------------------------------------------------------------------------------- /typeid/explain/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Explain subsystem for TypeID. 3 | 4 | This package provides a high-level, non-breaking API and CLI support 5 | for answering the question: 6 | 7 | "What is this TypeID?" 8 | 9 | It is intentionally: 10 | - additive (no changes to existing TypeID semantics), 11 | - schema-optional (works fully offline), 12 | - safe by default (read-only, no side effects). 13 | 14 | Public API: 15 | explain(id_str, schema_path=None, **options) -> Explanation 16 | """ 17 | 18 | from pathlib import Path 19 | from typing import Optional 20 | 21 | from .discovery import discover_schema_path 22 | from .engine import explain as _explain_engine 23 | from .model import Explanation 24 | from .registry import load_registry, make_lookup 25 | 26 | __all__ = [ 27 | "explain", 28 | "Explanation", 29 | ] 30 | 31 | 32 | def explain( 33 | id_str: str, 34 | *, 35 | schema_path: Optional[str | Path] = None, 36 | enable_schema: bool = True, 37 | enable_links: bool = True, 38 | ) -> Explanation: 39 | """ 40 | High-level convenience API for explaining a TypeID. 41 | 42 | This function: 43 | - parses and validates the TypeID, 44 | - discovers and loads schema if enabled, 45 | - executes the explain engine, 46 | - never raises on normal user errors. 47 | 48 | Args: 49 | id_str: TypeID string to explain. 50 | schema_path: Optional explicit path to schema file. 51 | If None, discovery rules are applied. 52 | enable_schema: Disable schema usage entirely if False. 53 | enable_links: Disable link rendering if False. 54 | 55 | Returns: 56 | Explanation object. 57 | """ 58 | lookup = None 59 | 60 | if enable_schema: 61 | path = None 62 | 63 | if schema_path is not None: 64 | path = Path(schema_path).expanduser() 65 | else: 66 | discovery = discover_schema_path() 67 | path = discovery.path 68 | 69 | if path is not None: 70 | result = load_registry(path) 71 | if result.registry is not None: 72 | lookup = make_lookup(result.registry) 73 | # Note: load errors are intentionally not raised here. 74 | # They will be surfaced as warnings by the CLI layer if desired. 75 | 76 | return _explain_engine( 77 | id_str, 78 | schema_lookup=lookup, 79 | enable_schema=enable_schema, 80 | enable_links=enable_links, 81 | ) 82 | -------------------------------------------------------------------------------- /tests/explain/test_discovery.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from typeid.explain.discovery import discover_schema_path 6 | 7 | 8 | def test_discovery_env_var_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): 9 | schema = tmp_path / "schema.json" 10 | schema.write_text('{"schema_version": 1, "types": {}}', encoding="utf-8") 11 | 12 | monkeypatch.setenv("TYPEID_SCHEMA", str(schema)) 13 | 14 | # even if cwd has other candidates, env must win 15 | cwd = tmp_path / "cwd" 16 | cwd.mkdir() 17 | (cwd / "typeid.schema.json").write_text('{"schema_version": 1, "types": {}}', encoding="utf-8") 18 | 19 | res = discover_schema_path(cwd=cwd) 20 | assert res.path == schema 21 | assert res.source.startswith("env:") 22 | 23 | 24 | def test_discovery_cwd_candidate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): 25 | monkeypatch.delenv("TYPEID_SCHEMA", raising=False) 26 | 27 | cwd = tmp_path / "cwd" 28 | cwd.mkdir() 29 | schema = cwd / "typeid.schema.json" 30 | schema.write_text('{"schema_version": 1, "types": {}}', encoding="utf-8") 31 | 32 | res = discover_schema_path(cwd=cwd) 33 | assert res.path == schema 34 | assert res.source == "cwd" 35 | 36 | 37 | def test_discovery_user_config_when_no_env_and_no_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): 38 | monkeypatch.delenv("TYPEID_SCHEMA", raising=False) 39 | 40 | # Force XDG_CONFIG_HOME to a temp dir 41 | xdg = tmp_path / "xdg" 42 | xdg.mkdir() 43 | monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) 44 | monkeypatch.delenv("APPDATA", raising=False) 45 | 46 | # Put schema in user config location: /typeid/schema.json 47 | base = xdg / "typeid" 48 | base.mkdir() 49 | schema = base / "schema.json" 50 | schema.write_text('{"schema_version": 1, "types": {}}', encoding="utf-8") 51 | 52 | cwd = tmp_path / "cwd" 53 | cwd.mkdir() 54 | 55 | res = discover_schema_path(cwd=cwd) 56 | assert res.path == schema 57 | assert res.source == "user_config" 58 | 59 | 60 | def test_discovery_none_when_missing_everywhere(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): 61 | monkeypatch.delenv("TYPEID_SCHEMA", raising=False) 62 | monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg_missing")) 63 | monkeypatch.delenv("APPDATA", raising=False) 64 | 65 | cwd = tmp_path / "cwd" 66 | cwd.mkdir() 67 | 68 | res = discover_schema_path(cwd=cwd) 69 | assert res.path is None 70 | assert res.source in {"none", "env:TYPEID_SCHEMA (not found)"} 71 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # TypeID Examples 2 | 3 | This directory contains **independent, self-contained examples** demonstrating 4 | different ways to use **TypeID in real projects**. 5 | 6 | Each example focuses on a specific integration or use case and can be studied 7 | and used on its own. 8 | 9 | ## `examples/explain/` — `typeid explain` feature 10 | 11 | This directory contains **advanced examples** for the `typeid explain` feature. 12 | 13 | These examples demonstrate how to: 14 | 15 | * inspect TypeIDs (“what is this ID?”) 16 | * enrich IDs using schemas (JSON / YAML) 17 | * batch-process IDs for automation 18 | * safely handle invalid or unknown IDs 19 | * generate machine-readable reports 20 | 21 | ## `examples/sqlalchemy.py` — SQLAlchemy integration 22 | 23 | This example demonstrates how to use **TypeID with SQLAlchemy** in a clean and 24 | database-friendly way. 25 | 26 | ### Purpose 27 | 28 | * Store **native UUIDs** in the database 29 | * Expose **TypeID objects** at the application level 30 | * Enforce prefix correctness automatically 31 | * Keep database schema simple and efficient 32 | 33 | This example is **independent** of the `typeid explain` feature. 34 | 35 | ### What this example shows 36 | 37 | * How to implement a custom `TypeDecorator` for TypeID 38 | * How to: 39 | 40 | * bind a `TypeID` to a UUID column 41 | * reconstruct a `TypeID` on read 42 | * How to ensure: 43 | 44 | * prefixes are validated 45 | * Alembic autogeneration preserves constructor arguments 46 | 47 | ### Usage snippet 48 | 49 | ```python 50 | id = mapped_column( 51 | TypeIDType("user"), 52 | primary_key=True, 53 | default=lambda: TypeID("user") 54 | ) 55 | ``` 56 | 57 | Resulting identifiers look like: 58 | 59 | ```text 60 | user_01h45ytscbebyvny4gc8cr8ma2 61 | ``` 62 | 63 | while the database stores only the UUID value. 64 | 65 | ## Choosing the right example 66 | 67 | | Use case | Example | 68 | | ---------------------------- | ------------------------------------ | 69 | | Understand `typeid explain` | `examples/explain/` | 70 | | Batch / CI / reporting | `examples/explain/explain_report.py` | 71 | | SQLAlchemy ORM integration | `examples/sqlalchemy.py` | 72 | | UUID-native database storage | `examples/sqlalchemy.py` | 73 | 74 | ## Design Principles 75 | 76 | All examples in this directory follow these principles: 77 | 78 | * ✅ non-breaking 79 | * ✅ production-oriented 80 | * ✅ minimal dependencies 81 | * ✅ explicit and readable 82 | * ✅ safe handling of invalid input 83 | 84 | Examples are meant to be **copied, adapted, and extended**. 85 | -------------------------------------------------------------------------------- /examples/explain/schemas/typeid.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema_version": 1, 3 | "types": { 4 | "user": { 5 | "name": "User", 6 | "description": "End-user account", 7 | "owner_team": "identity-platform", 8 | "pii": true, 9 | "retention": "7y", 10 | "services": ["user-service", "auth-service"], 11 | "storage": { 12 | "primary": { "kind": "postgres", "table": "users", "shard_by": "tenant_id" } 13 | }, 14 | "events": ["user.created", "user.updated", "user.deleted"], 15 | "policies": { 16 | "delete": { "allowed": false, "reason": "GDPR retention policy" } 17 | }, 18 | "links": { 19 | "docs": "https://docs.company/entities/user", 20 | "logs": "https://logs.company/search?q={id}", 21 | "trace": "https://traces.company/?q={id}", 22 | "admin": "https://admin.company/users/{id}" 23 | } 24 | }, 25 | 26 | "order": { 27 | "name": "Order", 28 | "description": "Customer purchase order", 29 | "owner_team": "commerce-platform", 30 | "pii": false, 31 | "retention": "10y", 32 | "services": ["order-service", "billing-service"], 33 | "storage": { 34 | "primary": { "kind": "postgres", "table": "orders", "shard_by": "region" } 35 | }, 36 | "events": ["order.created", "order.paid", "order.refunded"], 37 | "policies": { 38 | "delete": { "allowed": true, "reason": "No compliance hold" } 39 | }, 40 | "links": { 41 | "admin": "https://admin.company/orders/{id}", 42 | "logs": "https://logs.company/search?q={id}" 43 | } 44 | }, 45 | 46 | "evt_payment": { 47 | "name": "PaymentEvent", 48 | "description": "Event emitted by payment pipeline", 49 | "owner_team": "payments", 50 | "pii": false, 51 | "retention": "30d", 52 | "services": ["payment-service"], 53 | "events": ["payment.authorized", "payment.failed", "payment.captured"], 54 | "policies": { 55 | "replay": { "allowed": false, "reason": "Non-idempotent event stream" } 56 | }, 57 | "links": { 58 | "kafka": "https://kafka-ui.company/topics/payment-events?key={id}", 59 | "trace": "https://traces.company/?q={id}" 60 | } 61 | }, 62 | 63 | "user_live_eu": { 64 | "name": "User (prod EU)", 65 | "description": "Production EU user identifier (prefix taxonomy demo)", 66 | "owner_team": "identity-platform", 67 | "pii": true, 68 | "retention": "7y", 69 | "policies": { 70 | "cross_region": { "allowed": false, "reason": "EU PII must not leave EU" } 71 | }, 72 | "links": { 73 | "logs": "https://logs.company/search?q={id}®ion=eu", 74 | "admin": "https://admin.company/eu/users/{id}" 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This document describes how to contribute to **typeid-python**. 4 | 5 | Thank you for taking the time to contribute ❤️ 6 | 7 | ## Requirements 8 | 9 | - Linux or macOS (the development workflow is primarily tested on Unix-like systems) 10 | - A supported Python version (e.g. Python 3.10+; latest tested: Python 3.14) 11 | - [`uv`](https://astral.sh/uv/) – fast Python package manager and environment tool 12 | 13 | ## Installation 14 | 15 | ### 1. Fork & clone 16 | 17 | 1. Fork the repository on GitHub. 18 | 2. Clone your fork locally: 19 | 20 | ```bash 21 | git clone https://github.com/akhundMurad/typeid-python.git 22 | cd typeid-python 23 | ``` 24 | 25 | ### 2. Install `uv` 26 | 27 | ```bash 28 | curl -LsSf https://astral.sh/uv/install.sh | sh 29 | ``` 30 | 31 | Verify installation: 32 | 33 | ```bash 34 | uv --version 35 | ``` 36 | 37 | ### 3. Set up the development environment 38 | 39 | Create and sync the virtual environment (including dev dependencies): 40 | 41 | ```bash 42 | uv sync --all-groups 43 | ``` 44 | 45 | This will: 46 | 47 | - create a local `.venv/` 48 | - install dependencies according to `uv.lock` 49 | - keep the environment reproducible 50 | 51 | ## Running tests 52 | 53 | ```bash 54 | make test 55 | ``` 56 | 57 | or directly: 58 | 59 | ```bash 60 | uv run pytest -v 61 | ``` 62 | 63 | ## Formatters & linters 64 | 65 | We use the following tools: 66 | 67 | - **ruff** – linting & import sorting 68 | - **black** – code formatting 69 | - **mypy** – static type checking 70 | 71 | Run all linters: 72 | 73 | ```bash 74 | make check-linting 75 | ``` 76 | 77 | Auto-fix formatting issues where possible: 78 | 79 | ```bash 80 | make fix-linting 81 | ``` 82 | 83 | ## Building the package 84 | 85 | Build wheel and source distribution: 86 | 87 | ```bash 88 | make build 89 | ``` 90 | 91 | This uses `uv build` under the hood. 92 | 93 | ## Testing extras (CLI) 94 | 95 | To test the CLI extra locally: 96 | 97 | ```bash 98 | uv sync --all-groups --extra cli 99 | uv run typeid new -p test 100 | ``` 101 | 102 | ## Lockfile discipline 103 | 104 | - `uv.lock` **must be committed** 105 | - Always run dependency changes via `uv add` / `uv remove` 106 | - CI uses `uv sync --locked`, so lockfile drift will fail builds 107 | 108 | ## How to name branches 109 | 110 | Branch names are flexible, as long as they are respectful and descriptive. 111 | 112 | Recommended patterns: 113 | 114 | - `fix/core/32` 115 | - `feature/cli-support` 116 | - `docs/readme-update` 117 | - `chore/ci-cleanup` 118 | 119 | Referencing an issue number in the branch name is encouraged but not required. 120 | 121 | ## Submitting a Pull Request 122 | 123 | 1. Create a feature branch 124 | 2. Make sure tests and linters pass 125 | 3. Commit with a clear message 126 | 4. Open a pull request against `main` 127 | 5. Describe **what** changed and **why** 128 | 129 | Happy hacking 🚀 130 | If something is unclear, feel free to open an issue or discussion. 131 | -------------------------------------------------------------------------------- /tests/explain/test_cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from click.testing import CliRunner 5 | 6 | from typeid import TypeID 7 | from typeid.cli import cli 8 | 9 | 10 | def _make_valid_id(prefix: str = "usr") -> str: 11 | return str(TypeID(prefix=prefix)) 12 | 13 | 14 | def test_cli_explain_pretty_offline_no_schema(): 15 | runner = CliRunner() 16 | tid = _make_valid_id("usr") 17 | 18 | result = runner.invoke(cli, ["explain", tid, "--no-schema"]) 19 | assert result.exit_code == 0 20 | out = result.output 21 | 22 | assert f"id: {tid}" in out 23 | assert "valid: true" in out 24 | assert "schema:" in out 25 | assert "found: false" in out or "found: false" in out.lower() 26 | 27 | 28 | def test_cli_explain_json_offline(): 29 | runner = CliRunner() 30 | tid = _make_valid_id("usr") 31 | 32 | result = runner.invoke(cli, ["explain", tid, "--no-schema", "--json"]) 33 | assert result.exit_code == 0 34 | 35 | payload = json.loads(result.output) 36 | assert payload["id"] == tid 37 | assert payload["valid"] is True 38 | assert payload["schema"] is None 39 | assert payload["parsed"]["prefix"] == "usr" 40 | assert payload["parsed"]["uuid"] is not None 41 | 42 | 43 | def test_cli_explain_with_schema_file(tmp_path: Path): 44 | runner = CliRunner() 45 | tid = _make_valid_id("usr") 46 | 47 | schema = { 48 | "schema_version": 1, 49 | "types": { 50 | "usr": {"name": "User", "owner_team": "identity-platform", "links": {"logs": "https://logs?q={id}"}} 51 | }, 52 | } 53 | p = tmp_path / "typeid.schema.json" 54 | p.write_text(json.dumps(schema), encoding="utf-8") 55 | 56 | result = runner.invoke(cli, ["explain", tid, "--schema", str(p)]) 57 | assert result.exit_code == 0 58 | out = result.output 59 | 60 | assert "schema:" in out 61 | assert "found: true" in out 62 | assert "name: User" in out 63 | assert "owner_team: identity-platform" in out 64 | assert "links:" in out 65 | assert "logs:" in out 66 | 67 | 68 | def test_cli_explain_schema_load_failure_still_works(tmp_path: Path): 69 | runner = CliRunner() 70 | tid = _make_valid_id("usr") 71 | 72 | p = tmp_path / "typeid.schema.json" 73 | p.write_text("{not json", encoding="utf-8") 74 | 75 | result = runner.invoke(cli, ["explain", tid, "--schema", str(p)]) 76 | assert result.exit_code == 0 77 | out = result.output 78 | 79 | # Should still explain derived facts and surface warning 80 | assert f"id: {tid}" in out 81 | assert "valid: true" in out 82 | assert "warnings:" in out.lower() 83 | 84 | 85 | def test_cli_explain_invalid_id_exit_code_zero_but_valid_false(): 86 | # We keep exit_code 0 for "explain" so it can be used in scripts without 87 | # failing pipelines; the content will indicate validity. 88 | runner = CliRunner() 89 | 90 | result = runner.invoke(cli, ["explain", "not_a_typeid", "--no-schema"]) 91 | assert result.exit_code == 0 92 | assert "valid: false" in result.output.lower() 93 | assert "errors:" in result.output.lower() 94 | -------------------------------------------------------------------------------- /examples/explain/explain_complex.py: -------------------------------------------------------------------------------- 1 | """ 2 | Complex example: schema discovery + taxonomy prefixes + robust handling. 3 | 4 | Run: 5 | # (recommended) set schema location so discovery works 6 | export TYPEID_SCHEMA=examples/schemas/typeid.schema.json 7 | 8 | python examples/explain_complex.py 9 | 10 | Optional: 11 | pip install typeid-python[yaml] 12 | export TYPEID_SCHEMA=examples/schemas/typeid.schema.yaml 13 | """ 14 | 15 | import os 16 | from typing import Iterable 17 | 18 | from typeid import TypeID 19 | from typeid.explain.discovery import discover_schema_path 20 | from typeid.explain.registry import load_registry, make_lookup 21 | from typeid.explain.engine import explain as explain_engine 22 | from typeid.explain.formatters import format_explanation_pretty 23 | 24 | 25 | def _load_schema_lookup(): 26 | discovery = discover_schema_path() 27 | if discovery.path is None: 28 | print("No schema discovered. Proceeding without schema.") 29 | return None 30 | 31 | result = load_registry(discovery.path) 32 | if result.registry is None: 33 | print(f"Schema load failed: {result.error.message if result.error else 'unknown error'}") 34 | return None 35 | 36 | print(f"Schema loaded from: {discovery.path} ({discovery.source})") 37 | return make_lookup(result.registry) 38 | 39 | 40 | def _banner(title: str) -> None: 41 | print("\n" + "=" * 80) 42 | print(title) 43 | print("=" * 80) 44 | 45 | 46 | def _explain_many(ids: Iterable[str], lookup) -> None: 47 | for tid in ids: 48 | exp = explain_engine(tid, schema_lookup=lookup, enable_schema=True, enable_links=True) 49 | print(format_explanation_pretty(exp)) 50 | 51 | 52 | def main() -> None: 53 | _banner("TypeID explain — complex demo") 54 | 55 | # Use schema discovery (env/cwd/user-config) 56 | lookup = _load_schema_lookup() 57 | 58 | # Create a bunch of IDs: 59 | # - standard prefixes 60 | # - taxonomy prefix (env/region in prefix) 61 | # - unknown prefix 62 | # - invalid string 63 | user_id = str(TypeID(prefix="user")) 64 | order_id = str(TypeID(prefix="order")) 65 | evt_id = str(TypeID(prefix="evt_payment")) 66 | user_live_eu_id = str(TypeID(prefix="user_live_eu")) 67 | unknown_id = str(TypeID(prefix="something_new")) 68 | invalid_id = "user_NOT_A_SUFFIX" 69 | 70 | _banner("Explaining generated IDs") 71 | ids = [user_id, order_id, evt_id, user_live_eu_id, unknown_id, invalid_id] 72 | _explain_many(ids, lookup) 73 | 74 | _banner("Notes") 75 | print("- IDs still explain offline (derived facts always present).") 76 | print("- Schema adds meaning, ownership, policies, and links.") 77 | print("- Prefix taxonomy works because TypeID prefixes allow underscores.") 78 | print("- Invalid IDs never crash; they return valid=false and errors.") 79 | print("- Unknown prefixes still show derived facts, schema found=false.") 80 | 81 | 82 | if __name__ == "__main__": 83 | # Helpful hint for users 84 | if "TYPEID_SCHEMA" not in os.environ: 85 | print("Tip: set TYPEID_SCHEMA to enable schema discovery, e.g.:") 86 | print(" export TYPEID_SCHEMA=examples/schemas/typeid.schema.json\n") 87 | main() 88 | -------------------------------------------------------------------------------- /examples/explain/explain_report.py: -------------------------------------------------------------------------------- 1 | """ 2 | Batch report example: 3 | - Reads TypeIDs from a file (sample_ids.txt) 4 | - Explains each one 5 | - Prints summary stats 6 | - Optionally writes JSON report 7 | 8 | Run: 9 | export TYPEID_SCHEMA=examples/schemas/typeid.schema.json 10 | python examples/explain_report.py examples/sample_ids.txt --json-out /tmp/report.json 11 | """ 12 | 13 | import argparse 14 | import json 15 | from pathlib import Path 16 | 17 | from typeid.explain.discovery import discover_schema_path 18 | from typeid.explain.engine import explain as explain_engine 19 | from typeid.explain.registry import load_registry, make_lookup 20 | 21 | 22 | def _read_ids(path: Path) -> list[str]: 23 | ids: list[str] = [] 24 | for line in path.read_text(encoding="utf-8").splitlines(): 25 | line = line.strip() 26 | if not line or line.startswith("#"): 27 | continue 28 | ids.append(line) 29 | return ids 30 | 31 | 32 | def main() -> None: 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument("file", type=str, help="Path to file with TypeIDs (one per line).") 35 | parser.add_argument("--json-out", type=str, default=None, help="Optional path to write JSON report.") 36 | args = parser.parse_args() 37 | 38 | ids = _read_ids(Path(args.file)) 39 | 40 | # Discover schema (optional) 41 | discovery = discover_schema_path() 42 | lookup = None 43 | schema_info = {"found": False} 44 | 45 | if discovery.path is not None: 46 | r = load_registry(discovery.path) 47 | if r.registry is not None: 48 | lookup = make_lookup(r.registry) 49 | schema_info = {"found": True, "path": str(discovery.path), "source": discovery.source} 50 | else: 51 | schema_info = {"found": False, "error": r.error.message if r.error else "unknown"} 52 | 53 | explanations = [] 54 | valid_count = 0 55 | schema_hit = 0 56 | 57 | for tid in ids: 58 | exp = explain_engine(tid, schema_lookup=lookup, enable_schema=True, enable_links=True) 59 | explanations.append(exp) 60 | if exp.valid: 61 | valid_count += 1 62 | if exp.schema is not None: 63 | schema_hit += 1 64 | 65 | # Summary 66 | print("TypeID explain report") 67 | print("--------------------") 68 | print(f"IDs processed: {len(ids)}") 69 | print(f"Valid IDs: {valid_count}") 70 | print(f"Schema hits: {schema_hit}") 71 | print(f"Schema: {schema_info}") 72 | print() 73 | 74 | # Print concise table 75 | for exp in explanations: 76 | prefix = exp.parsed.prefix or "-" 77 | ok = "OK" if exp.valid else "ERR" 78 | name = exp.schema.name if exp.schema and exp.schema.name else "-" 79 | print(f"{ok:>3} {prefix:<16} {name:<22} {exp.id}") 80 | 81 | # Optional JSON output 82 | if args.json_out: 83 | payload = { 84 | "summary": { 85 | "count": len(ids), 86 | "valid": valid_count, 87 | "schema_hits": schema_hit, 88 | "schema": schema_info, 89 | }, 90 | "items": [e.to_dict() for e in explanations], 91 | } 92 | out_path = Path(args.json_out) 93 | out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") 94 | print(f"\nWrote JSON report to: {out_path}") 95 | 96 | 97 | if __name__ == "__main__": 98 | main() 99 | -------------------------------------------------------------------------------- /tests/explain/test_registry.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from typeid.explain.registry import load_registry 5 | 6 | 7 | def test_load_registry_json_happy_path(tmp_path: Path): 8 | schema = { 9 | "schema_version": 1, 10 | "types": { 11 | "usr": { 12 | "name": "User", 13 | "description": "End-user account", 14 | "owner_team": "identity-platform", 15 | "pii": True, 16 | "retention": "7y", 17 | "links": { 18 | "logs": "https://logs?q={id}", 19 | }, 20 | } 21 | }, 22 | } 23 | p = tmp_path / "typeid.schema.json" 24 | p.write_text(json.dumps(schema), encoding="utf-8") 25 | 26 | result = load_registry(p) 27 | assert result.registry is not None 28 | assert result.error is None 29 | 30 | s = result.registry.get("usr") 31 | assert s is not None 32 | assert s.name == "User" 33 | assert s.pii is True 34 | assert s.links["logs"].startswith("https://") 35 | 36 | 37 | def test_load_registry_missing_schema_version(tmp_path: Path): 38 | schema = {"types": {"usr": {"name": "User"}}} 39 | p = tmp_path / "typeid.schema.json" 40 | p.write_text(json.dumps(schema), encoding="utf-8") 41 | 42 | result = load_registry(p) 43 | assert result.registry is None 44 | assert result.error is not None 45 | assert result.error.code == "missing_schema_version" 46 | 47 | 48 | def test_load_registry_unsupported_schema_version(tmp_path: Path): 49 | schema = {"schema_version": 999, "types": {}} 50 | p = tmp_path / "typeid.schema.json" 51 | p.write_text(json.dumps(schema), encoding="utf-8") 52 | 53 | result = load_registry(p) 54 | assert result.registry is None 55 | assert result.error is not None 56 | assert result.error.code == "unsupported_schema_version" 57 | 58 | 59 | def test_load_registry_types_not_a_map(tmp_path: Path): 60 | schema = {"schema_version": 1, "types": ["usr"]} 61 | p = tmp_path / "typeid.schema.json" 62 | p.write_text(json.dumps(schema), encoding="utf-8") 63 | 64 | result = load_registry(p) 65 | assert result.registry is None 66 | assert result.error is not None 67 | assert result.error.code == "invalid_types" 68 | 69 | 70 | def test_load_registry_skips_invalid_type_entries(tmp_path: Path): 71 | schema = { 72 | "schema_version": 1, 73 | "types": { 74 | "usr": {"name": "User"}, 75 | "": {"name": "EmptyPrefixShouldSkip"}, 76 | "bad": "not a map", 77 | }, 78 | } 79 | p = tmp_path / "typeid.schema.json" 80 | p.write_text(json.dumps(schema), encoding="utf-8") 81 | 82 | result = load_registry(p) 83 | assert result.registry is not None 84 | 85 | assert result.registry.get("usr") is not None 86 | assert result.registry.get("") is None 87 | assert result.registry.get("bad") is None 88 | 89 | 90 | def test_load_registry_unknown_extension_tries_json_then_fails(tmp_path: Path): 91 | p = tmp_path / "schema.weird" 92 | p.write_text('{"schema_version": 1, "types": {"usr": {"name": "User"}}}', encoding="utf-8") 93 | 94 | result = load_registry(p) 95 | assert result.registry is not None 96 | assert result.error is None 97 | 98 | 99 | def test_load_registry_invalid_json_returns_error(tmp_path: Path): 100 | p = tmp_path / "typeid.schema.json" 101 | p.write_text("{not json", encoding="utf-8") 102 | 103 | result = load_registry(p) 104 | assert result.registry is None 105 | assert result.error is not None 106 | assert result.error.code == "read_failed" 107 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "typeid-python" 3 | version = "0.3.3" 4 | description = "Python implementation of TypeIDs: type-safe, K-sortable, and globally unique identifiers inspired by Stripe IDs" 5 | authors = [{ name = "Murad Akhundov", email = "akhundov1murad@gmail.com" }] 6 | requires-python = ">=3.10,<4" 7 | readme = "README.md" 8 | license = "MIT" 9 | keywords = [ 10 | "typeid", 11 | "uuid", 12 | "uuid6", 13 | "guid", 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Programming Language :: Python :: 3.14", 23 | "Operating System :: OS Independent", 24 | ] 25 | dependencies = ["uuid6>=2024.7.10,<2026.0.0"] 26 | 27 | [project.optional-dependencies] 28 | cli = ["click"] 29 | yaml = ["PyYAML"] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/akhundMurad/typeid-python" 33 | Repository = "https://github.com/akhundMurad/typeid-python" 34 | "Bug Tracker" = "https://github.com/akhundMurad/typeid-python/issues" 35 | 36 | [project.scripts] 37 | typeid = "typeid.cli:cli" 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "pytest>=7.3.2,<8", 42 | "black>=23.3.0,<24", 43 | "mypy>=1.3.0,<2", 44 | "requests>=2.31.0,<3", 45 | "ruff>=0.14.5,<0.15", 46 | "twine>=6.2.0,<7", 47 | "pyyaml>=6.0", 48 | "mkdocs-material>=9.7.1", 49 | "mkdocstrings[python]>=1.0.0", 50 | "mkdocs-git-revision-date-localized-plugin>=1.5.0", 51 | "mkdocs-gen-files>=0.6.0", 52 | "mkdocs-literate-nav>=0.6.2", 53 | "mkdocs-section-index>=0.3.10", 54 | "pytest-markdown-docs>=0.9.0", 55 | ] 56 | 57 | [tool.hatch.build.targets.sdist] 58 | include = ["typeid"] 59 | 60 | [tool.hatch.build.targets.wheel] 61 | include = ["typeid"] 62 | 63 | [build-system] 64 | requires = ["hatchling"] 65 | build-backend = "hatchling.build" 66 | 67 | 68 | # DEPRECATED poetry configuration: 69 | 70 | [tool.poetry] 71 | name = "typeid-python" 72 | version = "0.3.3" 73 | description = "Python implementation of TypeIDs: type-safe, K-sortable, and globally unique identifiers inspired by Stripe IDs" 74 | authors = ["Murad Akhundov "] 75 | license = "MIT" 76 | readme = "README.md" 77 | repository = "https://github.com/akhundMurad/typeid-python" 78 | classifiers = [ 79 | "Development Status :: 3 - Alpha", 80 | "License :: OSI Approved :: MIT License", 81 | "Programming Language :: Python :: 3.10", 82 | "Programming Language :: Python :: 3.11", 83 | "Programming Language :: Python :: 3.12", 84 | "Programming Language :: Python :: 3.13", 85 | "Programming Language :: Python :: 3.14", 86 | "Operating System :: OS Independent", 87 | ] 88 | keywords = ["typeid", "uuid", "uuid6", "guid"] 89 | packages = [{ include = "typeid" }] 90 | 91 | [tool.poetry.dependencies] 92 | python = ">=3.10,<4" 93 | uuid6 = ">=2024.7.10,<2026.0.0" 94 | 95 | [tool.poetry.group.dev.dependencies] 96 | pytest = "^7.3.2" 97 | black = "^23.3.0" 98 | mypy = "^1.3.0" 99 | requests = "^2.31.0" 100 | pyyaml = "^6.0" 101 | ruff = "^0.14.5" 102 | twine = "^6.2.0" 103 | 104 | [tool.poetry.extras] 105 | cli = ["click"] 106 | yaml = ["PyYAML"] 107 | 108 | [tool.poetry.scripts] 109 | typeid = "typeid.cli:cli" 110 | 111 | [tool.pylint] 112 | disable = ["C0111", "C0116", "C0114", "R0903"] 113 | 114 | [tool.ruff] 115 | line-length = 119 116 | target-version = "py310" 117 | src = ["typeid", "tests"] 118 | 119 | [tool.ruff.lint] 120 | select = ["E", "F", "W", "B", "I"] 121 | ignore = ["E203", "B028"] 122 | 123 | [tool.ruff.lint.isort] 124 | known-first-party = ["typeid"] 125 | -------------------------------------------------------------------------------- /typeid/explain/discovery.py: -------------------------------------------------------------------------------- 1 | """ 2 | Schema discovery for `typeid explain`. 3 | 4 | This module implements a conservative, non-breaking discovery mechanism: 5 | - If nothing is found, callers proceed without schema (feature still works). 6 | - No new mandatory dependencies. 7 | - Paths are resolved deterministically with clear precedence. 8 | 9 | Precedence (first match wins): 10 | 1) explicit CLI arg: --schema PATH (handled by caller; use discover_schema only if not provided) 11 | 2) environment variable: TYPEID_SCHEMA 12 | 3) current working directory: 13 | - typeid.schema.json 14 | - typeid.schema.yaml / typeid.schema.yml 15 | 4) user config directory: 16 | - /typeid/schema.json 17 | - /typeid/schema.yaml / schema.yml 18 | """ 19 | 20 | import os 21 | from dataclasses import dataclass 22 | from pathlib import Path 23 | from typing import Iterable, Optional 24 | 25 | DEFAULT_CWD_CANDIDATES = ( 26 | "typeid.schema.json", 27 | "typeid.schema.yaml", 28 | "typeid.schema.yml", 29 | ) 30 | 31 | DEFAULT_USER_CANDIDATES = ( 32 | "schema.json", 33 | "schema.yaml", 34 | "schema.yml", 35 | ) 36 | 37 | 38 | @dataclass(frozen=True, slots=True) 39 | class DiscoveryResult: 40 | """Result of schema discovery.""" 41 | 42 | path: Optional[Path] 43 | source: str # e.g., "env:TYPEID_SCHEMA", "cwd", "user_config", "none" 44 | 45 | 46 | def discover_schema_path( 47 | *, 48 | env_var: str = "TYPEID_SCHEMA", 49 | cwd: Optional[Path] = None, 50 | ) -> DiscoveryResult: 51 | """ 52 | Discover schema file path using the configured precedence rules. 53 | 54 | Args: 55 | env_var: environment variable name to check first. 56 | cwd: optional cwd override (useful for tests). 57 | 58 | Returns: 59 | DiscoveryResult with found path or None. 60 | """ 61 | # 1) Environment variable 62 | env_value = os.environ.get(env_var) 63 | if env_value: 64 | p = Path(env_value).expanduser() 65 | if p.is_file(): 66 | return DiscoveryResult(path=p, source=f"env:{env_var}") 67 | # If provided but invalid, we treat it as "not found" but caller can 68 | # warn separately if they want. 69 | return DiscoveryResult(path=None, source=f"env:{env_var} (not found)") 70 | 71 | # 2) Current working directory 72 | cwd_path = cwd or Path.cwd() 73 | for name in DEFAULT_CWD_CANDIDATES: 74 | p = cwd_path / name 75 | if p.is_file(): 76 | return DiscoveryResult(path=p, source="cwd") 77 | 78 | # 3) User config directory 79 | user_cfg = _user_config_dir() 80 | if user_cfg is not None: 81 | base = user_cfg / "typeid" 82 | for name in DEFAULT_USER_CANDIDATES: 83 | p = base / name 84 | if p.is_file(): 85 | return DiscoveryResult(path=p, source="user_config") 86 | 87 | return DiscoveryResult(path=None, source="none") 88 | 89 | 90 | def _user_config_dir() -> Optional[Path]: 91 | """ 92 | Return OS-appropriate user config directory. 93 | 94 | - Linux/macOS: ~/.config 95 | - Windows: %APPDATA% 96 | """ 97 | # Windows: APPDATA is the typical location for roaming config 98 | appdata = os.environ.get("APPDATA") 99 | if appdata: 100 | return Path(appdata).expanduser() 101 | 102 | # XDG on Linux, also often present on macOS 103 | xdg = os.environ.get("XDG_CONFIG_HOME") 104 | if xdg: 105 | return Path(xdg).expanduser() 106 | 107 | # Fallback to ~/.config 108 | home = Path.home() 109 | if home: 110 | return home / ".config" 111 | return None 112 | 113 | 114 | def iter_default_candidate_paths(*, cwd: Optional[Path] = None) -> Iterable[Path]: 115 | """ 116 | Yield all candidate paths in discovery order (excluding env var). 117 | 118 | Useful for debugging or `typeid explain --debug-discovery` style features. 119 | """ 120 | cwd_path = cwd or Path.cwd() 121 | for name in DEFAULT_CWD_CANDIDATES: 122 | yield cwd_path / name 123 | 124 | user_cfg = _user_config_dir() 125 | if user_cfg is not None: 126 | base = user_cfg / "typeid" 127 | for name in DEFAULT_USER_CANDIDATES: 128 | yield base / name 129 | -------------------------------------------------------------------------------- /tests/test_typeid.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid6 3 | 4 | from typeid import TypeID 5 | from typeid.errors import SuffixValidationException 6 | 7 | 8 | def test_default_suffix() -> None: 9 | prefix = "qutab" 10 | typeid = TypeID(suffix=None, prefix=prefix) 11 | 12 | assert typeid.prefix == prefix 13 | assert typeid.suffix 14 | 15 | 16 | def test_construct_typeid() -> None: 17 | prefix = "plov" 18 | suffix = "00041061050r3gg28a1c60t3gf" 19 | 20 | typeid = TypeID(prefix=prefix, suffix=suffix) 21 | 22 | assert typeid.prefix == prefix 23 | assert typeid.suffix == suffix 24 | 25 | 26 | def test_compare_typeid() -> None: 27 | prefix_1 = "plov" 28 | suffix_1 = "00041061050r3gg28a1c60t3gf" 29 | prefix_2 = "abcd" 30 | suffix_2 = "00000000000000000000000000" 31 | 32 | typeid_1 = TypeID(prefix=prefix_1, suffix=suffix_1) 33 | typeid_2 = TypeID(prefix=prefix_1, suffix=suffix_1) 34 | typeid_3 = TypeID(suffix=suffix_1) 35 | typeid_4 = TypeID(prefix=prefix_2, suffix=suffix_1) 36 | typeid_5 = TypeID(prefix=prefix_1, suffix=suffix_2) 37 | 38 | assert typeid_1 == typeid_2 39 | assert typeid_1 <= typeid_2 40 | assert typeid_1 >= typeid_2 41 | assert typeid_3 != typeid_1 42 | assert typeid_3 < typeid_1 43 | assert typeid_4 <= typeid_1 44 | assert typeid_1 > typeid_5 45 | 46 | 47 | def test_construct_type_from_string() -> None: 48 | string = "00041061050r3gg28a1c60t3gf" 49 | 50 | typeid = TypeID.from_string(string) 51 | 52 | assert isinstance(typeid, TypeID) 53 | assert typeid.prefix == "" 54 | assert isinstance(typeid.suffix, str) 55 | 56 | 57 | def test_construct_type_from_string_standalone() -> None: 58 | string = "00041061050r3gg28a1c60t3gf" 59 | 60 | typeid = TypeID.from_string(string) 61 | 62 | assert isinstance(typeid, TypeID) 63 | assert typeid.prefix == "" 64 | assert isinstance(typeid.suffix, str) 65 | 66 | 67 | def test_construct_type_from_string_with_prefix() -> None: 68 | string = "prefix_00041061050r3gg28a1c60t3gf" 69 | 70 | typeid = TypeID.from_string(string) 71 | 72 | assert isinstance(typeid, TypeID) 73 | assert typeid.prefix == "prefix" 74 | assert isinstance(typeid.suffix, str) 75 | 76 | 77 | def test_construct_type_from_string_with_prefix_standalone() -> None: 78 | string = "prefix_00041061050r3gg28a1c60t3gf" 79 | 80 | typeid = TypeID.from_string(string) 81 | 82 | assert isinstance(typeid, TypeID) 83 | assert typeid.prefix == "prefix" 84 | assert isinstance(typeid.suffix, str) 85 | 86 | 87 | def test_construct_type_from_string_with_multi_underscore_prefix() -> None: 88 | string = "double_prefix_00041061050r3gg28a1c60t3gf" 89 | 90 | typeid = TypeID.from_string(string) 91 | 92 | assert isinstance(typeid, TypeID) 93 | assert typeid.prefix == "double_prefix" 94 | assert isinstance(typeid.suffix, str) 95 | 96 | 97 | def test_construct_type_from_invalid_string() -> None: 98 | string = "invalid_string_to_typeid" 99 | 100 | with pytest.raises(SuffixValidationException): 101 | TypeID.from_string(string) 102 | 103 | 104 | def test_construct_type_from_uuid() -> None: 105 | uuid = uuid6.uuid7() 106 | 107 | typeid = TypeID.from_uuid(suffix=uuid, prefix="") 108 | 109 | assert isinstance(typeid, TypeID) 110 | assert typeid.prefix == "" 111 | assert isinstance(typeid.suffix, str) 112 | 113 | 114 | def test_construct_type_from_uuid_with_prefix() -> None: 115 | uuid = uuid6.uuid7() 116 | prefix = "prefix" 117 | 118 | typeid = TypeID.from_uuid(prefix=prefix, suffix=uuid) 119 | 120 | assert isinstance(typeid, TypeID) 121 | assert typeid.prefix == "prefix" 122 | assert isinstance(typeid.suffix, str) 123 | 124 | 125 | def test_hash_type_id() -> None: 126 | prefix = "plov" 127 | suffix = "00041061050r3gg28a1c60t3gf" 128 | 129 | typeid_1 = TypeID(prefix=prefix, suffix=suffix) 130 | typeid_2 = TypeID(prefix=prefix, suffix=suffix) 131 | typeid_3 = TypeID(suffix=suffix) 132 | 133 | assert hash(typeid_1) == hash(typeid_2) 134 | assert hash(typeid_3) != hash(typeid_1) 135 | 136 | 137 | def test_uuid_property() -> None: 138 | uuid = uuid6.uuid7() 139 | 140 | typeid = TypeID.from_uuid(suffix=uuid) 141 | 142 | assert isinstance(typeid.uuid, uuid6.UUID) 143 | assert typeid.uuid.version == uuid.version == 7 144 | assert typeid.uuid.time == uuid.time 145 | -------------------------------------------------------------------------------- /tests/explain/test_registry_yaml.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from typeid.explain.registry import load_registry 6 | 7 | yaml = pytest.importorskip("yaml") # skip entire file if PyYAML is not installed 8 | 9 | 10 | def test_load_registry_yaml_happy_path(tmp_path: Path): 11 | p = tmp_path / "typeid.schema.yaml" 12 | p.write_text( 13 | """ 14 | schema_version: 1 15 | types: 16 | usr: 17 | name: User 18 | description: End-user account 19 | owner_team: identity-platform 20 | pii: true 21 | retention: 7y 22 | links: 23 | logs: "https://logs?q={id}" 24 | trace: "https://trace?id={id}&uuid={uuid}" 25 | """, 26 | encoding="utf-8", 27 | ) 28 | 29 | result = load_registry(p) 30 | assert result.registry is not None 31 | assert result.error is None 32 | 33 | s = result.registry.get("usr") 34 | assert s is not None 35 | assert s.name == "User" 36 | assert s.description == "End-user account" 37 | assert s.owner_team == "identity-platform" 38 | assert s.pii is True 39 | assert s.retention == "7y" 40 | assert "logs" in s.links 41 | assert "{id}" in s.links["logs"] 42 | 43 | 44 | def test_load_registry_yaml_missing_schema_version(tmp_path: Path): 45 | p = tmp_path / "typeid.schema.yaml" 46 | p.write_text( 47 | """ 48 | types: 49 | usr: 50 | name: User 51 | """, 52 | encoding="utf-8", 53 | ) 54 | 55 | result = load_registry(p) 56 | assert result.registry is None 57 | assert result.error is not None 58 | assert result.error.code == "missing_schema_version" 59 | 60 | 61 | def test_load_registry_yaml_unsupported_schema_version(tmp_path: Path): 62 | p = tmp_path / "typeid.schema.yaml" 63 | p.write_text( 64 | """ 65 | schema_version: 2 66 | types: {} 67 | """, 68 | encoding="utf-8", 69 | ) 70 | 71 | result = load_registry(p) 72 | assert result.registry is None 73 | assert result.error is not None 74 | assert result.error.code == "unsupported_schema_version" 75 | 76 | 77 | def test_load_registry_yaml_types_not_a_map(tmp_path: Path): 78 | p = tmp_path / "typeid.schema.yaml" 79 | p.write_text( 80 | """ 81 | schema_version: 1 82 | types: 83 | - usr 84 | - ord 85 | """, 86 | encoding="utf-8", 87 | ) 88 | 89 | result = load_registry(p) 90 | assert result.registry is None 91 | assert result.error is not None 92 | assert result.error.code == "invalid_types" 93 | 94 | 95 | def test_load_registry_yaml_root_not_a_map(tmp_path: Path): 96 | p = tmp_path / "typeid.schema.yaml" 97 | p.write_text( 98 | """ 99 | - not 100 | - a 101 | - map 102 | """, 103 | encoding="utf-8", 104 | ) 105 | 106 | result = load_registry(p) 107 | assert result.registry is None 108 | assert result.error is not None 109 | assert result.error.code == "invalid_schema" 110 | 111 | 112 | def test_load_registry_yaml_skips_invalid_type_entries(tmp_path: Path): 113 | p = tmp_path / "typeid.schema.yaml" 114 | p.write_text( 115 | """ 116 | schema_version: 1 117 | types: 118 | usr: 119 | name: User 120 | "": # invalid prefix key -> should be skipped 121 | name: EmptyPrefix 122 | bad: "not a map" # invalid value -> should be skipped 123 | """, 124 | encoding="utf-8", 125 | ) 126 | 127 | result = load_registry(p) 128 | assert result.registry is not None 129 | assert result.error is None 130 | 131 | assert result.registry.get("usr") is not None 132 | assert result.registry.get("") is None 133 | assert result.registry.get("bad") is None 134 | 135 | 136 | def test_load_registry_yaml_links_not_a_map_becomes_empty(tmp_path: Path): 137 | p = tmp_path / "typeid.schema.yaml" 138 | p.write_text( 139 | """ 140 | schema_version: 1 141 | types: 142 | usr: 143 | name: User 144 | links: "not a map" 145 | """, 146 | encoding="utf-8", 147 | ) 148 | 149 | result = load_registry(p) 150 | assert result.registry is not None 151 | s = result.registry.get("usr") 152 | assert s is not None 153 | assert s.links == {} 154 | 155 | 156 | def test_load_registry_yaml_malformed_yaml_returns_read_failed(tmp_path: Path): 157 | p = tmp_path / "typeid.schema.yaml" 158 | p.write_text( 159 | """ 160 | schema_version: 1 161 | types: 162 | usr: 163 | name: User 164 | links: 165 | logs: "https://logs?q={id}" 166 | bad: [unclosed 167 | """, 168 | encoding="utf-8", 169 | ) 170 | 171 | result = load_registry(p) 172 | assert result.registry is None 173 | assert result.error is not None 174 | assert result.error.code == "read_failed" 175 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | This page walks through the core workflow of TypeID: generating identifiers, inspecting them, and understanding how they fit into a real system. 4 | 5 | It is intentionally short. The goal is to get you productive quickly, not to explain every detail. 6 | 7 | --- 8 | 9 | ## Installation 10 | 11 | Install the package using your preferred tool. 12 | 13 | With pip: 14 | 15 | ```bash 16 | pip install typeid-python 17 | ``` 18 | 19 | With uv: 20 | 21 | ```bash 22 | uv add typeid-python 23 | ``` 24 | 25 | If you plan to use YAML schemas later, install the optional extra: 26 | 27 | ```bash 28 | pip install "typeid-python[yaml]" 29 | ``` 30 | 31 | JSON schemas work without any extras. 32 | 33 | ## Creating a TypeID 34 | 35 | A TypeID is created by providing a prefix that describes what the identifier represents. 36 | 37 | ```python 38 | from typeid import TypeID 39 | 40 | tid = TypeID("user") 41 | 42 | value = str(tid) 43 | 44 | assert value.startswith("user_") 45 | assert len(value.split("_", 1)[1]) > 0 46 | ``` 47 | 48 | This produces a string similar to: 49 | 50 | ```text 51 | user_01h45ytscbebyvny4gc8cr8ma2 52 | ``` 53 | 54 | The prefix is meaningful to humans. 55 | The suffix is globally unique and time-sortable. 56 | 57 | Prefixes are optional. If you omit it, you get a prefix-less identifier: 58 | 59 | ```python 60 | from typeid import TypeID 61 | 62 | tid = TypeID(None) 63 | 64 | assert not tid.prefix 65 | ``` 66 | 67 | This can be useful when the type is implied by context or stored elsewhere. 68 | 69 | ## Pre-defined prefixes 70 | 71 | Sometimes it's useful to have pre-defined prefix. 72 | 73 | ```python 74 | from dataclasses import dataclass, field 75 | from typing import Literal 76 | from typeid import TypeID, typeid_factory 77 | 78 | UserID = TypeID[Literal["user"]] 79 | gen_user_id = typeid_factory("user") 80 | 81 | 82 | @dataclass() 83 | class UserDTO: 84 | user_id: UserID = field(default_factory=gen_user_id) 85 | full_name: str = "A J" 86 | age: int = 18 87 | 88 | 89 | user = UserDTO() 90 | assert str(user.user_id).startswith("user_") 91 | ``` 92 | 93 | ## Parsing and validation 94 | 95 | TypeIDs can be parsed back from strings. 96 | 97 | ```python 98 | from typeid import TypeID 99 | 100 | prefix = "user" 101 | suffix = "01h45ytscbebyvny4gc8cr8ma2" 102 | tid = TypeID.from_string(f"{prefix}_{suffix}") 103 | 104 | assert tid.prefix == "user" and tid.suffix == suffix 105 | ``` 106 | 107 | If the string is invalid, parsing fails explicitly. Invalid identifiers are never silently accepted. 108 | 109 | When dealing with untrusted input, you will usually want to rely on the `explain` functionality instead of raising exceptions. This is covered later. 110 | 111 | ## UUID compatibility 112 | 113 | Every TypeID is backed by a UUID. 114 | 115 | You can always extract the UUID: 116 | 117 | ```text 118 | tid.uuid 119 | ``` 120 | 121 | And you can always reconstruct a TypeID from a UUID: 122 | 123 | ```python 124 | from uuid6 import uuid7 125 | from typeid import TypeID 126 | 127 | u = uuid7() 128 | tid = TypeID.from_uuid(suffix=u, prefix="user") 129 | assert str(tid).startswith("user_") 130 | ``` 131 | 132 | This is the intended storage model: 133 | 134 | > **Store UUIDs in the database. 135 | > Expose TypeIDs at the application boundary.** 136 | 137 | You get the benefits of UUIDs at rest and the benefits of TypeIDs everywhere else. 138 | 139 | ## Using the CLI 140 | 141 | TypeID also ships with a command-line interface. 142 | 143 | If you installed the CLI extra: 144 | 145 | ```bash 146 | pip install "typeid-python[cli]" 147 | ``` 148 | 149 | You can generate identifiers directly: 150 | 151 | ```bash 152 | typeid new -p user 153 | ``` 154 | 155 | You can inspect existing identifiers: 156 | 157 | ```bash 158 | typeid decode user_01h45ytscbebyvny4gc8cr8ma2 159 | ``` 160 | 161 | And you can convert between UUIDs and TypeIDs: 162 | 163 | ```bash 164 | typeid encode --prefix user 165 | ``` 166 | 167 | ## Explaining an identifier 168 | 169 | The most distinctive feature of this implementation is the ability to explain identifiers. 170 | 171 | ```bash 172 | typeid explain user_01h45ytscbebyvny4gc8cr8ma2 173 | ``` 174 | 175 | This command inspects the identifier and reports: 176 | 177 | * whether it is valid 178 | * what prefix it uses 179 | * which UUID it represents 180 | * when it was created 181 | 182 | This works even without any configuration and never crashes on invalid input. 183 | 184 | ## What to read next 185 | 186 | If you want to understand *why* TypeID works the way it does, read **Concepts**. 187 | 188 | If you want to understand *what explain can do*, read **Explain**. 189 | 190 | If you want integration details or API reference material, those sections are available as well. 191 | 192 | At this point, you already know enough to start using TypeID in a real project. 193 | -------------------------------------------------------------------------------- /typeid/cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | import click 5 | from uuid6 import UUID 6 | 7 | from typeid import TypeID, base32, from_uuid, get_prefix_and_suffix 8 | from typeid.explain.discovery import discover_schema_path 9 | from typeid.explain.engine import explain as explain_engine 10 | from typeid.explain.formatters import format_explanation_json, format_explanation_pretty 11 | from typeid.explain.registry import load_registry, make_lookup 12 | 13 | 14 | @click.group() 15 | def cli(): 16 | # Root CLI command group. 17 | # This acts as the entry point for all subcommands. 18 | pass 19 | 20 | 21 | @cli.command() 22 | @click.option("-p", "--prefix") 23 | def new(prefix: Optional[str] = None) -> None: 24 | """ 25 | Generate a new TypeID. 26 | 27 | If a prefix is provided, it will be validated and included in the output. 28 | If no prefix is provided, a prefix-less TypeID is generated. 29 | """ 30 | typeid = TypeID(prefix=prefix) 31 | click.echo(str(typeid)) 32 | 33 | 34 | @cli.command() 35 | @click.argument("uuid") 36 | @click.option("-p", "--prefix") 37 | def encode(uuid: str, prefix: Optional[str] = None) -> None: 38 | """ 39 | Encode an existing UUID into a TypeID. 40 | 41 | This command is intended for cases where UUIDs already exist 42 | (e.g. stored in a database) and need to be represented as TypeIDs. 43 | """ 44 | uuid_obj = UUID(uuid) 45 | typeid = from_uuid(suffix=uuid_obj, prefix=prefix) 46 | 47 | click.echo(str(typeid)) 48 | 49 | 50 | @cli.command() 51 | @click.argument("encoded") 52 | def decode(encoded: str) -> None: 53 | """ 54 | Decode a TypeID into its components. 55 | 56 | This extracts: 57 | - the prefix (if any) 58 | - the underlying UUID 59 | 60 | This command is primarily intended for inspection and debugging. 61 | """ 62 | 63 | prefix, suffix = get_prefix_and_suffix(encoded) 64 | decoded_bytes = bytes(base32.decode(suffix)) 65 | 66 | uuid = UUID(bytes=decoded_bytes) 67 | 68 | click.echo(f"type: {prefix}") 69 | click.echo(f"uuid: {uuid}") 70 | 71 | 72 | @cli.command() 73 | @click.argument("encoded") 74 | @click.option( 75 | "--schema", 76 | "schema_path", 77 | type=click.Path(exists=True, dir_okay=False, path_type=str), 78 | required=False, 79 | help="Path to TypeID schema file (JSON, or YAML if PyYAML is installed). " 80 | "If omitted, TypeID will try to discover a schema automatically.", 81 | ) 82 | @click.option( 83 | "--json", 84 | "as_json", 85 | is_flag=True, 86 | help="Output machine-readable JSON.", 87 | ) 88 | @click.option( 89 | "--no-schema", 90 | is_flag=True, 91 | help="Disable schema lookup (derived facts only).", 92 | ) 93 | @click.option( 94 | "--no-links", 95 | is_flag=True, 96 | help="Disable link template rendering.", 97 | ) 98 | def explain( 99 | encoded: str, 100 | schema_path: Optional[str], 101 | as_json: bool, 102 | no_schema: bool, 103 | no_links: bool, 104 | ) -> None: 105 | """ 106 | Explain a TypeID: parse/validate it, derive facts (uuid, created_at), 107 | and optionally enrich explanation from a user-provided schema. 108 | """ 109 | enable_schema = not no_schema 110 | enable_links = not no_links 111 | 112 | schema_lookup = None 113 | warnings: list[str] = [] 114 | 115 | # Load schema (optional) 116 | if enable_schema: 117 | resolved_path = None 118 | 119 | if schema_path: 120 | resolved_path = schema_path 121 | else: 122 | discovery = discover_schema_path() 123 | if discovery.path is not None: 124 | resolved_path = str(discovery.path) 125 | # If env var was set but invalid, discovery returns source info; 126 | # we keep CLI robust and simply proceed without schema. 127 | 128 | if resolved_path: 129 | result = load_registry(Path(resolved_path)) 130 | 131 | if result.registry is not None: 132 | schema_lookup = make_lookup(result.registry) 133 | else: 134 | if result.error is not None: 135 | warnings.append(f"Schema load failed: {result.error.message}") 136 | 137 | # Build explanation (never raises on normal errors) 138 | exp = explain_engine( 139 | encoded, 140 | schema_lookup=schema_lookup, 141 | enable_schema=enable_schema, 142 | enable_links=enable_links, 143 | ) 144 | 145 | # Surface schema-load warnings (if any) 146 | if warnings: 147 | exp.warnings.extend(warnings) 148 | 149 | # Print 150 | if as_json: 151 | click.echo(format_explanation_json(exp)) 152 | else: 153 | click.echo(format_explanation_pretty(exp)) 154 | 155 | 156 | if __name__ == "__main__": 157 | cli() 158 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | TypeID exists because identifiers are used for much more than uniqueness. 4 | 5 | They appear in logs, URLs, dashboards, tickets, alerts, database rows, and Slack messages. 6 | Yet most identifiers—especially UUIDs—are opaque. They carry no meaning, no context, and no affordances for inspection. 7 | 8 | TypeID is an attempt to fix that, without breaking the properties that make UUIDs useful. 9 | 10 | --- 11 | 12 | ## TypeID as an identifier 13 | 14 | A TypeID is a string identifier composed of two independent parts: 15 | 16 | ```text 17 | _ 18 | ``` 19 | 20 | For example: 21 | 22 | ```text 23 | user_01h45ytscbebyvny4gc8cr8ma2 24 | ``` 25 | 26 | The **suffix** is the identity. It is globally unique and backed by a UUID. 27 | The **prefix** is context. It tells a human (and tooling) what kind of thing the identifier refers to. 28 | 29 | Crucially, the prefix does *not* participate in uniqueness. Two TypeIDs with different prefixes but the same suffix represent the same underlying UUID. The prefix is a semantic layer, not a storage primitive. 30 | 31 | This separation is intentional. It allows TypeID to add meaning without interfering with existing UUID-based systems. 32 | 33 | ## UUID compatibility by design 34 | 35 | TypeID is not a replacement for UUIDs. It is a layer on top of them. 36 | 37 | Every TypeID corresponds to exactly one UUID, and that UUID can always be extracted or reconstructed. This makes it possible to: 38 | 39 | * store native UUIDs in databases 40 | * use existing UUID indexes and constraints 41 | * introduce TypeID without schema migrations 42 | * roll back or interoperate with systems that know nothing about TypeID 43 | 44 | The recommended pattern is simple: **store UUIDs, expose TypeIDs**. 45 | 46 | TypeID lives at the boundaries of your system—APIs, logs, tooling—not at the lowest storage level. 47 | 48 | ## Sortability and time 49 | 50 | The suffix used by TypeID is time-sortable. When two TypeIDs are compared lexicographically, the one created earlier sorts before the one created later. 51 | 52 | This property is not about business semantics; it is about ergonomics. 53 | 54 | Sortable identifiers make logs readable, pagination predictable, and debugging less frustrating. When you scan a list of IDs, you can usually infer their relative age without additional metadata. 55 | 56 | There are important limits to this property. Ordering reflects **generation time**, not transaction time or business events. Clock skew and distributed systems still exist. TypeID does not attempt to impose global ordering or causality. 57 | 58 | Sortability is a convenience, not a guarantee. 59 | 60 | ## Explainability 61 | 62 | Once an identifier carries structure, it becomes possible to inspect it. 63 | 64 | TypeID can be *explained*: given a string, the system can determine whether it is a valid TypeID, extract its UUID, derive its creation time, and report these facts in a structured way. 65 | 66 | This is useful in places where identifiers normally appear as dead text: 67 | 68 | * logs 69 | * error messages 70 | * database dumps 71 | * incident reports 72 | * CI output 73 | 74 | Explainability is designed to be safe. Invalid identifiers do not crash the system. Unknown prefixes are accepted. Each identifier is handled independently, which makes batch processing robust. 75 | 76 | ## Schemas as optional meaning 77 | 78 | Derived facts are always available, but they are not always enough. In real systems, prefixes often correspond to domain concepts: users, orders, events, aggregates. 79 | 80 | Schemas allow you to describe that meaning explicitly. 81 | 82 | A schema can say that a `user` ID represents an end-user account, that it contains PII, that it is owned by a particular team, or that related logs and dashboards can be found at specific URLs. 83 | 84 | Schemas are optional and additive. If a schema is missing, outdated, or invalid, TypeID still works. The identifier does not become invalid because metadata could not be loaded. 85 | 86 | This separation keeps the core identifier system stable while allowing richer interpretation where it is useful. 87 | 88 | ## Unknown and invalid identifiers 89 | 90 | TypeID makes a clear distinction between identifiers that are **invalid** and those that are merely **unknown**. 91 | 92 | An invalid identifier is structurally wrong: it cannot be parsed or decoded. 93 | An unknown identifier is structurally valid, but its prefix is not recognized by any schema. 94 | 95 | Unknown identifiers are first-class citizens. They allow systems to evolve independently and avoid tight coupling between producers and consumers of IDs. 96 | 97 | This distinction is essential for forward compatibility and safe tooling. 98 | 99 | ## A note on safety 100 | 101 | TypeID is deliberately conservative. 102 | 103 | It does not infer meaning. 104 | It does not mutate state. 105 | It does not enforce authorization. 106 | It does not treat identifiers as secrets. 107 | 108 | Its goal is to make identifiers **more understandable**. 109 | 110 | ## Closing thoughts 111 | 112 | TypeID treats identifiers as part of the system’s interface, not as incidental implementation details. 113 | 114 | By combining UUID compatibility, time-based sortability, and structured explainability, it aims to make everyday engineering tasks—debugging, inspection, reasoning—slightly less painful. 115 | 116 | Identifiers should not be mysterious. They should be inspectable, understandable, and boring in the best possible way. 117 | -------------------------------------------------------------------------------- /typeid/explain/model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Data models for the `typeid explain` feature. 3 | 4 | Design goals: 5 | - Additive, non-breaking: does not modify existing TypeID behavior. 6 | - Stable-ish: callers can rely on these dataclasses, but we keep flexibility 7 | by storing schema/derived sections as dicts (schema evolves without breaking). 8 | - Provenance: every top-level field can be tagged by where it came from. 9 | """ 10 | 11 | from dataclasses import dataclass, field 12 | from datetime import datetime 13 | from enum import Enum 14 | from typing import Any, Dict, List, Optional 15 | 16 | 17 | class Provenance(str, Enum): 18 | """Where a piece of information came from.""" 19 | 20 | DERIVED_FROM_ID = "derived_from_id" 21 | SCHEMA = "schema" 22 | EXTERNAL = "external" 23 | UNKNOWN = "unknown" 24 | 25 | 26 | @dataclass(frozen=True, slots=True) 27 | class ParseError: 28 | """Represents a recoverable parse/validation error.""" 29 | 30 | code: str 31 | message: str 32 | 33 | 34 | @dataclass(frozen=True, slots=True) 35 | class ParsedTypeID: 36 | """ 37 | Facts extracted from the TypeID string without any schema lookup. 38 | 39 | Notes: 40 | - `prefix` is the full prefix as per TypeID spec (may contain underscores). 41 | - `suffix` is the encoded UUIDv7 portion (base32 string). 42 | - `uuid` and `created_at` are *derived* from suffix if possible. 43 | """ 44 | 45 | raw: str 46 | prefix: Optional[str] 47 | suffix: Optional[str] 48 | 49 | valid: bool 50 | errors: List[ParseError] = field(default_factory=list) 51 | 52 | # Derived (best-effort) 53 | uuid: Optional[str] = None # keep as string to avoid uuid/uuid6 typing bleed 54 | created_at: Optional[datetime] = None 55 | sortable: Optional[bool] = None # TypeIDs w/ UUIDv7 are typically sortable 56 | 57 | 58 | @dataclass(frozen=True, slots=True) 59 | class TypeSchema: 60 | """ 61 | Schema info for a given prefix, loaded from a registry file. 62 | 63 | This is intentionally flexible to keep the schema format evolving without 64 | breaking the Python API: we store raw dict and also normalize a few 65 | commonly-used fields for nicer UX. 66 | """ 67 | 68 | prefix: str 69 | raw: Dict[str, Any] = field(default_factory=dict) 70 | 71 | # Common optional fields (convenience) 72 | name: Optional[str] = None 73 | description: Optional[str] = None 74 | owner_team: Optional[str] = None 75 | pii: Optional[bool] = None 76 | retention: Optional[str] = None 77 | 78 | # Link templates (e.g. {"logs": "https://...q={id}"}) 79 | links: Dict[str, str] = field(default_factory=dict) 80 | 81 | 82 | @dataclass(frozen=True, slots=True) 83 | class Explanation: 84 | """ 85 | Final explanation object produced by the explain engine. 86 | 87 | Sections: 88 | - parsed: always present (even if invalid; fields may be None) 89 | - schema: may be None if no schema found or schema loading disabled 90 | - derived: small dict for extra derived facts (extensible) 91 | - links: rendered links (from schema templates), safe for display 92 | - provenance: per-field provenance labels for transparency 93 | """ 94 | 95 | id: str 96 | valid: bool 97 | 98 | parsed: ParsedTypeID 99 | schema: Optional[TypeSchema] = None 100 | 101 | # Additional derived facts that aren't worth dedicated fields yet 102 | derived: Dict[str, Any] = field(default_factory=dict) 103 | 104 | # Rendered (not templates) links 105 | links: Dict[str, str] = field(default_factory=dict) 106 | 107 | # Field -> provenance label; keep keys simple (e.g. "created_at", "retention") 108 | provenance: Dict[str, Provenance] = field(default_factory=dict) 109 | 110 | # Non-fatal warnings (e.g. schema loaded but link template failed) 111 | warnings: List[str] = field(default_factory=list) 112 | 113 | # Errors copied from parsed.errors for convenience (and future external errors) 114 | errors: List[ParseError] = field(default_factory=list) 115 | 116 | def to_dict(self) -> Dict[str, Any]: 117 | """ 118 | Convert to a JSON-serializable dict. 119 | 120 | We avoid serializing complex objects directly (datetime, Enums) without 121 | conversion to keep `--json` output stable and easy to consume. 122 | """ 123 | parsed = { 124 | "raw": self.parsed.raw, 125 | "prefix": self.parsed.prefix, 126 | "suffix": self.parsed.suffix, 127 | "valid": self.parsed.valid, 128 | "errors": [e.__dict__ for e in self.parsed.errors], 129 | "uuid": self.parsed.uuid, 130 | "created_at": self.parsed.created_at.isoformat() if self.parsed.created_at else None, 131 | "sortable": self.parsed.sortable, 132 | } 133 | 134 | schema = None 135 | if self.schema is not None: 136 | schema = { 137 | "prefix": self.schema.prefix, 138 | "name": self.schema.name, 139 | "description": self.schema.description, 140 | "owner_team": self.schema.owner_team, 141 | "pii": self.schema.pii, 142 | "retention": self.schema.retention, 143 | "links": dict(self.schema.links), 144 | "raw": dict(self.schema.raw), 145 | } 146 | 147 | return { 148 | "id": self.id, 149 | "valid": self.valid, 150 | "parsed": parsed, 151 | "derived": dict(self.derived), 152 | "schema": schema, 153 | "links": dict(self.links), 154 | "provenance": {k: str(v.value) for k, v in self.provenance.items()}, 155 | "warnings": list(self.warnings), 156 | "errors": [e.__dict__ for e in self.errors], 157 | } 158 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | akhundov1murad@gmail.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /tests/explain/test_engine.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | from typeid import TypeID 5 | from typeid.explain.engine import explain 6 | from typeid.explain.model import Provenance, TypeSchema 7 | 8 | 9 | def _make_valid_id(prefix: str = "usr") -> str: 10 | return str(TypeID(prefix=prefix)) 11 | 12 | 13 | def test_explain_valid_id_without_schema_has_derived_fields(): 14 | tid = _make_valid_id("usr") 15 | 16 | exp = explain(tid, schema_lookup=None, enable_schema=True, enable_links=True) 17 | 18 | assert exp.id == tid 19 | assert exp.valid is True 20 | assert exp.parsed.valid is True 21 | assert exp.parsed.prefix == "usr" 22 | assert exp.parsed.suffix is not None 23 | assert exp.parsed.uuid is not None 24 | 25 | # UUIDv7 timestamp should be derivable 26 | assert exp.parsed.created_at is not None 27 | assert isinstance(exp.parsed.created_at, datetime) 28 | assert exp.parsed.created_at.tzinfo is not None 29 | 30 | # Provenance should mark derived fields 31 | assert exp.provenance["prefix"] == Provenance.DERIVED_FROM_ID 32 | assert exp.provenance["suffix"] == Provenance.DERIVED_FROM_ID 33 | assert exp.provenance["uuid"] == Provenance.DERIVED_FROM_ID 34 | assert exp.provenance["created_at"] == Provenance.DERIVED_FROM_ID 35 | 36 | 37 | def test_explain_invalid_id_returns_valid_false_and_errors(): 38 | exp = explain("not_a_typeid", schema_lookup=None) 39 | 40 | assert exp.valid is False 41 | assert exp.parsed.valid is False 42 | assert exp.errors, "Should include parse/validation errors" 43 | assert any(e.code in {"invalid_typeid", "parse_error"} for e in exp.errors) 44 | 45 | 46 | def test_explain_best_effort_split_on_invalid_but_contains_underscore(): 47 | # invalid suffix, but prefix/suffix should still be split best-effort 48 | exp = explain("usr_badSuffix", schema_lookup=None) 49 | 50 | assert exp.valid is False 51 | assert exp.parsed.prefix == "usr" 52 | assert exp.parsed.suffix == "badSuffix" 53 | 54 | 55 | def test_explain_schema_lookup_applies_schema_fields_and_provenance(): 56 | tid = _make_valid_id("ord") 57 | 58 | schema = TypeSchema( 59 | prefix="ord", 60 | raw={ 61 | "name": "Order", 62 | "owner_team": "commerce-platform", 63 | "pii": False, 64 | "retention": "7y", 65 | }, 66 | name="Order", 67 | owner_team="commerce-platform", 68 | pii=False, 69 | retention="7y", 70 | links={}, 71 | ) 72 | 73 | def lookup(prefix: str): 74 | assert prefix == "ord" 75 | return schema 76 | 77 | exp = explain(tid, schema_lookup=lookup, enable_schema=True, enable_links=False) 78 | 79 | assert exp.valid is True 80 | assert exp.schema is not None 81 | assert exp.schema.name == "Order" 82 | assert exp.schema.owner_team == "commerce-platform" 83 | assert exp.provenance["name"] == Provenance.SCHEMA 84 | assert exp.provenance["owner_team"] == Provenance.SCHEMA 85 | assert exp.provenance["pii"] == Provenance.SCHEMA 86 | assert exp.provenance["retention"] == Provenance.SCHEMA 87 | 88 | 89 | def test_explain_schema_lookup_exception_does_not_crash_and_adds_warning(): 90 | tid = _make_valid_id("usr") 91 | 92 | def lookup(_prefix: str): 93 | raise RuntimeError("boom") 94 | 95 | exp = explain(tid, schema_lookup=lookup, enable_schema=True) 96 | 97 | assert exp.valid is True 98 | assert exp.schema is None 99 | assert any("Schema lookup failed" in w for w in exp.warnings) 100 | 101 | 102 | def test_explain_disable_schema_skips_lookup(): 103 | tid = _make_valid_id("usr") 104 | 105 | called = {"n": 0} 106 | 107 | def lookup(_prefix: str): 108 | called["n"] += 1 109 | return None 110 | 111 | exp = explain(tid, schema_lookup=lookup, enable_schema=False) 112 | 113 | assert exp.valid is True 114 | assert exp.schema is None 115 | assert called["n"] == 0 116 | 117 | 118 | def test_explain_link_rendering_basic_placeholders(): 119 | tid = _make_valid_id("usr") 120 | 121 | schema = TypeSchema( 122 | prefix="usr", 123 | raw={}, 124 | name="User", 125 | links={ 126 | "logs": "https://logs.local/search?q={id}", 127 | "trace": "https://trace.local/?id={id}&uuid={uuid}", 128 | }, 129 | ) 130 | 131 | exp = explain(tid, schema_lookup=lambda p: schema if p == "usr" else None, enable_schema=True, enable_links=True) 132 | 133 | assert "logs" in exp.links 134 | assert tid in exp.links["logs"] 135 | assert "trace" in exp.links 136 | assert tid in exp.links["trace"] 137 | assert (exp.parsed.uuid or "") in exp.links["trace"] 138 | 139 | assert exp.provenance["links.logs"] == Provenance.SCHEMA 140 | assert exp.provenance["links.trace"] == Provenance.SCHEMA 141 | 142 | 143 | def test_explain_link_rendering_unknown_placeholder_is_left_intact(): 144 | tid = _make_valid_id("usr") 145 | 146 | schema = TypeSchema( 147 | prefix="usr", 148 | raw={}, 149 | links={"x": "http://x/{does_not_exist}/{id}"}, 150 | ) 151 | 152 | exp = explain(tid, schema_lookup=lambda p: schema if p == "usr" else None) 153 | 154 | assert exp.links["x"].startswith("http://x/") 155 | assert "{does_not_exist}" in exp.links["x"] 156 | assert tid in exp.links["x"] 157 | 158 | 159 | def test_explain_link_rendering_non_string_template_is_skipped_with_warning(): 160 | tid = _make_valid_id("usr") 161 | 162 | schema = TypeSchema( 163 | prefix="usr", 164 | raw={}, 165 | links={"bad": 123}, # type: ignore 166 | ) 167 | 168 | exp = explain(tid, schema_lookup=lambda p: schema if p == "usr" else None) 169 | 170 | assert "bad" not in exp.links 171 | assert any("not a string" in w.lower() for w in exp.warnings) 172 | 173 | 174 | def test_to_dict_is_json_serializable(): 175 | tid = _make_valid_id("usr") 176 | exp = explain(tid) 177 | 178 | payload = exp.to_dict() 179 | json.dumps(payload) # should not raise 180 | -------------------------------------------------------------------------------- /docs/explain.md: -------------------------------------------------------------------------------- 1 | # Explain 2 | 3 | The `explain` feature exists because identifiers rarely live in isolation. 4 | 5 | They appear in logs, stack traces, database rows, monitoring dashboards, tickets, and CI output. When something goes wrong, engineers are often faced with a string that looks like an identifier and the implicit question: 6 | 7 | > “What is this ID?” 8 | 9 | In most systems, that question cannot be answered without code, database access, or tribal knowledge. `typeid explain` is an attempt to make identifiers themselves carry enough structure to answer that question directly. 10 | 11 | --- 12 | 13 | ## What “explain” means 14 | 15 | To explain a TypeID means to inspect it and produce **structured information** about it. 16 | 17 | This includes: 18 | - whether the identifier is structurally valid 19 | - what prefix it uses 20 | - which UUID it represents 21 | - when it was created 22 | - whether it is sortable 23 | 24 | These facts are derived purely from the identifier itself. They require no configuration, no network access, and no external state. 25 | 26 | This is the baseline: explanation always works, even offline. 27 | 28 | ## Derived facts 29 | 30 | Every explanation starts with derived facts. 31 | 32 | Given a string, the system attempts to parse it as a TypeID. If parsing succeeds, the result includes: 33 | - the parsed prefix (or absence of one) 34 | - the underlying UUID 35 | - the approximate creation time 36 | - sortability guarantees 37 | 38 | If parsing fails, the explanation still succeeds — it simply reports that the identifier is invalid and why. 39 | 40 | This distinction is important. `explain` is not a validator that throws errors; it is an inspection tool that always returns an answer. 41 | 42 | ## Invalid vs unknown 43 | 44 | An invalid identifier is one that cannot be parsed at all. Its structure is wrong, its encoding is broken, or it does not conform to the TypeID format. 45 | 46 | An unknown identifier, on the other hand, may be perfectly valid but use a prefix that the system does not recognize. 47 | 48 | `explain` treats these cases very differently. 49 | 50 | Invalid identifiers are reported as invalid. 51 | Unknown identifiers are reported as valid, but unrecognized. 52 | 53 | This distinction allows systems to evolve independently. Producers of IDs can introduce new prefixes without breaking consumers, and tooling can remain forward-compatible. 54 | 55 | ## Schemas as optional meaning 56 | 57 | Derived facts answer questions about structure and origin, but they do not answer questions about *semantics*. 58 | 59 | What does a `user` ID represent? 60 | Does it contain PII? 61 | Which team owns it? 62 | Where should an engineer look next? 63 | 64 | Schemas exist to answer these questions. 65 | 66 | ```yaml 67 | schema_version: 1 68 | types: 69 | user: 70 | name: User 71 | description: End-user account 72 | owner_team: identity-platform 73 | pii: true 74 | retention: 7y 75 | services: [user-service, auth-service] 76 | storage: 77 | primary: 78 | kind: postgres 79 | table: users 80 | shard_by: tenant_id 81 | events: [user.created, user.updated, user.deleted] 82 | policies: 83 | delete: 84 | allowed: false 85 | reason: GDPR retention policy 86 | links: 87 | docs: "https://docs.company/entities/user" 88 | logs: "https://logs.company/search?q={id}" 89 | trace: "https://traces.company/?q={id}" 90 | admin: "https://admin.company/users/{id}" 91 | ``` 92 | 93 | A schema is a declarative description of what a prefix means. It can attach human-readable descriptions, ownership information, retention rules, links to logs or dashboards, and other metadata that helps people reason about identifiers in context. 94 | 95 | Schemas are explicitly optional. They are not required for explanation, and they never affect structural validity. 96 | 97 | If a schema is present and valid, `explain` enriches the output. 98 | If a schema is missing, outdated, or invalid, explanation still works using derived facts only. 99 | 100 | ## Schema discovery 101 | 102 | To make schemas practical, `explain` supports discovery. 103 | 104 | Rather than requiring every invocation to specify a schema path explicitly, the system looks for schemas in well-defined locations, such as environment variables, the current working directory, or a user-level configuration directory. 105 | 106 | This allows schemas to be shared across tools, CI jobs, and developer machines without tight coupling. 107 | 108 | Discovery failure is not an error. It is simply reported as “schema not found”. 109 | 110 | ## Links and affordances 111 | 112 | One of the most practical uses of schemas is attaching links. 113 | 114 | A schema can define URL templates for logs, traces, admin tools, dashboards, or documentation. During explanation, these templates are expanded using the identifier being explained. 115 | 116 | This turns an identifier into a navigational object: from an ID, you can jump directly to relevant systems without knowing how URLs are constructed. 117 | 118 | This is especially useful during incident response and debugging, where speed and clarity matter. 119 | 120 | ## Batch explanation 121 | 122 | Explanation is designed to scale beyond single identifiers. 123 | 124 | Batch explanation allows many IDs to be processed independently. One invalid identifier does not affect others. Partial results are always produced. 125 | 126 | This makes `explain` suitable for: 127 | - CI checks 128 | - offline analysis 129 | - reporting 130 | - log and data pipeline inspection 131 | 132 | Machine-readable output formats make it easy to integrate with other tooling. 133 | 134 | ## Safety and non-goals 135 | 136 | The `explain` feature is intentionally conservative. 137 | 138 | It does not: 139 | - mutate data 140 | - enforce policy 141 | - make authorization decisions 142 | - infer meaning beyond what is explicitly declared 143 | 144 | Schemas describe intent; they do not impose it. 145 | 146 | Identifiers remain identifiers. `explain` helps humans and tools understand them, nothing more. 147 | 148 | ## Mental model 149 | 150 | A useful way to think about `typeid explain` is: 151 | 152 | > **OpenAPI, but for identifiers instead of HTTP endpoints** 153 | 154 | It provides a shared, inspectable contract for something that is otherwise opaque and informal. 155 | 156 | ## Closing 157 | 158 | Identifiers are part of a system’s interface, whether we acknowledge it or not. 159 | 160 | By making identifiers inspectable and explainable, TypeID aims to reduce friction in debugging, improve communication across teams, and make systems slightly easier to reason about — without sacrificing compatibility or safety. 161 | -------------------------------------------------------------------------------- /typeid/explain/registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Schema registry loader for `typeid explain`. 3 | 4 | This module loads a schema file (JSON by default, YAML optionally) and exposes 5 | a lookup function: prefix -> TypeSchema. 6 | 7 | Goals: 8 | - Non-breaking: schema is optional; failures are handled gracefully. 9 | - Minimal dependencies: JSON uses stdlib; YAML support is optional. 10 | - Future-proof: schema versioning with a light validation layer. 11 | 12 | Schema shape (v1) - JSON/YAML: 13 | { 14 | "schema_version": 1, 15 | "types": { 16 | "usr": { 17 | "name": "User", 18 | "description": "...", 19 | "owner_team": "...", 20 | "pii": true, 21 | "retention": "7y", 22 | "links": { 23 | "logs": "https://...q={id}", 24 | "trace": "https://...?id={id}" 25 | } 26 | } 27 | } 28 | } 29 | """ 30 | 31 | import json 32 | from dataclasses import dataclass 33 | from pathlib import Path 34 | from typing import Any, Dict, Optional, Tuple 35 | 36 | from .model import TypeSchema 37 | 38 | 39 | @dataclass(frozen=True, slots=True) 40 | class RegistryLoadError: 41 | code: str 42 | message: str 43 | 44 | 45 | @dataclass(frozen=True, slots=True) 46 | class RegistryLoadResult: 47 | registry: Optional["SchemaRegistry"] 48 | error: Optional[RegistryLoadError] = None 49 | 50 | 51 | class SchemaRegistry: 52 | """ 53 | In-memory registry of TypeSchema objects loaded from a schema file. 54 | 55 | Lookup is by full TypeID prefix (which may contain underscores). 56 | """ 57 | 58 | def __init__(self, *, schema_version: int, types: Dict[str, TypeSchema], source_path: Path): 59 | self.schema_version = schema_version 60 | self._types = types 61 | self.source_path = source_path 62 | 63 | def get(self, prefix: str) -> Optional[TypeSchema]: 64 | return self._types.get(prefix) 65 | 66 | def __contains__(self, prefix: str) -> bool: 67 | return prefix in self._types 68 | 69 | def __len__(self) -> int: 70 | return len(self._types) 71 | 72 | 73 | def load_registry(path: Path) -> RegistryLoadResult: 74 | """ 75 | Load a schema registry from the given path. 76 | 77 | Returns RegistryLoadResult: 78 | - registry != None on success 79 | - error != None on failure (never raises for normal user mistakes) 80 | """ 81 | try: 82 | data, fmt = _read_schema_file(path) 83 | except Exception as e: 84 | return RegistryLoadResult( 85 | registry=None, 86 | error=RegistryLoadError(code="read_failed", message=f"Failed to read schema: {e!s}"), 87 | ) 88 | 89 | if not isinstance(data, dict): 90 | return RegistryLoadResult( 91 | registry=None, 92 | error=RegistryLoadError(code="invalid_schema", message="Schema root must be an object/map."), 93 | ) 94 | 95 | schema_version = data.get("schema_version") 96 | if schema_version is None: 97 | return RegistryLoadResult( 98 | registry=None, 99 | error=RegistryLoadError(code="missing_schema_version", message="Schema missing 'schema_version'."), 100 | ) 101 | if not isinstance(schema_version, int): 102 | return RegistryLoadResult( 103 | registry=None, 104 | error=RegistryLoadError(code="invalid_schema_version", message="'schema_version' must be an integer."), 105 | ) 106 | if schema_version != 1: 107 | return RegistryLoadResult( 108 | registry=None, 109 | error=RegistryLoadError( 110 | code="unsupported_schema_version", 111 | message=f"Unsupported schema_version={schema_version}. Supported: 1.", 112 | ), 113 | ) 114 | 115 | types_raw = data.get("types") 116 | if types_raw is None: 117 | return RegistryLoadResult( 118 | registry=None, 119 | error=RegistryLoadError(code="missing_types", message="Schema missing 'types' map."), 120 | ) 121 | if not isinstance(types_raw, dict): 122 | return RegistryLoadResult( 123 | registry=None, 124 | error=RegistryLoadError(code="invalid_types", message="'types' must be an object/map."), 125 | ) 126 | 127 | types: Dict[str, TypeSchema] = {} 128 | for prefix, spec in types_raw.items(): 129 | if not isinstance(prefix, str) or not prefix: 130 | # skip invalid keys but don't fail entire load 131 | continue 132 | if not isinstance(spec, dict): 133 | # skip invalid type spec entries 134 | continue 135 | types[prefix] = _to_type_schema(prefix, spec) 136 | 137 | return RegistryLoadResult(registry=SchemaRegistry(schema_version=schema_version, types=types, source_path=path)) 138 | 139 | 140 | def make_lookup(registry: Optional[SchemaRegistry]): 141 | """ 142 | Convenience helper to make a schema_lookup callable for engine.explain(). 143 | 144 | Example: 145 | reg = load_registry(path).registry 146 | lookup = make_lookup(reg) 147 | explanation = explain(id, schema_lookup=lookup) 148 | """ 149 | 150 | def _lookup(prefix: str) -> Optional[TypeSchema]: 151 | if registry is None: 152 | return None 153 | return registry.get(prefix) 154 | 155 | return _lookup 156 | 157 | 158 | def _read_schema_file(path: Path) -> Tuple[Dict[str, Any], str]: 159 | """ 160 | Read schema file and parse it into a dict. 161 | 162 | Returns: 163 | (data, format) where format is 'json' or 'yaml' 164 | 165 | JSON is always supported. 166 | YAML is supported only if PyYAML is installed. 167 | """ 168 | suffix = path.suffix.lower() 169 | raw = path.read_text(encoding="utf-8") 170 | 171 | if suffix == ".json": 172 | return json.loads(raw), "json" 173 | 174 | if suffix in (".yaml", ".yml"): 175 | # Optional dependency 176 | try: 177 | import yaml # type: ignore 178 | except Exception as e: 179 | raise RuntimeError( 180 | "YAML schema requires optional dependency. " 181 | "Install PyYAML (or `typeid[yaml]` if you provide extras)." 182 | ) from e 183 | data = yaml.safe_load(raw) 184 | return data, "yaml" 185 | 186 | # If extension unknown, try JSON first for convenience. 187 | try: 188 | return json.loads(raw), "json" 189 | except Exception as e: 190 | raise RuntimeError( 191 | f"Unsupported schema file extension: {path.suffix!s} (supported: .json, .yaml, .yml)" 192 | ) from e 193 | 194 | 195 | def _to_type_schema(prefix: str, spec: Dict[str, Any]) -> TypeSchema: 196 | """ 197 | Normalize a raw type spec into TypeSchema. 198 | 199 | We keep `raw` for forward-compatibility but also extract a few common fields 200 | for nicer UX. 201 | """ 202 | links = spec.get("links") or {} 203 | if not isinstance(links, dict): 204 | links = {} 205 | 206 | # Extract common fields safely 207 | name = spec.get("name") 208 | description = spec.get("description") 209 | owner_team = spec.get("owner_team") 210 | pii = spec.get("pii") 211 | retention = spec.get("retention") 212 | 213 | return TypeSchema( 214 | prefix=prefix, 215 | raw=dict(spec), 216 | name=name if isinstance(name, str) else None, 217 | description=description if isinstance(description, str) else None, 218 | owner_team=owner_team if isinstance(owner_team, str) else None, 219 | pii=pii if isinstance(pii, bool) else None, 220 | retention=retention if isinstance(retention, str) else None, 221 | links={str(k): str(v) for k, v in links.items() if isinstance(k, str) and isinstance(v, str)}, 222 | ) 223 | -------------------------------------------------------------------------------- /typeid/explain/formatters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Formatting helpers for `typeid explain`. 3 | 4 | This module is intentionally small and dependency-free. 5 | It supports: 6 | - YAML-ish pretty output (human-friendly) 7 | - JSON output via Explanation.to_dict() (machine-friendly) 8 | 9 | It also provides a minimal "safe formatter" for link templates 10 | (kept here so CLI and engine can share behavior if needed). 11 | 12 | Note: This file does NOT require PyYAML. We output YAML-like text 13 | without claiming it's strict YAML. 14 | """ 15 | 16 | import json 17 | from datetime import datetime 18 | from typing import Any, Dict, List, Mapping, Optional 19 | 20 | from .model import Explanation, Provenance 21 | 22 | 23 | def format_explanation_pretty(exp: Explanation) -> str: 24 | """ 25 | Render an Explanation as readable YAML-ish text. 26 | 27 | We intentionally keep it stable-ish and human-friendly: 28 | - predictable section ordering 29 | - indentation 30 | - lists rendered as "- item" 31 | 32 | This is NOT guaranteed to be strict YAML; it is "YAML-like". 33 | For strict machine consumption, use JSON output. 34 | """ 35 | lines: List[str] = [] 36 | 37 | def add(line: str = "") -> None: 38 | lines.append(line) 39 | 40 | add(f"id: {exp.id}") 41 | add(f"valid: {str(exp.valid).lower()}") 42 | 43 | if exp.errors: 44 | add("errors:") 45 | for e in exp.errors: 46 | add(f" - code: {e.code}") 47 | add(f" message: {_quote_if_needed(e.message)}") 48 | 49 | add() 50 | add("parsed:") 51 | _emit_kv(lines, " ", "prefix", exp.parsed.prefix) 52 | _emit_kv(lines, " ", "suffix", exp.parsed.suffix) 53 | _emit_kv(lines, " ", "uuid", exp.parsed.uuid) 54 | _emit_kv(lines, " ", "created_at", _iso(exp.parsed.created_at)) 55 | _emit_kv(lines, " ", "sortable", exp.parsed.sortable) 56 | 57 | # Schema section 58 | add() 59 | add("schema:") 60 | if exp.schema is None: 61 | add(" found: false") 62 | else: 63 | add(" found: true") 64 | _emit_kv(lines, " ", "prefix", exp.schema.prefix) 65 | _emit_kv(lines, " ", "name", exp.schema.name) 66 | _emit_kv(lines, " ", "description", exp.schema.description) 67 | _emit_kv(lines, " ", "owner_team", exp.schema.owner_team) 68 | _emit_kv(lines, " ", "pii", exp.schema.pii) 69 | _emit_kv(lines, " ", "retention", exp.schema.retention) 70 | 71 | # Show extra raw keys (optional, but helpful) 72 | extra = _schema_extras(exp.schema.raw) 73 | if extra: 74 | add(" extra:") 75 | for k in sorted(extra.keys()): 76 | _emit_any(lines, " ", k, extra[k]) 77 | 78 | # Derived 79 | if exp.derived: 80 | add() 81 | add("derived:") 82 | for k in sorted(exp.derived.keys()): 83 | _emit_any(lines, " ", k, exp.derived[k]) 84 | 85 | # Links 86 | add() 87 | add("links:") 88 | if not exp.links: 89 | add(" {}") 90 | else: 91 | for k in sorted(exp.links.keys()): 92 | _emit_kv(lines, " ", k, exp.links[k]) 93 | 94 | # Provenance 95 | if exp.provenance: 96 | add() 97 | add("provenance:") 98 | for k in sorted(exp.provenance.keys()): 99 | prov = exp.provenance[k] 100 | add(f" {k}: {prov.value if isinstance(prov, Provenance) else str(prov)}") 101 | 102 | # Warnings 103 | if exp.warnings: 104 | add() 105 | add("warnings:") 106 | for w in exp.warnings: 107 | add(f" - {_quote_if_needed(w)}") 108 | 109 | return "\n".join(lines).rstrip() + "\n" 110 | 111 | 112 | def format_explanation_json(exp: Explanation, *, indent: int = 2) -> str: 113 | """ 114 | Render Explanation as JSON string. 115 | """ 116 | return json.dumps(exp.to_dict(), indent=indent, ensure_ascii=False) + "\n" 117 | 118 | 119 | class SafeFormatDict(dict): 120 | """dict that leaves unknown placeholders intact rather than raising KeyError.""" 121 | 122 | def __missing__(self, key: str) -> str: 123 | return "{" + key + "}" 124 | 125 | 126 | def render_template(template: str, mapping: Mapping[str, Any]) -> str: 127 | """ 128 | Render a template using str.format_map with SafeFormatDict. 129 | 130 | Unknown placeholders remain unchanged. 131 | """ 132 | safe = SafeFormatDict({k: _stringify(v) for k, v in mapping.items()}) 133 | return template.format_map(safe) 134 | 135 | 136 | def _iso(dt: Optional[datetime]) -> Optional[str]: 137 | return dt.isoformat() if dt else None 138 | 139 | 140 | def _emit_kv(lines: List[str], indent: str, key: str, value: Any) -> None: 141 | if value is None: 142 | lines.append(f"{indent}{key}: null") 143 | return 144 | if isinstance(value, bool): 145 | lines.append(f"{indent}{key}: {str(value).lower()}") 146 | return 147 | if isinstance(value, (int, float)): 148 | lines.append(f"{indent}{key}: {value}") 149 | return 150 | lines.append(f"{indent}{key}: {_quote_if_needed(str(value))}") 151 | 152 | 153 | def _emit_any(lines: List[str], indent: str, key: str, value: Any) -> None: 154 | """ 155 | Emit arbitrary JSON-y values in YAML-ish style. 156 | """ 157 | if value is None or isinstance(value, (str, bool, int, float)): 158 | _emit_kv(lines, indent, key, value) 159 | return 160 | 161 | if isinstance(value, list): 162 | lines.append(f"{indent}{key}:") 163 | if not value: 164 | lines.append(f"{indent} []") 165 | return 166 | for item in value: 167 | if isinstance(item, (str, int, float, bool)) or item is None: 168 | lines.append(f"{indent} - {_quote_if_needed(_stringify(item))}") 169 | else: 170 | # nested complex item: render as JSON inline 171 | lines.append(f"{indent} - {_quote_if_needed(json.dumps(item, ensure_ascii=False))}") 172 | return 173 | 174 | if isinstance(value, dict): 175 | lines.append(f"{indent}{key}:") 176 | if not value: 177 | lines.append(f"{indent} {{}}") 178 | return 179 | for k in sorted(value.keys(), key=lambda x: str(x)): 180 | _emit_any(lines, indent + " ", str(k), value[k]) 181 | return 182 | 183 | # Fallback: stringify 184 | _emit_kv(lines, indent, key, _stringify(value)) 185 | 186 | 187 | def _stringify(v: Any) -> str: 188 | if v is None: 189 | return "null" 190 | if isinstance(v, bool): 191 | return str(v).lower() 192 | if isinstance(v, (int, float)): 193 | return str(v) 194 | if isinstance(v, datetime): 195 | return v.isoformat() 196 | return str(v) 197 | 198 | 199 | def _quote_if_needed(s: str) -> str: 200 | """ 201 | Add quotes if the string contains characters that could confuse YAML-ish output. 202 | """ 203 | if s == "": 204 | return '""' 205 | # Minimal quoting rules for readability; not strict YAML. 206 | needs = any(ch in s for ch in [":", "#", "{", "}", "[", "]", ",", "\n", "\r", "\t"]) 207 | if s.strip() != s: 208 | needs = True 209 | if s.lower() in {"true", "false", "null", "none"}: 210 | needs = True 211 | if needs: 212 | escaped = s.replace("\\", "\\\\").replace('"', '\\"') 213 | return f'"{escaped}"' 214 | return s 215 | 216 | 217 | def _schema_extras(raw: Dict[str, Any]) -> Dict[str, Any]: 218 | """ 219 | Return schema keys excluding the ones we already print as normalized fields. 220 | """ 221 | exclude = { 222 | "name", 223 | "description", 224 | "owner_team", 225 | "pii", 226 | "retention", 227 | "links", 228 | } 229 | return {k: v for k, v in raw.items() if k not in exclude} 230 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/venv,python,visualstudiocode,pycharm 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=venv,python,visualstudiocode,pycharm 3 | 4 | ### PyCharm ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # AWS User-specific 16 | .idea/**/aws.xml 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/artifacts 39 | # .idea/compiler.xml 40 | # .idea/jarRepositories.xml 41 | # .idea/modules.xml 42 | # .idea/*.iml 43 | # .idea/modules 44 | # *.iml 45 | # *.ipr 46 | 47 | # CMake 48 | cmake-build-*/ 49 | 50 | # Mongo Explorer plugin 51 | .idea/**/mongoSettings.xml 52 | 53 | # File-based project format 54 | *.iws 55 | 56 | # IntelliJ 57 | out/ 58 | 59 | # mpeltonen/sbt-idea plugin 60 | .idea_modules/ 61 | 62 | # JIRA plugin 63 | atlassian-ide-plugin.xml 64 | 65 | # Cursive Clojure plugin 66 | .idea/replstate.xml 67 | 68 | # SonarLint plugin 69 | .idea/sonarlint/ 70 | 71 | # Crashlytics plugin (for Android Studio and IntelliJ) 72 | com_crashlytics_export_strings.xml 73 | crashlytics.properties 74 | crashlytics-build.properties 75 | fabric.properties 76 | 77 | # Editor-based Rest Client 78 | .idea/httpRequests 79 | 80 | # Android studio 3.1+ serialized cache file 81 | .idea/caches/build_file_checksums.ser 82 | 83 | ### PyCharm Patch ### 84 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 85 | 86 | # *.iml 87 | # modules.xml 88 | # .idea/misc.xml 89 | # *.ipr 90 | 91 | # Sonarlint plugin 92 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 93 | .idea/**/sonarlint/ 94 | 95 | # SonarQube Plugin 96 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 97 | .idea/**/sonarIssues.xml 98 | 99 | # Markdown Navigator plugin 100 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 101 | .idea/**/markdown-navigator.xml 102 | .idea/**/markdown-navigator-enh.xml 103 | .idea/**/markdown-navigator/ 104 | 105 | # Cache file creation bug 106 | # See https://youtrack.jetbrains.com/issue/JBR-2257 107 | .idea/$CACHE_FILE$ 108 | 109 | # CodeStream plugin 110 | # https://plugins.jetbrains.com/plugin/12206-codestream 111 | .idea/codestream.xml 112 | 113 | # Azure Toolkit for IntelliJ plugin 114 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 115 | .idea/**/azureSettings.xml 116 | 117 | ### Python ### 118 | # Byte-compiled / optimized / DLL files 119 | __pycache__/ 120 | *.py[cod] 121 | *$py.class 122 | 123 | # C extensions 124 | *.so 125 | 126 | # Distribution / packaging 127 | .Python 128 | build/ 129 | develop-eggs/ 130 | dist/ 131 | downloads/ 132 | eggs/ 133 | .eggs/ 134 | lib/ 135 | lib64/ 136 | parts/ 137 | sdist/ 138 | var/ 139 | wheels/ 140 | share/python-wheels/ 141 | *.egg-info/ 142 | .installed.cfg 143 | *.egg 144 | MANIFEST 145 | 146 | # PyInstaller 147 | # Usually these files are written by a python script from a template 148 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 149 | *.manifest 150 | *.spec 151 | 152 | # Installer logs 153 | pip-log.txt 154 | pip-delete-this-directory.txt 155 | 156 | # Unit test / coverage reports 157 | htmlcov/ 158 | .tox/ 159 | .nox/ 160 | .coverage 161 | .coverage.* 162 | .cache 163 | nosetests.xml 164 | coverage.xml 165 | *.cover 166 | *.py,cover 167 | .hypothesis/ 168 | .pytest_cache/ 169 | cover/ 170 | 171 | # Translations 172 | *.mo 173 | *.pot 174 | 175 | # Django stuff: 176 | *.log 177 | local_settings.py 178 | db.sqlite3 179 | db.sqlite3-journal 180 | 181 | # Flask stuff: 182 | instance/ 183 | .webassets-cache 184 | 185 | # Scrapy stuff: 186 | .scrapy 187 | 188 | # Sphinx documentation 189 | docs/_build/ 190 | 191 | # PyBuilder 192 | .pybuilder/ 193 | target/ 194 | 195 | # Jupyter Notebook 196 | .ipynb_checkpoints 197 | 198 | # IPython 199 | profile_default/ 200 | ipython_config.py 201 | 202 | # pyenv 203 | # For a library or package, you might want to ignore these files since the code is 204 | # intended to run in multiple environments; otherwise, check them in: 205 | # .python-version 206 | 207 | # pipenv 208 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 209 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 210 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 211 | # install all needed dependencies. 212 | #Pipfile.lock 213 | 214 | # poetry 215 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 216 | # This is especially recommended for binary packages to ensure reproducibility, and is more 217 | # commonly ignored for libraries. 218 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 219 | #poetry.lock 220 | 221 | # pdm 222 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 223 | #pdm.lock 224 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 225 | # in version control. 226 | # https://pdm.fming.dev/#use-with-ide 227 | .pdm.toml 228 | 229 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 230 | __pypackages__/ 231 | 232 | # Celery stuff 233 | celerybeat-schedule 234 | celerybeat.pid 235 | 236 | # SageMath parsed files 237 | *.sage.py 238 | 239 | # Environments 240 | .env 241 | .venv 242 | env/ 243 | venv/ 244 | ENV/ 245 | env.bak/ 246 | venv.bak/ 247 | 248 | # Spyder project settings 249 | .spyderproject 250 | .spyproject 251 | 252 | # Rope project settings 253 | .ropeproject 254 | 255 | # mkdocs documentation 256 | /site 257 | 258 | # mypy 259 | .mypy_cache/ 260 | .dmypy.json 261 | dmypy.json 262 | 263 | # Pyre type checker 264 | .pyre/ 265 | 266 | # pytype static type analyzer 267 | .pytype/ 268 | 269 | # Cython debug symbols 270 | cython_debug/ 271 | 272 | # PyCharm 273 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 274 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 275 | # and can be added to the global gitignore or merged into this file. For a more nuclear 276 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 277 | #.idea/ 278 | 279 | ### Python Patch ### 280 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 281 | poetry.toml 282 | 283 | # ruff 284 | .ruff_cache/ 285 | 286 | ### venv ### 287 | # Virtualenv 288 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 289 | [Bb]in 290 | [Ii]nclude 291 | [Ll]ib 292 | [Ll]ib64 293 | [Ll]ocal 294 | [Ss]cripts 295 | pyvenv.cfg 296 | pip-selfcheck.json 297 | 298 | ### VisualStudioCode ### 299 | .vscode/ 300 | 301 | # Local History for Visual Studio Code 302 | .history/ 303 | 304 | # Built Visual Studio Code Extensions 305 | *.vsix 306 | 307 | ### VisualStudioCode Patch ### 308 | # Ignore all local history of files 309 | .history 310 | .ionide 311 | 312 | # End of https://www.toptal.com/developers/gitignore/api/venv,python,visualstudiocode,pycharm 313 | 314 | .DS_Store 315 | -------------------------------------------------------------------------------- /typeid/base32.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from typeid.constants import SUFFIX_LEN 4 | 5 | ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" 6 | 7 | 8 | TABLE = [ 9 | 0xFF, 10 | 0xFF, 11 | 0xFF, 12 | 0xFF, 13 | 0xFF, 14 | 0xFF, 15 | 0xFF, 16 | 0xFF, 17 | 0xFF, 18 | 0xFF, 19 | 0xFF, 20 | 0xFF, 21 | 0xFF, 22 | 0xFF, 23 | 0xFF, 24 | 0xFF, 25 | 0xFF, 26 | 0xFF, 27 | 0xFF, 28 | 0xFF, 29 | 0xFF, 30 | 0xFF, 31 | 0xFF, 32 | 0xFF, 33 | 0xFF, 34 | 0xFF, 35 | 0xFF, 36 | 0xFF, 37 | 0xFF, 38 | 0xFF, 39 | 0xFF, 40 | 0xFF, 41 | 0xFF, 42 | 0xFF, 43 | 0xFF, 44 | 0xFF, 45 | 0xFF, 46 | 0xFF, 47 | 0xFF, 48 | 0xFF, 49 | 0xFF, 50 | 0xFF, 51 | 0xFF, 52 | 0xFF, 53 | 0xFF, 54 | 0xFF, 55 | 0xFF, 56 | 0xFF, 57 | 0x00, 58 | 0x01, 59 | 0x02, 60 | 0x03, 61 | 0x04, 62 | 0x05, 63 | 0x06, 64 | 0x07, 65 | 0x08, 66 | 0x09, 67 | 0xFF, 68 | 0xFF, 69 | 0xFF, 70 | 0xFF, 71 | 0xFF, 72 | 0xFF, 73 | 0xFF, 74 | 0x0A, 75 | 0x0B, 76 | 0x0C, 77 | 0x0D, 78 | 0x0E, 79 | 0x0F, 80 | 0x10, 81 | 0x11, 82 | 0xFF, 83 | 0x12, 84 | 0x13, 85 | 0xFF, 86 | 0x14, 87 | 0x15, 88 | 0xFF, 89 | 0x16, 90 | 0x17, 91 | 0x18, 92 | 0x19, 93 | 0x1A, 94 | 0xFF, 95 | 0x1B, 96 | 0x1C, 97 | 0x1D, 98 | 0x1E, 99 | 0x1F, 100 | 0xFF, 101 | 0xFF, 102 | 0xFF, 103 | 0xFF, 104 | 0xFF, 105 | 0xFF, 106 | 0x0A, 107 | 0x0B, 108 | 0x0C, 109 | 0x0D, 110 | 0x0E, 111 | 0x0F, 112 | 0x10, 113 | 0x11, 114 | 0xFF, 115 | 0x12, 116 | 0x13, 117 | 0xFF, 118 | 0x14, 119 | 0x15, 120 | 0xFF, 121 | 0x16, 122 | 0x17, 123 | 0x18, 124 | 0x19, 125 | 0x1A, 126 | 0xFF, 127 | 0x1B, 128 | 0x1C, 129 | 0x1D, 130 | 0x1E, 131 | 0x1F, 132 | 0xFF, 133 | 0xFF, 134 | 0xFF, 135 | 0xFF, 136 | 0xFF, 137 | 0xFF, 138 | 0xFF, 139 | 0xFF, 140 | 0xFF, 141 | 0xFF, 142 | 0xFF, 143 | 0xFF, 144 | 0xFF, 145 | 0xFF, 146 | 0xFF, 147 | 0xFF, 148 | 0xFF, 149 | 0xFF, 150 | 0xFF, 151 | 0xFF, 152 | 0xFF, 153 | 0xFF, 154 | 0xFF, 155 | 0xFF, 156 | 0xFF, 157 | 0xFF, 158 | 0xFF, 159 | 0xFF, 160 | 0xFF, 161 | 0xFF, 162 | 0xFF, 163 | 0xFF, 164 | 0xFF, 165 | 0xFF, 166 | 0xFF, 167 | 0xFF, 168 | 0xFF, 169 | 0xFF, 170 | 0xFF, 171 | 0xFF, 172 | 0xFF, 173 | 0xFF, 174 | 0xFF, 175 | 0xFF, 176 | 0xFF, 177 | 0xFF, 178 | 0xFF, 179 | 0xFF, 180 | 0xFF, 181 | 0xFF, 182 | 0xFF, 183 | 0xFF, 184 | 0xFF, 185 | 0xFF, 186 | 0xFF, 187 | 0xFF, 188 | 0xFF, 189 | 0xFF, 190 | 0xFF, 191 | 0xFF, 192 | 0xFF, 193 | 0xFF, 194 | 0xFF, 195 | 0xFF, 196 | 0xFF, 197 | 0xFF, 198 | 0xFF, 199 | 0xFF, 200 | 0xFF, 201 | 0xFF, 202 | 0xFF, 203 | 0xFF, 204 | 0xFF, 205 | 0xFF, 206 | 0xFF, 207 | 0xFF, 208 | 0xFF, 209 | 0xFF, 210 | 0xFF, 211 | 0xFF, 212 | 0xFF, 213 | 0xFF, 214 | 0xFF, 215 | 0xFF, 216 | 0xFF, 217 | 0xFF, 218 | 0xFF, 219 | 0xFF, 220 | 0xFF, 221 | 0xFF, 222 | 0xFF, 223 | 0xFF, 224 | 0xFF, 225 | 0xFF, 226 | 0xFF, 227 | 0xFF, 228 | 0xFF, 229 | 0xFF, 230 | 0xFF, 231 | 0xFF, 232 | 0xFF, 233 | 0xFF, 234 | 0xFF, 235 | 0xFF, 236 | 0xFF, 237 | 0xFF, 238 | 0xFF, 239 | 0xFF, 240 | 0xFF, 241 | 0xFF, 242 | 0xFF, 243 | 0xFF, 244 | 0xFF, 245 | 0xFF, 246 | 0xFF, 247 | 0xFF, 248 | 0xFF, 249 | 0xFF, 250 | 0xFF, 251 | 0xFF, 252 | 0xFF, 253 | 0xFF, 254 | 0xFF, 255 | 0xFF, 256 | 0xFF, 257 | 0xFF, 258 | 0xFF, 259 | 0xFF, 260 | 0xFF, 261 | 0xFF, 262 | 0xFF, 263 | 0xFF, 264 | 0xFF, 265 | ] 266 | 267 | 268 | def encode(src: List[int]) -> str: 269 | dst = [""] * SUFFIX_LEN 270 | 271 | if len(src) != 16: 272 | raise RuntimeError("Invalid length.") 273 | 274 | # 10 byte timestamp 275 | dst[0] = ALPHABET[(src[0] & 224) >> 5] 276 | dst[1] = ALPHABET[src[0] & 31] 277 | dst[2] = ALPHABET[(src[1] & 248) >> 3] 278 | dst[3] = ALPHABET[((src[1] & 7) << 2) | ((src[2] & 192) >> 6)] 279 | dst[4] = ALPHABET[(src[2] & 62) >> 1] 280 | dst[5] = ALPHABET[((src[2] & 1) << 4) | ((src[3] & 240) >> 4)] 281 | dst[6] = ALPHABET[((src[3] & 15) << 1) | ((src[4] & 128) >> 7)] 282 | dst[7] = ALPHABET[(src[4] & 124) >> 2] 283 | dst[8] = ALPHABET[((src[4] & 3) << 3) | ((src[5] & 224) >> 5)] 284 | dst[9] = ALPHABET[src[5] & 31] 285 | 286 | # 16 bytes of randomness 287 | dst[10] = ALPHABET[(src[6] & 248) >> 3] 288 | dst[11] = ALPHABET[((src[6] & 7) << 2) | ((src[7] & 192) >> 6)] 289 | dst[12] = ALPHABET[(src[7] & 62) >> 1] 290 | dst[13] = ALPHABET[((src[7] & 1) << 4) | ((src[8] & 240) >> 4)] 291 | dst[14] = ALPHABET[((src[8] & 15) << 1) | ((src[9] & 128) >> 7)] 292 | dst[15] = ALPHABET[(src[9] & 124) >> 2] 293 | dst[16] = ALPHABET[((src[9] & 3) << 3) | ((src[10] & 224) >> 5)] 294 | dst[17] = ALPHABET[src[10] & 31] 295 | dst[18] = ALPHABET[(src[11] & 248) >> 3] 296 | dst[19] = ALPHABET[((src[11] & 7) << 2) | ((src[12] & 192) >> 6)] 297 | dst[20] = ALPHABET[(src[12] & 62) >> 1] 298 | dst[21] = ALPHABET[((src[12] & 1) << 4) | ((src[13] & 240) >> 4)] 299 | dst[22] = ALPHABET[((src[13] & 15) << 1) | ((src[14] & 128) >> 7)] 300 | dst[23] = ALPHABET[(src[14] & 124) >> 2] 301 | dst[24] = ALPHABET[((src[14] & 3) << 3) | ((src[15] & 224) >> 5)] 302 | dst[25] = ALPHABET[src[15] & 31] 303 | 304 | return "".join(dst) 305 | 306 | 307 | def decode(s: str) -> List[int]: 308 | v = bytes(s, encoding="utf-8") 309 | 310 | if ( 311 | TABLE[v[0]] == 0xFF 312 | and TABLE[v[1]] == 0xFF 313 | and TABLE[v[2]] == 0xFF 314 | and TABLE[v[3]] == 0xFF 315 | and TABLE[v[4]] == 0xFF 316 | and TABLE[v[5]] == 0xFF 317 | and TABLE[v[6]] == 0xFF 318 | and TABLE[v[7]] == 0xFF 319 | and TABLE[v[8]] == 0xFF 320 | and TABLE[v[9]] == 0xFF 321 | and TABLE[v[10]] == 0xFF 322 | and TABLE[v[11]] == 0xFF 323 | and TABLE[v[12]] == 0xFF 324 | and TABLE[v[13]] == 0xFF 325 | and TABLE[v[14]] == 0xFF 326 | and TABLE[v[15]] == 0xFF 327 | and TABLE[v[16]] == 0xFF 328 | and TABLE[v[17]] == 0xFF 329 | and TABLE[v[18]] == 0xFF 330 | and TABLE[v[19]] == 0xFF 331 | and TABLE[v[20]] == 0xFF 332 | and TABLE[v[21]] == 0xFF 333 | and TABLE[v[22]] == 0xFF 334 | and TABLE[v[23]] == 0xFF 335 | and TABLE[v[24]] == 0xFF 336 | and TABLE[v[25]] == 0xFF 337 | ): 338 | raise RuntimeError("Invalid base32 character") 339 | 340 | typeid = [0] * 16 341 | 342 | # 6 bytes timestamp (48 bits) 343 | typeid[0] = (TABLE[v[0]] << 5) | TABLE[v[1]] 344 | typeid[1] = (TABLE[v[2]] << 3) | (TABLE[v[3]] >> 2) 345 | typeid[2] = ((TABLE[v[3]] & 3) << 6) | (TABLE[v[4]] << 1) | (TABLE[v[5]] >> 4) 346 | typeid[3] = ((TABLE[v[5]] & 15) << 4) | (TABLE[v[6]] >> 1) 347 | typeid[4] = ((TABLE[v[6]] & 1) << 7) | (TABLE[v[7]] << 2) | (TABLE[v[8]] >> 3) 348 | typeid[5] = ((TABLE[v[8]] & 7) << 5) | TABLE[v[9]] 349 | 350 | # 10 bytes of entropy (80 bits) 351 | typeid[6] = (TABLE[v[10]] << 3) | (TABLE[v[11]] >> 2) 352 | typeid[7] = ((TABLE[v[11]] & 3) << 6) | (TABLE[v[12]] << 1) | (TABLE[v[13]] >> 4) 353 | typeid[8] = ((TABLE[v[13]] & 15) << 4) | (TABLE[v[14]] >> 1) 354 | typeid[9] = ((TABLE[v[14]] & 1) << 7) | (TABLE[v[15]] << 2) | (TABLE[v[16]] >> 3) 355 | typeid[10] = ((TABLE[v[16]] & 7) << 5) | TABLE[v[17]] 356 | typeid[11] = (TABLE[v[18]] << 3) | (TABLE[v[19]] >> 2) 357 | typeid[12] = ((TABLE[v[19]] & 3) << 6) | (TABLE[v[20]] << 1) | (TABLE[v[21]] >> 4) 358 | typeid[13] = ((TABLE[v[21]] & 15) << 4) | (TABLE[v[22]] >> 1) 359 | typeid[14] = ((TABLE[v[22]] & 1) << 7) | (TABLE[v[23]] << 2) | (TABLE[v[24]] >> 3) 360 | typeid[15] = ((TABLE[v[24]] & 7) << 5) | TABLE[v[25]] 361 | 362 | return typeid 363 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeID Python 2 | 3 | 4 | Test 5 | 6 | 7 | Downloads 8 | 9 | 10 | Package version 11 | 12 | 13 | Supported Python versions 14 | 15 | 16 | ## A Python implementation of [TypeIDs](https://github.com/jetpack-io/typeid) using Python 17 | 18 | TypeIDs are a modern, **type-safe**, globally unique identifier based on the upcoming 19 | UUIDv7 standard. They provide a ton of nice properties that make them a great choice 20 | as the primary identifiers for your data in a database, APIs, and distributed systems. 21 | Read more about TypeIDs in their [spec](https://github.com/jetpack-io/typeid). 22 | 23 | This particular implementation provides an pip package that can be used by any Python project. 24 | 25 | ## Installation 26 | 27 | - Pip: 28 | 29 | ```console 30 | pip install typeid-python 31 | ``` 32 | 33 | - Uv: 34 | 35 | ```console 36 | uv add typeid-python 37 | ``` 38 | 39 | - Poetry: 40 | 41 | ```console 42 | poetry add typeid-python 43 | ``` 44 | 45 | ### Optional dependencies 46 | 47 | TypeID supports schema-based ID explanations using JSON (always available) and 48 | YAML (optional). 49 | 50 | To enable YAML support: 51 | 52 | ```console 53 | pip install typeid-python[yaml] 54 | ``` 55 | 56 | If the extra is not installed, JSON schemas will still work. 57 | 58 | ## Usage 59 | 60 | ### Basic 61 | 62 | - Create TypeID Instance: 63 | 64 | ```python 65 | from typeid import TypeID 66 | 67 | # Default TypeID (no prefix) 68 | typeid = TypeID() 69 | 70 | assert typeid.prefix == "" 71 | assert isinstance(typeid.suffix, str) 72 | assert len(typeid.suffix) > 0 # encoded UUIDv7 73 | 74 | # TypeID with prefix 75 | typeid = TypeID(prefix="user") 76 | 77 | assert typeid.prefix == "user" 78 | assert str(typeid).startswith("user_") 79 | ``` 80 | 81 | - Create TypeID from string: 82 | 83 | ```python 84 | from typeid import TypeID 85 | 86 | value = "user_01h45ytscbebyvny4gc8cr8ma2" 87 | typeid = TypeID.from_string(value) 88 | 89 | assert str(typeid) == value 90 | assert typeid.prefix == "user" 91 | ``` 92 | 93 | - Create TypeID from uuid7: 94 | 95 | ```python 96 | from typeid import TypeID 97 | from uuid6 import uuid7 98 | 99 | uuid = uuid7() 100 | prefix = "user" 101 | 102 | typeid = TypeID.from_uuid(prefix=prefix, suffix=uuid) 103 | 104 | assert typeid.prefix == prefix 105 | assert str(typeid).startswith(f"{prefix}_") 106 | 107 | ``` 108 | 109 | - Use pre-defined prefix: 110 | 111 | ```python 112 | from dataclasses import dataclass, field 113 | from typing import Literal 114 | from typeid import TypeID, typeid_factory 115 | 116 | UserID = TypeID[Literal["user"]] 117 | gen_user_id = typeid_factory("user") 118 | 119 | 120 | @dataclass 121 | class UserDTO: 122 | user_id: UserID = field(default_factory=gen_user_id) 123 | full_name: str = "A J" 124 | age: int = 18 125 | 126 | 127 | user = UserDTO() 128 | 129 | assert str(user.user_id).startswith("user_") 130 | ``` 131 | 132 | ### CLI-tool 133 | 134 | - Install dependencies: 135 | 136 | ```console 137 | pip install typeid-python[cli] 138 | ``` 139 | 140 | - To generate a new TypeID, run: 141 | 142 | ```console 143 | $ typeid new -p prefix 144 | prefix_01h2xcejqtf2nbrexx3vqjhp41 145 | ``` 146 | 147 | - To decode an existing TypeID into a UUID run: 148 | 149 | ```console 150 | $ typeid decode prefix_01h2xcejqtf2nbrexx3vqjhp41 151 | type: prefix 152 | uuid: 0188bac7-4afa-78aa-bc3b-bd1eef28d881 153 | ``` 154 | 155 | - And to encode an existing UUID into a TypeID run: 156 | 157 | ```console 158 | $ typeid encode 0188bac7-4afa-78aa-bc3b-bd1eef28d881 --prefix prefix 159 | prefix_01h2xcejqtf2nbrexx3vqjhp41 160 | ``` 161 | 162 | ## ✨ NEW: `typeid explain` — “What is this ID?” 163 | 164 | TypeID can now **explain a TypeID** in a human-readable way. 165 | 166 | This is useful when: 167 | 168 | * debugging logs 169 | * inspecting database records 170 | * reviewing production incidents 171 | * understanding IDs shared via Slack, tickets, or dashboards 172 | 173 | ### Basic usage (no schema required) 174 | 175 | ```console 176 | $ typeid explain user_01h45ytscbebyvny4gc8cr8ma2 177 | ``` 178 | 179 | Example output: 180 | 181 | ```yaml 182 | id: user_01h45ytscbebyvny4gc8cr8ma2 183 | valid: true 184 | 185 | parsed: 186 | prefix: user 187 | suffix: 01h45ytscbebyvny4gc8cr8ma2 188 | uuid: 01890bf0-846f-7762-8605-5a3abb40e0e5 189 | created_at: 2025-03-12T10:41:23Z 190 | sortable: true 191 | 192 | schema: 193 | found: false 194 | ``` 195 | 196 | Even without configuration, `typeid explain` can: 197 | 198 | * validate the ID 199 | * extract the UUID 200 | * derive creation time (UUIDv7) 201 | * determine sortability 202 | 203 | ## Schema-based explanations 204 | 205 | To make explanations richer, you can define a **TypeID schema** describing what each 206 | prefix represents. 207 | 208 | ### Example schema (`typeid.schema.json`) 209 | 210 | ```json 211 | { 212 | "schema_version": 1, 213 | "types": { 214 | "user": { 215 | "name": "User", 216 | "description": "End-user account", 217 | "owner_team": "identity-platform", 218 | "pii": true, 219 | "retention": "7y", 220 | "links": { 221 | "logs": "https://logs.company/search?q={id}", 222 | "trace": "https://traces.company/?id={id}" 223 | } 224 | } 225 | } 226 | } 227 | ``` 228 | 229 | ### Explain using schema 230 | 231 | ```console 232 | $ typeid explain user_01h45ytscbebyvny4gc8cr8ma2 233 | ``` 234 | 235 | Output (excerpt): 236 | 237 | ```yaml 238 | schema: 239 | found: true 240 | name: User 241 | owner_team: identity-platform 242 | pii: true 243 | retention: 7y 244 | 245 | links: 246 | logs: https://logs.company/search?q=user_01h45ytscbebyvny4gc8cr8ma2 247 | ``` 248 | 249 | ## Schema discovery rules 250 | 251 | If `--schema` is not provided, TypeID looks for a schema in the following order: 252 | 253 | 1. Environment variable: 254 | 255 | ```console 256 | TYPEID_SCHEMA=/path/to/schema.json 257 | ``` 258 | 2. Current directory: 259 | 260 | * `typeid.schema.json` 261 | * `typeid.schema.yaml` 262 | 3. User config directory: 263 | 264 | * `~/.config/typeid/schema.json` 265 | * `~/.config/typeid/schema.yaml` 266 | 267 | If no schema is found, the command still works with derived information only. 268 | 269 | ## YAML schemas (optional) 270 | 271 | YAML schemas are supported if the optional dependency is installed: 272 | 273 | ```console 274 | pip install typeid-python[yaml] 275 | ``` 276 | 277 | Example (`typeid.schema.yaml`): 278 | 279 | ```yaml 280 | schema_version: 1 281 | types: 282 | user: 283 | name: User 284 | owner_team: identity-platform 285 | links: 286 | logs: "https://logs.company/search?q={id}" 287 | ``` 288 | 289 | ## JSON output (machine-readable) 290 | 291 | ```console 292 | $ typeid explain user_01h45ytscbebyvny4gc8cr8ma2 --json 293 | ``` 294 | 295 | Useful for: 296 | 297 | * scripts 298 | * CI pipelines 299 | * IDE integrations 300 | 301 | ## Design principles 302 | 303 | * **Non-breaking**: existing APIs and CLI commands remain unchanged 304 | * **Schema-optional**: works fully offline 305 | * **Read-only**: no side effects or external mutations 306 | * **Declarative**: meaning is defined by users, not inferred by the tool 307 | 308 | You can think of `typeid explain` as: 309 | 310 | > **OpenAPI — but for identifiers instead of HTTP endpoints** 311 | 312 | ## License 313 | 314 | MIT 315 | 316 | -------------------------------------------------------------------------------- /typeid/explain/engine.py: -------------------------------------------------------------------------------- 1 | """ 2 | Explain engine for the `typeid explain` feature. 3 | 4 | This module is intentionally: 5 | - Additive (doesn't change existing TypeID behavior) 6 | - Defensive (never crashes on normal user input) 7 | - Dependency-light (stdlib only) 8 | 9 | It builds an Explanation by combining: 10 | 1) parsed + derived facts from the ID (always available if parsable) 11 | 2) optional schema (registry) data looked up by prefix 12 | 3) optional rendered links (from schema templates) 13 | """ 14 | 15 | from dataclasses import replace 16 | from datetime import datetime, timezone 17 | from typing import Any, Callable, Dict, Optional 18 | 19 | from typeid import TypeID 20 | from typeid.errors import TypeIDException 21 | 22 | from .model import Explanation, ParsedTypeID, ParseError, Provenance, TypeSchema 23 | 24 | SchemaLookup = Callable[[str], Optional[TypeSchema]] 25 | 26 | 27 | def explain( 28 | id_str: str, 29 | *, 30 | schema_lookup: Optional[SchemaLookup] = None, 31 | enable_schema: bool = True, 32 | enable_links: bool = True, 33 | ) -> Explanation: 34 | """ 35 | Produce an Explanation for a TypeID string. 36 | 37 | Args: 38 | id_str: The TypeID string to explain. 39 | schema_lookup: Optional callable to fetch TypeSchema by prefix. 40 | If provided and enable_schema=True, we will look up schema. 41 | enable_schema: If False, do not attempt schema lookup (offline mode). 42 | enable_links: If True, render link templates from schema (if any). 43 | 44 | Returns: 45 | Explanation (always returned; valid=False if parse/validation fails). 46 | """ 47 | parsed = _parse_typeid(id_str) 48 | 49 | # Start building explanation; keep it useful even if invalid. 50 | exp = Explanation( 51 | id=id_str, 52 | valid=parsed.valid, 53 | parsed=parsed, 54 | schema=None, 55 | derived={}, 56 | links={}, 57 | provenance={}, 58 | warnings=[], 59 | errors=list(parsed.errors), 60 | ) 61 | 62 | # If parse failed, nothing more we can deterministically derive. 63 | if not parsed.valid or parsed.prefix is None or parsed.suffix is None: 64 | return exp 65 | 66 | # Schema lookup (optional) 67 | schema: Optional[TypeSchema] = None 68 | if enable_schema and schema_lookup is not None and parsed.prefix: 69 | try: 70 | schema = schema_lookup(parsed.prefix) 71 | except Exception as e: # never let schema backend break explain 72 | exp.warnings.append(f"Schema lookup failed: {e!s}") 73 | schema = None 74 | 75 | if schema is not None: 76 | exp = replace(exp, schema=schema) 77 | _apply_schema_provenance(exp) 78 | 79 | # Render links (optional) 80 | if enable_links and schema is not None and schema.links: 81 | rendered, warnings = _render_links(schema.links, exp) 82 | exp.links.update(rendered) 83 | exp.warnings.extend(warnings) 84 | for k in rendered.keys(): 85 | exp.provenance.setdefault(f"links.{k}", Provenance.SCHEMA) 86 | 87 | # Derived facts provenance 88 | _apply_derived_provenance(exp) 89 | 90 | return exp 91 | 92 | 93 | def _parse_typeid(id_str: str) -> ParsedTypeID: 94 | """ 95 | Parse and validate a TypeID using the library's existing logic. 96 | 97 | Implementation detail: 98 | - We rely on TypeID.from_string() to ensure behavior matches existing users. 99 | - On error, we still attempt to extract prefix/suffix best-effort to show 100 | something helpful (without promising correctness). 101 | """ 102 | try: 103 | tid = TypeID.from_string(id_str) 104 | except TypeIDException as e: 105 | # Best-effort split so users can see what's wrong. 106 | prefix, suffix = _best_effort_split(id_str) 107 | return ParsedTypeID( 108 | raw=id_str, 109 | prefix=prefix, 110 | suffix=suffix, 111 | valid=False, 112 | errors=[ParseError(code="invalid_typeid", message=str(e))], 113 | uuid=None, 114 | created_at=None, 115 | sortable=None, 116 | ) 117 | except Exception as e: 118 | prefix, suffix = _best_effort_split(id_str) 119 | return ParsedTypeID( 120 | raw=id_str, 121 | prefix=prefix, 122 | suffix=suffix, 123 | valid=False, 124 | errors=[ParseError(code="parse_error", message=f"Unexpected error: {e!s}")], 125 | uuid=None, 126 | created_at=None, 127 | sortable=None, 128 | ) 129 | 130 | # Derived facts from the validated TypeID 131 | uuid_obj = tid.uuid # library returns a UUID object (uuid6.UUID) 132 | uuid_str = str(uuid_obj) 133 | 134 | created_at = _uuid7_created_at(uuid_obj) 135 | sortable = True # UUIDv7 is time-ordered by design 136 | 137 | return ParsedTypeID( 138 | raw=id_str, 139 | prefix=tid.prefix, 140 | suffix=tid.suffix, 141 | valid=True, 142 | errors=[], 143 | uuid=uuid_str, 144 | created_at=created_at, 145 | sortable=sortable, 146 | ) 147 | 148 | 149 | def _best_effort_split(id_str: str) -> tuple[Optional[str], Optional[str]]: 150 | """ 151 | Split by the last underscore (TypeID allows underscores in prefix). 152 | Returns (prefix, suffix) or (None, None) if not splittable. 153 | """ 154 | if "_" not in id_str: 155 | return None, None 156 | prefix, suffix = id_str.rsplit("_", 1) 157 | if not prefix or not suffix: 158 | return None, None 159 | return prefix, suffix 160 | 161 | 162 | def _uuid7_created_at(uuid_obj: Any) -> Optional[datetime]: 163 | """ 164 | Extract created_at from a UUIDv7. 165 | 166 | UUIDv7 layout: the top 48 bits are unix epoch time in milliseconds. 167 | Python's uuid.UUID.int is a 128-bit integer with the most significant bits first, 168 | so unix_ms = int >> 80 (128-48). 169 | 170 | Returns: 171 | UTC datetime or None if extraction fails. 172 | """ 173 | try: 174 | # uuid_obj is likely uuid6.UUID, but supports .int like uuid.UUID 175 | u_int = int(uuid_obj.int) 176 | unix_ms = u_int >> 80 177 | unix_s = unix_ms / 1000.0 178 | return datetime.fromtimestamp(unix_s, tz=timezone.utc) 179 | except Exception: 180 | return None 181 | 182 | 183 | class _SafeFormatDict(dict): 184 | """dict that leaves unknown placeholders intact instead of raising KeyError.""" 185 | 186 | def __missing__(self, key: str) -> str: 187 | return "{" + key + "}" 188 | 189 | 190 | def _render_links(templates: Dict[str, str], exp: Explanation) -> tuple[Dict[str, str], list[str]]: 191 | """ 192 | Render schema link templates using known values. 193 | 194 | Supported placeholders: 195 | {id}, {prefix}, {suffix}, {uuid} 196 | {created_at} (ISO8601 if available) 197 | 198 | Unknown placeholders remain unchanged. 199 | """ 200 | mapping = _SafeFormatDict( 201 | id=exp.id, 202 | prefix=exp.parsed.prefix or "", 203 | suffix=exp.parsed.suffix or "", 204 | uuid=exp.parsed.uuid or "", 205 | created_at=exp.parsed.created_at.isoformat() if exp.parsed.created_at else "", 206 | ) 207 | 208 | rendered: Dict[str, str] = {} 209 | warnings: list[str] = [] 210 | 211 | for name, tmpl in templates.items(): 212 | if not isinstance(tmpl, str): 213 | warnings.append(f"Link template '{name}' is not a string; skipping.") 214 | continue 215 | try: 216 | rendered[name] = tmpl.format_map(mapping) 217 | except Exception as e: 218 | warnings.append(f"Failed to render link '{name}': {e!s}") 219 | 220 | return rendered, warnings 221 | 222 | 223 | def _apply_schema_provenance(exp: Explanation) -> None: 224 | """ 225 | Mark common schema fields as coming from schema. 226 | (We keep this small; schema.raw stays schema by definition.) 227 | """ 228 | if exp.schema is None: 229 | return 230 | 231 | for key in ("name", "description", "owner_team", "pii", "retention"): 232 | if getattr(exp.schema, key, None) is not None: 233 | exp.provenance.setdefault(key, Provenance.SCHEMA) 234 | 235 | 236 | def _apply_derived_provenance(exp: Explanation) -> None: 237 | """Mark parsed-derived fields as coming from the ID itself.""" 238 | if exp.parsed.prefix is not None: 239 | exp.provenance.setdefault("prefix", Provenance.DERIVED_FROM_ID) 240 | if exp.parsed.suffix is not None: 241 | exp.provenance.setdefault("suffix", Provenance.DERIVED_FROM_ID) 242 | if exp.parsed.uuid is not None: 243 | exp.provenance.setdefault("uuid", Provenance.DERIVED_FROM_ID) 244 | if exp.parsed.created_at is not None: 245 | exp.provenance.setdefault("created_at", Provenance.DERIVED_FROM_ID) 246 | if exp.parsed.sortable is not None: 247 | exp.provenance.setdefault("sortable", Provenance.DERIVED_FROM_ID) 248 | -------------------------------------------------------------------------------- /typeid/typeid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import warnings 3 | from typing import Generic, Optional, TypeVar 4 | 5 | import uuid6 6 | 7 | from typeid import base32 8 | from typeid.errors import InvalidTypeIDStringException 9 | from typeid.validation import validate_prefix, validate_suffix 10 | 11 | PrefixT = TypeVar("PrefixT", bound=str) 12 | 13 | 14 | class TypeID(Generic[PrefixT]): 15 | """ 16 | A TypeID is a human-meaningful, UUID-backed identifier. 17 | 18 | A TypeID is rendered as: 19 | 20 | _ or just (when prefix is None/empty) 21 | 22 | - **prefix**: optional semantic label (e.g. "user", "order"). It is *not* part of the UUID. 23 | Prefixes are validated for allowed characters/shape (see `validate_prefix`). 24 | - **suffix**: a compact, URL-safe Base32 encoding of a UUID (UUIDv7 by default). 25 | Suffixes are validated structurally (see `validate_suffix`). 26 | 27 | Design notes: 28 | - A TypeID is intended to be safe to store as a string (e.g. in logs / URLs). 29 | - The underlying UUID can always be recovered via `.uuid`. 30 | - Ordering (`>`, `>=`) is based on lexicographic order of the string representation, 31 | which corresponds to time-ordering if the UUID version is time-sortable (UUIDv7). 32 | 33 | Type parameters: 34 | PrefixT: a type-level constraint for the prefix (often `str` or a Literal). 35 | """ 36 | 37 | def __init__(self, prefix: Optional[PrefixT] = None, suffix: Optional[str] = None) -> None: 38 | """ 39 | Create a new TypeID. 40 | 41 | If `suffix` is not provided, a new UUIDv7 is generated and encoded as Base32. 42 | If `prefix` is provided, it is validated. 43 | 44 | Args: 45 | prefix: Optional prefix. If None, the TypeID has no prefix and its string 46 | form will be just the suffix. If provided, it must pass `validate_prefix`. 47 | suffix: Optional Base32-encoded UUID string. If None, a new UUIDv7 is generated. 48 | 49 | Raises: 50 | InvalidTypeIDStringException (or another project-specific exception): 51 | If `suffix` is invalid, or if `prefix` is invalid. 52 | """ 53 | # If no suffix is provided, generate a new UUIDv7 and encode it as Base32. 54 | suffix = _convert_uuid_to_b32(uuid6.uuid7()) if not suffix else suffix 55 | 56 | # Ensure the suffix is a valid encoded UUID representation. 57 | validate_suffix(suffix=suffix) 58 | 59 | # Prefix is optional; when present it must satisfy the project's prefix rules. 60 | if prefix is not None: 61 | validate_prefix(prefix=prefix) 62 | 63 | # Keep prefix as Optional internally. String rendering decides whether to show it. 64 | self._prefix: Optional[PrefixT] = prefix 65 | self._suffix: str = suffix 66 | 67 | @classmethod 68 | def from_string(cls, string: str) -> "TypeID": 69 | """ 70 | Parse a TypeID from its string form. 71 | 72 | The input can be either: 73 | - "_" 74 | - "" (prefix-less) 75 | 76 | Args: 77 | string: String representation of a TypeID. 78 | 79 | Returns: 80 | A `TypeID` instance. 81 | 82 | Raises: 83 | InvalidTypeIDStringException (or another project-specific exception): 84 | If the string cannot be split/parsed or if the extracted parts are invalid. 85 | """ 86 | # Split into (prefix, suffix) according to project rules. 87 | prefix, suffix = get_prefix_and_suffix(string=string) 88 | return cls(suffix=suffix, prefix=prefix) 89 | 90 | @classmethod 91 | def from_uuid(cls, suffix: uuid.UUID, prefix: Optional[PrefixT] = None) -> "TypeID": 92 | """ 93 | Construct a TypeID from an existing UUID. 94 | 95 | This is useful when you store UUIDs in a database but want to expose 96 | TypeIDs at the application boundary. 97 | 98 | Args: 99 | suffix: UUID value to encode into the TypeID suffix. 100 | prefix: Optional prefix to attach (validated if provided). 101 | 102 | Returns: 103 | A `TypeID` whose `.uuid` equals the provided UUID. 104 | """ 105 | # Encode the UUID into the canonical Base32 suffix representation. 106 | suffix_str = _convert_uuid_to_b32(suffix) 107 | return cls(suffix=suffix_str, prefix=prefix) 108 | 109 | @property 110 | def suffix(self) -> str: 111 | """ 112 | The Base32-encoded UUID portion of the TypeID (always present). 113 | 114 | Notes: 115 | - This is the identity-carrying part. 116 | - It is validated at construction time. 117 | """ 118 | return self._suffix 119 | 120 | @property 121 | def prefix(self) -> str: 122 | """ 123 | The prefix portion of the TypeID, as a string. 124 | 125 | Returns: 126 | The configured prefix, or "" if the TypeID is prefix-less. 127 | 128 | Notes: 129 | - Empty string is the *presentation* of "no prefix". Internally, `_prefix` 130 | remains Optional to preserve the distinction between None and a real value. 131 | """ 132 | return self._prefix or "" 133 | 134 | @property 135 | def uuid(self) -> uuid6.UUID: 136 | """ 137 | The UUID represented by this TypeID. 138 | 139 | Returns: 140 | The decoded UUID value. 141 | 142 | Notes: 143 | - This decodes `self.suffix` each time it is accessed. 144 | - The UUID type here follows `uuid6.UUID` used by the project. 145 | """ 146 | return _convert_b32_to_uuid(self.suffix) 147 | 148 | def __str__(self) -> str: 149 | """ 150 | Render the TypeID into its canonical string representation. 151 | 152 | Returns: 153 | "_" if prefix is present, otherwise "". 154 | """ 155 | value = "" 156 | if self.prefix: 157 | value += f"{self.prefix}_" 158 | value += self.suffix 159 | return value 160 | 161 | def __repr__(self): 162 | """ 163 | Developer-friendly representation. 164 | 165 | Uses a constructor-like form to make debugging and copy/paste easier. 166 | """ 167 | return "%s.from_string(%r)" % (self.__class__.__name__, str(self)) 168 | 169 | def __eq__(self, value: object) -> bool: 170 | """ 171 | Equality based on prefix and suffix. 172 | 173 | Notes: 174 | - Two TypeIDs are considered equal if both their string components match. 175 | - This is stricter than "same UUID" because prefix is part of the public ID. 176 | """ 177 | if not isinstance(value, TypeID): 178 | return False 179 | return value.prefix == self.prefix and value.suffix == self.suffix 180 | 181 | def __gt__(self, other) -> bool: 182 | """ 183 | Compare TypeIDs by lexicographic order of their string form. 184 | 185 | This is useful because TypeID suffixes based on UUIDv7 are time-sortable, 186 | so string order typically corresponds to creation time order (within a prefix). 187 | 188 | Returns: 189 | True/False if `other` is a TypeID, otherwise NotImplemented. 190 | """ 191 | if isinstance(other, TypeID): 192 | return str(self) > str(other) 193 | return NotImplemented 194 | 195 | def __ge__(self, other) -> bool: 196 | """ 197 | Compare TypeIDs by lexicographic order of their string form (>=). 198 | 199 | See `__gt__` for rationale and notes. 200 | """ 201 | if isinstance(other, TypeID): 202 | return str(self) >= str(other) 203 | return NotImplemented 204 | 205 | def __hash__(self) -> int: 206 | """ 207 | Hash based on (prefix, suffix), allowing TypeIDs to be used as dict keys / set members. 208 | """ 209 | return hash((self.prefix, self.suffix)) 210 | 211 | 212 | def from_string(string: str) -> TypeID: 213 | warnings.warn("Consider TypeID.from_string instead.", DeprecationWarning) 214 | return TypeID.from_string(string=string) 215 | 216 | 217 | def from_uuid(suffix: uuid.UUID, prefix: Optional[str] = None) -> TypeID: 218 | warnings.warn("Consider TypeID.from_uuid instead.", DeprecationWarning) 219 | return TypeID.from_uuid(suffix=suffix, prefix=prefix) 220 | 221 | 222 | def get_prefix_and_suffix(string: str) -> tuple: 223 | parts = string.rsplit("_", 1) 224 | 225 | # When there's no underscore in the string. 226 | if len(parts) == 1: 227 | if parts[0].strip() == "": 228 | raise InvalidTypeIDStringException(f"Invalid TypeID: {string}") 229 | return None, parts[0] 230 | 231 | # When there is an underscore, unpack prefix and suffix. 232 | prefix, suffix = parts 233 | if prefix.strip() == "" or suffix.strip() == "": 234 | raise InvalidTypeIDStringException(f"Invalid TypeID: {string}") 235 | 236 | return prefix, suffix 237 | 238 | 239 | def _convert_uuid_to_b32(uuid_instance: uuid.UUID) -> str: 240 | return base32.encode(list(uuid_instance.bytes)) 241 | 242 | 243 | def _convert_b32_to_uuid(b32: str) -> uuid6.UUID: 244 | uuid_bytes = bytes(base32.decode(b32)) 245 | uuid_int = int.from_bytes(uuid_bytes, byteorder="big") 246 | return uuid6.UUID(int=uuid_int, version=7) 247 | --------------------------------------------------------------------------------