diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3b32647..e23e21a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: - id: python-no-log-warn - id: text-unicode-replacement-char - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.2 + rev: v0.13.3 hooks: - id: ruff-format - id: ruff-check diff --git a/CHANGELOG.md b/CHANGELOG.md index 40dbabd9..ad84a7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## 0.5.6 - 2025-xx-xx - {pull}`703` fixes {issue}`701` by allowing `--capture tee-sys` again. +- {pull}`704` adds the `--explain` flag to show why tasks would be executed. Closes {issue}`466`. ## 0.5.5 - 2025-07-25 diff --git a/docs/source/_static/md/capture.md b/docs/source/_static/md/capture.md index a14e22cb..0f41a9de 100644 --- a/docs/source/_static/md/capture.md +++ b/docs/source/_static/md/capture.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 2 tasks. +Collected 2 tasks. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/clean-dry-run-directories.md b/docs/source/_static/md/clean-dry-run-directories.md index 8dff0403..0451985f 100644 --- a/docs/source/_static/md/clean-dry-run-directories.md +++ b/docs/source/_static/md/clean-dry-run-directories.md @@ -6,7 +6,7 @@ $ pytask clean --directories ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. Files which can be removed: diff --git a/docs/source/_static/md/clean-dry-run.md b/docs/source/_static/md/clean-dry-run.md index 3baadb32..e5b094be 100644 --- a/docs/source/_static/md/clean-dry-run.md +++ b/docs/source/_static/md/clean-dry-run.md @@ -6,7 +6,7 @@ $ pytask clean ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. Files which can be removed: diff --git a/docs/source/_static/md/collect-nodes.md b/docs/source/_static/md/collect-nodes.md index cb13848f..fac4a7cd 100644 --- a/docs/source/_static/md/collect-nodes.md +++ b/docs/source/_static/md/collect-nodes.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. Collected tasks: └── 🐍 <Module task_module.py> diff --git a/docs/source/_static/md/collect.md b/docs/source/_static/md/collect.md index 6fc0a420..5afdddf6 100644 --- a/docs/source/_static/md/collect.md +++ b/docs/source/_static/md/collect.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. Collected tasks: └── 🐍 <Module task_module.py> diff --git a/docs/source/_static/md/defining-dependencies-products.md b/docs/source/_static/md/defining-dependencies-products.md index 1112bfc0..161821e6 100644 --- a/docs/source/_static/md/defining-dependencies-products.md +++ b/docs/source/_static/md/defining-dependencies-products.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 2 task. +Collected 2 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/dry-run.md b/docs/source/_static/md/dry-run.md index 3bb608e9..07c10b41 100644 --- a/docs/source/_static/md/dry-run.md +++ b/docs/source/_static/md/dry-run.md @@ -6,7 +6,7 @@ $ pytask --dry-run ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/explain.md b/docs/source/_static/md/explain.md new file mode 100644 index 00000000..f90fa425 --- /dev/null +++ b/docs/source/_static/md/explain.md @@ -0,0 +1,46 @@ +
+ +```console + +$ pytask --explain +────────────────────────── Start pytask session ───────────────────────── +Platform: darwin -- Python 3.12.0, pytask 0.5.6, pluggy 1.6.0 +Root: /Users/pytask-dev/git/my_project +Collected 3 tasks. + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ +┃ Task ┃ Outcome ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ +│ task_data.py::task_create_data │ w │ +│ task_analysis.py::task_analyze │ w │ +│ task_plot.py::task_plot │ s │ +└───────────────────────────────────────────────────┴─────────┘ + +───────────────────────────────────────────────────────────────────────── +────────────────────────────── Explanation ────────────────────────────── + +─── Tasks that would be executed ──────────────────────────────────────── + +task_data.py::task_create_data + • task_data.py::task_create_data: Changed + +task_analysis.py::task_analyze + • Preceding task_data.py::task_create_data would be executed + +─── Skipped tasks ─────────────────────────────────────────────────────── + +task_plot.py::task_plot + • Skipped by marker + +1 persisted task(s) (use -vv to show details) + +───────────────────────────────────────────────────────────────────────── +╭─────────── Summary ──────────────╮ + 3 Collected tasks + 2 Would be executed (66.7%) + 1 Skipped (33.3%) +╰──────────────────────────────────╯ +─────────────────────── Succeeded in 0.02 seconds ─────────────────────── +``` + +
diff --git a/docs/source/_static/md/migrating-from-scripts-to-pytask.md b/docs/source/_static/md/migrating-from-scripts-to-pytask.md index 75e0ea66..4f498035 100644 --- a/docs/source/_static/md/migrating-from-scripts-to-pytask.md +++ b/docs/source/_static/md/migrating-from-scripts-to-pytask.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/pdb.md b/docs/source/_static/md/pdb.md index 899e47eb..2131d5fb 100644 --- a/docs/source/_static/md/pdb.md +++ b/docs/source/_static/md/pdb.md @@ -6,7 +6,7 @@ $ pytask --pdb ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ╭─────────────────── Traceback (most recent call last) ─────────────────╮ diff --git a/docs/source/_static/md/persist-executed.md b/docs/source/_static/md/persist-executed.md index b23d66f1..6a2f0b86 100644 --- a/docs/source/_static/md/persist-executed.md +++ b/docs/source/_static/md/persist-executed.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/persist-persisted.md b/docs/source/_static/md/persist-persisted.md index 88ffa1cc..5db81309 100644 --- a/docs/source/_static/md/persist-persisted.md +++ b/docs/source/_static/md/persist-persisted.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/persist-skipped.md b/docs/source/_static/md/persist-skipped.md index d31249d4..8c563282 100644 --- a/docs/source/_static/md/persist-skipped.md +++ b/docs/source/_static/md/persist-skipped.md @@ -6,7 +6,7 @@ $ pytask --verbose 2 ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/profiling-tasks.md b/docs/source/_static/md/profiling-tasks.md index 215f62eb..f1212373 100644 --- a/docs/source/_static/md/profiling-tasks.md +++ b/docs/source/_static/md/profiling-tasks.md @@ -6,7 +6,7 @@ $ pytask profile ───────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 18 task. +Collected 18 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓ ┃ Task ┃ Duration (in s) ┃ Size ┃ diff --git a/docs/source/_static/md/readme.md b/docs/source/_static/md/readme.md index 55d10b44..7bfcd368 100644 --- a/docs/source/_static/md/readme.md +++ b/docs/source/_static/md/readme.md @@ -6,7 +6,7 @@ $ pytask ──────────────────────────── Start pytask session ──────────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/repeating-tasks.md b/docs/source/_static/md/repeating-tasks.md index 8dabb0b6..d4e357b9 100644 --- a/docs/source/_static/md/repeating-tasks.md +++ b/docs/source/_static/md/repeating-tasks.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 10 task. +Collected 10 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/show-locals.md b/docs/source/_static/md/show-locals.md index 3cd41633..8352cb78 100644 --- a/docs/source/_static/md/show-locals.md +++ b/docs/source/_static/md/show-locals.md @@ -6,7 +6,7 @@ $ pytask --show-locals ──────────────────────────── Start pytask session ──────────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/trace.md b/docs/source/_static/md/trace.md index 69ceb95f..0ab7edc2 100644 --- a/docs/source/_static/md/trace.md +++ b/docs/source/_static/md/trace.md @@ -6,7 +6,7 @@ $ pytask --trace ──────────────────────────── Start pytask session ──────────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. >>>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>> diff --git a/docs/source/_static/md/try-first.md b/docs/source/_static/md/try-first.md index 6c668ec8..bc71ba4b 100644 --- a/docs/source/_static/md/try-first.md +++ b/docs/source/_static/md/try-first.md @@ -6,7 +6,7 @@ $ pytask -s ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 2 task. +Collected 2 task. I'm first I'm second diff --git a/docs/source/_static/md/try-last.md b/docs/source/_static/md/try-last.md index 01d187f5..2abbe272 100644 --- a/docs/source/_static/md/try-last.md +++ b/docs/source/_static/md/try-last.md @@ -6,7 +6,7 @@ $ pytask -s ────────────────────────── Start pytask session ──────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 2 task. +Collected 2 task. I'm second I'm first diff --git a/docs/source/_static/md/warning.md b/docs/source/_static/md/warning.md index 49dbd3df..558af1ba 100644 --- a/docs/source/_static/md/warning.md +++ b/docs/source/_static/md/warning.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/_static/md/write-a-task.md b/docs/source/_static/md/write-a-task.md index 7d335a61..9f12c4eb 100644 --- a/docs/source/_static/md/write-a-task.md +++ b/docs/source/_static/md/write-a-task.md @@ -6,7 +6,7 @@ $ pytask ────────────────────────── Start pytask session ───────────────────────── Platform: win32 -- Python 3.12.0, pytask 0.5.3, pluggy 1.3.0 Root: C:\Users\pytask-dev\git\my_project -Collected 1 task. +Collected 1 task. ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ Task ┃ Outcome ┃ diff --git a/docs/source/tutorials/invoking_pytask.md b/docs/source/tutorials/invoking_pytask.md index f3a5396a..7a86f960 100644 --- a/docs/source/tutorials/invoking_pytask.md +++ b/docs/source/tutorials/invoking_pytask.md @@ -83,6 +83,22 @@ Do a dry run to see which tasks will be executed without executing them. ```{include} ../_static/md/dry-run.md ``` +### Explaining why tasks are executed + +Use the `--explain` flag to understand why tasks need to be executed. This shows what +changed (source files, dependencies, products, previous tasks) and helps you understand +pytask's execution decisions. + +```{include} ../_static/md/explain.md +``` + +The explanation output respects the `--verbose` flag: + +- Default verbosity: Shows tasks that would be executed and skipped tasks +- `-v` or `--verbose 1`: Same as default, with summary for persisted and unchanged tasks +- `--verbose 2`: Shows detailed information including persisted and unchanged tasks with + change reasons + ## Functional interface pytask also has a functional interface that is explained in this diff --git a/src/_pytask/build.py b/src/_pytask/build.py index b5ccf0bf..78426cfd 100644 --- a/src/_pytask/build.py +++ b/src/_pytask/build.py @@ -76,6 +76,7 @@ def build( # noqa: C901, PLR0912, PLR0913 dry_run: bool = False, editor_url_scheme: Literal["no_link", "file", "vscode", "pycharm"] # noqa: PYI051 | str = "file", + explain: bool = False, expression: str = "", force: bool = False, ignore: Iterable[str] = (), @@ -125,6 +126,8 @@ def build( # noqa: C901, PLR0912, PLR0913 editor_url_scheme An url scheme that allows to click on task names, node names and filenames and jump right into you preferred editor to the right line. + explain + Explain why tasks need to be executed by showing what changed. expression Same as ``-k`` on the command line. Select tasks via expressions on task ids. force @@ -189,6 +192,7 @@ def build( # noqa: C901, PLR0912, PLR0913 "disable_warnings": disable_warnings, "dry_run": dry_run, "editor_url_scheme": editor_url_scheme, + "explain": explain, "expression": expression, "force": force, "ignore": ignore, @@ -324,6 +328,12 @@ def build( # noqa: C901, PLR0912, PLR0913 default=False, help="Execute a task even if it succeeded successfully before.", ) +@click.option( + "--explain", + is_flag=True, + default=False, + help="Explain why tasks need to be executed by showing what changed.", +) def build_command(**raw_config: Any) -> NoReturn: """Collect tasks, execute them and report the results.""" raw_config["command"] = "build" diff --git a/src/_pytask/collect.py b/src/_pytask/collect.py index 0df50442..d1ed0c3a 100644 --- a/src/_pytask/collect.py +++ b/src/_pytask/collect.py @@ -597,7 +597,10 @@ def pytask_collect_log( """Log collection.""" session.collection_end = time.time() - console.print(f"Collected {len(tasks)} task{'' if len(tasks) == 1 else 's'}.") + console.print( + f"Collected {len(tasks)} task{'' if len(tasks) == 1 else 's'}.", + highlight=False, + ) failed_reports = [r for r in reports if r.outcome == CollectionOutcome.FAIL] if failed_reports: diff --git a/src/_pytask/console.py b/src/_pytask/console.py index 8a450172..fb8bd302 100644 --- a/src/_pytask/console.py +++ b/src/_pytask/console.py @@ -169,10 +169,10 @@ def format_strings_as_flat_tree( def create_url_style_for_task( - task_function: Callable[..., Any], edtior_url_scheme: str + task_function: Callable[..., Any], editor_url_scheme: str ) -> Style: """Create the style to add a link to a task id.""" - url_scheme = _EDITOR_URL_SCHEMES.get(edtior_url_scheme, edtior_url_scheme) + url_scheme = _EDITOR_URL_SCHEMES.get(editor_url_scheme, editor_url_scheme) if not url_scheme: return Style() @@ -190,9 +190,9 @@ def create_url_style_for_task( return style -def create_url_style_for_path(path: Path, edtior_url_scheme: str) -> Style: +def create_url_style_for_path(path: Path, editor_url_scheme: str) -> Style: """Create the style to add a link to a task id.""" - url_scheme = _EDITOR_URL_SCHEMES.get(edtior_url_scheme, edtior_url_scheme) + url_scheme = _EDITOR_URL_SCHEMES.get(editor_url_scheme, editor_url_scheme) return ( Style() if not url_scheme diff --git a/src/_pytask/database_utils.py b/src/_pytask/database_utils.py index dc262967..77be06c5 100644 --- a/src/_pytask/database_utils.py +++ b/src/_pytask/database_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from typing import Literal from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase @@ -22,6 +23,7 @@ "BaseTable", "DatabaseSession", "create_database", + "get_node_change_info", "update_states_in_database", ] @@ -83,3 +85,42 @@ def has_node_changed(task: PTask, node: PTask | PNode, state: str | None) -> boo return True return state != db_state.hash_ + + +def get_node_change_info( + task: PTask, node: PTask | PNode, state: str | None +) -> tuple[ + bool, Literal["missing", "not_in_db", "changed", "unchanged"], dict[str, str] +]: + """Get detailed information about why a node changed. + + Returns + ------- + tuple[bool, Literal["missing", "not_in_db", "changed", "unchanged"], dict[str, str]] + A tuple of (has_changed, reason, details) where: + - has_changed: Whether the node has changed + - reason: The reason for the change ("missing", "not_in_db", "changed", + "unchanged") + - details: Additional details like old and new hash values + + """ + details: dict[str, str] = {} + + # If node does not exist, we receive None. + if state is None: + return True, "missing", details + + with DatabaseSession() as session: + db_state = session.get(State, (task.signature, node.signature)) + + # If the node is not in the database. + if db_state is None: + return True, "not_in_db", details + + # Check if state changed + if state != db_state.hash_: + details["old_hash"] = db_state.hash_ + details["new_hash"] = state + return True, "changed", details + + return False, "unchanged", details diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index d52b6fa0..64ba004e 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -20,11 +20,17 @@ from _pytask.dag_utils import TopologicalSorter from _pytask.dag_utils import descending_tasks from _pytask.dag_utils import node_and_neighbors +from _pytask.database_utils import get_node_change_info from _pytask.database_utils import has_node_changed from _pytask.database_utils import update_states_in_database from _pytask.exceptions import ExecutionError from _pytask.exceptions import NodeLoadError from _pytask.exceptions import NodeNotFoundError +from _pytask.explain import ChangeReason +from _pytask.explain import NodeType +from _pytask.explain import ReasonType +from _pytask.explain import TaskExplanation +from _pytask.explain import create_change_reason from _pytask.mark import Mark from _pytask.mark_utils import has_mark from _pytask.node_protocols import PNode @@ -99,6 +105,10 @@ def pytask_execute_build(session: Session) -> bool | None: @hookimpl def pytask_execute_task_protocol(session: Session, task: PTask) -> ExecutionReport: """Follow the protocol to execute each task.""" + # Initialize explanation for this task if in explain mode + if session.config["explain"]: + task.attributes["explanation"] = TaskExplanation(reasons=[]) + session.hook.pytask_execute_task_log_start(session=session, task=task) try: session.hook.pytask_execute_task_setup(session=session, task=task) @@ -119,7 +129,7 @@ def pytask_execute_task_protocol(session: Session, task: PTask) -> ExecutionRepo @hookimpl(trylast=True) -def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C901 +def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C901, PLR0912, PLR0915 """Set up the execution of a task. 1. Check whether all dependencies of a task are available. @@ -127,14 +137,38 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C """ if has_mark(task, "would_be_executed"): + # Add cascade reason if in explain mode + if session.config["explain"] and "explanation" in task.attributes: + marks = [m for m in task.markers if m.name == "would_be_executed"] + if marks: + preceding_task_name = marks[0].kwargs.get("task_name", "unknown") + task.attributes["explanation"].reasons.append( + ChangeReason( + node_name=preceding_task_name, + node_type="task", + reason="cascade", + details={}, + ) + ) raise WouldBeExecuted dag = session.dag + change_reasons = [] # Task generators are always executed since their states are not updated, but we # skip the checks as well. needs_to_be_executed = session.config["force"] or is_task_generator(task) + if session.config["force"] and session.config["explain"]: + change_reasons.append( + ChangeReason( + node_name="", + node_type="task", + reason="forced", + details={}, + ) + ) + if not needs_to_be_executed: predecessors = set(dag.predecessors(task.signature)) | {task.signature} for node_signature in node_and_neighbors(dag, task.signature): @@ -159,9 +193,42 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C ) raise NodeNotFoundError(msg) - has_changed = has_node_changed(task=task, node=node, state=node_state) - if has_changed: - needs_to_be_executed = True + # Check if node changed and collect detailed info if in explain mode + if session.config["explain"]: + has_changed, reason, details = get_node_change_info( + task=task, node=node, state=node_state + ) + if has_changed: + needs_to_be_executed = True + # Determine node type + node_type: NodeType + if node_signature == task.signature: + node_type = "source" + elif node_signature in predecessors: + node_type = "dependency" + else: + node_type = "product" + + # Cast reason since get_node_change_info can return "unchanged" + # but we only call this when has_changed is True + reason_typed: ReasonType = reason # type: ignore[assignment] + change_reasons.append( + create_change_reason( + node=node, + node_type=node_type, + reason=reason_typed, + old_hash=details.get("old_hash"), + new_hash=details.get("new_hash"), + ) + ) + else: + has_changed = has_node_changed(task=task, node=node, state=node_state) + if has_changed: + needs_to_be_executed = True + + # Update explanation on task if in explain mode + if session.config["explain"] and "explanation" in task.attributes: + task.attributes["explanation"].reasons = change_reasons if not needs_to_be_executed: collect_provisional_products(session, task) @@ -188,7 +255,7 @@ def _safe_load(node: PNode | PProvisionalNode, task: PTask, *, is_product: bool) @hookimpl(trylast=True) def pytask_execute_task(session: Session, task: PTask) -> bool: """Execute task.""" - if session.config["dry_run"]: + if session.config["dry_run"] or session.config["explain"]: raise WouldBeExecuted parameters = inspect.signature(task.function).parameters @@ -255,6 +322,7 @@ def pytask_execute_task_process_report( """ task = report.task + if report.outcome == TaskOutcome.SUCCESS: update_states_in_database(session, task.signature) elif report.exc_info and isinstance(report.exc_info[1], WouldBeExecuted): @@ -266,7 +334,10 @@ def pytask_execute_task_process_report( Mark( "would_be_executed", (), - {"reason": f"Previous task {task.name!r} would be executed."}, + { + "reason": f"Previous task {task.name!r} would be executed.", + "task_name": task.name, + }, ) ) else: diff --git a/src/_pytask/explain.py b/src/_pytask/explain.py new file mode 100644 index 00000000..28dd59ee --- /dev/null +++ b/src/_pytask/explain.py @@ -0,0 +1,248 @@ +"""Contains logic for explaining why tasks need to be re-executed.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal + +from attrs import define +from attrs import field +from rich.text import Text + +from _pytask.console import console +from _pytask.console import format_task_name +from _pytask.outcomes import TaskOutcome +from _pytask.pluginmanager import hookimpl + +if TYPE_CHECKING: + from collections.abc import Iterator + + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderableType + + from _pytask.node_protocols import PNode + from _pytask.node_protocols import PTask + from _pytask.reports import ExecutionReport + from _pytask.session import Session + + +NodeType = Literal["source", "dependency", "product", "task"] +ReasonType = Literal[ + "changed", "missing", "not_in_db", "first_run", "forced", "cascade" +] + + +@define +class ChangeReason: + """Represents a reason why a node changed.""" + + node_name: str + node_type: NodeType + reason: ReasonType + details: dict[str, Any] = field(factory=dict) + verbose: int = 1 + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> Iterator[RenderableType]: + """Render the change reason using Rich protocol.""" + if self.reason == "missing": + yield Text(f" • {self.node_name}: Missing") + elif self.reason == "not_in_db": + yield Text( + f" • {self.node_name}: Not in database (first run or database cleared)" + ) + elif self.reason == "changed": + if self.verbose >= 2 and "old_hash" in self.details: # noqa: PLR2004 + yield Text(f" • {self.node_name}: Changed") + yield Text(f" Previous hash: {self.details['old_hash'][:8]}...") + yield Text(f" Current hash: {self.details['new_hash'][:8]}...") + else: + yield Text(f" • {self.node_name}: Changed") + elif self.reason == "first_run": + yield Text(" • First execution") + elif self.reason == "forced": + yield Text(" • Forced execution (--force flag)") + elif self.reason == "cascade": + yield Text(f" • Preceding {self.node_name} would be executed") + else: + yield Text(f" • {self.node_name}: {self.reason}") + + +@define +class TaskExplanation: + """Represents the explanation for why a task needs to be executed.""" + + reasons: list[ChangeReason] = field(factory=list) + task: PTask | None = None + outcome: TaskOutcome | None = None + verbose: int = 1 + editor_url_scheme: str = "" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> Iterator[RenderableType]: + """Render the task explanation using Rich protocol.""" + # Format task name with proper styling and links + if self.task: + yield format_task_name(self.task, self.editor_url_scheme) + else: + yield Text("Unknown task") + + # Add explanation based on outcome + if self.outcome == TaskOutcome.SKIP_UNCHANGED: + yield Text(" ✓ No changes detected") + elif self.outcome == TaskOutcome.PERSISTENCE: + yield Text(" • Persisted (products exist, changes ignored)") + elif self.outcome == TaskOutcome.SKIP: + yield Text(" • Skipped by marker") + elif not self.reasons: + yield Text(" ✓ No changes detected") + else: + for reason in self.reasons: + reason.verbose = self.verbose + yield reason + + +def create_change_reason( + node: PNode | PTask, + node_type: NodeType, + reason: ReasonType, + old_hash: str | None = None, + new_hash: str | None = None, +) -> ChangeReason: + """Create a ChangeReason object.""" + details = {} + if old_hash is not None: + details["old_hash"] = old_hash + if new_hash is not None: + details["new_hash"] = new_hash + + return ChangeReason( + node_name=node.name, + node_type=node_type, + reason=reason, + details=details, + ) + + +@hookimpl(tryfirst=True) +def pytask_execute_log_end( # noqa: C901, PLR0915 + session: Session, reports: list[ExecutionReport] +) -> None: + """Log explanations if --explain flag is set.""" + if not session.config["explain"]: + return + + console.print() + console.rule(Text("Explanation", style="bold blue"), style="bold blue") + console.print() + + # Collect all reports with explanations + reports_with_explanations = [ + report for report in reports if "explanation" in report.task.attributes + ] + + if not reports_with_explanations: + console.print("No tasks require execution - everything is up to date.") + return + + # Group by outcome + would_execute = [ + r + for r in reports_with_explanations + if r.outcome == TaskOutcome.WOULD_BE_EXECUTED + ] + skipped = [ + r + for r in reports_with_explanations + if r.outcome in (TaskOutcome.SKIP, TaskOutcome.SKIP_PREVIOUS_FAILED) + ] + unchanged = [ + r for r in reports_with_explanations if r.outcome == TaskOutcome.SKIP_UNCHANGED + ] + persisted = [ + r for r in reports_with_explanations if r.outcome == TaskOutcome.PERSISTENCE + ] + + verbose = session.config.get("verbose", 1) + editor_url_scheme = session.config.get("editor_url_scheme", "no_link") + + if would_execute: + console.rule( + Text( + "─── Tasks that would be executed", + style=TaskOutcome.WOULD_BE_EXECUTED.style, + ), + align="left", + style=TaskOutcome.WOULD_BE_EXECUTED.style, + ) + console.print() + for report in would_execute: + explanation = report.task.attributes["explanation"] + explanation.task = report.task + explanation.outcome = report.outcome + explanation.verbose = verbose + explanation.editor_url_scheme = editor_url_scheme + console.print(explanation) + console.print() + + if skipped: + console.rule( + Text("─── Skipped tasks", style=TaskOutcome.SKIP.style), + align="left", + style=TaskOutcome.SKIP.style, + ) + console.print() + for report in skipped: + explanation = report.task.attributes["explanation"] + explanation.task = report.task + explanation.outcome = report.outcome + explanation.verbose = verbose + explanation.editor_url_scheme = editor_url_scheme + console.print(explanation) + console.print() + + if persisted and verbose >= 2: # noqa: PLR2004 + console.rule( + Text("─── Persisted tasks", style=TaskOutcome.PERSISTENCE.style), + align="left", + style=TaskOutcome.PERSISTENCE.style, + ) + console.print() + for report in persisted: + explanation = report.task.attributes["explanation"] + explanation.task = report.task + explanation.outcome = report.outcome + explanation.verbose = verbose + explanation.editor_url_scheme = editor_url_scheme + console.print(explanation) + console.print() + elif persisted and verbose == 1: + console.print( + f"{len(persisted)} persisted task(s) (use -vv to show details)", + highlight=False, + ) + + if unchanged and verbose >= 2: # noqa: PLR2004 + console.rule( + Text("─── Tasks with no changes", style=TaskOutcome.SKIP_UNCHANGED.style), + align="left", + style=TaskOutcome.SKIP_UNCHANGED.style, + ) + console.print() + for report in unchanged: + explanation = report.task.attributes["explanation"] + explanation.task = report.task + explanation.outcome = report.outcome + explanation.verbose = verbose + explanation.editor_url_scheme = editor_url_scheme + console.print(explanation) + console.print() + elif unchanged and verbose == 1: + console.print( + f"{len(unchanged)} task(s) with no changes (use -vv to show details)", + highlight=False, + ) diff --git a/src/_pytask/outcomes.py b/src/_pytask/outcomes.py index acb46cbd..3dd4a2e1 100644 --- a/src/_pytask/outcomes.py +++ b/src/_pytask/outcomes.py @@ -95,6 +95,8 @@ class TaskOutcome(Enum): source files and products have not changed. SUCCESS Outcome for task which was executed successfully. + WOULD_BE_EXECUTED + Outcome for tasks which would be executed. """ diff --git a/src/_pytask/pluginmanager.py b/src/_pytask/pluginmanager.py index dbe8b049..2a7ef4a6 100644 --- a/src/_pytask/pluginmanager.py +++ b/src/_pytask/pluginmanager.py @@ -49,6 +49,7 @@ def pytask_add_hooks(pm: PluginManager) -> None: "_pytask.dag_command", "_pytask.database", "_pytask.debugging", + "_pytask.explain", "_pytask.provisional", "_pytask.execute", "_pytask.live", diff --git a/tests/test_console.py b/tests/test_console.py index 657ae790..ecaa3718 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -34,7 +34,7 @@ def task_func(): ... @pytest.mark.parametrize( - ("edtior_url_scheme", "expected"), + ("editor_url_scheme", "expected"), [ ("no_link", ""), ("file", "link file://{path}"), @@ -46,14 +46,14 @@ def task_func(): ... ), ], ) -def test_create_url_style_for_task(edtior_url_scheme, expected): +def test_create_url_style_for_task(editor_url_scheme, expected): path = Path(__file__) - style = create_url_style_for_task(task_func, edtior_url_scheme) + style = create_url_style_for_task(task_func, editor_url_scheme) assert style == Style.parse(expected.format(path=path)) @pytest.mark.parametrize( - ("edtior_url_scheme", "expected"), + ("editor_url_scheme", "expected"), [ ("no_link", ""), ("file", "link file://{path}"), @@ -65,9 +65,9 @@ def test_create_url_style_for_task(edtior_url_scheme, expected): ), ], ) -def test_create_url_style_for_path(edtior_url_scheme, expected): +def test_create_url_style_for_path(editor_url_scheme, expected): path = Path(__file__) - style = create_url_style_for_path(path, edtior_url_scheme) + style = create_url_style_for_path(path, editor_url_scheme) assert style == Style.parse(expected.format(path=path)) diff --git a/tests/test_explain.py b/tests/test_explain.py new file mode 100644 index 00000000..a22ccb6f --- /dev/null +++ b/tests/test_explain.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import textwrap + +from pytask import ExitCode +from pytask import cli + + +def test_explain_source_file_changed(runner, tmp_path): + """Test --explain shows source file changed.""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "1 Succeeded" in result.output + + # Modify source file + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source + "\n")) + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "Explanation" in result.output + assert "Changed" in result.output or "changed" in result.output + assert "task_example" in result.output + + +def test_explain_dependency_file_changed(runner, tmp_path): + """Test --explain detects changed dependency file.""" + source_first = """ + from pathlib import Path + + def task_first(produces=Path("out.txt")): + produces.write_text("hello") + """ + tmp_path.joinpath("task_first.py").write_text(textwrap.dedent(source_first)) + + source_second = """ + from pathlib import Path + + def task_second(path=Path("out.txt"), produces=Path("out2.txt")): + content = path.read_text() + produces.write_text(content + " world") + """ + tmp_path.joinpath("task_second.py").write_text(textwrap.dedent(source_second)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "2 Succeeded" in result.output + + # Manually modify the intermediate file + tmp_path.joinpath("out.txt").write_text("modified") + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "out.txt" in result.output + assert "changed" in result.output.lower() + + +def test_explain_dependency_missing(runner, tmp_path): + """Test --explain detects missing dependency.""" + source_first = """ + from pathlib import Path + + def task_first(produces=Path("out.txt")): + produces.write_text("hello") + """ + tmp_path.joinpath("task_first.py").write_text(textwrap.dedent(source_first)) + + source_second = """ + from pathlib import Path + + def task_second(path=Path("out.txt"), produces=Path("out2.txt")): + produces.write_text(path.read_text()) + """ + tmp_path.joinpath("task_second.py").write_text(textwrap.dedent(source_second)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + # Delete dependency + tmp_path.joinpath("out.txt").unlink() + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "out.txt" in result.output + assert "missing" in result.output.lower() or "not found" in result.output.lower() + + +def test_explain_product_missing(runner, tmp_path): + """Test --explain detects missing product.""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.write_text("hello") + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert tmp_path.joinpath("out.txt").exists() + + # Delete product + tmp_path.joinpath("out.txt").unlink() + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "out.txt" in result.output + assert "missing" in result.output.lower() or "not found" in result.output.lower() + + +def test_explain_multiple_changes(runner, tmp_path): + """Test --explain shows multiple reasons when multiple things changed.""" + source = """ + from pathlib import Path + + def task_example(path=Path("input.txt"), produces=Path("out.txt")): + produces.write_text(path.read_text()) + """ + tmp_path.joinpath("input.txt").write_text("original") + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + # Change both source and dependency + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source + "\n")) + tmp_path.joinpath("input.txt").write_text("modified") + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + # Should mention both changes + assert "task_example.py" in result.output or "source" in result.output.lower() + assert "input.txt" in result.output + + +def test_explain_with_persist_marker(runner, tmp_path): + """Test --explain respects @pytask.mark.persist.""" + source = """ + import pytask + from pathlib import Path + + @pytask.mark.persist + def task_example(path=Path("input.txt"), produces=Path("out.txt")): + produces.write_text(path.read_text()) + """ + tmp_path.joinpath("input.txt").write_text("original") + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + # Change dependency + tmp_path.joinpath("input.txt").write_text("modified") + + # Test with default verbosity (should show summary) + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "persisted task(s)" in result.output.lower() + assert "use -vv to show details" in result.output.lower() + + # Change dependency again for verbose test + tmp_path.joinpath("input.txt").write_text("modified again") + + # Test with verbose 2 (should show details) + result = runner.invoke(cli, ["--explain", "--verbose", "2", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "Persisted tasks" in result.output + assert "Persisted (products exist, changes ignored)" in result.output + + +def test_explain_cascade_execution(runner, tmp_path): + """Test --explain shows cascading task executions.""" + source_a = """ + from pathlib import Path + + def task_a(produces=Path("a.txt")): + produces.write_text("a") + """ + tmp_path.joinpath("task_a.py").write_text(textwrap.dedent(source_a)) + + source_b = """ + from pathlib import Path + + def task_b(path=Path("a.txt"), produces=Path("b.txt")): + produces.write_text(path.read_text() + "b") + """ + tmp_path.joinpath("task_b.py").write_text(textwrap.dedent(source_b)) + + source_c = """ + from pathlib import Path + + def task_c(path=Path("b.txt"), produces=Path("c.txt")): + produces.write_text(path.read_text() + "c") + """ + tmp_path.joinpath("task_c.py").write_text(textwrap.dedent(source_c)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "3 Succeeded" in result.output + + # Change first task + tmp_path.joinpath("task_a.py").write_text(textwrap.dedent(source_a + "\n")) + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + # All three tasks should be shown + assert "task_a" in result.output + assert "task_b" in result.output + assert "task_c" in result.output + # Check cascade explanations + assert "Preceding task" in result.output + assert "task_a" in result.output + assert "Changed" in result.output + + +def test_explain_first_run_no_database(runner, tmp_path): + """Test --explain on first run (no database).""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "task_example" in result.output + # Should indicate first run or not in database + assert ( + "first" in result.output.lower() + or "not in database" in result.output.lower() + or "never executed" in result.output.lower() + ) + + +def test_explain_with_force_flag(runner, tmp_path): + """Test --explain with --force flag.""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + result = runner.invoke(cli, ["--force", "--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "force" in result.output.lower() or "task_example" in result.output + + +def test_explain_no_changes(runner, tmp_path): + """Test --explain when nothing changed.""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + # Should indicate nothing needs to be executed + assert ( + "no changes" in result.output.lower() + or "unchanged" in result.output.lower() + or "0 " in result.output + ) + assert "1 task(s) with no changes (use -vv to show details)" in result.output + + +def test_explain_with_dry_run(runner, tmp_path): + """Test --explain works with --dry-run.""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + # Modify source + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source + "\n")) + + result = runner.invoke(cli, ["--dry-run", "--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "task_example" in result.output + # Should show explanation and not execute + assert ( + not tmp_path.joinpath("out.txt").read_text() + or tmp_path.joinpath("out.txt").stat().st_size == 0 + ) + + +def test_explain_skipped_tasks(runner, tmp_path): + """Test --explain handles skipped tasks.""" + source = """ + import pytask + from pathlib import Path + + @pytask.mark.skip + def task_skip(produces=Path("skip.txt")): + produces.touch() + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "skip" in result.output.lower() + + +def test_explain_task_source_and_dependency_changed(runner, tmp_path): + """Test --explain when both task source and dependency change.""" + source = """ + from pathlib import Path + + def task_example(path=Path("data.txt"), produces=Path("out.txt")): + content = path.read_text() + produces.write_text(content) + """ + tmp_path.joinpath("data.txt").write_text("original") + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + # Change both source and data + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source + "\n")) + tmp_path.joinpath("data.txt").write_text("modified") + + result = runner.invoke(cli, ["--explain", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + assert "Explanation" in result.output + assert "task_example" in result.output + # Should show changes + assert "changed" in result.output.lower() or "Changed" in result.output + + +def test_explain_verbose_output(runner, tmp_path): + """Test --explain with -v shows more details.""" + source = """ + from pathlib import Path + + def task_example(produces=Path("out.txt")): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + + # Modify source + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source + "\n")) + + result = runner.invoke(cli, ["--explain", "-v", "1", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK + # Verbose mode should show more details + assert "task_example" in result.output diff --git a/tests/test_mark.py b/tests/test_mark.py index 5ed81f0e..badf9792 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -356,7 +356,7 @@ def task_write_text(): ... @pytest.mark.parametrize("name", ["parametrize", "depends_on", "produces", "task"]) -def test_error_with_depreacated_markers(runner, tmp_path, name): +def test_error_with_deprecated_markers(runner, tmp_path, name): source = f""" from pytask import mark