Skip to content

Index

axm_anvil

Deterministic CST-based refactoring toolkit for Python.

Move, rename, and extract symbols atomically across files (split and merge are on the roadmap).

ExtractTool

Bases: AXMTool

Extract top-level symbols from a module into a brand-new module.

Registered as anvil_extract via the axm.tools entry point. Delegates to :func:axm_anvil.core.extract.extract_symbols (itself a thin adapter over the move pipeline) and adapts exceptions into ToolResult(success=False). The result shape matches anvil_move.

Source code in packages/axm-anvil/src/axm_anvil/tools/extract.py
Python
class ExtractTool(AXMTool):
    """Extract top-level symbols from a module into a brand-new module.

    Registered as ``anvil_extract`` via the ``axm.tools`` entry point.
    Delegates to :func:`axm_anvil.core.extract.extract_symbols` (itself a
    thin adapter over the move pipeline) and adapts exceptions into
    ``ToolResult(success=False)``. The result shape matches ``anvil_move``.
    """

    agent_hint: str = (
        "Extract classes, functions, or constants into a NEW module "
        "(created on disk), with their transitive dependencies, and rewrite "
        "cross-file callers. Use dry_run=True to preview changes."
    )

    @property
    def name(self) -> str:
        """Return tool name for registry lookup."""
        return "anvil_extract"

    @staticmethod
    def _parse_decorators(spec: str | None) -> frozenset[str] | None:
        if spec is None:
            return None
        return frozenset(entry.strip() for entry in spec.split(",") if entry.strip())

    @staticmethod
    def _build_result_data(
        plan: MovePlan,
        src_path: Path,
        tgt_path: Path,
    ) -> dict[str, object]:
        return {
            "moved": [{"symbol": name} for name in plan.moved_names],
            "dependencies_copied": {
                "imports": list(plan.imports_added),
                "constants": list(plan.constants_added),
            },
            "callers_updated": [
                {
                    "file": entry.file,
                    "line": entry.line,
                    "old": entry.old,
                    "new": entry.new,
                }
                for entry in plan.callers_updated
            ],
            "warnings": list(plan.warnings),
            "shared_helpers_detected": [
                {
                    "name": det.name,
                    "used_by_moved": list(det.used_by_moved),
                    "used_by_remaining": list(det.used_by_remaining),
                }
                for det in plan.shared_helpers_detected
            ],
            "files_modified": [str(src_path), str(tgt_path)],
        }

    def execute(  # noqa: PLR0913
        self,
        *,
        path: str = ".",
        symbols: str = "",
        from_file: str = "",
        to_file: str = "",
        dry_run: bool = False,
        shared_helpers: str = "duplicate",
        shared_helpers_module: str | None = None,
        rename: str | None = None,
        strict: bool = False,
        insert_after: str | None = None,
        include_helpers: bool = True,
        side_effect_decorators: str | None = None,
        **kwargs: object,
    ) -> ToolResult:
        """Extract ``symbols`` (CSV) from ``from_file`` into a new ``to_file``.

        Parameters
        ----------
        path:
            Workspace root used to resolve relative ``from_file`` / ``to_file``
            and to constrain caller updates.
        symbols:
            Comma-separated list of top-level symbol names to extract. Empty
            entries are ignored.
        from_file:
            Source Python file. Relative paths are resolved against ``path``.
        to_file:
            Target Python file to **create**. Relative paths are resolved
            against ``path``; missing parent directories are created.
        dry_run:
            When ``True``, compute the :class:`MovePlan` without writing (and
            without leaving a scaffolded target on disk).
        shared_helpers:
            Policy for helpers used by both moved and remaining symbols:
            ``"duplicate"``, ``"extract"``, or ``"error"``.
        shared_helpers_module:
            Target module path used when ``shared_helpers="extract"``.
        rename:
            Optional JSON object string mapping old symbol names to new ones
            (e.g. ``'{"OldName": "NewName"}'``). Invalid JSON yields a
            ``success=False`` result.
        strict:
            When ``True``, a requested symbol absent from the source module
            raises (surfaced as ``success=False``) instead of being skipped
            with a warning.
        insert_after:
            Optional name of a top-level symbol in the target module after
            which extracted blocks are spliced. ``None`` appends at the end.
        include_helpers:
            When ``True`` (default) transitively-referenced local helpers and
            constants are copied into the target.
        side_effect_decorators:
            Optional comma-separated list of extra side-effect decorator
            dotted-names extending the built-in whitelist.

        Returns
        -------
        ToolResult
            ``success=True`` with a plan summary on success; otherwise
            ``success=False`` with a message (missing symbol, collision,
            shared helpers, validation error).
        """
        root, src_path, tgt_path, symbol_list = normalize_execute_args(
            path, symbols, from_file, to_file
        )

        extra_decorators = self._parse_decorators(side_effect_decorators)

        rename_map: dict[str, str] | None = None
        if rename is not None:
            try:
                rename_map = json.loads(rename)
            except json.JSONDecodeError as exc:
                return ToolResult(success=False, error=f"invalid JSON in rename: {exc}")

        try:
            plan = extract_symbols(
                src_path,
                tgt_path,
                symbol_list,
                dry_run=dry_run,
                workspace_root=root,
                shared_helpers=shared_helpers,
                shared_helpers_module=shared_helpers_module,
                rename=rename_map,
                strict=strict,
                insert_after=insert_after,
                include_helpers=include_helpers,
                side_effect_decorators=extra_decorators,
            )
        except Exception as exc:  # noqa: BLE001
            return exception_to_result(exc)

        data = self._build_result_data(plan, src_path, tgt_path)
        text = self._format_text(plan, from_file=str(src_path), to_file=str(tgt_path))
        return ToolResult(success=True, data=data, text=text)

    def _format_text(
        self,
        plan: MovePlan,
        *,
        from_file: str,
        to_file: str,
    ) -> str:
        """Render the extract plan as compact text (mirrors anvil_move)."""
        n = len(plan.moved_names)
        src_name = Path(from_file).name or from_file
        tgt_name = Path(to_file).name or to_file
        lines: list[str] = [
            f"anvil_extract | {n} symbols | {src_name}{tgt_name} (new)",
            "",
            "Extracted:",
        ]
        for name in plan.moved_names:
            lines.append(f"  - {name}")
        lines.append("")
        lines.append("Dependencies:")
        lines.append(f"  imports: {len(plan.imports_added)}")
        lines.append(f"  constants: {len(plan.constants_added)}")
        lines.append("")
        lines.append(f"Callers Updated: {len(plan.callers_updated)}")
        if plan.shared_helpers_detected:
            lines.append("")
            lines.append("Shared Helpers:")
            for det in plan.shared_helpers_detected:
                lines.append(
                    f"  - {det.name} (also used by: {', '.join(det.used_by_remaining)})"
                )
        if plan.warnings:
            lines.append("")
            lines.append("Warnings:")
            for warning in plan.warnings:
                lines.append(f"  - {warning}")
        return "\n".join(lines)
