refine 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
refine/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+
5
+ try:
6
+ from refine._version import __version__
7
+ except ImportError: # pragma: no cover
8
+ __version__ = "0.0.0.not-installed"
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version
11
+
12
+ with contextlib.suppress(PackageNotFoundError):
13
+ __version__ = version("re-code")
refine/__main__.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Utility to simplify rewriting python code.
3
+
4
+ It can be used for one-off rewrites, or, to maintain code styling rules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import pathlib
12
+ import sys
13
+ from multiprocessing import freeze_support
14
+ from typing import NoReturn
15
+
16
+ from refine import __version__
17
+ from refine.config import Config
18
+ from refine.exc import ReCodeSystemExit
19
+ from refine.processor import Processor
20
+ from refine.registry import Registry
21
+
22
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(message)s")
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def main() -> NoReturn: # noqa: PLR0915
28
+ parser = argparse.ArgumentParser(description=__doc__)
29
+ parser.add_argument("--version", action="version", version=__version__)
30
+ parser.add_argument("files", metavar="FILE", nargs="*", type=pathlib.Path, help="One or more files to process.")
31
+ parser.add_argument(
32
+ "--config",
33
+ type=pathlib.Path,
34
+ help="Path to config file. Defaults to '.refine.ini' on the current directory.",
35
+ default=".refine.toml",
36
+ )
37
+ parser.add_argument("--quiet", "-q", action="store_true", default=False, help="Quiet down the tool output.")
38
+ parser.add_argument(
39
+ "--fail-fast",
40
+ "--ff",
41
+ action="store_true",
42
+ default=False,
43
+ help="Exit as soon as possible on the first processing error",
44
+ )
45
+ parser.add_argument(
46
+ "--list-codemods",
47
+ "--list",
48
+ action="store_true",
49
+ help="List all available codemods",
50
+ )
51
+ parser.add_argument(
52
+ "--exclude-codemod",
53
+ "--exclude",
54
+ default=[],
55
+ action="append",
56
+ help="Exclude codemods from available codemods.",
57
+ )
58
+ parser.add_argument(
59
+ "--select-codemod",
60
+ "--select",
61
+ default=[],
62
+ action="append",
63
+ help="Explicitly select codemod names from available codemods.",
64
+ )
65
+ parser.add_argument(
66
+ "--codemods-path",
67
+ type=pathlib.Path,
68
+ action="append",
69
+ default=[],
70
+ dest="codemods_paths",
71
+ help="Path to a codemods directory. Can be passed multiple times.",
72
+ )
73
+ args = parser.parse_args()
74
+ if args.quiet:
75
+ logging.getLogger().setLevel(logging.ERROR)
76
+ if not args.config.is_absolute():
77
+ config_file = pathlib.Path.cwd().joinpath(args.config).resolve()
78
+ else:
79
+ config_file = args.config
80
+ if config_file.exists():
81
+ if config_file.name == "pyproject.toml":
82
+ config = Config.from_pyproject_file(config_file)
83
+ else:
84
+ config = Config.from_default_file(config_file)
85
+ elif pathlib.Path.cwd().joinpath("pyproject.toml").exists():
86
+ config = Config.from_pyproject_file(pathlib.Path.cwd().joinpath("pyproject.toml"))
87
+ else:
88
+ config = Config()
89
+
90
+ if args.list_codemods:
91
+ # Add the additional CLI passed codemod paths
92
+ config.codemod_paths[:] = list(set(config.codemod_paths) | set(args.codemods_paths))
93
+
94
+ if args.select_codemod:
95
+ # Add any additional CLI passed selections
96
+ config.select[:] = list(set(config.select) | set(args.select_codemod))
97
+
98
+ if args.exclude_codemod:
99
+ # Add any additional CLI passed exclusions
100
+ config.exclude[:] = list(set(config.exclude) | set(args.exclude_codemod))
101
+
102
+ if args.fail_fast:
103
+ config = config.model_copy(update={"fail_fast": True}, deep=True)
104
+
105
+ registry = Registry()
106
+ registry.load(config.codemod_paths)
107
+ if args.list_codemods:
108
+ log.info("Available codemods:")
109
+ for codemod in registry.codemods():
110
+ # In case the description is comming from the docstring, we just really want the first line.
111
+ description = codemod.DESCRIPTION.strip().splitlines()[0]
112
+ log.info(" - %s: %s", codemod.NAME, description.strip())
113
+ parser.exit()
114
+
115
+ paths = args.files
116
+ if not paths:
117
+ paths = config.repo_root.glob("**/*.py")
118
+
119
+ files: list[pathlib.Path] = []
120
+ for path in paths:
121
+ if path.is_file():
122
+ files.append(path)
123
+ continue
124
+ for subpath in path.rglob("*.py"):
125
+ files.append(subpath)
126
+
127
+ codemods = list(registry.codemods(select_codemods=config.select, exclude_codemods=config.exclude))
128
+ log.info("Selected codemods:")
129
+ for codemod in codemods:
130
+ log.info(" - %s: %s", codemod.NAME, codemod.DESCRIPTION)
131
+
132
+ processor = Processor(config=config, registry=registry, codemods=codemods)
133
+ try:
134
+ result = processor.process(files)
135
+ if result.failures:
136
+ parser.exit(status=1)
137
+ except ReCodeSystemExit as exc:
138
+ parser.exit(status=exc.code, message=exc.message)
139
+ except SystemExit as exc:
140
+ code: str | int | None = exc.code
141
+ if code is None:
142
+ parser.exit(status=1)
143
+ if isinstance(code, int):
144
+ parser.exit(status=code)
145
+ parser.exit(status=1, message=code)
146
+ except KeyboardInterrupt:
147
+ parser.exit(status=1)
148
+ parser.exit(status=0)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ freeze_support()
153
+ main()
refine/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ # Version attribute defined and written at build time
2
+
3
+ __version__ = "0.10.0"
refine/_version.pyi ADDED
@@ -0,0 +1 @@
1
+ __version__: str
refine/abc.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ Abstract base classes for defining codemod types and their configurations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC
8
+ from typing import TYPE_CHECKING
9
+ from typing import ClassVar
10
+ from typing import Generic
11
+ from typing import TypeVar
12
+
13
+ from libcst.codemod import CodemodContext
14
+ from libcst.codemod import VisitorBasedCodemodCommand
15
+ from pydantic import BaseModel
16
+ from pydantic import ConfigDict
17
+
18
+
19
+ class BaseConfig(BaseModel):
20
+ """
21
+ Base configuration class for codemoders.
22
+ """
23
+
24
+ model_config = ConfigDict(frozen=True)
25
+
26
+
27
+ CodemodConfigType = TypeVar("CodemodConfigType", bound=BaseConfig)
28
+
29
+
30
+ class BaseCodemod(VisitorBasedCodemodCommand, ABC, Generic[CodemodConfigType]):
31
+ """
32
+ Base class for codemoders.
33
+ """
34
+
35
+ NAME: ClassVar[str]
36
+ CONFIG_CLS: ClassVar[type[BaseConfig]]
37
+ PRIORITY: ClassVar[int] = 0
38
+
39
+ def __init__(self, context: CodemodContext, config: CodemodConfigType):
40
+ super().__init__(context)
41
+ self.config = config
42
+ self.__post_codemod_init__()
43
+
44
+ def __post_codemod_init__(self) -> None:
45
+ """
46
+ This method can implement additional codemod initialization.
47
+ """
48
+
49
+ @classmethod
50
+ def get_short_description(cls) -> str:
51
+ """
52
+ Return a short description of the codemod.
53
+
54
+ This short description is used in the CLI to list available codemods and should be a single line.
55
+
56
+ By default, it returns the first line of the class docstring, override this method to provide a
57
+ custom description.
58
+ """
59
+ doc = cls.__doc__
60
+ if cls is None:
61
+ error_msg = f"Codemod {cls.__name__} must have a docstring to be used in the CLI."
62
+ raise TypeError(error_msg)
63
+ if TYPE_CHECKING:
64
+ assert doc is not None
65
+ return doc.strip().splitlines()[0].strip()
66
+
67
+
68
+ CodemodType = TypeVar("CodemodType", bound=BaseCodemod)
refine/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """
2
+ re:Code configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import tomllib
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel
14
+ from pydantic import ConfigDict
15
+ from pydantic import Field
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ def _cpu_count() -> int:
21
+ # os.cpu_count() can return None, let's not.
22
+ return os.cpu_count() or 1
23
+
24
+
25
+ class ConfigError(ValueError):
26
+ """
27
+ Config related error.
28
+ """
29
+
30
+
31
+ class ConfigLoadError(ConfigError):
32
+ """
33
+ Exception raised when failing to load the configuration from file.
34
+ """
35
+
36
+
37
+ class InvalidConfigError(ConfigError):
38
+ """
39
+ Exception raised when the loaded configuration is not valid.
40
+ """
41
+
42
+
43
+ class Config(BaseModel):
44
+ """
45
+ Main codemod configuration schema.
46
+ """
47
+
48
+ model_config = ConfigDict(
49
+ # Don't allow the config object to be mutable.
50
+ # Do note that mutable objects(set, list, dict) of the immutable config class remain mutable.
51
+ frozen=True,
52
+ # Allow extra keys, these will be codemods configs
53
+ extra="allow",
54
+ )
55
+
56
+ select: list[str] = Field(
57
+ default_factory=list,
58
+ description="""
59
+ List of codemods to run.
60
+
61
+ When no selection is made, all available codemods are run.
62
+ """,
63
+ )
64
+ exclude: list[str] = Field(
65
+ default_factory=list,
66
+ description="""
67
+ List of codemods to exclude.
68
+
69
+ Only makes sense when `select` is empty and all codemods are run.
70
+ """,
71
+ )
72
+ codemod_paths: list[Path] = Field(
73
+ default_factory=list,
74
+ description="""
75
+ List of additional paths to search for codemods.
76
+ """,
77
+ )
78
+ process_pool_size: int = Field(
79
+ default_factory=_cpu_count,
80
+ description="""
81
+ Number of processes to use for parallel processing.
82
+ Defaults to the number of available CPUs.
83
+ """,
84
+ )
85
+ repo_root: Path = Field(
86
+ default_factory=Path.cwd,
87
+ description="""
88
+ The root directory of the repository.
89
+ Defaults to the current working directory.
90
+ """,
91
+ )
92
+ fail_fast: bool = Field(
93
+ default=False,
94
+ description="""
95
+ Stop processing as soon as possible after the first error.
96
+ """,
97
+ )
98
+
99
+ @classmethod
100
+ def from_dict(cls, data: dict[str, Any]) -> Config:
101
+ """
102
+ Load the configuration from a dictionary.
103
+
104
+ Arguments:
105
+ data: The configuration to load.
106
+
107
+ Returns:
108
+ Config instance.
109
+ """
110
+ try:
111
+ return Config(**data)
112
+ except ValueError as exc:
113
+ error = f"Invalid configuration: {exc}"
114
+ raise InvalidConfigError(error) from exc
115
+
116
+ @classmethod
117
+ def from_default_file(cls, path: Path) -> Config:
118
+ """
119
+ Load the configuration from a file.
120
+
121
+ Arguments:
122
+ path: The path to the configuration file.
123
+
124
+ Returns:
125
+ Config instance.
126
+ """
127
+ try:
128
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
129
+ except tomllib.TOMLDecodeError as exc:
130
+ error = f"Unable to parse {path}: {exc}"
131
+ raise ConfigLoadError(error) from exc
132
+ else:
133
+ return cls.from_dict(data)
134
+
135
+ @classmethod
136
+ def from_pyproject_file(cls, path: Path) -> Config:
137
+ """
138
+ Load the configuration from a file.
139
+
140
+ Arguments:
141
+ path: The path to the configuration file.
142
+
143
+ Returns:
144
+ Config instance.
145
+ """
146
+ try:
147
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
148
+ except tomllib.TOMLDecodeError as exc:
149
+ error = f"Unable to parse {path}: {exc}"
150
+ raise ConfigLoadError(error) from exc
151
+ else:
152
+ return cls.from_dict(data.get("tool", {}).get("refine", {}))
refine/exc.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ re:Code related exceptions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+
10
+ class ReCodeError(Exception):
11
+ """
12
+ re:Code specific exception.
13
+ """
14
+
15
+
16
+ class ReCodeSystemExit(SystemExit):
17
+ """
18
+ re:Code system exit exception that accepts a message argument.
19
+ """
20
+
21
+ code: int
22
+ message: str | None
23
+
24
+ def __init__(self, code: int, message: str | None = None):
25
+ if TYPE_CHECKING:
26
+ assert code is not None
27
+ assert isinstance(code, int)
28
+ super().__init__(code)
29
+ self.message = message
File without changes
File without changes
@@ -0,0 +1,142 @@
1
+ """
2
+ This codemod enforces the use of dashes over underscores in CLI arguments of [ArgumentParser][argparse.ArgumentParser].
3
+
4
+ For example, it will transform this code:
5
+ ```python
6
+ parser.add_argument("--a_command")
7
+ ```
8
+ into this code:
9
+ ```python
10
+ parser.add_argument("--a-command")
11
+ ```
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import pathlib
18
+ from ast import literal_eval
19
+ from typing import TYPE_CHECKING
20
+ from typing import cast
21
+
22
+ import libcst as cst
23
+ import libcst.matchers as m
24
+ from libcst.codemod import SkipFile
25
+
26
+ from refine.abc import BaseCodemod
27
+ from refine.abc import BaseConfig
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ class CliDashesConfig(BaseConfig):
33
+ django_base_command_package: str = "django.core.management"
34
+ django_base_command_class_name: str = "BaseCommand"
35
+
36
+
37
+ class CliDashes(BaseCodemod):
38
+ """
39
+ Replace `_` with `-`, ie, `--a-command` instead of `--a_command` in CLI commands.
40
+
41
+ This works if the parser argument is typed and only for ArgumentParser. `def foo(parser: ArgumentParser)`.
42
+ """
43
+
44
+ NAME = "cli-dashes-over-underscores"
45
+ CONFIG_CLS = CliDashesConfig
46
+
47
+ def __post_codemod_init__(self) -> None:
48
+ """
49
+ Additional class setup.
50
+ """
51
+ self.typed_parameters: dict[str, str] = {}
52
+ self.typed_assignments: dict[str, str] = {}
53
+
54
+ def visit_Module(self, mod: cst.Module) -> bool:
55
+ filename: str | None = self.context.filename
56
+ if TYPE_CHECKING:
57
+ assert filename is not None
58
+ if pathlib.Path(filename).name.startswith("test_"):
59
+ skip_reason = "Not touching test files"
60
+ raise SkipFile(skip_reason)
61
+ return True
62
+
63
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> None:
64
+ # Collect type annotations of parameters
65
+ self.typed_parameters = {}
66
+ for param in node.params.params:
67
+ if param.annotation:
68
+ param_name = param.name.value
69
+ annotation = param.annotation.annotation
70
+ if isinstance(annotation, cst.Name) and annotation.value == "ArgumentParser":
71
+ self.typed_parameters[param_name] = annotation.value
72
+
73
+ def leave_FunctionDef(self, original: cst.FunctionDef, updated: cst.FunctionDef) -> cst.FunctionDef:
74
+ # Clear state when leaving the function
75
+ self.typed_parameters = {}
76
+ self.typed_assignments = {}
77
+ return updated
78
+
79
+ def visit_AnnAssign(self, node: cst.AnnAssign) -> None:
80
+ if isinstance(node.target, cst.Name):
81
+ var_name = node.target.value
82
+ annotation = node.annotation.annotation
83
+ if isinstance(annotation, cst.Name) and annotation.value == "ArgumentParser":
84
+ self.typed_assignments[var_name] = annotation.value
85
+
86
+ def visit_Assign(self, node: cst.Assign) -> None:
87
+ # NOTE: we're not taking into account import aliases
88
+ assign_target: cst.AssignTarget
89
+ for assign_target in node.targets:
90
+ if not isinstance(assign_target.target, cst.Name):
91
+ # We only want to handle simple assignments:
92
+ # var_a = value_a
93
+ # We're not handling assignment expansion, ie
94
+ # var_a, var_b = value_a, value_b
95
+ continue
96
+ target: cst.Name = cast("cst.Name", assign_target.target)
97
+ var_name = target.value
98
+ if m.matches(
99
+ node.value,
100
+ m.OneOf(
101
+ # blah = ArgumentParser
102
+ m.Call(func=m.Name("ArgumentParser")),
103
+ # blah = argparse.ArgumentParser
104
+ m.Call(func=m.Attribute(value=m.Name("argparse"), attr=m.Name("ArgumentParser"))),
105
+ ),
106
+ ):
107
+ self.typed_assignments[var_name] = "ArgumentParser"
108
+
109
+ def leave_Call(self, original: cst.Call, updated: cst.Call) -> cst.Call:
110
+ # Check for `<something>.add_argument(...)` calls
111
+ if m.matches(
112
+ original.func,
113
+ m.Attribute(attr=m.Name("add_argument"), value=m.Name()),
114
+ ):
115
+ # The `<something>` in `<something>.add_argument`
116
+ caller_func_attribute = cast("cst.Attribute", original.func)
117
+ caller_func_attribute_name = cast("cst.Name", caller_func_attribute.value)
118
+ caller_name = caller_func_attribute_name.value
119
+
120
+ # Verify if the caller object is typed as ArgumentParser
121
+ if caller_name not in self.typed_parameters and caller_name not in self.typed_assignments:
122
+ return updated
123
+
124
+ args: list[cst.Arg] = []
125
+ for arg in updated.args:
126
+ if not isinstance(arg.value, cst.SimpleString):
127
+ args.append(arg)
128
+ continue
129
+ simple_string = arg.value
130
+ flag = literal_eval(simple_string.value)
131
+ if not flag.startswith("--"):
132
+ args.append(arg)
133
+ continue
134
+ if "_" not in flag:
135
+ args.append(arg)
136
+ continue
137
+ updated_flag_value = f"{simple_string.quote}{flag.replace('_', '-')}{simple_string.quote}"
138
+ updated_simple_string = simple_string.with_changes(value=updated_flag_value)
139
+ updated_arg = arg.with_changes(value=updated_simple_string)
140
+ args.append(updated_arg)
141
+ return updated.with_changes(args=args)
142
+ return updated
@@ -0,0 +1,45 @@
1
+ [sqlfluff]
2
+ dialect = mysql
3
+ templater = placeholder
4
+ max_line_length = 120
5
+ processes = -1
6
+
7
+ [sqlfluff:templater:placeholder]
8
+ param_style = pyformat
9
+
10
+ [sqlfluff:indentation]
11
+ allow_implicit_indents = True
12
+ indented_joins = True
13
+ tab_space_size = 4
14
+ indent_unit = space
15
+
16
+ [sqlfluff:rules:aliasing.length]
17
+ min_alias_length = 3
18
+
19
+ # The default configuration for capitalisation rules is "consistent"
20
+ # which will auto-detect the setting from the rest of the file. This
21
+ # is less desirable in a new project and you may find this (slightly
22
+ # more strict) setting more useful.
23
+ # Typically we find users rely on syntax highlighting rather than
24
+ # capitalisation to distinguish between keywords and identifiers.
25
+ # Clearly, if your organisation has already settled on uppercase
26
+ # formatting for any of these syntax elements then set them to "upper".
27
+ # See https://stackoverflow.com/questions/608196/why-should-i-capitalize-my-sql-keywords-is-there-a-good-reason
28
+ [sqlfluff:rules:capitalisation.keywords]
29
+ capitalisation_policy = lower
30
+ [sqlfluff:rules:capitalisation.identifiers]
31
+ extended_capitalisation_policy = lower
32
+ [sqlfluff:rules:capitalisation.functions]
33
+ extended_capitalisation_policy = lower
34
+ [sqlfluff:rules:capitalisation.literals]
35
+ capitalisation_policy = lower
36
+ [sqlfluff:rules:capitalisation.types]
37
+ extended_capitalisation_policy = lower
38
+
39
+ # The default configuration for the not equal convention rule is "consistent"
40
+ # which will auto-detect the setting from the rest of the file. This
41
+ # is less desirable in a new project and you may find this (slightly
42
+ # more strict) setting more useful.
43
+ [sqlfluff:rules:convention.not_equal]
44
+ # Default to preferring the "c_style" (i.e. `!=`)
45
+ preferred_not_equal_style = c_style
File without changes