Skip to content

Commit 03f9896

Browse files
authored
Merge pull request #4 from pomponchik/develop
0.0.4
2 parents dbc2c40 + f72dabe commit 03f9896

8 files changed

Lines changed: 305 additions & 30 deletions

File tree

README.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,20 @@ Many source code analysis tools use comments in a special format to mark it up.
3434

3535
In the Python ecosystem, there are many tools dealing with source code: linters, test coverage collection systems, and many others. Many of them use special comments, and as a rule, the style of these comments is very similar. Here are some examples:
3636

37-
- [`Ruff`](https://docs.astral.sh/ruff/linter/#error-suppression), [`Vulture`](https://github.com/jendrikseipp/vulture?tab=readme-ov-file#flake8-noqa-comments)`# noqa`, `# noqa: E741, F841`.
38-
- [`Black`](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#ignoring-sections) and [`Ruff`](https://docs.astral.sh/ruff/formatter/#format-suppression)`# fmt: on`, `# fmt: off`.
39-
- [`Mypy`](https://discuss.python.org/t/ignore-mypy-specific-type-errors/58535)`# type: ignore`, `type: ignore[error-code]`.
40-
- [`Coverage`](https://coverage.readthedocs.io/en/7.13.0/excluding.html#default-exclusions)`# pragma: no cover`, `# pragma: no branch`.
41-
- [`Isort`](https://pycqa.github.io/isort/docs/configuration/action_comments.html)`# isort: skip`, `# isort: off`.
42-
- [`Bandit`](https://bandit.readthedocs.io/en/latest/config.html#suppressing-individual-lines)`# nosec`.
37+
- [`Ruff`](https://docs.astral.sh/ruff/linter/#error-suppression), [`Vulture`](https://github.com/jendrikseipp/vulture?tab=readme-ov-file#flake8-noqa-comments)> `# noqa`, `# noqa: E741, F841`.
38+
- [`Black`](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#ignoring-sections) and [`Ruff`](https://docs.astral.sh/ruff/formatter/#format-suppression)> `# fmt: on`, `# fmt: off`.
39+
- [`Mypy`](https://discuss.python.org/t/ignore-mypy-specific-type-errors/58535)> `# type: ignore`, `type: ignore[error-code]`.
40+
- [`Coverage`](https://coverage.readthedocs.io/en/7.13.0/excluding.html#default-exclusions)> `# pragma: no cover`, `# pragma: no branch`.
41+
- [`Isort`](https://pycqa.github.io/isort/docs/configuration/action_comments.html)> `# isort: skip`, `# isort: off`.
42+
- [`Bandit`](https://bandit.readthedocs.io/en/latest/config.html#suppressing-individual-lines)> `# nosec`.
4343

44-
But you know what? *There is no single standard for such comments*. Seriously.
44+
But you know what? *There is no single standard for such comments*.
4545

4646
The internal implementation of reading such comments is also different. Someone uses regular expressions, someone uses even more primitive string processing tools, and someone uses full-fledged parsers, including the Python parser or even written from scratch.
4747

4848
As a result, as a user, you need to remember the rules by which comments are written for each specific tool. And at the same time, you can't be sure that things like double comments (when you want to leave 2 comments for different tools in one line of code) will work in principle. And as the creator of such tools, you are faced with a seemingly simple task — just to read a comment — and find out for yourself that it suddenly turns out to be quite difficult, and there are many possible mistakes.
4949

50-
This is exactly the problem that this library solves. It describes a simple and intuitive standard for action comments, and also offers a ready-made parser that creators of other tools can use. The standard offered by this library is based entirely on a subset of the Python syntax and can be easily reimplemented even if you do not want to use this library directly.
50+
This is exactly the problem that this library solves. It describes a [simple and intuitive standard](https://xkcd.com/927/) for action comments, and also offers a ready-made parser that creators of other tools can use. The standard offered by this library is based entirely on a subset of the Python syntax and can be easily reimplemented even if you do not want to use this library directly.
5151

5252

5353
## The language
@@ -163,6 +163,30 @@ print(parse('key: action # other_key: other_action', ['key', 'other_key']))
163163
#> [ParsedComment(key='key', command='action', arguments=[]), ParsedComment(key='other_key', command='other_action', arguments=[])]
164164
```
165165

166+
Well, now we can read the comments. But what if we want to record? There is another function for this: `insert()`:
167+
168+
```python
169+
from metacode import insert, ParsedComment
170+
```
171+
172+
You send the comment you want to insert there, as well as the current comment (empty if there is no comment, or starting with # if there is), and you get a ready-made new comment text:
173+
174+
```python
175+
print(insert(ParsedComment(key='key', command='command', arguments=['lol', 'lol-kek']), ''))
176+
# key: command[lol, 'lol-kek']
177+
print(insert(ParsedComment(key='key', command='command', arguments=['lol', 'lol-kek']), '# some existing text'))
178+
# key: command[lol, 'lol-kek'] # some existing text
179+
```
180+
181+
As you can see, our comment is inserted before the existing comment. However, you can do the opposite:
182+
183+
```python
184+
print(insert(ParsedComment(key='key', command='command', arguments=['lol', 'lol-kek']), '# some existing text', at_end=True))
185+
# some existing text # key: command[lol, 'lol-kek']
186+
```
187+
188+
> ⚠️ Be careful: AST nodes can be read, but cannot be written.
189+
166190

167191
## What about other languages?
168192

metacode/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from metacode.errors import (
2-
UnknownArgumentTypeError as UnknownArgumentTypeError,
3-
)
4-
from metacode.parsing import ParsedComment as ParsedComment
1+
from metacode.building import build as build
2+
from metacode.building import insert as insert
3+
from metacode.comment import ParsedComment as ParsedComment
4+
from metacode.errors import UnknownArgumentTypeError as UnknownArgumentTypeError
55
from metacode.parsing import parse as parse

metacode/building.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from ast import AST
2+
3+
from metacode.comment import ParsedComment
4+
from metacode.typing import EllipsisType # type: ignore[attr-defined]
5+
6+
7+
def build(comment: ParsedComment) -> str:
8+
if not comment.key.isidentifier():
9+
raise ValueError('The key must be valid Python identifier.')
10+
if not comment.command.isidentifier():
11+
raise ValueError('The command must be valid Python identifier.')
12+
13+
result = f'# {comment.key}: {comment.command}'
14+
15+
if comment.arguments:
16+
arguments_representations = []
17+
18+
for argument in comment.arguments:
19+
if isinstance(argument, AST):
20+
raise TypeError('AST nodes are read-only and cannot be written to.')
21+
if isinstance(argument, EllipsisType):
22+
arguments_representations.append('...')
23+
elif isinstance(argument, str) and argument.isidentifier():
24+
arguments_representations.append(argument)
25+
else:
26+
arguments_representations.append(repr(argument))
27+
28+
result += f'[{", ".join(arguments_representations)}]'
29+
30+
return result
31+
32+
33+
def insert(comment: ParsedComment, existing_comment: str, at_end: bool = False) -> str:
34+
if not existing_comment:
35+
return build(comment)
36+
37+
if not existing_comment.lstrip().startswith('#'):
38+
raise ValueError('The existing part of the comment should start with a #.')
39+
40+
if at_end:
41+
if existing_comment.endswith(' '):
42+
return existing_comment + build(comment)
43+
return f'{existing_comment} {build(comment)}'
44+
45+
if existing_comment.startswith(' '):
46+
return f'{build(comment)}{existing_comment}'
47+
return f'{build(comment)} {existing_comment}'

metacode/comment.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from dataclasses import dataclass
2+
3+
from metacode.typing import Arguments
4+
5+
6+
@dataclass
7+
class ParsedComment:
8+
key: str
9+
command: str
10+
arguments: Arguments

metacode/parsing.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
11
from ast import AST, AnnAssign, BinOp, Constant, Index, Name, Sub, Subscript, Tuple
22
from ast import parse as ast_parse
3-
from dataclasses import dataclass
43
from typing import Generator, List, Optional, Union
54

6-
# TODO: delete this catch block and "type: ignore" if minimum supported version of Python is > 3.9.
7-
try:
8-
from types import EllipsisType # type: ignore[attr-defined, unused-ignore]
9-
except ImportError: # pragma: no cover
10-
EllipsisType = type(...) # type: ignore[misc, unused-ignore]
11-
125
from libcst import SimpleStatementLine
136
from libcst import parse_module as cst_parse
147

8+
from metacode.comment import ParsedComment
159
from metacode.errors import UnknownArgumentTypeError
16-
17-
18-
@dataclass
19-
class ParsedComment:
20-
key: str
21-
command: str
22-
arguments: List[Optional[Union[str, int, float, complex, bool, EllipsisType, AST]]]
10+
from metacode.typing import Arguments
2311

2412

2513
def get_right_part(comment: str) -> str:
@@ -57,7 +45,7 @@ def get_candidates(comment: str) -> Generator[ParsedComment, None, None]:
5745
assign = parsed_ast.body[0]
5846
key = assign.target.id # type: ignore[union-attr]
5947

60-
arguments: List[Optional[Union[str, int, float, complex, bool, EllipsisType, AST]]] = []
48+
arguments: Arguments = []
6149
if isinstance(assign.annotation, Name):
6250
command = assign.annotation.id
6351

metacode/typing.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from ast import AST
2+
from typing import List, Optional, Union
3+
4+
# TODO: delete this catch blocks and "type: ignore" if minimum supported version of Python is > 3.9.
5+
try:
6+
from typing import TypeAlias # type: ignore[attr-defined, unused-ignore]
7+
except ImportError: # pragma: no cover
8+
from typing_extensions import TypeAlias
9+
10+
try:
11+
from types import EllipsisType # type: ignore[attr-defined, unused-ignore]
12+
except ImportError: # pragma: no cover
13+
EllipsisType = type(...) # type: ignore[misc, unused-ignore]
14+
15+
16+
Argument: TypeAlias = Union[str, int, float, complex, bool, EllipsisType, AST]
17+
Arguments: TypeAlias = List[Optional[Argument]]

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "metacode"
7-
version = "0.0.3"
7+
version = "0.0.4"
88
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
9-
description = 'The standard language for machine-readable code comments'
9+
description = 'A standard language for machine-readable code comments'
1010
readme = "README.md"
1111
requires-python = ">=3.8"
12-
dependencies = ["libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'"]
12+
dependencies = ["libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'", "typing_extensions ; python_version <= '3.9'"]
1313
classifiers = [
1414
"Operating System :: OS Independent",
1515
'Operating System :: MacOS :: MacOS X',

tests/test_building.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from ast import Name
2+
3+
import pytest
4+
from full_match import match
5+
6+
from metacode import ParsedComment, build, insert
7+
8+
9+
def test_run_build_with_wrong_key_or_action():
10+
with pytest.raises(ValueError, match=match('The key must be valid Python identifier.')):
11+
build(ParsedComment(
12+
key='123',
13+
command='action',
14+
arguments=[],
15+
))
16+
17+
with pytest.raises(ValueError, match=match('The command must be valid Python identifier.')):
18+
build(ParsedComment(
19+
key='key',
20+
command='123',
21+
arguments=[],
22+
))
23+
24+
25+
def test_build_ast():
26+
with pytest.raises(TypeError, match=match('AST nodes are read-only and cannot be written to.')):
27+
build(ParsedComment(
28+
key='key',
29+
command='command',
30+
arguments=[Name()],
31+
))
32+
33+
34+
def test_create_simple_comment():
35+
assert build(ParsedComment(
36+
key='key',
37+
command='command',
38+
arguments=[],
39+
)) == '# key: command'
40+
41+
42+
def test_create_difficult_comment():
43+
assert build(ParsedComment(
44+
key='key',
45+
command='command',
46+
arguments=[1],
47+
)) == '# key: command[1]'
48+
49+
assert build(ParsedComment(
50+
key='key',
51+
command='command',
52+
arguments=[1, 2, 3],
53+
)) == '# key: command[1, 2, 3]'
54+
55+
assert build(ParsedComment(
56+
key='key',
57+
command='command',
58+
arguments=['build'],
59+
)) == '# key: command[build]'
60+
61+
assert build(ParsedComment(
62+
key='key',
63+
command='command',
64+
arguments=['build', 'build'],
65+
)) == '# key: command[build, build]'
66+
67+
assert build(ParsedComment(
68+
key='key',
69+
command='command',
70+
arguments=['lol-kek'],
71+
)) == "# key: command['lol-kek']"
72+
73+
assert build(ParsedComment(
74+
key='key',
75+
command='command',
76+
arguments=['lol-kek', 'lol-kek-chedurek'],
77+
)) == "# key: command['lol-kek', 'lol-kek-chedurek']"
78+
79+
assert build(ParsedComment(
80+
key='key',
81+
command='command',
82+
arguments=[...],
83+
)) == "# key: command[...]"
84+
85+
assert build(ParsedComment(
86+
key='key',
87+
command='command',
88+
arguments=[..., ...],
89+
)) == "# key: command[..., ...]"
90+
91+
assert build(ParsedComment(
92+
key='key',
93+
command='command',
94+
arguments=[1.5],
95+
)) == "# key: command[1.5]"
96+
97+
assert build(ParsedComment(
98+
key='key',
99+
command='command',
100+
arguments=[1.5, 3.0],
101+
)) == "# key: command[1.5, 3.0]"
102+
103+
assert build(ParsedComment(
104+
key='key',
105+
command='command',
106+
arguments=[5j],
107+
)) == "# key: command[5j]"
108+
109+
assert build(ParsedComment(
110+
key='key',
111+
command='command',
112+
arguments=[None],
113+
)) == "# key: command[None]"
114+
115+
assert build(ParsedComment(
116+
key='key',
117+
command='command',
118+
arguments=[True],
119+
)) == "# key: command[True]"
120+
121+
assert build(ParsedComment(
122+
key='key',
123+
command='command',
124+
arguments=[False],
125+
)) == "# key: command[False]"
126+
127+
assert build(ParsedComment(
128+
key='key',
129+
command='command',
130+
arguments=[1, 2, 3, 1.5, 3.0, 5j, 1000j, 'build', 'build2', 'lol-kek', 'lol-kek-chedurek', None, True, False, ...],
131+
)) == "# key: command[1, 2, 3, 1.5, 3.0, 5j, 1000j, build, build2, 'lol-kek', 'lol-kek-chedurek', None, True, False, ...]"
132+
133+
134+
def test_insert_to_strange_comment():
135+
with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
136+
insert(ParsedComment(key='key', command='command', arguments=[]), 'kek', at_end=True)
137+
138+
with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
139+
insert(ParsedComment(key=' key', command='command', arguments=[]), 'kek', at_end=True)
140+
141+
with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
142+
insert(ParsedComment(key=' key', command='command', arguments=[]), 'kek')
143+
144+
with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
145+
insert(ParsedComment(key='key', command='command', arguments=[]), 'kek')
146+
147+
148+
def test_insert_at_begin_to_empty():
149+
comment = ParsedComment(
150+
key='key',
151+
command='command',
152+
arguments=['build'],
153+
)
154+
155+
assert insert(comment, '') == build(comment)
156+
157+
158+
def test_insert_at_end_to_empty():
159+
comment = ParsedComment(
160+
key='key',
161+
command='command',
162+
arguments=['build'],
163+
)
164+
165+
assert insert(comment, '', at_end=True) == build(comment)
166+
167+
168+
def test_insert_at_begin_to_not_empty():
169+
comment = ParsedComment(
170+
key='key',
171+
command='command',
172+
arguments=['build'],
173+
)
174+
175+
assert insert(comment, '# kek') == build(comment) + ' # kek'
176+
assert insert(comment, ' # kek') == build(comment) + ' # kek'
177+
assert insert(comment, build(comment)) == build(comment) + ' ' + build(comment)
178+
179+
180+
def test_insert_at_end_to_not_empty():
181+
comment = ParsedComment(
182+
key='key',
183+
command='command',
184+
arguments=['build'],
185+
)
186+
187+
assert insert(comment, '# kek', at_end=True) == '# kek ' + build(comment)
188+
assert insert(comment, '# kek ', at_end=True) == '# kek ' + build(comment)
189+
assert insert(comment, build(comment), at_end=True) == build(comment) + ' ' + build(comment)

0 commit comments

Comments
 (0)