Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6.6 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 on Linux, Windows and Mac. Runtime deps are appdirs, configobj and colorlog.
Commands
pip install -e '.[dev]' pytest # dev extras = black, isort, flake8, pre-commit; pytest is NOT in the extras
pytest tests # full suite from repo root (or `pytest .` from inside tests/)
pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults # single test
pytest tests -k "<keyword>"
black app_skellington
isort --profile black app_skellington
flake8 app_skellington # .flake8: max-line-length 120, tests/ excluded
python -m build # sdist + wheel into dist/
python -m setuptools_scm # print the version the build will get
- Versioning is tag-driven. setuptools_scm uses
release-branch-semverand derives the version from the latestvX.Y.Zgit tag. It writesapp_skellington/_version.pyat build time. That file is tracked, but don't hand-edit it.pip install -e .also rewrites it, sogit restoreit rather than committing the churn. A release means tagging, thenpython -m build, thentwine upload dist/*. The README covers the full publish steps. - Formatter config is inconsistent.
pyproject.tomlhas no[tool.black], so black runs at its default width of 88. Only flake8 allows 120. The code onmainis formatted at 88. The pre-commit black hook pinslanguage_version: python3.8. It fails unless a 3.8 interpreter is on PATH (.python-versionnames 3.8.x for pyenv). - Lint is not currently clean on
main. black wants to reformat 2 files, isort flags 3, and flake8 reports W391 and W503. Keep unrelated reformatting out of feature diffs. - Tests cover only
cfg(ConfigObj fixtures live intests/cfg/) plus a CommandTree constructor smoke test.tests/test_log.pyis empty.
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
main is the active branch. develop exists on the remote but is stale: it stopped in 2024-08 and still uses the old Poetry build.