name property

Return tool name for registry lookup.

execute(*, path='.', symbols='', from_file='', to_file='', dry_run=False, shared_helpers='duplicate', shared_helpers_module=None, rename=None, strict=False, insert_after=None, include_helpers=True, side_effect_decorators=None, **kwargs)

Extract symbols (CSV) from from_file into a new to_file.

Parameters

path: Workspace root used to resolve relative from_file / to_file and to constrain caller updates. symbols: Comma-separated list of top-level symbol names to extract. Empty entries are ignored. from_file: Source Python file. Relative paths are resolved against path. to_file: Target Python file to create. Relative paths are resolved against path; missing parent directories are created. dry_run: When True, compute the :class:MovePlan without writing (and without leaving a scaffolded target on disk). shared_helpers: Policy for helpers used by both moved and remaining symbols: "duplicate", "extract", or "error". shared_helpers_module: Target module path used when shared_helpers="extract". rename: Optional JSON object string mapping old symbol names to new ones (e.g. '{"OldName": "NewName"}'). Invalid JSON yields a success=False result. strict: When True, a requested symbol absent from the source module raises (surfaced as success=False) instead of being skipped with a warning. insert_after: Optional name of a top-level symbol in the target module after which extracted blocks are spliced. None appends at the end. include_helpers: When True (default) transitively-referenced local helpers and constants are copied into the target. side_effect_decorators: Optional comma-separated list of extra side-effect decorator dotted-names extending the built-in whitelist.

Returns

ToolResult success=True with a plan summary on success; otherwise success=False with a message (missing symbol, collision, shared helpers, validation error).

Source code in packages/axm-anvil/src/axm_anvil/tools/extract.py
Python
def execute(  # noqa: PLR0913
    self,
    *,
    path: str = ".",
    symbols: str = "",
    from_file: str = "",
    to_file: str = "",
    dry_run: bool = False,
    shared_helpers: str = "duplicate",
    shared_helpers_module: str | None = None,
    rename: str | None = None,
    strict: bool = False,
    insert_after: str | None = None,
    include_helpers: bool = True,
    side_effect_decorators: str | None = None,
    **kwargs: object,
) -> ToolResult:
    """Extract ``symbols`` (CSV) from ``from_file`` into a new ``to_file``.

    Parameters
    ----------
    path:
        Workspace root used to resolve relative ``from_file`` / ``to_file``
        and to constrain caller updates.
    symbols:
        Comma-separated list of top-level symbol names to extract. Empty
        entries are ignored.
    from_file:
        Source Python file. Relative paths are resolved against ``path``.
    to_file:
        Target Python file to **create**. Relative paths are resolved
        against ``path``; missing parent directories are created.
    dry_run:
        When ``True``, compute the :class:`MovePlan` without writing (and
        without leaving a scaffolded target on disk).
    shared_helpers:
        Policy for helpers used by both moved and remaining symbols:
        ``"duplicate"``, ``"extract"``, or ``"error"``.
    shared_helpers_module:
        Target module path used when ``shared_helpers="extract"``.
    rename:
        Optional JSON object string mapping old symbol names to new ones
        (e.g. ``'{"OldName": "NewName"}'``). Invalid JSON yields a
        ``success=False`` result.
    strict:
        When ``True``, a requested symbol absent from the source module
        raises (surfaced as ``success=False``) instead of being skipped
        with a warning.
    insert_after:
        Optional name of a top-level symbol in the target module after
        which extracted blocks are spliced. ``None`` appends at the end.
    include_helpers:
        When ``True`` (default) transitively-referenced local helpers and
        constants are copied into the target.
    side_effect_decorators:
        Optional comma-separated list of extra side-effect decorator
        dotted-names extending the built-in whitelist.

    Returns
    -------
    ToolResult
        ``success=True`` with a plan summary on success; otherwise
        ``success=False`` with a message (missing symbol, collision,
        shared helpers, validation error).
    """
    root, src_path, tgt_path, symbol_list = normalize_execute_args(
        path, symbols, from_file, to_file
    )

    extra_decorators = self._parse_decorators(side_effect_decorators)

    rename_map: dict[str, str] | None = None
    if rename is not None:
        try:
            rename_map = json.loads(rename)
        except json.JSONDecodeError as exc:
            return ToolResult(success=False, error=f"invalid JSON in rename: {exc}")

    try:
        plan = extract_symbols(
            src_path,
            tgt_path,
            symbol_list,
            dry_run=dry_run,
            workspace_root=root,
            shared_helpers=shared_helpers,
            shared_helpers_module=shared_helpers_module,
            rename=rename_map,
            strict=strict,
            insert_after=insert_after,
            include_helpers=include_helpers,
            side_effect_decorators=extra_decorators,
        )
    except Exception as exc:  # noqa: BLE001
        return exception_to_result(exc)

    data = self._build_result_data(plan, src_path, tgt_path)
    text = self._format_text(plan, from_file=str(src_path), to_file=str(tgt_path))
    return ToolResult(success=True, data=data, text=text)

ImportCycleError

Bases: Exception

Raised when a move would introduce a new import cycle.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class ImportCycleError(Exception):
    """Raised when a move would introduce a new import cycle."""

    def __init__(self, cycle: list[str]) -> None:
        self.cycle = list(cycle)
        chain = " \u2192 ".join([*self.cycle, self.cycle[0]])
        super().__init__(f"Import cycle: {chain}")

MovePathError

Bases: Exception

Raised when source and target paths share no usable common base.

Computing the relative paths handed to batch_edit requires a base directory that contains both the source and the target. When the two live in disjoint trees (e.g. different drives) no such base exists, so the fallback raises this typed error instead of leaking a bare ValueError.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class MovePathError(Exception):
    """Raised when source and target paths share no usable common base.

    Computing the relative paths handed to ``batch_edit`` requires a base
    directory that contains *both* the source and the target. When the two
    live in disjoint trees (e.g. different drives) no such base exists, so the
    fallback raises this typed error instead of leaking a bare ``ValueError``.
    """

    def __init__(self, source: object, target: object) -> None:
        self.source = source
        self.target = target
        super().__init__(
            f"No common base directory contains both {source} and {target}"
        )

MovePlan dataclass

Result of a :func:move_symbols call.

