Skip to content

Rendering API

render

Render selected services without discarding handwritten source comments.

RenderError

Bases: RuntimeError

Raised when generation or Compose validation fails.

render_header

render_header(plan: StackPlan) -> str

Render the established header and selected-service manifest.

Parameters:

Name Type Description Default
plan StackPlan

Resolved stack plan supplying summary text and service order.

required

Returns:

Type Description
str

Commented Compose header ending with one blank line.

Source code in docker/src/maraudarr/render.py
def render_header(plan: StackPlan) -> str:
    """Render the established header and selected-service manifest.

    Args:
        plan: Resolved stack plan supplying summary text and service order.

    Returns:
        Commented Compose header ending with one blank line.
    """
    summary = "\n".join(f"# {line}" if line else "#" for line in plan.preset.compose_summary)
    service_width = max(len(service.service) + 1 for service in plan.services) + 2
    services = "\n".join(
        f"#   - {(service.service + ':').ljust(service_width)}{service.url}"
        for service in plan.services
    )
    return (
        "#\n"
        "# Copyright 2025-2026 Scott Gigawatt\n"
        "#\n"
        "# Licensed under the Apache License, Version 2.0.\n"
        "#\n"
        f"{summary}\n"
        "#\n"
        "# Services included:\n"
        f"{services}\n"
        "#\n\n"
    )

render_compose

render_compose(catalog: Catalog, plan: StackPlan) -> str

Render the complete comment-rich Compose file.

Parameters:

Name Type Description Default
catalog Catalog

Validated source catalog containing templates and charts.

required
plan StackPlan

Resolved service selection in deterministic output order.

required

Returns:

Type Description
str

A single Compose document with unresolved environment variables and

str

project-owned comments preserved.

Raises:

Type Description
TemplateError

If an owned Compose source no longer contains an expected service, foundation, footer, or comment group.

OSError

If a required source file cannot be read.

Source code in docker/src/maraudarr/render.py
def render_compose(catalog: Catalog, plan: StackPlan) -> str:
    """Render the complete comment-rich Compose file.

    Args:
        catalog: Validated source catalog containing templates and charts.
        plan: Resolved service selection in deterministic output order.

    Returns:
        A single Compose document with unresolved environment variables and
        project-owned comments preserved.

    Raises:
        TemplateError: If an owned Compose source no longer contains an
            expected service, foundation, footer, or comment group.
        OSError: If a required source file cannot be read.
    """
    base_source = catalog.source_path("templates/compose.yml").read_text()
    selected = set(plan.service_ids)
    include_native_plex = plan.preset.id == "plundarr" or "plex" in selected
    service_blocks = []
    for service in plan.services:
        source = catalog.source_path(service.compose).read_text()
        block = extract_service(source, service.service)
        service_blocks.append(
            _prepare_service(block, service.id, selected, include_native_plex)
        )

    return (
        render_header(plan)
        + extract_foundation(base_source)
        + "\n\n".join(block.rstrip("\n") for block in service_blocks)
        + "\n\n"
        + extract_footer(base_source)
    )

render_environment

render_environment(
    catalog: Catalog,
    plan: StackPlan,
    existing_path: Path | None,
    generate_secrets: bool = True,
) -> str

Render the selected environment while preserving user-managed values.

Parameters:

Name Type Description Default
catalog Catalog

Validated catalog containing environment source fragments.

required
plan StackPlan

Resolved service selection in deterministic output order.

required
existing_path Path | None

Existing .env file whose assignment lines should be preserved. No prior values are loaded when this value is absent.

required
generate_secrets bool

Whether known first-run placeholders should receive cryptographically strong generated values.

True

Returns:

Type Description
str

The complete environment document with a trailing newline.

Raises:

Type Description
OSError

If a source or existing environment file cannot be read.

Source code in docker/src/maraudarr/render.py
def render_environment(
    catalog: Catalog,
    plan: StackPlan,
    existing_path: Path | None,
    generate_secrets: bool = True,
) -> str:
    """Render the selected environment while preserving user-managed values.

    Args:
        catalog: Validated catalog containing environment source fragments.
        plan: Resolved service selection in deterministic output order.
        existing_path: Existing ``.env`` file whose assignment lines should be
            preserved. No prior values are loaded when this value is absent.
        generate_secrets: Whether known first-run placeholders should receive
            cryptographically strong generated values.

    Returns:
        The complete environment document with a trailing newline.

    Raises:
        OSError: If a source or existing environment file cannot be read.
    """
    base_source = catalog.source_path("templates/environment.env").read_text()
    selected = set(plan.service_ids)
    include_plex_homepage = plan.preset.id == "plundarr" or "plex" in selected
    rendered_sections = [base_source]
    for service in plan.services:
        section = catalog.source_path(service.environment).read_text()
        if service.id == "homepage":
            section = _filter_homepage_env(
                section,
                selected,
                include_plex_homepage,
            )
        rendered_sections.append(section)

    rendered = "\n\n".join(
        section.rstrip("\n") for section in rendered_sections
    )
    # Project identity follows the selected product preset and intentionally
    # remains generator-owned during preservation.
    project_name = "boudoirr" if plan.preset.id == "boudoirr" else "plundarr"
    rendered = rendered.replace(
        'COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-plundarr}"',
        f'COMPOSE_PROJECT_NAME="${{COMPOSE_PROJECT_NAME:-{project_name}}}"',
        1,
    )
    rendered = rendered.rstrip() + "\n"
    existing = _existing_values(existing_path) if existing_path else {}
    if generate_secrets:
        rendered = _generate_first_run_secrets(rendered, existing)
    rendered = _preserve_values(rendered, existing)
    all_sources = [base_source] + [
        catalog.source_path(service.environment).read_text()
        for service in catalog.services.values()
    ]
    known_keys = set().union(*(_assignment_keys(source) for source in all_sources))
    return _preserve_inactive_values(rendered, existing, known_keys)

