Skip to content

Text helper API

text

Extract and format source fragments without discarding project comments.

TemplateError

Bases: ValueError

Raised when a source template does not match the expected structure.

extract_service

extract_service(source: str, service_name: str) -> str

Extract one Compose service with its leading project comments.

Parameters:

Name Type Description Default
source str

Complete Compose source containing a top-level services section.

required
service_name str

Exact service key to extract.

required

Returns:

Type Description
str

The selected service block with one trailing newline.

Raises:

Type Description
TemplateError

If the services section or requested service is absent.

Note

This intentionally avoids YAML parsing so operator-facing comments and unresolved Compose variables survive generation verbatim.

Source code in docker/src/maraudarr/text.py
def extract_service(source: str, service_name: str) -> str:
    """Extract one Compose service with its leading project comments.

    Args:
        source: Complete Compose source containing a top-level services section.
        service_name: Exact service key to extract.

    Returns:
        The selected service block with one trailing newline.

    Raises:
        TemplateError: If the services section or requested service is absent.

    Note:
        This intentionally avoids YAML parsing so operator-facing comments and
        unresolved Compose variables survive generation verbatim.
    """
    lines = source.splitlines(keepends=True)
    try:
        services_index = next(
            index for index, line in enumerate(lines) if line.rstrip() == "services:"
        )
    except StopIteration as error:
        raise TemplateError("Compose source does not contain a services section.") from error

    # Service declarations are the only two-space keys in the owned services
    # section. Stop at the next top-level key instead of scanning unrelated YAML.
    declarations: list[tuple[str, int]] = []
    for index in range(services_index + 1, len(lines)):
        match = SERVICE_PATTERN.match(lines[index].rstrip("\n"))
        if match:
            declarations.append((match.group(1), index))
        elif lines[index] and not lines[index].startswith((" ", "\n", "\r")):
            break

    names = [name for name, _ in declarations]
    if service_name not in names:
        raise TemplateError(f"Service '{service_name}' was not found in its source file.")

    position = names.index(service_name)
    declaration_index = declarations[position][1]
    # Walk backward across only blank and service-indented comment lines so the
    # selected service retains its purpose banner without stealing its neighbor.
    start = declaration_index
    while start > services_index + 1:
        previous = lines[start - 1]
        if previous.strip() == "" or previous.startswith("  #"):
            start -= 1
            continue
        break

    if position + 1 < len(declarations):
        next_declaration = declarations[position + 1][1]
        end = next_declaration
        while end > declaration_index:
            previous = lines[end - 1]
            if previous.strip() == "" or previous.startswith("  #"):
                end -= 1
                continue
            break
    else:
        end = len(lines)
        for index in range(declaration_index + 1, len(lines)):
            if lines[index] and not lines[index].startswith((" ", "\n", "\r")):
                end = index
                break

    return "".join(lines[start:end]).strip("\n") + "\n"

extract_foundation

extract_foundation(source: str) -> str

Extract shared anchors and the opening services key.

Parameters:

Name Type Description Default
source str

Complete project-owned base Compose template.

required

Returns:

Type Description
str

Foundation text ending immediately after services:.

Raises:

Type Description
TemplateError

If either required structural marker is absent.

Source code in docker/src/maraudarr/text.py
def extract_foundation(source: str) -> str:
    """Extract shared anchors and the opening services key.

    Args:
        source: Complete project-owned base Compose template.

    Returns:
        Foundation text ending immediately after ``services:``.

    Raises:
        TemplateError: If either required structural marker is absent.
    """
    marker = "#\n# Setup default properties for all or most containers.\n#\n"
    start = source.find(marker)
    if start < 0:
        raise TemplateError("Compose foundation marker was not found.")
    services_marker = "#\n# Define the services section.\n#\nservices:\n"
    end = source.find(services_marker, start)
    if end < 0:
        raise TemplateError("Compose services marker was not found.")
    end += len(services_marker)
    return source[start:end]

align_env_comments

align_env_comments(source: str) -> str

Align inline comments within contiguous environment assignment groups.

Parameters:

Name Type Description Default
source str

Rendered environment text after value substitutions.

required

Returns:

Type Description
str

Text with two or more spaces before aligned inline comments. Values,

str

comment text, uncommented assignments, and section boundaries are preserved.

