Skip to content

Commit

Permalink
feat: added --exclude-tags option (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
scastlara committed Apr 7, 2024
1 parent 6a6e29a commit c7154d8
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 112 deletions.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -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).
2 changes: 1 addition & 1 deletion pytest_tagging/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .plugin import combine_tags
from .selector import combine_tags

__all__ = [
"combine_tags",
Expand Down
6 changes: 6 additions & 0 deletions pytest_tagging/choices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import Enum


class OperandChoices(Enum):
OR = "OR"
AND = "AND"
113 changes: 37 additions & 76 deletions pytest_tagging/plugin.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions pytest_tagging/selector.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 8 additions & 2 deletions pytest_tagging/utils.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Loading

0 comments on commit c7154d8

Please sign in to comment.