Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflow-templates/package.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ name: Test tool {{ package.name }}
on:
pull_request:
paths:
- "{{ package.path }}/**"
- "{% if package.path != "." %}{{ package.path }}/{% endif %}**"
# - "pyproject.toml"
# - "uv.lock"
push:
branches: [master]
paths:
- "{{ package.path }}/**"
- "{% if package.path != "." %}{{ package.path }}/{% endif %}**"
- "pyproject.toml"
- "uv.lock"

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to the uv-workspace-codegen package will be documented in th
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0] - 2025-11-25

### Added
- Support for multiple templates per `template_type`.
- Automatically discovers templates matching `<type>.<suffix>.template.yml`.
- Generates workflows with name `<type>-<suffix>-<package-name>.yml`.

## [0.5.0] - 2025-11-20

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ Templates are Jinja2 files that receive a `package` object with fields such as
templates in the directory configured by `template_dir`. Create a file named
`<type>.template.yml` to support `template_type = "<type>"`.

You can also create multiple templates for the same type by adding a suffix:
`<type>.<suffix>.template.yml`. For example, `package.extra.template.yml` will
also be used for `template_type = "package"`, generating a workflow named
`package-extra-<package-name>.yml`.

Template capabilities (examples):

- inject package metadata
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uv-workspace-codegen"
version = "0.5.0"
version = "0.6.0"
description = "Generate individual CI/CD workflows for packages in the workspace"
requires-python = ">=3.12"
dependencies = [
Expand Down
138 changes: 85 additions & 53 deletions src/uv_workspace_codegen/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,65 +166,92 @@ def get_workspace_config(workspace_dir: Path) -> dict:
return {}


def load_template(
def load_templates(
template_type: str,
workspace_dir: Path,
workspace_config: dict,
diff_mode: bool = False,
) -> Template:
"""Load the appropriate template based on template type."""
) -> list[tuple[str, Template]]:
"""Load all templates matching the template type."""
# Get template directory from workspace config, with default fallback
template_dir_str = workspace_config.get(
"template_dir", ".github/workflow-templates"
)
templates_dir = workspace_dir / template_dir_str
template_path = templates_dir / f"{template_type}.template.yml"

# If the requested template does not exist, and the requested type is
# 'package', attempt to populate it from the bundled template located in
# this package's `templates/` directory. Only create the workspace
# templates directory when we actually need to write the default file.
if not template_path.exists():
if template_type == "package":
bundled_template = (
Path(__file__).parent / "templates" / "package.template.yml"
)
if bundled_template.exists():
try:
# In diff mode, we don't want to create the template file
if diff_mode:
with open(bundled_template, "r") as src:
return create_jinja_environment().from_string(src.read())

# Create templates dir now that we will populate it
templates_dir.mkdir(parents=True, exist_ok=True)
with (
open(bundled_template, "r") as src,
open(template_path, "w") as dst,
):
dst.write(src.read())
except Exception:
# On any failure, raise a clear FileNotFoundError to match
# previous behavior for missing templates.
raise FileNotFoundError(
f"Template not found or could not be created: {template_path}"
)
else:

# Find all matching templates
# Pattern 1: {template_type}.template.yml (main template, empty suffix)
# Pattern 2: {template_type}.{suffix}.template.yml (extra templates)

found_templates: list[tuple[str, Path]] = []

# Check for main template
main_template_path = templates_dir / f"{template_type}.template.yml"
if main_template_path.exists():
found_templates.append(("", main_template_path))

# Check for extra templates
if templates_dir.exists():
for path in templates_dir.glob(f"{template_type}.*.template.yml"):
# Extract suffix: template_type.suffix.template.yml
# name is template_type.suffix.template.yml
parts = path.name.split(".")
# parts should be [template_type, suffix, 'template', 'yml']
if len(parts) == 4 and parts[0] == template_type and parts[2] == "template" and parts[3] == "yml":
suffix = parts[1]
found_templates.append((suffix, path))

# If no templates found, and type is 'package', try bundled template
if not found_templates and template_type == "package":
bundled_template = (
Path(__file__).parent / "templates" / "package.template.yml"
)
if bundled_template.exists():
try:
# In diff mode, we don't want to create the template file
if diff_mode:
with open(bundled_template, "r") as src:
env = create_jinja_environment()
return [("", env.from_string(src.read()))]

# Create templates dir now that we will populate it
templates_dir.mkdir(parents=True, exist_ok=True)
with (
open(bundled_template, "r") as src,
open(main_template_path, "w") as dst,
):
dst.write(src.read())
found_templates.append(("", main_template_path))
except Exception:
# On any failure, raise a clear FileNotFoundError to match
# previous behavior for missing templates.
raise FileNotFoundError(
f"Bundled default template missing: {bundled_template}"
f"Template not found or could not be created: {main_template_path}"
)
else:
raise FileNotFoundError(f"Template not found: {template_path}")

with open(template_path, "r") as f:
template_content = f.read()
raise FileNotFoundError(
f"Bundled default template missing: {bundled_template}"
)
elif not found_templates:
raise FileNotFoundError(f"No templates found for type: {template_type}")

results = []
env = create_jinja_environment()
return env.from_string(template_content)

for suffix, path in found_templates:
with open(path, "r") as f:
template_content = f.read()
results.append((suffix, env.from_string(template_content)))

return results


def generate_workflow(
package: Package, template: Template, output_dir: Path, diff_mode: bool = False
package: Package,
template: Template,
output_dir: Path,
diff_mode: bool = False,
suffix: str = ""
) -> Optional[Path]:
"""Generate a workflow file for a single package."""

Expand All @@ -240,7 +267,10 @@ def generate_workflow(
workflow_content = autogen_comment + workflow_content

# Create workflow filename based on package name and template type
workflow_filename = f"{package.template_type}-{package.name}.yml"
if suffix:
workflow_filename = f"{package.template_type}-{suffix}-{package.name}.yml"
else:
workflow_filename = f"{package.template_type}-{package.name}.yml"
workflow_path = output_dir / workflow_filename

if diff_mode:
Expand Down Expand Up @@ -361,23 +391,25 @@ def main(root_dir: Optional[Path], diff: bool):

for package in packages:
try:
# Load template if not cached
# Load templates if not cached
if package.template_type not in templates_cache:
templates_cache[package.template_type] = load_template(
templates_cache[package.template_type] = load_templates(
package.template_type,
workspace_dir,
workspace_config,
diff_mode=diff,
)

template = templates_cache[package.template_type]
generated_file = generate_workflow(
package, template, workflows_dir, diff_mode=diff
)
if (
generated_file
): # Only append if a file was actually generated (not in diff mode)
generated_files.append(generated_file)
templates = templates_cache[package.template_type]

for suffix, template in templates:
generated_file = generate_workflow(
package, template, workflows_dir, diff_mode=diff, suffix=suffix
)
if (
generated_file
): # Only append if a file was actually generated (not in diff mode)
generated_files.append(generated_file)

except Exception as e:
print(f"Error generating workflow for {package.name}: {e}")
Expand Down
17 changes: 8 additions & 9 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Package,
discover_packages,
get_workspace_config,
load_template,
load_templates,
)


Expand Down Expand Up @@ -222,7 +222,7 @@ def test_get_workspace_config():
assert config == {"template_dir": "custom-templates"}


def test_load_template_configurable_dir():
def test_load_templates_configurable_dir():
"""Test loading templates from configurable directory."""

# Create a temporary directory structure
Expand All @@ -248,22 +248,21 @@ def test_load_template_configurable_dir():

# Test with custom template directory configuration
workspace_config = {"template_dir": "my-custom-templates"}
template = load_template("lib", workspace_dir, workspace_config)
templates = load_templates("lib", workspace_dir, workspace_config)

# Verify the template loads correctly
assert len(templates) == 1
suffix, template = templates[0]
assert suffix == ""
assert template is not None

# Test default template directory (should fail since we don't have templates there)
workspace_config_default = {}
try:
load_template("lib", workspace_dir, workspace_config_default)
load_templates("lib", workspace_dir, workspace_config_default)
assert False, "Should have raised FileNotFoundError"
except FileNotFoundError as e:
assert "Template not found" in str(e)
expected_path = os.path.join(
".github", "workflow-templates", "lib.template.yml"
)
assert expected_path in str(e)
assert "No templates found for type: lib" in str(e)


def test_default_template_type():
Expand Down
32 changes: 32 additions & 0 deletions tests/test_multi_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pathlib import Path

from click.testing import CliRunner

from uv_workspace_codegen.main import main


def test_multi_template_generation():
workspace_dir = Path(__file__).parent / "workspaces" / "multi_template"
runner = CliRunner()

# Run codegen
result = runner.invoke(main, [str(workspace_dir)])
assert result.exit_code == 0

workflows_dir = workspace_dir / ".github" / "workflows"

# Check main workflow
main_workflow = workflows_dir / "package-pkg1.yml"
assert main_workflow.exists()
content = main_workflow.read_text()
assert "Main template" in content

# Check extra workflow
extra_workflow = workflows_dir / "package-extra-pkg1.yml"
assert extra_workflow.exists()
content = extra_workflow.read_text()
assert "Extra template" in content

# Cleanup (optional, but good for local dev)
main_workflow.unlink()
extra_workflow.unlink()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: {{ package.name }} Extra
on: push
jobs:
extra:
runs-on: ubuntu-latest
steps:
- run: echo "Extra template"
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: {{ package.name }}
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Main template"
5 changes: 5 additions & 0 deletions tests/workspaces/multi_template/packages/pkg1/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "pkg1"

[tool.uv-workspace-codegen]
generate = true
5 changes: 5 additions & 0 deletions tests/workspaces/multi_template/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[tool.uv.workspace]
members = ["packages/*"]

[tool.uv-workspace-codegen]
default_template_type = "package"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.