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