Guidance for AI agents working in this repository. GEMINI.md and AGENTS.md
are symlinks to this file — edit this one.
switchlang adds an explicit switch statement to Python without changing the
language. It is implemented as a context manager: you open a with switch(value)
block, register cases as method calls, and read the matched case's return value
from s.result. The whole library is ~200 lines with zero runtime dependencies.
- Repo name is
python-switch; the PyPI / import name isswitchlang. Don't confuse the two —import switchlang,pip install switchlang. - The package ships type hints and a
py.typedmarker (PEP 561). It isTyping :: Typed. - The default git branch is
master, notmain. Several tools (andgreat-docs.yml) hard-code this.
| Path | What it is |
|---|---|
| switchlang/init.py | Public package: re-exports switch and closed_range, sets __version__/__author__/__all__. |
| switchlang/__switchlang_impl.py | The entire implementation — switch class and closed_range(). Make code changes here. |
| switchlang/py.typed | PEP 561 marker so type checkers read our hints. |
| tests/test_core.py | The whole test suite (29 unittest-style tests). |
| pyproject.toml | Packaging (hatchling), metadata, version, [dev] extra. |
| ruff.toml | Lint + format config. |
| great-docs.yml | Docs site config (Great Docs / Quarto). |
| scripts/build_docs.py | Builds docs and mirrors them into docs/. |
| scripts/serve_docs.py | Local preview that mimics the nginx subpath. |
| docs/ | Generated static site (committed, served at mkennedy.codes/docs/python-switch). Do not hand-edit. |
| README.md | The narrative/marketing docs and rationale. Keep in sync with behavior. |
Generated / ignored artifacts you should not edit or commit by hand: docs/
(regenerate it), great-docs/ (ephemeral build dir, gitignored), dist/,
*.egg-info/, venv/.
Only two names are public (switchlang.__all__): switch and closed_range.
from switchlang import switch, closed_range
with switch(value) as s:
s.case('a', process_a) # key == value -> run process_a
s.case(['v', 'b'], view_bookings) # list key: each item is a case
s.case(range(1, 6), handler) # range key: each item is a case
s.case(closed_range(1, 5), handler) # inclusive range: 1,2,3,4,5
s.case(2, do_two, fallthrough=True) # opt into running the next case too
s.default(unknown_command) # runs if nothing else matched
print(s.result) # return value of the executed caseBehaviors that are easy to get wrong — preserve all of these (they are pinned by tests):
- Cases run on block exit, not at registration.
case()/default()only register; the matched function(s) execute in__exit__. Sos.resultis only valid after thewithblock. Reading it inside the block raises. - Matching is equality-based (
key == value). Keys are also stored in aset, so case keys must be hashable. Any hashable value works as a key, includingNoneand arbitrary objects. default()is just a case keyed on a private sentinel, and ordering is not enforced: a default registered before a matching case will also run. Always registerdefault()last.resultuses identity, not equality, against its "no result" sentinel. A computed result with a permissive__eq__(e.g. a NumPy array) must not be mistaken for "nothing computed."Noneis a valid computed result and is distinct from "not computed."case()returnsbool—Trueif the case (or any item of a list/range key) matched.- Fall-through is opt-in per case via
fallthrough=True; the next registered case then runs whether or not its key matches, and so on until a case without fall-through. When falling through,resultis the last function executed. Thefallthrough=Nonevalue is reserved for internal recursion (list/range expansion) and must not be used by callers. closed_range(start, stop, step=1)is inclusive on both ends and never overshootsstop:closed_range(1, 5)->1,2,3,4,5;closed_range(1, 6, 2)->1,3,5;closed_range(1, 7, 2)->1,3,5,7. Note adjacent closed ranges overlap (closed_range(1,5)andclosed_range(5,9)both contain 5) and will raise a duplicate-case error.
Validation (all raise on registration/exit):
- Duplicate case key ->
ValueError. functhat isNoneor not callable ->ValueError.- Empty list/range key (
[]) ->ValueError(it could never match). closed_rangewithstart >= stoporstep < 1->ValueError.- No case matched and no
default()registered ->Exceptionon block exit. - An exception raised inside the
withblock propagates and aborts the switch:__exit__re-raises it before any case actions run, so no case actions run. This holds for any exception (the guard isif exc_val is not None:, so an exception whose__bool__is falsy still aborts — see Implementation notes).
- The implementation lives in a deliberately private module,
__switchlang_impl.py(leading dunder);__init__.pyre-exportsswitchandclosed_range. Import via the package (from switchlang import ...), never the impl module directly. - The
switchclass's__no_resultand__defaultare name-mangled class attributes assigneduuid.uuid4()at class-load time (mangled to_switch__no_result/_switch__default) — unique, opaque sentinels. Don't rename the class or hoist these to module level, and keep theresultcheck identity-based (is, not==); that identity check is what stops a result with a permissive__eq__(e.g. a NumPy array) from being read as "no result." __exit__guards exception re-raise withif exc_val is not None:— an identity check, not truthiness. This is deliberate (fixed in #15): testingif exc_val:would invoke the exception's__bool__/__len__, so a falsy exception would slip past and let the matched case run. Keep itis not None.
There is a local, gitignored uv-managed venv/ — it is not in the repo, so
on a fresh checkout it is absent (and even on the maintainer's machine its symlinks may
be broken). Never rely on venv/bin/python existing; use uv run, which provisions
the environment on demand. Note that the tracked .vscode/tasks.json
and .vscode/launch.json (Build / Preview Docs) hard-code
venv/bin/python and venv/bin/great-docs, so those IDE configs only work once such a
venv exists at that path — prefer uv run from the CLI.
# Run the test suite — fast path, no extra deps (tests are unittest-based):
uv run python -m unittest discover -s tests
# Or with pytest + the full dev environment (pulls in great-docs and its deps):
uv run --extra dev pytest -q
# Lint and format (config in ruff.toml):
uvx ruff check .
uvx ruff format .Coding constraints — match the existing style:
- Python 3.9+ is supported (classifiers go through 3.15). Do not use syntax
that breaks 3.9. The impl uses
from __future__ import annotationsso it can write modern annotation syntax (set[Any],X | None) while still importing on 3.9. Ruff'starget-versionis pinned topy39to enforce this. - Ruff config: line length 120, single-quoted strings, import sorting on
(
I), error/pyflakes rules (E,F), McCabe max complexity 10. - Keep the public API fully type-hinted (the package is
py.typed). Note no static type checker is wired into the project — ruff only does lint/format/import-sort (E/F/I). Validate hints ad hoc if you want, e.g.uvx mypy switchlangoruvx ty check switchlang; there is no CI gate for types. - When you change behavior, update all three places that document it:
the docstrings in
__switchlang_impl.py(source of the API reference), the README, and the tests. Then rebuild the docs.
- The version of record is
[project].versionin pyproject.toml (currently0.1.3).__version__is read at runtime from installed package metadata viaimportlib.metadata, falling back to'0.0.0'when not installed. Because it reflects installed metadata, after a bump you must reinstall (uv pip install -e .) before__version__or the introspected docs show the new number.uv.lockis gitignored, so there is no committed lockfile.- Gotcha: a stale
switchlang.egg-info/in the repo root (left by an oldsetup.py-era build) shadows the venv's*.dist-infoonsys.pathand makesimportlib.metadatareport an old version no matter what you reinstall. If__version__looks wrong,rm -rf switchlang.egg-info(it's gitignored).
- Gotcha: a stale
- To cut a release: bump
[project].version, rename## [Unreleased]inCHANGELOG.mdto## [X.Y.Z] - <date>(add a fresh empty[Unreleased]and a new compare-link line in the footer), reinstall, then rebuild docs. The docs version badge comes from PyPI (pypi: true), so it shows the latest published release and only updates to the new version after you publish to PyPI and rebuild — it legitimately lags during a release. - Build backend is hatchling; the wheel packages the
switchlang/directory. - The changelog is hand-maintained in the repo-root CHANGELOG.md
(Keep a Changelog format). Record every
user-facing change there under the
## [Unreleased]heading; on release, rename that heading to the new version. The docs site renders this file —scripts/build_docs.pystagesCHANGELOG.mdintochangelog/index.qmdbefore each build (configured ingreat-docs.yml), so the generateddocs/changelog/output is not a source you edit.
Docs are built with Great Docs (Quarto) and dynamically introspect the
installed package, so the docstrings in __switchlang_impl.py are the real source
of the API reference. The flow:
scripts/build_docs.pyrunsgreat-docs build(output ->great-docs/_site, which is ephemeral/gitignored) and mirrors it into the committeddocs/folder (full replace, so deleted pages don't linger).scripts/serve_docs.pyservesdocs/locally on port 8099 under the/docs/python-switchsubpath to mirror production nginx and catch absolute-path asset bugs.- Production serves the committed
docs/athttps://mkennedy.codes/docs/python-switch/.
great-docs.yml has documented gotchas (subpath site_url needs a trailing
slash; SEO canonical.base_url must be set explicitly or the sitemap points at
the wrong domain; mcp.enabled is disabled; source.branch is master). The
generated site also includes agent-skill descriptors under
docs/.well-known/ and llms.txt/llms-full.txt — these are
build output (produced from the docstrings and great-docs.yml), so edit the
sources and rebuild rather than hand-editing them.
- Edit switchlang/__switchlang_impl.py.
- Update/extend tests/test_core.py and run
uv run python -m unittest discover -s tests. - Keep docstrings + README.md in sync with the new behavior.
- Add a
## [Unreleased]entry to CHANGELOG.md for any user-facing change. uvx ruff format . && uvx ruff check .- If docs-visible, rebuild:
uv run --extra dev python scripts/build_docs.py. - Bump
[project].versionin pyproject.toml if releasing.