diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f60ce3e..f515a4b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,10 +14,10 @@ jobs: steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} @@ -27,4 +27,5 @@ jobs: pip install --requirement requirements/ci.txt - name: Test with tox - run: tox + # Set pip index URL to override devpi server default on local + run: PIP_INDEX_URL=https://pypi.org/simple tox diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8e25888..deb4b41 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -23,6 +23,33 @@ Unreleased_ See also `latest documentation `_. +Added +..... + +* ⛏️ New ``--aaa-act-block-style`` option added with corresponding + ``aaa_act_block_style`` config value. Only default behaviour supported at + this stage, but makes room to fix the `Black formatting problems in issue + #200 `_. + +* 📕 New "Options and configuration" page added to documentation to support + `issue #200 `_. + +* ⛏️ New tox configuration added ``PIP_INDEX_URL`` pointed at locally running + `devpi server instance `_. + +* ⛏️ Faker added to test requirements to generate random data. + +Changed +....... + +* 📕 Previous documentation page "Controlling Flake8-AAA" contained information + on both code directives (``# noqa``, etc) and how to use the command line + tool. These have been split into two separate pages: "Directives" and + "Command line". + +* ⛏️ Documentation can be built locally with ``make docs`` recipe, but this has + been adjusted to call tox. + 0.14.0_ - 2023/03/01 -------------------- @@ -33,7 +60,7 @@ Added `_ fixes `issue #185 `_. -* 📕: AAA06 hash comment resolution added to docs. `Pull #208 +* 📕 AAA06 hash comment resolution added to docs. `Pull #208 `_ fixes `issue #193 `_. diff --git a/Makefile b/Makefile index b831c98..963a103 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ lintexamplespy38: .PHONY: docs docs: - $(MAKE) -C docs html + tox r -e py310-docs .PHONY: cmd cmd: @@ -73,7 +73,7 @@ signature: .PHONY: clean clean: - rm -rf dist build .tox .pytest_cache src/flake8_aaa.egg-info + rm -rf dist build .tox .pytest_cache src/flake8_aaa.egg-info docs/_build/ find . -name '*.pyc' -delete .PHONY: sdist diff --git a/configs/explicit_default.ini b/configs/explicit_default.ini new file mode 100644 index 0000000..e3c4a31 --- /dev/null +++ b/configs/explicit_default.ini @@ -0,0 +1,2 @@ +[flake8] +aaa_act_block_style = default diff --git a/docs/commands.rst b/docs/command_line.rst similarity index 69% rename from docs/commands.rst rename to docs/command_line.rst index 6c4cb43..96fc60e 100644 --- a/docs/commands.rst +++ b/docs/command_line.rst @@ -1,44 +1,5 @@ -Controlling Flake8-AAA -====================== - -In code -------- - -Flake8-AAA can be controlled using some special comments in your test code. - -Explicitly marking blocks -......................... - -One can set the act block explicitly using the ``# act`` comment. This is -necessary when there is no assignment possible. - -See :ref:`AAA01: no Act block found in test - Correct code 2 `. - - -Disabling Flake8-AAA selectively -................................ - -When invoked via Flake8, Flake8 will filter any errors raised when lines are -marked with the ``# noqa`` syntax. You can turn off all errors from Flake8-AAA -by marking a line with ``# noqa: AAA`` and other Flake8 errors will still be -returned. - -If you just want to ignore a particular error, then you can use the more -specific code and indicate the exact error to be ignored. For example, to -ignore the check for a space before the Act block, we can mark the Act block -with ``# noqa: AAA03``:: - - def test(): - x = 1 - result = x + 1 # noqa: AAA03 - - assert result == 2 - - -.. _command-line: - Command line ------------- +============ Flake8-AAA has a simple command line interface to assist with development and debugging. Its goal is to show the state of analysed test functions, which @@ -46,7 +7,7 @@ lines are considered to be parts of which blocks and any errors that have been found. Invocation, output and return value -................................... +----------------------------------- With Flake8-AAA installed, it can be called as a Python module:: @@ -121,10 +82,10 @@ in a test suite, ``find`` should be used: find tests -name '*.py' | xargs -n 1 python -m flake8_aaa -noqa and command line -..................... +Directives and command line +--------------------------- -The ``# noqa`` comment marker works slightly differently when Flake8-AAA is +The ``# noqa`` directive comment marker works slightly differently when Flake8-AAA is called on the command line rather than invoked through ``flake8``. When called on the command line, to skip linting a test function, mark the function definition with ``# noqa`` on the same line as the ``def``. @@ -140,7 +101,7 @@ For example:: .. _line-markers: Line markers -............ +------------ Each test found in the passed file is displayed. Each line is annotated with its line number in the file and a marker to show how Flake8-AAA classified that diff --git a/docs/directives.rst b/docs/directives.rst new file mode 100644 index 0000000..4d413ae --- /dev/null +++ b/docs/directives.rst @@ -0,0 +1,32 @@ +Directives +========== + +Flake8-AAA can be controlled using some special directives in the form of +comments in your test code. + +Explicitly marking blocks +------------------------- + +One can set the act block explicitly using the ``# act`` comment. This is +necessary when there is no assignment possible. + +See :ref:`AAA01: no Act block found in test - Correct code 2 `. + +Disabling Flake8-AAA selectively +-------------------------------- + +When invoked via Flake8, Flake8 will filter any errors raised when lines are +marked with the ``# noqa`` syntax. You can turn off all errors from Flake8-AAA +by marking a line with ``# noqa: AAA`` and other Flake8 errors will still be +returned. + +If you just want to ignore a particular error, then you can use the more +specific code and indicate the exact error to be ignored. For example, to +ignore the check for a space before the Act block, we can mark the Act block +with ``# noqa: AAA03``:: + + def test(): + x = 1 + result = x + 1 # noqa: AAA03 + + assert result == 2 diff --git a/docs/error_codes/AAA99-collision-when-marking-this-line.rst b/docs/error_codes/AAA99-collision-when-marking-this-line.rst index b5d14b6..6f219a4 100644 --- a/docs/error_codes/AAA99-collision-when-marking-this-line.rst +++ b/docs/error_codes/AAA99-collision-when-marking-this-line.rst @@ -1,16 +1,16 @@ AAA99: collision when marking this line as NEW_CODE, was already OLD_CODE -------------------------------------------------------------------------- +========================================================================= This is an error code that is raised when Flake8 tries to mark a single line as occupied by two different types of block. It *should* never happen. The values for ``NEW_CODE`` and ``OLD_CODE`` are from the list of :ref:`line-markers`. Resolution -.......... +---------- Please open a `new issue `_ containing the output -for the failing test as generated by the :ref:`command-line` tool. +for the failing test as generated by the :doc:`../command_line` tool. You could hack around with your test to see if you can get it to work while waiting for someone to reply to your issue. If you're able to adjust the test diff --git a/docs/index.rst b/docs/index.rst index 3d66a26..4cf4227 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,8 @@ Continue here for more detail about using Flake8-AAA. compatibility error_codes/all + options + directives discovery - commands + command_line release_checklist diff --git a/docs/options.rst b/docs/options.rst new file mode 100644 index 0000000..7d6984a --- /dev/null +++ b/docs/options.rst @@ -0,0 +1,53 @@ +Options and configuration +========================= + +Flake8 can be invoked with ``--`` options *and* can read values from project +configuration files. + +All names of Flake8-AAA's options and configuration values are prefixed with +"aaa". E.g. ``--aaa-act-block-style``. + +Act block style +--------------- + +Command line flag + ``--aaa-act-block-style`` + +Configuration option + ``aaa_act_block_style`` + +The Act block style option adjusts how Flake8-AAA builds the Act block from the +Act node. + +The allowed value is "default". + +In default mode the Act block is the single Act node, best demonstrated by +example: + +.. code-block:: python + + result = do_thing() + +Or... + +.. code-block:: python + + with pytest.raises(ValueError): + do_thing() + +The important feature of default Act blocks is that they do not contain any +context managers other than pytest or unittest ones. + +.. code-block:: python + + def test_with(): + a_class = AClass() + with freeze_time("2021-02-02 12:00:02"): + + result = a_class.action('test') + + assert result == 'test' + +In the example above, Flake8-AAA considers the ``with freeze_time()`` context +manager to be in the Arrange block. It therefore expects a blank line between +it and the ``result =`` Act block. diff --git a/examples/good/black/noqa/test_01.py b/examples/good/black/noqa/test_01.py index 002d924..fdc1a98 100644 --- a/examples/good/black/noqa/test_01.py +++ b/examples/good/black/noqa/test_01.py @@ -1,6 +1,13 @@ -def test_specific(): # noqa: AAA01 +import pathlib + + +def test_specific() -> None: # noqa: AAA01 assert 1 + 1 == 2 -def test_multi_line_args_specific(math_fixture, *args, **kwargs): # noqa: AAA01 +def test_multi_line_args_specific( # noqa: AAA01 + tmp_path: pathlib.Path, + *args, + **kwargs, +) -> None: assert 1 + 1 == 2 diff --git a/examples/good/black/test_comments.py b/examples/good/black/test_comments.py index d63a0f0..f09186b 100644 --- a/examples/good/black/test_comments.py +++ b/examples/good/black/test_comments.py @@ -28,7 +28,8 @@ def test_ignore_typing() -> None: # Example from docs: -# mark the end of the line considered the Act block with ``# act`` (case insensitive) +# mark the end of the line considered the Act block with ``# act`` (case +# insensitive) def test_Act() -> None: """ Reverse shopping list operates in place @@ -77,7 +78,12 @@ def test_mark_last_line() -> None: ] ) # act - assert shopping == ["apples", "bananas", "cabbages", ["dill", "eggs", "fennel"]] + assert shopping == [ + "apples", + "bananas", + "cabbages", + ["dill", "eggs", "fennel"], + ] # Comments are OK in Arrange and Assert. diff --git a/examples/good/black/test_django_fakery_factories.py b/examples/good/black/test_django_fakery_factories.py index bba8dc7..1dc2e91 100644 --- a/examples/good/black/test_django_fakery_factories.py +++ b/examples/good/black/test_django_fakery_factories.py @@ -41,7 +41,9 @@ def test_default(self): with transaction.atomic(): UserFactory() - self.assertEqual(self.user_model.objects.count(), expected_num_created - 1) + self.assertEqual( + self.user_model.objects.count(), expected_num_created - 1 + ) # A comment to make black wrap self.assertIn("unique", str(cm.exception).lower()) for u in self.user_model.objects.all(): u.full_clean() diff --git a/examples/good/noqa/test_01.py b/examples/good/noqa/test_01.py index 67b8696..fdc1a98 100644 --- a/examples/good/noqa/test_01.py +++ b/examples/good/noqa/test_01.py @@ -1,10 +1,13 @@ -def test_specific(): # noqa: AAA01 +import pathlib + + +def test_specific() -> None: # noqa: AAA01 assert 1 + 1 == 2 def test_multi_line_args_specific( # noqa: AAA01 - math_fixture, + tmp_path: pathlib.Path, *args, - **kwargs -): + **kwargs, +) -> None: assert 1 + 1 == 2 diff --git a/examples/good/test_comments.py b/examples/good/test_comments.py index b1ee1dd..f28d3de 100644 --- a/examples/good/test_comments.py +++ b/examples/good/test_comments.py @@ -28,7 +28,8 @@ def test_ignore_typing() -> None: # Example from docs: -# mark the end of the line considered the Act block with ``# act`` (case insensitive) +# mark the end of the line considered the Act block with ``# act`` (case +# insensitive) def test_Act() -> None: """ Reverse shopping list operates in place @@ -75,7 +76,12 @@ def test_mark_last_line() -> None: 'fennel', ]) # act - assert shopping == ['apples', 'bananas', 'cabbages', ['dill', 'eggs', 'fennel']] + assert shopping == [ + 'apples', + 'bananas', + 'cabbages', + ['dill', 'eggs', 'fennel'], + ] # Comments are OK in Arrange and Assert. diff --git a/examples/good/test_django_fakery_factories.py b/examples/good/test_django_fakery_factories.py index 77f39b2..3b7f1d3 100644 --- a/examples/good/test_django_fakery_factories.py +++ b/examples/good/test_django_fakery_factories.py @@ -42,7 +42,10 @@ def test_default(self): with transaction.atomic(): UserFactory() - self.assertEqual(self.user_model.objects.count(), expected_num_created - 1) + self.assertEqual( + self.user_model.objects.count(), + expected_num_created - 1, + ) self.assertIn('unique', str(cm.exception).lower()) for u in self.user_model.objects.all(): u.full_clean() diff --git a/examples/good/test_with_statement_unittest.py b/examples/good/test_with_statement_unittest.py index 5fc429a..0c40d47 100644 --- a/examples/good/test_with_statement_unittest.py +++ b/examples/good/test_with_statement_unittest.py @@ -6,7 +6,7 @@ class Test(unittest.TestCase): def setUp(self): - self.hello_world_path = pathlib.Path(__file__).parent.parent / 'data' / 'hello_world.txt' + self.hello_world_path = pathlib.Path(__file__).parent.parent / 'data' / 'hello_world.txt' # noqa: E501 def test_assert_raises_in_block(self): """ diff --git a/requirements/examples.txt b/requirements/examples.txt index 74ecb4c..39ff79d 100644 --- a/requirements/examples.txt +++ b/requirements/examples.txt @@ -4,8 +4,6 @@ # # pip-compile --output-file=examples.txt examples.in # -attrs==22.2.0 - # via pytest black==22.12.0 # via -r examples.in click==8.1.3 @@ -24,13 +22,13 @@ iniconfig==2.0.0 # via pytest mccabe==0.7.0 # via flake8 -mypy==1.1.1 +mypy==1.2.0 # via -r examples.in mypy-extensions==1.0.0 # via # black # mypy -packaging==23.0 +packaging==23.1 # via pytest pathspec==0.11.1 # via black @@ -42,7 +40,7 @@ pycodestyle==2.9.1 # via flake8 pyflakes==2.5.0 # via flake8 -pytest==7.2.2 +pytest==7.3.1 # via -r examples.in tomli==2.0.1 # via diff --git a/requirements/test.in b/requirements/test.in index 2f7390a..f8d8a6f 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -1,4 +1,5 @@ # py37 +faker flake8==4.0.1 isort mypy==0.930 diff --git a/requirements/test.txt b/requirements/test.txt index ab038b8..348c687 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -10,6 +10,8 @@ docutils==0.19 # via restructuredtext-lint exceptiongroup==1.1.1 # via pytest +faker==18.4.0 + # via -r test.in flake8==4.0.1 # via -r test.in importlib-metadata==4.2.0 @@ -39,8 +41,12 @@ pygments==2.14.0 # via -r test.in pytest==7.2.2 # via -r test.in +python-dateutil==2.8.2 + # via faker restructuredtext-lint==1.4.0 # via -r test.in +six==1.16.0 + # via python-dateutil tomli==2.0.1 # via # mypy @@ -49,6 +55,7 @@ typed-ast==1.5.4 # via mypy typing-extensions==4.5.0 # via + # faker # importlib-metadata # mypy yapf==0.32.0 diff --git a/src/flake8_aaa/block.py b/src/flake8_aaa/block.py index ae9fdf0..c72fbde 100644 --- a/src/flake8_aaa/block.py +++ b/src/flake8_aaa/block.py @@ -1,6 +1,7 @@ import ast from typing import Iterable, List, Tuple, Type, TypeVar +from .conf import ActBlockStyle from .exceptions import EmptyBlock from .helpers import filter_arrange_nodes, get_first_token, get_last_token from .types import LineType @@ -12,17 +13,17 @@ class Block: """ An Arrange, Act or Assert block of code as parsed from the test function. + Act blocks are simply a single Act node in default mode. However, in a + future version (update TODO200), "large" Act blocks will include the Act + node and any context managers that wrap them. + Note: - This may just become the Act Block *AND* since the Act Block is just a - single node, this might not even be required. + Blocks with no nodes are allowed (at the moment). Args: nodes: Nodes that make up this block. line_type: Type of line that this blocks writes into the line markers instance for the function. - - Notes: - * Blocks with no nodes are allowed (at the moment). """ def __init__(self, nodes: Iterable[ast.AST], lt: LineType) -> None: @@ -30,9 +31,19 @@ def __init__(self, nodes: Iterable[ast.AST], lt: LineType) -> None: self.line_type = lt @classmethod - def build_act(cls: Type[_Block], node: ast.stmt) -> _Block: + def build_act( + cls: Type[_Block], + node: ast.stmt, + test_func_node: ast.FunctionDef, # use this in TODO200 + act_block_style: ActBlockStyle, # use this in TODO200 + ) -> _Block: """ - Act block is a single node. + Act block is a single node by default. TODO200 + + Args: + node: Act node already found by Function.mark_act() + test_func_node: Node of test function / method. + act_block_style: Currently always DEFAULT. TODO200 """ return cls([node], LineType.act) diff --git a/src/flake8_aaa/checker.py b/src/flake8_aaa/checker.py index cd67bce..9988214 100644 --- a/src/flake8_aaa/checker.py +++ b/src/flake8_aaa/checker.py @@ -1,9 +1,11 @@ +import argparse from ast import AST from typing import Generator, List, Optional, Tuple import asttokens from .__about__ import __short_name__, __version__ +from .conf import Config from .exceptions import TokensNotLoaded, ValidationError from .function import Function from .helpers import find_test_functions, is_test_file @@ -16,6 +18,7 @@ class Checker: filename: Name of file under check. lines tree: Tree passed from flake8. + config: A Config instance containing passed options. """ name = __short_name__ @@ -26,6 +29,34 @@ def __init__(self, tree: AST, lines: List[str], filename: str): self.lines = lines self.filename = filename self.ast_tokens: Optional[asttokens.ASTTokens] = None + self.config: Config = Config.default_options() + + @staticmethod + def add_options(option_manager) -> None: + """ + Note: + No type annotation on `option_manager` because current flake8 + version required to maintain support for py37 causes problems. This + should be fixed (or at the least rechecked) when py37 is dropped in + #198 + """ + option_manager.add_option( + '--aaa-act-block-style', + parse_from_config=True, + default='default', + help='Style of Act block parsing with respect to surrounding lines. (Default: default)', + ) + + @classmethod + def parse_options(cls, option_manager, options: argparse.Namespace, args) -> None: + """ + Store options passed to flake8 in config instance. Only called when + user passes flags or sets config. + + Raises: + UnexpectedConfigValue: When config can't be loaded. + """ + cls.config = Config.load_options(options) def load(self) -> None: self.ast_tokens = asttokens.ASTTokens(''.join(self.lines), tree=self.tree) @@ -56,7 +87,7 @@ def run(self) -> Generator[Tuple[int, int, str, type], None, None]: self.load() for func in self.all_funcs(): try: - for error in func.check_all(): + for error in func.check_all(self.config): yield (error.line_number, error.offset, error.text, Checker) except ValidationError as error: yield error.to_flake8(Checker) diff --git a/src/flake8_aaa/command_line.py b/src/flake8_aaa/command_line.py index 5f3d9e6..fec03bc 100644 --- a/src/flake8_aaa/command_line.py +++ b/src/flake8_aaa/command_line.py @@ -24,7 +24,7 @@ def do_command_line(infile: typing.IO[str]) -> int: for func in checker.all_funcs(skip_noqa=True): try: - errors = list(func.check_all()) + errors = list(func.check_all(checker.config)) except ValidationError as error: errors = [error.to_aaa()] print(func.__str__(errors), end='') diff --git a/src/flake8_aaa/conf.py b/src/flake8_aaa/conf.py new file mode 100644 index 0000000..92dc3ea --- /dev/null +++ b/src/flake8_aaa/conf.py @@ -0,0 +1,66 @@ +import argparse +import dataclasses +import enum +from typing import List, Type, TypeVar + +from .exceptions import UnexpectedConfigValue + +_ActBlockStyle = TypeVar('_ActBlockStyle', bound='ActBlockStyle') + + +@enum.unique +class ActBlockStyle(enum.Enum): + DEFAULT = 'default' + # LARGE = 'large' # TODO200 + + @classmethod + def allowed_values(cls: Type[_ActBlockStyle]) -> List[str]: + """ + List of allowed values for this setting + """ + return [item.value for item in cls] + + +_Config = TypeVar('_Config', bound='Config') + + +@dataclasses.dataclass +class Config: + """ + Note: + Externally (flake8 and command line side) settings have the 'aaa_' + prefix, e.g. 'aaa_act_block_style'. However internally flake8-aaa, the + 'aaa_' prefix is dropped. This happens during loading time in + `load_options()`. + """ + act_block_style: ActBlockStyle + + @classmethod + def default_options(cls: Type[_Config]) -> _Config: + """ + Returns: + Config instance with default options set. + """ + return cls(act_block_style=ActBlockStyle.DEFAULT) + + @classmethod + def load_options(cls: Type[_Config], options: argparse.Namespace) -> _Config: + """ + Parse custom configuration options given to flake8. + + Raises: + UnexpectedConfigValue + + Returns: + Config instance with values set from passed options. + """ + try: + act_block_style = ActBlockStyle[options.aaa_act_block_style.upper()] + except KeyError: + raise UnexpectedConfigValue( + option_name='aaa_act_block_style', + value=options.aaa_act_block_style, + allowed_values=ActBlockStyle.allowed_values(), + ) + + return cls(act_block_style=act_block_style) diff --git a/src/flake8_aaa/exceptions.py b/src/flake8_aaa/exceptions.py index 88671d8..7930083 100644 --- a/src/flake8_aaa/exceptions.py +++ b/src/flake8_aaa/exceptions.py @@ -1,5 +1,7 @@ import typing +from .helpers import flatten_list + Flake8Error = typing.NamedTuple( 'Flake8Error', [ ('line_number', int), @@ -20,6 +22,25 @@ class Flake8AAAException(Exception): pass +class UnexpectedConfigValue(Flake8AAAException): + """ + Value of passed config is invalid. + """ + + def __init__(self, option_name: str, value: str, allowed_values: typing.List[str]) -> None: + self.option_name = option_name + self.value = value + self.allowed_values = allowed_values + + def __str__(self) -> str: + return ( + 'Error loading option / configuration...\n' + f' Option: {self.option_name}\n' + f' Want: {flatten_list(self.allowed_values)}\n' + f' Got: "{self.value}"\n' + ) + + class TokensNotLoaded(Flake8AAAException): """ `Checker.all_funcs()` was called before `ast_tokens` was populated. Usually diff --git a/src/flake8_aaa/function.py b/src/flake8_aaa/function.py index f537bea..9350a93 100644 --- a/src/flake8_aaa/function.py +++ b/src/flake8_aaa/function.py @@ -6,6 +6,7 @@ from .act_node import ActNode from .block import Block +from .conf import ActBlockStyle, Config from .exceptions import AAAError, EmptyBlock, ValidationError from .helpers import format_errors, function_is_noop, get_first_token, get_last_token, line_is_comment from .line_markers import LineMarkers @@ -74,9 +75,14 @@ def __str__(self, errors: Optional[List[AAAError]] = None) -> str: out += format_errors(len(errors)) return out - def check_all(self) -> Generator[AAAError, None, None]: + def check_all(self, config: Config) -> Generator[AAAError, None, None]: """ - Run everything required for checking this test. + Run everything required for checking this test. Selects relevant + options from received config instance to pass to each "mark_" and + "check_" method. + + Args: + config: Instance of Config class. Returns: A generator of errors. @@ -92,7 +98,7 @@ def check_all(self) -> Generator[AAAError, None, None]: self.mark_comments() self.mark_def() - self.mark_act() + self.mark_act(config.act_block_style) self.mark_arrange() self.mark_assert() @@ -167,11 +173,14 @@ def mark_def(self) -> int: self.line_markers.update(first_index, last_index, LineType.func_def) return last_index - first_index + 1 - def mark_act(self) -> int: + def mark_act(self, act_block_style: ActBlockStyle) -> int: """ Finds Act node, calculates its span and marks the associated lines in ``line_markers``. + Args: + act_block_style: Currently only DEFAULT. TODO200 + Returns: Number of lines covered by the Act block (used for debugging / testing only). Includes any comment or blank lines already marked @@ -185,7 +194,7 @@ def mark_act(self) -> int: """ # Load act block and kick out when none is found self.act_node = self.load_act_node() - self.act_block = Block.build_act(self.act_node.node) + self.act_block = Block.build_act(self.act_node.node, self.node, act_block_style) # Get relative line numbers of Act block footprint # TODO store first and last line numbers in Block - use them instead of # asking for span. diff --git a/src/flake8_aaa/helpers.py b/src/flake8_aaa/helpers.py index c1b444f..0bf60de 100644 --- a/src/flake8_aaa/helpers.py +++ b/src/flake8_aaa/helpers.py @@ -178,3 +178,22 @@ def line_is_comment(line: str) -> bool: # Assume that a token error happens because this is *not* a comment return False return first_token.type == tokenize.COMMENT + + +def flatten_list(items: List[str]) -> str: + """ + Given a list of strings, flatten them to '"X", "Y" or "Z"' format. + + Raises: + ValueError: When an empty list is received. + """ + if len(items) == 1: + return f'"{items[0]}"' + + try: + last = items[-1] + except IndexError: + # Empty list + raise ValueError('Empty list of values received') + + return ', '.join(f'"{item}"' for item in items[:-1]) + f' or "{last}"' diff --git a/tests/block/test_build_act.py b/tests/block/test_build_act.py index 409fddf..69398f0 100644 --- a/tests/block/test_build_act.py +++ b/tests/block/test_build_act.py @@ -1,6 +1,7 @@ import pytest from flake8_aaa.block import Block +from flake8_aaa.conf import ActBlockStyle from flake8_aaa.types import LineType @@ -14,14 +15,14 @@ def test(): ''' ] ) -def test(first_node_with_tokens): +def test(first_node_with_tokens) -> None: """ `pytest.raises()` with statement is the Act node. """ with_mock_node = first_node_with_tokens.body[0] with_pytest_node = with_mock_node.body[0] - result = Block.build_act(with_pytest_node) + result = Block.build_act(with_pytest_node, first_node_with_tokens, ActBlockStyle.DEFAULT) assert result.nodes == (with_pytest_node, ) assert result.line_type == LineType.act diff --git a/tests/checker/test_init.py b/tests/checker/test_init.py index e590aa7..437be31 100644 --- a/tests/checker/test_init.py +++ b/tests/checker/test_init.py @@ -3,6 +3,7 @@ import pytest from flake8_aaa import Checker +from flake8_aaa.conf import Config @pytest.fixture @@ -20,3 +21,4 @@ def test(ast_example) -> None: assert result.lines == [] assert result.filename == '__FILENAME__' assert result.ast_tokens is None + assert result.config == Config.default_options() diff --git a/tests/checker/test_parse_options.py b/tests/checker/test_parse_options.py new file mode 100644 index 0000000..6eabb4a --- /dev/null +++ b/tests/checker/test_parse_options.py @@ -0,0 +1,39 @@ +import argparse + +import pytest + +from flake8_aaa import Checker +from flake8_aaa.conf import ActBlockStyle, Config +from flake8_aaa.exceptions import UnexpectedConfigValue + + +def test() -> None: + """ + Smoke test that Checker can parse options and keep the result in its config + instance. + + Note: + The "real" testing happens in `Config.load_options()` which is called + by this method. + """ + option_manager = None # Fake because it's not used by SUT + options = argparse.Namespace(aaa_act_block_style='default') + + result = Checker.parse_options(option_manager, options, []) + + assert result is None + assert isinstance(Checker.config, Config) + assert Checker.config.act_block_style == ActBlockStyle.DEFAULT + + +# --- FAILURES --- + + +def test_unknown() -> None: + """ + Unknown value raises + """ + options = argparse.Namespace(aaa_act_block_style='foobar') + + with pytest.raises(UnexpectedConfigValue): + Checker.parse_options(None, options, []) diff --git a/tests/command_line/test_do_command_line.py b/tests/command_line/test_do_command_line.py index 6cb06b9..0fe0468 100644 --- a/tests/command_line/test_do_command_line.py +++ b/tests/command_line/test_do_command_line.py @@ -27,13 +27,13 @@ def test_other(): return f -def test_example_file_is_test(example_file): +def test_example_file_is_test(example_file) -> None: result = is_test_file(example_file.name) assert result is False -def test_example_file_has_functions(example_file): +def test_example_file_has_functions(example_file) -> None: lines = example_file.readlines() tree = ast.parse(''.join(lines)) @@ -45,7 +45,7 @@ def test_example_file_has_functions(example_file): # --- TESTS --- -def test(example_file, capsys): +def test(example_file, capsys) -> None: with example_file.open() as f: result = do_command_line(f) diff --git a/tests/conf/__init__.py b/tests/conf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conf/act_block_style/__init__.py b/tests/conf/act_block_style/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conf/act_block_style/test_allowed_values.py b/tests/conf/act_block_style/test_allowed_values.py new file mode 100644 index 0000000..124d694 --- /dev/null +++ b/tests/conf/act_block_style/test_allowed_values.py @@ -0,0 +1,7 @@ +from flake8_aaa.conf import ActBlockStyle + + +def test() -> None: + result = ActBlockStyle.allowed_values() + + assert result == ['default'] diff --git a/tests/conf/config/__init__.py b/tests/conf/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conf/config/test_default_options.py b/tests/conf/config/test_default_options.py new file mode 100644 index 0000000..f7dce97 --- /dev/null +++ b/tests/conf/config/test_default_options.py @@ -0,0 +1,10 @@ +from flake8_aaa.conf import ActBlockStyle, Config + + +def test() -> None: + """ + Config object can build itself with default options set. + """ + result = Config.default_options() + + assert result == Config(act_block_style=ActBlockStyle.DEFAULT) diff --git a/tests/conf/config/test_init.py b/tests/conf/config/test_init.py new file mode 100644 index 0000000..79f7e3a --- /dev/null +++ b/tests/conf/config/test_init.py @@ -0,0 +1,14 @@ +from faker import Faker + +from flake8_aaa.conf import Config + + +def test(faker: Faker) -> None: + """ + Any value can be set via dunder init + """ + value = faker.pystr() + + result = Config(act_block_style=value) + + assert result.act_block_style == value diff --git a/tests/conf/config/test_load_options.py b/tests/conf/config/test_load_options.py new file mode 100644 index 0000000..7d2d205 --- /dev/null +++ b/tests/conf/config/test_load_options.py @@ -0,0 +1,37 @@ +import argparse + +import pytest +from faker import Faker + +from flake8_aaa.conf import ActBlockStyle, Config +from flake8_aaa.exceptions import UnexpectedConfigValue + + +@pytest.mark.parametrize('value', ['default', 'DEFAULT', 'dEfAuLt']) +def test(value: str) -> None: + """ + Setting is case-insensitive + """ + options = argparse.Namespace(aaa_act_block_style=value) + + result = Config.load_options(options) + + assert result == Config(act_block_style=ActBlockStyle.DEFAULT) + + +# --- FAILURES --- + + +def test_unknown(faker: Faker) -> None: + """ + Unknown value for setting raises + """ + unknown_value = faker.pystr(min_chars=5) + options = argparse.Namespace(aaa_act_block_style=unknown_value) + + with pytest.raises(UnexpectedConfigValue) as excinfo: + Config.load_options(options) + + assert excinfo.value.option_name == 'aaa_act_block_style' + assert excinfo.value.value == unknown_value + assert excinfo.value.allowed_values == ['default'] diff --git a/tests/exceptions/test_unexpected_config_value.py b/tests/exceptions/test_unexpected_config_value.py new file mode 100644 index 0000000..1a2180d --- /dev/null +++ b/tests/exceptions/test_unexpected_config_value.py @@ -0,0 +1,17 @@ +from flake8_aaa.exceptions import UnexpectedConfigValue + + +def test_message() -> None: + exc = UnexpectedConfigValue( + option_name='aaa_SOME_OPTION', + value='__SOME_VALUE__', + allowed_values=['__FIRST__', '__ALLOWED__', '__ALSO_ALLOWED__'], + ) + + result = str(exc) + + assert result == """Error loading option / configuration... + Option: aaa_SOME_OPTION + Want: "__FIRST__", "__ALLOWED__" or "__ALSO_ALLOWED__" + Got: "__SOME_VALUE__" +""" diff --git a/tests/exceptions/test_validation_error.py b/tests/exceptions/test_validation_error.py index abcb88a..3e74abd 100644 --- a/tests/exceptions/test_validation_error.py +++ b/tests/exceptions/test_validation_error.py @@ -2,7 +2,7 @@ from flake8_aaa.exceptions import ValidationError -def test(): +def test() -> None: result = ValidationError( line_number=99, offset=777, diff --git a/tests/function/conftest.py b/tests/function/conftest.py index 2650c92..8081024 100644 --- a/tests/function/conftest.py +++ b/tests/function/conftest.py @@ -1,5 +1,6 @@ import pytest +from flake8_aaa.conf import Config from flake8_aaa.function import Function # Fixtures provide function instances in states of marking as per the order in @@ -19,30 +20,30 @@ def function(first_node_with_tokens, lines, tokens) -> Function: @pytest.fixture -def function_bl(function) -> Function: +def function_bl(function: Function) -> Function: function.mark_bl() return function @pytest.fixture -def function_bl_cmt(function_bl) -> Function: +def function_bl_cmt(function_bl: Function) -> Function: function_bl.mark_comments() return function_bl @pytest.fixture -def function_bl_cmt_def(function_bl_cmt) -> Function: +def function_bl_cmt_def(function_bl_cmt: Function) -> Function: function_bl_cmt.mark_def() return function_bl_cmt @pytest.fixture -def function_bl_cmt_def_act(function_bl_cmt_def) -> Function: - function_bl_cmt_def.mark_act() +def function_bl_cmt_def_act(function_bl_cmt_def: Function) -> Function: + function_bl_cmt_def.mark_act(Config.default_options().act_block_style) return function_bl_cmt_def @pytest.fixture -def function_bl_cmt_def_act_arr(function_bl_cmt_def_act) -> Function: +def function_bl_cmt_def_act_arr(function_bl_cmt_def_act: Function) -> Function: function_bl_cmt_def_act.mark_arrange() return function_bl_cmt_def_act diff --git a/tests/function/test_check_all.py b/tests/function/test_check_all.py index c15a8b8..0e05f0a 100644 --- a/tests/function/test_check_all.py +++ b/tests/function/test_check_all.py @@ -2,7 +2,9 @@ import pytest +from flake8_aaa.conf import Config from flake8_aaa.exceptions import AAAError +from flake8_aaa.function import Function @pytest.mark.parametrize( @@ -13,8 +15,8 @@ ], ids=['pass', 'docstring'], ) -def test_noop(function): - result = function.check_all() +def test_noop(function: Function) -> None: + result = function.check_all(Config.default_options()) assert isinstance(result, Generator) assert list(result) == [] @@ -37,8 +39,8 @@ def test(api_client, url): ''', ] ) -def test_context_manager(function): - result = list(function.check_all()) +def test_context_manager(function: Function) -> None: + result = list(function.check_all(Config.default_options())) assert result == [] @@ -66,8 +68,8 @@ def test_push(queue): ], ids=['no line before result= act', 'no line before marked act'], ) -def test_missing_space_before_act(function): - result = function.check_all() +def test_missing_space_before_act(function: Function) -> None: + result = function.check_all(Config.default_options()) assert isinstance(result, Generator) errors = list(result) @@ -98,8 +100,8 @@ def test_push(queue): ], ids=['no line before assert', 'no line before assert with marked act'], ) -def test_missing_space_before_assert(function): - result = function.check_all() +def test_missing_space_before_assert(function: Function) -> None: + result = function.check_all(Config.default_options()) assert isinstance(result, Generator) errors = list(result) @@ -119,11 +121,11 @@ def test(file_resource): ''', ] ) -def test_multi(function): +def test_multi(function: Function) -> None: """ No space before or after act - two errors are returned """ - result = function.check_all() + result = function.check_all(Config.default_options()) assert isinstance(result, Generator) errors = list(result) diff --git a/tests/function/test_mark_act.py b/tests/function/test_mark_act.py index 5552443..8a2748c 100644 --- a/tests/function/test_mark_act.py +++ b/tests/function/test_mark_act.py @@ -1,5 +1,7 @@ import pytest +from flake8_aaa.conf import ActBlockStyle +from flake8_aaa.function import Function from flake8_aaa.types import LineType @@ -15,11 +17,11 @@ def test(hello_world_path): ''' ] ) -def test_simple(function_bl_cmt_def): +def test_simple(function_bl_cmt_def: Function) -> None: """ `with` statement is part of arrange. Blank lines are maintained around Act. """ - result = function_bl_cmt_def.mark_act() + result = function_bl_cmt_def.mark_act(ActBlockStyle.DEFAULT) assert result == 1 assert function_bl_cmt_def.line_markers.types == [ @@ -45,11 +47,11 @@ def test_pytest_assert_raises_in_block(hello_world_path): ''' ] ) -def test_raises_block(function_bl_cmt_def): +def test_raises_block(function_bl_cmt_def: Function) -> None: """ Checking on a raise in a with block works with Pytest. """ - result = function_bl_cmt_def.mark_act() + result = function_bl_cmt_def.mark_act(ActBlockStyle.DEFAULT) assert result == 2 assert function_bl_cmt_def.line_markers.types == [ @@ -77,11 +79,11 @@ def test_pytest_assert_raises_in_block(hello_world_path): ''' ] ) -def test_raises_block_with_comment(function_bl_cmt_def): +def test_raises_block_with_comment(function_bl_cmt_def: Function) -> None: """ Act block can be marked even though there is a comment in the middle of it """ - result = function_bl_cmt_def.mark_act() + result = function_bl_cmt_def.mark_act(ActBlockStyle.DEFAULT) assert result == 3 assert function_bl_cmt_def.line_markers.types == [ diff --git a/tests/function/test_mark_arrange.py b/tests/function/test_mark_arrange.py index d117a61..6412db1 100644 --- a/tests/function/test_mark_arrange.py +++ b/tests/function/test_mark_arrange.py @@ -1,5 +1,6 @@ import pytest +from flake8_aaa.function import Function from flake8_aaa.types import LineType @@ -15,7 +16,7 @@ def test(hello_world_path): ''' ] ) -def test_simple(function_bl_cmt_def_act): +def test_simple(function_bl_cmt_def_act: Function) -> None: """ `with` statement is part of arrange. Blank lines are maintained around Act. """ @@ -46,7 +47,7 @@ def test_extra_arrange(hello_world_path): ''' ] ) -def test_extra(function_bl_cmt_def_act): +def test_extra(function_bl_cmt_def_act: Function) -> None: """ Any extra arrangement goes in the `with` block. """ @@ -79,7 +80,7 @@ def test_long_string(): ''' ] ) -def test_bl_in_str(function_bl_cmt_def_act): +def test_bl_in_str(function_bl_cmt_def_act: Function) -> None: """ String containing blank lines before Act is marked as Arrange """ @@ -104,7 +105,7 @@ def test_addition(): assert result == 4 ''']) -def test_no_arrange(function_bl_cmt_def_act): +def test_no_arrange(function_bl_cmt_def_act: Function) -> None: """ Function without arrange block does not cause failure """ diff --git a/tests/function/test_str.py b/tests/function/test_str.py index c10899a..e7c669e 100644 --- a/tests/function/test_str.py +++ b/tests/function/test_str.py @@ -1,5 +1,8 @@ import pytest +from flake8_aaa.conf import Config +from flake8_aaa.function import Function + @pytest.mark.parametrize( 'code_str', [ @@ -12,7 +15,7 @@ def test(file_resource): ''', ] ) -def test_unprocessed(function): +def test_unprocessed(function: Function) -> None: """ No parsing has happened, no errors are passed in, lines are marked with ??? """ @@ -46,7 +49,7 @@ def test(file_resource): ''', ] ) -def test_marked(function_bl_cmt_def): +def test_marked(function_bl_cmt_def: Function) -> None: """ Function has marked itself, but no errors passed """ @@ -80,8 +83,8 @@ def test(file_resource): ''', ] ) -def test_processed(function): - errors = list(function.check_all()) +def test_processed(function: Function) -> None: + errors = list(function.check_all(Config.default_options())) result = function.__str__(errors) @@ -110,8 +113,8 @@ def test(): assert result == 2 '''] ) -def test_multi_spaces(function): - errors = list(function.check_all()) +def test_multi_spaces(function: Function) -> None: + errors = list(function.check_all(Config.default_options())) result = function.__str__(errors) @@ -137,8 +140,8 @@ def test(): result = x * 5 assert result == 5 ''']) -def test_multi_errors(function): - errors = list(function.check_all()) +def test_multi_errors(function: Function) -> None: + errors = list(function.check_all(Config.default_options())) result = function.__str__(errors) diff --git a/tests/helpers/test_flatten_list.py b/tests/helpers/test_flatten_list.py new file mode 100644 index 0000000..e4c7584 --- /dev/null +++ b/tests/helpers/test_flatten_list.py @@ -0,0 +1,35 @@ +from typing import List + +import pytest + +from flake8_aaa.helpers import flatten_list + + +@pytest.mark.parametrize( + 'items, expected_out', [ + (['X'], '"X"'), + (['X', 'Y'], '"X" or "Y"'), + (['X', 'Y', 'Z'], '"X", "Y" or "Z"'), + (['X', 'Y', 'Z', 'infinity'], '"X", "Y", "Z" or "infinity"'), + ] +) +def test(items: List[str], expected_out: str) -> None: + items_before = items.copy() + + result = flatten_list(items) + + assert result == expected_out + assert items == items_before + + +# --- FAILURES --- + + +def test_empty() -> None: + """ + Empty list raises + """ + with pytest.raises(ValueError) as excinfo: + flatten_list([]) + + assert 'Empty' in str(excinfo.value) diff --git a/tox.ini b/tox.ini index cfced7a..86b321a 100644 --- a/tox.ini +++ b/tox.ini @@ -10,25 +10,33 @@ # * meta; Integration tests - run as plugin and command against code and examples. # # Additional: -# * docs: Emulate doc build on RTD. +# * docs: Run documentation build. +# +# Pip URL +# ------- +# +# URL for pip is set as the default local URL for devpi server to avoid +# spamming PyPI. CI is run with the PIP_INDEX_URL env var set to point at the +# default PyPI simple index URL. # # Cheat-sheet # ----------- # # * When updating this file, check all default environments have a description: # ``tox l``. Check a label's envs: ``tox l -m examples``. +# * `make docs` recipe can still be used to call tox to build HTML documentation. [tox] envlist = py3{7,8,9,10,11}-lint_{code,examples} py3{7,8,9,10,11}-test_{code,examples} - py3{7,8,9,10,11}-meta_{plugin,command} + py3{7,8,9,10,11}-meta_plugin_{dogfood,default,option,config} + py3{7,8,9,10,11}-meta_command py310-docs [testenv] setenv = - PYTHONWARNINGS = default - TOXDIR = {envdir} + PIP_INDEX_URL = {env:PIP_INDEX_URL:http://localhost:3141/root/pypi/+simple/} # === Env commands === @@ -129,28 +137,78 @@ commands = # --- META: plugin --- # Run as plugin to lint Flake8-AAA's own tests (dog fooding), and also lint all # good and bad examples. Bad examples generate expected errors. -[testenv:py3{7,8,9,10,11}-meta_plugin] -description = 🎈 Run -m flake_aaa against examples and tests. + +[base_meta_plugin] labels = meta meta_plugin deps = flake8>=4 +# Common full integration test command used to against good and bad examples, +# both with default and various configs commands = - flake8 tests - flake8 examples/good - - bash -c "flake8 examples/bad/ | sort > {envtmpdir}/out" - - bash -c "sort examples/bad/bad_expected.out > {envtmpdir}/expected_out" + flake8 {env:FLAKE8FLAGS:} examples/good + bash -c "flake8 {env:FLAKE8FLAGS:} examples/bad/ | sort > {envtmpdir}/out" + bash -c "sort examples/bad/bad_expected.out > {envtmpdir}/expected_out" diff {envtmpdir}/out {envtmpdir}/expected_out allowlist_externals = bash diff +[testenv:py3{7,8,9,10,11}-meta_plugin_dogfood] +# No FLAKE8FLAGS set, so default behaviour +description = 🐕 Run -m flake_aaa against it's own tests +labels = + {[base_meta_plugin]labels} + meta_plugin_dogfood +deps = {[base_meta_plugin]deps} +commands = + flake8 tests + +[testenv:py3{7,8,9,10,11}-meta_plugin_default] +# No FLAKE8FLAGS set, so default behaviour +description = 🎈 Run -m flake_aaa against examples and tests +labels = + meta + meta_plugin + meta_plugin_default +deps = {[base_meta_plugin]deps} +commands = {[base_meta_plugin]commands} +allowlist_externals = {[base_meta_plugin]allowlist_externals} + +[testenv:py3{7,8,9,10,11}-meta_plugin_option] +# FLAKE8FLAGS set to command line options --aaa-* to their default values, +# ensure that defaults can be specified explicitly +description = 🎈 Run -m flake_aaa against examples and tests (pass default options) +labels = + meta + meta_plugin + meta_plugin_option +setenv = + FLAKE8FLAGS = --aaa-act-block-style=default +deps = {[base_meta_plugin]deps} +commands = {[base_meta_plugin]commands} +allowlist_externals = {[base_meta_plugin]allowlist_externals} + +[testenv:py3{7,8,9,10,11}-meta_plugin_config] +# FLAKE8FLAGS pass command line --config reference to config file with explicit +# defaults set to ensure defaults can be passed through explicitly +description = 🎈 Run -m flake_aaa against examples and tests (pass default config) +labels = + meta + meta_plugin + meta_plugin_config +setenv = + FLAKE8FLAGS = --config=configs/explicit_default.ini +deps = {[base_meta_plugin]deps} +commands = {[base_meta_plugin]commands} +allowlist_externals = {[base_meta_plugin]allowlist_externals} + # --- META: command --- # Run `... -m flake8_aaa`) on all example files. Check errors from bad examples # are as expected. [testenv:py3{7,8,9,10,11}-meta_command] -description = 🎈 Run command "-m flake8_aaa" on all examples +description = 🖥️ Run command "-m flake8_aaa" on all examples labels = meta meta_cmd @@ -161,12 +219,15 @@ allowlist_externals = make # --- Docs --- +# Originally this env was used to check that RTD could build docs using py310, +# however, it's also used now to build docs on local when writing +# documentation. [testenv:py310-docs] -description = 📕 Emulate the documentation build on RTD using Python 3.10 +description = 📕 Build docs deps = -rrequirements/docs.txt commands = - make docs + make -C docs html allowlist_externals = make