Carries the rendered source and target texts, the names that were actually moved, and the direct dependencies (imports, constants) copied into the target. warnings aggregates non-fatal issues such as ruff post-processing errors.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
@dataclass
class MovePlan:
    """Result of a :func:`move_symbols` call.

    Carries the rendered source and target texts, the names that were
    actually moved, and the direct dependencies (imports, constants)
    copied into the target. ``warnings`` aggregates non-fatal issues
    such as ruff post-processing errors.
    """

    source_text_new: str
    target_text_new: str
    moved_names: list[str]
    imports_added: list[str] = field(default_factory=list)
    constants_added: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    shared_helpers_detected: list[SharedHelperDetection] = field(default_factory=list)
    callers_updated: list[CallerRewrite] = field(default_factory=list)

MoveTool

Bases: AXMTool

Move top-level symbols between Python files atomically.

Registered as anvil_move via the axm.tools entry point. Delegates to :func:axm_anvil.core.move.move_symbols and adapts exceptions into ToolResult(success=False).

Source code in packages/axm-anvil/src/axm_anvil/tools/move.py
Python
class MoveTool(AXMTool):
    """Move top-level symbols between Python files atomically.

    Registered as ``anvil_move`` via the ``axm.tools`` entry point.
    Delegates to :func:`axm_anvil.core.move.move_symbols` and adapts
    exceptions into ``ToolResult(success=False)``.
    """

    agent_hint: str = (
        "Move classes, functions, or constants between Python files atomically. "
        "Use dry_run=True to preview changes."
    )

    @property
    def name(self) -> str:
        """Return tool name for registry lookup."""
        return "anvil_move"

    @staticmethod
    def _parse_decorators(spec: str | None) -> frozenset[str] | None:
        if spec is None:
            return None
        return frozenset(entry.strip() for entry in spec.split(",") if entry.strip())

    @staticmethod
    def _build_result_data(
        plan: MovePlan,
        src_path: Path,
        tgt_path: Path,
        *,
        reexport: bool,
        check: bool,
    ) -> dict[str, object]:
        data: dict[str, object] = {
            "moved": [{"symbol": name} for name in plan.moved_names],
            "dependencies_copied": {
                "imports": list(plan.imports_added),
                "constants": list(plan.constants_added),
            },
            "callers_updated": [
                {
                    "file": entry.file,
                    "line": entry.line,
                    "old": entry.old,
                    "new": entry.new,
                }
                for entry in plan.callers_updated
            ],
            "warnings": list(plan.warnings),
            "shared_helpers_detected": [
                {
                    "name": det.name,
                    "used_by_moved": list(det.used_by_moved),
                    "used_by_remaining": list(det.used_by_remaining),
                }
                for det in plan.shared_helpers_detected
            ],
            "files_modified": [
                str(src_path),
                str(tgt_path),
                *(entry.file for entry in plan.callers_updated),
            ],
        }
        if reexport:
            data["reexport"] = True
        if check:
            data["check"] = True
        return data

    def execute(  # noqa: PLR0913
        self,
        *,
        path: str = ".",
        symbols: str = "",
        from_file: str = "",
        to_file: str = "",
        dry_run: bool = False,
        shared_helpers: str = "duplicate",
        shared_helpers_module: str | None = None,
        reexport: bool = False,
        rename: str | None = None,
        check: bool = False,
        strict: bool = False,
        insert_after: str | None = None,
        include_helpers: bool = True,
        side_effect_decorators: str | None = None,
        **kwargs: object,
    ) -> ToolResult:
        """Move ``symbols`` (CSV) from ``from_file`` to ``to_file``.

        Parameters
        ----------
        path:
            Workspace root used to resolve relative ``from_file`` / ``to_file``
            and to constrain caller updates.
        symbols:
            Comma-separated list of top-level symbol names to move. Empty
            entries are ignored.
        from_file:
            Source Python file. Relative paths are resolved against ``path``.
        to_file:
            Target Python file. Relative paths are resolved against ``path``.
        dry_run:
            When ``True``, compute the :class:`MovePlan` without writing.
        shared_helpers:
            Policy for helpers used by both moved and remaining symbols:
            ``"duplicate"``, ``"extract"``, or ``"error"``.
        shared_helpers_module:
            Target module path used when ``shared_helpers="extract"``.
        reexport:
            When ``True``, leave callers untouched and inject a re-export in
            the source module. Incompatible with ``rename``.
        rename:
            Optional JSON object string mapping old symbol names to new ones
            (e.g. ``'{"OldName": "NewName"}'``). Parsed to ``dict[str, str]``
            and forwarded to :func:`move_symbols`. Invalid JSON yields a
            ``success=False`` result.
        strict:
            When ``True``, a requested symbol absent from the source module
            raises (surfaced as ``success=False``) instead of being silently
            skipped with a warning. When ``False`` (default) the current
            skip-and-warn behaviour is preserved.
        insert_after:
            Optional name of a top-level symbol in the target module; moved
            blocks are spliced immediately after it. When ``None`` blocks
            append at the end; an absent name appends at the end with a
            warning.
        include_helpers:
            When ``True`` (default) transitively-referenced local helpers and
            constants are copied into the target. When ``False`` they are not
            copied (a warning enumerates the un-copied names); imports are
            still copied regardless.
        side_effect_decorators:
            Optional comma-separated list of extra side-effect decorator
            dotted-names that extend the built-in ``SIDE_EFFECT_DECORATORS``
            whitelist. A moved symbol decorated with a matching decorator
            yields a non-blocking warning on the plan.

        Returns
        -------
        ToolResult
            ``success=True`` with a ``MovePlan`` summary on success; otherwise
            ``success=False`` with a message describing the failure
            (missing symbol, collision, shared helpers, validation error).
        """
        root, src_path, tgt_path, symbol_list = normalize_execute_args(
            path, symbols, from_file, to_file
        )

        extra_decorators = self._parse_decorators(side_effect_decorators)

        rename_map: dict[str, str] | None = None
        if rename is not None:
            try:
                rename_map = json.loads(rename)
            except json.JSONDecodeError as exc:
                return ToolResult(success=False, error=f"invalid JSON in rename: {exc}")

        try:
            plan = move_symbols(
                src_path,
                tgt_path,
                symbol_list,
                dry_run=dry_run,
                workspace_root=root,
                shared_helpers=shared_helpers,
                shared_helpers_module=shared_helpers_module,
                reexport=reexport,
                rename=rename_map,
                check=check,
                strict=strict,
                insert_after=insert_after,
                include_helpers=include_helpers,
                side_effect_decorators=extra_decorators,
            )
        except Exception as exc:  # noqa: BLE001
            return exception_to_result(exc)

        data = self._build_result_data(
            plan, src_path, tgt_path, reexport=reexport, check=check
        )
        text = self._format_text(
            plan,
            from_file=str(src_path),
            to_file=str(tgt_path),
            reexport=reexport,
        )
        return ToolResult(success=True, data=data, text=text)

    def _format_text(
        self,
        plan: MovePlan,
        *,
        from_file: str,
        to_file: str,
        reexport: bool = False,
    ) -> str:
        """Render the move plan as compact text per spec §14.2."""
        n = len(plan.moved_names)
        src_name = Path(from_file).name or from_file
        tgt_name = Path(to_file).name or to_file
        lines: list[str] = [
            f"anvil_move | {n} symbols | {src_name} \u2192 {tgt_name}",
            "",
        ]
        if reexport:
            lines.append("Mode: reexport")
            lines.append("")
        lines.append("Moved:")
        for name in plan.moved_names:
            lines.append(f"  - {name}")
        lines.append("")
        lines.append("Dependencies:")
        lines.append(f"  imports: {len(plan.imports_added)}")
        lines.append(f"  constants: {len(plan.constants_added)}")
        lines.append("")
        lines.append(f"Callers Updated: {len(plan.callers_updated)}")
        if plan.shared_helpers_detected:
            lines.append("")
            lines.append("Shared Helpers:")
            for det in plan.shared_helpers_detected:
                lines.append(
                    f"  - {det.name} (also used by: {', '.join(det.used_by_remaining)})"
                )
        if plan.warnings:
            lines.append("")
            lines.append("Warnings:")
            for warning in plan.warnings:
                lines.append(f"  - {warning}")
        return "\n".join(lines)
