Build a Craft-Parts Lifecycle CLI
Build a miniature craft-parts lifecycle engine with plugins and YAML validation.
Introduction
30 Second Summary
Every time you install a snap package or deploy a charm on Ubuntu, a lifecycle engine orchestrates multi-step builds behind the scenes. These engines manage dependencies between components, validate configurations, and run each part through a strict sequence of steps.
In this project, you will build a miniature version of Canonical's craft-parts lifecycle engine called "minicraft". It reads a YAML project file, validates it using Pydantic, plans execution order by resolving dependencies, and runs actions through a plugin system.
What You'll Build
A Python CLI tool that mirrors the exact architectural patterns used by Canonical's Starcraft team across Snapcraft, Rockcraft, and Charmcraft.
By the end of this project, you'll have:
- A Python package with pyproject.toml configuration, Ruff linting, and uv package management matching the starbase template conventions.
- A plugin system using abstract base classes with two concrete implementations (dump and python plugins).
- A lifecycle engine that resolves dependencies via topological sort and executes PULL, BUILD, STAGE, and PRIME steps across multiple parts.
- Secret Mission: Add pytest unit tests covering models, lifecycle planning, and plugin registration.
Are there any prerequisites?
You should have basic Python experience with classes, functions, and imports. Familiarity with running commands in a terminal is also helpful.
No prior knowledge of Canonical's tooling or lifecycle engines is needed. This project teaches those patterns from scratch.
Set Up the Project
With uv and Python 3.12 ready, it is time to scaffold the minicraft project itself.
A well-structured Python project needs more than just code files. It needs dependency declarations, linting rules, and a source layout that mirrors production standards. In this step you will create the project skeleton using uv, add the libraries minicraft depends on, and configure Ruff linting to match the starbase template used by the Starcraft team.
In this step, get ready to:
- Create the minicraft project with uv and add its dependencies.
- Build the src layout directory structure.
- Configure pyproject.toml with Ruff settings and verify the setup.
Create the project with uv
The uv init command scaffolds a new Python project with a pyproject.toml and starter files. You will then add runtime and dev dependencies so uv can manage them for you.
- Move to your Desktop by running this command:
cd ~\Desktop
- Create the minicraft project and move into it by running these commands:
uv init minicraft --python 3.12
cd minicraft
What does this do?
The uv init minicraft --python 3.12 command creates a new directory called minicraft with a pyproject.toml, a main.py starter file, and a .python-version pinned to 3.12.
You will replace the starter file with a proper src layout shortly.
- Add the runtime dependencies (Pydantic for model validation and PyYAML for YAML parsing) by running:
uv add pydantic pyyaml
- Add the dev dependencies (Ruff for linting and pytest for testing) by running:
uv add --dev ruff pytest
Why separate runtime and dev dependencies?
Runtime dependencies (pydantic, pyyaml) ship with your tool when others install it. Dev dependencies (ruff, pytest) are only needed while you are developing. Keeping them separate avoids bloating the production install.
- Confirm the dependencies installed successfully by checking the terminal output for resolved package versions.
Seeing a network error?
Make sure you have an active internet connection. If you are behind a corporate proxy, check that your HTTPS_PROXY environment variable is set.
Help me troubleshoot
Now you need to create the source layout that mirrors how the Starcraft team structures packages.
- Delete the auto-generated main.py starter file by running:
Remove-Item main.py
- Create the src layout directories by running:
mkdir src\minicraft\plugins
mkdir tests
mkdir examples
- Confirm the directories exist by running:
ls
- You should see examples, src, and tests directories alongside pyproject.toml.
Directory already exists error?
If you see a message about the directory already existing, that is fine. PowerShell's mkdir does not fail destructively. Just confirm the folders are present with ls.
Help me troubleshoot
Configure pyproject.toml with Ruff and pytest settings
The pyproject.toml generated by uv init is minimal. You need to add the Ruff linter configuration and pytest settings that match the starbase template conventions.
- Open the minicraft folder in VS Code.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the minicraft folder, and click Select Folder.
- Open pyproject.toml from the Explorer sidebar.
- Replace the entire contents of the file with this configuration:
[project]
name = "minicraft"
version = "0.1.0"
description = "A miniature craft-parts lifecycle engine"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.13.4",
"pyyaml>=6.0.2",
]
[tool.uv]
dev-dependencies = [
"ruff>=0.15.18",
"pytest>=8.3.5",
]
[tool.ruff]
target-version = "py312"
line-length = 99
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "ANN", "B", "A", "COM", "C4", "PT"]
ignore = ["ANN101", "ANN102"]
[tool.pytest.ini_options]
testpaths = ["tests"]
What does this configuration do?
The [tool.ruff] section targets Python 3.12 and sets a max line length of 99 characters. The select list enables lint rules for errors, imports, naming, type annotations, and more.
The ignore list skips annotations for self and cls parameters. These settings mirror the starbase template that the Starcraft team uses across all *Craft repositories.
- Save pyproject.toml by pressing Ctrl+S.
Seeing red squiggly lines in pyproject.toml?
VS Code's TOML extension may flag version constraints if it cannot resolve them yet. This is not an error. The versions will resolve correctly when you run uv sync.
Help me troubleshoot
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
[project]
name = "minicraft"
version = "0.1.0"
description = "A miniature craft-parts lifecycle engine"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.13.4",
"pyyaml>=6.0.2",
]
[tool.uv]
dev-dependencies = [
"ruff>=0.15.18",
"pytest>=8.3.5",
]
[tool.ruff]
target-version = "py312"
line-length = 99
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "ANN", "B", "A", "COM", "C4", "PT"]
ignore = ["ANN101", "ANN102"]
[tool.pytest.ini_options]
testpaths = ["tests"]
Now create the package's __init__.py file so Python recognises the minicraft module.
- In the Explorer sidebar, expand the src/minicraft folder.
- Right-click on minicraft and select New File.
- Name the file __init__.py.
- Paste the following code into the new file:
"""Minicraft - A miniature craft-parts lifecycle engine."""
__version__ = "0.1.0"
What does this file do?
The __init__.py file marks a directory as a Python package. The __version__ variable follows Python packaging conventions and lets you query the version at runtime.
- Save __init__.py by pressing Ctrl+S.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
"""Minicraft - A miniature craft-parts lifecycle engine."""
__version__ = "0.1.0"
Time to verify that everything works together.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key).
- Verify Ruff can lint the project by running:
uv run ruff check .
- You should see no errors or warnings (an empty output means everything passes).
- Verify the minicraft package is importable by running:
uv run python -c "import minicraft; print(minicraft.__version__)"
- You should see 0.1.0 printed to the terminal.
Seeing ModuleNotFoundError?
Make sure your __init__.py file is saved inside src\minicraft\ and not at the project root. Double-check the Explorer sidebar shows the path src > minicraft > __init__.py.
Also confirm you are running the command from inside the minicraft project folder (where pyproject.toml lives).
Help me fix this
Your project skeleton is in place with dependencies locked, Ruff configured, and the package importable. Next up, you will define Pydantic models that validate YAML project specs.
Define the Project Model with Pydantic
Your project scaffolding is in place. You have a properly configured Python package with Pydantic and PyYAML ready to go.
A lifecycle engine needs to know what it's building. In craft-parts, this comes from a YAML spec file that declares each part's plugin, source, and dependencies. Before the engine can do anything, it must parse that YAML and validate it against a strict schema.
In this step, get ready to:
- Define Pydantic models that validate project YAML specs.
- Create custom exception classes for clear error reporting.
- Build a project loader that reads YAML and validates it end-to-end.
Create the Pydantic models
Pydantic models define the exact shape your YAML file must follow. The real craft-parts uses a CraftBaseModel with extra="forbid" to reject any unknown fields. This catches typos in YAML specs early, before they cause confusing runtime errors.
- Create a new file at src/minicraft/models.py and add the following code:
"""Pydantic models for minicraft project validation."""
from pydantic import BaseModel, ConfigDict, Field
class CraftBaseModel(BaseModel):
"""Base model for all minicraft models.
Rejects unknown fields to catch typos in YAML specs early.
"""
model_config = ConfigDict(extra="forbid")
class PartSpec(CraftBaseModel):
"""Specification for a single part in the project."""
plugin: str = Field(description="The plugin to use for building this part.")
source: str = Field(default=".", description="The source directory for this part.")
after: list[str] = Field(
default_factory=list,
description="Parts that must be staged before this part can build.",
)
class Project(CraftBaseModel):
"""Top-level project model validated from a YAML spec file."""
name: str = Field(description="The identifying name of the project.")
parts: dict[str, PartSpec] = Field(
description="A mapping of part names to their specifications."
)
What does this code do?
CraftBaseModel is the base class for all models. Setting extra="forbid" means Pydantic will raise a validation error if the YAML contains any field not explicitly declared in the model.
PartSpec defines what each part looks like: a required plugin name, an optional source directory (defaults to .), and an optional after list declaring dependencies on other parts.
Project is the top-level model mapping a project name to a dictionary of named PartSpec entries.
- Save src/minicraft/models.py.
- Verify the models import correctly by running:
uv run python -c "from minicraft.models import Project; print('Models loaded')"
You should see Models loaded printed in your terminal.
Seeing a ModuleNotFoundError?
- Make sure the file is saved at src/minicraft/models.py (inside the src/minicraft/ directory, not the project root).
- Check that src/minicraft/__init__.py exists from the previous step.
Help me debug a ModuleNotFoundError when importing minicraft.models
✔️ Awesome, I've got everything!
Great. Make sure you have saved models.py before moving on.
ⓧ I'd like to double check the full code
"""Pydantic models for minicraft project validation."""
from pydantic import BaseModel, ConfigDict, Field
class CraftBaseModel(BaseModel):
"""Base model for all minicraft models.
Rejects unknown fields to catch typos in YAML specs early.
"""
model_config = ConfigDict(extra="forbid")
class PartSpec(CraftBaseModel):
"""Specification for a single part in the project."""
plugin: str = Field(description="The plugin to use for building this part.")
source: str = Field(default=".", description="The source directory for this part.")
after: list[str] = Field(
default_factory=list,
description="Parts that must be staged before this part can build.",
)
class Project(CraftBaseModel):
"""Top-level project model validated from a YAML spec file."""
name: str = Field(description="The identifying name of the project.")
parts: dict[str, PartSpec] = Field(
description="A mapping of part names to their specifications."
)
Create custom error classes
Clear error messages help users understand what went wrong. A hierarchy of custom exceptions lets the CLI catch specific failure types and format messages appropriately.
- Create a new file at src/minicraft/errors.py and add the following code:
"""Custom exceptions for minicraft."""
class MiniCraftError(Exception):
"""Base exception for all minicraft errors."""
class ProjectValidationError(MiniCraftError):
"""Raised when a project YAML file fails validation."""
class PluginNotFoundError(MiniCraftError):
"""Raised when a requested plugin does not exist."""
class LifecycleError(MiniCraftError):
"""Raised when the lifecycle encounters an unrecoverable error."""
What does this code do?
MiniCraftError is the base exception that all other minicraft errors inherit from. This lets you catch any minicraft error with a single except MiniCraftError block.
Each subclass names a specific failure mode: validation errors from bad YAML, missing plugins, or lifecycle problems. You will use these throughout the project as you build each layer.
- Save src/minicraft/errors.py.
- Verify the exceptions import correctly by running:
uv run python -c "from minicraft.errors import ProjectValidationError; print('Errors loaded')"
You should see Errors loaded printed in your terminal.
Import failing?
- Confirm the file is saved at src/minicraft/errors.py (not src/errors.py).
- Check for any syntax errors in your class definitions.
Help me debug my minicraft errors import
✔️ Awesome, I've got everything!
Great. Make sure you have saved errors.py before moving on.
ⓧ I'd like to double check the full code
"""Custom exceptions for minicraft."""
class MiniCraftError(Exception):
"""Base exception for all minicraft errors."""
class ProjectValidationError(MiniCraftError):
"""Raised when a project YAML file fails validation."""
class PluginNotFoundError(MiniCraftError):
"""Raised when a requested plugin does not exist."""
class LifecycleError(MiniCraftError):
"""Raised when the lifecycle encounters an unrecoverable error."""
Create the project loader and example spec
Now you need a function that reads a YAML file from disk and validates it against your Pydantic models. You also need a sample YAML file to test against.
- Create examples/project.yaml with the following content:
name: my-app
parts:
assets:
plugin: dump
source: static/
app:
plugin: python
source: .
after:
- assets
What does this YAML define?
This spec declares a project called my-app with two parts. The assets part uses the dump plugin to copy files from static/. The app part uses the python plugin and depends on assets (it can only build after assets is staged).
- Save examples/project.yaml.
- Now create src/minicraft/project.py with the project loader function:
"""Project loading and validation."""
from pathlib import Path
import yaml
from pydantic import ValidationError
from minicraft.errors import ProjectValidationError
from minicraft.models import Project
def load_project(filepath: Path) -> Project:
"""Load and validate a project from a YAML file.
:param filepath: Path to the project YAML file.
:returns: A validated Project instance.
:raises ProjectValidationError: If the file is invalid.
"""
if not filepath.exists():
raise ProjectValidationError(f"Project file not found: {filepath}")
with open(filepath, encoding="utf-8") as f:
raw_data = yaml.safe_load(f)
if not isinstance(raw_data, dict):
raise ProjectValidationError("Project file must contain a YAML mapping.")
try:
return Project.model_validate(raw_data)
except ValidationError as e:
raise ProjectValidationError(f"Invalid project spec:\n{e}") from e
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python -m minicraft.project <path-to-yaml>")
sys.exit(1)
project = load_project(Path(sys.argv[1]))
print(f"Loaded project: {project.name}")
for part_name, spec in project.parts.items():
print(f" Part '{part_name}': plugin={spec.plugin}, source={spec.source}")
What does this code do?
load_project takes a file path, reads the YAML content with yaml.safe_load, and passes it to Project.model_validate(). If Pydantic finds any issues (missing fields, extra keys, wrong types), it raises a ValidationError that gets wrapped in your custom ProjectValidationError.
The if __name__ == "__main__" block at the bottom lets you run this file directly as a quick test. It loads the YAML path from the command line and prints what it found.
- Save src/minicraft/project.py.
- Test the full pipeline by running:
uv run python -m minicraft.project examples/project.yaml
You should see output like this:
Loaded project: my-app
Part 'assets': plugin=dump, source=static/
Part 'app': plugin=python, source=.
Seeing a ProjectValidationError or FileNotFoundError?
- Make sure you are running the command from the minicraft project root (the folder containing pyproject.toml).
- Double check that examples/project.yaml exists and uses correct YAML indentation (2 spaces, no tabs).
- If you see a message about extra fields, check for typos in your YAML keys.
Help me debug my minicraft project loader
✔️ Awesome, I've got everything!
Great. Make sure both project.py and project.yaml are saved.
ⓧ I'd like to double check the full code
"""Project loading and validation."""
from pathlib import Path
import yaml
from pydantic import ValidationError
from minicraft.errors import ProjectValidationError
from minicraft.models import Project
def load_project(filepath: Path) -> Project:
"""Load and validate a project from a YAML file.
:param filepath: Path to the project YAML file.
:returns: A validated Project instance.
:raises ProjectValidationError: If the file is invalid.
"""
if not filepath.exists():
raise ProjectValidationError(f"Project file not found: {filepath}")
with open(filepath, encoding="utf-8") as f:
raw_data = yaml.safe_load(f)
if not isinstance(raw_data, dict):
raise ProjectValidationError("Project file must contain a YAML mapping.")
try:
return Project.model_validate(raw_data)
except ValidationError as e:
raise ProjectValidationError(f"Invalid project spec:\n{e}") from e
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python -m minicraft.project <path-to-yaml>")
sys.exit(1)
project = load_project(Path(sys.argv[1]))
print(f"Loaded project: {project.name}")
for part_name, spec in project.parts.items():
print(f" Part '{part_name}': plugin={spec.plugin}, source={spec.source}")
name: my-app
parts:
assets:
plugin: dump
source: static/
app:
plugin: python
source: .
after:
- assets
Your data layer is solid. Next up, you will build the plugin system that tells the lifecycle engine how to actually build each part.
Build the Plugin System
In the previous step, you built the project model layer. Your tool can now load a YAML file and reject typos automatically through strict Pydantic validation.
A validated spec is just data in memory. To turn that data into real build actions, you need a plugin system that maps each part's plugin field to executable logic. This is exactly how craft-parts works: a shared abstract base class defines the interface, and concrete plugins implement the details.
In this step, get ready to:
- Define an abstract Plugin base class that all plugins must implement.
- Create two concrete plugins (dump and python) mirroring craft-parts patterns.
- Wire up a plugin registry with a factory function.
Create the abstract Plugin base class
In craft-parts, every plugin inherits from a shared abstract base class. This guarantees that any plugin the lifecycle engine encounters has the same interface: methods for pulling dependencies, building the part, and exposing environment variables.
- Create a new file called base.py inside the src/minicraft/plugins/ folder.
- Add the following code to base.py:
"""Abstract base class for minicraft plugins."""
import abc
from minicraft.models import PartSpec
class Plugin(abc.ABC):
"""The base class for plugins.
Subclasses must implement the abstract methods to define how a part
is built within the lifecycle.
"""
def __init__(self, *, part_name: str, part_spec: PartSpec) -> None:
self._part_name = part_name
self._part_spec = part_spec
@property
def part_name(self) -> str:
"""The name of the part this plugin is building."""
return self._part_name
@property
def source(self) -> str:
"""The source directory for this part."""
return self._part_spec.source
def get_pull_commands(self) -> list[str]:
"""Return commands to retrieve dependencies during the pull step."""
return []
@abc.abstractmethod
def get_build_commands(self) -> list[str]:
"""Return commands to execute during the build step."""
@abc.abstractmethod
def get_build_environment(self) -> dict[str, str]:
"""Return environment variables for the build step."""
What does this code do?
The Plugin class inherits from abc.ABC, making it an abstract base class. You cannot instantiate Plugin directly. Any subclass must implement the methods decorated with @abc.abstractmethod.
The constructor uses keyword-only arguments (* in the signature) to force explicit naming at call sites. The get_pull_commands method returns an empty list by default because not every plugin needs a pull step. Subclasses only override it if they have dependencies to fetch.
- Save src/minicraft/plugins/base.py.
- You won't see a change yet. The next chunk creates concrete plugins that implement this interface.
Why abstract base classes instead of duck typing?
Python supports duck typing, so you could skip the ABC and just rely on plugins having the right methods. The craft-parts team uses ABCs deliberately because they surface errors at instantiation time, not deep inside the lifecycle engine at runtime.
If a plugin author forgets to implement get_build_commands, Python raises TypeError the moment the plugin is created. This fail-fast behaviour makes plugin development far easier to debug.
✔️ Awesome, I've got everything!
Great. Make sure you have saved base.py before moving on.
ⓧ I'd like to double check the full code
"""Abstract base class for minicraft plugins."""
import abc
from minicraft.models import PartSpec
class Plugin(abc.ABC):
"""The base class for plugins.
Subclasses must implement the abstract methods to define how a part
is built within the lifecycle.
"""
def __init__(self, *, part_name: str, part_spec: PartSpec) -> None:
self._part_name = part_name
self._part_spec = part_spec
@property
def part_name(self) -> str:
"""The name of the part this plugin is building."""
return self._part_name
@property
def source(self) -> str:
"""The source directory for this part."""
return self._part_spec.source
def get_pull_commands(self) -> list[str]:
"""Return commands to retrieve dependencies during the pull step."""
return []
@abc.abstractmethod
def get_build_commands(self) -> list[str]:
"""Return commands to execute during the build step."""
@abc.abstractmethod
def get_build_environment(self) -> dict[str, str]:
"""Return environment variables for the build step."""
Create the dump and python plugins
With the abstract interface defined, you need two concrete implementations. The dump plugin copies files as-is. The python plugin creates a virtual environment and installs packages.
- Create a new file called dump_plugin.py inside src/minicraft/plugins/.
- Add the following code to dump_plugin.py:
"""Dump plugin - copies files from source to install directory."""
from minicraft.plugins.base import Plugin
class DumpPlugin(Plugin):
"""A plugin that copies source files directly into the install area.
This is equivalent to craft-parts' dump plugin: no build step,
just file transfer from source to the staging area.
"""
def get_build_commands(self) -> list[str]:
"""Return commands to copy source files."""
return [
f'echo "Copying files from {self.source} to install directory"',
f"xcopy /E /I /Y {self.source} install\\",
]
def get_build_environment(self) -> dict[str, str]:
"""No special environment needed for dump."""
return {}
What does this code do?
DumpPlugin is the simplest possible plugin. It overrides get_build_commands to return an xcopy command that copies the source directory into an install folder. The get_build_environment method returns an empty dict because no special environment variables are needed.
Notice it inherits from Plugin and does NOT override get_pull_commands. It inherits the default empty list from the base class.
- Save src/minicraft/plugins/dump_plugin.py.
- You won't see a change yet. The registry in the next file will tie this plugin into the system.
- Now create a second file called python_plugin.py in the same src/minicraft/plugins/ folder.
- Add the following code to python_plugin.py:
"""Python plugin - creates a venv and installs dependencies."""
from minicraft.plugins.base import Plugin
class PythonPlugin(Plugin):
"""A plugin for building Python parts.
Creates a virtual environment and installs packages,
mirroring the craft-parts Python plugin pattern.
"""
def get_pull_commands(self) -> list[str]:
"""Pull commands to fetch Python dependencies."""
return [
'echo "Fetching Python dependencies"',
]
def get_build_commands(self) -> list[str]:
"""Return commands to create venv and install packages."""
return [
f'echo "Creating virtual environment for {self.part_name}"',
"python -m venv install\\.venv",
'echo "Installing dependencies"',
"install\\.venv\\Scripts\\pip.exe install -r requirements.txt",
]
def get_build_environment(self) -> dict[str, str]:
"""Return Python-specific build environment."""
return {
"PARTS_PYTHON_INTERPRETER": "python3",
"PARTS_PYTHON_VENV_ARGS": "",
}
What does this code do?
PythonPlugin is more involved. It overrides get_pull_commands (unlike the dump plugin) because Python parts need to fetch dependencies before building.
The build commands create a virtual environment and run pip install inside it. The environment dict exposes variables like PARTS_PYTHON_INTERPRETER that the lifecycle engine can inject into the build environment. This mirrors the real craft-parts Python plugin.
- Save src/minicraft/plugins/python_plugin.py.
- You won't see a change yet. The final substep creates the registry that connects everything.
Seeing a red underline on the import?
Your editor may show a warning on from minicraft.plugins.base import Plugin because the plugins package does not yet have an __init__.py file. This is expected. The warning disappears after the next substep when you create that file.
help me fix import errors in my minicraft plugins
✔️ Awesome, I've got everything!
Great. Make sure both dump_plugin.py and python_plugin.py are saved.
ⓧ I'd like to double check the full code
Here is dump_plugin.py:
"""Dump plugin - copies files from source to install directory."""
from minicraft.plugins.base import Plugin
class DumpPlugin(Plugin):
"""A plugin that copies source files directly into the install area.
This is equivalent to craft-parts' dump plugin: no build step,
just file transfer from source to the staging area.
"""
def get_build_commands(self) -> list[str]:
"""Return commands to copy source files."""
return [
f'echo "Copying files from {self.source} to install directory"',
f"xcopy /E /I /Y {self.source} install\\",
]
def get_build_environment(self) -> dict[str, str]:
"""No special environment needed for dump."""
return {}
Here is python_plugin.py:
"""Python plugin - creates a venv and installs dependencies."""
from minicraft.plugins.base import Plugin
class PythonPlugin(Plugin):
"""A plugin for building Python parts.
Creates a virtual environment and installs packages,
mirroring the craft-parts Python plugin pattern.
"""
def get_pull_commands(self) -> list[str]:
"""Pull commands to fetch Python dependencies."""
return [
'echo "Fetching Python dependencies"',
]
def get_build_commands(self) -> list[str]:
"""Return commands to create venv and install packages."""
return [
f'echo "Creating virtual environment for {self.part_name}"',
"python -m venv install\\.venv",
'echo "Installing dependencies"',
"install\\.venv\\Scripts\\pip.exe install -r requirements.txt",
]
def get_build_environment(self) -> dict[str, str]:
"""Return Python-specific build environment."""
return {
"PARTS_PYTHON_INTERPRETER": "python3",
"PARTS_PYTHON_VENV_ARGS": "",
}
Create the plugin registry and factory function
The lifecycle engine needs a single entry point to look up plugins by name. A registry dictionary maps string names to classes, and a factory function handles instantiation and error reporting.
- Create a new file called __init__.py inside src/minicraft/plugins/.
- Add the following code to __init__.py:
"""Plugin registry for minicraft."""
from minicraft.errors import PluginNotFoundError
from minicraft.models import PartSpec
from minicraft.plugins.base import Plugin
from minicraft.plugins.dump_plugin import DumpPlugin
from minicraft.plugins.python_plugin import PythonPlugin
_PLUGIN_REGISTRY: dict[str, type[Plugin]] = {
"dump": DumpPlugin,
"python": PythonPlugin,
}
def get_plugin(name: str, part_name: str, part_spec: PartSpec) -> Plugin:
"""Instantiate a plugin by name.
:param name: The plugin identifier.
:param part_name: The name of the part using this plugin.
:param part_spec: The part specification.
:returns: An instantiated Plugin.
:raises PluginNotFoundError: If the plugin name is not registered.
"""
plugin_class = _PLUGIN_REGISTRY.get(name)
if plugin_class is None:
available = ", ".join(sorted(_PLUGIN_REGISTRY.keys()))
raise PluginNotFoundError(
f"Plugin '{name}' not found. Available plugins: {available}"
)
return plugin_class(part_name=part_name, part_spec=part_spec)
What does this code do?
_PLUGIN_REGISTRY is a dictionary that maps plugin name strings (like "dump" and "python") to their class objects. This is the exact pattern craft-parts uses to discover plugins at runtime.
The get_plugin factory function looks up the class, raises PluginNotFoundError if the name is unknown, and instantiates the plugin with the part's name and spec. The lifecycle engine only ever calls this one function to get a ready-to-use plugin.
- Save src/minicraft/plugins/__init__.py.
- Verify the plugin system works by running this command in your terminal:
uv run python -c "from minicraft.plugins import get_plugin; from minicraft.models import PartSpec; p = get_plugin('dump', 'assets', PartSpec(plugin='dump', source='static/')); print(p.get_build_commands())"
- You should see output showing the two commands the dump plugin returns:
['echo "Copying files from static/ to install directory"', 'xcopy /E /I /Y static/ install\\']
Seeing a ModuleNotFoundError?
Make sure you are running the command from the minicraft project root folder (the one containing pyproject.toml). The uv run command needs to find the package configuration to resolve imports correctly.
Also check that __init__.py is in src/minicraft/plugins/ and not accidentally in src/minicraft/ or the project root.
help me debug a ModuleNotFoundError when importing minicraft.plugins
✔️ Awesome, I've got everything!
Great. Make sure all four plugin files are saved before moving on.
ⓧ I'd like to double check the full code
"""Plugin registry for minicraft."""
from minicraft.errors import PluginNotFoundError
from minicraft.models import PartSpec
from minicraft.plugins.base import Plugin
from minicraft.plugins.dump_plugin import DumpPlugin
from minicraft.plugins.python_plugin import PythonPlugin
_PLUGIN_REGISTRY: dict[str, type[Plugin]] = {
"dump": DumpPlugin,
"python": PythonPlugin,
}
def get_plugin(name: str, part_name: str, part_spec: PartSpec) -> Plugin:
"""Instantiate a plugin by name.
:param name: The plugin identifier.
:param part_name: The name of the part using this plugin.
:param part_spec: The part specification.
:returns: An instantiated Plugin.
:raises PluginNotFoundError: If the plugin name is not registered.
"""
plugin_class = _PLUGIN_REGISTRY.get(name)
if plugin_class is None:
available = ", ".join(sorted(_PLUGIN_REGISTRY.keys()))
raise PluginNotFoundError(
f"Plugin '{name}' not found. Available plugins: {available}"
)
return plugin_class(part_name=part_name, part_spec=part_spec)
Your plugin system is complete. The lifecycle engine now has a clean way to look up any plugin by name and get back an object with a guaranteed interface. Next up, you'll build the lifecycle engine that orchestrates these plugins across the PULL, BUILD, STAGE, and PRIME steps.
Implement the Lifecycle Engine
Your plugin system is ready to build parts. But plugins alone don't know when to run or in what order.
In craft-parts, a LifecycleManager orchestrates the entire build process. It plans actions (PULL, BUILD, STAGE, PRIME) for each part, resolves dependencies so parts build in the right order, and then executes the plan by delegating to plugins. In this step, you'll build that engine.
In this step, get ready to:
- Define lifecycle steps, action types, and data structures.
- Build a LifecycleManager that plans and executes actions across parts.
- Implement topological sort to resolve part dependencies.
Define the lifecycle data types
The lifecycle engine needs a vocabulary before it can plan anything. You need enums to represent the steps a part moves through, an action type to distinguish between work the engine should do versus work it should skip, and a data class to bundle these into a single planned action.
- Create a new file src/minicraft/lifecycle.py and add the following imports and type definitions:
"""Lifecycle engine for minicraft."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from minicraft.errors import LifecycleError
from minicraft.models import Project
from minicraft.plugins import get_plugin
class Step(Enum):
"""Lifecycle steps in execution order."""
PULL = "pull"
BUILD = "build"
STAGE = "stage"
PRIME = "prime"
class ActionType(Enum):
"""The type of action to perform."""
RUN = "run"
SKIP = "skip"
@dataclass(frozen=True)
class Action:
"""A planned lifecycle action for a specific part."""
step: Step
action_type: ActionType
part_name: str
reason: str = ""
What does this code do?
The Step enum defines the four lifecycle phases a part moves through: PULL (fetch sources), BUILD (compile/process), STAGE (copy to a shared area), and PRIME (prepare for final output).
The Action dataclass bundles a step, an action type (RUN or SKIP), a part name, and an optional reason into a single planned unit. The frozen=True argument makes actions immutable once created, so the plan cannot be accidentally mutated during execution.
You won't see a change yet. The next chunk adds the constants that use these types.
- Below the Action dataclass, add the step ordering constant, message formatting dictionary, and the format_action_message function:
_STEP_ORDER = [Step.PULL, Step.BUILD, Step.STAGE, Step.PRIME]
_ACTION_MESSAGES: dict[Step, dict[ActionType, str]] = {
Step.PULL: {ActionType.RUN: "Pull", ActionType.SKIP: "Skip pull"},
Step.BUILD: {ActionType.RUN: "Build", ActionType.SKIP: "Skip build"},
Step.STAGE: {ActionType.RUN: "Stage", ActionType.SKIP: "Skip stage"},
Step.PRIME: {ActionType.RUN: "Prime", ActionType.SKIP: "Skip prime"},
}
def format_action_message(action: Action) -> str:
"""Format an action into a human-readable message."""
message = f"{_ACTION_MESSAGES[action.step][action.action_type]} {action.part_name}"
if action.reason:
message += f" ({action.reason})"
return message
What does this code do?
The _STEP_ORDER list defines the execution sequence. When you target BUILD, the engine knows it must also run PULL first.
The _ACTION_MESSAGES dictionary maps each step and action type to a human-readable label. This mirrors craft-parts' output style: "Pull assets", "Skip build app".
The format_action_message function assembles these into a complete message, optionally appending the reason in parentheses.
- Save src/minicraft/lifecycle.py.
You won't see a change yet. The next substep adds the LifecycleManager class that uses these data types.
Implement the LifecycleManager class
The LifecycleManager is the orchestrator. It accepts a validated Project, plans which actions need to happen for a given target step, and executes them by delegating to the appropriate plugin for each part.
- In src/minicraft/lifecycle.py, add the LifecycleManager class with its __init__ and plan method below the format_action_message function:
class LifecycleManager:
"""Plans and executes lifecycle actions for a project."""
def __init__(self, project: Project) -> None:
self._project = project
def plan(
self,
target_step: Step,
part_names: list[str] | None = None,
) -> list[Action]:
"""Plan actions up to and including the target step.
:param target_step: The step to execute up to.
:param part_names: Optional list of parts to target. Defaults to all.
:returns: An ordered list of Actions.
"""
ordered_parts = self._resolve_order()
if part_names:
for name in part_names:
if name not in self._project.parts:
raise LifecycleError(f"Part '{name}' not found in project.")
target_index = _STEP_ORDER.index(target_step)
steps_to_run = _STEP_ORDER[: target_index + 1]
actions: list[Action] = []
for step in steps_to_run:
for part_name in ordered_parts:
if part_names and part_name not in part_names:
actions.append(
Action(
step=step,
action_type=ActionType.SKIP,
part_name=part_name,
reason="not selected",
)
)
else:
actions.append(
Action(step=step, action_type=ActionType.RUN, part_name=part_name)
)
return actions
What does this code do?
The plan method generates the full action list for a build request. It first calls _resolve_order() (which you'll implement next) to get parts in dependency order.
It then slices _STEP_ORDER up to and including the target step. If you target BUILD, it runs both PULL and BUILD. For each step, it iterates through all parts in dependency order and creates either a RUN or SKIP action depending on whether the part was explicitly requested.
You won't see a change yet. The execute method in the next chunk brings the plan to life.
- Add the execute method to the LifecycleManager class, below the plan method:
def execute(self, actions: list[Action], *, dry_run: bool = False) -> None:
"""Execute a list of planned actions.
:param actions: The actions to execute.
:param dry_run: If True, print the plan without executing.
"""
for action in actions:
if action.action_type == ActionType.SKIP:
continue
message = format_action_message(action)
if dry_run:
print(message)
continue
print(f"Execute: {message}")
part_spec = self._project.parts[action.part_name]
plugin = get_plugin(part_spec.plugin, action.part_name, part_spec)
if action.step == Step.PULL:
commands = plugin.get_pull_commands()
elif action.step == Step.BUILD:
commands = plugin.get_build_commands()
elif action.step == Step.STAGE:
commands = [f'echo "Staging {action.part_name}"']
elif action.step == Step.PRIME:
commands = [f'echo "Priming {action.part_name}"']
for cmd in commands:
print(f" + {cmd}")
What does this code do?
The execute method iterates through the planned actions and delegates to plugins. It skips any SKIP actions entirely.
In dry_run mode, it only prints the action message without instantiating plugins. In normal mode, it instantiates the appropriate plugin via get_plugin and prints the commands that would be executed for each step.
- Save src/minicraft/lifecycle.py.
The file won't run yet because plan calls self._resolve_order(), which doesn't exist. The next substep adds it.
Seeing an indentation error?
The execute method must be indented one level inside the LifecycleManager class body. Make sure it lines up with the plan method above it.
Help me fix indentation in my LifecycleManager class.
Implement topological sort for dependency resolution
Parts can depend on other parts via the after field. If part "app" lists after: [assets], the engine must process "assets" before "app" at every step. This is a classic topological sort problem.
- Add the _resolve_order method to the LifecycleManager class, below the execute method:
def _resolve_order(self) -> list[str]:
"""Resolve part execution order via topological sort.
:returns: Part names in dependency order.
:raises LifecycleError: If a circular dependency is detected.
"""
parts = self._project.parts
in_degree: dict[str, int] = {name: 0 for name in parts}
graph: dict[str, list[str]] = {name: [] for name in parts}
for name, spec in parts.items():
for dep in spec.after:
if dep not in parts:
raise LifecycleError(
f"Part '{name}' depends on unknown part '{dep}'."
)
graph[dep].append(name)
in_degree[name] += 1
queue: list[str] = [name for name, degree in in_degree.items() if degree == 0]
ordered: list[str] = []
while queue:
queue.sort()
current = queue.pop(0)
ordered.append(current)
for neighbor in graph[current]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
if len(ordered) != len(parts):
raise LifecycleError("Circular dependency detected among parts.")
return ordered
What does this code do?
This is Kahn's algorithm for topological sorting. It builds a directed graph from each part's after list and tracks how many dependencies each part has (its "in-degree").
Parts with zero dependencies go into a queue first. As each part is processed, its dependents have their in-degree decremented. When a dependent reaches zero, it joins the queue. If the final ordered list is shorter than the total parts count, a cycle must exist.
The queue.sort() call ensures deterministic ordering when multiple parts have zero dependencies at the same time.
Why topological sort?
In craft-parts, the real lifecycle guarantees that all dependencies are staged before a dependent part can build. This topological sort gives you the same guarantee. It also catches invalid specs (circular or unknown dependencies) at plan time, before any work starts.
- Save src/minicraft/lifecycle.py.
- Verify the module imports cleanly by running:
uv run python -c "from minicraft.lifecycle import LifecycleManager, Step; print('Lifecycle engine loaded')"
You should see Lifecycle engine loaded printed in your terminal.
Seeing an ImportError?
Check that your file is saved at exactly src/minicraft/lifecycle.py. The import path must match the directory structure.
Also confirm that _resolve_order is indented inside the LifecycleManager class. If it's at the module level, plan won't find it via self._resolve_order().
Help me debug my lifecycle.py ImportError.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete src/minicraft/lifecycle.py for comparison:
"""Lifecycle engine for minicraft."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from minicraft.errors import LifecycleError
from minicraft.models import Project
from minicraft.plugins import get_plugin
class Step(Enum):
"""Lifecycle steps in execution order."""
PULL = "pull"
BUILD = "build"
STAGE = "stage"
PRIME = "prime"
class ActionType(Enum):
"""The type of action to perform."""
RUN = "run"
SKIP = "skip"
@dataclass(frozen=True)
class Action:
"""A planned lifecycle action for a specific part."""
step: Step
action_type: ActionType
part_name: str
reason: str = ""
_STEP_ORDER = [Step.PULL, Step.BUILD, Step.STAGE, Step.PRIME]
_ACTION_MESSAGES: dict[Step, dict[ActionType, str]] = {
Step.PULL: {ActionType.RUN: "Pull", ActionType.SKIP: "Skip pull"},
Step.BUILD: {ActionType.RUN: "Build", ActionType.SKIP: "Skip build"},
Step.STAGE: {ActionType.RUN: "Stage", ActionType.SKIP: "Skip stage"},
Step.PRIME: {ActionType.RUN: "Prime", ActionType.SKIP: "Skip prime"},
}
def format_action_message(action: Action) -> str:
"""Format an action into a human-readable message."""
message = f"{_ACTION_MESSAGES[action.step][action.action_type]} {action.part_name}"
if action.reason:
message += f" ({action.reason})"
return message
class LifecycleManager:
"""Plans and executes lifecycle actions for a project."""
def __init__(self, project: Project) -> None:
self._project = project
def plan(
self,
target_step: Step,
part_names: list[str] | None = None,
) -> list[Action]:
"""Plan actions up to and including the target step.
:param target_step: The step to execute up to.
:param part_names: Optional list of parts to target. Defaults to all.
:returns: An ordered list of Actions.
"""
ordered_parts = self._resolve_order()
if part_names:
for name in part_names:
if name not in self._project.parts:
raise LifecycleError(f"Part '{name}' not found in project.")
target_index = _STEP_ORDER.index(target_step)
steps_to_run = _STEP_ORDER[: target_index + 1]
actions: list[Action] = []
for step in steps_to_run:
for part_name in ordered_parts:
if part_names and part_name not in part_names:
actions.append(
Action(
step=step,
action_type=ActionType.SKIP,
part_name=part_name,
reason="not selected",
)
)
else:
actions.append(
Action(step=step, action_type=ActionType.RUN, part_name=part_name)
)
return actions
def execute(self, actions: list[Action], *, dry_run: bool = False) -> None:
"""Execute a list of planned actions.
:param actions: The actions to execute.
:param dry_run: If True, print the plan without executing.
"""
for action in actions:
if action.action_type == ActionType.SKIP:
continue
message = format_action_message(action)
if dry_run:
print(message)
continue
print(f"Execute: {message}")
part_spec = self._project.parts[action.part_name]
plugin = get_plugin(part_spec.plugin, action.part_name, part_spec)
if action.step == Step.PULL:
commands = plugin.get_pull_commands()
elif action.step == Step.BUILD:
commands = plugin.get_build_commands()
elif action.step == Step.STAGE:
commands = [f'echo "Staging {action.part_name}"']
elif action.step == Step.PRIME:
commands = [f'echo "Priming {action.part_name}"']
for cmd in commands:
print(f" + {cmd}")
def _resolve_order(self) -> list[str]:
"""Resolve part execution order via topological sort.
:returns: Part names in dependency order.
:raises LifecycleError: If a circular dependency is detected.
"""
parts = self._project.parts
in_degree: dict[str, int] = {name: 0 for name in parts}
graph: dict[str, list[str]] = {name: [] for name in parts}
for name, spec in parts.items():
for dep in spec.after:
if dep not in parts:
raise LifecycleError(
f"Part '{name}' depends on unknown part '{dep}'."
)
graph[dep].append(name)
in_degree[name] += 1
queue: list[str] = [name for name, degree in in_degree.items() if degree == 0]
ordered: list[str] = []
while queue:
queue.sort()
current = queue.pop(0)
ordered.append(current)
for neighbor in graph[current]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
if len(ordered) != len(parts):
raise LifecycleError("Circular dependency detected among parts.")
return ordered
Your lifecycle engine can now plan actions, resolve dependencies, and execute through plugins. Next up, you'll wire everything together with a CLI so you can run the full tool from the command line.
Create the CLI Interface
You have a lifecycle engine that plans and executes actions, a plugin system that knows how to build parts, and a project loader that validates YAML specs. These pieces are powerful, but they have no front door.
In this step, you will build the CLI that ties everything together. The learner (or developer) runs a single command, and the CLI loads the project, plans the lifecycle, and executes it. This mirrors the exact argparse subcommand pattern used by craft-parts itself.
In this step, get ready to:
- Build an argument parser with subcommands for each lifecycle step.
- Wire the CLI to the lifecycle engine so commands trigger real actions.
- Run the full tool end-to-end and verify the output.
Create the CLI module with argparse
Python's __main__.py convention lets you run a package as a module with python -m minicraft. This is where your CLI lives.
- Create a new file src/minicraft/__main__.py and add the imports and step-mapping helper by pasting this code:
"""CLI entry point for minicraft."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from minicraft.errors import MiniCraftError
from minicraft.lifecycle import ActionType, LifecycleManager, Step, format_action_message
from minicraft.project import load_project
def _parse_step(name: str) -> Step:
"""Map a subcommand name to a Step enum value."""
step_map = {
"pull": Step.PULL,
"build": Step.BUILD,
"stage": Step.STAGE,
"prime": Step.PRIME,
}
return step_map[name]
What does this code do?
The imports pull in every module you have built so far: the error hierarchy, the lifecycle engine (including ActionType for filtering skip actions), and the project loader.
The _parse_step helper converts a CLI subcommand string (like "build") into the corresponding Step enum value that the lifecycle engine expects.
- Below the _parse_step function, add the argument parser that defines all the CLI subcommands:
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="minicraft",
description="A miniature craft-parts lifecycle engine.",
)
parser.add_argument(
"--file",
"-f",
type=Path,
default=Path("project.yaml"),
help="Path to the project YAML file. Defaults to 'project.yaml'.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show the planned actions without executing.",
)
subparsers = parser.add_subparsers(dest="command")
for cmd in ("pull", "build", "stage", "prime"):
sub = subparsers.add_parser(cmd, help=f"Run the lifecycle up to the {cmd} step.")
sub.add_argument(
"parts",
nargs="*",
help="Specific parts to target. Defaults to all parts.",
)
clean_parser = subparsers.add_parser("clean", help="Remove build artifacts.")
clean_parser.add_argument(
"parts",
nargs="*",
help="Specific parts to clean. Defaults to all parts.",
)
return parser
What does the parser define?
Two global flags apply to every subcommand: --file (or -f) sets the project spec path, and --dry-run shows the plan without executing it.
The four lifecycle subcommands (pull, build, stage, prime) share the same shape: an optional positional parts argument with nargs="*" so the learner can target specific parts or default to all. The clean subcommand is a stub for now.
Why argparse instead of Click or Typer?
The real craft-parts uses argparse with subparsers in its main.py. By using the same library, you mirror the team's actual pattern rather than introducing an external dependency they do not use.
Wire the CLI to the lifecycle engine
The parser knows what arguments to expect. Now you need a main() function that takes those parsed arguments, loads the project, plans the lifecycle, and either prints the plan or executes it.
- Below the _build_parser function in __main__.py, add the main entry point:
def main() -> int:
"""Main entry point for the minicraft CLI."""
parser = _build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
return 0
if args.command == "clean":
print("Cleaning build artifacts...")
return 0
try:
project = load_project(args.file)
except MiniCraftError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
target_step = _parse_step(args.command)
manager = LifecycleManager(project)
try:
part_names = args.parts if args.parts else None
actions = manager.plan(target_step, part_names)
except MiniCraftError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if args.dry_run:
printed = False
for action in actions:
if action.action_type != ActionType.SKIP:
print(format_action_message(action))
printed = True
if not printed:
print("No actions to execute.")
else:
manager.execute(actions)
return 0
if __name__ == "__main__":
sys.exit(main())
What does main() do?
The function follows a clear pipeline: parse arguments, handle the no-command and clean cases early, then load the project and plan the lifecycle. Errors from loading or planning are caught and printed to stderr with a non-zero exit code.
In dry-run mode, the function iterates through planned actions, skips any with ActionType.SKIP, and prints formatted messages like Pull assets and Build app. Without --dry-run, it delegates to manager.execute() which prints the plugin commands.
- Save src/minicraft/__main__.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
"""CLI entry point for minicraft."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from minicraft.errors import MiniCraftError
from minicraft.lifecycle import ActionType, LifecycleManager, Step, format_action_message
from minicraft.project import load_project
def _parse_step(name: str) -> Step:
"""Map a subcommand name to a Step enum value."""
step_map = {
"pull": Step.PULL,
"build": Step.BUILD,
"stage": Step.STAGE,
"prime": Step.PRIME,
}
return step_map[name]
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="minicraft",
description="A miniature craft-parts lifecycle engine.",
)
parser.add_argument(
"--file",
"-f",
type=Path,
default=Path("project.yaml"),
help="Path to the project YAML file. Defaults to 'project.yaml'.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show the planned actions without executing.",
)
subparsers = parser.add_subparsers(dest="command")
for cmd in ("pull", "build", "stage", "prime"):
sub = subparsers.add_parser(cmd, help=f"Run the lifecycle up to the {cmd} step.")
sub.add_argument(
"parts",
nargs="*",
help="Specific parts to target. Defaults to all parts.",
)
clean_parser = subparsers.add_parser("clean", help="Remove build artifacts.")
clean_parser.add_argument(
"parts",
nargs="*",
help="Specific parts to clean. Defaults to all parts.",
)
return parser
def main() -> int:
"""Main entry point for the minicraft CLI."""
parser = _build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
return 0
if args.command == "clean":
print("Cleaning build artifacts...")
return 0
try:
project = load_project(args.file)
except MiniCraftError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
target_step = _parse_step(args.command)
manager = LifecycleManager(project)
try:
part_names = args.parts if args.parts else None
actions = manager.plan(target_step, part_names)
except MiniCraftError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if args.dry_run:
printed = False
for action in actions:
if action.action_type != ActionType.SKIP:
print(format_action_message(action))
printed = True
if not printed:
print("No actions to execute.")
else:
manager.execute(actions)
return 0
if __name__ == "__main__":
sys.exit(main())
Run the full tool end-to-end
Time to see everything work together. You will run the CLI in dry-run mode first to confirm the action plan, then without dry-run to see execution output.
- Test the CLI in dry-run mode by running this command:
uv run python -m minicraft build --file examples/project.yaml --dry-run
- You should see output showing the planned actions with dependency ordering (assets always before app):
Pull assets
Pull app
Build assets
Build app
Why does assets appear before app?
In examples/project.yaml, the app part declares after: [assets]. Your topological sort in _resolve_order correctly resolves this dependency, ensuring assets is processed first in every step.
- Now run without --dry-run to see the full execution output:
uv run python -m minicraft build --file examples/project.yaml
- You should see each action executed with the plugin commands printed below it, including venv creation commands for the python plugin and xcopy commands for the dump plugin.
Seeing a ModuleNotFoundError?
If you see an import error for minicraft, make sure you are running the command from the minicraft project root directory (the one containing pyproject.toml). The uv run command needs pyproject.toml to find your package.
help me debug a ModuleNotFoundError when running uv run python -m minicraft
- Run the linter and formatter to confirm your code is clean:
uv run ruff check src/
uv run ruff format src/
- Both commands should complete without reporting any errors or reformatted files.
Ruff reporting lint errors?
If ruff check flags issues, check that your imports match the complete code exactly. Common causes include unused imports or missing type annotations on function signatures.
help me fix Ruff lint errors in my minicraft project
Your minicraft CLI is fully functional. You can load a YAML spec, validate it, plan a lifecycle respecting dependencies, and execute actions through the plugin system. This is the exact architecture the Starcraft team uses daily across Snapcraft, Rockcraft, and Charmcraft.
Secret mission
Add Pytest Unit Tests
Your minicraft CLI works end-to-end, but production codebases need automated tests to stay reliable. In this secret mission, you'll write pytest unit tests that cover model validation, lifecycle planning, and plugin registration. This is the same testing discipline the Starcraft team applies to craft-parts.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources on your machine, or delete them entirely.
Resources you used:
- The minicraft project folder on your Desktop.
- Python 3.12 installed via uv.
- uv (the Python package manager).
Keep everything running
No action needed. Choose this if you plan to keep developing with uv and Python 3.12 for other projects.
- Your minicraft folder stays on your Desktop.
- uv and Python 3.12 remain available for future projects.
Pause - I'll come back to this later
There are no running processes to stop. Your project files are static, so there is nothing to pause. Come back anytime and pick up where you left off.
- No action required.
Delete - I don't want to use this again
Remove all project files and the tools you installed during this project.
- Delete the minicraft project folder by running this command in PowerShell:
cd ~/Desktop
rmdir /s /q minicraft
- Remove the Python 3.12 installation managed by uv by running:
uv python uninstall 3.12
- Clean up uv's cache and stored data by running:
uv cache clean
- Remove the uv binaries by running these commands:
rm $HOME\.local\bin\uv.exe
rm $HOME\.local\bin\uvx.exe
rm $HOME\.local\bin\uvw.exe
Nice Work!
Nice Work!
You just built a production-grade Python CLI tool modelled on Canonical's craft-parts architecture. You now have a concrete, working example of the exact patterns the Starcraft team uses every day.
You've learned how to:
- Build a plugin system using abstract base classes and a factory registry, mirroring the architecture of Canonical's craft-parts.
- Validate YAML specs with Pydantic models using strict schemas (extra="forbid"), matching the CraftBaseModel conventions used across Snapcraft and Rockcraft.
- Create a lifecycle engine with dependency resolution via topological sorting, planning and executing actions across PULL, BUILD, STAGE, and PRIME steps.
- Secret Mission: Added comprehensive pytest unit tests covering model validation, lifecycle planning, and plugin registration.
Ready to quiz yourself?