diff --git a/README.md b/README.md index 10ee299..6a2fdc5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ for each specific tag. This package exists because doing all of this with `pytest.mark` is painful, since it requires registering marks, and you cannot use variables defined elsewhere easily. - ## Usage ```python @@ -30,7 +29,6 @@ pytest --tags integration --tags MY_COMPONENT_NAME ![pytest-tagging-screenshot](/media/screenshot-1.png) - By default, all tests that match at least one tag will be collected. To only select tests that have all the provided tags, use the option --tags-operand=AND, like so: @@ -47,14 +45,27 @@ pytest --tags >>bar ``` +### Combining tags + Tags can be combined using `pytest_tagging.combine_tags`: -```sh +```python from pytest_tagging import combine_tags combine_tags("all", "foo", "bar") ``` Then you can execute `pytest --tags all` and it will run all tests with `foo` and `bar` tags +### Excluding tags + +You can exclude tags for a particular test run by using the option `--exclude-tags` in a similar +way to the `--tags` option. Notice tests with tags that are excluded will not be executed, even if +they contain a tag that was selected with `--tags`. + +```sh +pytest --tags mobile --tags web --exclude-tags flaky +``` + ## Extra + - It is thread-safe, so it can be used with [pytest-parallel](https://github.com/browsertron/pytest-parallel). diff --git a/pytest_tagging/__init__.py b/pytest_tagging/__init__.py index a0a6294..5485263 100644 --- a/pytest_tagging/__init__.py +++ b/pytest_tagging/__init__.py @@ -1,4 +1,4 @@ -from .plugin import combine_tags +from .selector import combine_tags __all__ = [ "combine_tags", diff --git a/pytest_tagging/choices.py b/pytest_tagging/choices.py new file mode 100644 index 0000000..d6586c5 --- /dev/null +++ b/pytest_tagging/choices.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class OperandChoices(Enum): + OR = "OR" + AND = "AND" diff --git a/pytest_tagging/plugin.py b/pytest_tagging/plugin.py index 34421cb..7a4a162 100644 --- a/pytest_tagging/plugin.py +++ b/pytest_tagging/plugin.py @@ -1,30 +1,10 @@ from collections import Counter -from dataclasses import dataclass -from enum import Enum import pytest -from .utils import TagCounterThreadSafe, get_tags_from_item - - -class OperandChoices(Enum): - OR = "OR" - AND = "AND" - - -@dataclass(frozen=True, slots=True) -class TaggingOptions: - tags: list[str] | None = None - operand: OperandChoices = OperandChoices.OR - - -# Allows you to combine tags -_combined_tags = {} - - -def combine_tags(tag_name: str, *args): - """Combine all tags in `args` into `new_tag`""" - _combined_tags[tag_name] = args +from pytest_tagging.choices import OperandChoices +from pytest_tagging.selector import TestSelector, get_tags_from_item +from pytest_tagging.utils import TagCounterThreadSafe, TaggingOptions def select_counter_class(config) -> type[Counter] | type[TagCounterThreadSafe]: @@ -36,10 +16,12 @@ def pytest_configure(config) -> None: config.addinivalue_line("markers", "tags('tag1', 'tag2'): add tags to a given test") config_opts = TaggingOptions( tags=config.getoption("--tags"), + exclude_tags=config.getoption("--exclude-tags"), operand=config.getoption("--tags-operand"), ) counter_class = select_counter_class(config) - config.pluginmanager.register(TaggerRunner(counter_class, config=config_opts), "taggerrunner") + selector = TestSelector(config=config_opts) + config.pluginmanager.register(TaggerRunnerPlugin(counter_class, selector=selector), "taggerrunner") def pytest_addoption(parser, pluginmanager) -> None: @@ -58,74 +40,53 @@ def pytest_addoption(parser, pluginmanager) -> None: default=OperandChoices.OR, choices=list(OperandChoices), ) + group.addoption( + "--exclude-tags", + default=None, + nargs="*", + action="extend", + help="Exclude the tests that contain the given tags.", + ) -class TaggerRunner: - def __init__( - self, - counter_class: type[Counter] | type[TagCounterThreadSafe], - config: TaggingOptions, - ) -> None: +class TaggerRunnerPlugin: + def __init__(self, counter_class: type[Counter] | type[TagCounterThreadSafe], selector: TestSelector) -> None: self.counter = counter_class() - self.config = config + self.selector = selector self._available_tags: list[str] = [] - def get_available_tags(self, items) -> list[str]: - """ - Returns all available tags - :param items: Items from pytest_collection_modifyitems - """ - available_tags = set() - for item in items: - test_tags = set(get_tags_from_item(item)) - available_tags.update(test_tags) - return list(available_tags) - - def get_tags_to_run(self, tags: list[str] | None, available_tags: list[str], operand: OperandChoices) -> set[str]: - if tags is None: - return set(available_tags) - found_combined_tags = set(tags) & set(_combined_tags) - for tag_name in found_combined_tags: - tags += _combined_tags[tag_name] - return set(tags) or set() - def pytest_report_header(self, config) -> list[str]: """Add tagging config to pytest header.""" - return [f"tagging: tags={self.config.tags}"] + tags_to_display = self.selector.config.tags or [] + exclude_tags_to_display = self.selector.config.exclude_tags or [] + return [f"tagging: tags={tags_to_display} , exclude-tags={exclude_tags_to_display}"] @pytest.hookimpl(hookwrapper=True) def pytest_collection_modifyitems(self, session, config, items): - selected_items = [] - deselected_items = [] - self._available_tags = self.get_available_tags(items) - - all_run_tags = self.get_tags_to_run( - tags=self.config.tags, - available_tags=self._available_tags, - operand=self.config.operand, + self._available_tags = self.selector.get_available_tags(items) + tags_to_run = ( + self.selector.resolve_combined_tags(tags=self.selector.config.tags) + if self.selector.config.tags is not None + else set(self._available_tags) + ) + tags_to_exclude = ( + self.selector.resolve_combined_tags(tags=self.selector.config.exclude_tags) + if self.selector.config.exclude_tags is not None + else set() ) - if self.config.tags == [] or (self.config.tags is not None and not all_run_tags): - # No tags match the conditions to run - # or we just passed an empty `--tags` options to see them all. - for item in items: - deselected_items.append(item) - else: - # Some tags were selected - for item in items: - test_tags = get_tags_from_item(item) - if (self.config.operand is OperandChoices.OR and test_tags & all_run_tags) or ( - self.config.operand is OperandChoices.AND and all_run_tags <= test_tags - ): - selected_items.append(item) - else: - deselected_items.append(item) - config.hook.pytest_deselected(items=deselected_items) + selected_items, deselected_items = self.selector.get_items_to_run( + tags_to_run=tags_to_run, tags_to_exclude=tags_to_exclude, items=items + ) + + # Deselect those tags that should be excluded + config.hook.pytest_deselected(items=deselected_items) + # Select those tags that match our tag selection items[:] = selected_items yield @pytest.hookimpl(hookwrapper=True, trylast=True) def pytest_terminal_summary(self, terminalreporter, exitstatus, config): - tags = self.config.tags + tags = self.selector.config.tags if tags is not None and len(tags) == 0: terminalreporter.write_line("=" * 6 + " Available tags " + "=" * 6) for tag in self._available_tags: diff --git a/pytest_tagging/selector.py b/pytest_tagging/selector.py new file mode 100644 index 0000000..5dba252 --- /dev/null +++ b/pytest_tagging/selector.py @@ -0,0 +1,63 @@ +# Allows you to combine tags +from pytest_tagging.choices import OperandChoices +from pytest_tagging.utils import TaggingOptions + +_combined_tags = {} + + +def combine_tags(tag_name: str, *args): + """Combine all tags in `args` into `new_tag`""" + _combined_tags[tag_name] = args + + +class TestSelector: + def __init__(self, config: TaggingOptions) -> None: + self.config = config + + def get_available_tags(self, items) -> list[str]: + """ + Returns all available tags + :param items: Items from pytest_collection_modifyitems + """ + available_tags = set() + for item in items: + test_tags = set(get_tags_from_item(item)) + available_tags.update(test_tags) + return list(available_tags) + + def resolve_combined_tags(self, tags: list[str]) -> set[str]: + """Resolve all combined tags to return the tags that we want to select.""" + found_combined_tags = set(tags) & set(_combined_tags) + for tag_name in found_combined_tags: + tags += _combined_tags[tag_name] + return set(tags) or set() + + def get_items_to_run(self, tags_to_run, tags_to_exclude, items): + """Given tags and the pytest items collected, return two sets of items: + - Those that should be selected. + - Those that should be deselected. + """ + selected_items = set() + deselected_items = set() + if self.config.tags == [] or (self.config.tags is not None and not tags_to_run): + # No tags match the conditions to run + # or we just passed an empty `--tags` options to see them all. + deselected_items.update(items) + else: + # Some tags were selected + for item in items: + test_tags = get_tags_from_item(item) + # Excluding tags takes precendence ove any selection. + if test_tags & tags_to_exclude: + deselected_items.add(item) + elif (self.config.operand is OperandChoices.OR and test_tags & tags_to_run) or ( + self.config.operand is OperandChoices.AND and tags_to_run <= test_tags + ): + selected_items.add(item) + else: + deselected_items.add(item) + return list(selected_items), list(deselected_items) + + +def get_tags_from_item(item) -> set[str]: + return set(item.get_closest_marker("tags").args) if item.get_closest_marker("tags") else set() diff --git a/pytest_tagging/utils.py b/pytest_tagging/utils.py index 697a4b9..b0a90c9 100644 --- a/pytest_tagging/utils.py +++ b/pytest_tagging/utils.py @@ -1,6 +1,9 @@ +from dataclasses import dataclass from multiprocessing import Manager from typing import Any, Iterable +from pytest_tagging.choices import OperandChoices + class TagCounterThreadSafe: def __init__(self) -> None: @@ -23,5 +26,8 @@ def __bool__(self) -> bool: return bool(self.counter) -def get_tags_from_item(item) -> set[str]: - return set(item.get_closest_marker("tags").args) if item.get_closest_marker("tags") else set() +@dataclass(frozen=True, slots=True) +class TaggingOptions: + tags: list[str] | None = None + exclude_tags: list[str] | None = None + operand: OperandChoices = OperandChoices.OR diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 7ce1e75..b8bf7e0 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -67,46 +67,105 @@ def test_tagged_1(): @pytest.mark.tags('bar') def test_tagged_2(): assert True + + @pytest.mark.tags('baz') + def test_tagged_3(): + assert True """ ) result = testdir.runpytest("--tags=foo", "--tags=bar") result.assert_outcomes(passed=2) -class TestsTagNotSelected: - def test_collect_only_tagged(self, testdir): - testdir.makepyfile( - """ - import pytest +def test_exclude_tags(testdir): + testdir.makepyfile( + """ + import pytest - @pytest.mark.tags('foo') - def test_tagged_1(): - assert True + @pytest.mark.tags('foo') + def test_1(): + assert True - @pytest.mark.tags('bar') - def test_tagged_2(): - assert True + @pytest.mark.tags('bar') + def test_2(): + assert False """ - ) - result = testdir.runpytest("--tags=foo") - result.assert_outcomes(passed=1, failed=0) - - def test_none_tagged(self, testdir): - testdir.makepyfile( - """ - import pytest - - @pytest.mark.tags('foo') - def test_tagged_1(): - assert True - - @pytest.mark.tags('bar') - def test_tagged_2(): - assert True + ) + result = testdir.runpytest("--exclude-tags=bar") + result.assert_outcomes(passed=1) + + +def test_exclude_tags_takes_precedence_over_tags(testdir): + testdir.makepyfile( """ - ) - result = testdir.runpytest("--tags=123") - result.assert_outcomes(passed=0, failed=0) + import pytest + + @pytest.mark.tags('foo') + def test_1(): + assert True + + @pytest.mark.tags('bar', 'baz') + def test_2(): + assert False + """ + ) + + result = testdir.runpytest("--tags=baz", "--tags=foo", "--exclude-tags=bar") + result.assert_outcomes(passed=1) + + +def test_empty_exclude_tags(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.tags('foo') + def test_1(): + assert True + + @pytest.mark.tags('bar', 'baz') + def test_2(): + assert False + """ + ) + result = testdir.runpytest("--exclude-tags") + result.assert_outcomes(passed=1, failed=1) + + +def test_collect_only_tagged(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.tags('foo') + def test_tagged_1(): + assert True + + @pytest.mark.tags('bar') + def test_tagged_2(): + assert True + """ + ) + result = testdir.runpytest("--tags=foo") + result.assert_outcomes(passed=1, failed=0) + + +def test_none_tagged(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.tags('foo') + def test_tagged_1(): + assert True + + @pytest.mark.tags('bar') + def test_tagged_2(): + assert True + """ + ) + result = testdir.runpytest("--tags=123") + result.assert_outcomes(passed=0, failed=0) def test_collect_tags_and(testdir): @@ -204,6 +263,29 @@ def test_tagged3(): res.assert_outcomes(passed=3) +def test_combine_tags_with_exclude(pytester): + pytester.makepyfile( + """ + import pytest + from pytest_tagging import combine_tags + + combine_tags("new_tag", "foo", "bar") + + @pytest.mark.tags('bar') + def test_tagged1(): + pass + @pytest.mark.tags('bar') + def test_tagged2(): + pass + @pytest.mark.tags('foo') + def test_tagged3(): + pass + """ + ) + result = pytester.runpytest("--exclude-tags=new_tag") + result.assert_outcomes(passed=0, failed=0) + + @pytest.mark.parametrize( ("options", "expected_in_output"), [