name property

Return tool name for registry lookup.

execute(*, path='.', symbols='', from_file='', to_file='', dry_run=False, shared_helpers='duplicate', shared_helpers_module=None, reexport=False, rename=None, check=False, strict=False, insert_after=None, include_helpers=True, side_effect_decorators=None, **kwargs)

Move symbols (CSV) from from_file to to_file.

Parameters

path: Workspace root used to resolve relative from_file / to_file and to constrain caller updates. symbols: Comma-separated list of top-level symbol names to move. Empty entries are ignored. from_file: Source Python file. Relative paths are resolved against path. to_file: Target Python file. Relative paths are resolved against path. dry_run: When True, compute the :class:MovePlan without writing. shared_helpers: Policy for helpers used by both moved and remaining symbols: "duplicate", "extract", or "error". shared_helpers_module: Target module path used when shared_helpers="extract". reexport: When True, leave callers untouched and inject a re-export in the source module. Incompatible with rename. rename: Optional JSON object string mapping old symbol names to new ones (e.g. '{"OldName": "NewName"}'). Parsed to dict[str, str] and forwarded to :func:move_symbols. Invalid JSON yields a success=False result. strict: When True, a requested symbol absent from the source module raises (surfaced as success=False) instead of being silently skipped with a warning. When False (default) the current skip-and-warn behaviour is preserved. insert_after: Optional name of a top-level symbol in the target module; moved blocks are spliced immediately after it. When None blocks append at the end; an absent name appends at the end with a warning. include_helpers: When True (default) transitively-referenced local helpers and constants are copied into the target. When False they are not copied (a warning enumerates the un-copied names); imports are still copied regardless. side_effect_decorators: Optional comma-separated list of extra side-effect decorator dotted-names that extend the built-in SIDE_EFFECT_DECORATORS whitelist. A moved symbol decorated with a matching decorator yields a non-blocking warning on the plan.

Returns

ToolResult success=True with a MovePlan summary on success; otherwise success=False with a message describing the failure (missing symbol, collision, shared helpers, validation error).

Source code in packages/axm-anvil/src/axm_anvil/tools/move.py
Python
def execute(  # noqa: PLR0913
    self,
    *,
    path: str = ".",
    symbols: str = "",
    from_file: str = "",
    to_file: str = "",
    dry_run: bool = False,
    shared_helpers: str = "duplicate",
    shared_helpers_module: str | None = None,
    reexport: bool = False,
    rename: str | None = None,
    check: bool = False,
    strict: bool = False,
    insert_after: str | None = None,
    include_helpers: bool = True,
    side_effect_decorators: str | None = None,
    **kwargs: object,
) -> ToolResult:
    """Move ``symbols`` (CSV) from ``from_file`` to ``to_file``.

    Parameters
    ----------
    path:
        Workspace root used to resolve relative ``from_file`` / ``to_file``
        and to constrain caller updates.
    symbols:
        Comma-separated list of top-level symbol names to move. Empty
        entries are ignored.
    from_file:
        Source Python file. Relative paths are resolved against ``path``.
    to_file:
        Target Python file. Relative paths are resolved against ``path``.
    dry_run:
        When ``True``, compute the :class:`MovePlan` without writing.
    shared_helpers:
        Policy for helpers used by both moved and remaining symbols:
        ``"duplicate"``, ``"extract"``, or ``"error"``.
    shared_helpers_module:
        Target module path used when ``shared_helpers="extract"``.
    reexport:
        When ``True``, leave callers untouched and inject a re-export in
        the source module. Incompatible with ``rename``.
    rename:
        Optional JSON object string mapping old symbol names to new ones
        (e.g. ``'{"OldName": "NewName"}'``). Parsed to ``dict[str, str]``
        and forwarded to :func:`move_symbols`. Invalid JSON yields a
        ``success=False`` result.
    strict:
        When ``True``, a requested symbol absent from the source module
        raises (surfaced as ``success=False``) instead of being silently
        skipped with a warning. When ``False`` (default) the current
        skip-and-warn behaviour is preserved.
    insert_after:
        Optional name of a top-level symbol in the target module; moved
        blocks are spliced immediately after it. When ``None`` blocks
        append at the end; an absent name appends at the end with a
        warning.
    include_helpers:
        When ``True`` (default) transitively-referenced local helpers and
        constants are copied into the target. When ``False`` they are not
        copied (a warning enumerates the un-copied names); imports are
        still copied regardless.
    side_effect_decorators:
        Optional comma-separated list of extra side-effect decorator
        dotted-names that extend the built-in ``SIDE_EFFECT_DECORATORS``
        whitelist. A moved symbol decorated with a matching decorator
        yields a non-blocking warning on the plan.

    Returns
    -------
    ToolResult
        ``success=True`` with a ``MovePlan`` summary on success; otherwise
        ``success=False`` with a message describing the failure
        (missing symbol, collision, shared helpers, validation error).
    """
    root, src_path, tgt_path, symbol_list = normalize_execute_args(
        path, symbols, from_file, to_file
    )

    extra_decorators = self._parse_decorators(side_effect_decorators)

    rename_map: dict[str, str] | None = None
    if rename is not None:
        try:
            rename_map = json.loads(rename)
        except json.JSONDecodeError as exc:
            return ToolResult(success=False, error=f"invalid JSON in rename: {exc}")

    try:
        plan = move_symbols(
            src_path,
            tgt_path,
            symbol_list,
            dry_run=dry_run,
            workspace_root=root,
            shared_helpers=shared_helpers,
            shared_helpers_module=shared_helpers_module,
            reexport=reexport,
            rename=rename_map,
            check=check,
            strict=strict,
            insert_after=insert_after,
            include_helpers=include_helpers,
            side_effect_decorators=extra_decorators,
        )
    except Exception as exc:  # noqa: BLE001
        return exception_to_result(exc)

    data = self._build_result_data(
        plan, src_path, tgt_path, reexport=reexport, check=check
    )
    text = self._format_text(
        plan,
        from_file=str(src_path),
        to_file=str(tgt_path),
        reexport=reexport,
    )
    return ToolResult(success=True, data=data, text=text)

