Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,20 @@ Many source code analysis tools use comments in a special format to mark it up.

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:

- [`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`.
- [`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`.
- [`Mypy`](https://discuss.python.org/t/ignore-mypy-specific-type-errors/58535) — `# type: ignore`, `type: ignore[error-code]`.
- [`Coverage`](https://coverage.readthedocs.io/en/7.13.0/excluding.html#default-exclusions) — `# pragma: no cover`, `# pragma: no branch`.
- [`Isort`](https://pycqa.github.io/isort/docs/configuration/action_comments.html) — `# isort: skip`, `# isort: off`.
- [`Bandit`](https://bandit.readthedocs.io/en/latest/config.html#suppressing-individual-lines) — `# nosec`.
- [`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`.
- [`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`.
- [`Mypy`](https://discuss.python.org/t/ignore-mypy-specific-type-errors/58535) —> `# type: ignore`, `type: ignore[error-code]`.
- [`Coverage`](https://coverage.readthedocs.io/en/7.13.0/excluding.html#default-exclusions) —> `# pragma: no cover`, `# pragma: no branch`.
- [`Isort`](https://pycqa.github.io/isort/docs/configuration/action_comments.html) —> `# isort: skip`, `# isort: off`.
- [`Bandit`](https://bandit.readthedocs.io/en/latest/config.html#suppressing-individual-lines) —> `# nosec`.

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

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.

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.

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.
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.


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

Well, now we can read the comments. But what if we want to record? There is another function for this: `insert()`:

```python
from metacode import insert, ParsedComment
```

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:

```python
print(insert(ParsedComment(key='key', command='command', arguments=['lol', 'lol-kek']), ''))
# key: command[lol, 'lol-kek']
print(insert(ParsedComment(key='key', command='command', arguments=['lol', 'lol-kek']), '# some existing text'))
# key: command[lol, 'lol-kek'] # some existing text
```

As you can see, our comment is inserted before the existing comment. However, you can do the opposite:

```python
print(insert(ParsedComment(key='key', command='command', arguments=['lol', 'lol-kek']), '# some existing text', at_end=True))
# some existing text # key: command[lol, 'lol-kek']
```

> ⚠️ Be careful: AST nodes can be read, but cannot be written.


## What about other languages?

Expand Down
8 changes: 4 additions & 4 deletions metacode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from metacode.errors import (
UnknownArgumentTypeError as UnknownArgumentTypeError,
)
from metacode.parsing import ParsedComment as ParsedComment
from metacode.building import build as build
from metacode.building import insert as insert
from metacode.comment import ParsedComment as ParsedComment
from metacode.errors import UnknownArgumentTypeError as UnknownArgumentTypeError
from metacode.parsing import parse as parse
47 changes: 47 additions & 0 deletions metacode/building.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from ast import AST

from metacode.comment import ParsedComment
from metacode.typing import EllipsisType # type: ignore[attr-defined]


def build(comment: ParsedComment) -> str:
if not comment.key.isidentifier():
raise ValueError('The key must be valid Python identifier.')
if not comment.command.isidentifier():
raise ValueError('The command must be valid Python identifier.')

result = f'# {comment.key}: {comment.command}'

if comment.arguments:
arguments_representations = []

for argument in comment.arguments:
if isinstance(argument, AST):
raise TypeError('AST nodes are read-only and cannot be written to.')
if isinstance(argument, EllipsisType):
arguments_representations.append('...')
elif isinstance(argument, str) and argument.isidentifier():
arguments_representations.append(argument)
else:
arguments_representations.append(repr(argument))

result += f'[{", ".join(arguments_representations)}]'

return result


def insert(comment: ParsedComment, existing_comment: str, at_end: bool = False) -> str:
if not existing_comment:
return build(comment)

if not existing_comment.lstrip().startswith('#'):
raise ValueError('The existing part of the comment should start with a #.')

if at_end:
if existing_comment.endswith(' '):
return existing_comment + build(comment)
return f'{existing_comment} {build(comment)}'

if existing_comment.startswith(' '):
return f'{build(comment)}{existing_comment}'
return f'{build(comment)} {existing_comment}'
10 changes: 10 additions & 0 deletions metacode/comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from dataclasses import dataclass

from metacode.typing import Arguments


@dataclass
class ParsedComment:
key: str
command: str
arguments: Arguments
18 changes: 3 additions & 15 deletions metacode/parsing.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
from ast import AST, AnnAssign, BinOp, Constant, Index, Name, Sub, Subscript, Tuple
from ast import parse as ast_parse
from dataclasses import dataclass
from typing import Generator, List, Optional, Union

# TODO: delete this catch block and "type: ignore" if minimum supported version of Python is > 3.9.
try:
from types import EllipsisType # type: ignore[attr-defined, unused-ignore]
except ImportError: # pragma: no cover
EllipsisType = type(...) # type: ignore[misc, unused-ignore]

from libcst import SimpleStatementLine
from libcst import parse_module as cst_parse

from metacode.comment import ParsedComment
from metacode.errors import UnknownArgumentTypeError


@dataclass
class ParsedComment:
key: str
command: str
arguments: List[Optional[Union[str, int, float, complex, bool, EllipsisType, AST]]]
from metacode.typing import Arguments


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

arguments: List[Optional[Union[str, int, float, complex, bool, EllipsisType, AST]]] = []
arguments: Arguments = []
if isinstance(assign.annotation, Name):
command = assign.annotation.id

Expand Down
17 changes: 17 additions & 0 deletions metacode/typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from ast import AST
from typing import List, Optional, Union

# TODO: delete this catch blocks and "type: ignore" if minimum supported version of Python is > 3.9.
try:
from typing import TypeAlias # type: ignore[attr-defined, unused-ignore]
except ImportError: # pragma: no cover
from typing_extensions import TypeAlias

try:
from types import EllipsisType # type: ignore[attr-defined, unused-ignore]
except ImportError: # pragma: no cover
EllipsisType = type(...) # type: ignore[misc, unused-ignore]


Argument: TypeAlias = Union[str, int, float, complex, bool, EllipsisType, AST]
Arguments: TypeAlias = List[Optional[Argument]]
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"

[project]
name = "metacode"
version = "0.0.3"
version = "0.0.4"
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
description = 'The standard language for machine-readable code comments'
description = 'A standard language for machine-readable code comments'
readme = "README.md"
requires-python = ">=3.8"
dependencies = ["libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'"]
dependencies = ["libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'", "typing_extensions ; python_version <= '3.9'"]
classifiers = [
"Operating System :: OS Independent",
'Operating System :: MacOS :: MacOS X',
Expand Down
189 changes: 189 additions & 0 deletions tests/test_building.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
from ast import Name

import pytest
from full_match import match

from metacode import ParsedComment, build, insert


def test_run_build_with_wrong_key_or_action():
with pytest.raises(ValueError, match=match('The key must be valid Python identifier.')):
build(ParsedComment(
key='123',
command='action',
arguments=[],
))

with pytest.raises(ValueError, match=match('The command must be valid Python identifier.')):
build(ParsedComment(
key='key',
command='123',
arguments=[],
))


def test_build_ast():
with pytest.raises(TypeError, match=match('AST nodes are read-only and cannot be written to.')):
build(ParsedComment(
key='key',
command='command',
arguments=[Name()],
))


def test_create_simple_comment():
assert build(ParsedComment(
key='key',
command='command',
arguments=[],
)) == '# key: command'


def test_create_difficult_comment():
assert build(ParsedComment(
key='key',
command='command',
arguments=[1],
)) == '# key: command[1]'

assert build(ParsedComment(
key='key',
command='command',
arguments=[1, 2, 3],
)) == '# key: command[1, 2, 3]'

assert build(ParsedComment(
key='key',
command='command',
arguments=['build'],
)) == '# key: command[build]'

assert build(ParsedComment(
key='key',
command='command',
arguments=['build', 'build'],
)) == '# key: command[build, build]'

assert build(ParsedComment(
key='key',
command='command',
arguments=['lol-kek'],
)) == "# key: command['lol-kek']"

assert build(ParsedComment(
key='key',
command='command',
arguments=['lol-kek', 'lol-kek-chedurek'],
)) == "# key: command['lol-kek', 'lol-kek-chedurek']"

assert build(ParsedComment(
key='key',
command='command',
arguments=[...],
)) == "# key: command[...]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[..., ...],
)) == "# key: command[..., ...]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[1.5],
)) == "# key: command[1.5]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[1.5, 3.0],
)) == "# key: command[1.5, 3.0]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[5j],
)) == "# key: command[5j]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[None],
)) == "# key: command[None]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[True],
)) == "# key: command[True]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[False],
)) == "# key: command[False]"

