typeid-python
Advanced tools
| from typeid.cli.main import cli | ||
| if __name__ == "__main__": | ||
| cli() |
| from pathlib import Path | ||
| from typing import Optional | ||
| import click | ||
| from uuid_utils import UUID | ||
| from typeid import TypeID | ||
| from typeid.codecs import base32 | ||
| from typeid.core.parsing import get_prefix_and_suffix | ||
| from typeid.explain.discovery import discover_schema_path | ||
| from typeid.explain.engine import explain as explain_engine | ||
| from typeid.explain.formatters import format_explanation_json, format_explanation_pretty | ||
| from typeid.explain.registry import load_registry, make_lookup | ||
| @click.group() | ||
| def cli(): | ||
| # Root CLI command group. | ||
| # This acts as the entry point for all subcommands. | ||
| pass | ||
| @cli.command() | ||
| @click.option("-p", "--prefix") | ||
| def new(prefix: Optional[str] = None) -> None: | ||
| """ | ||
| Generate a new TypeID. | ||
| If a prefix is provided, it will be validated and included in the output. | ||
| If no prefix is provided, a prefix-less TypeID is generated. | ||
| """ | ||
| typeid = TypeID(prefix=prefix) | ||
| click.echo(str(typeid)) | ||
| @cli.command() | ||
| @click.argument("uuid") | ||
| @click.option("-p", "--prefix") | ||
| def encode(uuid: str, prefix: Optional[str] = None) -> None: | ||
| """ | ||
| Encode an existing UUID into a TypeID. | ||
| This command is intended for cases where UUIDs already exist | ||
| (e.g. stored in a database) and need to be represented as TypeIDs. | ||
| """ | ||
| uuid_obj = UUID(uuid) | ||
| typeid = TypeID.from_uuid(suffix=uuid_obj, prefix=prefix) | ||
| click.echo(str(typeid)) | ||
| @cli.command() | ||
| @click.argument("encoded") | ||
| def decode(encoded: str) -> None: | ||
| """ | ||
| Decode a TypeID into its components. | ||
| This extracts: | ||
| - the prefix (if any) | ||
| - the underlying UUID | ||
| This command is primarily intended for inspection and debugging. | ||
| """ | ||
| prefix, suffix = get_prefix_and_suffix(encoded) | ||
| decoded_bytes = bytes(base32.decode(suffix)) | ||
| uuid = UUID(bytes=decoded_bytes) | ||
| click.echo(f"type: {prefix}") | ||
| click.echo(f"uuid: {uuid}") | ||
| @cli.command() | ||
| @click.argument("encoded") | ||
| @click.option( | ||
| "--schema", | ||
| "schema_path", | ||
| type=click.Path(exists=True, dir_okay=False, path_type=str), | ||
| required=False, | ||
| help="Path to TypeID schema file (JSON, or YAML if PyYAML is installed). " | ||
| "If omitted, TypeID will try to discover a schema automatically.", | ||
| ) | ||
| @click.option( | ||
| "--json", | ||
| "as_json", | ||
| is_flag=True, | ||
| help="Output machine-readable JSON.", | ||
| ) | ||
| @click.option( | ||
| "--no-schema", | ||
| is_flag=True, | ||
| help="Disable schema lookup (derived facts only).", | ||
| ) | ||
| @click.option( | ||
| "--no-links", | ||
| is_flag=True, | ||
| help="Disable link template rendering.", | ||
| ) | ||
| def explain( | ||
| encoded: str, | ||
| schema_path: Optional[str], | ||
| as_json: bool, | ||
| no_schema: bool, | ||
| no_links: bool, | ||
| ) -> None: | ||
| """ | ||
| Explain a TypeID: parse/validate it, derive facts (uuid, created_at), | ||
| and optionally enrich explanation from a user-provided schema. | ||
| """ | ||
| enable_schema = not no_schema | ||
| enable_links = not no_links | ||
| schema_lookup = None | ||
| warnings: list[str] = [] | ||
| # Load schema (optional) | ||
| if enable_schema: | ||
| resolved_path = None | ||
| if schema_path: | ||
| resolved_path = schema_path | ||
| else: | ||
| discovery = discover_schema_path() | ||
| if discovery.path is not None: | ||
| resolved_path = str(discovery.path) | ||
| # If env var was set but invalid, discovery returns source info; | ||
| # we keep CLI robust and simply proceed without schema. | ||
| if resolved_path: | ||
| result = load_registry(Path(resolved_path)) | ||
| if result.registry is not None: | ||
| schema_lookup = make_lookup(result.registry) | ||
| else: | ||
| if result.error is not None: | ||
| warnings.append(f"Schema load failed: {result.error.message}") | ||
| # Build explanation (never raises on normal errors) | ||
| exp = explain_engine( | ||
| encoded, | ||
| schema_lookup=schema_lookup, | ||
| enable_schema=enable_schema, | ||
| enable_links=enable_links, | ||
| ) | ||
| # Surface schema-load warnings (if any) | ||
| if warnings: | ||
| exp.warnings.extend(warnings) | ||
| if as_json: | ||
| click.echo(format_explanation_json(exp)) | ||
| else: | ||
| click.echo(format_explanation_pretty(exp)) |
| from typeid._base32 import encode as _encode_rust, decode as _decode_rust # type: ignore | ||
| def encode(src: bytes) -> str: | ||
| """ | ||
| Encode 16 raw bytes into a 26-character TypeID suffix (Rust-accelerated). | ||
| This function is the low-level codec used by TypeID to transform the 16-byte | ||
| UUID payload into the 26-character suffix string. | ||
| It is **not** a general-purpose Base32 encoder: | ||
| - Input length is strictly **16 bytes**. | ||
| - Output length is strictly **26 characters**. | ||
| - The alphabet is fixed to: | ||
| ``0123456789abcdefghjkmnpqrstvwxyz`` (lowercase only). | ||
| The mapping is a fixed bit-packing scheme: | ||
| - The first 6 input bytes (48 bits) become the first 10 characters | ||
| (often corresponding to the UUIDv7 timestamp portion in TypeID usage). | ||
| - The remaining 10 bytes (80 bits) become the last 16 characters. | ||
| Parameters | ||
| ---------- | ||
| src : bytes | ||
| Exactly 16 bytes to encode (the raw UUID bytes). | ||
| Returns | ||
| ------- | ||
| str | ||
| A 26-character ASCII string using only the TypeID Base32 alphabet. | ||
| Raises | ||
| ------ | ||
| RuntimeError | ||
| If ``src`` is not exactly 16 bytes long: | ||
| ``"Invalid length (expected 16 bytes)."`` | ||
| Notes | ||
| ----- | ||
| - This function is implemented in Rust and exposed via a CPython extension; | ||
| there is no Python fallback. | ||
| - The returned string is always lowercase. | ||
| """ | ||
| return _encode_rust(src) | ||
| def decode(s: str) -> bytes: | ||
| """ | ||
| Decode a 26-character TypeID suffix into 16 raw bytes (Rust-accelerated). | ||
| This function is the inverse of :func:`encode`. It takes a TypeID suffix | ||
| (26 characters) and reconstructs the original 16 bytes by reversing the | ||
| same fixed bit-packing scheme. | ||
| Decoding is strict: | ||
| - Input length must be exactly **26 characters**. | ||
| - Only characters from the alphabet | ||
| ``0123456789abcdefghjkmnpqrstvwxyz`` are accepted. | ||
| - Uppercase letters, whitespace, separators, and Crockford aliases | ||
| (e.g. 'O'→'0', 'I'/'L'→'1') are **not** accepted. | ||
| Parameters | ||
| ---------- | ||
| s : str | ||
| The 26-character suffix string to decode. | ||
| Returns | ||
| ------- | ||
| bytes | ||
| Exactly 16 bytes (the raw UUID bytes). | ||
| Raises | ||
| ------ | ||
| RuntimeError | ||
| If ``s`` is not exactly 26 characters long: | ||
| ``"Invalid length (expected 26 chars)."`` | ||
| If ``s`` contains any character outside the allowed alphabet: | ||
| ``"Invalid base32 character."`` | ||
| Notes | ||
| ----- | ||
| - This function is implemented in Rust and exposed via a CPython extension; | ||
| there is no Python fallback. | ||
| - This performs only decoding/validation of the suffix encoding. It does not | ||
| validate UUID version/variant semantics. | ||
| """ | ||
| return _decode_rust(s) |
| SUFFIX_LEN = 26 | ||
| PREFIX_MAX_LEN = 63 | ||
| ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" |
| class TypeIDException(Exception): | ||
| ... | ||
| class PrefixValidationException(TypeIDException): | ||
| ... | ||
| class SuffixValidationException(TypeIDException): | ||
| ... | ||
| class InvalidTypeIDStringException(TypeIDException): | ||
| ... |
| from dataclasses import dataclass | ||
| from functools import lru_cache | ||
| from typing import Callable | ||
| from typeid.core.typeid import TypeID | ||
| @dataclass(frozen=True, slots=True) | ||
| class TypeIDFactory: | ||
| """ | ||
| Callable object that generates TypeIDs with a fixed prefix. | ||
| Example: | ||
| user_id = TypeIDFactory("user")() | ||
| """ | ||
| prefix: str | ||
| def __call__(self) -> TypeID: | ||
| return TypeID(self.prefix) | ||
| def typeid_factory(prefix: str) -> Callable[[], TypeID]: | ||
| """ | ||
| Return a zero-argument callable that generates TypeIDs with a fixed prefix. | ||
| Example: | ||
| user_id = typeid_factory("user")() | ||
| """ | ||
| return TypeIDFactory(prefix) | ||
| @lru_cache(maxsize=256) | ||
| def cached_typeid_factory(prefix: str) -> Callable[[], TypeID]: | ||
| """ | ||
| Same as typeid_factory, but caches factories by prefix. | ||
| Use this if you create factories repeatedly at runtime. | ||
| """ | ||
| return TypeIDFactory(prefix) |
| from typeid.core.errors import InvalidTypeIDStringException | ||
| def get_prefix_and_suffix(string: str) -> tuple: | ||
| parts = string.rsplit("_", 1) | ||
| # When there's no underscore in the string. | ||
| if len(parts) == 1: | ||
| if parts[0].strip() == "": | ||
| raise InvalidTypeIDStringException(f"Invalid TypeID: {string}") | ||
| return None, parts[0] | ||
| # When there is an underscore, unpack prefix and suffix. | ||
| prefix, suffix = parts | ||
| if prefix.strip() == "" or suffix.strip() == "": | ||
| raise InvalidTypeIDStringException(f"Invalid TypeID: {string}") | ||
| return prefix, suffix |
| from datetime import datetime, timezone | ||
| import warnings | ||
| import uuid_utils | ||
| from typing import Generic, Optional, TypeVar | ||
| from typeid.codecs import base32 | ||
| from typeid.core.parsing import get_prefix_and_suffix | ||
| from typeid.core.validation import validate_prefix, validate_suffix_and_decode | ||
| PrefixT = TypeVar("PrefixT", bound=str) | ||
| def _uuid_from_bytes_v7(uuid_bytes: bytes) -> uuid_utils.UUID: | ||
| """ | ||
| Construct a UUID object from bytes. | ||
| """ | ||
| uuid_int = int.from_bytes(uuid_bytes, "big") | ||
| return uuid_utils.UUID(int=uuid_int) | ||
| class TypeID(Generic[PrefixT]): | ||
| """ | ||
| A TypeID is a human-meaningful, UUID-backed identifier. | ||
| A TypeID is rendered as: | ||
| <prefix>_<suffix> or just <suffix> (when prefix is None/empty) | ||
| - **prefix**: optional semantic label (e.g. "user", "order"). It is *not* part of the UUID. | ||
| Prefixes are validated for allowed characters/shape (see `validate_prefix`). | ||
| - **suffix**: a compact, URL-safe Base32 encoding of a UUID (UUIDv7 by default). | ||
| Suffixes are validated structurally (see `validate_suffix`). | ||
| Design notes: | ||
| - A TypeID is intended to be safe to store as a string (e.g. in logs / URLs). | ||
| - The underlying UUID can always be recovered via `.uuid`. | ||
| - Ordering (`>`, `>=`) is based on lexicographic order of the string representation, | ||
| which corresponds to time-ordering if the UUID version is time-sortable (UUIDv7). | ||
| Type parameters: | ||
| PrefixT: a type-level constraint for the prefix (often `str` or a Literal). | ||
| """ | ||
| __slots__ = ("_prefix", "_suffix", "_uuid_bytes", "_uuid", "_str") | ||
| def __init__(self, prefix: Optional[PrefixT] = None, suffix: Optional[str] = None) -> None: | ||
| """ | ||
| Create a new TypeID. | ||
| If `suffix` is not provided, a new UUIDv7 is generated and encoded as Base32. | ||
| If `prefix` is provided, it is validated. | ||
| Args: | ||
| prefix: Optional prefix. If None, the TypeID has no prefix and its string | ||
| form will be just the suffix. If provided, it must pass `validate_prefix`. | ||
| suffix: Optional Base32-encoded UUID string. If None, a new UUIDv7 is generated. | ||
| Raises: | ||
| InvalidTypeIDStringException (or another project-specific exception): | ||
| If `suffix` is invalid, or if `prefix` is invalid. | ||
| """ | ||
| # Validate prefix early (cheap) so failures don't do extra work | ||
| if prefix: | ||
| validate_prefix(prefix=prefix) | ||
| self._prefix: Optional[PrefixT] = prefix | ||
| self._str: Optional[str] = None | ||
| self._uuid: Optional[uuid_utils.UUID] = None | ||
| self._uuid_bytes: Optional[bytes] = None | ||
| if not suffix: | ||
| # generate uuid (fast path) | ||
| u = uuid_utils.uuid7() | ||
| uuid_bytes = u.bytes | ||
| suffix = base32.encode(uuid_bytes) | ||
| # Cache UUID object (keep original type for user expectations) | ||
| self._uuid = u | ||
| self._uuid_bytes = uuid_bytes | ||
| else: | ||
| # validate+decode once; don't create UUID object yet | ||
| uuid_bytes = validate_suffix_and_decode(suffix) | ||
| self._uuid_bytes = uuid_bytes | ||
| self._suffix = suffix | ||
| @classmethod | ||
| def from_string(cls, string: str) -> "TypeID": | ||
| """ | ||
| Parse a TypeID from its string form. | ||
| The input can be either: | ||
| - "<prefix>_<suffix>" | ||
| - "<suffix>" (prefix-less) | ||
| Args: | ||
| string: String representation of a TypeID. | ||
| Returns: | ||
| A `TypeID` instance. | ||
| Raises: | ||
| InvalidTypeIDStringException (or another project-specific exception): | ||
| If the string cannot be split/parsed or if the extracted parts are invalid. | ||
| """ | ||
| # Split into (prefix, suffix) according to project rules. | ||
| prefix, suffix = get_prefix_and_suffix(string=string) | ||
| return cls(suffix=suffix, prefix=prefix) | ||
| @classmethod | ||
| def from_uuid(cls, suffix: uuid_utils.UUID, prefix: Optional[PrefixT] = None) -> "TypeID": | ||
| """ | ||
| Construct a TypeID from an existing UUID. | ||
| This is useful when you store UUIDs in a database but want to expose | ||
| TypeIDs at the application boundary. | ||
| Args: | ||
| suffix: UUID value to encode into the TypeID suffix. | ||
| prefix: Optional prefix to attach (validated if provided). | ||
| Returns: | ||
| A `TypeID` whose `.uuid` equals the provided UUID. | ||
| """ | ||
| # Validate prefix (if provided) | ||
| if prefix: | ||
| validate_prefix(prefix=prefix) | ||
| uuid_bytes = suffix.bytes | ||
| suffix_str = base32.encode(uuid_bytes) | ||
| obj = cls.__new__(cls) # bypass __init__ to avoid decode+validate cycle | ||
| obj._prefix = prefix | ||
| obj._suffix = suffix_str | ||
| obj._uuid_bytes = uuid_bytes | ||
| obj._uuid = suffix # keep original object type | ||
| obj._str = None | ||
| return obj | ||
| @property | ||
| def suffix(self) -> str: | ||
| """ | ||
| The Base32-encoded UUID portion of the TypeID (always present). | ||
| Notes: | ||
| - This is the identity-carrying part. | ||
| - It is validated at construction time. | ||
| """ | ||
| return self._suffix | ||
| @property | ||
| def prefix(self) -> str: | ||
| """ | ||
| The prefix portion of the TypeID, as a string. | ||
| Returns: | ||
| The configured prefix, or "" if the TypeID is prefix-less. | ||
| Notes: | ||
| - Empty string is the *presentation* of "no prefix". Internally, `_prefix` | ||
| remains Optional to preserve the distinction between None and a real value. | ||
| """ | ||
| return self._prefix or "" | ||
| @property | ||
| def uuid(self) -> uuid_utils.UUID: | ||
| """ | ||
| The UUID represented by this TypeID. | ||
| Returns: | ||
| The decoded UUID value. | ||
| """ | ||
| # Lazy materialization | ||
| if self._uuid is None: | ||
| assert self._uuid_bytes is not None | ||
| self._uuid = _uuid_from_bytes_v7(self._uuid_bytes) | ||
| return self._uuid | ||
| @property | ||
| def uuid_bytes(self) -> bytes: | ||
| """ | ||
| Raw bytes of the underlying UUID. | ||
| This returns the canonical 16-byte representation of the UUID encoded | ||
| in this TypeID. The value is derived lazily from the suffix and cached | ||
| on first access. | ||
| Returns: | ||
| A 16-byte ``bytes`` object representing the UUID. | ||
| """ | ||
| if self._uuid_bytes is None: | ||
| self._uuid_bytes = base32.decode(self._suffix) | ||
| return self._uuid_bytes | ||
| @property | ||
| def created_at(self) -> Optional[datetime]: | ||
| """ | ||
| Creation time embedded in the underlying UUID, if available. | ||
| TypeID typically uses UUIDv7 for generated IDs. UUIDv7 encodes the Unix | ||
| timestamp (milliseconds) in the most significant 48 bits of the 128-bit UUID. | ||
| Returns: | ||
| A timezone-aware UTC datetime if the underlying UUID is version 7, | ||
| otherwise None. | ||
| """ | ||
| u = self.uuid | ||
| # Only UUIDv7 has a defined "created_at" in this sense. | ||
| try: | ||
| if getattr(u, "version", None) != 7: | ||
| return None | ||
| except Exception: | ||
| return None | ||
| try: | ||
| # UUID is 128 bits; top 48 bits are unix epoch time in milliseconds. | ||
| # So: unix_ms = uuid_int >> (128 - 48) = uuid_int >> 80 | ||
| unix_ms = int(u.int) >> 80 | ||
| return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc) | ||
| except Exception: | ||
| return None | ||
| def __str__(self) -> str: | ||
| """ | ||
| Render the TypeID into its canonical string representation. | ||
| Returns: | ||
| "<prefix>_<suffix>" if prefix is present, otherwise "<suffix>". | ||
| """ | ||
| # cache string representation; helps workflow + comparisons | ||
| s = self._str | ||
| if s is not None: | ||
| return s | ||
| if self.prefix: | ||
| s = f"{self.prefix}_{self.suffix}" | ||
| else: | ||
| s = self.suffix | ||
| self._str = s | ||
| return s | ||
| def __repr__(self): | ||
| """ | ||
| Developer-friendly representation. | ||
| Uses a constructor-like form to make debugging and copy/paste easier. | ||
| """ | ||
| return "%s.from_string(%r)" % (self.__class__.__name__, str(self)) | ||
| def __eq__(self, value: object) -> bool: | ||
| """ | ||
| Equality based on prefix and suffix. | ||
| Notes: | ||
| - Two TypeIDs are considered equal if both their string components match. | ||
| - This is stricter than "same UUID" because prefix is part of the public ID. | ||
| """ | ||
| if not isinstance(value, TypeID): | ||
| return False | ||
| return value.prefix == self.prefix and value.suffix == self.suffix | ||
| def __gt__(self, other) -> bool: | ||
| """ | ||
| Compare TypeIDs by lexicographic order of their string form. | ||
| This is useful because TypeID suffixes based on UUIDv7 are time-sortable, | ||
| so string order typically corresponds to creation time order (within a prefix). | ||
| Returns: | ||
| True/False if `other` is a TypeID, otherwise NotImplemented. | ||
| """ | ||
| if isinstance(other, TypeID): | ||
| return str(self) > str(other) | ||
| return NotImplemented | ||
| def __ge__(self, other) -> bool: | ||
| """ | ||
| Compare TypeIDs by lexicographic order of their string form (>=). | ||
| See `__gt__` for rationale and notes. | ||
| """ | ||
| if isinstance(other, TypeID): | ||
| return str(self) >= str(other) | ||
| return NotImplemented | ||
| def __hash__(self) -> int: | ||
| """ | ||
| Hash based on (prefix, suffix), allowing TypeIDs to be used as dict keys / set members. | ||
| """ | ||
| return hash((self.prefix, self.suffix)) | ||
| def from_string(string: str) -> TypeID: | ||
| warnings.warn("Consider TypeID.from_string instead.", DeprecationWarning) | ||
| return TypeID.from_string(string=string) | ||
| def from_uuid(suffix: uuid_utils.UUID, prefix: Optional[str] = None) -> TypeID: | ||
| warnings.warn("Consider TypeID.from_uuid instead.", DeprecationWarning) | ||
| return TypeID.from_uuid(suffix=suffix, prefix=prefix) |
| import re | ||
| from typeid.codecs import base32 | ||
| from typeid.core.constants import SUFFIX_LEN, ALPHABET | ||
| from typeid.core.errors import PrefixValidationException, SuffixValidationException | ||
| _PREFIX_RE = re.compile(r"^([a-z]([a-z0-9_]{0,61}[a-z0-9])?)?$") # allow digits too (spec-like) | ||
| def validate_prefix(prefix: str) -> None: | ||
| # Use fullmatch (anchored) and precompiled regex | ||
| if not _PREFIX_RE.fullmatch(prefix or ""): | ||
| raise PrefixValidationException(f"Invalid prefix: {prefix}.") | ||
| def validate_suffix_and_decode(suffix: str) -> bytes: | ||
| """ | ||
| Validate a TypeID suffix and return decoded UUID bytes (16 bytes). | ||
| This guarantees: one decode per suffix on the fast path. | ||
| """ | ||
| if ( | ||
| len(suffix) != SUFFIX_LEN | ||
| or suffix == "" | ||
| or " " in suffix | ||
| or (not suffix.isdigit() and not suffix.islower()) | ||
| or any([symbol not in ALPHABET for symbol in suffix]) | ||
| or suffix[0] > "7" | ||
| ): | ||
| raise SuffixValidationException(f"Invalid suffix: {suffix}.") | ||
| try: | ||
| uuid_bytes = base32.decode(suffix) # rust-backed or py fallback | ||
| except Exception as exc: | ||
| raise SuffixValidationException(f"Invalid suffix: {suffix}.") from exc | ||
| if len(uuid_bytes) != 16: | ||
| raise SuffixValidationException(f"Invalid suffix: {suffix}.") | ||
| return uuid_bytes |
| from .v2 import TypeIDField | ||
| __all__ = ["TypeIDField"] |
| from dataclasses import dataclass | ||
| from typing import Any, ClassVar, Generic, Literal, Optional, TypeVar, get_args, get_origin | ||
| from pydantic_core import core_schema | ||
| from pydantic.json_schema import JsonSchemaValue | ||
| from typeid import TypeID | ||
| T = TypeVar("T") | ||
| def _parse_typeid(value: Any) -> TypeID: | ||
| """ | ||
| Convert input into a TypeID instance. | ||
| Supports: | ||
| - TypeID -> TypeID | ||
| - str -> parse into TypeID | ||
| """ | ||
| if isinstance(value, TypeID): | ||
| return value | ||
| if isinstance(value, str): | ||
| return TypeID.from_string(value) | ||
| raise TypeError(f"TypeID must be str or TypeID, got {type(value).__name__}") | ||
| @dataclass(frozen=True) | ||
| class _TypeIDMeta: | ||
| expected_prefix: Optional[str] = None | ||
| pattern: Optional[str] = None | ||
| example: Optional[str] = None | ||
| class _TypeIDFieldBase: | ||
| """ | ||
| Base class implementing Pydantic v2 hooks. | ||
| Subclasses specify _typeid_meta. | ||
| """ | ||
| _typeid_meta: ClassVar[_TypeIDMeta] = _TypeIDMeta() | ||
| @classmethod | ||
| def _validate(cls, v: Any) -> TypeID: | ||
| tid = _parse_typeid(v) | ||
| exp = cls._typeid_meta.expected_prefix | ||
| if exp is not None: | ||
| if tid.prefix != exp: | ||
| raise ValueError(f"TypeID prefix mismatch: expected '{exp}', got '{tid.prefix}'") | ||
| return tid | ||
| @classmethod | ||
| def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema: | ||
| """ | ||
| Build a schema that: | ||
| - accepts TypeID instances | ||
| - accepts strings and validates/parses them | ||
| - serializes to string in JSON | ||
| """ | ||
| # Accept either already-parsed TypeID, or a string (or any -> we validate) | ||
| # Using a plain validator keeps it simple and fast. | ||
| return core_schema.no_info_plain_validator_function( | ||
| cls._validate, | ||
| serialization=core_schema.plain_serializer_function_ser_schema( | ||
| lambda v: str(v), | ||
| when_used="json", | ||
| ), | ||
| ) | ||
| @classmethod | ||
| def __get_pydantic_json_schema__(cls, core_schema_: core_schema.CoreSchema, handler: Any) -> JsonSchemaValue: | ||
| schema = handler(core_schema_) | ||
| # Ensure JSON schema is "string" | ||
| schema.update( | ||
| { | ||
| "type": "string", | ||
| "format": "typeid", | ||
| } | ||
| ) | ||
| # Add prefix hint in schema | ||
| exp = cls._typeid_meta.expected_prefix | ||
| if exp is not None: | ||
| schema.setdefault("description", f"TypeID with prefix '{exp}'") | ||
| # Optional pattern / example | ||
| if cls._typeid_meta.pattern: | ||
| schema["pattern"] = cls._typeid_meta.pattern | ||
| if cls._typeid_meta.example: | ||
| schema.setdefault("examples", [cls._typeid_meta.example]) | ||
| return schema | ||
| class TypeIDField(Generic[T]): | ||
| """ | ||
| Usage: | ||
| from typeid.integrations.pydantic import TypeIDField | ||
| class User(BaseModel): | ||
| id: TypeIDField["user"] | ||
| This returns a specialized *type* that Pydantic will validate into your core TypeID. | ||
| """ | ||
| def __class_getitem__(cls, item: str | tuple[str]) -> type[TypeID]: | ||
| """ | ||
| Support: | ||
| - TypeIDField["user"] | ||
| - TypeIDField[Literal["user"]] | ||
| - TypeIDField[("user",)] | ||
| """ | ||
| if isinstance(item, tuple): | ||
| if len(item) != 1: | ||
| raise TypeError("TypeIDField[...] expects a single prefix") | ||
| item = item[0] | ||
| # Literal["user"] | ||
| if get_origin(item) is Literal: | ||
| args = get_args(item) | ||
| if len(args) != 1 or not isinstance(args[0], str): | ||
| raise TypeError("TypeIDField[Literal['prefix']] expects a single string literal") | ||
| prefix = args[0] | ||
| # Plain "user" | ||
| elif isinstance(item, str): | ||
| prefix = item | ||
| else: | ||
| raise TypeError("TypeIDField[...] expects a string prefix or Literal['prefix']") | ||
| name = f"TypeIDField_{prefix}" | ||
| # Optionally add a simple example that looks like TypeID format | ||
| # You can improve this to a real example generator if your core has one. | ||
| example = f"{prefix}_01hxxxxxxxxxxxxxxxxxxxxxxx" | ||
| # Create a new subclass of _TypeIDFieldBase with fixed meta | ||
| field_cls = type( | ||
| name, | ||
| (_TypeIDFieldBase,), | ||
| { | ||
| "_typeid_meta": _TypeIDMeta( | ||
| expected_prefix=prefix, | ||
| # If you know your precise regex, put it here: | ||
| # pattern=rf"^{prefix}_[0-9a-z]{{26}}$", | ||
| pattern=None, | ||
| example=example, | ||
| ) | ||
| }, | ||
| ) | ||
| # IMPORTANT: | ||
| # We return `field_cls` as the annotation type, but the runtime validated value is your core TypeID. | ||
| return field_cls # type: ignore[return-value] |
+38
-11
| Metadata-Version: 2.4 | ||
| Name: typeid-python | ||
| Version: 0.3.7 | ||
| Version: 0.3.8 | ||
| Classifier: Development Status :: 3 - Alpha | ||
@@ -23,13 +23,15 @@ Classifier: License :: OSI Approved :: MIT License | ||
| Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM | ||
| Project-URL: Bug Tracker, https://github.com/akhundMurad/typeid-python/issues | ||
| Project-URL: Homepage, https://github.com/akhundMurad/typeid-python | ||
| Project-URL: Repository, https://github.com/akhundMurad/typeid-python | ||
| Project-URL: Bug Tracker, https://github.com/akhundMurad/typeid-python/issues | ||
| # TypeID Python | ||
| [](https://github.com/akhundMurad/typeid-python/actions/workflows/test.yml) | ||
| [](https://pepy.tech/projects/typeid-python) | ||
| [](https://pypi.org/project/typeid-python/) | ||
| [](https://pypi.org/project/typeid-python/) | ||
| [](https://pepy.tech/projects/typeid-python) | ||
|  | ||
| > [!WARNING] | ||
| > `main` may contain unreleased changes. For stable usage, use the latest release tag. | ||
@@ -46,3 +48,3 @@ A **high-performance Python implementation of [TypeIDs](https://github.com/jetpack-io/typeid)** — type-safe, | ||
| This library provides a Python package with optional Rust acceleration. | ||
| This library provides a Python package with Rust acceleration. | ||
@@ -52,7 +54,8 @@ ## Key features | ||
| - ✅ UUIDv7-based, time-sortable identifiers | ||
| - ✅ Type-safe prefixes (`user_`, `order_`, …) | ||
| - ✅ Schema-based ID explanations (JSON / YAML) | ||
| - ✅ Fast generation & parsing (Rust-accelerated) | ||
| - ✅ Multiple integrations (Pydantic, FastAPI, ...) | ||
| - ✅ Type-safe prefixes (`user_`, `order_`, ...) | ||
| - ✅ Human-readable and URL-safe | ||
| - ✅ Fast generation & parsing (Rust-accelerated) | ||
| - ✅ CLI tools (`new`, `encode`, `decode`, `explain`) | ||
| - ✅ Schema-based ID explanations (JSON / YAML) | ||
| - ✅ Fully offline, no external services | ||
@@ -102,4 +105,4 @@ | ||
| ```console | ||
| $ pip install typeid-python[yaml] # YAML schema support | ||
| $ pip install typeid-python[cli] # CLI tools | ||
| $ pip install typeid-python[yaml] # YAML schema support | ||
| $ pip install typeid-python[cli] # CLI tools | ||
| ``` | ||
@@ -182,2 +185,27 @@ | ||
| ## Framework integrations | ||
| TypeID is **framework-agnostic by design**. | ||
| Integrations are provided as optional adapters, installed explicitly and kept separate from the core. | ||
| ### Available integrations | ||
| * **Pydantic (v2)** | ||
| Native field type with validation and JSON Schema support. | ||
| ```python | ||
| from typing import Literal | ||
| from pydantic import BaseModel | ||
| from typeid.integrations.pydantic import TypeIDField | ||
| class User(BaseModel): | ||
| id: TypeIDField[Literal["user"]] | ||
| ``` | ||
| * **FastAPI** (Coming Soon 🚧) | ||
| * **SQLAlchemy** (Coming Soon 🚧) | ||
| All integrations are **opt-in via extras** and never affect the core package. | ||
| ## ✨ `typeid explain` — understand any ID | ||
@@ -231,3 +259,2 @@ | ||
| * **Non-breaking**: stable APIs | ||
| * **Optional acceleration**: Rust is opt-in | ||
| * **Lazy evaluation**: work is done only when needed | ||
@@ -234,0 +261,0 @@ * **Explainability**: identifiers carry meaning |
+15
-2
| [project] | ||
| name = "typeid-python" | ||
| version = "0.3.7" | ||
| version = "0.3.8" | ||
| description = "Python implementation of TypeIDs: type-safe, K-sortable, and globally unique identifiers inspired by Stripe IDs" | ||
@@ -32,3 +32,3 @@ authors = [{ name = "Murad Akhundov", email = "akhundov1murad@gmail.com" }] | ||
| [project.scripts] | ||
| typeid = "typeid.cli:cli" | ||
| typeid = "typeid.cli.main:cli" | ||
@@ -53,4 +53,17 @@ [dependency-groups] | ||
| "maturin>=1.5; platform_system != 'Windows'", | ||
| "pre-commit>=4.5.1", | ||
| "httpx>=0.28.1", | ||
| "pydantic>=2,<3", | ||
| ] | ||
| [tool.black] | ||
| line-length = 119 | ||
| [tool.ruff] | ||
| line-length = 119 | ||
| target-version = "py311" | ||
| [tool.mypy] | ||
| pretty = true | ||
| [build-system] | ||
@@ -57,0 +70,0 @@ requires = ["maturin>=1.5"] |
+36
-9
| # TypeID Python | ||
| [](https://github.com/akhundMurad/typeid-python/actions/workflows/test.yml) | ||
| [](https://pepy.tech/projects/typeid-python) | ||
| [](https://pypi.org/project/typeid-python/) | ||
| [](https://pypi.org/project/typeid-python/) | ||
| [](https://pepy.tech/projects/typeid-python) | ||
|  | ||
| > [!WARNING] | ||
| > `main` may contain unreleased changes. For stable usage, use the latest release tag. | ||
@@ -19,3 +21,3 @@ A **high-performance Python implementation of [TypeIDs](https://github.com/jetpack-io/typeid)** — type-safe, | ||
| This library provides a Python package with optional Rust acceleration. | ||
| This library provides a Python package with Rust acceleration. | ||
@@ -25,7 +27,8 @@ ## Key features | ||
| - ✅ UUIDv7-based, time-sortable identifiers | ||
| - ✅ Type-safe prefixes (`user_`, `order_`, …) | ||
| - ✅ Schema-based ID explanations (JSON / YAML) | ||
| - ✅ Fast generation & parsing (Rust-accelerated) | ||
| - ✅ Multiple integrations (Pydantic, FastAPI, ...) | ||
| - ✅ Type-safe prefixes (`user_`, `order_`, ...) | ||
| - ✅ Human-readable and URL-safe | ||
| - ✅ Fast generation & parsing (Rust-accelerated) | ||
| - ✅ CLI tools (`new`, `encode`, `decode`, `explain`) | ||
| - ✅ Schema-based ID explanations (JSON / YAML) | ||
| - ✅ Fully offline, no external services | ||
@@ -75,4 +78,4 @@ | ||
| ```console | ||
| $ pip install typeid-python[yaml] # YAML schema support | ||
| $ pip install typeid-python[cli] # CLI tools | ||
| $ pip install typeid-python[yaml] # YAML schema support | ||
| $ pip install typeid-python[cli] # CLI tools | ||
| ``` | ||
@@ -155,2 +158,27 @@ | ||
| ## Framework integrations | ||
| TypeID is **framework-agnostic by design**. | ||
| Integrations are provided as optional adapters, installed explicitly and kept separate from the core. | ||
| ### Available integrations | ||
| * **Pydantic (v2)** | ||
| Native field type with validation and JSON Schema support. | ||
| ```python | ||
| from typing import Literal | ||
| from pydantic import BaseModel | ||
| from typeid.integrations.pydantic import TypeIDField | ||
| class User(BaseModel): | ||
| id: TypeIDField[Literal["user"]] | ||
| ``` | ||
| * **FastAPI** (Coming Soon 🚧) | ||
| * **SQLAlchemy** (Coming Soon 🚧) | ||
| All integrations are **opt-in via extras** and never affect the core package. | ||
| ## ✨ `typeid explain` — understand any ID | ||
@@ -204,3 +232,2 @@ | ||
| * **Non-breaking**: stable APIs | ||
| * **Optional acceleration**: Rust is opt-in | ||
| * **Lazy evaluation**: work is done only when needed | ||
@@ -207,0 +234,0 @@ * **Explainability**: identifiers carry meaning |
@@ -157,3 +157,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "typeid-base32" | ||
| version = "0.3.7" | ||
| version = "0.3.8" | ||
| dependencies = [ | ||
@@ -160,0 +160,0 @@ "pyo3", |
| [package] | ||
| name = "typeid-base32" | ||
| version = "0.3.7" | ||
| version = "0.3.8" | ||
| edition = "2021" | ||
@@ -5,0 +5,0 @@ |
@@ -1,3 +0,4 @@ | ||
| from .factory import TypeIDFactory, cached_typeid_factory, typeid_factory | ||
| from .typeid import TypeID, from_string, from_uuid, get_prefix_and_suffix | ||
| from typeid.core.factory import TypeIDFactory, cached_typeid_factory, typeid_factory | ||
| from typeid.core.typeid import TypeID, from_string, from_uuid | ||
| from typeid.core.parsing import get_prefix_and_suffix | ||
@@ -4,0 +5,0 @@ __all__ = ( |
+10
-6
@@ -1,9 +0,13 @@ | ||
| from typeid._base32 import encode as _encode_rust, decode as _decode_rust # type: ignore | ||
| # Compatibility shim. | ||
| # | ||
| # This module exists to preserve backward compatibility with earlier | ||
| # versions of the library. Public symbols are re-exported from their | ||
| # current implementation locations. | ||
| # | ||
| # New code should prefer importing from the canonical modules, but | ||
| # existing imports will continue to work. | ||
| from typeid.codecs.base32 import encode, decode | ||
| def encode(src: bytes) -> str: | ||
| return _encode_rust(src) | ||
| def decode(s: str) -> bytes: | ||
| return _decode_rust(s) | ||
| __all__ = ("encode", "decode") |
+11
-3
@@ -1,5 +0,13 @@ | ||
| SUFFIX_LEN = 26 | ||
| # Compatibility shim. | ||
| # | ||
| # This module exists to preserve backward compatibility with earlier | ||
| # versions of the library. Public symbols are re-exported from their | ||
| # current implementation locations. | ||
| # | ||
| # New code should prefer importing from the canonical modules, but | ||
| # existing imports will continue to work. | ||
| PREFIX_MAX_LEN = 63 | ||
| from typeid.core.constants import PREFIX_MAX_LEN, SUFFIX_LEN, ALPHABET | ||
| ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" | ||
| __all__ = ("PREFIX_MAX_LEN", "SUFFIX_LEN", "ALPHABET") |
+15
-11
@@ -1,14 +0,18 @@ | ||
| class TypeIDException(Exception): | ||
| ... | ||
| # Compatibility shim. | ||
| # | ||
| # This module exists to preserve backward compatibility with earlier | ||
| # versions of the library. Public symbols are re-exported from their | ||
| # current implementation locations. | ||
| # | ||
| # New code should prefer importing from the canonical modules, but | ||
| # existing imports will continue to work. | ||
| from typeid.core.errors import ( | ||
| TypeIDException, | ||
| PrefixValidationException, | ||
| SuffixValidationException, | ||
| InvalidTypeIDStringException, | ||
| ) | ||
| class PrefixValidationException(TypeIDException): | ||
| ... | ||
| class SuffixValidationException(TypeIDException): | ||
| ... | ||
| class InvalidTypeIDStringException(TypeIDException): | ||
| ... | ||
| __all__ = ("TypeIDException", "PrefixValidationException", "SuffixValidationException", "InvalidTypeIDStringException") |
+10
-37
@@ -1,40 +0,13 @@ | ||
| from dataclasses import dataclass | ||
| from functools import lru_cache | ||
| from typing import Callable | ||
| # Compatibility shim. | ||
| # | ||
| # This module exists to preserve backward compatibility with earlier | ||
| # versions of the library. Public symbols are re-exported from their | ||
| # current implementation locations. | ||
| # | ||
| # New code should prefer importing from the canonical modules, but | ||
| # existing imports will continue to work. | ||
| from .typeid import TypeID | ||
| from typeid.core.factory import TypeIDFactory, typeid_factory, cached_typeid_factory | ||
| @dataclass(frozen=True, slots=True) | ||
| class TypeIDFactory: | ||
| """ | ||
| Callable object that generates TypeIDs with a fixed prefix. | ||
| Example: | ||
| user_id = TypeIDFactory("user")() | ||
| """ | ||
| prefix: str | ||
| def __call__(self) -> TypeID: | ||
| return TypeID(self.prefix) | ||
| def typeid_factory(prefix: str) -> Callable[[], TypeID]: | ||
| """ | ||
| Return a zero-argument callable that generates TypeIDs with a fixed prefix. | ||
| Example: | ||
| user_id = typeid_factory("user")() | ||
| """ | ||
| return TypeIDFactory(prefix) | ||
| @lru_cache(maxsize=256) | ||
| def cached_typeid_factory(prefix: str) -> Callable[[], TypeID]: | ||
| """ | ||
| Same as typeid_factory, but caches factories by prefix. | ||
| Use this if you create factories repeatedly at runtime. | ||
| """ | ||
| return TypeIDFactory(prefix) | ||
| __all__ = ("TypeIDFactory", "typeid_factory", "cached_typeid_factory") |
+11
-313
@@ -1,316 +0,14 @@ | ||
| from datetime import datetime, timezone | ||
| import warnings | ||
| import uuid_utils | ||
| from typing import Generic, Optional, TypeVar | ||
| # Compatibility shim. | ||
| # | ||
| # This module exists to preserve backward compatibility with earlier | ||
| # versions of the library. Public symbols are re-exported from their | ||
| # current implementation locations. | ||
| # | ||
| # New code should prefer importing from the canonical modules, but | ||
| # existing imports will continue to work. | ||
| from typeid import base32 | ||
| from typeid.errors import InvalidTypeIDStringException | ||
| from typeid.validation import validate_prefix, validate_suffix_and_decode | ||
| from typeid.core.typeid import PrefixT, TypeID, from_string, from_uuid | ||
| from typeid.core.parsing import get_prefix_and_suffix | ||
| PrefixT = TypeVar("PrefixT", bound=str) | ||
| def _uuid_from_bytes_v7(uuid_bytes: bytes) -> uuid_utils.UUID: | ||
| """ | ||
| Construct a UUID object from bytes. | ||
| """ | ||
| uuid_int = int.from_bytes(uuid_bytes, "big") | ||
| return uuid_utils.UUID(int=uuid_int) | ||
| class TypeID(Generic[PrefixT]): | ||
| """ | ||
| A TypeID is a human-meaningful, UUID-backed identifier. | ||
| A TypeID is rendered as: | ||
| <prefix>_<suffix> or just <suffix> (when prefix is None/empty) | ||
| - **prefix**: optional semantic label (e.g. "user", "order"). It is *not* part of the UUID. | ||
| Prefixes are validated for allowed characters/shape (see `validate_prefix`). | ||
| - **suffix**: a compact, URL-safe Base32 encoding of a UUID (UUIDv7 by default). | ||
| Suffixes are validated structurally (see `validate_suffix`). | ||
| Design notes: | ||
| - A TypeID is intended to be safe to store as a string (e.g. in logs / URLs). | ||
| - The underlying UUID can always be recovered via `.uuid`. | ||
| - Ordering (`>`, `>=`) is based on lexicographic order of the string representation, | ||
| which corresponds to time-ordering if the UUID version is time-sortable (UUIDv7). | ||
| Type parameters: | ||
| PrefixT: a type-level constraint for the prefix (often `str` or a Literal). | ||
| """ | ||
| __slots__ = ("_prefix", "_suffix", "_uuid_bytes", "_uuid", "_str") | ||
| def __init__(self, prefix: Optional[PrefixT] = None, suffix: Optional[str] = None) -> None: | ||
| """ | ||
| Create a new TypeID. | ||
| If `suffix` is not provided, a new UUIDv7 is generated and encoded as Base32. | ||
| If `prefix` is provided, it is validated. | ||
| Args: | ||
| prefix: Optional prefix. If None, the TypeID has no prefix and its string | ||
| form will be just the suffix. If provided, it must pass `validate_prefix`. | ||
| suffix: Optional Base32-encoded UUID string. If None, a new UUIDv7 is generated. | ||
| Raises: | ||
| InvalidTypeIDStringException (or another project-specific exception): | ||
| If `suffix` is invalid, or if `prefix` is invalid. | ||
| """ | ||
| # Validate prefix early (cheap) so failures don't do extra work | ||
| if prefix: | ||
| validate_prefix(prefix=prefix) | ||
| self._prefix: Optional[PrefixT] = prefix | ||
| self._str: Optional[str] = None | ||
| self._uuid: Optional[uuid_utils.UUID] = None | ||
| self._uuid_bytes: Optional[bytes] = None | ||
| if not suffix: | ||
| # generate uuid (fast path) | ||
| u = uuid_utils.uuid7() | ||
| uuid_bytes = u.bytes | ||
| suffix = base32.encode(uuid_bytes) | ||
| # Cache UUID object (keep original type for user expectations) | ||
| self._uuid = u | ||
| self._uuid_bytes = uuid_bytes | ||
| else: | ||
| # validate+decode once; don't create UUID object yet | ||
| uuid_bytes = validate_suffix_and_decode(suffix) | ||
| self._uuid_bytes = uuid_bytes | ||
| self._suffix = suffix | ||
| @classmethod | ||
| def from_string(cls, string: str) -> "TypeID": | ||
| """ | ||
| Parse a TypeID from its string form. | ||
| The input can be either: | ||
| - "<prefix>_<suffix>" | ||
| - "<suffix>" (prefix-less) | ||
| Args: | ||
| string: String representation of a TypeID. | ||
| Returns: | ||
| A `TypeID` instance. | ||
| Raises: | ||
| InvalidTypeIDStringException (or another project-specific exception): | ||
| If the string cannot be split/parsed or if the extracted parts are invalid. | ||
| """ | ||
| # Split into (prefix, suffix) according to project rules. | ||
| prefix, suffix = get_prefix_and_suffix(string=string) | ||
| return cls(suffix=suffix, prefix=prefix) | ||
| @classmethod | ||
| def from_uuid(cls, suffix: uuid_utils.UUID, prefix: Optional[PrefixT] = None) -> "TypeID": | ||
| """ | ||
| Construct a TypeID from an existing UUID. | ||
| This is useful when you store UUIDs in a database but want to expose | ||
| TypeIDs at the application boundary. | ||
| Args: | ||
| suffix: UUID value to encode into the TypeID suffix. | ||
| prefix: Optional prefix to attach (validated if provided). | ||
| Returns: | ||
| A `TypeID` whose `.uuid` equals the provided UUID. | ||
| """ | ||
| # Validate prefix (if provided) | ||
| if prefix: | ||
| validate_prefix(prefix=prefix) | ||
| uuid_bytes = suffix.bytes | ||
| suffix_str = base32.encode(uuid_bytes) | ||
| obj = cls.__new__(cls) # bypass __init__ to avoid decode+validate cycle | ||
| obj._prefix = prefix | ||
| obj._suffix = suffix_str | ||
| obj._uuid_bytes = uuid_bytes | ||
| obj._uuid = suffix # keep original object type | ||
| obj._str = None | ||
| return obj | ||
| @property | ||
| def suffix(self) -> str: | ||
| """ | ||
| The Base32-encoded UUID portion of the TypeID (always present). | ||
| Notes: | ||
| - This is the identity-carrying part. | ||
| - It is validated at construction time. | ||
| """ | ||
| return self._suffix | ||
| @property | ||
| def prefix(self) -> str: | ||
| """ | ||
| The prefix portion of the TypeID, as a string. | ||
| Returns: | ||
| The configured prefix, or "" if the TypeID is prefix-less. | ||
| Notes: | ||
| - Empty string is the *presentation* of "no prefix". Internally, `_prefix` | ||
| remains Optional to preserve the distinction between None and a real value. | ||
| """ | ||
| return self._prefix or "" | ||
| @property | ||
| def uuid(self) -> uuid_utils.UUID: | ||
| """ | ||
| The UUID represented by this TypeID. | ||
| Returns: | ||
| The decoded UUID value. | ||
| """ | ||
| # Lazy materialization | ||
| if self._uuid is None: | ||
| assert self._uuid_bytes is not None | ||
| self._uuid = _uuid_from_bytes_v7(self._uuid_bytes) | ||
| return self._uuid | ||
| @property | ||
| def uuid_bytes(self) -> bytes: | ||
| """ | ||
| Raw bytes of the underlying UUID. | ||
| This returns the canonical 16-byte representation of the UUID encoded | ||
| in this TypeID. The value is derived lazily from the suffix and cached | ||
| on first access. | ||
| Returns: | ||
| A 16-byte ``bytes`` object representing the UUID. | ||
| """ | ||
| if self._uuid_bytes is None: | ||
| self._uuid_bytes = base32.decode(self._suffix) | ||
| return self._uuid_bytes | ||
| @property | ||
| def created_at(self) -> Optional[datetime]: | ||
| """ | ||
| Creation time embedded in the underlying UUID, if available. | ||
| TypeID typically uses UUIDv7 for generated IDs. UUIDv7 encodes the Unix | ||
| timestamp (milliseconds) in the most significant 48 bits of the 128-bit UUID. | ||
| Returns: | ||
| A timezone-aware UTC datetime if the underlying UUID is version 7, | ||
| otherwise None. | ||
| """ | ||
| u = self.uuid | ||
| # Only UUIDv7 has a defined "created_at" in this sense. | ||
| try: | ||
| if getattr(u, "version", None) != 7: | ||
| return None | ||
| except Exception: | ||
| return None | ||
| try: | ||
| # UUID is 128 bits; top 48 bits are unix epoch time in milliseconds. | ||
| # So: unix_ms = uuid_int >> (128 - 48) = uuid_int >> 80 | ||
| unix_ms = int(u.int) >> 80 | ||
| return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc) | ||
| except Exception: | ||
| return None | ||
| def __str__(self) -> str: | ||
| """ | ||
| Render the TypeID into its canonical string representation. | ||
| Returns: | ||
| "<prefix>_<suffix>" if prefix is present, otherwise "<suffix>". | ||
| """ | ||
| # cache string representation; helps workflow + comparisons | ||
| s = self._str | ||
| if s is not None: | ||
| return s | ||
| if self.prefix: | ||
| s = f"{self.prefix}_{self.suffix}" | ||
| else: | ||
| s = self.suffix | ||
| self._str = s | ||
| return s | ||
| def __repr__(self): | ||
| """ | ||
| Developer-friendly representation. | ||
| Uses a constructor-like form to make debugging and copy/paste easier. | ||
| """ | ||
| return "%s.from_string(%r)" % (self.__class__.__name__, str(self)) | ||
| def __eq__(self, value: object) -> bool: | ||
| """ | ||
| Equality based on prefix and suffix. | ||
| Notes: | ||
| - Two TypeIDs are considered equal if both their string components match. | ||
| - This is stricter than "same UUID" because prefix is part of the public ID. | ||
| """ | ||
| if not isinstance(value, TypeID): | ||
| return False | ||
| return value.prefix == self.prefix and value.suffix == self.suffix | ||
| def __gt__(self, other) -> bool: | ||
| """ | ||
| Compare TypeIDs by lexicographic order of their string form. | ||
| This is useful because TypeID suffixes based on UUIDv7 are time-sortable, | ||
| so string order typically corresponds to creation time order (within a prefix). | ||
| Returns: | ||
| True/False if `other` is a TypeID, otherwise NotImplemented. | ||
| """ | ||
| if isinstance(other, TypeID): | ||
| return str(self) > str(other) | ||
| return NotImplemented | ||
| def __ge__(self, other) -> bool: | ||
| """ | ||
| Compare TypeIDs by lexicographic order of their string form (>=). | ||
| See `__gt__` for rationale and notes. | ||
| """ | ||
| if isinstance(other, TypeID): | ||
| return str(self) >= str(other) | ||
| return NotImplemented | ||
| def __hash__(self) -> int: | ||
| """ | ||
| Hash based on (prefix, suffix), allowing TypeIDs to be used as dict keys / set members. | ||
| """ | ||
| return hash((self.prefix, self.suffix)) | ||
| def from_string(string: str) -> TypeID: | ||
| warnings.warn("Consider TypeID.from_string instead.", DeprecationWarning) | ||
| return TypeID.from_string(string=string) | ||
| def from_uuid(suffix: uuid_utils.UUID, prefix: Optional[str] = None) -> TypeID: | ||
| warnings.warn("Consider TypeID.from_uuid instead.", DeprecationWarning) | ||
| return TypeID.from_uuid(suffix=suffix, prefix=prefix) | ||
| def get_prefix_and_suffix(string: str) -> tuple: | ||
| parts = string.rsplit("_", 1) | ||
| # When there's no underscore in the string. | ||
| if len(parts) == 1: | ||
| if parts[0].strip() == "": | ||
| raise InvalidTypeIDStringException(f"Invalid TypeID: {string}") | ||
| return None, parts[0] | ||
| # When there is an underscore, unpack prefix and suffix. | ||
| prefix, suffix = parts | ||
| if prefix.strip() == "" or suffix.strip() == "": | ||
| raise InvalidTypeIDStringException(f"Invalid TypeID: {string}") | ||
| return prefix, suffix | ||
| __all__ = ("PrefixT", "TypeID", "from_string", "from_uuid", "get_prefix_and_suffix") |
+10
-35
@@ -1,38 +0,13 @@ | ||
| import re | ||
| # Compatibility shim. | ||
| # | ||
| # This module exists to preserve backward compatibility with earlier | ||
| # versions of the library. Public symbols are re-exported from their | ||
| # current implementation locations. | ||
| # | ||
| # New code should prefer importing from the canonical modules, but | ||
| # existing imports will continue to work. | ||
| from typeid import base32 | ||
| from typeid.constants import SUFFIX_LEN, ALPHABET | ||
| from typeid.errors import PrefixValidationException, SuffixValidationException | ||
| from typeid.core.validation import validate_prefix, validate_suffix_and_decode | ||
| _PREFIX_RE = re.compile(r"^([a-z]([a-z0-9_]{0,61}[a-z0-9])?)?$") # allow digits too (spec-like) | ||
| def validate_prefix(prefix: str) -> None: | ||
| # Use fullmatch (anchored) and precompiled regex | ||
| if not _PREFIX_RE.fullmatch(prefix or ""): | ||
| raise PrefixValidationException(f"Invalid prefix: {prefix}.") | ||
| def validate_suffix_and_decode(suffix: str) -> bytes: | ||
| """ | ||
| Validate a TypeID suffix and return decoded UUID bytes (16 bytes). | ||
| This guarantees: one decode per suffix on the fast path. | ||
| """ | ||
| if ( | ||
| len(suffix) != SUFFIX_LEN | ||
| or suffix == "" | ||
| or " " in suffix | ||
| or (not suffix.isdigit() and not suffix.islower()) | ||
| or any([symbol not in ALPHABET for symbol in suffix]) | ||
| or suffix[0] > "7" | ||
| ): | ||
| raise SuffixValidationException(f"Invalid suffix: {suffix}.") | ||
| try: | ||
| uuid_bytes = base32.decode(suffix) # rust-backed or py fallback | ||
| except Exception as exc: | ||
| raise SuffixValidationException(f"Invalid suffix: {suffix}.") from exc | ||
| if len(uuid_bytes) != 16: | ||
| raise SuffixValidationException(f"Invalid suffix: {suffix}.") | ||
| return uuid_bytes | ||
| __all__ = ("validate_prefix", "validate_suffix_and_decode") |
-157
| from pathlib import Path | ||
| from typing import Optional | ||
| import click | ||
| from uuid_utils import UUID | ||
| from typeid import TypeID, base32, from_uuid, get_prefix_and_suffix | ||
| from typeid.explain.discovery import discover_schema_path | ||
| from typeid.explain.engine import explain as explain_engine | ||
| from typeid.explain.formatters import format_explanation_json, format_explanation_pretty | ||
| from typeid.explain.registry import load_registry, make_lookup | ||
| @click.group() | ||
| def cli(): | ||
| # Root CLI command group. | ||
| # This acts as the entry point for all subcommands. | ||
| pass | ||
| @cli.command() | ||
| @click.option("-p", "--prefix") | ||
| def new(prefix: Optional[str] = None) -> None: | ||
| """ | ||
| Generate a new TypeID. | ||
| If a prefix is provided, it will be validated and included in the output. | ||
| If no prefix is provided, a prefix-less TypeID is generated. | ||
| """ | ||
| typeid = TypeID(prefix=prefix) | ||
| click.echo(str(typeid)) | ||
| @cli.command() | ||
| @click.argument("uuid") | ||
| @click.option("-p", "--prefix") | ||
| def encode(uuid: str, prefix: Optional[str] = None) -> None: | ||
| """ | ||
| Encode an existing UUID into a TypeID. | ||
| This command is intended for cases where UUIDs already exist | ||
| (e.g. stored in a database) and need to be represented as TypeIDs. | ||
| """ | ||
| uuid_obj = UUID(uuid) | ||
| typeid = from_uuid(suffix=uuid_obj, prefix=prefix) | ||
| click.echo(str(typeid)) | ||
| @cli.command() | ||
| @click.argument("encoded") | ||
| def decode(encoded: str) -> None: | ||
| """ | ||
| Decode a TypeID into its components. | ||
| This extracts: | ||
| - the prefix (if any) | ||
| - the underlying UUID | ||
| This command is primarily intended for inspection and debugging. | ||
| """ | ||
| prefix, suffix = get_prefix_and_suffix(encoded) | ||
| decoded_bytes = bytes(base32.decode(suffix)) | ||
| uuid = UUID(bytes=decoded_bytes) | ||
| click.echo(f"type: {prefix}") | ||
| click.echo(f"uuid: {uuid}") | ||
| @cli.command() | ||
| @click.argument("encoded") | ||
| @click.option( | ||
| "--schema", | ||
| "schema_path", | ||
| type=click.Path(exists=True, dir_okay=False, path_type=str), | ||
| required=False, | ||
| help="Path to TypeID schema file (JSON, or YAML if PyYAML is installed). " | ||
| "If omitted, TypeID will try to discover a schema automatically.", | ||
| ) | ||
| @click.option( | ||
| "--json", | ||
| "as_json", | ||
| is_flag=True, | ||
| help="Output machine-readable JSON.", | ||
| ) | ||
| @click.option( | ||
| "--no-schema", | ||
| is_flag=True, | ||
| help="Disable schema lookup (derived facts only).", | ||
| ) | ||
| @click.option( | ||
| "--no-links", | ||
| is_flag=True, | ||
| help="Disable link template rendering.", | ||
| ) | ||
| def explain( | ||
| encoded: str, | ||
| schema_path: Optional[str], | ||
| as_json: bool, | ||
| no_schema: bool, | ||
| no_links: bool, | ||
| ) -> None: | ||
| """ | ||
| Explain a TypeID: parse/validate it, derive facts (uuid, created_at), | ||
| and optionally enrich explanation from a user-provided schema. | ||
| """ | ||
| enable_schema = not no_schema | ||
| enable_links = not no_links | ||
| schema_lookup = None | ||
| warnings: list[str] = [] | ||
| # Load schema (optional) | ||
| if enable_schema: | ||
| resolved_path = None | ||
| if schema_path: | ||
| resolved_path = schema_path | ||
| else: | ||
| discovery = discover_schema_path() | ||
| if discovery.path is not None: | ||
| resolved_path = str(discovery.path) | ||
| # If env var was set but invalid, discovery returns source info; | ||
| # we keep CLI robust and simply proceed without schema. | ||
| if resolved_path: | ||
| result = load_registry(Path(resolved_path)) | ||
| if result.registry is not None: | ||
| schema_lookup = make_lookup(result.registry) | ||
| else: | ||
| if result.error is not None: | ||
| warnings.append(f"Schema load failed: {result.error.message}") | ||
| # Build explanation (never raises on normal errors) | ||
| exp = explain_engine( | ||
| encoded, | ||
| schema_lookup=schema_lookup, | ||
| enable_schema=enable_schema, | ||
| enable_links=enable_links, | ||
| ) | ||
| # Surface schema-load warnings (if any) | ||
| if warnings: | ||
| exp.warnings.extend(warnings) | ||
| if as_json: | ||
| click.echo(format_explanation_json(exp)) | ||
| else: | ||
| click.echo(format_explanation_pretty(exp)) | ||
| if __name__ == "__main__": | ||
| cli() |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
85467
16.44%35
59.09%1592
19.79%