Skip to content

Workspace patcher

workspace_patcher

Workspace patcher — patch root files after member scaffold.

Provides idempotent patching functions for workspace root files (Makefile, mkdocs.yml, pyproject.toml, ci.yml, publish.yml, release.yml) when a new member sub-package is added via scaffold --member.

PatchReport dataclass

Truthful accounting of a :func:patch_all run.

Attributes:

Name Type Description
patched list[str]

Names of files a patcher actually modified.

skipped list[str]

Names of files left unchanged — either a no-op (already patched) or absent (FileNotFoundError). Never overlaps patched.

failed list[str]

Names of files whose patcher raised an unexpected error (PermissionError, UnicodeDecodeError, …). A non-empty list is the partial-state signal.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
@dataclass(frozen=True, slots=True)
class PatchReport:
    """Truthful accounting of a :func:`patch_all` run.

    Attributes:
        patched: Names of files a patcher actually modified.
        skipped: Names of files left unchanged — either a no-op (already
            patched) or absent (``FileNotFoundError``). Never overlaps
            *patched*.
        failed: Names of files whose patcher raised an unexpected error
            (``PermissionError``, ``UnicodeDecodeError``, …). A non-empty
            list is the partial-state signal.
    """

    patched: list[str]
    skipped: list[str]
    failed: list[str]

    @property
    def has_partial_failure(self) -> bool:
        """``True`` when at least one patcher failed unexpectedly."""
        return bool(self.failed)
has_partial_failure property

True when at least one patcher failed unexpectedly.

patch_all(root, member_name)

Run all workspace patches for member_name.

Calls each patch_* function and records, per file, whether it was actually modified (patched), left unchanged (skipped — no-op or missing file), or failed unexpectedly (failed). Non-FileNotFoundError exceptions are caught and surfaced as a partial-state signal instead of being raised, so a single unwritable file never aborts the whole run.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Name Type Description
A PatchReport

class:PatchReport partitioning every patcher into patched /

PatchReport

skipped / failed. patched lists only files with a real write.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_all(root: Path, member_name: str) -> PatchReport:
    """Run all workspace patches for *member_name*.

    Calls each ``patch_*`` function and records, per file, whether it was
    actually modified (*patched*), left unchanged (*skipped* — no-op or
    missing file), or failed unexpectedly (*failed*). Non-``FileNotFoundError``
    exceptions are caught and surfaced as a partial-state signal instead of
    being raised, so a single unwritable file never aborts the whole run.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        A :class:`PatchReport` partitioning every patcher into patched /
        skipped / failed. ``patched`` lists only files with a real write.
    """
    patched: list[str] = []
    skipped: list[str] = []
    failed: list[str] = []

    patchers = [
        ("Makefile", patch_makefile),
        ("mkdocs.yml", patch_mkdocs),
        ("pyproject.toml", patch_pyproject),
        ("pyproject.toml (testpaths)", patch_testpaths),
        (".github/workflows/ci.yml", patch_ci),
        (".github/workflows/publish.yml", patch_publish),
        (".github/workflows/release.yml", patch_release),
        (".github/dependabot.yml", patch_dependabot),
    ]

    for name, fn in patchers:
        try:
            if fn(root, member_name):
                patched.append(name)
            else:
                skipped.append(name)
        except FileNotFoundError:
            logger.warning("Skipping %s — file not found", name)
            skipped.append(name)
        except (OSError, UnicodeDecodeError) as exc:
            logger.error("Failed to patch %s — %s", name, exc)
            failed.append(name)

    return PatchReport(patched=patched, skipped=skipped, failed=failed)

patch_ci(root, member_name)

Add member_name to CI matrix package list.

Inserts the package name in the strategy.matrix.package list of .github/workflows/ci.yml. Idempotent — skips if already present.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if ci.yml was modified, False if the member was

bool

already listed or no matrix list was found (no-op).

Raises:

Type Description
FileNotFoundError

