cstvis 0.0.1__tar.gz

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.
cstvis-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 pomponchik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cstvis-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.1
2
+ Name: cstvis
3
+ Version: 0.0.1
4
+ Summary: Incremental change of CST
5
+ Author-email: Evgeniy Blinov <zheni-b@yandex.ru>
6
+ Project-URL: Source, https://github.com/pomponchik/cstvis
7
+ Project-URL: Tracker, https://github.com/pomponchik/cstvis/issues
8
+ Keywords: CST,visitor
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Operating System :: MacOS :: MacOS X
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: Free Threading
23
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
24
+ Classifier: License :: OSI Approved :: MIT License
25
+ Classifier: Intended Audience :: Developers
26
+ Classifier: Topic :: Software Development :: Libraries
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+
31
+ <details>
32
+ <summary>ⓘ</summary>
33
+
34
+ [![Downloads](https://static.pepy.tech/badge/cstvis/month)](https://pepy.tech/project/cstvis)
35
+ [![Downloads](https://static.pepy.tech/badge/cstvis)](https://pepy.tech/project/cstvis)
36
+ [![Coverage Status](https://coveralls.io/repos/github/pomponchik/cstvis/badge.svg?branch=main)](https://coveralls.io/github/pomponchik/cstvis?branch=main)
37
+ [![Lines of code](https://sloc.xyz/github/pomponchik/cstvis/?category=code)](https://github.com/boyter/scc/)
38
+ [![Hits-of-Code](https://hitsofcode.com/github/pomponchik/cstvis?branch=main&label=Hits-of-Code&exclude=docs/)](https://hitsofcode.com/github/pomponchik/cstvis/view?branch=main)
39
+ [![Test-Package](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml/badge.svg)](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/cstvis.svg)](https://pypi.python.org/pypi/cstvis)
41
+ [![PyPI version](https://badge.fury.io/py/cstvis.svg)](https://badge.fury.io/py/cstvis)
42
+ [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
43
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
44
+ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/pomponchik/cstvis)
45
+
46
+ </details>
47
+
48
+ ![logo](https://raw.githubusercontent.com/pomponchik/cstvis/develop/docs/assets/logo_1.svg)
49
+
50
+ A large number of source code tools (linters, formatters, and others) work with [CST](https://en.wikipedia.org/wiki/Parse_tree), a special representation of the source code that already has a tree shape (like [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree)), but still contains "extra" nodes such as spaces or comments. This library is a wrapper around such a tree, designed for convenient and iterative work with nodes: traversal and replacement.
51
+
52
+
53
+ ## Table of contents
54
+
55
+ - [**Installation**](#installation)
56
+ - [**Usage**](#usage)
57
+
58
+
59
+ ## Installation
60
+
61
+ Install it:
62
+
63
+ ```bash
64
+ pip install cstvis
65
+ ```
66
+
67
+ You can also quickly try out this and other packages without having to install using [instld](https://github.com/pomponchik/instld).
68
+
69
+
70
+ ## Usage
71
+
72
+ This library is a wrapper around the [`libcst`](https://pypi.org/project/libcst/) library.
73
+
74
+ The flow of work is very simple:
75
+
76
+ - Create an object of the `Changer` class.
77
+ - Register converter functions that will convert some `CST` nodes to others, using the decorator. Each such function takes a node object as the first argument, and it must be accompanied by a type annotation. It is based on the annotation that the system will understand which nodes it needs to be applied to and which ones it does not.
78
+ - If necessary, also register filters, which are special functions that can prevent the system from changing certain nodes.
79
+ - Iterate over atomic changes and apply them if necessary.
80
+
81
+ Let me show you a simple example:
82
+
83
+ ```python
84
+ from libcst import Subtract, Add
85
+ from cstvis import Changer, Context
86
+ from pathlib import Path
87
+
88
+ # Content of the file:
89
+ # a = 4 + 5
90
+ # b = 15 - a
91
+ # c = b + a # kek
92
+ changer = Changer(Path('tests/some_code/simple_sum.py').read_text())
93
+
94
+ @changer.converter
95
+ def change_add(node: Add, context: Context):
96
+ return Subtract(
97
+ whitespace_before=node.whitespace_before,
98
+ whitespace_after=node.whitespace_after,
99
+ )
100
+
101
+ for x in changer.iterate_coordinates():
102
+ print(x)
103
+ print(changer.apply_coordinate(x))
104
+
105
+ #> Coordinate(file=None, class_name='Add', start_line=1, start_column=6, end_line=1, end_column=7)
106
+ #> a = 4 - 5
107
+ #> b = 15 - a
108
+ #> c = b + a # kek
109
+ #>
110
+ #> Coordinate(file=None, class_name='Add', start_line=3, start_column=6, end_line=3, end_column=7)
111
+ #> a = 4 + 5
112
+ #> b = 15 - a
113
+ #> c = b - a # kek
114
+ ```
115
+
116
+ The key part of this example is the last two lines where the coordinates are iterated. What does it mean? The fact is that any change to the code that this library makes occurs in 2 stages: outline the coordinates of the change and make the change. Due to this separation, it becomes possible, for example, to divide this work between several threads or even several computers. However, this scheme also limits us. If you apply one coordinate change, the resulting code will differ from the original one and subsequent coordinates will no longer be possible to apply. You can only apply one change at a time.
117
+
118
+ A filter is a special function with the same signature as a converter, which we mark with the `@filter` decorator. This should decide whether to change a specific `CST` node or not, and return `True` if yes, or `False` if no. The filter is applied to all nodes if the node parameter does not have a type annotation, or if [Any](https://docs.python.org/3/library/typing.html#typing.Any) / [CSTNode](https://libcst.readthedocs.io/en/latest/nodes.html#libcst.CSTNode) annotation is used. If you specify a specific type of node in the annotation, the filter will be applied only to them. Any other annotations are not allowed.
119
+
120
+ Let's look at another example (part of the code is omitted):
121
+
122
+ ```python
123
+ count_adds = 0
124
+
125
+ @changer.filter
126
+ def only_first(node: Add, context: Context) -> bool:
127
+ global count_adds
128
+
129
+ count_adds += 1
130
+
131
+ return True if count_adds <= 1 else False
132
+
133
+ for x in changer.iterate_coordinates():
134
+ print(x)
135
+ print(changer.apply_coordinate(x))
136
+
137
+ #> Coordinate(file=None, class_name='Add', start_line=1, start_column=6, end_line=1, end_column=7)
138
+ #> a = 4 - 5
139
+ #> b = 15 - a
140
+ #> c = b + a # kek
141
+ ```
142
+
143
+ You see? Now, during the iteration, we got only the first version of possible changes, the rest are automatically filtered out because the filter function told us to do so.
144
+
145
+ So, now it's roughly clear how to use it. But what kind of `context` parameter do we see in converters and filters? It has 2 fields and 1 interesting method:
146
+
147
+ - `coordinate` with fields `start_line: int`, `start_column: int`, `end_line: int`, `end_column: int` and some others. This identifies where we are at in the code.
148
+ - `comment` - a comment line, if there is such a comment in the first line of this node, without a `#` at the beginning, or `None` if there is no comment.
149
+ - `get_metacodes(key: Union[str, List[str]]) -> List[ParsedComment]` - a method that returns a list of parsed comments in [metacode format](https://github.com/pomponchik/metacode) related to this line of code.
cstvis-0.0.1/README.md ADDED
@@ -0,0 +1,119 @@
1
+ <details>
2
+ <summary>ⓘ</summary>
3
+
4
+ [![Downloads](https://static.pepy.tech/badge/cstvis/month)](https://pepy.tech/project/cstvis)
5
+ [![Downloads](https://static.pepy.tech/badge/cstvis)](https://pepy.tech/project/cstvis)
6
+ [![Coverage Status](https://coveralls.io/repos/github/pomponchik/cstvis/badge.svg?branch=main)](https://coveralls.io/github/pomponchik/cstvis?branch=main)
7
+ [![Lines of code](https://sloc.xyz/github/pomponchik/cstvis/?category=code)](https://github.com/boyter/scc/)
8
+ [![Hits-of-Code](https://hitsofcode.com/github/pomponchik/cstvis?branch=main&label=Hits-of-Code&exclude=docs/)](https://hitsofcode.com/github/pomponchik/cstvis/view?branch=main)
9
+ [![Test-Package](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml/badge.svg)](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml)
10
+ [![Python versions](https://img.shields.io/pypi/pyversions/cstvis.svg)](https://pypi.python.org/pypi/cstvis)
11
+ [![PyPI version](https://badge.fury.io/py/cstvis.svg)](https://badge.fury.io/py/cstvis)
12
+ [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
13
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
14
+ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/pomponchik/cstvis)
15
+
16
+ </details>
17
+
18
+ ![logo](https://raw.githubusercontent.com/pomponchik/cstvis/develop/docs/assets/logo_1.svg)
19
+
20
+ A large number of source code tools (linters, formatters, and others) work with [CST](https://en.wikipedia.org/wiki/Parse_tree), a special representation of the source code that already has a tree shape (like [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree)), but still contains "extra" nodes such as spaces or comments. This library is a wrapper around such a tree, designed for convenient and iterative work with nodes: traversal and replacement.
21
+
22
+
23
+ ## Table of contents
24
+
25
+ - [**Installation**](#installation)
26
+ - [**Usage**](#usage)
27
+
28
+
29
+ ## Installation
30
+
31
+ Install it:
32
+
33
+ ```bash
34
+ pip install cstvis
35
+ ```
36
+
37
+ You can also quickly try out this and other packages without having to install using [instld](https://github.com/pomponchik/instld).
38
+
39
+
40
+ ## Usage
41
+
42
+ This library is a wrapper around the [`libcst`](https://pypi.org/project/libcst/) library.
43
+
44
+ The flow of work is very simple:
45
+
46
+ - Create an object of the `Changer` class.
47
+ - Register converter functions that will convert some `CST` nodes to others, using the decorator. Each such function takes a node object as the first argument, and it must be accompanied by a type annotation. It is based on the annotation that the system will understand which nodes it needs to be applied to and which ones it does not.
48
+ - If necessary, also register filters, which are special functions that can prevent the system from changing certain nodes.
49
+ - Iterate over atomic changes and apply them if necessary.
50
+
51
+ Let me show you a simple example:
52
+
53
+ ```python
54
+ from libcst import Subtract, Add
55
+ from cstvis import Changer, Context
56
+ from pathlib import Path
57
+
58
+ # Content of the file:
59
+ # a = 4 + 5
60
+ # b = 15 - a
61
+ # c = b + a # kek
62
+ changer = Changer(Path('tests/some_code/simple_sum.py').read_text())
63
+
64
+ @changer.converter
65
+ def change_add(node: Add, context: Context):
66
+ return Subtract(
67
+ whitespace_before=node.whitespace_before,
68
+ whitespace_after=node.whitespace_after,
69
+ )
70
+
71
+ for x in changer.iterate_coordinates():
72
+ print(x)
73
+ print(changer.apply_coordinate(x))
74
+
75
+ #> Coordinate(file=None, class_name='Add', start_line=1, start_column=6, end_line=1, end_column=7)
76
+ #> a = 4 - 5
77
+ #> b = 15 - a
78
+ #> c = b + a # kek
79
+ #>
80
+ #> Coordinate(file=None, class_name='Add', start_line=3, start_column=6, end_line=3, end_column=7)
81
+ #> a = 4 + 5
82
+ #> b = 15 - a
83
+ #> c = b - a # kek
84
+ ```
85
+
86
+ The key part of this example is the last two lines where the coordinates are iterated. What does it mean? The fact is that any change to the code that this library makes occurs in 2 stages: outline the coordinates of the change and make the change. Due to this separation, it becomes possible, for example, to divide this work between several threads or even several computers. However, this scheme also limits us. If you apply one coordinate change, the resulting code will differ from the original one and subsequent coordinates will no longer be possible to apply. You can only apply one change at a time.
87
+
88
+ A filter is a special function with the same signature as a converter, which we mark with the `@filter` decorator. This should decide whether to change a specific `CST` node or not, and return `True` if yes, or `False` if no. The filter is applied to all nodes if the node parameter does not have a type annotation, or if [Any](https://docs.python.org/3/library/typing.html#typing.Any) / [CSTNode](https://libcst.readthedocs.io/en/latest/nodes.html#libcst.CSTNode) annotation is used. If you specify a specific type of node in the annotation, the filter will be applied only to them. Any other annotations are not allowed.
89
+
90
+ Let's look at another example (part of the code is omitted):
91
+
92
+ ```python
93
+ count_adds = 0
94
+
95
+ @changer.filter
96
+ def only_first(node: Add, context: Context) -> bool:
97
+ global count_adds
98
+
99
+ count_adds += 1
100
+
101
+ return True if count_adds <= 1 else False
102
+
103
+ for x in changer.iterate_coordinates():
104
+ print(x)
105
+ print(changer.apply_coordinate(x))
106
+
107
+ #> Coordinate(file=None, class_name='Add', start_line=1, start_column=6, end_line=1, end_column=7)
108
+ #> a = 4 - 5
109
+ #> b = 15 - a
110
+ #> c = b + a # kek
111
+ ```
112
+
113
+ You see? Now, during the iteration, we got only the first version of possible changes, the rest are automatically filtered out because the filter function told us to do so.
114
+
115
+ So, now it's roughly clear how to use it. But what kind of `context` parameter do we see in converters and filters? It has 2 fields and 1 interesting method:
116
+
117
+ - `coordinate` with fields `start_line: int`, `start_column: int`, `end_line: int`, `end_column: int` and some others. This identifies where we are at in the code.
118
+ - `comment` - a comment line, if there is such a comment in the first line of this node, without a `#` at the beginning, or `None` if there is no comment.
119
+ - `get_metacodes(key: Union[str, List[str]]) -> List[ParsedComment]` - a method that returns a list of parsed comments in [metacode format](https://github.com/pomponchik/metacode) related to this line of code.
@@ -0,0 +1,3 @@
1
+ from cstvis.changer import Changer as Changer
2
+ from cstvis.dto import Context as Context
3
+ from cstvis.dto import Coordinate as Coordinate
@@ -0,0 +1,77 @@
1
+ from collections import defaultdict
2
+ from functools import cached_property
3
+ from inspect import _empty, signature
4
+ from typing import Any, Callable, Dict, Generator, List, Type
5
+
6
+ from libcst import CSTNode, metadata, parse_module
7
+
8
+ from cstvis.dto import Context, Coordinate
9
+ from cstvis.errors import TwoConvertersForOneNodeError
10
+ from cstvis.transformers.super_transformer import SuperTransformer
11
+ from cstvis.visitors.bloodhound import Bloodhound
12
+ from cstvis.visitors.comments_aggregator import CommentsAggregator
13
+
14
+
15
+ class Changer:
16
+ def __init__(self, source: str) -> None:
17
+ self.source = source
18
+ self.module = parse_module(source)
19
+
20
+ self.converters_by_types: Dict[Type[CSTNode], List[Callable[[CSTNode, Context], CSTNode]]] = defaultdict(list)
21
+ self.filters_by_types: Dict[Type[CSTNode], List[Callable[[CSTNode, Context], bool]]] = defaultdict(list)
22
+
23
+ @cached_property
24
+ def _comments_by_lines(self) -> Dict[int, str]:
25
+ wrapper = metadata.MetadataWrapper(self.module)
26
+ aggregator = CommentsAggregator()
27
+ wrapper.visit(aggregator)
28
+ return aggregator.comments
29
+
30
+ def filter(self, function: Callable[[CSTNode, Context], bool]) -> Callable[[CSTNode, Context], bool]:
31
+ converter_signature = signature(function)
32
+ parameters = converter_signature.parameters
33
+
34
+ if len(parameters) != 2:
35
+ raise ValueError(f'The filter is expected to accept 2 parameters: node and context; you have passed {len(parameters)} parameters.')
36
+
37
+ first_parameter = converter_signature.parameters[next(iter(converter_signature.parameters))]
38
+ annotation = first_parameter.annotation if first_parameter.annotation is not _empty else CSTNode
39
+ if annotation is Any:
40
+ annotation = CSTNode
41
+
42
+ if not issubclass(annotation, CSTNode):
43
+ raise TypeError('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class (or be a libcst.CSTNode class if you want to set a filter for all nodes).')
44
+
45
+ self.filters_by_types[annotation].append(function)
46
+ return function
47
+
48
+ def converter(self, function: Callable[[CSTNode, Context], CSTNode]) -> Callable[[CSTNode, Context], CSTNode]:
49
+ converter_signature = signature(function)
50
+ parameters = converter_signature.parameters
51
+
52
+ if len(parameters) != 2:
53
+ raise ValueError(f'The converter is expected to accept 2 parameters: node and context; you have passed {len(parameters)} parameters.')
54
+
55
+ first_parameter = converter_signature.parameters[next(iter(converter_signature.parameters))]
56
+ annotation = first_parameter.annotation if first_parameter.annotation is not _empty and first_parameter.annotation is not Any else CSTNode
57
+
58
+ if annotation is CSTNode or not issubclass(annotation, CSTNode):
59
+ raise TypeError('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class.')
60
+
61
+ if annotation in self.converters_by_types:
62
+ raise TwoConvertersForOneNodeError('You cannot assign 2 or more converters to the same subtype of libcst.CSTNode.')
63
+
64
+ self.converters_by_types[annotation].append(function)
65
+ return function
66
+
67
+ def iterate_coordinates(self) -> Generator[Coordinate, None, None]:
68
+ wrapper = metadata.MetadataWrapper(self.module)
69
+ printer = Bloodhound(self.converters_by_types, self._comments_by_lines, self.filters_by_types)
70
+
71
+ wrapper.visit(printer)
72
+ yield from printer.coordinates
73
+
74
+ def apply_coordinate(self, coordinate: Coordinate) -> str:
75
+ wrapper = metadata.MetadataWrapper(self.module)
76
+ modified = wrapper.visit(SuperTransformer(coordinate, self.converters_by_types, self._comments_by_lines))
77
+ return modified.code
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import List, Optional, Union
4
+
5
+ from metacode import ParsedComment, parse
6
+
7
+
8
+ @dataclass
9
+ class Coordinate:
10
+ file: Optional[Path]
11
+ class_name: str
12
+ start_line: int
13
+ start_column: int
14
+ end_line: int
15
+ end_column: int
16
+
17
+ @dataclass
18
+ class Context:
19
+ coordinate: Coordinate
20
+ comment: Optional[str]
21
+
22
+ def get_metacodes(self, key: Union[str, List[str]]) -> List[ParsedComment]:
23
+ if self.comment is None:
24
+ return []
25
+ return parse(self.comment, key)
@@ -0,0 +1,2 @@
1
+ class TwoConvertersForOneNodeError(Exception):
2
+ ...
File without changes
File without changes
@@ -0,0 +1,66 @@
1
+ from typing import Any, Callable, Dict, List, Type
2
+
3
+ import libcst.matchers as matchers_module
4
+ from libcst import CSTNode, metadata
5
+ from libcst.matchers import (
6
+ BaseMatcherNode,
7
+ MatcherDecoratableTransformer,
8
+ TypeOf,
9
+ leave,
10
+ )
11
+
12
+ from cstvis.dto import Context, Coordinate
13
+
14
+
15
+ def get_all_matcher_nodes() -> List[BaseMatcherNode]:
16
+ result = []
17
+
18
+ for name in dir(matchers_module):
19
+ attribute = getattr(matchers_module, name)
20
+ try:
21
+ if issubclass(attribute, BaseMatcherNode) and attribute is not BaseMatcherNode and attribute is not TypeOf:
22
+ result.append(attribute())
23
+ except TypeError:
24
+ pass
25
+
26
+ return result
27
+
28
+ def leave_all(function: Callable[[Any, CSTNode, CSTNode], CSTNode]) -> Callable[[Any, CSTNode, CSTNode], CSTNode]:
29
+ for matcher in get_all_matcher_nodes():
30
+ function = leave(matcher)(function)
31
+
32
+ return function
33
+
34
+
35
+ class SuperTransformer(MatcherDecoratableTransformer):
36
+ METADATA_DEPENDENCIES = (metadata.PositionProvider,)
37
+
38
+ def __init__(
39
+ self,
40
+ target_coordinate: Coordinate,
41
+ nodes_mapping: Dict[Type[CSTNode], List[Callable[[CSTNode, Context], CSTNode]]],
42
+ comments: Dict[int, str],
43
+ ):
44
+ self.target_coordinate = target_coordinate
45
+ self.nodes_mapping = nodes_mapping
46
+ self.comments = comments
47
+
48
+ super().__init__()
49
+
50
+ @leave_all
51
+ def leave(self, original_node, updated_node): # type: ignore[no-untyped-def]
52
+ position = self.get_metadata(metadata.PositionProvider, original_node)
53
+ coordinate = Coordinate(
54
+ file=None,
55
+ class_name=original_node.__class__.__name__,
56
+ start_line=position.start.line,
57
+ start_column=position.start.column,
58
+ end_line=position.end.line,
59
+ end_column=position.end.column,
60
+ )
61
+
62
+ if coordinate == self.target_coordinate and self.nodes_mapping.get(type(original_node)):
63
+ context = Context(coordinate, self.comments.get(coordinate.start_line))
64
+ converters = self.nodes_mapping[type(original_node)]
65
+ return converters[0](updated_node, context)
66
+ return updated_node
File without changes
@@ -0,0 +1,44 @@
1
+ from typing import Callable, Dict, List, Type
2
+
3
+ from libcst import CSTNode, CSTVisitor, metadata
4
+
5
+ from cstvis.dto import Context, Coordinate
6
+
7
+
8
+ class Bloodhound(CSTVisitor):
9
+ METADATA_DEPENDENCIES = (metadata.PositionProvider,)
10
+
11
+ def __init__(
12
+ self,
13
+ nodes_mapping: Dict[Type[CSTNode], List[Callable[[CSTNode, Context], CSTNode]]],
14
+ comments: Dict[int, str],
15
+ filters: Dict[Type[CSTNode], List[Callable[[CSTNode, Context], bool]]],
16
+ ) -> None:
17
+ self.coordinates: List[Coordinate] = []
18
+ self.nodes_mapping = nodes_mapping
19
+ self.comments = comments
20
+ self.filters = filters
21
+
22
+ def on_visit(self, node: CSTNode) -> bool:
23
+ position = self.get_metadata(metadata.PositionProvider, node)
24
+ coordinate = Coordinate(
25
+ file=None,
26
+ class_name=node.__class__.__name__,
27
+ start_line=position.start.line,
28
+ start_column=position.start.column,
29
+ end_line=position.end.line,
30
+ end_column=position.end.column,
31
+ )
32
+
33
+ if self.nodes_mapping.get(type(node)) or self.nodes_mapping.get(CSTNode): # type: ignore[type-abstract]
34
+ filters = self.filters.get(type(node), []) + self.filters.get(CSTNode, []) # type: ignore[type-abstract]
35
+ context = Context(coordinate, self.comments.get(coordinate.start_line))
36
+ if filters:
37
+ for filter_function in filters:
38
+ if not filter_function(node, context):
39
+ return True
40
+ self.coordinates.append(
41
+ coordinate,
42
+ )
43
+
44
+ return True
@@ -0,0 +1,16 @@
1
+ from typing import Dict
2
+
3
+ from libcst import Comment, CSTNode, CSTVisitor, metadata
4
+
5
+
6
+ class CommentsAggregator(CSTVisitor):
7
+ METADATA_DEPENDENCIES = (metadata.PositionProvider,)
8
+
9
+ def __init__(self) -> None:
10
+ self.comments: Dict[int, str] = {}
11
+
12
+ def on_visit(self, node: CSTNode) -> bool:
13
+ if isinstance(node, Comment):
14
+ position = self.get_metadata(metadata.PositionProvider, node)
15
+ self.comments[position.start.line] = node.value[1:]
16
+ return True
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.1
2
+ Name: cstvis
3
+ Version: 0.0.1
4
+ Summary: Incremental change of CST
5
+ Author-email: Evgeniy Blinov <zheni-b@yandex.ru>
6
+ Project-URL: Source, https://github.com/pomponchik/cstvis
7
+ Project-URL: Tracker, https://github.com/pomponchik/cstvis/issues
8
+ Keywords: CST,visitor
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Operating System :: MacOS :: MacOS X
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: Free Threading
23
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
24
+ Classifier: License :: OSI Approved :: MIT License
25
+ Classifier: Intended Audience :: Developers
26
+ Classifier: Topic :: Software Development :: Libraries
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+
31
+ <details>
32
+ <summary>ⓘ</summary>
33
+
34
+ [![Downloads](https://static.pepy.tech/badge/cstvis/month)](https://pepy.tech/project/cstvis)
35
+ [![Downloads](https://static.pepy.tech/badge/cstvis)](https://pepy.tech/project/cstvis)
36
+ [![Coverage Status](https://coveralls.io/repos/github/pomponchik/cstvis/badge.svg?branch=main)](https://coveralls.io/github/pomponchik/cstvis?branch=main)
37
+ [![Lines of code](https://sloc.xyz/github/pomponchik/cstvis/?category=code)](https://github.com/boyter/scc/)
38
+ [![Hits-of-Code](https://hitsofcode.com/github/pomponchik/cstvis?branch=main&label=Hits-of-Code&exclude=docs/)](https://hitsofcode.com/github/pomponchik/cstvis/view?branch=main)
39
+ [![Test-Package](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml/badge.svg)](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/cstvis.svg)](https://pypi.python.org/pypi/cstvis)
41
+ [![PyPI version](https://badge.fury.io/py/cstvis.svg)](https://badge.fury.io/py/cstvis)
42
+ [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
43
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
44
+ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/pomponchik/cstvis)
45
+
46
+ </details>
47
+
48
+ ![logo](https://raw.githubusercontent.com/pomponchik/cstvis/develop/docs/assets/logo_1.svg)
49
+
50
+ A large number of source code tools (linters, formatters, and others) work with [CST](https://en.wikipedia.org/wiki/Parse_tree), a special representation of the source code that already has a tree shape (like [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree)), but still contains "extra" nodes such as spaces or comments. This library is a wrapper around such a tree, designed for convenient and iterative work with nodes: traversal and replacement.
51
+
52
+
53
+ ## Table of contents
54
+
55
+ - [**Installation**](#installation)
56
+ - [**Usage**](#usage)
57
+
58
+
59
+ ## Installation
60
+
61
+ Install it:
62
+
63
+ ```bash
64
+ pip install cstvis
65
+ ```
66
+
67
+ You can also quickly try out this and other packages without having to install using [instld](https://github.com/pomponchik/instld).
68
+
69
+
70
+ ## Usage
71
+
72
+ This library is a wrapper around the [`libcst`](https://pypi.org/project/libcst/) library.
73
+
74
+ The flow of work is very simple:
75
+
76
+ - Create an object of the `Changer` class.
77
+ - Register converter functions that will convert some `CST` nodes to others, using the decorator. Each such function takes a node object as the first argument, and it must be accompanied by a type annotation. It is based on the annotation that the system will understand which nodes it needs to be applied to and which ones it does not.
78
+ - If necessary, also register filters, which are special functions that can prevent the system from changing certain nodes.
79
+ - Iterate over atomic changes and apply them if necessary.
80
+
81
+ Let me show you a simple example:
82
+
83
+ ```python
84
+ from libcst import Subtract, Add
85
+ from cstvis import Changer, Context
86
+ from pathlib import Path
87
+
88
+ # Content of the file:
89
+ # a = 4 + 5
90
+ # b = 15 - a
91
+ # c = b + a # kek
92
+ changer = Changer(Path('tests/some_code/simple_sum.py').read_text())
93
+
94
+ @changer.converter
95
+ def change_add(node: Add, context: Context):
96
+ return Subtract(
97
+ whitespace_before=node.whitespace_before,
98
+ whitespace_after=node.whitespace_after,
99
+ )
100
+
101
+ for x in changer.iterate_coordinates():
102
+ print(x)
103
+ print(changer.apply_coordinate(x))
104
+
105
+ #> Coordinate(file=None, class_name='Add', start_line=1, start_column=6, end_line=1, end_column=7)
106
+ #> a = 4 - 5
107
+ #> b = 15 - a
108
+ #> c = b + a # kek
109
+ #>
110
+ #> Coordinate(file=None, class_name='Add', start_line=3, start_column=6, end_line=3, end_column=7)
111
+ #> a = 4 + 5
112
+ #> b = 15 - a
113
+ #> c = b - a # kek
114
+ ```
115
+
116
+ The key part of this example is the last two lines where the coordinates are iterated. What does it mean? The fact is that any change to the code that this library makes occurs in 2 stages: outline the coordinates of the change and make the change. Due to this separation, it becomes possible, for example, to divide this work between several threads or even several computers. However, this scheme also limits us. If you apply one coordinate change, the resulting code will differ from the original one and subsequent coordinates will no longer be possible to apply. You can only apply one change at a time.
117
+
118
+ A filter is a special function with the same signature as a converter, which we mark with the `@filter` decorator. This should decide whether to change a specific `CST` node or not, and return `True` if yes, or `False` if no. The filter is applied to all nodes if the node parameter does not have a type annotation, or if [Any](https://docs.python.org/3/library/typing.html#typing.Any) / [CSTNode](https://libcst.readthedocs.io/en/latest/nodes.html#libcst.CSTNode) annotation is used. If you specify a specific type of node in the annotation, the filter will be applied only to them. Any other annotations are not allowed.
119
+
120
+ Let's look at another example (part of the code is omitted):
121
+
122
+ ```python
123
+ count_adds = 0
124
+
125
+ @changer.filter
126
+ def only_first(node: Add, context: Context) -> bool:
127
+ global count_adds
128
+
129
+ count_adds += 1
130
+
131
+ return True if count_adds <= 1 else False
132
+
133
+ for x in changer.iterate_coordinates():
134
+ print(x)
135
+ print(changer.apply_coordinate(x))
136
+
137
+ #> Coordinate(file=None, class_name='Add', start_line=1, start_column=6, end_line=1, end_column=7)
138
+ #> a = 4 - 5
139
+ #> b = 15 - a
140
+ #> c = b + a # kek
141
+ ```
142
+
143
+ You see? Now, during the iteration, we got only the first version of possible changes, the rest are automatically filtered out because the filter function told us to do so.
144
+
145
+ So, now it's roughly clear how to use it. But what kind of `context` parameter do we see in converters and filters? It has 2 fields and 1 interesting method:
146
+
147
+ - `coordinate` with fields `start_line: int`, `start_column: int`, `end_line: int`, `end_column: int` and some others. This identifies where we are at in the code.
148
+ - `comment` - a comment line, if there is such a comment in the first line of this node, without a `#` at the beginning, or `None` if there is no comment.
149
+ - `get_metacodes(key: Union[str, List[str]]) -> List[ParsedComment]` - a method that returns a list of parsed comments in [metacode format](https://github.com/pomponchik/metacode) related to this line of code.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ cstvis/__init__.py
5
+ cstvis/changer.py
6
+ cstvis/dto.py
7
+ cstvis/errors.py
8
+ cstvis/py.typed
9
+ cstvis.egg-info/PKG-INFO
10
+ cstvis.egg-info/SOURCES.txt
11
+ cstvis.egg-info/dependency_links.txt
12
+ cstvis.egg-info/requires.txt
13
+ cstvis.egg-info/top_level.txt
14
+ cstvis/transformers/__init__.py
15
+ cstvis/transformers/super_transformer.py
16
+ cstvis/visitors/__init__.py
17
+ cstvis/visitors/bloodhound.py
18
+ cstvis/visitors/comments_aggregator.py
19
+ tests/test_changer.py
@@ -0,0 +1,7 @@
1
+ metacode>=0.0.4
2
+
3
+ [:python_version == "3.8"]
4
+ libcst>=1.1.0
5
+
6
+ [:python_version > "3.8"]
7
+ libcst>=1.8.6
@@ -0,0 +1 @@
1
+ cstvis
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools==68.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cstvis"
7
+ version = "0.0.1"
8
+ authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
9
+ description = 'Incremental change of CST'
10
+ readme = "README.md"
11
+ requires-python = ">=3.8"
12
+ dependencies = ["libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'", 'metacode>=0.0.4']
13
+ classifiers = [
14
+ "Operating System :: OS Independent",
15
+ 'Operating System :: MacOS :: MacOS X',
16
+ 'Operating System :: Microsoft :: Windows',
17
+ 'Operating System :: POSIX',
18
+ 'Operating System :: POSIX :: Linux',
19
+ 'Programming Language :: Python',
20
+ 'Programming Language :: Python :: 3.8',
21
+ 'Programming Language :: Python :: 3.9',
22
+ 'Programming Language :: Python :: 3.10',
23
+ 'Programming Language :: Python :: 3.11',
24
+ 'Programming Language :: Python :: 3.12',
25
+ 'Programming Language :: Python :: 3.13',
26
+ 'Programming Language :: Python :: 3.14',
27
+ 'Programming Language :: Python :: Free Threading',
28
+ 'Programming Language :: Python :: Free Threading :: 3 - Stable',
29
+ 'License :: OSI Approved :: MIT License',
30
+ 'Intended Audience :: Developers',
31
+ 'Topic :: Software Development :: Libraries',
32
+ ]
33
+ keywords = ['CST', 'visitor']
34
+
35
+ [tool.setuptools.package-data]
36
+ "cstvis" = ["py.typed"]
37
+
38
+ [tool.mutmut]
39
+ paths_to_mutate = "cstvis"
40
+ runner = "pytest"
41
+
42
+ [tool.pytest.ini_options]
43
+ markers = ["mypy_testing"]
44
+
45
+ [tool.ruff]
46
+ lint.ignore = ['E501', 'E712', 'PTH123', 'PTH118', 'PLR2004', 'PTH107', 'SIM105', 'SIM102', 'RET503', 'PT006']
47
+ lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", "T20", "PT", "RSE", "RET", "SIM", "SLOT", "TID252", "ARG", "PTH", "I", "C90", "N", "E", "W", "D201", "D202", "D419", "F", "PL", "PLE", "PLR", "PLW", "RUF", "TRY201", "TRY400", "TRY401"]
48
+ format.quote-style = "single"
49
+
50
+ [project.urls]
51
+ 'Source' = 'https://github.com/pomponchik/cstvis'
52
+ 'Tracker' = 'https://github.com/pomponchik/cstvis/issues'
cstvis-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,548 @@
1
+ # ruff: noqa: ARG001
2
+
3
+ from typing import Any
4
+
5
+ import pytest
6
+ from full_match import match
7
+ from libcst import Add, CSTNode, Subtract
8
+ from metacode import ParsedComment
9
+
10
+ from cstvis import Changer, Context
11
+ from cstvis.errors import TwoConvertersForOneNodeError
12
+
13
+
14
+ @pytest.mark.parametrize(
15
+ ['strings'],
16
+ [
17
+ ([
18
+ 'a = 5',
19
+ 'b = 12 * a #lol',
20
+ 'c = 12 + b # kek',
21
+ 'c *= 5',
22
+ 'c += 5',
23
+ 'a = c + 5',
24
+ ],),
25
+ ],
26
+ )
27
+ def test_just_iterate_add_coordinates(file):
28
+ changer = Changer(file)
29
+
30
+ @changer.converter
31
+ def name_changer(node: Add, context: Context):
32
+ return True
33
+
34
+ coordinates = list(changer.iterate_coordinates())
35
+
36
+ assert len(coordinates) == 2
37
+
38
+ assert coordinates[0].file is None
39
+ assert coordinates[0].class_name == 'Add'
40
+ assert coordinates[0].start_line == 3
41
+ assert coordinates[0].start_column == 7
42
+ assert coordinates[0].start_line == 3
43
+ assert coordinates[0].start_column == 7
44
+
45
+ assert coordinates[1].file is None
46
+ assert coordinates[1].class_name == 'Add'
47
+ assert coordinates[1].start_line == 6
48
+ assert coordinates[1].start_column == 6
49
+ assert coordinates[1].start_line == 6
50
+ assert coordinates[1].start_column == 6
51
+
52
+
53
+ @pytest.mark.parametrize(
54
+ ['strings'],
55
+ [
56
+ ([
57
+ 'a = 5',
58
+ 'b = 12 * a #lol',
59
+ 'c = 12 + b # kek',
60
+ ],),
61
+ ],
62
+ )
63
+ def test_apply_one_change(file):
64
+ changer = Changer(file)
65
+
66
+ @changer.converter
67
+ def change_add_to_sub(node: Add, context: Context):
68
+ return Subtract(
69
+ whitespace_before=node.whitespace_before,
70
+ whitespace_after=node.whitespace_after,
71
+ )
72
+
73
+ results = []
74
+
75
+ for coordinate in changer.iterate_coordinates():
76
+ results.append(changer.apply_coordinate(coordinate))
77
+
78
+ assert len(results) == 1
79
+ assert results[0] == file.replace('+', '-')
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ ['strings'],
84
+ [
85
+ ([
86
+ 'a = 5 + 6+ 7',
87
+ ],),
88
+ ],
89
+ )
90
+ def test_apply_two_changes_at_same_line(file):
91
+ changer = Changer(file)
92
+
93
+ @changer.converter
94
+ def change_add_to_sub(node: Add, context: Context):
95
+ return Subtract(
96
+ whitespace_before=node.whitespace_before,
97
+ whitespace_after=node.whitespace_after,
98
+ )
99
+
100
+ results = []
101
+
102
+ for coordinate in changer.iterate_coordinates():
103
+ results.append(changer.apply_coordinate(coordinate))
104
+
105
+ assert len(results) == 2
106
+ assert results == [
107
+ 'a = 5 - 6+ 7',
108
+ 'a = 5 + 6- 7',
109
+ ]
110
+
111
+
112
+ @pytest.mark.parametrize(
113
+ ['strings'],
114
+ [
115
+ ([
116
+ 'a = 5 + 6- 7',
117
+ ],),
118
+ ],
119
+ )
120
+ def test_to_different_changers_to_same_line(file):
121
+ changer = Changer(file)
122
+
123
+ @changer.converter
124
+ def change_add_to_sub(node: Add, context: Context):
125
+ return Subtract(
126
+ whitespace_before=node.whitespace_before,
127
+ whitespace_after=node.whitespace_after,
128
+ )
129
+
130
+ @changer.converter
131
+ def change_sub_to_add(node: Subtract, context: Context):
132
+ return Add(
133
+ whitespace_before=node.whitespace_before,
134
+ whitespace_after=node.whitespace_after,
135
+ )
136
+
137
+ results = []
138
+
139
+ for coordinate in changer.iterate_coordinates():
140
+ results.append(changer.apply_coordinate(coordinate))
141
+
142
+ assert len(results) == 2
143
+ assert results == [
144
+ 'a = 5 - 6- 7',
145
+ 'a = 5 + 6+ 7',
146
+ ]
147
+
148
+
149
+ @pytest.mark.parametrize(
150
+ ['strings'],
151
+ [
152
+ ([
153
+ 'a = 5 + 6- 7',
154
+ ],),
155
+ ],
156
+ )
157
+ def test_changing_function_with_wrong_number_of_parameters(file):
158
+ changer = Changer(file)
159
+
160
+ with pytest.raises(ValueError, match=match('The converter is expected to accept 2 parameters: node and context; you have passed 3 parameters.')):
161
+ @changer.converter
162
+ def changing_function(node: Add, context: Context, something_else: str):
163
+ return Subtract(
164
+ whitespace_before=node.whitespace_before,
165
+ whitespace_after=node.whitespace_after,
166
+ )
167
+
168
+ with pytest.raises(ValueError, match=match('The converter is expected to accept 2 parameters: node and context; you have passed 1 parameters.')):
169
+ @changer.converter
170
+ def changing_function(node: Add):
171
+ return Subtract(
172
+ whitespace_before=node.whitespace_before,
173
+ whitespace_after=node.whitespace_after,
174
+ )
175
+
176
+
177
+ @pytest.mark.parametrize(
178
+ ['strings', 'expected_comment'],
179
+ [
180
+ (['a = 5 + 6- 7'], None),
181
+ (['a = 5 + 6- 7#'], ''),
182
+ (['a = 5 + 6- 7# ololo!'], ' ololo!'),
183
+ (['a = 5 + 6- 7# other_key: action'], ' other_key: action'),
184
+ (['a = 5 + 6- 7#key: action'], 'key: action'),
185
+ (['a = 5 + 6- 7# key: action'], ' key: action'),
186
+ (['a = 5 + 6- 7 # key: action'], ' key: action'),
187
+ (['a = 5 + 6- 7 # key: action# ololo!'], ' key: action# ololo!'),
188
+ ],
189
+ )
190
+ def test_read_comments(file, expected_comment):
191
+ changer = Changer(file)
192
+
193
+ comments_containers = []
194
+
195
+ @changer.converter
196
+ def change_something(node: Add, context: Context):
197
+ comments_containers.append(context.comment)
198
+ return node
199
+
200
+ for coordinate in changer.iterate_coordinates():
201
+ changer.apply_coordinate(coordinate)
202
+
203
+ assert comments_containers[0] == expected_comment
204
+
205
+
206
+ @pytest.mark.parametrize(
207
+ ['strings', 'expected_metacodes'],
208
+ [
209
+ (['a = 5 + 6- 7'], []),
210
+ (['a = 5 + 6- 7#'], []),
211
+ (['a = 5 + 6- 7# ololo!'], []),
212
+ (['a = 5 + 6- 7# other_key: action'], []),
213
+ (['a = 5 + 6- 7# key: action'], [ParsedComment(key='key', command='action', arguments=[])]),
214
+ (['a = 5 + 6- 7 # key: action'], [ParsedComment(key='key', command='action', arguments=[])]),
215
+ (['a = 5 + 6- 7 # key: action# ololo!'], [ParsedComment(key='key', command='action', arguments=[])]),
216
+ ],
217
+ )
218
+ def test_read_metacodes_from_comment(file, expected_metacodes):
219
+ changer = Changer(file)
220
+
221
+ metacodes_containers = []
222
+
223
+ @changer.converter
224
+ def change_something(node: Add, context: Context):
225
+ metacodes_containers.append(context.get_metacodes('key'))
226
+ return node
227
+
228
+ for coordinate in changer.iterate_coordinates():
229
+ changer.apply_coordinate(coordinate)
230
+
231
+ assert metacodes_containers[0] == expected_metacodes
232
+
233
+
234
+ @pytest.mark.parametrize(
235
+ ['strings'],
236
+ [
237
+ ([
238
+ 'a = 5 + 6- 7',
239
+ ],),
240
+ ],
241
+ )
242
+ def test_filter_any_on(file):
243
+ changer = Changer(file)
244
+
245
+ @changer.converter
246
+ def change_something(node: Add, context: Context):
247
+ return Subtract(
248
+ whitespace_before=node.whitespace_before,
249
+ whitespace_after=node.whitespace_after,
250
+ )
251
+
252
+ @changer.filter
253
+ def filter_something(node: Any, context: Context) -> bool:
254
+ return True
255
+
256
+ results = []
257
+
258
+ for coordinate in changer.iterate_coordinates():
259
+ results.append(changer.apply_coordinate(coordinate))
260
+
261
+ assert len(results) == 1
262
+ assert results[0] == file.replace('+', '-')
263
+
264
+
265
+ @pytest.mark.parametrize(
266
+ ['strings'],
267
+ [
268
+ ([
269
+ 'a = 5 + 6- 7',
270
+ ],),
271
+ ],
272
+ )
273
+ def test_filter_any_off(file):
274
+ changer = Changer(file)
275
+
276
+ @changer.converter
277
+ def change_something(node: Add, context: Context):
278
+ return Subtract(
279
+ whitespace_before=node.whitespace_before,
280
+ whitespace_after=node.whitespace_after,
281
+ )
282
+
283
+ @changer.filter
284
+ def filter_something(node: Any, context: Context) -> bool:
285
+ return False
286
+
287
+ results = []
288
+
289
+ for coordinate in changer.iterate_coordinates():
290
+ results.append(changer.apply_coordinate(coordinate))
291
+
292
+ assert len(results) == 0
293
+
294
+
295
+ @pytest.mark.parametrize(
296
+ ['strings'],
297
+ [
298
+ ([
299
+ 'a = 5 + 6- 7',
300
+ ],),
301
+ ],
302
+ )
303
+ def test_filter_cstnode_on(file):
304
+ changer = Changer(file)
305
+
306
+ @changer.converter
307
+ def change_something(node: Add, context: Context):
308
+ return Subtract(
309
+ whitespace_before=node.whitespace_before,
310
+ whitespace_after=node.whitespace_after,
311
+ )
312
+
313
+ @changer.filter
314
+ def filter_something(node: CSTNode, context: Context) -> bool:
315
+ return True
316
+
317
+ results = []
318
+
319
+ for coordinate in changer.iterate_coordinates():
320
+ results.append(changer.apply_coordinate(coordinate))
321
+
322
+ assert len(results) == 1
323
+ assert results[0] == file.replace('+', '-')
324
+
325
+
326
+ @pytest.mark.parametrize(
327
+ ['strings'],
328
+ [
329
+ ([
330
+ 'a = 5 + 6- 7',
331
+ ],),
332
+ ],
333
+ )
334
+ def test_filter_cstnode_off(file):
335
+ changer = Changer(file)
336
+
337
+ @changer.converter
338
+ def change_something(node: Add, context: Context):
339
+ return Subtract(
340
+ whitespace_before=node.whitespace_before,
341
+ whitespace_after=node.whitespace_after,
342
+ )
343
+
344
+ @changer.filter
345
+ def filter_something(node: CSTNode, context: Context) -> bool:
346
+ return False
347
+
348
+ results = []
349
+
350
+ for coordinate in changer.iterate_coordinates():
351
+ results.append(changer.apply_coordinate(coordinate))
352
+
353
+ assert len(results) == 0
354
+
355
+
356
+ @pytest.mark.parametrize(
357
+ ['strings'],
358
+ [
359
+ ([
360
+ 'a = 5 + 6- 7',
361
+ ],),
362
+ ],
363
+ )
364
+ def test_filter_node_on(file):
365
+ changer = Changer(file)
366
+
367
+ @changer.converter
368
+ def change_something(node: Add, context: Context):
369
+ return Subtract(
370
+ whitespace_before=node.whitespace_before,
371
+ whitespace_after=node.whitespace_after,
372
+ )
373
+
374
+ @changer.filter
375
+ def filter_something(node: Add, context: Context) -> bool:
376
+ return True
377
+
378
+ results = []
379
+
380
+ for coordinate in changer.iterate_coordinates():
381
+ results.append(changer.apply_coordinate(coordinate))
382
+
383
+ assert len(results) == 1
384
+ assert results[0] == file.replace('+', '-')
385
+
386
+
387
+ @pytest.mark.parametrize(
388
+ ['strings'],
389
+ [
390
+ ([
391
+ 'a = 5 + 6- 7',
392
+ ],),
393
+ ],
394
+ )
395
+ def test_filter_node_off(file):
396
+ changer = Changer(file)
397
+
398
+ @changer.converter
399
+ def change_something(node: Add, context: Context):
400
+ return Subtract(
401
+ whitespace_before=node.whitespace_before,
402
+ whitespace_after=node.whitespace_after,
403
+ )
404
+
405
+ @changer.filter
406
+ def filter_something(node: Add, context: Context) -> bool:
407
+ return False
408
+
409
+ results = []
410
+
411
+ for coordinate in changer.iterate_coordinates():
412
+ results.append(changer.apply_coordinate(coordinate))
413
+
414
+ assert len(results) == 0
415
+
416
+
417
+ @pytest.mark.parametrize(
418
+ ['strings'],
419
+ [
420
+ ([
421
+ 'a = 5 + 6- 7',
422
+ ],),
423
+ ],
424
+ )
425
+ def test_filter_other_node_on(file):
426
+ changer = Changer(file)
427
+
428
+ @changer.converter
429
+ def change_something(node: Add, context: Context):
430
+ return Subtract(
431
+ whitespace_before=node.whitespace_before,
432
+ whitespace_after=node.whitespace_after,
433
+ )
434
+
435
+ @changer.filter
436
+ def filter_something(node: Subtract, context: Context) -> bool:
437
+ return True
438
+
439
+ results = []
440
+
441
+ for coordinate in changer.iterate_coordinates():
442
+ results.append(changer.apply_coordinate(coordinate))
443
+
444
+ assert len(results) == 1
445
+ assert results[0] == file.replace('+', '-')
446
+
447
+
448
+ @pytest.mark.parametrize(
449
+ ['strings'],
450
+ [
451
+ ([
452
+ 'a = 5 + 6- 7',
453
+ ],),
454
+ ],
455
+ )
456
+ def test_filter_other_node_off(file):
457
+ changer = Changer(file)
458
+
459
+ @changer.converter
460
+ def change_something(node: Add, context: Context):
461
+ return Subtract(
462
+ whitespace_before=node.whitespace_before,
463
+ whitespace_after=node.whitespace_after,
464
+ )
465
+
466
+ @changer.filter
467
+ def filter_something(node: Subtract, context: Context) -> bool:
468
+ return False
469
+
470
+ results = []
471
+
472
+ for coordinate in changer.iterate_coordinates():
473
+ results.append(changer.apply_coordinate(coordinate))
474
+
475
+ assert len(results) == 1
476
+ assert results[0] == file.replace('+', '-')
477
+
478
+
479
+ def test_converter_with_no_annotation():
480
+ changer = Changer('a = 5')
481
+
482
+ with pytest.raises(TypeError, match=match('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class.')):
483
+ @changer.converter
484
+ def converter_func(node, context):
485
+ return node
486
+
487
+
488
+ def test_converter_with_any_annotation():
489
+ changer = Changer('a = 5')
490
+
491
+ with pytest.raises(TypeError, match=match('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class.')):
492
+ @changer.converter
493
+ def converter_func(node: Any, context: Context):
494
+ return node
495
+
496
+
497
+ def test_converter_with_invalid_type_annotation():
498
+ changer = Changer('a = 5')
499
+
500
+ with pytest.raises(TypeError, match=match('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class.')):
501
+ @changer.converter
502
+ def converter_func(node: str, context: Context):
503
+ return node
504
+
505
+
506
+ def test_converter_with_cstnode_annotation_restriction():
507
+ changer = Changer('a = 5')
508
+
509
+ with pytest.raises(TypeError, match=match('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class.')):
510
+ @changer.converter
511
+ def converter_func(node: CSTNode, context: Context):
512
+ return node
513
+
514
+
515
+ def test_filter_with_wrong_number_of_parameters():
516
+ changer = Changer('a = 5')
517
+
518
+ with pytest.raises(ValueError, match=match('The filter is expected to accept 2 parameters: node and context; you have passed 3 parameters.')):
519
+ @changer.filter
520
+ def filter_func(node: Add, context: Context, extra_param: str):
521
+ return True
522
+
523
+ with pytest.raises(ValueError, match=match('The filter is expected to accept 2 parameters: node and context; you have passed 1 parameters.')):
524
+ @changer.filter
525
+ def filter_func(node: Add):
526
+ return True
527
+
528
+
529
+ def test_filter_with_invalid_annotation():
530
+ changer = Changer('a = 5')
531
+
532
+ with pytest.raises(TypeError, match=match('The type annotation for the first argument of the function must be descended from the libcst.CSTNode class (or be a libcst.CSTNode class if you want to set a filter for all nodes).')):
533
+ @changer.filter
534
+ def filter_func(node: str, context: Context):
535
+ return True
536
+
537
+
538
+ def test_two_converters_for_same_node_error():
539
+ changer = Changer('a = 5')
540
+
541
+ @changer.converter
542
+ def converter1(node: Add, context: Context):
543
+ return node
544
+
545
+ with pytest.raises(TwoConvertersForOneNodeError, match=match('You cannot assign 2 or more converters to the same subtype of libcst.CSTNode.')):
546
+ @changer.converter
547
+ def converter2(node: Add, context: Context):
548
+ return node