Architecture Overview
pyrig is organized into two layers: a generic core/ layer of reusable Python
utilities and a domain-specific rig/ layer that builds on core/ to implement
all scaffolding logic.
src/pyrig/
├── core/ # Generic, project-agnostic utilities
│ └── introspection/ # Runtime inspection of modules, classes, packages, deps
└── rig/ # pyrig domain logic
├── cli/ # Entry point + subcommand registration
├── configs/ # Declarative config file generators
├── builders/ # Artifact builders (executables, archives)
├── tools/ # CLI tool wrappers (git, uv, pytest, ruff …)
├── tests/ # Mirror test framework + pytest fixtures
└── utils/ # Rig-layer helpers (paths, versions, GitHub API)
Plugin System — DependencySubclass
The foundation of pyrig's extensibility is DependencySubclass, an abstract base
class that provides zero-registration cross-package subclass discovery.
When ConfigFile.subclasses() (or Tool.subclasses(), etc.) is called, it:
- Builds a directed graph of all installed packages rooted at
pyrigusingimportlib.metadata. - For every package in that ancestor set it locates the equivalent sub-package
(e.g.
myproject.rig.configsmirrorspyrig.rig.configs). - Imports every module in that sub-package and collects all non-abstract subclasses.
This means any installed package that depends on pyrig automatically contributes
its ConfigFile, Tool, and BuilderConfigFile subclasses — no entry-point
declaration or explicit registration needed.
pyrig
└── installed dependent A
└── installed dependent B ← B's rig/configs/** is searched automatically
The RigDependencySubclass intermediate base pre-configures the two required hooks
(definition_package → pyrig.rig, base_dependency → pyrig) so that concrete
subsystem classes (Tool, ConfigFile, BuilderConfigFile) inherit discovery
for free. Further BaseSubclasses like Tool and ConfigFile override
definition_package to a more specific sub-package, so that only relevant
modules are imported and searched for further subclasses. This is just for
efficiency — the system would work even if all subclasses were defined
directly under pyrig.rig.
The .L (used for abstract final leafs) classproperty returns the cached
leaf subclass — the single outermost override across all dependencies.
.I (used for concrete final leafs) returns a cached instance of that leaf.
These two shortcuts are used throughout the codebase for all usages of
subclasses of DependencySubclass to allow downstream projects to override
any part of the system by simply defining a new subclass in the right place. :
PackageManager.I.install_dependencies_args().run()
MirrorTestConfigFile.L.validate_all_subclasses()
Config Files — ConfigFile
ConfigFile is the central abstraction for declarative, idempotent file
management. A subclass declares:
parent_path()— where the file livesstem()/extension()— the filename_configs()— the minimum required content (a dict or list)_load()/_dump()— format-specific I/O
Do not override the public methods:
configs()— public cached version of_configs()load()— public cached version of_load()dump()— public version of_dump()that clears the cache ofload()
validate() then enforces the invariant:
file missing? → create it, merge required content, write
file present but incomplete? → merge missing keys/lines, write
file correct? → no-op
User-added content is always preserved; only absent required content is enforced.
This also means that content that you change manually without overriding the right
ConfigFile subclass is overwritten if it chnages one of the content defined
in _configs(), only additions like additional keys in a dict or items in a
list are preserved.
A class hierarchy of format-specific bases builds on top of ConfigFile:
ConfigFile
├── DictConfigFile
│ └── TomlConfigFile → pyproject.toml, prek.toml …
├── YamlConfigFile
│ └── YmlConfigFile
│ └── DictYmlConfigFile → GitHub Actions workflows
├── JsonConfigFile
│ └── DictJsonConfigFile → branch-protection.json
└── ListConfigFile
├── StringConfigFile
│ └── MarkdownConfigFile → README.md, CONTRIBUTING.md …
└── BuilderConfigFile → artifact builders (repurposes the interface)
Tools — Tool and Args
Every external tool is wrapped in a Tool subclass. A Tool exposes methods
that return Args objects. Args is an immutable
tuple[str, ...] subclass with .run() and .run_cached() that run the command
via subprocess. This allows tools to be used in a declarative, composable way:
class PackageManager(Tool):
def install_dependencies_args(self) -> Args:
return self.args("sync")
PackageManager.I.install_dependencies_args().run()
# → subprocess: uv sync
Any usages of tools by pyrig works through these Tool subclasses, so
downstream projects can override any command or method that provides information
about the tool by overriding the relevant method in the relevant Tool subclass.
Mirror Tests — MirrorTestConfigFile
MirrorTestConfigFile is a ConfigFile subclass that treats test files as
configuration: _configs() returns the expected list of test stubs and _load()
reads the existing test file. validate() appends only the missing stubs, never
touching existing implementations.
At test-run time a session-scoped autouse fixture (all_modules_tested) enforces
that every source module has a corresponding test file, failing the session if any
are absent. Combined with all_config_files_correct (which checks all other
ConfigFile subclasses), the test suite acts as a continuous conformance check
for the whole project structure.
CLI Dispatch
The single entry point pyrig.rig.cli.cli:main is shared by pyrig and every
downstream project. On startup it:
- Derives the calling package name from
sys.argv[0](the console-script path). - Imports
<package>.rig.cli.subcommandsand registers all its functions as Typer commands — these are project-specific commands. - Walks the full dependency chain from
pyrigto the calling package, importing<package>.rig.cli.shared_subcommandsfrom each, and registers those functions as shared commands available in every project.
This means pyrig's shared version command is available in every
downstream project without any explicit registration because it is defined in
pyrig.rig.cli.shared_subcommands. Any project can define its own commands in
<package>.rig.cli.subcommands and they will be available in that project
without any explicit registration.
A project can also define its own shared commands in <package>.rig.cli.shared_subcommands
and they will be available in every project downstream of it, not including
upstream dependencies like pyrig itself.
CI/CD Pipeline
The four generated GitHub Actions workflows are chained via workflow_run triggers:
Health Check ──► Build ──► Release ──► Deploy
Each workflow is a WorkflowConfigFile subclass (DictYmlConfigFile) that
assembles the YAML structure from composable building-block methods (job(),
step_run_tests(), steps_core_installed_setup(), etc.). The generated YAML is
checked into the repository and kept in sync by pyrig mkroot.
How to subclass and override
To subclass a DependencySubclass (or RigDependencySubclass specifically) like
ConfigFile or Tool it must be defined under the same package path as the
base class from the package root, meaning a subclass defined in a module under
some_pyrig_project.rig.configs.some_mdoule must be defined under the same package
respectively as defined in the method definition_package() of the base class,
which is pyrig.rig.configs for ConfigFile.
So your ovveride must be defined a module like: my_project.rig.configs.my_config_file.py.
As can be seen the initial part that is the project's name is the only part that
can be different, the rest of the path must be the same as the base class's
definition package. Each module under that path is searched for subclasses and
they are registered as plugins, so in this case what the name of the final module
is doesn't matter as long as it is under the correct path, so it could be
my_project.rig.configs.my_config_file.py or my_project.rig.configs.some_folder.my_other_config_file.py.
Now this sounds all a bit complicated and can get difficult to understand, especially if you then need to look up what config files you need to override to change a specific part of the system.
To make this process easier there is the pyrig subcls CLI command that opens
a fuzzy search interafce in the terminal where you can search for any class
that you want to override and after you select it will generate a skeleton of
the subclass for you in the correct path with the correct imports and everything,
so you can just fill in the logic that you want to change without worrying about
where to define it and how to import it and all that.
Together with the CodeWiki where you can ask the AI about the codebase and get explanations about how everything works and where to look to change specific parts of the system, this makes it very easy to understand how to customize pyrig for your project and what parts of the system you need to override to change specific behaviors.
Usage Recommendations
Our suggestions is that you use pyrig only once to create your own personal package and publish it to PyPI, so that you can customize it once with all behaviour you want for your projects, and after that you can simply use your package to start new projects by running:
uv init my-new-project
cd my-new-project
uv add my-pyrig-package
uv run pyrig init
All your customizations to pyrig will be in your package and you can keep improving and customizing it as much as you want, and all your projects that depend on it will automatically get all the benefits of your customizations without you needing to make customizations in each of your projects.