If ci.yml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_ci(root: Path, member_name: str) -> bool:
    """Add *member_name* to CI matrix package list.

    Inserts the package name in the ``strategy.matrix.package`` list
    of ``.github/workflows/ci.yml``.
    Idempotent — skips if already present.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``ci.yml`` was modified, ``False`` if the member was
        already listed or no matrix list was found (no-op).

    Raises:
        FileNotFoundError: If ``ci.yml`` is missing.
    """
    ci_yml = root / ".github" / "workflows" / "ci.yml"
    content = ci_yml.read_text()

    lines = content.splitlines(keepends=True)
    # Token-exact guard: match the matrix entry the patcher inserts
    # (``<indent>- <member_name>``), not a bare substring — otherwise a
    # prefix collision (``foo`` already listed) would silently skip
    # ``foo-bar``. Indentation is normalized away via ``strip``.
    matrix_entry = f"- {member_name}"
    if any(line.strip() == matrix_entry for line in lines):
        logger.info("ci.yml already contains %s — skipping", member_name)
        return False

    new_lines, changed = _insert_into_yaml_list(
        lines, member_name, list_marker="package:"
    )
    if not changed:
        logger.info("ci.yml has no matrix package list — skipping %s", member_name)
        return False
    ci_yml.write_text("".join(new_lines))
    logger.info("Patched ci.yml matrix with %s", member_name)
    return True

patch_dependabot(root, member_name)

Add a per-package Dependabot entry for member_name.

Appends a package-ecosystem: uv update block scoped to /packages/<member_name> so Dependabot keeps that published package's pyproject constraints current (its PyPI contract), alongside the shared workspace-root lockfile entry. Dependencies pinned exactly (==) in the root [tool.uv].constraint-dependencies are ignored by that block: their single bump channel is the root entry. The block is inserted before the trailing github-actions entry. Idempotent — skips if already present.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if dependabot.yml was modified, False if the member

bool

entry already existed (no-op).

Raises:

Type Description
FileNotFoundError

If .github/dependabot.yml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_dependabot(root: Path, member_name: str) -> bool:
    """Add a per-package Dependabot entry for *member_name*.

    Appends a ``package-ecosystem: uv`` update block scoped to
    ``/packages/<member_name>`` so Dependabot keeps that published package's
    pyproject constraints current (its PyPI contract), alongside the shared
    workspace-root lockfile entry. Dependencies pinned exactly (``==``) in the
    root ``[tool.uv].constraint-dependencies`` are ignored by that block: their
    single bump channel is the root entry. The block is inserted before the
    trailing ``github-actions`` entry. Idempotent — skips if already present.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``dependabot.yml`` was modified, ``False`` if the member
        entry already existed (no-op).

    Raises:
        FileNotFoundError: If ``.github/dependabot.yml`` is missing.
    """
    dependabot_yml = root / ".github" / "dependabot.yml"
    content = dependabot_yml.read_text()

    member_dir = f"directory: /packages/{member_name}"
    if member_dir in content:
        logger.info("dependabot.yml already covers %s — skipping", member_name)
        return False

    block = (
        f"  - package-ecosystem: uv\n"
        f"    directory: /packages/{member_name}\n"
        f"    schedule:\n"
        f"      interval: weekly\n"
        f"    groups:\n"
        f"      {member_name}:\n"
        f"        patterns:\n"
        f'          - "*"\n'
    )
    pinned = _exact_pinned_constraints(root)
    if pinned:
        block += "    ignore:\n" + "".join(
            f"      - dependency-name: {name}\n" for name in pinned
        )

    anchor = "  - package-ecosystem: github-actions\n"
    if anchor in content:
        content = content.replace(anchor, block + anchor, 1)
    else:
        # No github-actions entry to anchor on — append at end of file.
        if not content.endswith("\n"):
            content += "\n"
        content += block

    dependabot_yml.write_text(content)
    logger.info("Patched dependabot.yml with per-package entry for %s", member_name)
    return True

patch_makefile(root, member_name)

Append per-package test/lint targets for member_name.

Adds test-<name> and lint-<name> Makefile targets. Idempotent — skips if targets already exist.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if the Makefile was modified, False if the targets

bool

already existed (no-op).

Raises:

Type Description
FileNotFoundError