Source code in docker/src/maraudarr/text.py
def align_env_comments(source: str) -> str:
    """Align inline comments within contiguous environment assignment groups.

    Args:
        source: Rendered environment text after value substitutions.

    Returns:
        Text with two or more spaces before aligned inline comments. Values,
        comment text, uncommented assignments, and section boundaries are preserved.
    """
    assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
    lines = source.splitlines(keepends=True)
    group: list[tuple[int, str, str, str]] = []
    for index, line in enumerate([*lines, ""]):
        if not assignment.match(line):
            if group:
                width = max(len(code) for _, code, _, _ in group)
                for position, code, comment, ending in group:
                    lines[position] = f"{code.ljust(width)}  {comment}{ending}"
                group = []
            continue

        # Only unquoted, unescaped hashes preceded by whitespace start comments.
        quote = ""
        escaped = False
        for offset in range(line.index("=") + 1, len(line)):
            character = line[offset]
            if escaped:
                escaped = False
            elif character == "\\":
                escaped = True
            elif quote:
                if character == quote:
                    quote = ""
            elif character in ("'", '"'):
                quote = character
            elif character == "#" and line[offset - 1] in " \t":
                code = line[:offset].rstrip(" \t")
                comment = line[offset:].rstrip("\r\n")
                ending = line[len(line.rstrip("\r\n")) :]
                group.append((index, code, comment, ending))
                break
    return "".join(lines)

prune_unused_anchors

prune_unused_anchors(foundation: str, content: str) -> str

Keep shared anchor blocks reachable from the rendered stack.

Parameters:

Name Type Description Default
foundation str

Extracted foundation with blank-separated anchor blocks.

required
content str

Rendered services and footer whose aliases select anchors.

required

Returns:

Type Description
str

Foundation with unused anchor blocks and their comments removed.

str

Referenced anchors retain their original order and source text.

Note

Project charts use standalone mapping or sequence aliases. Match those values without treating comments, quoted strings, or shell globs as aliases.

Source code in docker/src/maraudarr/text.py
def prune_unused_anchors(foundation: str, content: str) -> str:
    """Keep shared anchor blocks reachable from the rendered stack.

    Args:
        foundation: Extracted foundation with blank-separated anchor blocks.
        content: Rendered services and footer whose aliases select anchors.

    Returns:
        Foundation with unused anchor blocks and their comments removed.
        Referenced anchors retain their original order and source text.

    Note:
        Project charts use standalone mapping or sequence aliases. Match those
        values without treating comments, quoted strings, or shell globs as aliases.
    """
    anchor_pattern = re.compile(r"^x-[\w-]+: &([\w-]+)(?:\s+#.*)?$", re.MULTILINE)
    alias_pattern = re.compile(r"^\s*(?:[\w<>-]+:|-)\s+\*([\w-]+)\s*(?:#.*)?$", re.MULTILINE)
    blocks = foundation.rstrip("\n").split("\n\n")
    anchors: dict[str, str] = {}
    for block in blocks:
        match = anchor_pattern.search(block)
        if match:
            anchors[match.group(1)] = block

    # Follow anchor dependencies so indirect defaults survive pruning too.
    needed = set(alias_pattern.findall(content))
    pending = list(needed)
    while pending:
        name = pending.pop()
        dependencies = set(alias_pattern.findall(anchors.get(name, ""))) - needed
        needed.update(dependencies)
        pending.extend(dependencies)

    unused_blocks = {block for name, block in anchors.items() if name not in needed}
    return "\n\n".join(block for block in blocks if block not in unused_blocks) + "\n"
extract_footer(source: str) -> str

Extract the final networks section from the base Compose template.

Parameters:

Name Type Description Default
source str

Complete project-owned base Compose template.

required

Returns:

Type Description
str

Footer text beginning at the networks purpose comment.

Raises:

Type Description
TemplateError

If the networks marker is absent.

Source code in docker/src/maraudarr/text.py
def extract_footer(source: str) -> str:
    """Extract the final networks section from the base Compose template.

    Args:
        source: Complete project-owned base Compose template.

    Returns:
        Footer text beginning at the networks purpose comment.

    Raises:
        TemplateError: If the networks marker is absent.
    """
    marker = "#\n# Define the networks section.\n#\nnetworks:\n"
    start = source.find(marker)
    if start < 0:
        raise TemplateError("Compose networks marker was not found.")
    return source[start:].strip("\n") + "\n"

extract_env_preamble

extract_env_preamble(source: str) -> str

Extract the copyright and description preamble from an environment file.

Parameters:

Name Type Description Default
source str

Complete project-owned environment source.

required

Returns:

Type Description
str

Preamble text ending before the first setting group.

Raises:

Type Description
TemplateError

If the first setting marker is absent.

Source code in docker/src/maraudarr/text.py
def extract_env_preamble(source: str) -> str:
    """Extract the copyright and description preamble from an environment file.

    Args:
        source: Complete project-owned environment source.

    Returns:
        Preamble text ending before the first setting group.

    Raises:
        TemplateError: If the first setting marker is absent.
    """
    first_setting = (
        "#\n# Name of the project which adds namespace for all services and volumes.\n#\n"
    )
    end = source.find(first_setting)
    if end < 0:
        raise TemplateError("Environment preamble marker was not found.")
    return source[:end].rstrip("\n") + "\n\n"

