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 +21 -0
- cstvis-0.0.1/PKG-INFO +149 -0
- cstvis-0.0.1/README.md +119 -0
- cstvis-0.0.1/cstvis/__init__.py +3 -0
- cstvis-0.0.1/cstvis/changer.py +77 -0
- cstvis-0.0.1/cstvis/dto.py +25 -0
- cstvis-0.0.1/cstvis/errors.py +2 -0
- cstvis-0.0.1/cstvis/py.typed +0 -0
- cstvis-0.0.1/cstvis/transformers/__init__.py +0 -0
- cstvis-0.0.1/cstvis/transformers/super_transformer.py +66 -0
- cstvis-0.0.1/cstvis/visitors/__init__.py +0 -0
- cstvis-0.0.1/cstvis/visitors/bloodhound.py +44 -0
- cstvis-0.0.1/cstvis/visitors/comments_aggregator.py +16 -0
- cstvis-0.0.1/cstvis.egg-info/PKG-INFO +149 -0
- cstvis-0.0.1/cstvis.egg-info/SOURCES.txt +19 -0
- cstvis-0.0.1/cstvis.egg-info/dependency_links.txt +1 -0
- cstvis-0.0.1/cstvis.egg-info/requires.txt +7 -0
- cstvis-0.0.1/cstvis.egg-info/top_level.txt +1 -0
- cstvis-0.0.1/pyproject.toml +52 -0
- cstvis-0.0.1/setup.cfg +4 -0
- cstvis-0.0.1/tests/test_changer.py +548 -0
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
|
+
[](https://pepy.tech/project/cstvis)
|
|
35
|
+
[](https://pepy.tech/project/cstvis)
|
|
36
|
+
[](https://coveralls.io/github/pomponchik/cstvis?branch=main)
|
|
37
|
+
[](https://github.com/boyter/scc/)
|
|
38
|
+
[](https://hitsofcode.com/github/pomponchik/cstvis/view?branch=main)
|
|
39
|
+
[](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml)
|
|
40
|
+
[](https://pypi.python.org/pypi/cstvis)
|
|
41
|
+
[](https://badge.fury.io/py/cstvis)
|
|
42
|
+
[](http://mypy-lang.org/)
|
|
43
|
+
[](https://github.com/astral-sh/ruff)
|
|
44
|
+
[](https://deepwiki.com/pomponchik/cstvis)
|
|
45
|
+
|
|
46
|
+
</details>
|
|
47
|
+
|
|
48
|
+

|
|
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
|
+
[](https://pepy.tech/project/cstvis)
|
|
5
|
+
[](https://pepy.tech/project/cstvis)
|
|
6
|
+
[](https://coveralls.io/github/pomponchik/cstvis?branch=main)
|
|
7
|
+
[](https://github.com/boyter/scc/)
|
|
8
|
+
[](https://hitsofcode.com/github/pomponchik/cstvis/view?branch=main)
|
|
9
|
+
[](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml)
|
|
10
|
+
[](https://pypi.python.org/pypi/cstvis)
|
|
11
|
+
[](https://badge.fury.io/py/cstvis)
|
|
12
|
+
[](http://mypy-lang.org/)
|
|
13
|
+
[](https://github.com/astral-sh/ruff)
|
|
14
|
+
[](https://deepwiki.com/pomponchik/cstvis)
|
|
15
|
+
|
|
16
|
+
</details>
|
|
17
|
+
|
|
18
|
+

|
|
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,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)
|
|
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
|
+
[](https://pepy.tech/project/cstvis)
|
|
35
|
+
[](https://pepy.tech/project/cstvis)
|
|
36
|
+
[](https://coveralls.io/github/pomponchik/cstvis?branch=main)
|
|
37
|
+
[](https://github.com/boyter/scc/)
|
|
38
|
+
[](https://hitsofcode.com/github/pomponchik/cstvis/view?branch=main)
|
|
39
|
+
[](https://github.com/pomponchik/cstvis/actions/workflows/tests_and_coverage.yml)
|
|
40
|
+
[](https://pypi.python.org/pypi/cstvis)
|
|
41
|
+
[](https://badge.fury.io/py/cstvis)
|
|
42
|
+
[](http://mypy-lang.org/)
|
|
43
|
+
[](https://github.com/astral-sh/ruff)
|
|
44
|
+
[](https://deepwiki.com/pomponchik/cstvis)
|
|
45
|
+
|
|
46
|
+
</details>
|
|
47
|
+
|
|
48
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|