Tool Architecture
pyrig's tools system provides type-safe wrappers around command-line tools
through a composable Tool and Args pattern. This document explains the
design philosophy and extensibility mechanisms - for API details, see
the source docstrings.
Design Philosophy
The tools system is built on three core principles:
- Single Source of Truth: Each tool has one wrapper class that defines all its commands
- Automatic Propagation: Customizations in dependent packages automatically apply everywhere
- Explicit Over Implicit: Commands are constructed as inspectable
Argsobjects before execution
Two Extension Mechanisms
pyrig provides two complementary patterns for customization. Understanding when to use each is essential:
| Mechanism | Purpose | Used For |
|---|---|---|
.I |
Get an instance of the deepest subclass of a Tool or ConfigFile | Internal pyrig operations that should use your customizations |
subclasses() |
Discover all ConfigFile implementations | Finding all configs to generate, including new ones you define |
The .I Pattern: Dynamic Tool Resolution
The .I property resolves to an instance of the deepest subclass in the
inheritance chain. pyrig uses .I internally so your customizations propagate
automatically.
Example: How prek uses .I
The prek config file uses .I to reference tools:
# pyrig/rig/configs/git/pre_commit.py
hooks = [
self.hook("lint-code", Linter.I.check_fix_args()),
self.hook("check-types", TypeChecker.I.check_args()),
# ...
]
Because it uses TypeChecker.I (not TypeChecker directly), if you subclass
TypeChecker to use mypy:
# myapp/rig/tools/type_checker.py
from pyrig.rig.tools.type_checker import TypeChecker as BaseTypeChecker
class TypeChecker(BaseTypeChecker):
def name(self) -> str:
return "mypy"
The prek config automatically uses mypy. No need to override the config file itself.
The subclasses() Pattern: ConfigFile Discovery
For ConfigFile classes, pyrig uses subclasses() to discover all
non-abstract implementations across all dependent packages. This enables:
- Adding entirely new config files by creating new
ConfigFilesubclasses - Overriding existing configs by subclassing them (the parent is discarded)
Note: pyrig.PyprojectConfigFile is not in the result because
myapp.PyprojectConfigFile subclasses it - only leaf classes are returned.
Dynamic vs Static: When Each Applies
Not everything in pyrig uses dynamic resolution. Understanding the difference prevents confusion:
Dynamic (Uses .I - Your Subclass Applies Automatically)
| Component | Why Dynamic |
|---|---|
| Prek hooks | Uses Tool.I.*_args() to build commands |
| CLI commands | Uses Tool.I for all operations |
| Most config file content | Generated from Tool.I or ConfigFile. |
If you subclass a Tool, these automatically use your version.
Static (Hardcoded - Requires Override)
| Component | Why Static | What To Override |
|---|---|---|
| GitHub Actions workflow steps | External action references can't be dynamic | Subclass the WorkflowConfigFile and override step_* methods |
| External tool versions | Pinned for reproducibility | Subclass and override version constants |
Example: Container Engine in Workflows
The workflow uses a hardcoded GitHub Action for Podman:
# pyrig/rig/configs/base/workflow.py
def step_install_container_engine(self, ...):
return self.step(
uses="redhat-actions/podman-install@main", # Hardcoded!
...
)
Even if you subclass ContainerEngine to use Docker, the workflow still
installs Podman. You must also subclass the workflow:
# myapp/rig/configs/workflows/build.py
from pyrig.rig.configs.workflows.build import BuildWorkflowConfigFile as BaseBuildWorkflowConfigFile
class BuildWorkflowConfigFile(BaseBuildWorkflowConfigFile):
def step_install_container_engine(self, *, step=None):
return self.step(
step_func=self.step_install_container_engine,
uses="docker/setup-buildx-action@v3",
step=step,
)
Subclassing Guide
Extending a Tool (Add Behavior)
# myapp/rig/tools/linter.py
from pyrig.rig.tools.linter import Linter as BaseLinter
from pyrig.src.processes import Args
class Linter(BaseLinter):
def check_args(self, *args: str) -> Args:
# Always include --show-source
return super().check_args("--show-source", *args)
Replacing a Tool (Change the CLI)
# myapp/rig/tools/type_checker.py
from pyrig.rig.tools.type_checker import TypeChecker as BaseTypeChecker
class TypeChecker(BaseTypeChecker):
def name(self) -> str:
return "mypy" # Use mypy instead of ty
Adding a New ConfigFile
# myapp/rig/configs/my_config.py
from pathlib import Path
from pyrig.rig.configs.base.toml import TomlConfigFile
class MyAppConfigFile(TomlConfigFile):
def parent_path(self) -> Path:
return Path()
def _configs(self) -> dict:
return {"app": {"name": "myapp"}}
This config is automatically discovered and generated when pyrig init
runs.
Overriding an Existing ConfigFile
# myapp/rig/configs/pyproject.py
from pyrig.rig.configs.pyproject import PyprojectConfigFile as BasePyproject
class PyprojectConfigFile(BasePyproject):
def _configs(self) -> dict:
config = super()._configs()
config["tool"]["myapp"] = {"custom": "setting"}
return config
The parent class is automatically excluded from discovery.
Tool Replacement Complexity
| Replacement | Complexity | What's Needed |
|---|---|---|
| ty → mypy | Low | Just override name() in a TypeChecker subclass |
| ruff → black | Low | Override name() in a Linter subclass and adjust methods |
| Podman → Docker | Medium | Subclass Tool + override workflow steps |
| uv → pip | High | Affects nearly everything |
Note: We have not exhaustively tested all replacement scenarios. You may need additional adjustments beyond what is documented. We recommend using pyrig's default tools - they were chosen for their quality and integration.
Why Some Replacements Need More Work
- ty → mypy: Prek uses
TypeChecker.I, so it's automatic - Podman → Docker: WorkflowConfigFile steps use
hardcoded GitHub Actions, not
.I
The rule: If pyrig uses .I, your subclass applies automatically. If it's
hardcoded (like external action references), you must override.
See Also
- Trade-offs - What you sacrifice and gain with pyrig
- Tooling - Why pyrig chose each tool