diff --git a/README.md b/README.md index ff931bb..4e908be 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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? diff --git a/metacode/__init__.py b/metacode/__init__.py index 550ffcd..277e362 100644 --- a/metacode/__init__.py +++ b/metacode/__init__.py @@ -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 diff --git a/metacode/building.py b/metacode/building.py new file mode 100644 index 0000000..9637caf --- /dev/null +++ b/metacode/building.py @@ -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}' diff --git a/metacode/comment.py b/metacode/comment.py new file mode 100644 index 0000000..a0a8f92 --- /dev/null +++ b/metacode/comment.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from metacode.typing import Arguments + + +@dataclass +class ParsedComment: + key: str + command: str + arguments: Arguments diff --git a/metacode/parsing.py b/metacode/parsing.py index 5c714fc..fcbc479 100644 --- a/metacode/parsing.py +++ b/metacode/parsing.py @@ -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: @@ -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 diff --git a/metacode/typing.py b/metacode/typing.py new file mode 100644 index 0000000..fc0baf9 --- /dev/null +++ b/metacode/typing.py @@ -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]] diff --git a/pyproject.toml b/pyproject.toml index a929bef..7a66f74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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', diff --git a/tests/test_building.py b/tests/test_building.py new file mode 100644 index 0000000..fb7f06b --- /dev/null +++ b/tests/test_building.py @@ -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)