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.
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:
pip install git+https://github.com/patchfork/constutil.git
Days of the week
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
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.
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:
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
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-
Nonevalue takes precedence when both arguments are supplied. - No value and no name raises
ValueError; a missing match returnsNone. - 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.
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.
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:
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 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 <your-project>/.agents/skills/constutil/ and commit it. From that
project's root, with this repository cloned alongside it:
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.
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):
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:
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:
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.
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: concise index of documentation, examples, and skill instructions.
- index.md: this README as plain Markdown.
- 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
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:
- Create a GitHub environment named
pypiinpatchfork/constutil. - On PyPI, configure a pending publisher for
constutil(or a trusted publisher if you already own the project): ownerpatchfork, repositoryconstutil, workflow filenamepypi_publish.yml, environmentpypi.
For each new release:
- Update
project.versioninpyproject.toml, runuv lock, and push the changes. - Run Actions → Prepare GitHub release → Run workflow on that commit's branch,
with the matching tag (for example,
v1.0.1for version1.0.1). The workflow runs CI, builds and checks the packages, creates the tag at the tested commit, and creates a draft release with both distributions attached. - Review the draft's notes and downloads, then click Publish release in GitHub.
That user action triggers
pypi_publish.yml. The preparation workflow deliberately leaves publication to the user: events created usingGITHUB_TOKENdo 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.
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.
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.
License
MIT. See LICENSE.