Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
4053205
TypeVar: First initial version with testing
FrancescoPezzella Aug 26, 2026
f00e79b
TypeVar: Expanded upon the TypeVars - TypeVar/ParamSpec/TypeVarTuple.…
FrancescoPezzella Aug 26, 2026
06955c5
TypeVar: Added type checking and fixed as many linter problems that c…
FrancescoPezzella Aug 26, 2026
90857d4
Fixed a TODO bug with Snake_Case, file names with 2+ words were not c…
FrancescoPezzella Aug 27, 2026
df32a03
Extended TypeVarCheck to fully modernize TypeVar/ParamSpec/TypeVarTup…
FrancescoPezzella Aug 28, 2026
6d96dc0
Added a Python-version gate to TypeVarCheck (PEP 695 conversion only …
FrancescoPezzella Aug 28, 2026
e82bb79
Extracted type_var_domain.py (TypeVar/ParamSpec/TypeVarTuple domain m…
FrancescoPezzella Aug 31, 2026
f13eede
Fixed the 6 pyright strict findings in type_var_check.py/type_var_dom…
FrancescoPezzella Aug 31, 2026
f3744c8
Fix silent corruption when two rewrites target the same node (ast_rew…
FrancescoPezzella Aug 31, 2026
c213bbb
Fix silent corruption when two rewrites target the same node (ast_rew…
FrancescoPezzella Aug 31, 2026
30e712d
Fix TypeVarCheck corrupting or losing code during PEP 695 conversion
FrancescoPezzella Sep 1, 2026
57b961b
Ruff: Fixed and enabled (W)hitespace rule
FrancescoPezzella Sep 1, 2026
60852b0
Ruff: Enabled all unsafe fixes from other rules
FrancescoPezzella Sep 1, 2026
4092488
Fixed ruff releated formatting issues
FrancescoPezzella Sep 1, 2026
e9afd3f
Fix 2
FrancescoPezzella Sep 2, 2026
a4ce611
Applied ruff fixes
FrancescoPezzella Sep 2, 2026
070f4bc
Ruff fixes
FrancescoPezzella Sep 7, 2026
34e730b
Add migration-type-recipes.py CLI for TypeVarCheck
FrancescoPezzella Sep 8, 2026
639b831
CLI - both typevar and typevartuplecheck integrated
FrancescoPezzella Sep 8, 2026
2139086
Add Step/run_steps, refactor TypeVarCheck.check() to use it
FrancescoPezzella Sep 9, 2026
860cdb4
Removed dead code (which became irrelevant some ago), cleaned up some…
FrancescoPezzella Sep 9, 2026
85e2911
Delegate unused-import cleanup to ruff F401 (CLI will call Ruff, no n…
FrancescoPezzella Sep 9, 2026
b537c98
Rewrite CLI: remove dry-run (issues, easier to remove, git diff alrea…
FrancescoPezzella Sep 9, 2026
f903565
Marked two tests as failing due to an earlier fix
FrancescoPezzella Sep 10, 2026
79fc98b
Quote TYPE_CHECKING-only annotation, scope UP037 noqa to one line
FrancescoPezzella Sep 10, 2026
2c947ee
Ruff unsafe fix
FrancescoPezzella Sep 11, 2026
64d85df
'--review' now gives more information to the user regarding Type reci…
FrancescoPezzella Sep 11, 2026
24bbf05
Removed code duplication, fixed incorrect paths to files (before refa…
FrancescoPezzella Sep 11, 2026
6063c1f
Fixed a bug with the correct Python version not being identified by t…
FrancescoPezzella Sep 11, 2026
1910e93
Updated outdated Docs - removed references to deleted Python Kind Map
FrancescoPezzella Sep 14, 2026
f36a58e
Added feedback when processing each file with the Migration tool.
FrancescoPezzella Sep 14, 2026
5175cd0
Forced UTF until becomes Py 3.15 default
FrancescoPezzella Sep 16, 2026
d4f3993
New test, and cleaned up some old comments for tests
FrancescoPezzella Sep 17, 2026
cd75f15
Deleted incorrect files added during the rebase
FrancescoPezzella Sep 21, 2026
437604e
Rebase mistakes-
FrancescoPezzella Sep 21, 2026
d3c868d
Last rebase mistake
FrancescoPezzella Sep 21, 2026
f63b0a9
Add missing docstrings, reword existing ones, add missing import
FrancescoPezzella Sep 21, 2026
dd45946
TypeVar issues
FrancescoPezzella Sep 21, 2026
61fce5b
Add project-wide import safety to TypeVar localization
FrancescoPezzella Sep 22, 2026
94abe1f
Add missing docstrings to TypingRecipe related tests
FrancescoPezzella Sep 23, 2026
afad308
New test that shows a fault in the CLI
FrancescoPezzella Sep 23, 2026
8ee219a
Fix project_root wiring and relative paths in TypeVar import safety
FrancescoPezzella Sep 23, 2026
310c252
Clean up comments and doc strings
FrancescoPezzella Sep 23, 2026
57f301f
Add TODOs to migration tool
FrancescoPezzella Sep 23, 2026
f1499bb
Access TypeVars as model attributes
FrancescoPezzella Sep 23, 2026
af92c4b
Adjust CLI message and resolve path
FrancescoPezzella Sep 23, 2026
37c8b62
Fix outdated references
FrancescoPezzella Sep 23, 2026
e3bec3b
Cleaner docstring
FrancescoPezzella Sep 24, 2026
b59e4c6
Tool no longer looks for a Python Version in the project: was suspect…
FrancescoPezzella Sep 24, 2026
d6001ce
Removed failing test: CI/CD uses Linux environment (which is UTF-8 by…
FrancescoPezzella Sep 24, 2026
6a86ca1
Added an optional cmd for the user to not run Ruff with --no-ruff. Th…
FrancescoPezzella Sep 25, 2026
942a953
Refuse to localize a TypeVar whose origin imports its constructor con…
FrancescoPezzella Sep 30, 2026
dffbb68
Revert changes in ast_refactor_actions (handled in a separate PR)
FrancescoPezzella Oct 2, 2026
4afe68d
TODOs for 2 bugs
FrancescoPezzella Oct 2, 2026
9d68f0a
Remove disabled Ruff rule ignores, Clean up docstring and comment
FrancescoPezzella Oct 2, 2026
3055db3
Bug fix with Project Scanner: no longer enters excluded carpets, savi…
FrancescoPezzella Oct 2, 2026
80601d1
Docstring wording
FrancescoPezzella Oct 5, 2026
8b7bbb0
Remove TypeVarTupleCheck and its related tests have been removed or o…
FrancescoPezzella Oct 5, 2026
60b3cb1
Add tests with xfail regarding multilines and multiple Type Vars bein…
FrancescoPezzella Oct 5, 2026
389d1d1
Remove duplicate code
FrancescoPezzella Oct 6, 2026
0c16d73
Verify bug with TODO and fix another bug
FrancescoPezzella Oct 6, 2026
ee9cbf4
TODO for a bug
FrancescoPezzella Oct 6, 2026
9ea1332
TODO for another bug
FrancescoPezzella Oct 6, 2026
2bfd4e5
Both Step2 and Step3 from type_var_check no longer delete orphaned de…
FrancescoPezzella Oct 6, 2026
caef79c
Remove same name being shown twice in the CLI report and remove low v…
FrancescoPezzella Oct 6, 2026
0e7579a
Remove duplicate, low value, X is not present type tests
FrancescoPezzella Oct 7, 2026
c987dcc
Test with multiple TypeVars and their output order
FrancescoPezzella Oct 7, 2026
cee33e3
Fix test
FrancescoPezzella Oct 7, 2026
f3860d4
Remove `USED_OUTSIDE_FUNCTION` as a reason for not converting Type Pa…
FrancescoPezzella Oct 7, 2026
3348246
Combined Generic definition for 2 Generic related unsafe rules in typ…
FrancescoPezzella Oct 7, 2026
723a4cb
Remove `ORIGIN_CLASS_IS_GENERIC_OVER_NAME` and add `DECLARATION_NAME_…
FrancescoPezzella Oct 7, 2026
f687eaf
Remove 'ORIGIN_MODULE_EXPORTS_NAME' rule. TypeVar can be localized sa…
FrancescoPezzella Oct 7, 2026
3cffbdd
Big with TYPE_CHECKING fixed, test adjusted, text corrected
FrancescoPezzella Oct 7, 2026
434fedb
TypeVarCheck now conservs default= and converts it with Py 3.13+. Whe…
FrancescoPezzella Oct 8, 2026
be58c42
Clean up comments and docstrings
FrancescoPezzella Oct 8, 2026
3c273f3
Clean up tests
FrancescoPezzella Oct 8, 2026
2ed4c99
Docuement and test 3 bugs
FrancescoPezzella Oct 8, 2026
3f3394a
Add xfail to test which was incorrectly passing.
FrancescoPezzella Oct 9, 2026
a78e4d9
Lint errors and fixes for tests
FrancescoPezzella Oct 9, 2026
65051ac
Lint/ruff errors
FrancescoPezzella Oct 9, 2026
519fe85
Bug: Localize all TypeVars imported by one statement in a single edit
FrancescoPezzella Oct 9, 2026
b4cb5ef
Re-anchor a relatively imported TypeVar constructor on the localized …
FrancescoPezzella Oct 9, 2026
152ba33
Add any other form of `__all__ = [...]` as a Non-Goal (no patterns de…
FrancescoPezzella Oct 9, 2026
5a5349e
Replace narrowed_import_text with ast.unparse in TypeVarCheck._locali…
FrancescoPezzella Oct 9, 2026
7fbfc8b
Bring #202 within the lint budget
FrancescoPezzella Oct 12, 2026
127dbf1
TODOs regarding Py 3.15 lazy imports
FrancescoPezzella Oct 12, 2026
512d073
Add the official TNO copyright notice to new files
FrancescoPezzella Oct 16, 2026
91e346f
Update type parameter modernization documentation references
FrancescoPezzella Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/TODO
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@

### Naming

21. **Type parameter modernization: rename the recipe and update its code references** — The page is `user/features/type-parameter-modernization.md`, but the recipe is still `TypeVarCheck` (`type_var_check.py`, `type_var_domain.py`, `test_type_var_*`). `type_var_domain.py` hard-codes the old page URL (`.../typevar-modernization/`) and 9 old anchors (`feature-typevar-modernization-*`, and `...-declared-typevar-exported` is now `...-declared-type-parameter-exported`), and `test_type_var_domain.py` reads the old file path. Rename the recipe to `TypeParameterCheck` and update those references in one change (ADR 15 resolves recipe names with `snake_case()`), then update the docs that name the recipe.
21. **Type parameter modernization: rename the recipe and update its code references** — The page and rule links use `type-parameter-modernization`, but the recipe is still `TypeVarCheck` (`type_var_check.py`, `type_var_domain.py`, `test_type_var_*`). Rename the recipe to `TypeParameterCheck` and update its implementation and tests in one change (ADR 15 resolves recipe names with `snake_case()`), then update the docs that name the recipe.
295 changes: 295 additions & 0 deletions src/rejuvenation/migration-type-recipes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
# Copyright (C) 2024-2026 TNO
#
# Licensed under the terms specified in the LICENSE file in the root directory
# of this repository, also available at:
# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE

"""CLI that modernizes type parameters (TypeVar, ParamSpec, TypeVarTuple) to PEP 695 syntax.

Runs TypeVarCheck on a file or directory, and reports which files were modified and which need
manual review.

Examples:
python src/rejuvenation/migration-type-recipes.py ./some_repo --py 3.12 --report review.md
python src/rejuvenation/migration-type-recipes.py ./some_repo/file.py --py 3.10
python src/rejuvenation/migration-type-recipes.py ./some_repo --py 3.12 --no-ruff

"""

# Printing the report is this CLI's purpose.
# ruff: noqa: T201

import argparse
import subprocess
import sys
import textwrap
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path

from termcolor import colored

from renaissance.project.project_scanner import PythonScanner
from renaissance.recipes.type_var_check import TypeVarCheck
from renaissance.recipes.type_var_domain import UNSAFE_RULES, UnsafeReason, doc_link
from renaissance.utils.import_resolution import collect_project_imported_names

_MAJOR_MINOR_PART_COUNT = 2


@dataclass
class FileReport:
"""Outcome of running TypeVarCheck against a single file."""

path: Path
result: dict[str, dict[str, str]] | None
error: str | None
reasons: dict[str, dict[str, UnsafeReason]] | None = None


def resolve_target_files(target: Path) -> list[Path]:
"""Return the .py files to process for `target`.

A single .py file is returned as-is; a directory is scanned recursively with PythonScanner over
the whole tree (no package_dirs allowlist), so any project layout works.
"""
if target.is_file():
return [target]
return [Path(path) for path in PythonScanner(str(target)).find_sources()]


def _parse_py_version(text: str) -> tuple[int, int]:
"""Parse a "MAJOR.MINOR" string into a (major, minor) tuple for argparse's type=.

Raises:
argparse.ArgumentTypeError: If text is not in MAJOR.MINOR form.

"""
parts = text.split(".")
if len(parts) != _MAJOR_MINOR_PART_COUNT or not all(part.isdigit() for part in parts):
message = f"expected MAJOR.MINOR (e.g. 3.12), got {text!r}"
raise argparse.ArgumentTypeError(message)
return (int(parts[0]), int(parts[1]))


def has_fixed(report: FileReport) -> bool:
"""Return True if any phase of report.result fixed at least one name."""
if report.result is None:
return False
return any("fixed" in phase.values() for phase in report.result.values())


def has_unsafe(report: FileReport) -> bool:
"""Return True if any phase of report.result left at least one name unsafe to touch."""
if report.result is None:
return False
return any("unsafe" in phase.values() for phase in report.result.values())


def is_clean(report: FileReport) -> bool:
"""Return True if report.result found no type parameter usage at all."""
if report.result is None:
return False
return not any(phase for phase in report.result.values())


def process_file(
path: Path,
*,
min_python: tuple[int, int],
project_root: Path,
project_wide_imported_names: frozenset[str],
) -> FileReport:
"""Run TypeVarCheck's three phases on one file and return its FileReport.

Any exception is caught and stored on FileReport.error, so one bad file never aborts a batch run.
"""
try:
recipe = TypeVarCheck(path)
recipe.min_python = min_python
recipe.project_root = project_root
recipe.project_wide_imported_names = project_wide_imported_names
result = recipe.check()
reasons = {
"cross_file": recipe.cross_file_unsafe_reasons,
"converted": recipe.converted_unsafe_reasons,
"orphaned": recipe.orphaned_unsafe_reasons,
}
except Exception as exc: # noqa: BLE001 - isolate one bad file, never abort the whole batch
return FileReport(path=path, result=None, error=f"{type(exc).__name__}: {exc}")
return FileReport(path=path, result=result, error=None, reasons=reasons)


def _run_ruff_unused_import_cleanup(paths: list[Path]) -> None:
"""Run `ruff check --fix --select F401` over every path, dropping any now-unused import.

Best-effort: if ruff can't be invoked (e.g. not installed), prints a warning instead of raising.
The files are already valid without this cleanup.
"""
try:
subprocess.run( # noqa: S603 - fixed argv list (sys.executable + literals + our own discovered paths), no shell
[sys.executable, "-m", "ruff", "check", "--fix", "--select", "F401", *(str(path) for path in paths)],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
print(colored(f"warning: could not run ruff for import cleanup: {exc}", "yellow"))


def _format_commit_summary(reports: list[FileReport]) -> str:
"""Build the short, copy-pasteable commit-message-style summary."""
modified = sum(1 for report in reports if has_fixed(report))
needs_review = sum(1 for report in reports if has_unsafe(report))
clean = sum(1 for report in reports if is_clean(report))
errors = sum(1 for report in reports if report.error is not None)
return (
"Modernize TypeVar/ParamSpec/TypeVarTuple usage to PEP 695 syntax\n\n"
f"{modified} files modified, {needs_review} need manual review, {clean} clean, "
f"{errors} errors (of {len(reports)} processed)"
)


def _format_console_report(reports: list[FileReport], *, ruff_ran: bool) -> str:
"""Build the full per-file report: MODIFIED / NEEDS MANUAL REVIEW / ERRORS sections.

Clean files (no type parameter usage at all) are only counted, not listed, so the report shows
what needs attention. Under MODIFIED, a converted name isn't repeated as orphaned. The ruff
import-cleanup line is only included when ruff_ran is True.
"""
modified = [report for report in reports if has_fixed(report)]
needs_review = [report for report in reports if has_unsafe(report)]
errors = [report for report in reports if report.error is not None]
clean_count = sum(1 for report in reports if is_clean(report))

lines = [
"Renaissance TypeVarCheck migration report",
(
f"Processed {len(reports)} files: {len(modified)} modified, {len(needs_review)} need "
f"manual review, {clean_count} clean, {len(errors)} errors"
),
]
if ruff_ran:
lines.append(
"Unused imports across the modified files above were also cleaned up via `ruff check --fix --select F401`.",
)
lines.extend(["", f"MODIFIED ({len(modified)})"])
for report in modified:
lines.append(f" {report.path}")
result = report.result or {}
converted = {name for name, status in result.get("converted", {}).items() if status == "fixed"}
for phase, names in result.items():
# A converted name's declaration is always removed afterwards; listing it under orphaned too would repeat it.
fixed = [name for name, status in names.items() if status == "fixed" and not (phase == "orphaned" and name in converted)]
if fixed:
lines.append(f" {phase}: {', '.join(fixed)}")

lines.extend(["", f"NEEDS MANUAL REVIEW ({len(needs_review)})"])
for report in needs_review:
lines.append(f" {report.path}")
for phase, names in (report.result or {}).items():
phase_reasons = (report.reasons or {}).get(phase, {})
unsafe = [name for name, status in names.items() if status == "unsafe"]
if unsafe:
lines.append(f" {phase}: {', '.join(unsafe)}")
for name in unsafe:
reason = phase_reasons.get(name)
if reason is not None:
lines.append(f" {name}: {UNSAFE_RULES[reason].message} -> {doc_link(reason)}")

lines.extend(["", f"ERRORS ({len(errors)})"])
lines.extend(f" {report.path}: {report.error}" for report in errors)

return "\n".join(lines)


def build_arg_parser() -> argparse.ArgumentParser:
"""Build the argument parser for this CLI's --help/usage text and flags."""
parser = argparse.ArgumentParser(
prog="migration-type-recipes.py",
description="Modernize legacy TypeVar/ParamSpec/TypeVarTuple usage to PEP 695 syntax.",
epilog=textwrap.dedent("""\
Examples:
python src/rejuvenation/migration-type-recipes.py ./some_repo --py 3.12 --report review.md
python src/rejuvenation/migration-type-recipes.py ./some_repo/file.py --py 3.10
python src/rejuvenation/migration-type-recipes.py ./some_repo --py 3.12 --no-ruff
"""),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("path", type=Path, help="A .py file or a directory to scan.")
parser.add_argument(
"--py",
type=_parse_py_version,
required=True,
metavar="MAJOR.MINOR",
help=(
"Minimum Python version the target project supports (not the one running this tool), e.g. 3.12. "
"PEP 695 rewrites need 3.12+, PEP 696 type parameter defaults 3.13+."
),
)
parser.add_argument("--report", type=Path, metavar="PATH", help="Also write the full report to this file.")
parser.add_argument(
"--no-ruff",
action="store_true",
help="Skip the final `ruff check --fix --select F401` pass that drops imports made unused.",
)
return parser


def main(argv: Sequence[str] | None = None) -> int:
"""Parse arguments, run TypeVarCheck across the target, print/save the report, return an exit code.

Exit codes: 0 on normal completion (files needing manual review are informational, not a
failure), 2 on a usage error (bad path/argument), 3 if any file hit an unhandled exception.
"""
parser = build_arg_parser()
args = parser.parse_args(argv)

target: Path = args.path
if not target.exists():
parser.error(f"path does not exist: {target}")
if target.is_file() and target.suffix != ".py":
parser.error(f"not a Python file: {target}")

# Absolute and normalized, so file paths match the keys collect_project_imported_names returns.
target = target.resolve()
files = resolve_target_files(target)
project_root = target if target.is_dir() else target.parent
# TODO(FrancescoPezzella): computed once upfront, so an origin whose importers all get localized this run is only converted
# on a second run.
# https://github.com/TNO/Renaissance.Py/issues/279
imported_names_by_file = collect_project_imported_names(files, project_root)

reports: list[FileReport] = []
for path in files:
project_wide_imported_names = imported_names_by_file.get(path, frozenset())
report = process_file(
path,
min_python=args.py,
project_root=project_root,
project_wide_imported_names=project_wide_imported_names,
)
reports.append(report)
print(f"File {path} checked.")

modified_paths = [report.path for report in reports if has_fixed(report)]
ruff_ran = bool(modified_paths) and not args.no_ruff
if ruff_ran:
# TODO(FrancescoPezzella): removed statements leave their blank lines behind; ruff's E303 (preview) collapses them, but not
# at file start.
# https://github.com/TNO/Renaissance.Py/issues/279
_run_ruff_unused_import_cleanup(modified_paths)

console_report = _format_console_report(reports, ruff_ran=ruff_ran)
print(console_report)
print()
print(colored(_format_commit_summary(reports), "green", attrs=["bold"]))

if args.report is not None:
args.report.write_text(console_report, encoding="utf-8")

return 3 if any(report.error is not None for report in reports) else 0


if __name__ == "__main__":
raise SystemExit(main())
11 changes: 7 additions & 4 deletions src/renaissance/project/project_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def __init__(self, root_dir: str = ".", package_dirs: list[str] | None = None) -
self.package_dirs = package_dirs

def find_sources(self) -> list[str]:
"""Return every .py file under root_dir (or package_dirs, if given), sorted, excluding EXCLUDED_DIRS."""
"""Return every .py file under root_dir (or package_dirs, if given), sorted, skipping EXCLUDED_DIRS subdirectories."""
path = Path(self.root_dir)
if not path.exists():
message = f"root_dir does not exist: {self.root_dir}"
Expand All @@ -78,13 +78,16 @@ def find_sources(self) -> list[str]:
message = f"root_dir is not a directory: {self.root_dir}"
raise NotADirectoryError(message)

roots = [Path(self.root_dir) / d for d in self.package_dirs] if self.package_dirs else [Path(self.root_dir)]
roots = [path / d for d in self.package_dirs] if self.package_dirs else [path]
files: list[Path] = []
for root in roots:
if not root.exists():
continue
files.extend(path for path in root.rglob("*.py") if not any(part in self.EXCLUDED_DIRS for part in path.parts))
return sorted(str(path) for path in files)
for dirpath, dirnames, filenames in root.walk():
# Pruning in place stops walk() from descending into excluded directories at all.
dirnames[:] = [name for name in dirnames if name not in self.EXCLUDED_DIRS]
files.extend(dirpath / name for name in filenames if (dirpath / name).match("*.py"))
return sorted(str(file) for file in files)


class BearCppScanner(CppScanner):
Expand Down
17 changes: 16 additions & 1 deletion src/renaissance/recipes/python_refactoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

"""AI: Base processor for Python-specific source refactoring recipes."""

import ast
import importlib
from collections.abc import Sequence
from pathlib import Path
Expand Down Expand Up @@ -120,5 +121,19 @@ def body(self) -> Sequence[NodeT]:
return cast("Sequence[NodeT]", [wrappers[statement] for statement in root.translation_unit.atu.body])
return cast("Sequence[NodeT]", root.body)

def find_rst_node(self, target: ast.AST) -> PythonRstNode:
"""Return the PythonRstNode wrapping the raw ast node `target`, e.g. to pass to self.replace()."""
# TODO(FrancescoPezzella): Drop once recipes can navigate wrapper nodes via the unified node protocol?
# decided during 24-09 discussion over future Node Protocol implementation
# https://github.com/TNO/Renaissance.Py/issues/269
found: list[PythonRstNode] = []

def visit(node: PythonRstNode) -> None:
if node.node is target:
found.append(node)

cast("PythonRstNode", cast("object", self.root)).process(visit)
return found[0]

def run(self) -> None:
"""AI: Run this refactoring recipe. Subclasses override this to perform the refactoring."""
"""Perform this recipe's refactoring; a no-op unless a subclass overrides it."""
35 changes: 35 additions & 0 deletions src/renaissance/recipes/step_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright (C) 2024-2026 TNO
#
# Licensed under the terms specified in the LICENSE file in the root directory
# of this repository, also available at:
# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE

"""A small, generic way to sequence independently-committable fix actions across one or more recipes."""

from collections.abc import Callable, Sequence
from dataclasses import dataclass

from renaissance.recipes.python_refactoring import PythonRefactoring


@dataclass(frozen=True)
class Step:
"""One independently-runnable, independently-committable fix action."""

label: str
recipe: PythonRefactoring
action: Callable[[], dict[str, str]]


def run_steps(steps: Sequence[Step]) -> dict[str, dict[str, str]]:
"""Run each step in order, committing its recipe if the step fixed anything.

Returns {step.label: {name: "fixed" | "unsafe"}}, one entry per step, in step order.
"""
result: dict[str, dict[str, str]] = {}
for step in steps:
outcome = step.action()
if "fixed" in outcome.values():
step.recipe.commit()
result[step.label] = outcome
return result
Loading
Loading