Files

7.8 KiB
Raw Permalink Blame History

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. Only Linux is tested; Windows and macOS are goals. Runtime deps are appdirs, configobj and colorlog.

Commands

uv sync                          # .venv with the package (editable) + dev group: ruff, pytest, pre-commit, pyyaml
uv run pytest                    # full suite (testpaths = tests)
uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults   # single test
uv run pytest -k "<keyword>"
uv run --isolated --python 3.8 pytest   # any supported Python, without switching .venv

uv run ruff check --fix          # lint + import sorting (config in pyproject.toml)
uv run ruff format               # formatter, line length 88

uv build                         # sdist + wheel into dist/; the version shows in the file names
uv lock                          # after any dependency change; commit uv.lock
  • Versioning is tag-driven. setuptools_scm uses release-branch-semver and derives the version from the latest vX.Y.Z git tag. It generates app_skellington/_version.py, which is git-ignored, so never commit it. The release steps (tag on main, uv build, TestPyPI, verify, PyPI) are in the README's Publish section.
  • 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.

CI

.gitea/workflows/ci.yaml runs on PRs and on pushes to develop/main. PRs titled WIP: are skipped; removing the prefix starts the run.

  • lint: ruff.
  • test: a matrix over CPython 3.8–3.14 in python:<v> containers. Each lane runs the locked suite, then builds the wheel and sdist, installs each into a clean venv, and runs scripts/smoke_import.py from outside the source tree.
  • ci-ok: the only required check (CI / ci-ok (pull_request)). It uses if: always() and fails unless every gate succeeded, because Gitea reports a skipped job as success.

tests/test_ci_workflow.py pins these invariants. To add or drop a Python version, change all four together: the matrix, the classifiers, scripts/verify_index_release.sh's list, and NEWEST_MINOR (or requires-python).

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.