extract_env_sections

extract_env_sections(source: str) -> dict[str, str]

Split framed environment sections while retaining comments verbatim.

Parameters:

Name Type Description Default
source str

Complete project-owned environment source.

required

Returns:

Type Description
dict[str, str]

Ordered mapping of section titles to newline-terminated source blocks.

dict[str, str]

Copyright and file-description frames are excluded.

Source code in docker/src/maraudarr/text.py
def extract_env_sections(source: str) -> dict[str, str]:
    """Split framed environment sections while retaining comments verbatim.

    Args:
        source: Complete project-owned environment source.

    Returns:
        Ordered mapping of section titles to newline-terminated source blocks.
        Copyright and file-description frames are excluded.
    """
    matches = list(ENV_HEADER_PATTERN.finditer(source))
    sections: dict[str, str] = {}
    for position, match in enumerate(matches):
        title = match.group("title")
        # Header frames share the same visual syntax as setting groups but are
        # file metadata, not selectable environment content.
        if title.startswith(("Copyright ", "Licensed under ", ".env file:")):
            continue
        end = matches[position + 1].start() if position + 1 < len(matches) else len(source)
        sections[title] = source[match.start() : end].strip("\n") + "\n"
    return sections

strip_yaml_key

strip_yaml_key(block: str, key: str) -> str

Remove one service-level YAML key and its nested content.

Parameters:

Name Type Description Default
block str

Extracted Compose service block.

required
key str

Exact four-space key name to remove.

required

Returns:

Type Description
str

The normalized block, unchanged when the key is absent.

Source code in docker/src/maraudarr/text.py
def strip_yaml_key(block: str, key: str) -> str:
    """Remove one service-level YAML key and its nested content.

    Args:
        block: Extracted Compose service block.
        key: Exact four-space key name to remove.

    Returns:
        The normalized block, unchanged when the key is absent.
    """
    lines = block.splitlines(keepends=True)
    start = None
    end = len(lines)
    target = f"    {key}:"
    for index, line in enumerate(lines):
        if line.rstrip() == target:
            start = index
            break
    if start is None:
        return block
    key_start = start
    # Include the key's immediately preceding comment banner and blank line so
    # conditional removal does not leave misleading orphaned documentation.
    while start > 0 and lines[start - 1].startswith("    #"):
        start -= 1
    while start > 0 and lines[start - 1].strip() == "":
        start -= 1
    for index in range(key_start + 1, len(lines)):
        line = lines[index]
        if line.startswith("    ") and not line.startswith("      ") and line.strip():
            end = index
            break
    return "".join(lines[:start] + lines[end:]).rstrip("\n") + "\n"

remove_comment_group

remove_comment_group(block: str, heading: str) -> str

Remove one Homepage environment group identified by its heading.

Parameters:

Name Type Description Default
block str

Homepage Compose service block.

required
heading str

Exact project-owned comment text introducing the group.

required

Returns:

Type Description
str

The normalized block, unchanged when the heading is absent.

Source code in docker/src/maraudarr/text.py
def remove_comment_group(block: str, heading: str) -> str:
    """Remove one Homepage environment group identified by its heading.

    Args:
        block: Homepage Compose service block.
        heading: Exact project-owned comment text introducing the group.

    Returns:
        The normalized block, unchanged when the heading is absent.
    """
    marker = f"      # {heading}\n"
    start = block.find(marker)
    if start < 0:
        return block
    next_heading = block.find("\n      # Homepage ", start + len(marker))
    if next_heading < 0:
        next_heading = block.find("\n    # ", start + len(marker))
    if next_heading < 0:
        next_heading = len(block)
    return block[:start].rstrip() + "\n\n" + block[next_heading + 1 :].lstrip("\n")

aligned_yaml_lines

aligned_yaml_lines(
    entries: list[tuple[str, str]], indent: int
) -> str

Format a logical YAML group with aligned inline comments.

Parameters:

Name Type Description Default
entries list[tuple[str, str]]

Ordered (code, comment) pairs without indentation.

required
indent int

Number of leading spaces applied to every output line.

required

Returns:

Type Description
str

Newline-separated YAML lines, or an empty string for no entries.

Source code in docker/src/maraudarr/text.py
def aligned_yaml_lines(entries: list[tuple[str, str]], indent: int) -> str:
    """Format a logical YAML group with aligned inline comments.

    Args:
        entries: Ordered ``(code, comment)`` pairs without indentation.
        indent: Number of leading spaces applied to every output line.

    Returns:
        Newline-separated YAML lines, or an empty string for no entries.
    """
    if not entries:
        return ""
    prefix = " " * indent
    width = max(len(code) for code, _ in entries)
    return "\n".join(f"{prefix}{code.ljust(width)}  # {comment}" for code, comment in entries)