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: list[str] = []
    for service in plan.services:
        source = catalog.source_path(service.compose).read_text()
        for name in service.compose_services:
            block = extract_service(source, name)
            service_blocks.append(
                _prepare_service(
                    block,
                    service.id if name == service.service else name,
                    selected,
                    include_native_plex,
                    plan.preset.media_libraries,
                )
            )

    # Compose supplies the project prefix; avoid explicit global volume names.
    volume_entries = [
        (f"{volume}: {{}}", description)
        for service in plan.services
        for volume, description in service.named_volumes.items()
    ]
    volume_section = (
        "\n#\n# Define the volumes section.\n#\nvolumes:\n"
        + aligned_yaml_lines(volume_entries, indent=2)
        + "\n"
        if volume_entries
        else ""
    )
    content = (
        "\n\n".join(block.rstrip("\n") for block in service_blocks)
        + "\n\n"
        + extract_footer(base_source)
        + volume_section
    )
    foundation = prune_unused_anchors(extract_foundation(base_source), content)
    return render_header(plan) + foundation + content

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()

        # A standalone configuration generator has no Gluetun tunnel to monitor.
        if service.id == "privateerr" and "gluetun" not in selected:
            section = section.replace(
                "${PRIVATEERR_AUTO_RECOVER:-true}",
                "${PRIVATEERR_AUTO_RECOVER:-false}",
            )
        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)
    # Fresh environments inherit identity, network, and media defaults from
    # the selected preset. Existing user-managed values remain preserved below.
    media_root = plan.preset.media_root.rstrip("/")
    preset_defaults = {
        "COMPOSE_PROJECT_NAME": ("plundarr", plan.preset.project_name),
        "COMPOSE_NETWORK_SUBNET": ("172.20.0.0/16", plan.preset.network_subnet),
        "COMPOSE_NETWORK_IP_RANGE": (
            "172.20.5.0/24",
            plan.preset.network_ip_range,
        ),
        "COMPOSE_NETWORK_GATEWAY": (
            "172.20.5.254",
            plan.preset.network_gateway,
        ),
        "HOST_MOVIES_PATH": ("/volume1/plex/movies", f"{media_root}/movies"),
        "HOST_TV_PATH": ("/volume1/plex/tv", f"{media_root}/tv"),
        "HOST_ANIME_TV_PATH": (
            "/volume1/plex/anime-tv",
            f"{media_root}/anime-tv",
        ),
        "HOST_SCENES_PATH": ("/volume1/plex/scenes", f"{media_root}/scenes"),
        "JELLYFIN_DATA_PATH": ("/volume1/jellyfin", media_root),
        "WHISPARR_DATA_PATH": ("/volume1/media/adult", media_root),
    }
    for variable, (source_default, preset_default) in preset_defaults.items():
        rendered = rendered.replace(
            f"${{{variable}:-{source_default}}}",
            f"${{{variable}:-{preset_default}}}",
        )
    rendered = rendered.rstrip() + "\n"
    if plan.preset.host_port_offset:
        # Offset only variables that publish a host port in the resolved chart.
        # Internal service ports and host-network services remain unchanged.
        compose = render_compose(catalog, plan)
        published_variables = set(
            re.findall(
                r"^\s*-\s+\$\{([A-Z][A-Z0-9_]*PORT)\}:",
                compose,
                flags=re.MULTILINE,
            )
        )
        remapped_ports: dict[str, str] = {}
        for variable in published_variables:
            assignment = re.compile(
                rf'^{re.escape(variable)}="\$\{{{re.escape(variable)}:-(\d+)\}}"(?P<comment>\s+#.*)?$',
                flags=re.MULTILINE,
            )
            match = assignment.search(rendered)
            if not match:
                continue
            original_port = match.group(1)
            offset_port = str(int(original_port) + plan.preset.host_port_offset)
            if int(offset_port) > 65535:
                raise RenderError(
                    f"Preset '{plan.preset.id}' offsets {variable} beyond port 65535."
                )
            rendered = assignment.sub(
                f'{variable}="${{{variable}:-{offset_port}}}"{match.group("comment") or ""}',
                rendered,
                count=1,
            )
            remapped_ports[original_port] = offset_port
        for original_port, offset_port in remapped_ports.items():
            rendered = rendered.replace(
                f"host.or.ip:{original_port}",
                f"host.or.ip:{offset_port}",
            )
    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[str]().union(*(_assignment_keys(source) for source in all_sources))
    rendered = _preserve_inactive_values(rendered, existing, known_keys)
    return align_env_comments(rendered)

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: list[str] = []
    if include_plex_homepage:
        media_cards.append(_homepage_card(source, "Plex"))
    for service_id, label in (
        ("radarr", "Radarr"),
        ("sonarr", "Sonarr"),
        ("lidarr", "Lidarr"),
        ("sonarr-anime", "Sonarr Anime"),
        ("bazarr", "Bazarr"),
        ("seerr", "Seerr"),
        ("jellyfin", "Jellyfin"),
        ("calibre-web-automated", "Calibre-Web Automated"),
    ):
        if service_id not in selected:
            continue
        if service_id in {
            "sonarr-anime",
            "jellyfin",
            "calibre-web-automated",
        }:
            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: list[str] = []
    if selected.intersection({"radarr", "sonarr", "lidarr"}):
        data_cards.append(_filter_calendar(_homepage_card(source, "Calendar"), selected))
    if "tracearr" in selected:
        data_cards.append(_homepage_card(source, "Tracearr"))
    if "tautulli" in selected:
        data_cards.append(_homepage_card(source, "Tautulli"))

    download_cards: list[str] = []
    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: list[str] = []
    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,
        )

    # Upgrade only the unchanged wrapper shipped before automatic recovery (5a5dc2f).
    # Customized scripts, symlinks, and all application state remain operator-owned.
    if "gluetun" in plan.service_ids:
        wrapper = config_path / "gluetun/scripts/gluetun-entrypoint-wrapper.sh"
        previous_digest = "1aa664f06e67e2b7e944ae773a33b82b7fb52af8b27b14600f273f9f91e98eee"  # pragma: allowlist secret
        if (
            not wrapper.is_symlink()
            and hashlib.sha256(wrapper.read_bytes()).hexdigest() == previous_digest
        ):
            source = catalog.source_path(
                "services/gluetun/config/scripts/gluetun-entrypoint-wrapper.sh"
            )
            _atomic_write(wrapper, source.read_text(), source.stat().st_mode & 0o777)

    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

Missing Docker Compose executables are 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:
        Missing Docker Compose executables are tolerated so source-only
        environments can still render output. Any installed command that
        returns failure is treated as authoritative.
    """
    arguments = [
        "--env-file",
        str(output_dir / ".env"),
        "-f",
        str(output_dir / "docker-compose.yml"),
        "config",
        "--quiet",
    ]
    commands = (["docker", "compose", *arguments], ["docker-compose", *arguments])
    for command in commands:
        try:
            result = subprocess.run(
                command,
                capture_output=True,
                text=True,
                check=False,
            )
        except FileNotFoundError:
            continue
        if result.returncode:
            message = result.stderr.strip() or result.stdout.strip()
            raise RenderError(f"Docker Compose rejected the generated stack: {message}")
        return

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