MoveValidationError

Bases: Exception

Raised when a rendered module fails to parse post-transform.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class MoveValidationError(Exception):
    """Raised when a rendered module fails to parse post-transform."""

    def __init__(self, text: str, cause: BaseException) -> None:
        super().__init__(f"Rendered module failed to parse: {cause}")
        self.text = text

OverloadPartialMoveError

Bases: Exception

Raised when only a subset of an overload group is requested.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class OverloadPartialMoveError(Exception):
    """Raised when only a subset of an overload group is requested."""

RenamePlan dataclass

Result of a :func:rename_symbols call.

Carries the rewritten text of the defining module, the names that were actually renamed (old -> new), the caller-file rewrites and any non-fatal warnings (e.g. a requested symbol that was absent in non-strict mode).

Source code in packages/axm-anvil/src/axm_anvil/core/rename.py
Python
@dataclass
class RenamePlan:
    """Result of a :func:`rename_symbols` call.

    Carries the rewritten text of the defining module, the names that were
    actually renamed (``old -> new``), the caller-file rewrites and any
    non-fatal warnings (e.g. a requested symbol that was absent in
    non-strict mode).
    """

    source_text_new: str
    renamed: dict[str, str]
    callers_updated: list[CallerRewrite] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    files_modified: list[str] = field(default_factory=list)

RenameTool

Bases: AXMTool

Rename top-level symbols in place, rewriting cross-file callers.

Registered as anvil_rename via the axm.tools entry point. Delegates to :func:axm_anvil.core.rename.rename_symbols and adapts exceptions into ToolResult(success=False). Mono-symbol renames use --old/--new; batch renames pass a --mapping JSON object (symmetric with the rename JSON of :class:MoveTool). reexport is not exposed (incompatible with rename, per MoveTool.execute).

Source code in packages/axm-anvil/src/axm_anvil/tools/rename.py
Python
class RenameTool(AXMTool):
    """Rename top-level symbols in place, rewriting cross-file callers.

    Registered as ``anvil_rename`` via the ``axm.tools`` entry point.
    Delegates to :func:`axm_anvil.core.rename.rename_symbols` and adapts
    exceptions into ``ToolResult(success=False)``. Mono-symbol renames use
    ``--old``/``--new``; batch renames pass a ``--mapping`` JSON object
    (symmetric with the ``rename`` JSON of :class:`MoveTool`). ``reexport``
    is not exposed (incompatible with rename, per ``MoveTool.execute``).
    """

    agent_hint: str = (
        "Rename a top-level symbol in place and rewrite its cross-file "
        "callers atomically. Use dry_run=True to preview changes."
    )

    @property
    def name(self) -> str:
        """Return tool name for registry lookup."""
        return "anvil_rename"

    @staticmethod
    def _resolve_mapping(
        mapping: str | None, old: str, new: str
    ) -> dict[str, str] | ToolResult:
        """Build the ``old -> new`` mapping from JSON or ``old``/``new`` args."""
        if mapping is not None:
            try:
                parsed = json.loads(mapping)
            except json.JSONDecodeError as exc:
                return ToolResult(
                    success=False, error=f"invalid JSON in mapping: {exc}"
                )
            if not isinstance(parsed, dict):
                return ToolResult(success=False, error="mapping must be a JSON object")
            return {str(k): str(v) for k, v in parsed.items()}
        if old and new:
            return {old: new}
        return ToolResult(
            success=False,
            error="provide --old and --new, or a --mapping JSON object",
        )

    @staticmethod
    def _build_result_data(plan: RenamePlan) -> dict[str, object]:
        return {
            "renamed": [{"old": old, "new": new} for old, new in plan.renamed.items()],
            "callers_updated": [
                {
                    "file": entry.file,
                    "line": entry.line,
                    "old": entry.old,
                    "new": entry.new,
                }
                for entry in plan.callers_updated
            ],
            "warnings": list(plan.warnings),
            "files_modified": list(plan.files_modified),
        }

    @staticmethod
    def _exception_to_result(exc: Exception) -> ToolResult:
        match exc:
            case SymbolNotFoundError():
                return ToolResult(
                    success=False,
                    error=f"Symbol {exc!s} not found in module",
                )
            case MoveValidationError():
                return ToolResult(success=False, error=str(exc))
            case _:
                return ToolResult(success=False, error=str(exc))

    def execute(  # noqa: PLR0913
        self,
        *,
        path: str = ".",
        file: str = "",
        old: str = "",
        new: str = "",
        mapping: str | None = None,
        dry_run: bool = False,
        strict: bool = False,
        **kwargs: object,
    ) -> ToolResult:
        """Rename symbol(s) in ``file`` and rewrite cross-file callers.

        Parameters
        ----------
        path:
            Workspace root used to resolve a relative ``file`` and to
            constrain caller discovery.
        file:
            Python file defining the symbols. Relative paths resolve against
            ``path``.
        old / new:
            Mono-symbol rename: rename ``old`` to ``new``. Ignored when
            ``mapping`` is provided.
        mapping:
            Optional JSON object string mapping old names to new ones
            (e.g. ``'{"OldName": "NewName"}'``) for batch renames. Invalid
            JSON yields a ``success=False`` result.
        dry_run:
            When ``True``, compute the :class:`RenamePlan` without writing.
        strict:
            When ``True`` an absent symbol raises (surfaced as
            ``success=False``); when ``False`` (default) it is skipped with
            a warning.

        Returns
        -------
        ToolResult
            ``success=True`` with a rename summary (``renamed``,
            ``callers_updated``, ``warnings``, ``files_modified``) on
            success; otherwise ``success=False`` with a failure message.
        """
        resolved = self._resolve_mapping(mapping, old, new)
        if isinstance(resolved, ToolResult):
            return resolved
        root = Path(path).resolve()
        src_path = Path(file)
        if not src_path.is_absolute():
            src_path = root / src_path

        try:
            plan = rename_symbols(
                root,
                src_path,
                resolved,
                dry_run=dry_run,
                workspace_root=root,
                strict=strict,
            )
        except Exception as exc:  # noqa: BLE001
            return self._exception_to_result(exc)

        data = self._build_result_data(plan)
        text = self._format_text(plan, file=str(src_path))
        return ToolResult(success=True, data=data, text=text)

    def _format_text(self, plan: RenamePlan, *, file: str) -> str:
        """Render the rename plan as compact text (mirrors anvil_move)."""
        n = len(plan.renamed)
        name = Path(file).name or file
        lines: list[str] = [f"anvil_rename | {n} symbols | {name}", ""]
        lines.append("Renamed:")
        for old, new in plan.renamed.items():
            lines.append(f"  - {old}{new}")
        lines.append("")
        lines.append(f"Callers Updated: {len(plan.callers_updated)}")
        if plan.warnings:
            lines.append("")
            lines.append("Warnings:")
            for warning in plan.warnings:
                lines.append(f"  - {warning}")
        return "\n".join(lines)
