From e7687e99288f013400293236b113a1010d9a57ee Mon Sep 17 00:00:00 2001 From: Bartosz Sokorski Date: Mon, 5 Sep 2022 16:47:36 +0200 Subject: [PATCH] refactor(parser): delete doc-based command configuration parser (#239) * refactor(parser): delete doc-based command configuration parser * refactor(parser): delete doc-based command configuration parser * Update README and CHANGELOG * Apply suggestions from code review Co-authored-by: Branch Vincent --- CHANGELOG.md | 3 +- README.md | 190 ++++++++---------- src/cleo/commands/command.py | 31 --- src/cleo/parser.py | 185 ----------------- .../fixtures/command_with_colons.py | 10 +- .../completion/fixtures/hello_command.py | 18 +- tests/commands/test_command.py | 8 +- tests/fixtures/inherited_command.py | 7 +- tests/fixtures/signature_command.py | 18 +- tests/test_parser.py | 102 ---------- tests/testers/test_command_tester.py | 10 +- 11 files changed, 116 insertions(+), 466 deletions(-) delete mode 100644 src/cleo/parser.py delete mode 100644 tests/test_parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4728ca81..1532a35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ - Replaced `Terminal` class with `shutil.get_terminal_size()` from standard library [#175](https://github.com/python-poetry/cleo/pull/175). - +- Removed doc comment-based command configuration notation +[#239](https://github.com/python-poetry/cleo/pull/175). ## [0.8.1] - 2020-04-17 diff --git a/README.md b/README.md index c7d65cab..cf323941 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,26 @@ To make a command that greets you from the command line, create ```python from cleo.commands.command import Command - +from cleo.helpers import argument, option class GreetCommand(Command): - """ - Greets someone - - greet - {name? : Who do you want to greet?} - {--y|yell : If set, the task will yell in uppercase letters} - """ + name = "greet" + description = "Greets someone" + arguments = [ + argument( + "name", + description="Who do you want to greet?", + optional=True + ) + ] + options = [ + option( + "yell", + "y", + description="If set, the task will yell in uppercase letters", + flag=True + ) + ] def handle(self): name = self.argument("name") @@ -84,35 +94,6 @@ This prints: HELLO JOHN ``` -As you may have already seen, Cleo uses the command docstring to -determine the command definition. The docstring must be in the following -form : - -```python -""" -Command description - -Command signature -""" -``` - -The signature being in the following form: - -```python -""" -command:name {argument : Argument description} {--option : Option description} -""" -``` - -The signature can span multiple lines. - -```python -""" -command:name - {argument : Argument description} - {--option : Option description} -""" -``` ### Coloring the Output @@ -211,14 +192,27 @@ argument to the command and make the `name` argument required: ```python class GreetCommand(Command): - """ - Greets someone - - greet - {name : Who do you want to greet?} - {last_name? : Your last name?} - {--y|yell : If set, the task will yell in uppercase letters} - """ + name = "greet" + description = "Greets someone" + arguments = [ + argument( + "name", + description="Who do you want to greet?", + ), + argument( + "last_name", + description="Your last name?", + optional=True + ) + ] + options = [ + option( + "yell", + "y", + description="If set, the task will yell in uppercase letters", + flag=True + ) + ] ``` You now have access to a `last_name` argument in your command: @@ -242,13 +236,23 @@ the end of the argument list: ```python class GreetCommand(Command): - """ - Greets someone - - greet - {names* : Who do you want to greet?} - {--y|yell : If set, the task will yell in uppercase letters} - """ + name = "greet" + description = "Greets someone" + arguments = [ + argument( + "names", + description="Who do you want to greet?", + multiple=True + ) + ] + options = [ + option( + "yell", + "y", + description="If set, the task will yell in uppercase letters", + flag=True + ) + ] ``` To use this, just specify as many names as you want: @@ -265,34 +269,6 @@ if names: text = "Hello " + ", ".join(names) ``` -There are 3 argument variants you can use: - -| Mode | Notation | Value | -| -------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Required | none (just write the argument name) | The argument is required | -| Optional | `argument?` | The argument is optional and therefore can be omitted | -| List | `argument*` | The argument can contain an indefinite number of arguments and must be used at the end of the argument list | - -You can combine them like this: - -```python -class GreetCommand(Command): - """ - Greets someone - - greet - {names?* : Who do you want to greet?} - {--y|yell : If set, the task will yell in uppercase letters} - """ -``` - -If you want to set a default value, you can it like so: - -```text -argument=default -``` - -The argument will then be considered optional. ### Using Options @@ -312,20 +288,34 @@ how many times in a row the message should be printed: ```python class GreetCommand(Command): - """ - Greets someone - - greet - {name? : Who do you want to greet?} - {--y|yell : If set, the task will yell in uppercase letters} - {--iterations=1 : How many times should the message be printed?} - """ + name = "greet" + description = "Greets someone" + arguments = [ + argument( + "name", + description="Who do you want to greet?", + optional=True + ) + ] + options = [ + option( + "yell", + "y", + description="If set, the task will yell in uppercase letters", + flag=True + ), + option( + "iterations", + description="How many times should the message be printed?", + default=1 + ) + ] ``` Next, use this in the command to print the message multiple times: ```python -for _ in range(0, int(self.option("iterations"))): +for _ in range(int(self.option("iterations"))): self.line(text) ``` @@ -348,28 +338,6 @@ $ python application.py greet John --iterations=5 --yell $ python application.py greet John --yell --iterations=5 ``` -There are 4 option variants you can use: - -| Option | Notation | Value | -| -------------- | ------------ | ----------------------------------------------------------------------------------- | -| List | `--option=*` | This option accepts multiple values (e.g. `--dir=/foo --dir=/bar`) | -| Flag | `--option` | Do not accept input for this option (e.g. `--yell`) | -| Requires value | `--option=` | This value is required (e.g. `--iterations=5`), the option itself is still optional | -| Optional value | `--option=?` | This option may or may not have a value (e.g. `--yell` or `--yell=loud`) | - -You can combine them like this: - -```python -class GreetCommand(Command): - """ - Greets someone - - greet - {name? : Who do you want to greet?} - {--y|yell : If set, the task will yell in uppercase letters} - {--iterations=?*1 : How many times should the message be printed?} - """ -``` ### Testing Commands diff --git a/src/cleo/commands/command.py b/src/cleo/commands/command.py index 247b0cf0..10bb5342 100644 --- a/src/cleo/commands/command.py +++ b/src/cleo/commands/command.py @@ -1,8 +1,5 @@ from __future__ import annotations -import inspect -import re - from typing import TYPE_CHECKING from typing import Any from typing import ContextManager @@ -13,7 +10,6 @@ from cleo.io.inputs.string_input import StringInput from cleo.io.null_io import NullIO from cleo.io.outputs.output import Verbosity -from cleo.parser import Parser from cleo.ui.table_separator import TableSeparator @@ -47,17 +43,6 @@ def io(self) -> IO: return self._io def configure(self) -> None: - if not self.name: - doc = self.__doc__ - - if not doc: - for base in inspect.getmro(self.__class__): - if base.__doc__ is not None: - doc = base.__doc__ - break - - if doc: - self._parse_doc(doc) for argument in self.arguments: self._definition.add_argument(argument) @@ -65,22 +50,6 @@ def configure(self) -> None: for option in self.options: self._definition.add_option(option) - def _parse_doc(self, doc: str) -> None: - lines = doc.strip().split("\n", 1) - if len(lines) > 1: - self.description = lines[0].strip() - signature = re.sub(r"\s{2,}", " ", lines[1].strip()) - definition = Parser.parse(signature) - self.name = definition["name"] - - for argument in definition["arguments"]: - self._definition.add_argument(argument) - - for option in definition["options"]: - self._definition.add_option(option) - else: - self.description = lines[0].strip() - def execute(self, io: IO) -> int: self._io = io diff --git a/src/cleo/parser.py b/src/cleo/parser.py deleted file mode 100644 index 2a042ec6..00000000 --- a/src/cleo/parser.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -import os -import re - -from typing import Any - -from cleo.io.inputs.argument import Argument -from cleo.io.inputs.option import Option - - -class Parser: - @classmethod - def parse(cls, expression: str) -> dict[str, Any]: - """ - Parse the given console command definition into a dict. - """ - parsed: dict[str, Any] = {"name": None, "arguments": [], "options": []} - - if not expression.strip(): - raise ValueError("Console command signature is empty.") - - expression = expression.replace(os.linesep, "") - - matches = re.match(r"[^\s]+", expression) - - if not matches: - raise ValueError("Unable to determine command name from signature.") - - name = matches.group(0) - parsed["name"] = name - - tokens = re.findall(r"\{\s*(.*?)\s*\}", expression) - - if tokens: - parsed.update(cls._parameters(tokens)) - - return parsed - - @classmethod - def _parameters(cls, tokens: list[str]) -> dict[str, Any]: - """ - Extract all of the parameters from the tokens. - """ - arguments = [] - options = [] - - for token in tokens: - if not token.startswith("--"): - arguments.append(cls._parse_argument(token)) - else: - options.append(cls._parse_option(token)) - - return {"arguments": arguments, "options": options} - - @classmethod - def _parse_argument(cls, token: str) -> Argument: - """ - Parse an argument expression. - """ - description = "" - - if " : " in token: - token, description = tuple(token.split(" : ", 2)) - - token = token.strip() - - description = description.strip() - - # Checking validator: - matches = re.match(r"(.*)\((.*?)\)", token) - if matches: - token = matches.group(1).strip() - - if token.endswith("?*"): - return Argument( - token.rstrip("?*"), - required=False, - is_list=True, - description=description, - ) - elif token.endswith("*"): - return Argument( - token.rstrip("*"), - is_list=True, - description=description, - ) - elif token.endswith("?"): - return Argument( - token.rstrip("?"), - required=False, - description=description, - ) - - matches = re.match(r"(.+)=(.+)", token) - if matches: - return Argument( - matches.group(1), - required=False, - description=description, - default=matches.group(2), - ) - - return Argument( - token, - description=description, - ) - - @classmethod - def _parse_option(cls, token: str) -> Option: - """ - Parse an option expression. - """ - description = "" - - if " : " in token: - token, description = tuple(token.split(" : ", 2)) - - token = token.strip() - - description = description.strip() - - # Checking validator: - matches = re.match(r"(.*)\((.*?)\)", token) - if matches: - token = matches.group(1).strip() - - shortcut = None - - matches_list = re.split(r"\s*\|\s*", token, 2) - - if len(matches_list) > 1: - shortcut = matches_list[0].lstrip("-") - token = matches_list[1] - else: - token = token.lstrip("-") - - default = None - flag = True - requires_value = False - is_list = False - - if token.endswith("=*"): - flag = False - is_list = True - requires_value = True - token = token.rstrip("=*") - elif token.endswith("=?*"): - flag = False - is_list = True - token = token.rstrip("=?*") - elif token.endswith("=?"): - flag = False - token = token.rstrip("=?") - elif token.endswith("="): - flag = False - requires_value = True - token = token.rstrip("=") - else: - matches = re.match(r"(.+)(=[?*]*)(.+)", token) - if matches: - flag = False - token = matches.group(1) - operator = matches.group(2) - default = matches.group(3) - - if operator == "=*": - requires_value = True - is_list = True - elif operator == "=?*": - is_list = True - elif operator == "=?": - requires_value = False - elif operator == "=": - requires_value = True - - return Option( - token, - shortcut, - flag=flag, - requires_value=requires_value, - is_list=is_list, - description=description, - default=default, - ) diff --git a/tests/commands/completion/fixtures/command_with_colons.py b/tests/commands/completion/fixtures/command_with_colons.py index 4f3931f3..b2a52a32 100644 --- a/tests/commands/completion/fixtures/command_with_colons.py +++ b/tests/commands/completion/fixtures/command_with_colons.py @@ -1,12 +1,10 @@ from __future__ import annotations from cleo.commands.command import Command +from cleo.helpers import option class CommandWithColons(Command): - """ - Test. - - command:with:colons - { --goodbye } - """ + name = "command:with:colons" + options = [option("goodbye")] + description = "Test." diff --git a/tests/commands/completion/fixtures/hello_command.py b/tests/commands/completion/fixtures/hello_command.py index 2a92af7d..37228294 100644 --- a/tests/commands/completion/fixtures/hello_command.py +++ b/tests/commands/completion/fixtures/hello_command.py @@ -1,13 +1,17 @@ from __future__ import annotations from cleo.commands.command import Command +from cleo.helpers import option class HelloCommand(Command): - """ - Complete me please. - - hello - { --dangerous-option= : This $hould be `escaped`. } - { --option-without-description } - """ + name = "hello" + options = [ + option( + "dangerous-option", + flag=False, + description="This $hould be `escaped`.", + ), + option("option-without-description"), + ] + description = "Complete me please." diff --git a/tests/commands/test_command.py b/tests/commands/test_command.py index 8791cdd4..3f3c6286 100644 --- a/tests/commands/test_command.py +++ b/tests/commands/test_command.py @@ -9,12 +9,8 @@ class MyCommand(Command): - """ - Command testing. - - test - {action : The action to execute.} - """ + name = "test" + arguments = [argument("action", description="The action to execute.")] def handle(self) -> int: action = self.argument("action") diff --git a/tests/fixtures/inherited_command.py b/tests/fixtures/inherited_command.py index a8ae70ac..bccdd4a6 100644 --- a/tests/fixtures/inherited_command.py +++ b/tests/fixtures/inherited_command.py @@ -4,11 +4,8 @@ class ParentCommand(Command): - """ - Parent Command. - - parent - """ + name = "parent" + description = "Parent Command." class ChildCommand(ParentCommand): diff --git a/tests/fixtures/signature_command.py b/tests/fixtures/signature_command.py index 526bbacc..a67be8f7 100644 --- a/tests/fixtures/signature_command.py +++ b/tests/fixtures/signature_command.py @@ -1,16 +1,22 @@ from __future__ import annotations from cleo.commands.command import Command +from cleo.helpers import argument +from cleo.helpers import option class SignatureCommand(Command): - """ - description - - signature:command {foo : Foo} {bar? : Bar} {--z|baz : Baz} {--Z|bazz : Bazz} - """ - + name = "signature:command" + options = [ + option("baz", "z", description="Baz"), + option("bazz", "Z", description="Bazz"), + ] + arguments = [ + argument("foo", description="Foo"), + argument("bar", description="Bar", optional=True), + ] help = "help" + description = "description" def handle(self) -> int: self.line("handle called") diff --git a/tests/test_parser.py b/tests/test_parser.py deleted file mode 100644 index e89acc04..00000000 --- a/tests/test_parser.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from cleo.parser import Parser - - -def test_basic_parameter_parsing() -> None: - results = Parser.parse("command:name") - - assert results["name"] == "command:name" - - results = Parser.parse("command:name {argument} {--option}") - - assert results["name"] == "command:name" - assert results["arguments"][0].name == "argument" - assert results["options"][0].name == "option" - assert results["options"][0].is_flag() - - results = Parser.parse("command:name {argument*} {--option=}") - - assert results["name"] == "command:name" - assert results["arguments"][0].name == "argument" - assert results["arguments"][0].is_required() - assert results["arguments"][0].is_list() - assert results["options"][0].name == "option" - assert results["options"][0].requires_value() - - results = Parser.parse("command:name {argument?*} {--option=*}") - - assert results["name"] == "command:name" - assert results["arguments"][0].name == "argument" - assert not results["arguments"][0].is_required() - assert results["arguments"][0].is_list() - assert results["options"][0].name == "option" - assert results["options"][0].is_list() - - results = Parser.parse( - "command:name {argument?* : The argument description.}" - " {--option=* : The option description.}" - ) - - assert results["name"] == "command:name" - assert results["arguments"][0].name == "argument" - assert results["arguments"][0].description == "The argument description." - assert not results["arguments"][0].is_required() - assert results["arguments"][0].is_list() - assert results["options"][0].name == "option" - assert results["options"][0].description == "The option description." - assert results["options"][0].is_list() - - results = Parser.parse( - "command:name " - "{argument?* : The argument description.} " - "{--option=* : The option description.}" - ) - - assert results["name"] == "command:name" - assert results["arguments"][0].name == "argument" - assert results["arguments"][0].description == "The argument description." - assert not results["arguments"][0].is_required() - assert results["arguments"][0].is_list() - assert results["options"][0].name == "option" - assert results["options"][0].description == "The option description." - assert results["options"][0].is_list() - - -def test_shortcut_name_parsing() -> None: - results = Parser.parse("command:name {--o|option}") - - assert results["name"] == "command:name" - assert results["options"][0].name == "option" - assert results["options"][0].shortcut == "o" - assert results["options"][0].is_flag() - - results = Parser.parse("command:name {--o|option=}") - - assert results["name"] == "command:name" - assert results["options"][0].name == "option" - assert results["options"][0].shortcut == "o" - assert results["options"][0].requires_value() - - results = Parser.parse("command:name {--o|option=*}") - - assert results["name"] == "command:name" - assert results["options"][0].name == "option" - assert results["options"][0].shortcut == "o" - assert results["options"][0].is_list() - - results = Parser.parse("command:name {--o|option=* : The option description.}") - - assert results["name"] == "command:name" - assert results["options"][0].name == "option" - assert results["options"][0].shortcut == "o" - assert results["options"][0].description == "The option description." - assert results["options"][0].is_list() - - results = Parser.parse("command:name " "{--o|option=* : The option description.}") - - assert results["name"] == "command:name" - assert results["options"][0].name == "option" - assert results["options"][0].shortcut == "o" - assert results["options"][0].description == "The option description." - assert results["options"][0].is_list() diff --git a/tests/testers/test_command_tester.py b/tests/testers/test_command_tester.py index 4bc28485..a752b5db 100644 --- a/tests/testers/test_command_tester.py +++ b/tests/testers/test_command_tester.py @@ -4,16 +4,14 @@ from cleo.application import Application from cleo.commands.command import Command +from cleo.helpers import argument from cleo.testers.command_tester import CommandTester class FooCommand(Command): - """ - Foo command - - foo - {foo : Foo argument} - """ + name = "foo" + description = "Foo command" + arguments = [argument("foo", description="Foo argument")] def handle(self) -> int: self.line(self.argument("foo"))