If Makefile is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_makefile(root: Path, member_name: str) -> bool:
    """Append per-package test/lint targets for *member_name*.

    Adds ``test-<name>`` and ``lint-<name>`` Makefile targets.
    Idempotent — skips if targets already exist.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if the Makefile was modified, ``False`` if the targets
        already existed (no-op).

    Raises:
        FileNotFoundError: If ``Makefile`` is missing.
    """
    makefile = root / "Makefile"
    content = makefile.read_text()

    target = f"test-{member_name}"
    # Token-exact guard: match the target the patcher inserts (``<target>:``
    # at line start), not a bare substring — otherwise a prefix collision
    # (member ``foo`` vs an existing ``test-foo-bar:`` target) would silently
    # skip ``foo``. Mirrors the anchored guard in ``patch_ci``.
    if any(line.startswith(f"{target}:") for line in content.splitlines()):
        logger.info("Makefile already contains target %s — skipping", target)
        return False

    module_name = member_name.replace("-", "_")
    block = (
        f"\n## Test {member_name}\n"
        f"{target}:\n"
        f"\tuv run pytest --package {member_name} -q\n"
        f"\n## Lint {member_name}\n"
        f"lint-{member_name}:\n"
        f"\tuv run ruff check packages/{member_name}/src/{module_name}/\n"
    )
    makefile.write_text(content + block)
    logger.info("Patched Makefile with targets for %s", member_name)
    return True

patch_mkdocs(root, member_name)

Add !include nav entry for member_name.

Appends a nav entry referencing the member's mkdocs.yml so the monorepo plugin picks it up. Idempotent — skips if entry already exists.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if mkdocs.yml was modified, False if the include

bool

entry already existed (no-op).

Raises:

Type Description
FileNotFoundError

If mkdocs.yml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_mkdocs(root: Path, member_name: str) -> bool:
    """Add ``!include`` nav entry for *member_name*.

    Appends a nav entry referencing the member's ``mkdocs.yml``
    so the monorepo plugin picks it up.
    Idempotent — skips if entry already exists.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``mkdocs.yml`` was modified, ``False`` if the include
        entry already existed (no-op).

    Raises:
        FileNotFoundError: If ``mkdocs.yml`` is missing.
    """
    mkdocs = root / "mkdocs.yml"
    content = mkdocs.read_text()

    include = f"!include ./packages/{member_name}/mkdocs.yml"
    if include in content:
        logger.info("mkdocs.yml already includes %s — skipping", member_name)
        return False

    # Append nav entry at the end of the nav section
    entry = f"  - {member_name}: '{include}'\n"
    content = content.rstrip("\n") + "\n" + entry
    mkdocs.write_text(content)
    logger.info("Patched mkdocs.yml with !include for %s", member_name)
    return True

patch_publish(root, member_name)

Add tag trigger pattern for member_name.

Adds a <member_name>/v* tag pattern to the publish workflow's on.push.tags or on.release trigger. Idempotent — skips if already present.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if publish.yml was modified, False if the tag

bool

pattern already existed or nothing changed (no-op).

Raises:

Type Description
FileNotFoundError

If publish.yml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_publish(root: Path, member_name: str) -> bool:
    """Add tag trigger pattern for *member_name*.

    Adds a ``<member_name>/v*`` tag pattern to the publish workflow's
    ``on.push.tags`` or ``on.release`` trigger.
    Idempotent — skips if already present.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``publish.yml`` was modified, ``False`` if the tag
        pattern already existed or nothing changed (no-op).

    Raises:
        FileNotFoundError: If ``publish.yml`` is missing.
    """
    publish_yml = root / ".github" / "workflows" / "publish.yml"
    content = publish_yml.read_text()
    original = content

    tag_pattern = f"{member_name}/v*"
    if tag_pattern in content:
        logger.info("publish.yml already contains %s tag — skipping", member_name)
        return False

    # If there's a tags section, add the pattern there
    if "tags:" in content:
        lines = content.splitlines(keepends=True)
        new_lines, _ = _insert_into_yaml_list(
            lines, f'"{tag_pattern}"', list_marker="tags:", default_indent="      "
        )
        content = "".join(new_lines)
    else:
        # No tags section — add a push.tags trigger before the real
        # top-level ``jobs:`` mapping key. Anchor structurally (column-0,
        # exact key token, not a ``# jobs:`` comment or an indented step
        # name) instead of a first-substring replace; skip cleanly when no
        # top-level ``jobs:`` exists so the file is never corrupted.
        lines = content.splitlines(keepends=True)
        idx = _find_top_level_key_index(lines, "jobs")
        if idx is None:
            logger.info(
                "publish.yml has no top-level jobs: for %s — skipping",
                member_name,
            )
            return False
        lines.insert(idx, f'  push:\n    tags:\n      - "{tag_pattern}"\n\n')
        content = "".join(lines)

    if content == original:
        logger.info("publish.yml unchanged for %s — skipping", member_name)
        return False
    publish_yml.write_text(content)
    logger.info("Patched publish.yml with tag pattern %s", tag_pattern)
    return True

