Files
app_skellington/CLAUDE.md
T
2026-10-04 18:53:41 -06:00

77 lines
6.6 KiB
Markdown

# 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 "<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-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.
- **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.