From d02d38872ce6e41b673362485104d8f9ae349b97 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 18:53:04 -0600 Subject: [PATCH] doc: add CLAUDE.md for Claude Code sessions Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be89afa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# 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.