Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,6 @@ mail_config.toml
logs/*
logs

PR_DESCRIPTION.md
PR_DESCRIPTION.md

FEATURE_PLAN.md
86 changes: 86 additions & 0 deletions FEATURE_PLAN1.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
USER: Could ProcessResult be ditched and replaced by ProcessExecutionReport? Show impacts. Don't code yet.

Claude: Could ProcessResult be replaced by ProcessExecutionReport?

Structurally, yes — ProcessExecutionReport.entries is a superset of what ProcessResult carries (it adds function/args/kwargs/status/elapsed_seconds/attempts for every task, success/errored/skipped alike, in topological order). The raw data ProcessRunner already collects (passed_results, failed_results, skipped_tasks, process.tasks) is exactly what from_result consumes — so ProcessRunner.run() could build a ProcessExecutionReport directly instead of a ProcessResult, without from_result needing a ProcessResult input at all (avoids the current process.py ↔ execution_report.py TYPE_CHECKING coupling — execution_report.py becomes self-sufficient).

What breaks / changes

1. Lost: raw Exception object access
TaskResult.exception is a live Exception instance (tests do isinstance(result.exception, TimeoutError)). TaskReportEntry.error is ErrorData | None, whose .exception field is a str(exception). Any caller that needs to isinstance()-check or re-raise the original exception loses that — ErrorData would need an added field, or TaskReportEntry would need to carry the raw TaskResult too.

2. API shape changes for every consumer
- result.passed_tasks_results["x"].result → report.successes["x"].result (or report.entries["x"].result)
- result.failed_tasks (a set[str]) → no direct equivalent; becomes set(report.errored) | set(report.skipped)
- result.errored_tasks / result.skipped_tasks → set(report.errored) / set(report.skipped)
- result.failed_tasks_results["x"] (a TaskResult) → report.entries["x"].error (an ErrorData, narrower — see #1)
- .worked / boolean checks → .status == TaskStatus.SUCCESS etc.

3. Test impact — large
Every test file that touches these attributes needs rewriting:
test_args_kwargs.py, test_complex_dag_failures.py, test_normal_run_no_errors.py, test_parallel_race_conditions.py, test_timeout_retry.py, plus 3 manual-test scripts. This is the bulk of the effort — dozens of assertion sites.

4. Docs impact
README's ProcessResult section (added in this session) and any usage examples would need replacing with ProcessExecutionReport equivalents; docs/reference.md would drop the ProcessResult mkdocstrings entry.

5. Breaking change vs. additive discipline
This session deliberately kept everything additive (FEATURE_PLAN's stance). Removing ProcessResult and changing Process.run()'s return type is a hard breaking change — would warrant a major version bump and a migration note, not a patch/minor release.

6. Minor ergonomics loss
Simple "did everything pass?" checks (if not result.failed_tasks) become if not report.errored and not report.skipped unless a convenience property (e.g. report.ok) is added.

Net assessment

Feasible and arguably a cleaner unified model long-term, but it's a breaking API change with wide blast radius across tests/docs, and needs at least one new field on ErrorData/TaskReportEntry to avoid losing the raw exception object. If you want this, I'd suggest either (a) a v6 major bump doing a clean replacement, or (b) keep ProcessResult as a thin deprecated wrapper for one release while Process.run() returns ProcessExecutionReport and ProcessResult.from_report(...) is offered for back-compat.

✻ Cooked for 49s

❯ /advisor off
⎿ Advisor disabled

❯ is ProcessExecutionReport being created anywhere yet?

Searched for 1 pattern

Every test file that touches these attributes needs rewriting:
test_args_kwargs.py, test_complex_dag_failures.py, test_normal_run_no_errors.py, test_parallel_race_conditions.py, test_timeout_retry.py, plus 3 manual-test scripts. This is the bulk of the effort — dozens of assertion sites.

4. Docs impact
README's ProcessResult section (added in this session) and any usage examples would need replacing with ProcessExecutionReport equivalents; docs/reference.md would drop the ProcessResult mkdocstrings entry.

5. Breaking change vs. additive discipline
This session deliberately kept everything additive (FEATURE_PLAN's stance). Removing ProcessResult and changing Process.run()'s return type is a hard breaking change — would warrant a major version bump and a migration note, not a patch/minor release.

6. Minor ergonomics loss
Simple "did everything pass?" checks (if not result.failed_tasks) become if not report.errored and not report.skipped unless a convenience property (e.g. report.ok) is added.

Net assessment

Feasible and arguably a cleaner unified model long-term, but it's a breaking API change with wide blast radius across tests/docs, and needs at least one new field on ErrorData/TaskReportEntry to avoid losing the raw exception object. If you want this, I'd suggest either (a) a v6 major bump doing a clean replacement, or (b) keep ProcessResult as a thin deprecated wrapper for one release while Process.run() returns ProcessExecutionReport and ProcessResult.from_report(...) is offered for back-compat.

USER: failed_results and passed_results and failed_tasks are separated. Shouldn't they be in a single dict with k:v task.name:task_result_obj ?

Claude: Yes — and it'd fix a latent inconsistency: currently failed_tasks (a set) = errored_tasks ∪ skipped_tasks, but failed_results (a dict) only has entries for errored tasks, not skipped ones. So failed_tasks and failed_results.keys() silently diverge — anyone iterating failed_tasks and indexing into failed_results will KeyError on skipped tasks.

Proposal

Maintain one internal dict in ProcessRunner: self.results: dict[str, TaskResult], one entry per task regardless of outcome (success, errored, or skipped — synthesizing a TaskResult for skipped tasks the same way the parallel defensive branch already does for unexpected exceptions).

Discriminator problem: TaskResult.worked: bool alone can't tell errored apart from skipped (both worked=False). Two options:
- Implicit: attempts == 0 ⇒ skipped. Works, but relies on an unstated convention — fragile if someone constructs a TaskResult manually.
- Explicit (cleaner): give TaskResult a status: TaskStatus field (reuse the enum from execution_report.py). Then results[name].status is authoritative everywhere.

Impact

- Internals: _is_done, _all_deps_met, _is_unrunnable currently check membership across passed_results/failed_tasks/skipped_tasks/failed_results — all rewritten to query self.results by status. Net simplification.
- ProcessResult public API: can stay byte-for-byte unchanged. passed_tasks_results, failed_tasks, skipped_tasks, errored_tasks, failed_tasks_results all become derived views filtered from self.results at the end of run() — zero breaking change, fixes the divergence bug above as a side effect.
- Ties into the ProcessExecutionReport question: if TaskResult gains status: TaskStatus, then self.results is already shaped almost exactly like ProcessExecutionReport.entries (just missing name/function/args/kwargs, which come from Task). from_result/ProcessRunner.run() could build the report directly from this dict with minimal mapping — makes that future migration much cheaper.
- Effort: moderate, contained to process.py (ProcessRunner) + adding a field to TaskResult (additive, default-valued — safe). No test changes needed if ProcessResult's shape is preserved.

This seems like a good incremental step worth doing on its own regardless of the ProcessExecutionReport replacement decision.

USER: Tasks could have an extra attr TaskStatus already defined. Status could be expanded to PENDING, SUCCESS, ERRORED, SKIPPED. These changes should simplify or solve some of the problems stated by you.



INSTRUCTION: IMPLEMENT BOTH THINGS. Removal of ProcessResult and use of single dict self.results.
79 changes: 63 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ A `Process` holds a list of `Task`s. At construction it validates names, types,

When you call `process.run()`, tasks are topologically sorted and scheduled: dependencies first, independent tasks in parallel.

A `TaskDependency` can forward an upstream result directly into a downstream function, as a positional or keyword argument. The result is a `ProcessResult` with `passed_tasks_results` and `failed_tasks` for inspection.
A `TaskDependency` can forward an upstream result directly into a downstream function, as a positional or keyword argument. The result is a `ProcessExecutionReport` with `successes`, `errored`, and `skipped` for inspection.

---

Expand Down Expand Up @@ -70,7 +70,7 @@ tasks = [
with Process(tasks) as p:
result = p.run(parallel=True)

print(result.passed_tasks_results["enrich"].result)
print(result.successes["enrich"].result)
# [{'id': 1, 'name': 'user-1'}, {'id': 2, 'name': 'user-2'}, {'id': 3, 'name': 'user-3'}]
```

Expand Down Expand Up @@ -201,11 +201,11 @@ tasks = [
with Process(tasks) as process:
result = process.run(parallel=True)

print("passed:", sorted(result.passed_tasks_results))
print("passed:", sorted(result.successes))
# archive_report, build_report, compute_revenue, compute_stock, fetch_inventory, fetch_orders
print("failed:", sorted(result.failed_tasks))
print("failed:", sorted(set(result.errored) | set(result.skipped)))
# notify_slack
print("report:", result.passed_tasks_results["build_report"].result)
print("report:", result.successes["build_report"].result)
# daily-report | revenue=59.50 stock=262.50
```

Expand Down Expand Up @@ -265,24 +265,71 @@ TaskDependency(
```python
Process(tasks: list[Task]) # validates types, names, deps, cycles

process.run(parallel: bool | None = None, max_workers: int = 4) -> ProcessResult
process.run(parallel: bool | None = None, max_workers: int = 4) -> ProcessExecutionReport
```

- Raises `DependencyNotFoundError`, `CircularDependencyError`, `TypeError`, `ValueError` on construction if the workflow is malformed.
- `parallel=None` auto-parallelises when `len(tasks) >= 10`; `max_workers=1` is always sequential.
- Use as a context manager — it cleans up `FileHandler`s on exit.

### `ProcessResult`
### `TaskResult`

```python
result.passed_tasks_results # dict[str, TaskResult] — name → TaskResult for every task that succeeded
result.failed_tasks # set[str] — all tasks that did not produce a result (errored + skipped)
result.errored_tasks # set[str] — tasks whose function actually raised
result.skipped_tasks # set[str] — tasks skipped because an upstream dependency failed
TaskResult(
status: TaskStatus,
result: Any,
exception: Exception | None,
error_data: ErrorData | None = None,
elapsed_seconds: float = 0.0, # wall-clock time across all attempts
attempts: int = 0, # attempts actually executed (0 if never run)
)
```

- `status` — `TaskStatus.PENDING | SUCCESS | ERRORED | SKIPPED`.
- `worked` — `True` if `status == TaskStatus.SUCCESS`.

TaskResult(worked: bool, result: Any, exception: Exception | None)
### `ProcessExecutionReport`

```python
report.entries # dict[str, TaskReportEntry] — one entry per task, in topological order
report.successes # dict[str, TaskReportEntry] — entries with status == TaskStatus.SUCCESS
report.errored # dict[str, TaskReportEntry] — entries with status == TaskStatus.ERRORED
report.skipped # dict[str, TaskReportEntry] — entries with status == TaskStatus.SKIPPED
```

A `TaskReportEntry` carries `name`, `function`, `args`, `kwargs`, `status`
(`TaskStatus.SUCCESS | ERRORED | SKIPPED`), `elapsed_seconds`, `attempts`,
plus `result` (set when `SUCCESS`) and `error: ErrorData | None` (set when
`ERRORED`).

```python
with Process([load_task, apply_task, notify_task]) as process:
report = process.run()

for name, entry in report.entries.items():
if entry.status is TaskStatus.ERRORED:
print(f"{name} failed after {entry.attempts} attempt(s): {entry.error.exception}")
```

### `ErrorData`

```python
ErrorData(
task_name: str,
function: str,
args: tuple[Any, ...],
kwargs: dict[str, Any],
downstream_impact: list[str],
exception: str,
traceback_str: str,
traced_vars: dict[str, str],
traced_vars_location: str,
)
```

Structured failure context for a single task, available via
`TaskResult.error_data` and `TaskReportEntry.error`.

### `SMTPConfig`

```python
Expand Down Expand Up @@ -389,10 +436,10 @@ body, so receivers can verify the payload wasn't tampered with.

When a task raises:

1. The exception is caught and stored in `TaskResult.exception`; the task name goes into `failed_tasks` and `errored_tasks`.
2. **Every task that depends on it (directly or indirectly) is skipped** — added to `failed_tasks` and `skipped_tasks` without running.
1. The exception is caught and stored in `TaskResult.exception`; the task's entry in the report gets `status == TaskStatus.ERRORED`.
2. **Every task that depends on it (directly or indirectly) is skipped** — its entry gets `status == TaskStatus.SKIPPED` without running.
3. **Every other independent part of the workflow keeps running.** With `parallel=True` they keep running concurrently on the worker pool.
4. After `run()` returns, `ProcessResult.errored_tasks` and `ProcessResult.skipped_tasks` let you distinguish root failures from cascade skips for triage or alerting.
4. After `run()` returns, `ProcessExecutionReport.errored` and `ProcessExecutionReport.skipped` let you distinguish root failures from cascade skips for triage or alerting.

When a task has `retries >= 1`, a failure matching `retry_on` triggers another attempt before the task is declared failed and its dependants are skipped. This gives transient errors (network blips, connection resets) a chance to resolve without aborting downstream work.

Expand Down Expand Up @@ -422,7 +469,7 @@ This makes the library a good fit for fan-out / fan-in pipelines, "best-effort"

- **Shared log file** — pass the same `log_path` to every `Task` for a single combined run.log; pass distinct paths for per-task isolation.
- **Auto-parallel** — `Process.run()` with no argument runs sequentially for small workflows and switches to parallel for `len(tasks) >= 10`. Pass `parallel=True` or `parallel=False` to force the mode.
- **Result inspection** — iterate `result.passed_tasks_results.items()` to log or post-process every successful task; iterate `result.failed_tasks` for triage.
- **Result inspection** — iterate `report.successes.items()` to log or post-process every successful task; iterate `set(report.errored) | set(report.skipped)` for triage.
- **Re-raising** — wrap `process.run()` in `try/except` if you need a non-zero exit code on any failure; the library itself does not raise on partial failure.

</details>
Expand Down
6 changes: 5 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
This page is automatically generated from the source code docstrings.

::: processes.process.Process
::: processes.process.ProcessResult
::: processes.task.Task
::: processes.task.TaskDependency
::: processes.task.TaskResult
::: processes.execution_report.ProcessExecutionReport
::: processes.execution_report.TaskReportEntry
::: processes.execution_report.TaskStatus
::: processes._error_data.ErrorData
::: processes.email_config.SMTPConfig
::: processes.email_config.HTMLEmailStyle
4 changes: 4 additions & 0 deletions src/processes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from importlib.metadata import PackageNotFoundError as _pnfe
from importlib.metadata import version as _v

from ._error_data import ErrorData as ErrorData
from .email_config import HTMLEmailStyle as HTMLEmailStyle
from .email_config import SMTPConfig as SMTPConfig
from .exceptions import (
Expand All @@ -12,13 +13,16 @@
from .exceptions import (
TaskNotFoundError as TaskNotFoundError,
)
from .execution_report import ProcessExecutionReport as ProcessExecutionReport
from .execution_report import TaskReportEntry as TaskReportEntry
from .notification_channels import EmailChannel as EmailChannel
from .notification_channels import NotificationChannel as NotificationChannel
from .notification_channels import WebhookChannel as WebhookChannel
from .process import Process as Process
from .task import Task as Task
from .task import TaskDependency as TaskDependency
from .task import TaskResult as TaskResult
from .task import TaskStatus as TaskStatus
from .webhook_config import WebhookConfig as WebhookConfig

try:
Expand Down
8 changes: 4 additions & 4 deletions src/processes/_error_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


@dataclass(frozen=True)
class _ErrorData:
class ErrorData:
"""Typed view of a task failure, extracted from ``record.task_context``.

Attributes
Expand Down Expand Up @@ -46,7 +46,7 @@ class _ErrorData:
class _ErrorContextFormatter(logging.Formatter):
"""Base formatter providing typed access to a record's failure context."""

def _error_data(self, record: logging.LogRecord) -> _ErrorData:
def _error_data(self, record: logging.LogRecord) -> ErrorData:
"""Extract the failure context from a log record.

Parameters
Expand All @@ -57,12 +57,12 @@ def _error_data(self, record: logging.LogRecord) -> _ErrorData:

Returns
-------
_ErrorData
ErrorData
Typed view of ``record.task_context``, with defaults filled in
for any missing fields.
"""
ctx = getattr(record, "task_context", None) or {}
return _ErrorData(
return ErrorData(
task_name=str(ctx.get("task_name", "?")),
function=str(ctx.get("function", "?")),
args=ctx.get("args", ()),
Expand Down
8 changes: 4 additions & 4 deletions src/processes/_webhook_internals.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import urllib.request
from typing import Any

from ._error_data import _ErrorContextFormatter, _ErrorData
from ._error_data import ErrorData, _ErrorContextFormatter
from .webhook_config import WebhookConfig

_SIGNATURE_HEADER = "X-Signature-SHA256"
Expand Down Expand Up @@ -46,16 +46,16 @@ def format(self, record: logging.LogRecord) -> str:
payload = {**generic_payload, **self._extra_payload}
return json.dumps(payload)

def _build_payload(self, error: _ErrorData) -> dict[str, Any]:
"""Build the JSON-serializable payload dict from ``_ErrorData``.
def _build_payload(self, error: ErrorData) -> dict[str, Any]:
"""Build the JSON-serializable payload dict from ``ErrorData``.

Subclasses targeting a specific webhook service can override this
to reshape the payload, while reusing ``format`` and the rest of
the channel/handler machinery.

Parameters
----------
error : _ErrorData
error : ErrorData
Typed failure context for the record being formatted.

Returns
Expand Down
Loading
Loading