render_homepage_services

render_homepage_services(
    catalog: Catalog, plan: StackPlan
) -> str

Render Homepage groups and cards for selected integrations.

Parameters:

Name Type Description Default
catalog Catalog

Validated catalog used to locate Homepage source fragments.

required
plan StackPlan

Resolved service selection controlling cards and calendar items.

required

Returns:

Type Description
str

A complete Homepage services.yaml document.

Raises:

Type Description
RenderError

If a required built-in card cannot be found.

OSError

If a Homepage source fragment cannot be read.

Source code in docker/src/maraudarr/render.py
def render_homepage_services(catalog: Catalog, plan: StackPlan) -> str:
    """Render Homepage groups and cards for selected integrations.

    Args:
        catalog: Validated catalog used to locate Homepage source fragments.
        plan: Resolved service selection controlling cards and calendar items.

    Returns:
        A complete Homepage ``services.yaml`` document.

    Raises:
        RenderError: If a required built-in card cannot be found.
        OSError: If a Homepage source fragment cannot be read.
    """
    homepage_root = catalog.source_path("services/homepage/config")
    source = (homepage_root / "services.base.yaml").read_text()
    source += (homepage_root / "services.footer.yaml").read_text()
    selected = set(plan.service_ids)
    include_plex_homepage = plan.preset.id == "plundarr" or "plex" in selected
    preamble = source[: source.find("- Media:")].rstrip()

    media_cards = []
    if include_plex_homepage:
        media_cards.append(_homepage_card(source, "Plex"))
    for service_id, label in (
        ("radarr", "Radarr"),
        ("sonarr", "Sonarr"),
        ("sonarr-anime", "Sonarr Anime"),
        ("bazarr", "Bazarr"),
        ("seerr", "Seerr"),
        ("jellyfin", "Jellyfin"),
    ):
        if service_id not in selected:
            continue
        if service_id in {"sonarr-anime", "jellyfin"}:
            fragment = homepage_root / "fragments" / f"{service_id}.yaml"
            media_cards.append(fragment.read_text().strip("\n"))
        else:
            media_cards.append(_homepage_card(source, label))

    data_cards = []
    if selected.intersection({"radarr", "sonarr"}):
        data_cards.append(_filter_calendar(_homepage_card(source, "Calendar"), selected))
    if include_plex_homepage:
        data_cards.append(_homepage_card(source, "Tautulli"))

    download_cards = []
    if "prowlarr" in selected:
        download_cards.append(_homepage_card(source, "Prowlarr"))
    for service_id, label in (
        ("qbittorrent", "qBittorrent"),
        ("sabnzbd", "SABnzbd"),
        ("nzbget", "NZBGet"),
    ):
        if service_id in selected:
            fragment = homepage_root / "fragments" / f"{service_id}.yaml"
            download_cards.append(fragment.read_text().strip("\n"))
    if "speedtest-tracker" in selected:
        download_cards.append(_homepage_card(source, "Speedtest Tracker"))

    groups = []
    for title, cards in (
        ("Media", media_cards),
        ("Data", data_cards),
        ("Downloads", download_cards),
    ):
        if cards:
            groups.append(f"- {title}:\n" + "\n\n".join(cards))
    body = "\n\n".join(groups) if groups else "[]"
    return preamble + "\n\n" + body + "\n"

write_config

write_config(
    catalog: Catalog, plan: StackPlan, output_dir: Path
) -> Path

Create selected config directories without replacing application state.

Parameters:

Name Type Description Default
catalog Catalog

Validated catalog containing shared and service config seeds.

required
plan StackPlan

Resolved service selection controlling generated directories.

required
output_dir Path

Plundarr project directory that owns config/.

required

Returns:

Type Description
Path

Path to the generated or updated config root.

Raises:

Type Description
RenderError

If a required Homepage card cannot be rendered.

OSError

If directories or seed files cannot be created or copied.