assert build(ParsedComment(
key='key',
command='command',
arguments=[1, 2, 3, 1.5, 3.0, 5j, 1000j, 'build', 'build2', 'lol-kek', 'lol-kek-chedurek', None, True, False, ...],
)) == "# key: command[1, 2, 3, 1.5, 3.0, 5j, 1000j, build, build2, 'lol-kek', 'lol-kek-chedurek', None, True, False, ...]"


def test_insert_to_strange_comment():
with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
insert(ParsedComment(key='key', command='command', arguments=[]), 'kek', at_end=True)

with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
insert(ParsedComment(key=' key', command='command', arguments=[]), 'kek', at_end=True)

with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
insert(ParsedComment(key=' key', command='command', arguments=[]), 'kek')

with pytest.raises(ValueError, match=match('The existing part of the comment should start with a #.')):
insert(ParsedComment(key='key', command='command', arguments=[]), 'kek')


def test_insert_at_begin_to_empty():
comment = ParsedComment(
key='key',
command='command',
arguments=['build'],
)

assert insert(comment, '') == build(comment)


def test_insert_at_end_to_empty():
comment = ParsedComment(
key='key',
command='command',
arguments=['build'],
)

assert insert(comment, '', at_end=True) == build(comment)


def test_insert_at_begin_to_not_empty():
comment = ParsedComment(
key='key',
command='command',
arguments=['build'],
)

assert insert(comment, '# kek') == build(comment) + ' # kek'
assert insert(comment, ' # kek') == build(comment) + ' # kek'
assert insert(comment, build(comment)) == build(comment) + ' ' + build(comment)


def test_insert_at_end_to_not_empty():
comment = ParsedComment(
key='key',
command='command',
arguments=['build'],
)

assert insert(comment, '# kek', at_end=True) == '# kek ' + build(comment)
assert insert(comment, '# kek ', at_end=True) == '# kek ' + build(comment)
assert insert(comment, build(comment), at_end=True) == build(comment) + ' ' + build(comment)
Loading