From 3ebe76aa7a0aa853a6bb7e890355b56d66589633 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Wed, 5 Aug 2020 09:47:16 +0200 Subject: [PATCH 1/5] Implemented matching by marker expressions and expressions. --- src/pytask/__init__.py | 7 +- src/pytask/cli.py | 30 +-- src/pytask/collect.py | 15 +- src/pytask/config.py | 17 +- src/pytask/execute.py | 2 +- src/pytask/hookspecs.py | 4 +- src/pytask/main.py | 71 +------ src/pytask/mark/__init__.py | 0 src/pytask/mark/cli.py | 43 ----- src/pytask/mark/config.py | 27 --- src/pytask/mark_/__init__.py | 226 +++++++++++++++++++++++ src/pytask/mark_/expression.py | 220 ++++++++++++++++++++++ src/pytask/{mark => mark_}/structures.py | 6 +- src/pytask/nodes.py | 2 +- src/pytask/resolve_dependencies.py | 2 +- src/pytask/session.py | 34 ++++ src/pytask/skipping.py | 10 +- tests/test_mark.py | 8 +- tests/test_mark_cli.py | 2 +- tests/test_parametrize.py | 2 +- tests/test_skipping.py | 2 +- 21 files changed, 544 insertions(+), 186 deletions(-) delete mode 100644 src/pytask/mark/__init__.py delete mode 100644 src/pytask/mark/cli.py delete mode 100644 src/pytask/mark/config.py create mode 100644 src/pytask/mark_/__init__.py create mode 100644 src/pytask/mark_/expression.py rename src/pytask/{mark => mark_}/structures.py (97%) create mode 100644 src/pytask/session.py diff --git a/src/pytask/__init__.py b/src/pytask/__init__.py index 3c6f8df9..43deb66f 100644 --- a/src/pytask/__init__.py +++ b/src/pytask/__init__.py @@ -1,8 +1,5 @@ -import pluggy -from pytask.mark.structures import MARK_GEN as mark # noqa: F401, N811 - -hookimpl = pluggy.HookimplMarker("pytask") - +from pytask.config import hookimpl +from pytask.mark_ import MARK_GEN as mark # noqa: N811 __all__ = ["hookimpl", "mark"] __version__ = "0.0.4" diff --git a/src/pytask/cli.py b/src/pytask/cli.py index a5ea335f..6d2fad0c 100644 --- a/src/pytask/cli.py +++ b/src/pytask/cli.py @@ -2,7 +2,7 @@ from pathlib import Path import click -import pytask.mark.cli +from pytask.config import hookimpl from pytask.pluginmanager import get_plugin_manager @@ -22,23 +22,31 @@ def add_parameters(func): return func -@pytask.hookimpl +@hookimpl def pytask_add_hooks(pm): - """Add some hooks and plugins. - - This hook implementation registers only plugins which extend the command line - interface or patch the main entry-point :func:`pytask.hookspecs.pytask_main`. - - """ + from pytask import collect + from pytask import config from pytask import database from pytask import debugging + from pytask import execute + from pytask import logging from pytask import main - from pytask.mark import cli as mark_cli + from pytask import parametrize + from pytask import resolve_dependencies + from pytask import skipping + from pytask import mark_ + pm.register(collect) + pm.register(config) pm.register(database) pm.register(debugging) + pm.register(execute) + pm.register(logging) pm.register(main) - pm.register(mark_cli) + pm.register(parametrize) + pm.register(resolve_dependencies) + pm.register(skipping) + pm.register(mark_) def _to_path(ctx, param, value): # noqa: U100 @@ -46,7 +54,7 @@ def _to_path(ctx, param, value): # noqa: U100 return [Path(i).resolve() for i in value] -@pytask.hookimpl +@hookimpl def pytask_add_parameters_to_cli(command): additional_parameters = [ click.Argument( diff --git a/src/pytask/collect.py b/src/pytask/collect.py index 59130f58..60690fa7 100644 --- a/src/pytask/collect.py +++ b/src/pytask/collect.py @@ -11,7 +11,7 @@ import pytask from pytask.exceptions import CollectionError from pytask.exceptions import TaskDuplicatedError -from pytask.mark.structures import has_marker +from pytask.mark_ import has_marker from pytask.nodes import FilePathNode from pytask.nodes import PythonFunctionTask from pytask.report import CollectionReportFile @@ -22,8 +22,8 @@ def pytask_collect(session): reports = _collect_from_paths(session) tasks = _extract_tasks_from_reports(reports) - session.hook.pytask_collect_modify_tasks(tasks=tasks, config=session.config) - session.hook.pytask_collect_log(reports=reports, tasks=tasks, config=session.config) + session.hook.pytask_collect_modify_tasks(session=session, tasks=tasks) + session.hook.pytask_collect_log(session=session, reports=reports, tasks=tasks) session.collection_reports = reports session.tasks = tasks @@ -211,10 +211,13 @@ def _extract_tasks_from_reports(reports): @pytask.hookimpl -def pytask_collect_log(reports, tasks, config): - tm_width = config["terminal_width"] +def pytask_collect_log(session, reports, tasks): + tm_width = session.config["terminal_width"] - click.echo(f"Collected {len(tasks)} task(s).") + message = f"Collected {len(tasks)} task(s)." + if session.deselected: + message += f" Deselected {len(session.deselected)} task(s)." + click.echo(message) failed_reports = [i for i in reports if not i.successful] if failed_reports: diff --git a/src/pytask/config.py b/src/pytask/config.py index c562ceb7..d88c7ce7 100644 --- a/src/pytask/config.py +++ b/src/pytask/config.py @@ -7,11 +7,14 @@ from pathlib import Path import click -import pytask -from pytask.mark.structures import MARK_GEN +import pluggy from pytask.shared import get_first_not_none_value from pytask.shared import to_list + +hookimpl = pluggy.HookimplMarker("pytask") + + IGNORED_FILES_AND_FOLDERS = [ "*/.git/*", "*/__pycache__/*", @@ -20,7 +23,7 @@ ] -@pytask.hookimpl +@hookimpl def pytask_configure(pm, config_from_cli): config = {"pm": pm, "terminal_width": _get_terminal_width()} @@ -38,20 +41,18 @@ def pytask_configure(pm, config_from_cli): "produces": "Attach a product/products to a task.", } - config["pm"].hook.pytask_parse_config( + pm.hook.pytask_parse_config( config=config, config_from_cli=config_from_cli, config_from_file=config_from_file, ) - config["pm"].hook.pytask_post_parse(config=config) - - MARK_GEN.config = config + pm.hook.pytask_post_parse(config=config) return config -@pytask.hookimpl +@hookimpl def pytask_parse_config(config, config_from_cli, config_from_file): config["ignore"] = ( get_first_not_none_value( diff --git a/src/pytask/execute.py b/src/pytask/execute.py index 96b092e5..e9f44d37 100644 --- a/src/pytask/execute.py +++ b/src/pytask/execute.py @@ -12,7 +12,7 @@ from pytask.database import create_or_update_state from pytask.exceptions import ExecutionError from pytask.exceptions import NodeNotFoundError -from pytask.mark.structures import Mark +from pytask.mark_ import Mark from pytask.nodes import FilePathNode from pytask.report import ExecutionReport from pytask.report import format_execute_footer diff --git a/src/pytask/hookspecs.py b/src/pytask/hookspecs.py index a15c8789..db97a3be 100644 --- a/src/pytask/hookspecs.py +++ b/src/pytask/hookspecs.py @@ -104,7 +104,7 @@ def pytask_ignore_collect(path, config): @hookspec -def pytask_collect_modify_tasks(tasks, config): +def pytask_collect_modify_tasks(session, tasks): """Modify tasks after they have been collected. This hook can be used to deselect tasks when they match a certain keyword or mark. @@ -161,7 +161,7 @@ def pytask_collect_node(path, node): @hookspec(firstresult=True) -def pytask_collect_log(reports, tasks, config): +def pytask_collect_log(session, reports, tasks): """Log errors occurring during the collection. This hook reports errors during the collection. diff --git a/src/pytask/main.py b/src/pytask/main.py index 380afc1a..8409276e 100644 --- a/src/pytask/main.py +++ b/src/pytask/main.py @@ -1,39 +1,14 @@ import pdb -import sys -import attr -import pytask +import pytask.pluginmanager +from pytask import cli from pytask.database import create_database from pytask.enums import ExitCode from pytask.exceptions import CollectionError from pytask.exceptions import ExecutionError from pytask.exceptions import ResolvingDependenciesError from pytask.pluginmanager import get_plugin_manager - - -@pytask.hookimpl -def pytask_add_hooks(pm): - from pytask import collect - from pytask import config - from pytask import database - from pytask import debugging - from pytask import execute - from pytask import logging - from pytask import parametrize - from pytask import resolve_dependencies - from pytask import skipping - from pytask.mark import config as mark_config - - pm.register(collect) - pm.register(config) - pm.register(database) - pm.register(debugging) - pm.register(execute) - pm.register(logging) - pm.register(parametrize) - pm.register(resolve_dependencies) - pm.register(skipping) - pm.register(mark_config) +from pytask.session import Session @pytask.hookimpl @@ -52,13 +27,13 @@ def pytask_main(config_from_cli): Returns ------- - session : pytask.main.Session + session : pytask.session.Session The session captures all the information of the current run. """ try: pm = get_plugin_manager() - pm.register(sys.modules[__name__]) + pm.register(cli) pm.hook.pytask_add_hooks(pm=pm) config = pm.hook.pytask_configure(pm=pm, config_from_cli=config_from_cli) @@ -93,39 +68,3 @@ def pytask_main(config_from_cli): raise e return session - - -@attr.s -class Session: - """The session of pytask.""" - - config = attr.ib(type=dict) - """dict: A dictionary containing the configuration of the session.""" - - hook = attr.ib() - """pluggy.hooks._HookRelay: Holds all hooks collected by pytask.""" - - collection_reports = attr.ib(default=None) - """Optional[List[pytask.report.ExecutionReport]]: Reports for collected items. - - The reports capture errors which happened while collecting tasks. - - """ - - tasks = attr.ib(default=None) - """Optional[List[pytask.nodes.MetaTask]]: List of collected tasks.""" - - resolving_dependencies_report = attr.ib(default=None) - """Optional[pytask.report.ResolvingDependenciesReport]: A report. - - Report when resolving dependencies failed. - - """ - - execution_reports = attr.ib(default=None) - """Optional[List[pytask.report.ExecutionReport]]: Reports for executed tasks.""" - - @classmethod - def from_config(cls, config): - """Construct the class from a config.""" - return cls(config, config["pm"].hook) diff --git a/src/pytask/mark/__init__.py b/src/pytask/mark/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pytask/mark/cli.py b/src/pytask/mark/cli.py deleted file mode 100644 index 678c1d91..00000000 --- a/src/pytask/mark/cli.py +++ /dev/null @@ -1,43 +0,0 @@ -import textwrap - -import click -import pytask -from pytask import main -from pytask.enums import ExitCode -from pytask.main import Session -from pytask.pluginmanager import get_plugin_manager - - -@pytask.hookimpl -def pytask_add_parameters_to_cli(command: click.Command) -> None: - command.params.append( - click.Option(["--markers"], is_flag=True, help="Show available markers.") - ) - - -@pytask.hookimpl(tryfirst=True) -def pytask_main(config_from_cli: dict) -> int: - if config_from_cli.get("markers", False): - try: - # Duplication of the same mechanism in :func:`pytask.main.pytask_main`. - pm = get_plugin_manager() - pm.register(main) - pm.hook.pytask_add_hooks(pm=pm) - - config = pm.hook.pytask_configure(pm=pm, config_from_cli=config_from_cli) - - session = Session.from_config(config) - session.exit_code = ExitCode.OK - - except Exception as e: - raise Exception("Error while configuring pytask.") from e - - for name, description in config["markers"].items(): - click.echo( - textwrap.fill( - f"pytask.mark.{name}: {description}", width=config["terminal_width"] - ) - ) - click.echo("") - - return session diff --git a/src/pytask/mark/config.py b/src/pytask/mark/config.py deleted file mode 100644 index bb0cc2ca..00000000 --- a/src/pytask/mark/config.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytask - - -@pytask.hookimpl -def pytask_parse_config(config, config_from_file): - markers = _read_marker_mapping_from_ini(config_from_file.get("markers", "")) - config["markers"] = {**markers, **config["markers"]} - - -def _read_marker_mapping_from_ini(string: str) -> dict: - # Split by newlines and remove empty strings. - lines = filter(lambda x: bool(x), string.split("\n")) - mapping = {} - for line in lines: - try: - key, value = line.split(":") - except ValueError as e: - key = line - value = "" - if not key.isidentifier(): - raise ValueError( - f"{key} is not a valid Python name and cannot be used as a marker." - ) from e - - mapping[key] = value - - return mapping diff --git a/src/pytask/mark_/__init__.py b/src/pytask/mark_/__init__.py new file mode 100644 index 00000000..3ca33049 --- /dev/null +++ b/src/pytask/mark_/__init__.py @@ -0,0 +1,226 @@ +import textwrap +from typing import AbstractSet + +import attr +import click +from pytask.config import hookimpl +from pytask.enums import ExitCode +from pytask.mark_.expression import Expression +from pytask.mark_.expression import ParseError +from pytask.mark_.structures import get_marks_from_obj +from pytask.mark_.structures import get_specific_markers_from_task +from pytask.mark_.structures import has_marker +from pytask.mark_.structures import Mark +from pytask.mark_.structures import MARK_GEN +from pytask.mark_.structures import MarkDecorator +from pytask.mark_.structures import MarkGenerator +from pytask.pluginmanager import get_plugin_manager +from pytask.session import Session + + +__all__ = [ + "get_specific_markers_from_task", + "get_marks_from_obj", + "has_marker", + "Expression", + "Mark", + "MarkDecorator", + "MarkGenerator", + "MARK_GEN", + "ParseError", +] + + +@hookimpl +def pytask_add_parameters_to_cli(command: click.Command) -> None: + additional_parameters = [ + click.Option(["--markers"], is_flag=True, help="Show available markers."), + click.Option( + ["-m", "marker_expression"], + metavar="MARKER_EXPRESSION", + type=str, + help="Expression for selecting tasks with markers.", + ), + click.Option( + ["-k", "expression"], + metavar="EXPRESSION", + type=str, + help="Expression for selecting tasks with substrings.", + ), + ] + command.params.extend(additional_parameters) + + +@hookimpl +def pytask_parse_config(config, config_from_cli, config_from_file): + markers = _read_marker_mapping_from_ini(config_from_file.get("markers", "")) + config["markers"] = {**markers, **config["markers"]} + + config["expression"] = config_from_cli.get("expression") + config["marker_expression"] = config_from_cli.get("marker_expression") + + MARK_GEN.config = config + + +def _read_marker_mapping_from_ini(string: str) -> dict: + # Split by newlines and remove empty strings. + lines = filter(lambda x: bool(x), string.split("\n")) + mapping = {} + for line in lines: + try: + key, value = line.split(":") + except ValueError as e: + key = line + value = "" + if not key.isidentifier(): + raise ValueError( + f"{key} is not a valid Python name and cannot be used as a marker." + ) from e + + mapping[key] = value + + return mapping + + +@hookimpl(tryfirst=True) +def pytask_main(config_from_cli: dict) -> int: + if config_from_cli.get("markers", False): + try: + # Duplication of the same mechanism in :func:`pytask.main.pytask_main`. + pm = get_plugin_manager() + from pytask import cli + + pm.register(cli) + pm.hook.pytask_add_hooks(pm=pm) + + config = pm.hook.pytask_configure(pm=pm, config_from_cli=config_from_cli) + + session = Session.from_config(config) + session.exit_code = ExitCode.OK + + except Exception as e: + raise Exception("Error while configuring pytask.") from e + + for name, description in config["markers"].items(): + click.echo( + textwrap.fill( + f"pytask.mark.{name}: {description}", width=config["terminal_width"] + ) + ) + click.echo("") + + return session + + +@attr.s(slots=True) +class KeywordMatcher: + """A matcher for keywords. + + Given a list of names, matches any substring of one of these names. The string + inclusion check is case-insensitive. + + Will match on the name of colitem, including the names of its parents. Only matches + names of items which are either a :class:`Class` or :class:`Function`. + + Additionally, matches on names in the 'extra_keyword_matches' set of any task, as + well as names directly assigned to test functions. + + """ + + _names = attr.ib(type=AbstractSet[str]) + + @classmethod + def from_task(cls, task) -> "KeywordMatcher": + mapped_names = {task.name} + + # Add the names attached to the current function through direct assignment. + function_obj = getattr(task, "function", None) + if function_obj: + mapped_names.update(function_obj.__dict__) + + # Add the markers to the keywords as we no longer handle them correctly. + mapped_names.update(mark.name for mark in task.markers) + + return cls(mapped_names) + + def __call__(self, subname: str) -> bool: + subname = subname.lower() + names = (name.lower() for name in self._names) + + for name in names: + if subname in name: + return True + return False + + +def deselect_by_keyword(session, tasks) -> None: + keywordexpr = session.config["expression"] + if not keywordexpr: + return + + try: + expression = Expression.compile_(keywordexpr) + except ParseError as e: + raise ValueError( + f"Wrong expression passed to '-k': {keywordexpr}: {e}" + ) from None + + remaining = [] + deselected = [] + for task in tasks: + if keywordexpr and not expression.evaluate(KeywordMatcher.from_task(task)): + deselected.append(task) + else: + remaining.append(task) + + if deselected: + session.deselected.extend(deselected) + tasks[:] = remaining + + +@attr.s(slots=True) +class MarkMatcher: + """A matcher for markers which are present. + + Tries to match on any marker names, attached to the given colitem. + + """ + + own_mark_names = attr.ib() + + @classmethod + def from_task(cls, task) -> "MarkMatcher": + mark_names = {mark.name for mark in task.markers} + return cls(mark_names) + + def __call__(self, name: str) -> bool: + return name in self.own_mark_names + + +def deselect_by_mark(session, tasks) -> None: + matchexpr = session.config["marker_expression"] + if not matchexpr: + return + + try: + expression = Expression.compile_(matchexpr) + except ParseError as e: + raise ValueError(f"Wrong expression passed to '-m': {matchexpr}: {e}") from None + + remaining = [] + deselected = [] + for task in tasks: + if expression.evaluate(MarkMatcher.from_task(task)): + remaining.append(task) + else: + deselected.append(task) + + if deselected: + session.deselected.extend(deselected) + tasks[:] = remaining + + +@hookimpl +def pytask_collect_modify_tasks(session, tasks): + deselect_by_keyword(session, tasks) + deselect_by_mark(session, tasks) diff --git a/src/pytask/mark_/expression.py b/src/pytask/mark_/expression.py new file mode 100644 index 00000000..6c338982 --- /dev/null +++ b/src/pytask/mark_/expression.py @@ -0,0 +1,220 @@ +r"""Evaluate match expressions, as used by `-k` and `-m`. + +The grammar is: + +expression: expr? EOF +expr: and_expr ('or' and_expr)* +and_expr: not_expr ('and' not_expr)* +not_expr: 'not' not_expr | '(' expr ')' | ident +ident: (\w|:|\+|-|\.|\[|\])+ + +The semantics are: + +- Empty expression evaluates to False. +- ident evaluates to True of False according to a provided matcher function. +- or/and/not evaluate according to the usual boolean semantics. + +""" +import ast +import enum +import re +import types +from typing import Callable +from typing import Iterator +from typing import Mapping +from typing import Optional +from typing import Sequence + +import attr + + +__all__ = ["Expression", "ParseError"] + + +class TokenType(enum.Enum): + LPAREN = "left parenthesis" + RPAREN = "right parenthesis" + OR = "or" + AND = "and" + NOT = "not" + IDENT = "identifier" + EOF = "end of input" + + +@attr.s(frozen=True, slots=True) +class Token: + type_ = attr.ib(type=TokenType) + value = attr.ib(type=str) + pos = attr.ib(type=int) + + +class ParseError(Exception): + """The expression contains invalid syntax. + + :param column: The column in the line where the error occurred (1-based). + :param message: A description of the error. + + """ + + def __init__(self, column: int, message: str) -> None: + self.column = column + self.message = message + + def __str__(self) -> str: + return f"at column {self.column}: {self.message}" + + +class Scanner: + __slots__ = ("tokens", "current") + + def __init__(self, input_: str) -> None: + self.tokens = self.lex(input_) + self.current = next(self.tokens) + + def lex(self, input_: str) -> Iterator[Token]: + pos = 0 + while pos < len(input_): + if input_[pos] in (" ", "\t"): + pos += 1 + elif input_[pos] == "(": + yield Token(TokenType.LPAREN, "(", pos) + pos += 1 + elif input_[pos] == ")": + yield Token(TokenType.RPAREN, ")", pos) + pos += 1 + else: + match = re.match(r"(:?\w|:|\+|-|\.|\[|\])+", input_[pos:]) + if match: + value = match.group(0) + if value == "or": + yield Token(TokenType.OR, value, pos) + elif value == "and": + yield Token(TokenType.AND, value, pos) + elif value == "not": + yield Token(TokenType.NOT, value, pos) + else: + yield Token(TokenType.IDENT, value, pos) + pos += len(value) + else: + raise ParseError( + pos + 1, 'unexpected character "{}"'.format(input_[pos]), + ) + yield Token(TokenType.EOF, "", pos) + + def accept(self, type_: TokenType, *, reject: bool = False) -> Optional[Token]: + if self.current.type_ is type_: + token = self.current + if token.type_ is not TokenType.EOF: + self.current = next(self.tokens) + return token + if reject: + self.reject((type_,)) + return None + + def reject(self, expected: Sequence[TokenType]): + raise ParseError( + self.current.pos + 1, + "expected {}; got {}".format( + " OR ".join(type_.value for type_ in expected), + self.current.type_.value, + ), + ) + + +# True, False and None are legal match expression identifiers, +# but illegal as Python identifiers. To fix this, this prefix +# is added to identifiers in the conversion to Python AST. +IDENT_PREFIX = "$" + + +def expression(s: Scanner) -> ast.Expression: + if s.accept(TokenType.EOF): + ret = ast.NameConstant(False) # type: ast.expr + else: + ret = expr(s) + s.accept(TokenType.EOF, reject=True) + return ast.fix_missing_locations(ast.Expression(ret)) + + +def expr(s: Scanner) -> ast.expr: + ret = and_expr(s) + while s.accept(TokenType.OR): + rhs = and_expr(s) + ret = ast.BoolOp(ast.Or(), [ret, rhs]) + return ret + + +def and_expr(s: Scanner) -> ast.expr: + ret = not_expr(s) + while s.accept(TokenType.AND): + rhs = not_expr(s) + ret = ast.BoolOp(ast.And(), [ret, rhs]) + return ret + + +def not_expr(s: Scanner) -> ast.expr: + if s.accept(TokenType.NOT): + return ast.UnaryOp(ast.Not(), not_expr(s)) + if s.accept(TokenType.LPAREN): + ret = expr(s) + s.accept(TokenType.RPAREN, reject=True) + return ret + ident = s.accept(TokenType.IDENT) + if ident: + return ast.Name(IDENT_PREFIX + ident.value, ast.Load()) + s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT)) + + +class MatcherAdapter(Mapping[str, bool]): + """Adapts a matcher function to a locals mapping as required by eval().""" + + def __init__(self, matcher: Callable[[str], bool]) -> None: + self.matcher = matcher + + def __getitem__(self, key: str) -> bool: + return self.matcher(key[len(IDENT_PREFIX) :]) + + def __iter__(self) -> Iterator[str]: + raise NotImplementedError() + + def __len__(self) -> int: + raise NotImplementedError() + + +class Expression: + """A compiled match expression as used by -k and -m. + + The expression can be evaluated against different matchers. + + """ + + __slots__ = ("code",) + + def __init__(self, code: types.CodeType) -> None: + self.code = code + + @classmethod + def compile_(cls, input_: str) -> "Expression": + """Compile a match expression. + + :param input: The input expression - one line. + """ + astexpr = expression(Scanner(input_)) + code = compile( + astexpr, filename="", mode="eval", + ) # type: types.CodeType + return cls(code) + + def evaluate(self, matcher: Callable[[str], bool]) -> bool: + """Evaluate the match expression. + + :param matcher: + Given an identifier, should return whether it matches or not. + Should be prepared to handle arbitrary strings as input. + + :returns: Whether the expression matches or not. + """ + ret = eval( + self.code, {"__builtins__": {}}, MatcherAdapter(matcher) + ) # type: bool + return ret diff --git a/src/pytask/mark/structures.py b/src/pytask/mark_/structures.py similarity index 97% rename from src/pytask/mark/structures.py rename to src/pytask/mark_/structures.py index 377af121..d5091d11 100644 --- a/src/pytask/mark/structures.py +++ b/src/pytask/mark_/structures.py @@ -11,7 +11,7 @@ import attr -def get_markers_from_task(task, marker_name): +def get_specific_markers_from_task(task, marker_name): return [marker for marker in task.markers if marker.name == marker_name] @@ -172,7 +172,7 @@ def normalize_mark_list(mark_list: Iterable[Union[Mark, MarkDecorator]]) -> List Parameters ---------- - mark_list : List[Union[Mark, Markdecorator]] + mark_list : List[Union[Mark, MarkDecorator]] Returns ------- @@ -224,7 +224,7 @@ def __getattr__(self, name: str) -> MarkDecorator: raise AttributeError("Marker name must NOT start with underscore") if self.config is not None: - # We store a set of markers as a performance optimisation - if a mark + # We store a set of markers as a performance optimization - if a mark # name is in the set we definitely know it, but a mark may be known and # not in the set. We therefore start by updating the set! if name not in self.markers: diff --git a/src/pytask/nodes.py b/src/pytask/nodes.py index 2b97509e..5881cc3a 100644 --- a/src/pytask/nodes.py +++ b/src/pytask/nodes.py @@ -12,7 +12,7 @@ import attr from pytask.exceptions import NodeNotCollectedError from pytask.exceptions import NodeNotFoundError -from pytask.mark.structures import get_marks_from_obj +from pytask.mark_ import get_marks_from_obj from pytask.shared import to_list diff --git a/src/pytask/resolve_dependencies.py b/src/pytask/resolve_dependencies.py index 3c9fddd1..75e479c2 100644 --- a/src/pytask/resolve_dependencies.py +++ b/src/pytask/resolve_dependencies.py @@ -12,7 +12,7 @@ from pytask.database import State from pytask.exceptions import NodeNotFoundError from pytask.exceptions import ResolvingDependenciesError -from pytask.mark.structures import Mark +from pytask.mark_ import Mark from pytask.report import ResolvingDependenciesReport diff --git a/src/pytask/session.py b/src/pytask/session.py new file mode 100644 index 00000000..a3e8a05f --- /dev/null +++ b/src/pytask/session.py @@ -0,0 +1,34 @@ +import attr + + +@attr.s +class Session: + """The session of pytask.""" + + config = attr.ib(type=dict) + """dict: A dictionary containing the configuration of the session.""" + hook = attr.ib() + """pluggy.hooks._HookRelay: Holds all hooks collected by pytask.""" + collection_reports = attr.ib(default=None) + """Optional[List[pytask.report.ExecutionReport]]: Reports for collected items. + + The reports capture errors which happened while collecting tasks. + + """ + tasks = attr.ib(default=None) + """Optional[List[pytask.nodes.MetaTask]]: List of collected tasks.""" + deselected = attr.ib(default=[]) + """Optional[List[pytask.nodes.MetaTask]]: list of deselected tasks.""" + resolving_dependencies_report = attr.ib(default=None) + """Optional[pytask.report.ResolvingDependenciesReport]: A report. + + Report when resolving dependencies failed. + + """ + execution_reports = attr.ib(default=None) + """Optional[List[pytask.report.ExecutionReport]]: Reports for executed tasks.""" + + @classmethod + def from_config(cls, config): + """Construct the class from a config.""" + return cls(config, config["pm"].hook) diff --git a/src/pytask/skipping.py b/src/pytask/skipping.py index 797cbfaf..58b4dcda 100644 --- a/src/pytask/skipping.py +++ b/src/pytask/skipping.py @@ -1,8 +1,8 @@ import click import pytask from pytask.dag import descending_tasks -from pytask.mark.structures import get_markers_from_task -from pytask.mark.structures import Mark +from pytask.mark_ import get_specific_markers_from_task +from pytask.mark_ import Mark from pytask.outcomes import Skipped from pytask.outcomes import SkippedAncestorFailed from pytask.outcomes import SkippedUnchanged @@ -22,16 +22,16 @@ def pytask_parse_config(config): @pytask.hookimpl def pytask_execute_task_setup(task): - markers = get_markers_from_task(task, "skip_unchanged") + markers = get_specific_markers_from_task(task, "skip_unchanged") if markers: raise SkippedUnchanged - markers = get_markers_from_task(task, "skip_ancestor_failed") + markers = get_specific_markers_from_task(task, "skip_ancestor_failed") if markers: message = "\n".join([marker.kwargs["reason"] for marker in markers]) raise SkippedAncestorFailed(message) - markers = get_markers_from_task(task, "skip") + markers = get_specific_markers_from_task(task, "skip") if markers: raise Skipped diff --git a/tests/test_mark.py b/tests/test_mark.py index 37d69171..d26043f0 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -4,7 +4,7 @@ import pytask import pytest from pytask.main import pytask_main -from pytask.mark.structures import MarkGenerator as Mark +from pytask.mark_ import MarkGenerator @pytest.mark.unit @@ -15,9 +15,9 @@ def test_mark_exists_in_pytask_namespace(attribute): @pytest.mark.unit def test_pytask_mark_notcallable() -> None: - mark = Mark() + mark = MarkGenerator() with pytest.raises(TypeError): - mark() # type: ignore[operator] + mark() @pytest.mark.unit @@ -39,7 +39,7 @@ class SomeClass: @pytest.mark.unit def test_pytask_mark_name_starts_with_underscore(): - mark = Mark() + mark = MarkGenerator() with pytest.raises(AttributeError): mark._some_name diff --git a/tests/test_mark_cli.py b/tests/test_mark_cli.py index 2b9b5cd3..35099db1 100644 --- a/tests/test_mark_cli.py +++ b/tests/test_mark_cli.py @@ -1,7 +1,7 @@ import textwrap import pytest -from pytask.mark.cli import pytask_main +from pytask.mark_ import pytask_main @pytest.mark.end_to_end diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py index 82c9f314..d63f4207 100644 --- a/tests/test_parametrize.py +++ b/tests/test_parametrize.py @@ -4,7 +4,7 @@ import pytask import pytest from pytask.main import pytask_main -from pytask.mark.structures import Mark +from pytask.mark_ import Mark from pytask.parametrize import _parse_arg_names from pytask.parametrize import _parse_parametrize_markers from pytask.parametrize import pytask_parametrize_task diff --git a/tests/test_skipping.py b/tests/test_skipping.py index 3b3f3123..42aebd28 100644 --- a/tests/test_skipping.py +++ b/tests/test_skipping.py @@ -4,7 +4,7 @@ import pytest from pytask.main import pytask_main -from pytask.mark.structures import Mark +from pytask.mark_ import Mark from pytask.outcomes import Skipped from pytask.outcomes import SkippedAncestorFailed from pytask.outcomes import SkippedUnchanged From 1a8055889d82b020ea97157f79a5839d3b9b86ea Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Wed, 5 Aug 2020 09:53:01 +0200 Subject: [PATCH 2/5] Add to changes.rst. --- docs/changes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changes.rst b/docs/changes.rst index df82e868..5b5b0f76 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,7 @@ all releases are available on `Anaconda.org - :gh:`10` turns parametrization into a plugin. - :gh:`11` extends the documentation. - :gh:`12` replaces ``pytest.mark`` with ``pytask.mark``. +- :gh:`13` implements selecting tasks via expressions or marker expressions. 0.0.4 - 2020-07-22 From 82ad81d36490e68f0d7781a28d5a7e1ca45b39fc Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 7 Aug 2020 09:28:54 +0200 Subject: [PATCH 3/5] More tests. --- src/pytask/__init__.py | 2 +- src/pytask/cli.py | 22 +++-- src/pytask/mark_/__init__.py | 9 +- src/pytask/mark_/structures.py | 9 +- tests/test_mark.py | 149 +++++++++++++++++++++++++++++ tests/test_mark_expression.py | 168 +++++++++++++++++++++++++++++++++ 6 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 tests/test_mark_expression.py diff --git a/src/pytask/__init__.py b/src/pytask/__init__.py index 43deb66f..fa9be3ad 100644 --- a/src/pytask/__init__.py +++ b/src/pytask/__init__.py @@ -1,5 +1,5 @@ from pytask.config import hookimpl from pytask.mark_ import MARK_GEN as mark # noqa: N811 -__all__ = ["hookimpl", "mark"] +__all__ = ["hookimpl", "main", "mark"] __version__ = "0.0.4" diff --git a/src/pytask/cli.py b/src/pytask/cli.py index 6d2fad0c..bbcc0781 100644 --- a/src/pytask/cli.py +++ b/src/pytask/cli.py @@ -11,17 +11,21 @@ def add_parameters(func): """Add parameters from plugins to the commandline interface.""" - pm = get_plugin_manager() - pm.register(sys.modules[__name__]) - pm.hook.pytask_add_hooks(pm=pm) + pm = _prepare_plugin_manager() pm.hook.pytask_add_parameters_to_cli(command=func) - # Hack to pass the plugin manager via a hidden option to the ``config_from_cli``. func.params.append(click.Option(["--pm"], default=pm, hidden=True)) return func +def _prepare_plugin_manager(): + pm = get_plugin_manager() + pm.register(sys.modules[__name__]) + pm.hook.pytask_add_hooks(pm=pm) + return pm + + @hookimpl def pytask_add_hooks(pm): from pytask import collect @@ -80,6 +84,12 @@ def pytask_add_parameters_to_cli(command): @click.version_option() def pytask(**config_from_cli): """Command-line interface for pytask.""" - pm = config_from_cli["pm"] - session = pm.hook.pytask_main(config_from_cli=config_from_cli) + session = main(config_from_cli) sys.exit(session.exit_code) + + +def main(config_from_cli): + pm = config_from_cli.get("pm", _prepare_plugin_manager()) + session = pm.hook.pytask_main(config_from_cli=config_from_cli) + + return session diff --git a/src/pytask/mark_/__init__.py b/src/pytask/mark_/__init__.py index 3ca33049..7df2c1cc 100644 --- a/src/pytask/mark_/__init__.py +++ b/src/pytask/mark_/__init__.py @@ -16,6 +16,7 @@ from pytask.mark_.structures import MarkGenerator from pytask.pluginmanager import get_plugin_manager from pytask.session import Session +from pytask.shared import get_first_not_none_value __all__ = [ @@ -35,6 +36,9 @@ def pytask_add_parameters_to_cli(command: click.Command) -> None: additional_parameters = [ click.Option(["--markers"], is_flag=True, help="Show available markers."), + click.Option( + ["--strict-markers"], is_flag=True, help="Raise errors for unknown marks." + ), click.Option( ["-m", "marker_expression"], metavar="MARKER_EXPRESSION", @@ -55,6 +59,9 @@ def pytask_add_parameters_to_cli(command: click.Command) -> None: def pytask_parse_config(config, config_from_cli, config_from_file): markers = _read_marker_mapping_from_ini(config_from_file.get("markers", "")) config["markers"] = {**markers, **config["markers"]} + config["strict_markers"] = get_first_not_none_value( + config, config_from_file, config_from_cli, key="strict_markers", default=False + ) config["expression"] = config_from_cli.get("expression") config["marker_expression"] = config_from_cli.get("marker_expression") @@ -119,7 +126,7 @@ class KeywordMatcher: Given a list of names, matches any substring of one of these names. The string inclusion check is case-insensitive. - Will match on the name of colitem, including the names of its parents. Only matches + Will match on the name of the task, including the names of its parents. Only matches names of items which are either a :class:`Class` or :class:`Function`. Additionally, matches on names in the 'extra_keyword_matches' set of any task, as diff --git a/src/pytask/mark_/structures.py b/src/pytask/mark_/structures.py index d5091d11..42b1942b 100644 --- a/src/pytask/mark_/structures.py +++ b/src/pytask/mark_/structures.py @@ -228,16 +228,13 @@ def __getattr__(self, name: str) -> MarkDecorator: # name is in the set we definitely know it, but a mark may be known and # not in the set. We therefore start by updating the set! if name not in self.markers: - for line in self.config["markers"]: - # example lines: "skipif(condition): skip the given task if..." - # or "hypothesis: tasks which use Hypothesis", so to get the - # marker name we split on both `:` and `(`. - marker = line.split(":")[0].split("(")[0].strip() - self.markers.add(marker) + self.markers.update(self.config["markers"]) # If the name is not in the set of known marks after updating, # then it really is time to issue a warning or an error. if name not in self.markers: + if self.config["strict_markers"]: + raise ValueError(f"Unknown pytask.mark.{name}.") # Raise a specific error for common misspellings of "parametrize". if name in ["parameterize", "parametrise", "parameterise"]: warnings.warn(f"Unknown '{name}' mark, did you mean 'parametrize'?") diff --git a/tests/test_mark.py b/tests/test_mark.py index d26043f0..344665cf 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -3,6 +3,7 @@ import pytask import pytest +from pytask.cli import main from pytask.main import pytask_main from pytask.mark_ import MarkGenerator @@ -63,3 +64,151 @@ def test_ini_markers(tmp_path, config_name): assert session.exit_code == 0 assert "a1" in session.config["markers"] assert "a2" in session.config["markers"] + + +@pytest.mark.end_to_end +@pytest.mark.parametrize("config_name", ["pytask.ini", "tox.ini", "setup.cfg"]) +def test_markers_option(capsys, tmp_path, config_name): + tmp_path.joinpath(config_name).write_text( + textwrap.dedent( + """ + [pytask] + markers = + a1: this is a webtest marker + a2: this is a smoke marker + nodescription + """ + ) + ) + + session = main({"paths": tmp_path, "markers": True}) + + assert session.exit_code == 0 + + captured = capsys.readouterr() + for out in ["pytask.mark.a1", "pytask.mark.a2", "pytask.mark.nodescription"]: + assert out in captured.out + + +@pytest.mark.end_to_end +@pytest.mark.filterwarnings("ignore:Unknown pytask.mark.") +@pytest.mark.parametrize("config_name", ["pytask.ini", "tox.ini", "setup.cfg"]) +def test_ini_markers_whitespace(tmp_path, config_name): + tmp_path.joinpath(config_name).write_text( + textwrap.dedent( + """ + [pytask] + markers = + a1 : this is a whitespace marker + """ + ) + ) + tmp_path.joinpath("task_dummy.py").write_text( + textwrap.dedent( + """ + import pytask + @pytask.mark.a1 + def test_markers(): + assert True + """ + ) + ) + + session = main({"paths": tmp_path, "strict_markers": True}) + assert session.exit_code == 2 + assert isinstance(session.collection_reports[0].exc_info[1], ValueError) + + +@pytest.mark.filterwarnings("ignore:Unknown pytask.mark.") +@pytest.mark.parametrize( + ("expr", "expected_passed"), + [ + ("xyz", ["task_one"]), + ("((( xyz)) )", ["task_one"]), + ("not not xyz", ["task_one"]), + ("xyz and xyz2", []), + ("xyz2", ["task_two"]), + ("xyz or xyz2", ["task_one", "task_two"]), + ], +) +def test_mark_option(tmp_path, expr: str, expected_passed: str) -> None: + tmp_path.joinpath("task_dummy.py").write_text( + textwrap.dedent( + """ + import pytask + @pytask.mark.xyz + def task_one(): + pass + @pytask.mark.xyz2 + def task_two(): + pass + """ + ) + ) + session = main({"paths": tmp_path, "marker_expression": expr}) + + tasks_that_run = [report.task.name for report in session.execution_reports] + assert set(tasks_that_run) == set(expected_passed) + + +@pytest.mark.parametrize( + ("expr", "expected_passed"), + [ + ("interface", ["task_interface"]), + ("not interface", ["task_nointer", "task_pass", "task_1", "task_2"]), + ("pass", ["task_pass"]), + ("not pass", ["task_interface", "task_nointer", "task_1", "task_2"]), + ("not not not (pass)", ["task_interface", "task_nointer", "task_1", "task_2"]), + ("1 or 2", ["task_1", "task_2"]), + ("not (1 or 2)", ["task_interface", "task_nointer", "task_pass"]), + ], +) +def test_keyword_option_custom(tmp_path, expr: str, expected_passed: str) -> None: + tmp_path.joinpath("task_dummy.py").write_text( + textwrap.dedent( + """ + def task_interface(): + pass + def task_nointer(): + pass + def task_pass(): + pass + def task_1(): + pass + def task_2(): + pass + """ + ) + ) + session = main({"paths": tmp_path, "expression": expr}) + assert session.exit_code == 0 + + tasks_that_run = [report.task.name for report in session.execution_reports] + assert set(tasks_that_run) == set(expected_passed) + + +@pytest.mark.parametrize( + ("expr", "expected_passed"), + [ + ("arg0", ["task_func[arg0]"]), + ("arg1", ["task_func[arg1]"]), + ("arg2", ["task_func[arg2]"]), + ], +) +def test_keyword_option_parametrize(tmp_path, expr: str, expected_passed: str) -> None: + tmp_path.joinpath("task_dummy.py").write_text( + textwrap.dedent( + """ + import pytask + @pytask.mark.parametrize("arg", [None, 1.3, "2-3"]) + def task_func(arg): + pass + """ + ) + ) + + session = main({"paths": tmp_path, "expression": expr}) + assert session.exit_code == 0 + + tasks_that_run = [report.task.name for report in session.execution_reports] + assert set(tasks_that_run) == set(expected_passed) diff --git a/tests/test_mark_expression.py b/tests/test_mark_expression.py new file mode 100644 index 00000000..e5176c4c --- /dev/null +++ b/tests/test_mark_expression.py @@ -0,0 +1,168 @@ +from typing import Callable + +import pytest +from pytask.mark_.expression import Expression +from pytask.mark_.expression import ParseError + + +def evaluate(input_: str, matcher: Callable[[str], bool]) -> bool: + return Expression.compile_(input_).evaluate(matcher) + + +def test_empty_is_false() -> None: + assert not evaluate("", lambda ident: False) + assert not evaluate("", lambda ident: True) + assert not evaluate(" ", lambda ident: False) + assert not evaluate("\t", lambda ident: False) + + +@pytest.mark.parametrize( + ("expr", "expected"), + [ + ("true", True), + ("false", False), + ("not true", False), + ("not false", True), + ("not not true", True), + ("not not false", False), + ("true and true", True), + ("true and false", False), + ("false and true", False), + ("true and true and true", True), + ("true and true and false", False), + ("true and true and not true", False), + ("false or false", False), + ("false or true", True), + ("true or true", True), + ("true or true or false", True), + ("true and true or false", True), + ("not true or true", True), + ("(not true) or true", True), + ("not (true or true)", False), + ("true and true or false and false", True), + ("true and (true or false) and false", False), + ("true and (true or (not (not false))) and false", False), + ], +) +def test_basic(expr: str, expected: bool) -> None: + matcher = {"true": True, "false": False}.__getitem__ + assert evaluate(expr, matcher) is expected + + +@pytest.mark.parametrize( + ("expr", "expected"), + [ + (" true ", True), + (" ((((((true)))))) ", True), + (" ( ((\t (((true))))) \t \t)", True), + ("( true and (((false))))", False), + ("not not not not true", True), + ("not not not not not true", False), + ], +) +def test_syntax_oddeties(expr: str, expected: bool) -> None: + matcher = {"true": True, "false": False}.__getitem__ + assert evaluate(expr, matcher) is expected + + +@pytest.mark.parametrize( + ("expr", "column", "message"), + [ + ("(", 2, "expected not OR left parenthesis OR identifier; got end of input"), + (" (", 3, "expected not OR left parenthesis OR identifier; got end of input",), + ( + ")", + 1, + "expected not OR left parenthesis OR identifier; got right parenthesis", + ), + ( + ") ", + 1, + "expected not OR left parenthesis OR identifier; got right parenthesis", + ), + ("not", 4, "expected not OR left parenthesis OR identifier; got end of input",), + ( + "not not", + 8, + "expected not OR left parenthesis OR identifier; got end of input", + ), + ( + "(not)", + 5, + "expected not OR left parenthesis OR identifier; got right parenthesis", + ), + ("and", 1, "expected not OR left parenthesis OR identifier; got and"), + ( + "ident and", + 10, + "expected not OR left parenthesis OR identifier; got end of input", + ), + ("ident and or", 11, "expected not OR left parenthesis OR identifier; got or",), + ("ident ident", 7, "expected end of input; got identifier"), + ], +) +def test_syntax_errors(expr: str, column: int, message: str) -> None: + with pytest.raises(ParseError) as excinfo: + evaluate(expr, lambda ident: True) + assert excinfo.value.column == column + assert excinfo.value.message == message + + +@pytest.mark.parametrize( + "ident", + [ + ".", + "...", + ":::", + "a:::c", + "a+-b", + "אבגד", + "aaאבגדcc", + "a[bcd]", + "1234", + "1234abcd", + "1234and", + "notandor", + "not_and_or", + "not[and]or", + "1234+5678", + "123.232", + "True", + "False", + "None", + "if", + "else", + "while", + ], +) +def test_valid_idents(ident: str) -> None: + assert evaluate(ident, {ident: True}.__getitem__) + + +@pytest.mark.parametrize( + "ident", + [ + "/", + "\\", + "^", + "*", + "=", + "&", + "%", + "$", + "#", + "@", + "!", + "~", + "{", + "}", + '"', + "'", + "|", + ";", + "←", + ], +) +def test_invalid_idents(ident: str) -> None: + with pytest.raises(ParseError): + evaluate(ident, lambda ident: True) From 8ac44274acc60fd4eccb9799b7c2ee07e6cae502 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 7 Aug 2020 10:04:59 +0200 Subject: [PATCH 4/5] A little bit more documentation. --- docs/tutorials/how_to_select_tasks.rst | 78 +++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/how_to_select_tasks.rst b/docs/tutorials/how_to_select_tasks.rst index 6d9d41f1..2f8ba516 100644 --- a/docs/tutorials/how_to_select_tasks.rst +++ b/docs/tutorials/how_to_select_tasks.rst @@ -1,17 +1,81 @@ How to select tasks =================== -If you want to run only a subset of tasks, there exists currently one option. +If you want to run only a subset of tasks, there exist multiple options. -Selecting tasks via paths -------------------------- +Paths +----- -You can run all tasks in one file by passing the path to the file to pytask. The same -can be done for multiple paths +You can run all tasks in one file or one directory by passing the corresponding path to +pytask. The same can be done for multiple paths. .. code-block:: bash - $ pytask path/to/task_1.py + $ pytask src/task_1.py - $ pytask path/to/task_1.py path/to/task_2.py + $ pytask src + + $ pytask src/task_1.py src/task_2.py + + +Markers +------- + +If you assign markers to task functions, you can use marker expressions to select tasks. +For example, here is a task with the ``wip`` marker which indicates work-in-progress. + +.. code-block:: python + + @pytask.mark.wip + def task_1(): + pass + +To execute only tasks with the ``wip`` marker, use + +.. code-block:: bash + + $ pytask -m wip + +You can pass more complex expressions to ``-m`` by using multiple markers and ``and``, +``or``, ``not``, and brackets (``()``). The following pattern selects all tasks which +belong to the data management, but not the ones which produce plots and plots produced +for the analysis. + +.. code-block:: bash + + $ pytask -m "(data_management and not plots) or (analysis and plots)" + + +Expressions +----------- + +Expressions are similar to markers and offer the same syntax but target the task ids. +Assume you have the following tasks. + +.. code-block:: python + + def task_1(): + pass + + + def task_2(): + pass + + + def task_12(): + pass + +Then, + +.. code-block:: bash + + $ pytask -k 1 + +will execute the first and third task and + +.. code-block:: bash + + $ pytask -k "1 and not 2" + +executes only the first task. From 4b1ab428062fc6e5045d6f7694c57aaab16f6c21 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 7 Aug 2020 17:04:42 +0200 Subject: [PATCH 5/5] More tests and allow exceptions during the modification of tasks. --- src/pytask/collect.py | 11 +++++++++- src/pytask/report.py | 13 ++++++++++++ tests/test_mark.py | 42 +++++++++++++++++++++++++++++++++++++++ tests/test_parametrize.py | 28 +++++++++++++++++++++++--- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/pytask/collect.py b/src/pytask/collect.py index 60690fa7..67595d1d 100644 --- a/src/pytask/collect.py +++ b/src/pytask/collect.py @@ -14,6 +14,7 @@ from pytask.mark_ import has_marker from pytask.nodes import FilePathNode from pytask.nodes import PythonFunctionTask +from pytask.report import CollectionReport from pytask.report import CollectionReportFile from pytask.report import CollectionReportTask @@ -22,7 +23,15 @@ def pytask_collect(session): reports = _collect_from_paths(session) tasks = _extract_tasks_from_reports(reports) - session.hook.pytask_collect_modify_tasks(session=session, tasks=tasks) + + try: + session.hook.pytask_collect_modify_tasks(session=session, tasks=tasks) + except Exception: + report = CollectionReport( + " Modification of collected tasks failed ", sys.exc_info() + ) + reports.append(report) + session.hook.pytask_collect_log(session=session, reports=reports, tasks=tasks) session.collection_reports = reports diff --git a/src/pytask/report.py b/src/pytask/report.py index c6d365e6..438e871d 100644 --- a/src/pytask/report.py +++ b/src/pytask/report.py @@ -6,6 +6,19 @@ import click +@attr.s +class CollectionReport: + title = attr.ib(type=str) + exc_info = attr.ib(type=tuple) + + def format_title(self): + return self.title + + @property + def successful(self): + return self.exc_info is None + + @attr.s class CollectionReportTask: path = attr.ib(type=Path) diff --git a/tests/test_mark.py b/tests/test_mark.py index 344665cf..e4cc9778 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -212,3 +212,45 @@ def task_func(arg): tasks_that_run = [report.task.name for report in session.execution_reports] assert set(tasks_that_run) == set(expected_passed) + + +@pytest.mark.parametrize( + ("expr", "expected_error"), + [ + ( + "foo or", + "at column 7: expected not OR left parenthesis OR identifier; got end of " + "input", + ), + ( + "foo or or", + "at column 8: expected not OR left parenthesis OR identifier; got or", + ), + ("(foo", "at column 5: expected right parenthesis; got end of input",), + ("foo bar", "at column 5: expected end of input; got identifier",), + ( + "or or", + "at column 1: expected not OR left parenthesis OR identifier; got or", + ), + ( + "not or", + "at column 5: expected not OR left parenthesis OR identifier; got or", + ), + ], +) +def test_keyword_option_wrong_arguments( + tmp_path, capsys, expr: str, expected_error: str +) -> None: + tmp_path.joinpath("task_dummy.py").write_text( + textwrap.dedent( + """ + def task_func(arg): + pass + """ + ) + ) + session = main({"paths": tmp_path, "expression": expr}) + assert session.exit_code == 2 + + err = capsys.readouterr().err + assert expected_error in err diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py index d63f4207..ee227ad6 100644 --- a/tests/test_parametrize.py +++ b/tests/test_parametrize.py @@ -3,7 +3,7 @@ import pytask import pytest -from pytask.main import pytask_main +from pytask.cli import main from pytask.mark_ import Mark from pytask.parametrize import _parse_arg_names from pytask.parametrize import _parse_parametrize_markers @@ -149,7 +149,7 @@ def task_write_numbers_to_file(produces, i): """ tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source)) - session = pytask_main({"paths": tmp_path}) + session = main({"paths": tmp_path}) assert session.exit_code == 0 for i in range(1, 3): @@ -177,6 +177,28 @@ def task_save_numbers_again(depends_on, produces): """ tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source)) - session = pytask_main({"paths": tmp_path}) + session = main({"paths": tmp_path}) assert session.exit_code == 0 + + +@pytest.mark.end_to_end +def test_parametrize_iterator(tmp_path): + """`parametrize` should work with generators.""" + tmp_path.joinpath("task_dummy.py").write_text( + textwrap.dedent( + """ + import pytask + def gen(): + yield 1 + yield 2 + yield 3 + @pytask.mark.parametrize('a', gen()) + def task_func(a): + assert a >= 1 + """ + ) + ) + session = main({"paths": tmp_path}) + assert session.exit_code == 0 + assert len(session.execution_reports) == 3