patch_pyproject(root, member_name)

Register member_name as a UV workspace source.

The primary effect is appending a [tool.uv.sources.<member_name>] entry with workspace = true. Additionally, only if a dependencies = [...] array is present in pyproject.toml, the member is also added to it; on the shipped workspace template (which declares [dependency-groups] rather than [project.dependencies]) this branch is a no-op and only the source entry is written. Idempotent — skips if already present.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if pyproject.toml was modified, False if the member

bool

was already registered (no-op).

Raises:

Type Description
FileNotFoundError

If pyproject.toml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_pyproject(root: Path, member_name: str) -> bool:
    """Register *member_name* as a UV workspace source.

    The primary effect is appending a ``[tool.uv.sources.<member_name>]``
    entry with ``workspace = true``. Additionally, **only if** a
    ``dependencies = [...]`` array is present in ``pyproject.toml``, the
    member is also added to it; on the shipped workspace template (which
    declares ``[dependency-groups]`` rather than ``[project.dependencies]``)
    this branch is a no-op and only the source entry is written.
    Idempotent — skips if already present.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``pyproject.toml`` was modified, ``False`` if the member
        was already registered (no-op).

    Raises:
        FileNotFoundError: If ``pyproject.toml`` is missing.
    """
    pyproject = root / "pyproject.toml"
    content = pyproject.read_text()

    modified = False
    patched_deps = False

    # 1. Add to dependencies array if not present
    dep_pattern = re.compile(r"^dependencies\s*=\s*\[", re.MULTILINE)
    # Check if member_name appears in the deps section (before sources)
    sources_marker = "[tool.uv.sources]"
    if sources_marker in content:
        deps_section = content.split(sources_marker)[0]
    else:
        deps_section = content
    if f'"{member_name}"' not in deps_section:
        match = dep_pattern.search(content)
        if match:
            # Find the closing bracket of dependencies
            start = match.end()
            bracket_pos = content.index("]", start)
            # Ensure the preceding element carries a trailing comma before
            # inserting the new one — a single-line ``["axm-core"]`` (no
            # trailing comma, perfectly legal TOML) would otherwise produce
            # ``["axm-core"    "member",]`` and corrupt the whole workspace.
            head = content[:bracket_pos].rstrip()
            if head.endswith("["):
                new_dep = f'\n    "{member_name}",\n'
            elif head.endswith(","):
                new_dep = f'    "{member_name}",\n'
            else:
                content = content[:bracket_pos] + ",\n" + content[bracket_pos:]
                new_dep = f'    "{member_name}",\n'
                bracket_pos += 2
            content = content[:bracket_pos] + new_dep + content[bracket_pos:]
            modified = True
            patched_deps = True

    # 2. Add to [tool.uv.sources] if not present
    source_key = f"[tool.uv.sources.{member_name}]"
    if source_key not in content:
        # Append source entry
        source_block = f"\n{source_key}\nworkspace = true\n"
        content += source_block
        modified = True

    if modified:
        pyproject.write_text(content)
        effect = "dependency + source" if patched_deps else "source"
        logger.info("Patched pyproject.toml with %s (%s)", member_name, effect)
    else:
        logger.info("pyproject.toml already contains %s — skipping", member_name)
    return modified

patch_release(root, member_name)

Add tag trigger and detect block for member_name in release.yml.

Adds a <member_name>/v* tag pattern and a corresponding detect block (elif branch) to the release workflow so git-cliff scopes changelogs per-package. Idempotent — skips if already present.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if release.yml was modified, False if the tag

bool

pattern already existed or nothing changed (no-op).

Raises:

Type Description
FileNotFoundError