name property

Return tool name for registry lookup.

execute(*, path='.', file='', old='', new='', mapping=None, dry_run=False, strict=False, **kwargs)

Rename symbol(s) in file and rewrite cross-file callers.

Parameters

path: Workspace root used to resolve a relative file and to constrain caller discovery. file: Python file defining the symbols. Relative paths resolve against path. old / new: Mono-symbol rename: rename old to new. Ignored when mapping is provided. mapping: Optional JSON object string mapping old names to new ones (e.g. '{"OldName": "NewName"}') for batch renames. Invalid JSON yields a success=False result. dry_run: When True, compute the :class:RenamePlan without writing. strict: When True an absent symbol raises (surfaced as success=False); when False (default) it is skipped with a warning.

Returns

ToolResult success=True with a rename summary (renamed, callers_updated, warnings, files_modified) on success; otherwise success=False with a failure message.

Source code in packages/axm-anvil/src/axm_anvil/tools/rename.py
Python
def execute(  # noqa: PLR0913
    self,
    *,
    path: str = ".",
    file: str = "",
    old: str = "",
    new: str = "",
    mapping: str | None = None,
    dry_run: bool = False,
    strict: bool = False,
    **kwargs: object,
) -> ToolResult:
    """Rename symbol(s) in ``file`` and rewrite cross-file callers.

    Parameters
    ----------
    path:
        Workspace root used to resolve a relative ``file`` and to
        constrain caller discovery.
    file:
        Python file defining the symbols. Relative paths resolve against
        ``path``.
    old / new:
        Mono-symbol rename: rename ``old`` to ``new``. Ignored when
        ``mapping`` is provided.
    mapping:
        Optional JSON object string mapping old names to new ones
        (e.g. ``'{"OldName": "NewName"}'``) for batch renames. Invalid
        JSON yields a ``success=False`` result.
    dry_run:
        When ``True``, compute the :class:`RenamePlan` without writing.
    strict:
        When ``True`` an absent symbol raises (surfaced as
        ``success=False``); when ``False`` (default) it is skipped with
        a warning.

    Returns
    -------
    ToolResult
        ``success=True`` with a rename summary (``renamed``,
        ``callers_updated``, ``warnings``, ``files_modified``) on
        success; otherwise ``success=False`` with a failure message.
    """
    resolved = self._resolve_mapping(mapping, old, new)
    if isinstance(resolved, ToolResult):
        return resolved
    root = Path(path).resolve()
    src_path = Path(file)
    if not src_path.is_absolute():
        src_path = root / src_path

    try:
        plan = rename_symbols(
            root,
            src_path,
            resolved,
            dry_run=dry_run,
            workspace_root=root,
            strict=strict,
        )
    except Exception as exc:  # noqa: BLE001
        return self._exception_to_result(exc)

    data = self._build_result_data(plan)
    text = self._format_text(plan, file=str(src_path))
    return ToolResult(success=True, data=data, text=text)

SharedHelpersError

Bases: Exception

Raised in error mode when shared helpers would be duplicated.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class SharedHelpersError(Exception):
    """Raised in ``error`` mode when shared helpers would be duplicated."""

    def __init__(self, shared_helpers: list[str]) -> None:
        self.shared_helpers = list(shared_helpers)
        joined = ", ".join(self.shared_helpers)
        super().__init__(
            f"Shared helpers detected (also used by remaining symbols): {joined}"
        )

SymbolAlreadyExistsError

Bases: Exception

Raised when a requested symbol already exists in the target module.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class SymbolAlreadyExistsError(Exception):
    """Raised when a requested symbol already exists in the target module."""

SymbolNotFoundError

Bases: Exception

Raised when a requested symbol does not exist in the source module.

Source code in packages/axm-anvil/src/axm_anvil/core/plan.py
Python
class SymbolNotFoundError(Exception):
    """Raised when a requested symbol does not exist in the source module."""

extract_symbols(source_path, target_path, symbol_names, *, dry_run=False, workspace_root=None, shared_helpers='duplicate', shared_helpers_module=None, rename=None, strict=False, insert_after=None, include_helpers=True, side_effect_decorators=None)

Extract symbol_names from source_path into a new module.

extract is the specialisation of :func:move_symbols where target_path is created rather than amended. The moved blocks and their transitive dependencies (imports, local helpers, constants) are copied into the new module, and cross-file callers are rewritten to import from it — all via the move pipeline.

When target_path does not exist it is scaffolded as an empty module so the move pipeline can fill it. A pre-existing target that already defines a requested symbol raises :class:SymbolAlreadyExistsError (no silent overwrite).

With dry_run=True the :class:MovePlan is computed without leaving any file on disk: a target scaffolded for the dry run is removed before returning, so the source layout is byte-identical to before the call.

All other parameters mirror :func:move_symbols and are forwarded verbatim. reexport and check are intentionally not exposed: re-exporting from / cycle-checking against a freshly created module is meaningless for an extract.