Source code in docker/src/maraudarr/render.py
def write_config(catalog: Catalog, plan: StackPlan, output_dir: Path) -> Path:
    """Create selected config directories without replacing application state.

    Args:
        catalog: Validated catalog containing shared and service config seeds.
        plan: Resolved service selection controlling generated directories.
        output_dir: Plundarr project directory that owns ``config/``.

    Returns:
        Path to the generated or updated config root.

    Raises:
        RenderError: If a required Homepage card cannot be rendered.
        OSError: If directories or seed files cannot be created or copied.
    """
    config_path = output_dir / "config"
    config_path.mkdir(parents=True, exist_ok=True)
    _seed_config_tree(catalog.source_path("config"), config_path)

    for service in plan.services:
        _seed_config_tree(
            catalog.config_path(service),
            config_path / service.service,
        )

    if "homepage" in plan.service_ids:
        _atomic_write(
            config_path / "homepage" / "services.yaml",
            render_homepage_services(catalog, plan),
        )

    return config_path

validate_compose

validate_compose(output_dir: Path) -> None

Ask Docker Compose to validate a generated project pair.

Parameters:

Name Type Description Default
output_dir Path

Directory containing docker-compose.yml and .env.

required

Raises:

Type Description
RenderError

If an installed Docker Compose command rejects the project.

Note

A missing Docker executable is tolerated so source-only environments can still render output. Any installed command that returns failure is treated as authoritative.

Source code in docker/src/maraudarr/render.py
def validate_compose(output_dir: Path) -> None:
    """Ask Docker Compose to validate a generated project pair.

    Args:
        output_dir: Directory containing ``docker-compose.yml`` and ``.env``.

    Raises:
        RenderError: If an installed Docker Compose command rejects the project.

    Note:
        A missing Docker executable is tolerated so source-only environments
        can still render output. Any installed command that returns failure is
        treated as authoritative.
    """
    command = [
        "docker",
        "compose",
        "--env-file",
        str(output_dir / ".env"),
        "-f",
        str(output_dir / "docker-compose.yml"),
        "config",
        "--quiet",
    ]
    try:
        result = subprocess.run(command, capture_output=True, text=True, check=False)
    except FileNotFoundError:
        return
    if result.returncode:
        message = result.stderr.strip() or result.stdout.strip()
        raise RenderError(f"Docker Compose rejected the generated stack: {message}")

write_stack

write_stack(
    catalog: Catalog, plan: StackPlan, output_dir: Path
) -> tuple[Path, Path, Path]

Generate, validate, and write a complete Plundarr project.

Compose and environment artifacts are staged and validated before their public paths are atomically replaced. Config seeds are applied afterward using preservation rules appropriate for application-owned state.

Parameters:

Name Type Description Default
catalog Catalog

Validated catalog providing templates and service sources.

required
plan StackPlan

Resolved service selection in deterministic generation order.

required
output_dir Path

Directory that receives the generated Plundarr project.

required

Returns:

Type Description
tuple[Path, Path, Path]

Paths to docker-compose.yml, .env, and the config directory.

Raises:

Type Description
RenderError

If rendering requirements or Compose validation fail.

OSError

If staging, writing, or replacing output files fails.

Source code in docker/src/maraudarr/render.py
def write_stack(
    catalog: Catalog,
    plan: StackPlan,
    output_dir: Path,
) -> tuple[Path, Path, Path]:
    """Generate, validate, and write a complete Plundarr project.

    Compose and environment artifacts are staged and validated before their
    public paths are atomically replaced. Config seeds are applied afterward
    using preservation rules appropriate for application-owned state.

    Args:
        catalog: Validated catalog providing templates and service sources.
        plan: Resolved service selection in deterministic generation order.
        output_dir: Directory that receives the generated Plundarr project.

    Returns:
        Paths to ``docker-compose.yml``, ``.env``, and the config directory.

    Raises:
        RenderError: If rendering requirements or Compose validation fail.
        OSError: If staging, writing, or replacing output files fails.
    """
    compose_path = output_dir / "docker-compose.yml"
    env_path = output_dir / ".env"
    example_env_path = output_dir / "example.env"
    compose = render_compose(catalog, plan)
    environment = render_environment(catalog, plan, env_path)
    example_environment = render_environment(
        catalog,
        plan,
        None,
        generate_secrets=False,
    )
    output_dir.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix=".maraudarr-build-", dir=output_dir) as staging:
        staging_dir = Path(staging)
        staged_compose = staging_dir / "docker-compose.yml"
        staged_env = staging_dir / ".env"
        staged_example_env = staging_dir / "example.env"
        _atomic_write(staged_compose, compose)
        _atomic_write(staged_env, environment)
        _atomic_write(staged_example_env, example_environment)
        validate_compose(staging_dir)
        os.replace(staged_compose, compose_path)
        os.replace(staged_env, env_path)
        os.replace(staged_example_env, example_env_path)

    config_path = write_config(catalog, plan, output_dir)
    return compose_path, env_path, config_path