Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
7.8 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
app_skellington is a small Python library (published to PyPI) for building CLI applications. It has nothing runnable on its own. It glues together argparse-based multi-level command menus, name-based dependency injection, ConfigObj INI config with spec validation, and colorlog logging. It targets Python >=3.8. Only Linux is tested; Windows and macOS are goals. Runtime deps are appdirs, configobj and colorlog.
Commands
uv sync # .venv with the package (editable) + dev group: ruff, pytest, pre-commit, pyyaml
uv run pytest # full suite (testpaths = tests)
uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults # single test
uv run pytest -k "<keyword>"
uv run --isolated --python 3.8 pytest # any supported Python, without switching .venv
uv run ruff check --fix # lint + import sorting (config in pyproject.toml)
uv run ruff format # formatter, line length 88
uv build # sdist + wheel into dist/; the version shows in the file names
uv lock # after any dependency change; commit uv.lock
- Versioning is tag-driven. setuptools_scm uses
release-branch-semverand derives the version from the latestvX.Y.Zgit tag. It generatesapp_skellington/_version.py, which is git-ignored, so never commit it. The release steps (tag onmain,uv build, TestPyPI, verify, PyPI) are in the README's Publish section. - ruff handles formatting (88 columns) and lint (
E,F,W,I). E501 is ignored on purpose, since the formatter owns line length. Keep theruff==pin inpyproject.tomlequal to therev:in.pre-commit-config.yaml. - Tests cover only
cfg(ConfigObj fixtures live intests/cfg/) plus a CommandTree constructor smoke test.tests/test_log.pyis empty.
Python support policy
requires-python is >=3.8, and the claim should match what CI actually tests. When an end-of-life Python version starts failing, drop it: raise requires-python and remove its CI lane. Don't add compat shims, version branches or contorted code just to keep it working. Clean code matters more than supporting EOL runtimes. Users on old Pythons keep the older releases on PyPI.
CI
.gitea/workflows/ci.yaml runs on PRs and on pushes to develop/main. PRs titled WIP: are skipped; removing the prefix starts the run.
lint: ruff.test: a matrix over CPython 3.8–3.14 inpython:<v>containers. Each lane runs the locked suite, then builds the wheel and sdist, installs each into a clean venv, and runsscripts/smoke_import.pyfrom outside the source tree.ci-ok: the only required check (CI / ci-ok (pull_request)). It usesif: always()and fails unless every gate succeeded, because Gitea reports a skipped job as success.
tests/test_ci_workflow.py pins these invariants. To add or drop a Python version, change all four together: the matrix, the classifiers, scripts/verify_index_release.sh's list, and NEWEST_MINOR (or requires-python).
Architecture
Package layout: _bootstrap.py, _util.py, cfg.py, log.py, cli.py and app_container.py. __init__.py re-exports the public names explicitly.
ApplicationContainer (app_container.py) is the entry point applications subclass. Its __init__ does the following, in order:
- builds
cfg.Config(configspec_filepath, configini_filepath) - builds
log.LoggingLayerand configures it from the config's[logging]section, or fromDEFAULT_LOG_SETTINGSif there is none - creates
ApplicationContext(self.ctx, which holds config, log and parsed argv) - registers
ctxas a service and createsself.cli = CommandTree() - calls the optional subclass hooks
_cli_options(),_services(),_command_menu(), in that order, but only if they are defined
Applications then call invoke_from_cli(), which parses argv and dispatches.
Dependency injection works by parameter name.
- Services are registered as factories:
app["db"] = lambda: Db(...). app["db"]calls the factory each time, so factories decide whether services are singletons.- An unknown name raises
ServiceNotFound. _inject_service_dependencies(cls)readscls.__init__'s parameter names (skippingself) and returns a partial. When that partial is called, it resolves each name from the container and constructs the class.
Classes become commands. _util.register_class_as_commands(app, submenu, Cls) registers every public function of Cls as a command. Each invocation constructs a fresh Cls instance with its dependencies injected, then calls the method. The method's signature (minus self) and its docstring are what argparse sees. You pass the class, not an instance.
CommandTree (cli.py) maps function signatures onto argparse.
- Parameters with defaults become optional positionals (
nargs="?"). Parameters without defaults become required positionals. Options can be added withadd_argument. - Help text is the first sentence of the docstring (
HelpGenerator). - It runs in one of two mutually exclusive modes. In submenu mode,
init_submenu()→SubMenu.create_submenu()/register_command()fills nested subparsers, tracked inentries. In single-command mode,CommandTree.register_command()binds one function directly to the root parser. The two modes are guarded by asserts in_lookup_command. - Dispatch walks the parsed namespace one submenu
var_nameat a time until it hits aCommandEntry. It then calls the callback with the args whose names match the signature. - argparse placement matters:
prog --opt sub cmdapplies--optat the root, whileprog sub cmd --optapplies it to the command.
ServiceNotFound and NoCommandSpecified live in _util.py. That placement avoids a circular import between cli and app_container. Don't move them back into app_container.
Config (cfg.py) wraps configobj.ConfigObj.
- Setting
configspec_filepathorconfigini_filepathreloads and revalidates immediately. - Validation runs with
copy=True, so spec defaults populate the config. - Interpolation is
"template"($var, not%(var)s). - Keys beyond the spec are allowed.
- A validation failure logs and returns
False; it doesn't raise. The strict-validation flag is hard-coded off, and thecapabilitiesconstructor argument is ignored. EnvironmentVariablesis an unimplemented stub.
Logging (log.py, _bootstrap.py).
- The framework's own logs go to a private logger named
skell(_bootstrap_logger). It is silenced to CRITICAL withpropagate=False, unless the env varAPPSKELLINGTON_DEBUGis set. The README'sAPPSKELLINGTON_ENABLE_LOGGINGis stale; the code only readsAPPSKELLINGTON_DEBUG. - That env var is checked twice: once at import in
_bootstrap.py, and again inLoggingLayer._add_own_logconfigwhen the logging config is (re)applied. LoggingLayer.transform_configadapts a ConfigObj-friendly dict forlogging.config.dictConfig. It forcesversion=1and turns level strings ("debug","all", ...) into ints. It renames the logger keyrootto"", because ConfigObj can't hold an empty key. It also resolves a relativehandlers.file.filenameintoappdirs.user_log_dir(appname, appauthor)._bootstrap.pyalso checks at import time that the third-party deps are installed, and raisesImportErrorif they're missing.
Branches
develop is the integration branch: feature branches come off it, and PRs target it. Changes reach main through a develop → main PR. Release tags (vX.Y.Z) go on main. In 2026-10, develop was recreated from main; the 2024 develop and its PR #2 were superseded. Modernization roadmap: docs/superpowers/specs/2026-10-04-modernization-design.md.