Source code in packages/axm-anvil/src/axm_anvil/core/extract.py
Python
def extract_symbols(  # noqa: PLR0913
    source_path: str | Path,
    target_path: str | Path,
    symbol_names: Sequence[str],
    *,
    dry_run: bool = False,
    workspace_root: Path | None = None,
    shared_helpers: str = "duplicate",
    shared_helpers_module: str | None = None,
    rename: dict[str, str] | None = None,
    strict: bool = False,
    insert_after: str | None = None,
    include_helpers: bool = True,
    side_effect_decorators: frozenset[str] | None = None,
) -> MovePlan:
    """Extract ``symbol_names`` from ``source_path`` into a *new* module.

    ``extract`` is the specialisation of :func:`move_symbols` where
    ``target_path`` is created rather than amended. The moved blocks and
    their transitive dependencies (imports, local helpers, constants) are
    copied into the new module, and cross-file callers are rewritten to
    import from it — all via the move pipeline.

    When ``target_path`` does not exist it is scaffolded as an empty module
    so the move pipeline can fill it. A pre-existing target that already
    defines a requested symbol raises :class:`SymbolAlreadyExistsError`
    (no silent overwrite).

    With ``dry_run=True`` the :class:`MovePlan` is computed without leaving
    any file on disk: a target scaffolded for the dry run is removed before
    returning, so the source layout is byte-identical to before the call.

    All other parameters mirror :func:`move_symbols` and are forwarded
    verbatim. ``reexport`` and ``check`` are intentionally not exposed:
    re-exporting from / cycle-checking against a freshly created module is
    meaningless for an extract.
    """
    source_path = Path(source_path)
    target_path = Path(target_path)

    _check_collision(target_path, symbol_names)

    created_scaffold = False
    created_dirs: list[Path] = []
    if not target_path.exists():
        created_dirs = _mkdir_tracking(target_path.parent)
        target_path.write_text("")
        created_scaffold = True

    succeeded = False
    try:
        plan = move_symbols(
            source_path,
            target_path,
            symbol_names,
            dry_run=dry_run,
            workspace_root=workspace_root,
            shared_helpers=shared_helpers,
            shared_helpers_module=shared_helpers_module,
            rename=rename,
            strict=strict,
            insert_after=insert_after,
            include_helpers=include_helpers,
            side_effect_decorators=side_effect_decorators,
        )
        succeeded = True
    finally:
        # Two situations must not leave a scaffold on disk: a raised move
        # (its empty target file *and* the parent dirs we created would be
        # orphaned) and a dry run (which must leave disk byte-identical).
        # A successful write keeps the scaffold so the move pipeline's
        # output survives.
        if created_scaffold and (not succeeded or dry_run):
            _cleanup_scaffold(target_path, created_dirs)

    return plan

move_symbols(source_path, target_path, symbol_names, *, dry_run=False, workspace_root=None, shared_helpers='duplicate', shared_helpers_module=None, reexport=False, rename=None, check=False, strict=False, insert_after=None, include_helpers=True, side_effect_decorators=None)

Move top-level symbols from source_path to target_path.

Pipeline: parse → expand overloads → extract blocks → gather deps → build new target (imports + constants + symbols) → remove from source → classify shared helpers → validate parseability → atomic write via batch_edit → ruff fix.

shared_helpers selects the strategy when a helper is used by both a moved symbol and a remaining source symbol: "duplicate" copies and keeps the helper (emitting a warning); "error" aborts with :class:SharedHelpersError; "extract" is reserved for Phase 3.

When reexport=True, callers are left untouched and a from new_module import <names> # re-export for backwards compat line is appended to the source module. Incompatible with rename=.

When check=True, the move is simulated (no files written) and any newly introduced import cycle raises :class:ImportCycleError. A normal (non-dry_run) write also performs this check; dry_run=True alone preserves its historical "preview without enforcement" contract.

A requested name that is absent from the source module's top-level symbols is skipped with a warning on :attr:MovePlan.warnings rather than aborting the whole plan. Pass strict=True to restore the legacy behaviour of raising :class:SymbolNotFoundError on the first absent name.

insert_after controls where the moved blocks land in the target module body: when it names an existing top-level symbol the blocks are spliced immediately after it; when None (default) the blocks append at the end (unchanged contract); when it names an absent symbol the blocks append at the end and a warning is added to :attr:MovePlan.warnings. Imports and constants keep their historical placement regardless of insert_after.

include_helpers (default True) preserves the historical behaviour of copying transitively-referenced local helpers and constants into the target. When False those helpers/constants are not copied (the moved code is left referencing them), a warning enumerating the un-copied local helper names is added to :attr:MovePlan.warnings, and the shared_helpers classification is short-circuited (nothing is duplicated or extracted). Imports required by the moved code are always copied regardless of this flag.

