# constutil Typed constant definitions and groups for Python, with no runtime dependencies. `ConstDef` stores a value and a display name. `ConstGroup` collects named definitions and provides ordered enumeration, lookup, and validation. They can describe simple choices or richer records without requiring Python's `enum.Enum`. ## Installation Requires **Python 3.12 or newer**. ```sh pip install constutil ``` This branch prepares version 1.1.0 (Python 3.12+); PyPI currently provides 1.0.0. To use the unreleased changes, install directly from GitHub: ```sh pip install git+https://github.com/patchfork/constutil.git ``` ## Days of the week ```python from constutil import IntConstDef, IntConstGroup class Day(IntConstGroup): MONDAY = IntConstDef(1, "Monday") TUESDAY = IntConstDef(2, "Tuesday") WEDNESDAY = IntConstDef(3, "Wednesday") THURSDAY = IntConstDef(4, "Thursday") FRIDAY = IntConstDef(5, "Friday") SATURDAY = IntConstDef(6, "Saturday") SUNDAY = IntConstDef(7, "Sunday") _default_constant = MONDAY assert Day.MONDAY.value == 1 assert Day.MONDAY.name == "Monday" assert Day.get_default() is Day.MONDAY assert Day.get_by_value(1) is Day.MONDAY assert Day.get_constant(name="Monday") is Day.MONDAY assert Day.get_by_constant_name("MONDAY") is Day.MONDAY assert Day.get_all_values() == (1, 2, 3, 4, 5, 6, 7) assert Day.get_as_pairs()[0] == (1, "Monday") assert Day.get_by_value("1") is None assert Day.get_by_name("monday") is None ``` The attribute name (`MONDAY`), display name (`Monday`), and stored value (`1`) are distinct. `.name` means display text, not the Python attribute name. ## Moons of Saturn: additional metadata ```python from dataclasses import dataclass from constutil import ConstDef, ConstGroup @dataclass(frozen=True, slots=True) class MoonDef(ConstDef[str]): discovered_by: str discovery_year: int class SaturnMoon(ConstGroup[MoonDef]): TITAN = MoonDef("titan", "Titan", "Christiaan Huygens", 1655) IAPETUS = MoonDef("iapetus", "Iapetus", "Giovanni Domenico Cassini", 1671) RHEA = MoonDef("rhea", "Rhea", "Giovanni Domenico Cassini", 1672) moon = SaturnMoon.get_by_value("titan") assert moon is not None assert moon.discovery_year == 1655 # The result retains the MoonDef type. assert SaturnMoon.get_all_constant_names() == ("TITAN", "IAPETUS", "RHEA") assert SaturnMoon.get_name("TITAN") is None ``` ## String constants and custom records `IntConstDef` and `StrConstDef` are aliases for `ConstDef[int]` and `ConstDef[str]`. `IntConstGroup` and `StrConstGroup` are aliases for their corresponding specialized groups. The aliases do not introduce new runtime classes. ```python from constutil import StrConstDef, StrConstGroup class Season(StrConstGroup): SPRING = StrConstDef("spring", "Spring") SUMMER = StrConstDef("summer", "Summer") AUTUMN = StrConstDef("autumn", "Autumn") WINTER = StrConstDef("winter", "Winter") assert Season.get_value("Spring") == "spring" assert Season.is_valid_value("SPRING") is False ``` A custom member class does not have to inherit `ConstDef`. It must expose a `.value` of type `int | str` and a `.name` of type `str`, as expressed by the exported `ConstantMember` protocol. Pass the concrete member class to `ConstGroup[YourMember]`; the protocol is a static typing contract, not a runtime member-discovery type. Mutable dataclasses may also implement that contract. ## Runnable examples Clone the repository and run from its root: ```sh uv run examples/days.py 6 uv run examples/saturn_moons.py rhea ``` `uv` installs this checkout automatically; no published release is needed. See [examples/README.md](https://github.com/patchfork/constutil/tree/main/examples) for default runs and invalid-input demonstrations. ## Behavior ### Comparison and lookup All value, display-name, and attribute-name comparisons use **ordinary Python `==`**, with no string conversion, whitespace trimming, or case normalization. `1` does not match `"1"`, and `"titan"` does not match `"TITAN"`. Python's own numeric equality still applies: `1 == True == 1.0`. Exact comparison does not mean strict type identity. `get_constant(value=None, name=None)` retains a combined lookup interface: - A positional argument is a value: `Day.get_constant(1)`. - Use `name="Monday"` to search by display name. - A non-`None` value takes precedence when both arguments are supplied. - No value and no name raises `ValueError`; a missing match returns `None`. - All single-result lookups return the first match in declaration order. ### Enumeration, membership, and inheritance Public attributes matching the declared member type are discovered in declaration order. For a parameterized member such as `ConstDef[int]`, discovery also checks its value with `isinstance(value, int)`. Unrelated attributes and wrong value types are skipped. Prefix auxiliary attributes with `_` to exclude them explicitly. **Inheritance is not meant to be derived beyond the generic derivations**: use `class Day(ConstGroup[IntConstDef])`, its `IntConstGroup` alias, or a custom record derived from `ConstDef[str]` as above. Do not extend a populated group or build extra generic inheritance hierarchies. Enumeration inspects only the concrete group's own class dictionary: inherited attributes may be accessible through Python but are not included in that child's enumeration or lookup. Members can be freely constructed. There is no singleton or uniqueness guarantee; duplicate values, duplicate names, and multiple attributes referencing the same member are allowed. `is_valid()` uses member equality, not identity. With `ConstDef`, dataclass equality compares value and name and requires the same runtime definition class. `ConstDef` is a frozen dataclass. Groups are ordinary Python classes: their attributes can be reassigned, and enumeration reflects changes immediately. Each enumeration returns a fresh tuple or dictionary. A configured default is returned as-is and is not required to belong to the group; absent defaults are `None`. Custom mutable records remain mutable. ### API reference | Method | Result | | --- | --- | | `get_default()` | Configured member or `None` | | `get_all()` | Tuple of members | | `get_all_map()` | Fresh attribute-name → member dictionary | | `get_all_values()` | Tuple of stored values | | `get_all_names()` | Tuple of display names | | `get_all_constant_names()` | Tuple of Python attribute names | | `get_as_pairs()` | Tuple of `(value, display_name)` pairs; value types preserved | | `get_constant(value=None, name=None)` | Matching member or `None` | | `get_by_value(value)` | Member matching the stored value or `None` | | `get_by_name(name)` | Member matching the display name or `None` | | `get_by_constant_name(name)` | Member matching the attribute name or `None` | | `get_name(value)` | Display name or `None` | | `get_value(name)` | Stored value or `None` | | `is_valid(member)` | Whether an equal member exists in the group | | `is_valid_value(value)` | Whether a member has an equal stored value | | `is_value(member, value)` | Whether the supplied member's value equals `value` | | `get_filtered(members)` | List copy of the input; **does not filter or validate membership** | | `get_filtered_as_pairs(members)` | List of `(str(value), display_name)` pairs from the input | | `has_required(values)` | **Exact set equality** with all group values | The last three helpers intentionally retain their original behavior. Filter helpers preserve input order and duplicates and accept foreign members. `has_required()` ignores order and duplicates, but rejects missing or extra values. `is_value()` does not check membership. Pair serialization in `get_filtered_as_pairs()` is an output conversion, not a lookup comparison. Lookup results preserve the declared member type. Value-only convenience methods return `int | str` (and `None` for a missing lookup); use the typed member's `.value` when the narrower scalar type matters. Annotations do not validate constructor arguments at runtime. ## Python compatibility The minimum is **Python 3.12**, determined by the features actually used: | Feature | Introduced | | --- | --- | | `typing.Generic`, `TypeVar` | Python 3.5 | | Dataclasses | Python 3.7 | | `typing.Protocol`, `get_args`, `get_origin` | Python 3.8 | | Built-in collection annotations such as `tuple[str, ...]` | Python 3.9 | | Union annotations such as `MemberT | None` | Python 3.10 | | `@dataclass(slots=True)` in optional metadata subclasses | Python 3.10 | | `types.get_original_bases()` | Python 3.12 | The generic base deliberately omits `slots=True`: older Python versions raise a `TypeError` when instantiating a frozen, slotted generic alias because `typing` tries to assign `__orig_class__`. Frozen definitions without slots work across the supported versions. A subclass may use slots, but still inherits the base instance dictionary. Generic discovery uses the public `types.get_original_bases()` API, introduced in Python 3.12, to inspect generic bases before type erasure. This sets the minimum Python version; no fallback to direct `__orig_bases__` access is needed. The package includes `py.typed`, and CI tests Python 3.12–3.14. See the official [generic base introspection documentation](https://docs.python.org/3.12/library/types.html#types.get_original_bases). Version 1.0.0 supports Python 3.10–3.14; version 1.1.0 requires Python 3.12+. ## Adopt the coding skill (Codex and Claude Code) The repository includes an opinionated, reusable [constutil skill](https://github.com/patchfork/constutil/tree/main/skills/constutil). It directs an agent to use `constutil` for related constant values, usually in a `constants/` package, and explains naming, access, lookup, and existence checks. Installing the Python dependency alone does **not** install the skill. ### Quick installer From your project's root, download and run the installer: ```sh curl -fsSLo install-skills.sh https://constutil.patchfork.dev/install-skills.sh sh install-skills.sh both # Codex and Claude Code # Or: sh install-skills.sh codex # Or: sh install-skills.sh claude ``` From a checkout of this repository, run `sh scripts/install-skills.sh both`. The installer uses the checkout's skill files when available; the downloaded script fetches them from this site. It installs into `.agents/skills/constutil` and/or `.claude/skills/constutil` in the current directory. Use `--global` for `~/.agents/skills/constutil` and/or `~/.claude/skills/constutil`. Existing skill folders are preserved unless you pass `--force` to update the supplied files. ```sh sh install-skills.sh both --global sh install-skills.sh both --force ``` The script requires `sh` and standard Unix tools, plus `curl` when downloading. It does not change `AGENTS.md` or `CLAUDE.md`; add the project convention below if you want the skill's guidance to apply consistently. ### Manual installation For one project, copy the complete `skills/constutil/` directory from this repository to `/.agents/skills/constutil/` and commit it. From that project's root, with this repository cloned alongside it: ```sh mkdir -p .agents/skills cp -R ../constutil/skills/constutil .agents/skills/constutil ``` For personal use across projects, copy the directory to `~/.agents/skills/constutil/` instead. Other agents supporting `SKILL.md` can use the same skill folder in their own skill-discovery location. In Codex, invoke it as `$constutil`, or let Codex select it for matching tasks. See the official [skill installation and discovery documentation](https://learn.chatgpt.com/docs/build-skills). To make this an always-applicable project convention, also add this instruction to the consuming project's `AGENTS.md` (skill selection alone is task-dependent): ```text Always use constutil when grouping related Python constant values. Keep groups in the relevant constants package unless the existing package structure calls for another location. Follow .agents/skills/constutil/SKILL.md for definition and member names, access, exact lookup, and existence checks. ``` ### Download from the documentation site The site serves the same maintained skill files as this repository: - [Skill instructions](https://constutil.patchfork.dev/skills/constutil/SKILL.md) - [Codex metadata](https://constutil.patchfork.dev/skills/constutil/agents/openai.yaml) - [Complete skill ZIP](https://constutil.patchfork.dev/skills/constutil.zip) Extract the ZIP into `.agents/skills/` for Codex or `.claude/skills/` for Claude Code. It contains a `constutil/` directory. Review the instructions and commit the installed skill into your project. Installing the Python package does not install or activate the skill automatically. ### Claude Code Use the **same** `skills/constutil/` folder; the `SKILL.md` instructions are shared. From the consuming project's root: ```sh mkdir -p .claude/skills cp -R ../constutil/skills/constutil .claude/skills/constutil ``` Commit that folder for your team. For personal use across projects, copy it to `~/.claude/skills/constutil/`. Invoke it as `/constutil`, or let Claude select it when the task matches. The optional `agents/openai.yaml` file supplies Codex UI metadata; Claude uses `SKILL.md`. Add the same always-use instruction shown above to `CLAUDE.md`, changing the reference to `.claude/skills/constutil/SKILL.md`. This makes the project convention available each session while the skill supplies the detailed usage guidance. See [Claude Code's skill documentation](https://code.claude.com/docs/en/skills). The skill has no dependency on a framework or another skill. Keep its version aligned with the library version when updating it. ## Documentation for agents - [llms.txt](https://constutil.patchfork.dev/llms.txt): concise index of documentation, examples, and skill instructions. - [index.md](https://constutil.patchfork.dev/index.md): this README as plain Markdown. - [llms-full.txt](https://constutil.patchfork.dev/llms-full.txt): the README and shared skill instructions in one text file. These files and the downloadable skill are generated from the repository on every Pages deployment. `llms.txt` is a discovery aid; it does not install skills or make an agent follow them automatically. ## Development ```sh uv sync --locked uv run pytest --cov=constutil --cov-report=term-missing uv run ruff check . uv run ruff format --check . uv run mypy uv build uv run twine check dist/* uv run python scripts/build_docs.py ``` Tests execute the Python examples in this README as well as checking discovery, comparison, defaults, metadata, aliases, and helper semantics. Documentation is generated directly from this file into `site/index.html`, so the package README and website share one source. ## Publishing ### PyPI The `pypi_publish.yml` workflow runs on a published GitHub release, tests the package on Python 3.12–3.14, checks types and formatting, verifies that the release tag matches the package version, downloads the wheel and source distribution already attached to that release, checks their metadata, and publishes those exact files via PyPI Trusted Publishing. It does not require an API token. One-time setup: 1. Create a GitHub environment named `pypi` in `patchfork/constutil`. 2. On PyPI, configure a pending publisher for `constutil` (or a trusted publisher if you already own the project): owner `patchfork`, repository `constutil`, workflow filename `pypi_publish.yml`, environment `pypi`. #### Trigger a release For the prepared **1.1.0** release: 1. Open [Prepare GitHub release](https://github.com/patchfork/constutil/actions/workflows/prepare_release.yml). 2. Click **Run workflow**, select the `main` branch, enter **`v1.1.0`** as the tag, and start the workflow. 3. Wait for the workflow to pass CI and create a **draft release** with the wheel and source archive attached. 4. Open [Releases](https://github.com/patchfork/constutil/releases), review the draft's notes and downloads, and click **Publish release**. 5. Publishing automatically triggers [Publish to PyPI](https://github.com/patchfork/constutil/actions/workflows/pypi_publish.yml) to upload those exact packages. Check that workflow for the publishing result. For later releases, first update `project.version` in `pyproject.toml`, run `uv lock`, and push the changes. Then repeat these steps with the matching tag: version `1.2.0`, for example, uses tag `v1.2.0`. The preparation workflow deliberately leaves publication to the user: events created using `GITHUB_TOKEN` do not automatically trigger other workflows. GitHub releases are immutable after publication. Uploads must happen while the release is still a draft; the publish workflow never adds or replaces release assets. A preparation attempt for an existing tag fails rather than moving the tag or overwriting a release. If a draft's upload failed, attach the checked build artifacts to that draft before publishing it. The original `v1.0.0` release was published without binary attachments and cannot be retrofitted. Its packages are available from PyPI; subsequent releases use the draft-first process above. Rerunning the historical workflow uses the old workflow stored at its tag and cannot apply this fix retroactively. PyPI project-name availability is decided by PyPI when registering or publishing. See [PyPI's Trusted Publishing guide](https://docs.pypi.org/trusted-publishers/). ### GitHub Pages Select **GitHub Actions** under the repository's **Settings → Pages → Build and deployment → Source**. The `pages.yml` workflow builds this README and deploys it on pushes to `main`, or through a manual workflow run. The published site is [constutil.patchfork.dev](https://constutil.patchfork.dev/). Its DNS record is `CNAME constutil → patchfork.github.io` (without a repository path). The repository's Pages custom-domain setting must also be `constutil.patchfork.dev`; this Actions deployment does not use a `CNAME` file. HTTPS is managed by GitHub Pages. See [GitHub's Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages). ## License MIT. See [LICENSE](https://github.com/patchfork/constutil/blob/main/LICENSE). --- # Shared constutil coding skill --- name: constutil description: Define, organize, access, and validate related Python constants with constutil in projects adopting this convention. Use when adding or changing grouped constants, named choices, or constant metadata; not for unrelated configuration or isolated numeric literals. --- # constutil conventions In a project adopting this skill, always use `constutil` when grouping related constant values. Use `ConstDef` for definitions and `ConstGroup` for groups, including choices that might otherwise be represented by ad hoc dictionaries, parallel lists, or enum-like classes. Honor an explicit requirement for another representation or an external API that requires `enum.Enum`; do not migrate unrelated existing code as a side effect. ## Place and name definitions - Usually put groups in the application's `constants` package, for example `src/myapp/constants/days.py` or a domain's `constants/` package. Follow the existing package boundary and include `__init__.py`. - Use focused snake_case modules (`days.py`, `saturn_moons.py`), singular PascalCase group names (`Day`, `SaturnMoon`), and UPPER_SNAKE_CASE members (`MONDAY`, `TITAN`). - Name metadata records `Def`, such as `MoonDef`. Reserve `_`-prefixed attributes for helpers and defaults; public matching records become members. - Import public types from `constutil`. Use `IntConstDef`/`IntConstGroup` or `StrConstDef`/`StrConstGroup` for simple choices. For metadata, derive a frozen dataclass from `ConstDef[int]` or `ConstDef[str]`, then use `ConstGroup[ThatDef]`. - Use direct generic derivations only. Do not derive one populated group from another or build extra generic base layers. Inherited members are not enumerated. - Keep scalar values stable: they are the stored or exchanged identifiers. `.name` is display text, distinct from both `.value` and the class attribute name. ```python # src/myapp/constants/days.py from constutil import IntConstDef, IntConstGroup class Day(IntConstGroup): MONDAY = IntConstDef(1, "Monday") TUESDAY = IntConstDef(2, "Tuesday") _default_constant = MONDAY ``` ## Access, compare, and check existence For a known constant, use `Day.MONDAY`, `Day.MONDAY.value`, or `Day.MONDAY.name`. Import `Day` from its constants module, following the project's absolute import convention. Pass `.value` at storage, JSON, and API boundaries; use the definition object when metadata is useful. Do not scatter copied literals. For an external value, look it up once and explicitly handle a missing result: ```python member = Day.get_by_value(1) if member is None: raise ValueError("Unknown day") assert member.name == "Monday" ``` Choose the existence check matching the input: | Input | Existence check or lookup | | --- | --- | | Stored value, e.g. `1` | `Day.is_valid_value(value)` or `Day.get_by_value(value) is not None` | | Display name, e.g. `"Monday"` | `Day.get_by_name(name) is not None` | | Attribute name, e.g. `"MONDAY"` | `Day.get_by_constant_name(name) is not None` | | Definition object | `Day.is_valid(member)` (equality, not identity) | | Member and expected scalar | `Day.is_value(member, value)` (does not validate membership) | Do not use `hasattr(Day, input)` to validate membership: it also sees methods and inherited attributes. Do not use truthiness of `.value`; `0` and `""` may be valid. Do not compare a `ConstDef` directly to a scalar or use `value in Day`. Comparisons use Python `==` with no coercion or case folding. `"1"` differs from `1`, and `"Monday"` differs from `"monday"`. Python numeric equality still makes `True` and `1.0` equal to `1`. If the application requires strict input types, validate the type at its input boundary. Use `is None` for lookup misses. `get_constant(value=None, name=None)` remains available: a positional argument is a value, `name=` searches display text, non-None value wins if both are supplied, and neither raises `ValueError`. Do not pass a display name positionally. ## Enumerate and avoid helper traps - Use `get_all()` for member tuples, `get_all_values()` for scalar tuples, `get_as_pairs()` for value/display-name tuples, and `get_all_map()` for a fresh attribute-name/member dictionary. Order follows declaration order. - `get_all_names()` returns display names; `get_all_constant_names()` returns Python attribute names. `get_name(value)` and `get_value(name)` return `None` for a missing match. The latter and bulk scalar APIs return `int | str`; use a typed member's `.value` when a narrower scalar type matters. - `get_filtered(items)` only copies its input. To filter for membership, write `[item for item in items if Day.is_valid(item)]`. - `get_filtered_as_pairs(items)` accepts foreign members and stringifies their values. Use `get_as_pairs()` when original scalar types should be preserved. - `has_required(values)` checks exact set equality, not subset containment. For “all requested values exist”, use `all(Day.is_valid_value(v) for v in values)`. - Prefer unique values and names within an application group. The library permits duplicates and returns the first matching declaration. It does not enforce singleton identity, runtime constructor types, or group immutability. Check tests for valid and unknown inputs, case differences, scalar type mismatches, and zero/empty values when applicable. Do not silently choose a default on invalid input unless that behavior is part of the application's contract.