Building a Recipe¶
Recipes are just Python classes with some files. They work for any language — Python, Terraform, TypeScript, whatever. nskit doesn't care what's inside the templates.
Getting Started¶
The fastest way to create a new recipe is to use nskit's built-in recipe recipe:
This generates a complete recipe project with:
- Recipe class and ingredient template
- Dockerfile with
nskit.recipelabels pre-configured - pyproject.toml with entry point registration
- Tests, docs, git, and pre-commit setup
Then customise the generated recipe class and templates to your needs.
The Basics¶
from nskit.mixer.components import Recipe, File
class HelloRecipe(Recipe):
name: str = "hello"
contents = [
File(id_="readme", name="README.md", content="# {{name}}\n"),
]
That's a working recipe. It generates one file.
Template Files¶
Place .jinja files alongside your recipe module and reference them with package:filename:
class MyRecipe(Recipe):
name: str = "my-project"
contents = [
File(id_="pyproject", name="pyproject.toml",
content="my_recipe:pyproject.toml.jinja"),
File(id_="readme", name="README.md",
content="my_recipe:readme.md.jinja"),
Folder(id_="src", name="src", contents=[
File(id_="init", name="__init__.py",
content="my_recipe:__init__.py.jinja"),
]),
]
Non-Python Examples¶
Terraform module:
class TerraformModuleRecipe(Recipe):
name: str = RecipeField(default="my-module", prompt_text="Module name")
cloud_provider: str = RecipeField(default="aws", prompt_text="Cloud provider")
contents = [
File(id_="main", name="main.tf",
content="my_recipes.terraform:main.tf.jinja"),
File(id_="variables", name="variables.tf",
content="my_recipes.terraform:variables.tf.jinja"),
File(id_="readme", name="README.md",
content="# {{name}}\n\nTerraform module for {{cloud_provider}}.\n"),
]
TypeScript app:
class TypeScriptAppRecipe(Recipe):
name: str = RecipeField(default="my-app", prompt_text="App name")
contents = [
File(id_="pkg", name="package.json",
content="my_recipes.typescript:package.json.jinja"),
Folder(id_="src", name="src", contents=[
File(id_="index", name="index.ts",
content="my_recipes.typescript:index.ts.jinja"),
]),
]
Input Fields¶
from nskit.mixer.components.recipe import Recipe, RecipeField
class MyRecipe(Recipe):
name: str = RecipeField(default="my-pkg", prompt_text="Package name")
use_docker: bool = RecipeField(default=False, prompt_text="Include Docker?")
Users get prompted interactively via the CLI.
Hooks¶
from nskit.mixer.hooks.git import GitInit
from nskit.mixer.hooks.pre_commit import PrecommitInstall
class MyRecipe(Recipe):
post_hooks = [GitInit(), PrecommitInstall()]
contents = [...]
Sharing Ingredients¶
Don't copy-paste — share ingredients across recipes:
from my_company.ingredients import ci_pipeline, linting_config
class PackageRecipe(Recipe):
contents = [ci_pipeline, linting_config, src_dir, ...]
class APIRecipe(Recipe):
contents = [ci_pipeline, linting_config, docker_setup, api_src_dir, ...]
Update ci_pipeline once, both recipes get it. See Recipe Architecture for the full composition model.
Entry Points¶
Register your recipe so nskit can discover it:
[project.entry-points."mycompany.recipes"]
my_recipe = "my_package.recipes:MyRecipe"
terraform_module = "my_package.recipes:TerraformModuleRecipe"
The group name must match the entrypoint parameter used by the CLI.
Testing¶
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from nskit.client.engines import LocalEngine
class TestMyRecipe(unittest.TestCase):
def test_creates_project(self):
engine = LocalEngine()
with TemporaryDirectory() as tmp:
result = engine.execute(
recipe="my_recipe", version="local",
parameters={"name": "test"},
output_dir=Path(tmp) / "out",
entrypoint="my_package.recipes",
)
self.assertTrue(result.success, result.errors)
Distribution¶
Local (Python Package)¶
Ship as a Python package. Users install it and nskit discovers it via entry points:
This is the simplest option but doesn't support reproducible updates. See Docker vs Local Execution.
Docker (Recommended for Production)¶
Ship as a Docker image. The image contains nskit + your recipe package. The recipe recipe (nskit init --recipe recipe) generates a Dockerfile with the correct labels for discovery.
# Build with nskit labels (RECIPE_NAME sets nskit.recipe.name label)
docker build --build-arg RECIPE_NAME=python_package \
-t ghcr.io/myorg/python_package:v1.0.0 .
# Use locally without a registry
nskit --backend docker-local list
nskit --backend docker-local init --recipe python_package
# Or push to a registry for team use
docker push ghcr.io/myorg/python_package:v1.0.0
Images must have these labels:
| Label | Required | Purpose |
|---|---|---|
nskit.recipe=true |
Yes | Marks image as an nskit recipe |
nskit.recipe.name=<name> |
Yes | Canonical recipe name |
The image tag is used as the version. The recipe recipe generates a Dockerfile with these labels:
# Generated by nskit init --recipe recipe
ARG RECIPE_NAME=nskit
# ...
LABEL nskit.recipe="true"
LABEL nskit.recipe.name="${RECIPE_NAME}"
ENTRYPOINT ["sh", "-c", "uv run --no-sync $CLI_COMMAND \"$@\"", "--"]
Build with:
docker build --target runtime \
--build-arg RECIPE_NAME=python_package \
-t myorg/python_package:v1.0.0 .
Option A: Use the recipe recipe (recommended) — generates a Dockerfile with the correct labels:
nskit init --recipe recipe
# The generated project includes a Dockerfile with nskit.recipe labels
docker build --build-arg RECIPE_NAME=my_recipe -t myorg/my_recipe:v1.0.0 .
Option B: Write your own Dockerfile — just include the two labels:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install .
LABEL nskit.recipe="true"
LABEL nskit.recipe.name="my_recipe"
ENTRYPOINT ["nskit"]
One recipe per image. Each recipe gets its own image, independently versioned. This keeps images small and lets teams release at their own pace. Docker registries deduplicate shared layers, so common base layers (Python, nskit, shared dependencies) are stored once regardless of how many recipe images you have.
The nskit.recipe.name label is the canonical recipe name. Backends use their naming conventions (repo patterns, image prefixes) to narrow where to look, but the label on the image is the source of truth. At list time, backends read the label from the registry manifest (no pull needed). At init time, nskit reads it from the pulled image to pass the correct name to the container.
Backend options for Docker images:
--backend docker-local— discovers from locally pulled images (no registry needed)GitHubBackend— discovers from GitHub releases, pulls from ghcr.ioDockerBackend— discovers from any Docker registry
See Platform Integration for backend configuration.
GitHub Releases¶
The GitHubBackend discovers versions from release tags and resolves Docker images from ghcr.io. So publishing is:
git tag v1.0.0 && git push origin v1.0.0
gh release create v1.0.0
# Also build and push the Docker image for this tag
docker build -t ghcr.io/myorg/my-recipe:v1.0.0 .
docker push ghcr.io/myorg/my-recipe:v1.0.0
Versioning¶
- Semantic versioning:
v1.0.0,v1.1.0,v2.0.0 - Test updates from previous versions before releasing
- Document breaking changes