Source code in packages/axm-anvil/src/axm_anvil/core/move.py
Python
def move_symbols(  # noqa: PLR0913
    source_path: str | Path,
    target_path: str | Path,
    symbol_names: Sequence[str],
    *,
    dry_run: bool = False,
    workspace_root: Path | None = None,
    shared_helpers: str = "duplicate",
    shared_helpers_module: str | None = None,
    reexport: bool = False,
    rename: dict[str, str] | None = None,
    check: bool = False,
    strict: bool = False,
    insert_after: str | None = None,
    include_helpers: bool = True,
    side_effect_decorators: frozenset[str] | None = None,
) -> MovePlan:
    """Move top-level symbols from ``source_path`` to ``target_path``.

    Pipeline: parse → expand overloads → extract blocks → gather deps →
    build new target (imports + constants + symbols) → remove from source
    → classify shared helpers → validate parseability → atomic write via
    ``batch_edit`` → ruff fix.

    ``shared_helpers`` selects the strategy when a helper is used by both a
    moved symbol and a remaining source symbol: ``"duplicate"`` copies and
    keeps the helper (emitting a warning); ``"error"`` aborts with
    :class:`SharedHelpersError`; ``"extract"`` is reserved for Phase 3.

    When ``reexport=True``, callers are left untouched and a
    ``from new_module import <names>  # re-export for backwards compat`` line
    is appended to the source module. Incompatible with ``rename=``.

    When ``check=True``, the move is simulated (no files written) and any
    *newly introduced* import cycle raises :class:`ImportCycleError`. A
    normal (non-``dry_run``) write also performs this check; ``dry_run=True``
    alone preserves its historical "preview without enforcement" contract.

    A requested name that is absent from the source module's top-level
    symbols is **skipped** with a warning on :attr:`MovePlan.warnings`
    rather than aborting the whole plan. Pass ``strict=True`` to restore
    the legacy behaviour of raising :class:`SymbolNotFoundError` on the
    first absent name.

    ``insert_after`` controls where the moved *blocks* land in the target
    module body: when it names an existing top-level symbol the blocks are
    spliced immediately after it; when ``None`` (default) the blocks append
    at the end (unchanged contract); when it names an absent symbol the
    blocks append at the end and a warning is added to
    :attr:`MovePlan.warnings`. Imports and constants keep their historical
    placement regardless of ``insert_after``.

    ``include_helpers`` (default ``True``) preserves the historical
    behaviour of copying transitively-referenced local helpers and
    constants into the target. When ``False`` those helpers/constants are
    **not** copied (the moved code is left referencing them), a warning
    enumerating the un-copied local helper names is added to
    :attr:`MovePlan.warnings`, and the ``shared_helpers`` classification is
    short-circuited (nothing is duplicated or extracted). Imports required
    by the moved code are always copied regardless of this flag.
    """
    _validate_options(shared_helpers, shared_helpers_module, reexport, rename)

    source_path = Path(source_path)
    target_path = Path(target_path)

    source_text = source_path.read_text()
    target_text = target_path.read_text()
    source_tree = cst.parse_module(source_text)
    target_tree = cst.parse_module(target_text)

    expanded_names, moved_names, skipped_warnings = _validate_and_expand(
        source_tree, target_tree, symbol_names, strict=strict
    )
    if not moved_names:
        # Nothing resolved to a present symbol (all absent, non-strict): the
        # move is a no-op. Return an empty plan with the original texts BEFORE
        # any tree-building or write, so source and target stay byte-identical.
        noop_plan = _build_plan(
            MoveContext(
                source_text_new=source_text,
                target_text_new=target_text,
                moved_names=moved_names,
                imports_added=[],
                constants_added=[],
                shared_map={},
            )
        )
        noop_plan.warnings.extend(skipped_warnings)
        return noop_plan
    remove_targets, blocks = _extract_moved_blocks(source_tree, expanded_names)
    rename_map = _active_rename(rename, moved_names)
    if rename_map:
        _assert_rename_targets_free(target_tree, rename_map)
        blocks = _apply_rename_to_blocks(blocks, rename_map)

    root = (
        Path(workspace_root)
        if workspace_root is not None
        else find_project_root(source_path)
    )
    import_resolution = _build_import_resolution(source_path, target_path, root)

    (
        new_source_tree,
        new_target_tree,
        imports_added,
        constants_added,
        shared_map,
        redundant_import_warnings,
    ) = _build_trees(
        source_tree,
        target_tree,
        blocks,
        remove_targets,
        shared_helpers,
        insert_after=insert_after,
        include_helpers=include_helpers,
        import_resolution=import_resolution,
        rename_map=rename_map,
    )

    if reexport:
        try:
            new_module_path = _module_path_from_file(target_path, root)
        except ValueError:
            new_module_path = target_path.stem
        new_source_tree = _inject_reexport(
            new_source_tree, new_module_path, moved_names
        )

    source_text_new, target_text_new = _render_and_validate(
        new_source_tree, new_target_tree
    )

    caller_texts, caller_rewrites, caller_warnings = _resolve_caller_phase(
        reexport, root, moved_names, source_path, target_path, rename_map
    )

    plan = _build_plan(
        MoveContext(
            source_text_new=source_text_new,
            target_text_new=target_text_new,
            moved_names=moved_names,
            imports_added=imports_added,
            constants_added=constants_added,
            shared_map=shared_map,
            callers_updated=caller_rewrites,
            redundant_import_warnings=redundant_import_warnings,
        )
    )
    plan.warnings.extend(skipped_warnings)
    plan.warnings.extend(caller_warnings)
    # Forward-refs to *renamed* symbols are rewritten in the moved code
    # (RenameSymbols.leave_Annotation); only moved-but-not-renamed names still
    # warrant the manual-update warning.
    unrenamed_moved = [n for n in moved_names if n not in rename_map]
    plan.warnings.extend(_string_forward_ref_warnings(source_tree, unrenamed_moved))
    deco_whitelist = SIDE_EFFECT_DECORATORS | (side_effect_decorators or frozenset())
    plan.warnings.extend(_side_effect_decorator_warnings(blocks, deco_whitelist))
    plan.warnings.extend(
        _fixture_scope_warnings(blocks, source_tree, source_path, target_path, root)
    )

    with _graph_cache_session():
        cycle = _cycle_check(
            root,
            source_path,
            target_path,
            new_source_tree,
            new_target_tree,
            caller_texts,
            blocks,
            source_tree,
        )
    _enforce_cycle(cycle, check, dry_run)

    if dry_run or check:
        return plan

    _apply_write(
        source_path,
        target_path,
        source_text,
        target_text,
        source_text_new,
        target_text_new,
        workspace_root,
        caller_texts,
    )
    plan.warnings.extend(_ruff_fix(source_path, target_path, reexport=reexport))
    return plan

rename_symbols(path, file, mapping, *, dry_run=False, workspace_root=None, strict=False)

Rename top-level symbols in file and rewrite cross-file callers.

Parameters

path: Workspace root used to resolve a relative file and to constrain caller discovery. file: Python file defining the symbols. Relative paths resolve against path. mapping: {old_name: new_name} for the top-level symbols to rename. dry_run: When True, compute the :class:RenamePlan without writing. workspace_root: Explicit workspace root; falls back to the nearest ancestor with a pyproject.toml when None. strict: When True a requested old name absent from the module raises :class:SymbolNotFoundError; when False (default) it is skipped with a warning on :attr:RenamePlan.warnings.

Returns

RenamePlan The rewritten module text, the active renames, caller rewrites and warnings. Caller rewriting is pattern-based on imports; see the module docstring for the uncovered cases.

Source code in packages/axm-anvil/src/axm_anvil/core/rename.py
Python
def rename_symbols(  # noqa: PLR0913
    path: str | Path,
    file: str | Path,
    mapping: dict[str, str],
    *,
    dry_run: bool = False,
    workspace_root: Path | None = None,
    strict: bool = False,
) -> RenamePlan:
    """Rename top-level symbols in ``file`` and rewrite cross-file callers.

    Parameters
    ----------
    path:
        Workspace root used to resolve a relative ``file`` and to constrain
        caller discovery.
    file:
        Python file defining the symbols. Relative paths resolve against
        ``path``.
    mapping:
        ``{old_name: new_name}`` for the top-level symbols to rename.
    dry_run:
        When ``True``, compute the :class:`RenamePlan` without writing.
    workspace_root:
        Explicit workspace root; falls back to the nearest ancestor with a
        ``pyproject.toml`` when ``None``.
    strict:
        When ``True`` a requested ``old`` name absent from the module raises
        :class:`SymbolNotFoundError`; when ``False`` (default) it is skipped
        with a warning on :attr:`RenamePlan.warnings`.

    Returns
    -------
    RenamePlan
        The rewritten module text, the active renames, caller rewrites and
        warnings. Caller rewriting is pattern-based on imports; see the
        module docstring for the uncovered cases.
    """
    root = Path(workspace_root) if workspace_root is not None else Path(path).resolve()
    source_path = Path(file)
    if not source_path.is_absolute():
        source_path = root / source_path

    source_text = source_path.read_text()
    source_tree = cst.parse_module(source_text)

    active, warnings = _resolve_mapping(
        _top_level_names(source_tree), mapping, strict=strict
    )
    if not active:
        return RenamePlan(
            source_text_new=source_text,
            renamed={},
            warnings=warnings,
            files_modified=[],
        )

    source_text_new = _render_renamed(source_text, active)
    caller_texts, caller_rewrites = _rewrite_callers(root, source_path, active)

    files_modified = [str(source_path), *(str(p) for p in caller_texts)]
    plan = RenamePlan(
        source_text_new=source_text_new,
        renamed=active,
        callers_updated=caller_rewrites,
        warnings=warnings,
        files_modified=files_modified,
    )
    if dry_run:
        return plan

    _write_rename(root, source_path, source_text, source_text_new, caller_texts)
    return plan