# 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 ```bash pip install -e '.[dev]' pytest # dev extras = ruff, 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 "" ruff check --fix # lint + import sorting (config in pyproject.toml) ruff format # formatter, line length 88 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-semver` and derives the version from the latest `vX.Y.Z` git tag. It writes `app_skellington/_version.py` at build time. That file is tracked, but don't hand-edit it. `pip install -e .` also rewrites it, so `git restore` it rather than committing the churn. A release means tagging, then `python -m build`, then `twine upload dist/*`. The README covers the full publish steps. - **ruff** handles formatting (88 columns) and lint (`E`, `F`, `W`, `I`). E501 is ignored on purpose, since the formatter owns line length. Keep the `ruff==` pin in `pyproject.toml` equal to the `rev:` in `.pre-commit-config.yaml`. - Tests cover only `cfg` (ConfigObj fixtures live in `tests/cfg/`) plus a CommandTree constructor smoke test. `tests/test_log.py` is 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. ## 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: 1. builds `cfg.Config(configspec_filepath, configini_filepath)` 2. builds `log.LoggingLayer` and configures it from the config's `[logging]` section, or from `DEFAULT_LOG_SETTINGS` if there is none 3. creates `ApplicationContext` (`self.ctx`, which holds config, log and parsed argv) 4. registers `ctx` as a service and creates `self.cli = CommandTree()` 5. 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)` reads `cls.__init__`'s parameter names (skipping `self`) 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 with `add_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 in `entries`. 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_name` at a time until it hits a `CommandEntry`. It then calls the callback with the args whose names match the signature. - argparse placement matters: `prog --opt sub cmd` applies `--opt` at the root, while `prog sub cmd --opt` applies 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_filepath` or `configini_filepath` reloads 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 the `capabilities` constructor argument is ignored. - `EnvironmentVariables` is 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 with `propagate=False`, unless the env var **`APPSKELLINGTON_DEBUG`** is set. The README's `APPSKELLINGTON_ENABLE_LOGGING` is stale; the code only reads `APPSKELLINGTON_DEBUG`. - That env var is checked twice: once at import in `_bootstrap.py`, and again in `LoggingLayer._add_own_logconfig` when the logging config is (re)applied. - `LoggingLayer.transform_config` adapts a ConfigObj-friendly dict for `logging.config.dictConfig`. It forces `version=1` and turns level strings (`"debug"`, `"all"`, ...) into ints. It renames the logger key `root` to `""`, because ConfigObj can't hold an empty key. It also resolves a relative `handlers.file.filename` into `appdirs.user_log_dir(appname, appauthor)`. - `_bootstrap.py` also checks at import time that the third-party deps are installed, and raises `ImportError` if 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`.