constutil 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ tests:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python: ['3.10', '3.11', '3.12', '3.13', '3.14']
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ enable-cache: true
24
+ python-version: ${{ matrix.python }}
25
+ - run: uv sync --locked
26
+ - run: uv run pytest --cov=constutil --cov-report=term-missing --cov-fail-under=95
27
+ quality:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ - uses: astral-sh/setup-uv@v6
32
+ with:
33
+ enable-cache: true
34
+ python-version: '3.10'
35
+ - run: uv sync --locked
36
+ - run: uv run ruff check .
37
+ - run: uv run ruff format --check .
38
+ - run: uv run mypy
39
+ - run: uv run python scripts/build_docs.py
40
+ - run: uv build
41
+ - run: uv run twine check dist/*
@@ -0,0 +1,42 @@
1
+ name: GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: pages
13
+ cancel-in-progress: false
14
+
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: astral-sh/setup-uv@v6
21
+ with:
22
+ enable-cache: true
23
+ python-version: '3.14'
24
+ - run: uv sync --locked
25
+ - run: uv run python scripts/build_docs.py
26
+ - uses: actions/configure-pages@v5
27
+ - uses: actions/upload-pages-artifact@v3
28
+ with:
29
+ path: site/
30
+ deploy:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ permissions:
34
+ pages: write
35
+ id-token: write
36
+ environment:
37
+ name: github-pages
38
+ url: ${{ steps.deployment.outputs.page_url }}
39
+ steps:
40
+ - name: Deploy
41
+ id: deployment
42
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,71 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ uses: ./.github/workflows/ci.yml
13
+ build:
14
+ needs: test
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v6
19
+ with:
20
+ python-version: '3.14'
21
+ - run: uv sync --locked
22
+ - name: Verify release tag matches package version
23
+ env:
24
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
25
+ run: |
26
+ uv run python - <<'PY'
27
+ import os
28
+ import tomllib
29
+ from pathlib import Path
30
+ version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
31
+ if os.environ['RELEASE_TAG'] != f'v{version}':
32
+ raise SystemExit(f'Release tag must be v{version}')
33
+ PY
34
+ - run: uv build
35
+ - run: uv run twine check dist/*
36
+ - uses: actions/upload-artifact@v4
37
+ with:
38
+ name: distributions
39
+ path: dist/
40
+ if-no-files-found: error
41
+ publish:
42
+ needs: build
43
+ runs-on: ubuntu-latest
44
+ environment:
45
+ name: pypi
46
+ url: https://pypi.org/project/constutil/
47
+ permissions:
48
+ id-token: write
49
+ steps:
50
+ - uses: actions/download-artifact@v4
51
+ with:
52
+ name: distributions
53
+ path: dist/
54
+ - name: Publish with Trusted Publishing
55
+ uses: pypa/gh-action-pypi-publish@release/v1
56
+ release-assets:
57
+ needs: build
58
+ runs-on: ubuntu-latest
59
+ permissions:
60
+ contents: write
61
+ steps:
62
+ - uses: actions/download-artifact@v4
63
+ with:
64
+ name: distributions
65
+ path: dist/
66
+ - name: Attach distributions to the GitHub release
67
+ env:
68
+ GH_TOKEN: ${{ github.token }}
69
+ GH_REPO: ${{ github.repository }}
70
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
71
+ run: gh release upload "$RELEASE_TAG" dist/* --clobber
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ site/
12
+ *.egg-info/
13
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patchfork
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,388 @@
1
+ Metadata-Version: 2.5
2
+ Name: constutil
3
+ Version: 1.0.0
4
+ Summary: Typed constant definitions and groups for Python
5
+ Project-URL: Homepage, https://github.com/patchfork/constutil
6
+ Project-URL: Documentation, https://constutil.patchfork.dev/
7
+ Project-URL: Repository, https://github.com/patchfork/constutil
8
+ Project-URL: Issues, https://github.com/patchfork/constutil/issues
9
+ Author-email: Patchfork <pypi@patchfork.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: constants,dataclasses,enum,typing
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+
25
+ # constutil
26
+
27
+ Typed constant definitions and groups for Python, with no runtime dependencies.
28
+
29
+ `ConstDef` stores a value and a display name. `ConstGroup` collects named definitions
30
+ and provides ordered enumeration, lookup, and validation. They can describe simple
31
+ choices or richer records without requiring Python's `enum.Enum`.
32
+
33
+ ## Installation
34
+
35
+ Requires **Python 3.10 or newer**.
36
+
37
+ ```sh
38
+ pip install constutil
39
+ ```
40
+
41
+ Until the first PyPI release, install directly from GitHub:
42
+
43
+ ```sh
44
+ pip install git+https://github.com/patchfork/constutil.git
45
+ ```
46
+
47
+ ## Days of the week
48
+
49
+ ```python
50
+ from constutil import IntConstDef, IntConstGroup
51
+
52
+
53
+ class Day(IntConstGroup):
54
+ MONDAY = IntConstDef(1, "Monday")
55
+ TUESDAY = IntConstDef(2, "Tuesday")
56
+ WEDNESDAY = IntConstDef(3, "Wednesday")
57
+ THURSDAY = IntConstDef(4, "Thursday")
58
+ FRIDAY = IntConstDef(5, "Friday")
59
+ SATURDAY = IntConstDef(6, "Saturday")
60
+ SUNDAY = IntConstDef(7, "Sunday")
61
+
62
+ _default_constant = MONDAY
63
+
64
+
65
+ assert Day.MONDAY.value == 1
66
+ assert Day.MONDAY.name == "Monday"
67
+ assert Day.get_default() is Day.MONDAY
68
+ assert Day.get_by_value(1) is Day.MONDAY
69
+ assert Day.get_constant(name="Monday") is Day.MONDAY
70
+ assert Day.get_by_constant_name("MONDAY") is Day.MONDAY
71
+ assert Day.get_all_values() == (1, 2, 3, 4, 5, 6, 7)
72
+ assert Day.get_as_pairs()[0] == (1, "Monday")
73
+ assert Day.get_by_value("1") is None
74
+ assert Day.get_by_name("monday") is None
75
+ ```
76
+
77
+ The attribute name (`MONDAY`), display name (`Monday`), and stored value (`1`)
78
+ are distinct. `.name` means display text, not the Python attribute name.
79
+
80
+ ## Moons of Saturn: additional metadata
81
+
82
+ ```python
83
+ from dataclasses import dataclass
84
+ from constutil import ConstDef, ConstGroup
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class MoonDef(ConstDef[str]):
89
+ discovered_by: str
90
+ discovery_year: int
91
+
92
+
93
+ class SaturnMoon(ConstGroup[MoonDef]):
94
+ TITAN = MoonDef("titan", "Titan", "Christiaan Huygens", 1655)
95
+ IAPETUS = MoonDef("iapetus", "Iapetus", "Giovanni Domenico Cassini", 1671)
96
+ RHEA = MoonDef("rhea", "Rhea", "Giovanni Domenico Cassini", 1672)
97
+
98
+
99
+ moon = SaturnMoon.get_by_value("titan")
100
+ assert moon is not None
101
+ assert moon.discovery_year == 1655 # The result retains the MoonDef type.
102
+ assert SaturnMoon.get_all_constant_names() == ("TITAN", "IAPETUS", "RHEA")
103
+ assert SaturnMoon.get_name("TITAN") is None
104
+ ```
105
+
106
+ ## String constants and custom records
107
+
108
+ `IntConstDef` and `StrConstDef` are aliases for `ConstDef[int]` and `ConstDef[str]`.
109
+ `IntConstGroup` and `StrConstGroup` are aliases for their corresponding specialized
110
+ groups. The aliases do not introduce new runtime classes.
111
+
112
+ ```python
113
+ from constutil import StrConstDef, StrConstGroup
114
+
115
+
116
+ class Season(StrConstGroup):
117
+ SPRING = StrConstDef("spring", "Spring")
118
+ SUMMER = StrConstDef("summer", "Summer")
119
+ AUTUMN = StrConstDef("autumn", "Autumn")
120
+ WINTER = StrConstDef("winter", "Winter")
121
+
122
+
123
+ assert Season.get_value("Spring") == "spring"
124
+ assert Season.is_valid_value("SPRING") is False
125
+ ```
126
+
127
+ A custom member class does not have to inherit `ConstDef`. It must expose a
128
+ `.value` of type `int | str` and a `.name` of type `str`, as expressed by the
129
+ exported `ConstantMember` protocol. Pass the concrete member class to
130
+ `ConstGroup[YourMember]`; the protocol is a static typing contract, not a runtime
131
+ member-discovery type. Mutable dataclasses may also implement that contract.
132
+
133
+ ## Runnable examples
134
+
135
+ Clone the repository and run from its root:
136
+
137
+ ```sh
138
+ uv run examples/days.py 6
139
+ uv run examples/saturn_moons.py rhea
140
+ ```
141
+
142
+ `uv` installs this checkout automatically; no published release is needed. See
143
+ [examples/README.md](https://github.com/patchfork/constutil/tree/main/examples)
144
+ for default runs and invalid-input demonstrations.
145
+
146
+ ## Behavior
147
+
148
+ ### Comparison and lookup
149
+
150
+ All value, display-name, and attribute-name comparisons use **ordinary Python
151
+ `==`**, with no string conversion, whitespace trimming, or case normalization.
152
+ `1` does not match `"1"`, and `"titan"` does not match `"TITAN"`. Python's own
153
+ numeric equality still applies: `1 == True == 1.0`. Exact comparison does not
154
+ mean strict type identity.
155
+
156
+ `get_constant(value=None, name=None)` retains a combined lookup interface:
157
+
158
+ - A positional argument is a value: `Day.get_constant(1)`.
159
+ - Use `name="Monday"` to search by display name.
160
+ - A non-`None` value takes precedence when both arguments are supplied.
161
+ - No value and no name raises `ValueError`; a missing match returns `None`.
162
+ - All single-result lookups return the first match in declaration order.
163
+
164
+ ### Enumeration, membership, and inheritance
165
+
166
+ Public attributes matching the declared member type are discovered in declaration
167
+ order. For a parameterized member such as `ConstDef[int]`, discovery also checks
168
+ its value with `isinstance(value, int)`. Unrelated attributes and wrong value types
169
+ are skipped. Prefix auxiliary attributes with `_` to exclude them explicitly.
170
+
171
+ **Inheritance is not meant to be derived beyond the generic derivations**:
172
+ use `class Day(ConstGroup[IntConstDef])`, its `IntConstGroup` alias, or a custom
173
+ record derived from `ConstDef[str]` as above. Do not extend a populated group or
174
+ build extra generic inheritance hierarchies. Enumeration inspects only the
175
+ concrete group's own class dictionary: inherited attributes may be accessible
176
+ through Python but are not included in that child's enumeration or lookup.
177
+
178
+ Members can be freely constructed. There is no singleton or uniqueness guarantee;
179
+ duplicate values, duplicate names, and multiple attributes referencing the same
180
+ member are allowed. `is_valid()` uses member equality, not identity. With
181
+ `ConstDef`, dataclass equality compares value and name and requires the same
182
+ runtime definition class.
183
+
184
+ `ConstDef` is a frozen dataclass. Groups are ordinary Python classes:
185
+ their attributes can be reassigned, and enumeration reflects changes immediately.
186
+ Each enumeration returns a fresh tuple or dictionary. A configured default is
187
+ returned as-is and is not required to belong to the group; absent defaults are
188
+ `None`. Custom mutable records remain mutable.
189
+
190
+ ### API reference
191
+
192
+ | Method | Result |
193
+ | --- | --- |
194
+ | `get_default()` | Configured member or `None` |
195
+ | `get_all()` | Tuple of members |
196
+ | `get_all_map()` | Fresh attribute-name → member dictionary |
197
+ | `get_all_values()` | Tuple of stored values |
198
+ | `get_all_names()` | Tuple of display names |
199
+ | `get_all_constant_names()` | Tuple of Python attribute names |
200
+ | `get_as_pairs()` | Tuple of `(value, display_name)` pairs; value types preserved |
201
+ | `get_constant(value=None, name=None)` | Matching member or `None` |
202
+ | `get_by_value(value)` | Member matching the stored value or `None` |
203
+ | `get_by_name(name)` | Member matching the display name or `None` |
204
+ | `get_by_constant_name(name)` | Member matching the attribute name or `None` |
205
+ | `get_name(value)` | Display name or `None` |
206
+ | `get_value(name)` | Stored value or `None` |
207
+ | `is_valid(member)` | Whether an equal member exists in the group |
208
+ | `is_valid_value(value)` | Whether a member has an equal stored value |
209
+ | `is_value(member, value)` | Whether the supplied member's value equals `value` |
210
+ | `get_filtered(members)` | List copy of the input; **does not filter or validate membership** |
211
+ | `get_filtered_as_pairs(members)` | List of `(str(value), display_name)` pairs from the input |
212
+ | `has_required(values)` | **Exact set equality** with all group values |
213
+
214
+ The last three helpers intentionally retain their original behavior. Filter helpers
215
+ preserve input order and duplicates and accept foreign members. `has_required()`
216
+ ignores order and duplicates, but rejects missing or extra values. `is_value()`
217
+ does not check membership. Pair serialization in `get_filtered_as_pairs()` is an
218
+ output conversion, not a lookup comparison.
219
+
220
+ Lookup results preserve the declared member type. Value-only convenience methods
221
+ return `int | str` (and `None` for a missing lookup); use the typed member's `.value`
222
+ when the narrower scalar type matters. Annotations do not validate constructor
223
+ arguments at runtime.
224
+
225
+ ## Python compatibility
226
+
227
+ The minimum is **Python 3.10**, determined by the features actually used:
228
+
229
+ | Feature | Introduced |
230
+ | --- | --- |
231
+ | `typing.Generic`, `TypeVar` | Python 3.5 |
232
+ | Dataclasses | Python 3.7 |
233
+ | `typing.Protocol`, `get_args`, `get_origin` | Python 3.8 |
234
+ | Built-in collection annotations such as `tuple[str, ...]` | Python 3.9 |
235
+ | Union annotations such as `MemberT | None` | Python 3.10 |
236
+ | `@dataclass(slots=True)` in optional metadata subclasses | Python 3.10 |
237
+
238
+ The generic base deliberately omits `slots=True`: older Python versions raise a
239
+ `TypeError` when instantiating a frozen, slotted generic alias because `typing`
240
+ tries to assign `__orig_class__`. Frozen definitions without slots work across the
241
+ supported versions. A subclass may use slots, but still inherits the base instance
242
+ dictionary.
243
+
244
+ Generic discovery uses `__orig_bases__`; it does not need Python 3.12's
245
+ `types.get_original_bases` or PEP 695 type-parameter syntax. The package includes
246
+ `py.typed`. CI tests Python 3.10–3.14. See the official
247
+ [dataclass documentation](https://docs.python.org/3.10/library/dataclasses.html),
248
+ [typing documentation](https://docs.python.org/3.10/library/typing.html), and
249
+ [Python 3.10 changes](https://docs.python.org/3.10/whatsnew/3.10.html).
250
+
251
+ ## Adopt the coding skill (Codex and Claude Code)
252
+
253
+ The repository includes an opinionated, reusable
254
+ [constutil skill](https://github.com/patchfork/constutil/tree/main/skills/constutil).
255
+ It directs an agent to use `constutil` for related constant values, usually in a
256
+ `constants/` package, and explains naming, access, lookup, and existence checks.
257
+ Installing the Python dependency alone does **not** install the skill.
258
+
259
+ For one project, copy the complete `skills/constutil/` directory from this
260
+ repository to `<your-project>/.agents/skills/constutil/` and commit it. From that
261
+ project's root, with this repository cloned alongside it:
262
+
263
+ ```sh
264
+ mkdir -p .agents/skills
265
+ cp -R ../constutil/skills/constutil .agents/skills/constutil
266
+ ```
267
+
268
+ For personal use across projects, copy the directory to
269
+ `~/.agents/skills/constutil/` instead. Other agents supporting `SKILL.md` can use
270
+ the same skill folder in their own skill-discovery location. In Codex, invoke it
271
+ as `$constutil`, or let Codex select it for matching tasks. See the official
272
+ [skill installation and discovery documentation](https://learn.chatgpt.com/docs/build-skills).
273
+
274
+ To make this an always-applicable project convention, also add this instruction
275
+ to the consuming project's `AGENTS.md` (skill selection alone is task-dependent):
276
+
277
+ ```text
278
+ Always use constutil when grouping related Python constant values. Keep groups
279
+ in the relevant constants package unless the existing package structure calls
280
+ for another location. Follow .agents/skills/constutil/SKILL.md for definition
281
+ and member names, access, exact lookup, and existence checks.
282
+ ```
283
+
284
+ ### Download from the documentation site
285
+
286
+ The site serves the same maintained skill files as this repository:
287
+
288
+ - [Skill instructions](https://constutil.patchfork.dev/skills/constutil/SKILL.md)
289
+ - [Codex metadata](https://constutil.patchfork.dev/skills/constutil/agents/openai.yaml)
290
+ - [Complete skill ZIP](https://constutil.patchfork.dev/skills/constutil.zip)
291
+
292
+ Extract the ZIP into `.agents/skills/` for Codex or `.claude/skills/` for Claude
293
+ Code. It contains a `constutil/` directory. Review the instructions and commit the
294
+ installed skill into your project. Installing the Python package does not install
295
+ or activate the skill automatically.
296
+
297
+ ### Claude Code
298
+
299
+ Use the **same** `skills/constutil/` folder; the `SKILL.md` instructions are shared.
300
+ From the consuming project's root:
301
+
302
+ ```sh
303
+ mkdir -p .claude/skills
304
+ cp -R ../constutil/skills/constutil .claude/skills/constutil
305
+ ```
306
+
307
+ Commit that folder for your team. For personal use across projects, copy it to
308
+ `~/.claude/skills/constutil/`. Invoke it as `/constutil`, or let Claude select it
309
+ when the task matches. The optional `agents/openai.yaml` file supplies Codex UI
310
+ metadata; Claude uses `SKILL.md`.
311
+
312
+ Add the same always-use instruction shown above to `CLAUDE.md`, changing the
313
+ reference to `.claude/skills/constutil/SKILL.md`. This makes the project convention
314
+ available each session while the skill supplies the detailed usage guidance.
315
+ See [Claude Code's skill documentation](https://code.claude.com/docs/en/skills).
316
+
317
+ The skill has no dependency on a framework or another skill. Keep its version
318
+ aligned with the library version when updating it.
319
+
320
+ ## Documentation for agents
321
+
322
+ - [llms.txt](https://constutil.patchfork.dev/llms.txt): concise index of documentation,
323
+ examples, and skill instructions.
324
+ - [index.md](https://constutil.patchfork.dev/index.md): this README as plain Markdown.
325
+ - [llms-full.txt](https://constutil.patchfork.dev/llms-full.txt): the README and shared
326
+ skill instructions in one text file.
327
+
328
+ These files and the downloadable skill are generated from the repository on every
329
+ Pages deployment. `llms.txt` is a discovery aid; it does not install skills or make
330
+ an agent follow them automatically.
331
+
332
+ ## Development
333
+
334
+ ```sh
335
+ uv sync --locked
336
+ uv run pytest --cov=constutil --cov-report=term-missing
337
+ uv run ruff check .
338
+ uv run ruff format --check .
339
+ uv run mypy
340
+ uv build
341
+ uv run twine check dist/*
342
+ uv run python scripts/build_docs.py
343
+ ```
344
+
345
+ Tests execute the Python examples in this README as well as checking discovery,
346
+ comparison, defaults, metadata, aliases, and helper semantics. Documentation is
347
+ generated directly from this file into `site/index.html`, so the package README
348
+ and website share one source.
349
+
350
+ ## Publishing
351
+
352
+ ### PyPI
353
+
354
+ The `pypi_publish.yml` workflow runs on a published GitHub release, tests the package
355
+ on Python 3.10–3.14, checks types and formatting, verifies that the release tag
356
+ matches the package version, builds a wheel and source distribution, checks their
357
+ metadata, attaches the same wheel and source distribution to the GitHub release,
358
+ and publishes via PyPI Trusted Publishing. It does not require an API token.
359
+
360
+ One-time setup:
361
+
362
+ 1. Create a GitHub environment named `pypi` in `patchfork/constutil`.
363
+ 2. On PyPI, configure a pending publisher for `constutil` (or a trusted publisher
364
+ if you already own the project): owner `patchfork`, repository `constutil`,
365
+ workflow filename `pypi_publish.yml`, environment `pypi`.
366
+ 3. Update `project.version` in `pyproject.toml`, run `uv lock`, and commit the changes.
367
+ 4. Publish a GitHub release with a matching tag, for example `v1.0.0`.
368
+
369
+ PyPI project-name availability is decided by PyPI when registering or publishing.
370
+ See [PyPI's Trusted Publishing guide](https://docs.pypi.org/trusted-publishers/).
371
+
372
+ ### GitHub Pages
373
+
374
+ Select **GitHub Actions** under the repository's **Settings → Pages → Build and
375
+ deployment → Source**. The `pages.yml` workflow builds this README and deploys it
376
+ on pushes to `main`, or through a manual workflow run.
377
+
378
+ The published site is [constutil.patchfork.dev](https://constutil.patchfork.dev/).
379
+ Its DNS record is `CNAME constutil → patchfork.github.io` (without a repository
380
+ path). The repository's Pages custom-domain setting must also be
381
+ `constutil.patchfork.dev`; this Actions deployment does not use a `CNAME` file.
382
+ HTTPS is managed by GitHub Pages.
383
+
384
+ See [GitHub's Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages).
385
+
386
+ ## License
387
+
388
+ MIT. See [LICENSE](https://github.com/patchfork/constutil/blob/main/LICENSE).