# 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 = 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 "" 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-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. A release means tagging, then `python -m build`, then `twine upload dist/*`. The README covers the full publish steps. - **Formatter config is inconsistent.** `pyproject.toml` has no `[tool.black]`, so black runs at its default width of 88. Only flake8 allows 120. The code on `main` is formatted at 88. The pre-commit black hook pins `language_version: python3.8`. It fails unless a 3.8 interpreter is on PATH (`.python-version` names 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 in `tests/cfg/`) plus a CommandTree constructor smoke test. `tests/test_log.py` is 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: 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 `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.