If release.yml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_release(root: Path, member_name: str) -> bool:
    """Add tag trigger and detect block for *member_name* in release.yml.

    Adds a ``<member_name>/v*`` tag pattern and a corresponding
    detect block (elif branch) to the release workflow so git-cliff
    scopes changelogs per-package.
    Idempotent — skips if already present.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``release.yml`` was modified, ``False`` if the tag
        pattern already existed or nothing changed (no-op).

    Raises:
        FileNotFoundError: If ``release.yml`` is missing.
    """
    release_yml = root / ".github" / "workflows" / "release.yml"
    content = release_yml.read_text()
    original = content

    lines = content.splitlines(keepends=True)
    tag_bounds = _find_yaml_list_range(lines, "tags:")
    has_inline_tags = (
        tag_bounds is not None
        and _inline_yaml_sequence_span(lines[tag_bounds[0]], "tags:") is not None
    )
    tag_pattern = f"{member_name}-v*" if has_inline_tags else f"{member_name}/v*"
    if tag_pattern in content:
        logger.info("release.yml already contains %s — skipping", member_name)
        return False

    # 1. Add tag pattern — reuse the shared YAML list inserter
    if "tags:" in content:
        lines, _ = _insert_into_yaml_list(
            lines,
            tag_pattern if has_inline_tags else f'"{tag_pattern}"',
            list_marker="tags:",
            default_indent="      ",
        )
        content = "".join(lines)

    # 2. Add detect elif block before the "else" in the detect step
    pkg_dir = f"packages/{member_name}"
    detect_block = (
        f'          elif [[ "$TAG" == {member_name}/* ]]; then\n'
        f'            echo "package={member_name}" >> "$GITHUB_OUTPUT"\n'
        f'            echo "package-dir={pkg_dir}" >> "$GITHUB_OUTPUT"\n'
    )
    detect_condition = f'elif [[ "$TAG" == {member_name}/* ]]'
    if detect_condition not in content and "else" in content:
        content = content.replace(
            "          else\n",
            detect_block + "          else\n",
        )

    if content == original:
        logger.info("release.yml unchanged for %s — skipping", member_name)
        return False
    release_yml.write_text(content)
    logger.info("Patched release.yml with tag pattern + detect for %s", member_name)
    return True

patch_testpaths(root, member_name)

Ensure root testpaths includes the member's derived test suite.

The suite is named tests_<module_name> rather than a shared tests: a common directory name makes every member's tests.conftest resolve to the same module path, which kills collection at the workspace root.

Adds the test directory of member_name to [tool.pytest.ini_options].testpaths in the root pyproject.toml. Creates the section if it doesn't exist. Idempotent — skips if path already listed.

Parameters:

Name Type Description Default
root Path

Workspace root directory.

required
member_name str

Name of the new member package.

required

Returns:

Type Description
bool

True if pyproject.toml was modified, False if the test

bool

path was already listed (no-op).

Raises:

Type Description
FileNotFoundError

If pyproject.toml is missing.

Source code in packages/axm-init/src/axm_init/adapters/workspace_patcher.py
Python
def patch_testpaths(root: Path, member_name: str) -> bool:
    """Ensure root testpaths includes the member's derived test suite.

    The suite is named ``tests_<module_name>`` rather than a shared ``tests``:
    a common directory name makes every member's ``tests.conftest`` resolve to
    the same module path, which kills collection at the workspace root.

    Adds the test directory of *member_name* to
    ``[tool.pytest.ini_options].testpaths`` in the root ``pyproject.toml``.
    Creates the section if it doesn't exist.
    Idempotent — skips if path already listed.

    Args:
        root: Workspace root directory.
        member_name: Name of the new member package.

    Returns:
        ``True`` if ``pyproject.toml`` was modified, ``False`` if the test
        path was already listed (no-op).

    Raises:
        FileNotFoundError: If ``pyproject.toml`` is missing.
    """
    pyproject = root / "pyproject.toml"
    content = pyproject.read_text()

    test_path = f"packages/{member_name}/tests_{member_name.replace('-', '_')}"
    if test_path in content:
        logger.info("testpaths already contains %s — skipping", test_path)
        return False

    content = _insert_into_toml_array(content, test_path)
    pyproject.write_text(content)
    logger.info("Patched testpaths with %s", test_path)
    return True