Refactoring recipes¶
{ #codemod-recipes }
Stable ID: CODEMOD-RECIPES
Responsibility¶
Recipes are PythonRefactoring subclasses that rewrite one Python file at a time, for gaps ruff does not fully or
safely cover. This page covers TypeParameterCheck, the recipe behind
Type parameter modernization.
Location¶
src/renaissance/recipes/type_parameter_check.py: theTypeParameterCheckpipeline and the edits its phases write.src/renaissance/recipes/type_parameter_rules.py: the reasons a type parameter is left unconverted, and the refusal rules.src/renaissance/recipes/type_parameter_declarations.py: reads and rewrites a declaration (T = TypeVar(...)).src/renaissance/recipes/type_parameter_localization.py: decides which imported type parameters get a local declaration, and the imports and text that replace them.src/renaissance/recipes/type_parameter_uses.py: finds where a declared name is used.src/renaissance/recipes/step_runner.py:Step/run_steps, runs fix actions in order and commits only the ones that fixed something.src/renaissance/recipes/python_refactoring.py: the base class.src/renaissance/utils/import_resolution.py: the import statements of a module and what they bind, the resolution offrom X import Yto a file in the project, the re-anchoring of one file's relative import on another file, and the names each project file imports from the others.src/renaissance/utils/cst_utils.py: a module's top-level statements, the names they read or bind and the comment trailing a statement's line.
Public entry points¶
TypeParameterCheck.check()(andrun()): runs the three phases throughrun_steps, committing to disk between them.-
The phases, each returning
{name: "fixed" | "unsafe"}and recording theUnsafeReasonof each unsafe name:Phase Reasons attribute localize_imported_typevars()cross_file_unsafe_reasonsconvert_declared_typevars()(only adds type parameters)converted_unsafe_reasonsremove_orphaned_declarations()(only removes declarations)orphaned_unsafe_reasons -
type_param_kinds: maps each type parameter name the phases see to"TypeVar","ParamSpec"or"TypeVarTuple". - Attributes set by the CLI (
migration-type-recipes.py):min_python,project_root(falls back to the file's folder),project_wide_imported_names(fromcollect_project_imported_names),ignore_localize_origins(absolute paths of the files--ignorenames) andlocalize_imports(Falsewith--no-localization).ignored_namesholds the type parameter names this file imports from those files, or from any project file whenlocalize_importsisFalse. PythonRefactoring.process("TypeParameterCheck", file), used bycli.py refactor, never sets those attributes, somin_pythonstaysMIN_SUPPORTED_PYTHONthere and adefault=declaration is reported"unsafe".
Internal structure¶
-
libcst throughout. The recipe runs on the CST backend (
PythonRefactoring[PythonCstNode]) and analyses the same libcst tree it rewrites; the cross-file phase parses the origin withlibcst.parse_module. Each edit replaces only the node it changes, so formatting and comments elsewhere are kept. -
Unsafe reasons.
is_safe_to_remove,is_safe_to_localizeandconversion_refusalreturn anUnsafeReasonorNone. Each of the 8 reasons has anUnsafeRule(message + docs anchor) inUNSAFE_RULES. The CLI report prints each message and linksDOCS_BASE_URLonce. -
Refusal rules. Five of the reasons are independent checks, listed as
RefusalRuleentries inCONVERSION_RULES,REMOVAL_RULESandLOCALIZATION_RULES; the first rule of a stage that refuses decides the reason. The other three (USED_IN_GENERIC_CLASS,SHARES_FUNCTION_WITH_UNCONVERTIBLE,NAME_NOT_AT_MODULE_LEVEL) follow from how the phases work and are not listed. Commenting out an entry switches its rule off.NO_PEP695_EQUIVALENT,PEP696_VERSION_GATEandORIGIN_IMPORTS_CONSTRUCTOR_CONDITIONALLYthen let the recipe write code that fails or binds another object;DECLARED_TYPEVAR_EXPORTEDandIMPORTED_ELSEWHERE_IN_PROJECTthen let it break the files that import the name. -
Version gates. The CLI rejects
--pybelowMIN_SUPPORTED_PYTHON = (3, 12), so PEP 695 syntax is always available there; the recipe itself does not check it.conversion_refusalcomparesmin_pythonwithPEP_696_MINIMUM = (3, 13)for adefault=. A keyword outside_CONVERTIBLE_KEYWORDSis refused (NO_PEP695_EQUIVALENT) instead of dropped.covariantandcontravariantare in the set and are dropped, because a function's type parameter has no variance; converting classes would have to refuse them. -
Uses.
uses_oftakes a declaration's uses from libcst's scope analysis (referenced_by). A use inside a string annotation orcast("T", x)counts; aTthat a PEP 695 type parameter of an enclosing function or class declares (def f[T],class C[T]) does not. -
Signature edit.
_add_type_paramsreplaces only the function's name (f→f[T]) or, if it has one, its type-parameter bracket; the body is never touched.type_param_textwrites each parameter (T: int,**P,*Ts,= default) from the declaration's own text. -
One edit per function. All type parameters of a function go into one
replace(), in declaration order, with the defaulted ones last (type_params_bracket). Two edits on one node would be rejected as conflicting rewrites. -
Owning function. A legacy type parameter belongs to the function whose signature uses it.
signature_functionfinds the functions whose parameter or return annotations use the name, andowning_functiongives each use to the outermost of them that encloses it. A nested function whose enclosing function's signature also usesTgets no[T]of its own, since it already sees the outer one. A use outside every such signature, e.g.y: list[T] = []in a function whose signature doesn't useT, is given to no function. -
Generic classes. Methods of a class with the name in a base (
generic_classes) are skipped. A generic class at the origin does not block localization. -
No mixed syntax. A function using a declaration that
conversion_refusalrejects gets none of its type parameters, since PEP 695 forbids mixing them with legacy ones. A name whose every function is blocked isSHARES_FUNCTION_WITH_UNCONVERTIBLE. This needs two passes: all refusals are known before any parameter is added. -
Orphans. A declaration with no uses left (
uses_ofis empty) is orphaned. Conversion skips such names too. -
Localization. The origin's declaration is copied as written, with the comment trailing it when it is alone on its line (
declaration_text).NAME_NOT_AT_MODULE_LEVELif the origin only binds a name its arguments use (declaration_argument_names) underif TYPE_CHECKING:or this file binds it to something else (from_import_sources). All names localized from one import statement are handled together.
A name the origin imports, the constructor or one an argument uses, is imported here from the module the origin
gets it from, with its alias (from_import_binding). A relative import of the origin is re-anchored on this file
by rebase_relative_module. A name the origin defines itself, or one whose module this file can't name, is
imported from the origin.
-
Imports. An import whose names are now declared locally is narrowed, keeping its comments, or dropped with its trailing comment when no name remains. The needed imports, each once per file, and then the declarations go after the file's last import, so no import is left below code (E402). A localization is written where its import was when that import shares its line with other statements and is dropped, when no import of the file remains, or when a statement between it and the last import reads one of its names. Merging and sorting the added imports, and removing imports that became unused, is left to the CLI's
ruff check --fix --select F401,I001pass. -
Line endings. Files are read with universal newlines and a modified file is written back with LF, also on the lines the recipe did not change. A file the recipe does not modify is not rewritten.
Related features¶
Related concepts¶
Validated by test modules¶
See Verified by test modules in Type parameter modernization.
Extension points¶
- A new recipe is a
PythonRefactoringsubclass in its ownsnake_case-named module insrc/renaissance/recipes/; no registration is needed. - A new type-parameter-declaring construct: extend
_is_type_param_callandtype_param_texttogether, both intype_parameter_declarations.py. - A keyword added by a later Python, or future PEP 695 syntax for variance or a bound on
**P/*Ts: add it to_CONVERTIBLE_KEYWORDSand carry it over intype_param_text. - A new refusal rule: add a check function and a
RefusalRuleentry to the stage's tuple intype_parameter_rules.py, a message and anchor toUNSAFE_RULES, and a section to the feature page. - A new version gate uses the syntax's own minimum Python from its PEP, not a value copied from another gate.
- Re-exports through
__init__.py, and a namespace package (PEP 420) as an import's origin: extendresolve_project_module. Step/run_stepsis generic and can be reused by any recipe.
Non-goals¶
-
Make the tool more efficient. Currently the tool does not make use of any filtering or similar features, making it exponentially slower than other tools (Ruff can run on milliseconds on big projects, while it may take upwards of 30 minutes per project with this tool). This is a limitation of it, and a low priority fix due to time constraints.
-
The target's minimum Python is not detected (e.g. from
requires-python); it must be passed with--py. A project with:[project] requires-python = ">=3.13"run with
--py 3.12is treated as below 3.13, so adefault=declaration is not converted. -
Only the file an import names is read. A name it re-exports from another module (
pkg/__init__.pywithfrom .mod import T) is not followed, so the result depends on whether that file was already localized earlier in the run. Renamed imports andimport *are skipped, and so is a namespace package (a directory without__init__.py) as the origin; the modules inside it are followed. In:# origin.py from typing import TypeVar T = TypeVar("T") # main.py from origin import T as U def first(items: list[U]) -> U: ...main.pyis left unchanged. -
A file that imports a declaration with
from origin import *does not count as an importer, so the declaration is removed from its origin once nothing else in the origin uses it. In:# origin.py T = TypeVar("T") def identity(x: T) -> T: ... # main.py from origin import *T = TypeVar("T")is removed fromorigin.py, andmain.pyraisesNameErrorwhere it usesT. The run exits with 0 and gives no warning. -
Only declarations in the file itself can block a function. A legacy type parameter that stays imported, such as one from another package, does not. In:
from other_package import K V = TypeVar("V") def get(mapping: dict[K, V], key: K) -> V: ...getbecomesdef get[V](mapping: dict[K, V], key: K) -> V: ....Kis still the imported legacy type parameter, sogetmixes both syntaxes, which PEP 695 forbids. Type checkers report an error. -
Imported names are collected once before the run, so removing a declaration whose importers were all localized needs a second run, which
--second-passdoes for the files concerned. Withorigin.pyas above and:# main.py from origin import T def first(items: list[T]) -> T: ...the first run turns
main.pyintodef first[T](items: list[T]) -> T: ..., but keepsTinorigin.py, sincemain.pyimported it when the run started. A second run removes it. -
__all__.extend(...)and__all__.append(...)are not read to detect an exported declaration. In:__all__ = ["first"] __all__.extend(["T"]) T = TypeVar("T") def first(items: list[T]) -> T: ...firstis converted andT = TypeVar("T")is removed, although__all__exports it. -
A declaration is only treated as public API when
__all__lists it, since the tool sees nothing outside the scanned project. One that nothing in the project uses is removed, even if other projects import it.--ignoreleaves such a file untouched. In:# mylib/typing.py from typing import TypeVar KeyT = TypeVar("KeyT", bytes, str)KeyT = TypeVar("KeyT", bytes, str)is removed, sofrom mylib.typing import KeyTfails elsewhere. With__all__ = ["KeyT"]it is kept. -
A name used only in the string value of a
TypeAliasis not seen as a use, so its declaration can be removed as orphaned. In:T = TypeVar("T") Pairs: TypeAlias = "list[tuple[T, T]]"T = TypeVar("T")is removed, andPairsrefers to an undefinedT. -
Unpack[Ts]→*Tsis left toruff'sUP044, see Rejected recipes. In:Ts = TypeVarTuple("Ts") def pack(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]: ...only
[*Ts]is added:def pack[*Ts](*args: Unpack[Ts]) -> tuple[Unpack[Ts]]: .... -
An import that follows another statement on the same line is written back wrongly. This is rare, and
ruff'sE702flags such lines. In:import os; from origin import Tthe lines that replace the import get an extra indent, a syntax error.
-
Without the CLI's
ruffpass (--no-ruff, or using the recipe as a library) the added imports are not merged into the file's existingfromimports and not sorted. They are still placed with the other imports. -
A declaration still read by a decorator or a parameter default of a converted function can be removed. This is rare. In:
T = TypeVar("T") def first(items: list[T], default=T) -> T: ...firstbecomesdef first[T](items: list[T], default=T) -> T: .... The annotations now use the new type parameter, butdefault=Tis evaluated outside it and still reads the module-levelT. libcst places it inside the type parameter's scope, so the declaration looks unused and is removed: importing the module raisesNameError. -
A declaration followed by
;on the same line is removed without its;. This is rare, andruff'sE702flags such lines. In:T = TypeVar("T"); U = 1 def first(items: list[T]) -> T: ...the file is written as
; U = 1, a syntax error, and reported under errors. -
Declarations that break the naming rule of PEP 484 (a single variable named after the string argument) are not handled. This is rare, and
ruff'sPLC0132flags a mismatched name:T = U = TypeVar("T") # if T is unused, the statement is removed and U is no longer defined V = TypeVar("W") # def f(x: V) -> V becomes def f[W](x: V) -> V X = TypeVar(name) # AttributeError: no phase is applied to the fileIf both
TandUare unused, the run fails with aValueError("Conflicting rewrites") reported under errors, and the declaration stays. -
A declaration whose constructor comes from
from typing import *is localized without importing it. This is rare, andruff'sF403flags the star import. In:# origin.py from typing import * T = TypeVar("T") # main.py from origin import T class Pair(dict[str, T]): ...main.pygetsT = TypeVar("T")without an import ofTypeVar, so it raisesNameError. -
A
default=that uses another declaration is carried over as is. This is rare. With--py 3.13:T = TypeVar("T") U = TypeVar("U", default=T) def first(items: list[U]) -> U: ...becomes
def first[U = T](items: list[U]) -> U: ..., whose default still refers to the module-levelT. -
A constructor written with its module, such as
typing.TypeVar, is not recognised. This is rare. In:T = typing.TypeVar("T") def first(items: list[T]) -> T: ...firstis left unchanged and the file is reported clean. -
Absolute imports are resolved from the given path, so in a
src/layout run on the repository root,from pkg.typing import Tdoes not findsrc/pkg/typing.py. This is rare. In:# src/pkg/typing.py T = TypeVar("T") # src/pkg/other.py from pkg.typing import T def first(items: list[T]) -> T: ...T = TypeVar("T")is removed andother.pyfails withImportError.
Need to fix¶
Errors that should be fixed, not accepted.
-
The comment lines above a localized declaration are not copied. Only the comment trailing its line is, because the rewriter handles a comment as one line (issue 189). In:
# origin.py from typing import TypeVar # Placeholder until the array API types are narrower. T = TypeVar("T") # noqa: PLC0105 # main.py from origin import Tmain.pygetsT = TypeVar("T") # noqa: PLC0105without the comment above it. The origin keeps its comment when it drops the declaration. -
An existing type-parameter bracket on a converted function is rebuilt on one line, so its comments and line breaks are lost (issue 279). In:
T = TypeVar("T") def f[ A, # first B, ](x: A, y: B, z: T) -> T: ...fbecomesdef f[A, B, T](x: A, y: B, z: T) -> T: .... -
A localized declaration whose constructor is already imported from another module, such as
TypeVarfromtypingin this file and fromtyping_extensionsin the origin, adds a second import of the name that shadows the first (issue 279). -
A file that starts with a UTF-8 byte order mark is rewritten with invalid syntax and reported under
ERRORS: a converteddef identity(x: T) -> T:becomesdef ideidentity[T] T) -> T:. The file should keep its encoding and its byte order mark (issue 153). -
A file that is not valid UTF-8 stops the whole run with an uncaught
UnicodeDecodeError, before any file is changed. The file should be reported underERRORSand the others processed (issue 188).