functualize-flow-viz 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- functualize_flow_viz-0.1.0/.gitignore +101 -0
- functualize_flow_viz-0.1.0/PKG-INFO +85 -0
- functualize_flow_viz-0.1.0/README.md +65 -0
- functualize_flow_viz-0.1.0/examples/README.md +31 -0
- functualize_flow_viz-0.1.0/examples/pipeline/jobs.py +42 -0
- functualize_flow_viz-0.1.0/examples/pipeline/test_pipeline.py +17 -0
- functualize_flow_viz-0.1.0/pyproject.toml +41 -0
- functualize_flow_viz-0.1.0/src/functualize_flow_viz/__init__.py +5 -0
- functualize_flow_viz-0.1.0/src/functualize_flow_viz/plugin.py +363 -0
- functualize_flow_viz-0.1.0/src/functualize_flow_viz/py.typed +0 -0
- functualize_flow_viz-0.1.0/tests/__init__.py +0 -0
- functualize_flow_viz-0.1.0/tests/conftest.py +1 -0
- functualize_flow_viz-0.1.0/tests/test_flow_viz_plugin.py +265 -0
- functualize_flow_viz-0.1.0/tests/test_plugin.py +15 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
*.egg
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.whl
|
|
11
|
+
|
|
12
|
+
# Agents
|
|
13
|
+
.spec/archive/
|
|
14
|
+
.spec/features/
|
|
15
|
+
.spec/scrutiny-reports/
|
|
16
|
+
.spec/.agentic-coding
|
|
17
|
+
.spec/STATE.md
|
|
18
|
+
.spec/PROJECT.md
|
|
19
|
+
.spec/REQUIREMENTS.md
|
|
20
|
+
.spec/ROADMAP.md
|
|
21
|
+
.opencode/
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Virtual environments
|
|
25
|
+
.venv/
|
|
26
|
+
venv/
|
|
27
|
+
ENV/
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.coverage
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
htmlcov/
|
|
33
|
+
.hypothesis/
|
|
34
|
+
snapshot_report.html
|
|
35
|
+
_*_result*.txt
|
|
36
|
+
_debug.txt
|
|
37
|
+
_tui_debug.txt
|
|
38
|
+
_tui_eval_debug.txt
|
|
39
|
+
|
|
40
|
+
# IDE
|
|
41
|
+
.idea/
|
|
42
|
+
*.swp
|
|
43
|
+
*.swo
|
|
44
|
+
*~
|
|
45
|
+
*.code-workspace
|
|
46
|
+
|
|
47
|
+
# Coding-agent tooling state (guards — these dirs are not part of the repo)
|
|
48
|
+
.kiro/
|
|
49
|
+
.moai/
|
|
50
|
+
|
|
51
|
+
# OS
|
|
52
|
+
.DS_Store
|
|
53
|
+
Thumbs.db
|
|
54
|
+
|
|
55
|
+
# Environment / secrets
|
|
56
|
+
.env
|
|
57
|
+
.env.*
|
|
58
|
+
!.env.example
|
|
59
|
+
|
|
60
|
+
# Agent scratch space (test output, temp scripts)
|
|
61
|
+
tmp/
|
|
62
|
+
|
|
63
|
+
# Local-only files (not for the repo)
|
|
64
|
+
*.local.md
|
|
65
|
+
*.local.*
|
|
66
|
+
|
|
67
|
+
# Personal notes
|
|
68
|
+
HUMAN_NOTE.md
|
|
69
|
+
|
|
70
|
+
# Distribution
|
|
71
|
+
dist/
|
|
72
|
+
|
|
73
|
+
# Documentation site build output
|
|
74
|
+
site/
|
|
75
|
+
|
|
76
|
+
# uv
|
|
77
|
+
.python-version
|
|
78
|
+
.functualize/cache.json
|
|
79
|
+
.functualize_cache.json
|
|
80
|
+
.todos/
|
|
81
|
+
.sidecar/
|
|
82
|
+
.sidecar-agent
|
|
83
|
+
.sidecar-task
|
|
84
|
+
.sidecar-pr
|
|
85
|
+
.sidecar-start.sh
|
|
86
|
+
.sidecar-base
|
|
87
|
+
.td-root
|
|
88
|
+
.functualize/
|
|
89
|
+
.import_linter_cache/
|
|
90
|
+
.mypy_cache/
|
|
91
|
+
.pytest_cache/
|
|
92
|
+
.ruff_cache/
|
|
93
|
+
|
|
94
|
+
# OmO / OpenCode agent run-continuation scratch state
|
|
95
|
+
.omo/
|
|
96
|
+
.mcp.json
|
|
97
|
+
.agentsroom/handoff-transcript-*.txt
|
|
98
|
+
.agentsroom/handoff-summary-*.md
|
|
99
|
+
|
|
100
|
+
# Internal pre-release audit reports (contain session IDs / local infra notes)
|
|
101
|
+
.release/
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: functualize-flow-viz
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Inline flow visualization plugin for functualize job execution
|
|
5
|
+
Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: functualize<1.0.0,>=0.1.0
|
|
15
|
+
Requires-Dist: textual>=0.40.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# functualize-flow-viz
|
|
22
|
+
|
|
23
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
24
|
+
|
|
25
|
+
Inline execution tree visualization plugin for functualize. Renders a live job execution tree in the terminal showing step status, durations, and nested invocations updating in real time. Automatically detects TTY capabilities and falls back to structured plain-text output for non-interactive terminals.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install functualize-flow-viz
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from functualize_flow_viz import FlowVizPlugin
|
|
37
|
+
|
|
38
|
+
# Register the plugin with your functualize app
|
|
39
|
+
plugin = FlowVizPlugin()
|
|
40
|
+
plugin.on_job_start("deploy", {"env": "production"})
|
|
41
|
+
plugin.on_log("info", "Starting deployment...")
|
|
42
|
+
plugin.on_invoke_start("migrate", {"target": "latest"})
|
|
43
|
+
plugin.on_invoke_end("migrate", None)
|
|
44
|
+
plugin.on_job_end("deploy", None)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- **Live execution tree** — Renders a real-time tree of job execution with status icons (⏳ running, ✓ success, ✗ failure, ○ pending, ⊘ timeout)
|
|
50
|
+
- **Nested invocation tracking** — Shows `rc.invoke()` calls as indented children in the tree, preserving parent-child relationships
|
|
51
|
+
- **Real-time duration refresh** — Background thread updates elapsed durations at ≤1s intervals for running nodes
|
|
52
|
+
- **TTY auto-detection** — Uses ANSI inline rendering on interactive terminals; falls back to plain-text output for piped/CI environments
|
|
53
|
+
- **Custom event handling** — Renders domain-specific visualizations including progress bars for events with progress payloads
|
|
54
|
+
- **Log streaming** — Buffers log messages and prints them above the inline tree widget to avoid visual corruption
|
|
55
|
+
|
|
56
|
+
## API Reference
|
|
57
|
+
|
|
58
|
+
Public classes and functions exported by this plugin:
|
|
59
|
+
|
|
60
|
+
- `FlowVizPlugin` — Main plugin class implementing the OutputRenderer protocol. Auto-selects between TTY and plain-text rendering. Methods:
|
|
61
|
+
- `on_job_start(job_name, metadata)` — Initialize the execution tree for a job
|
|
62
|
+
- `on_log(level, message)` — Render a log message
|
|
63
|
+
- `on_status_change(old_status, new_status, message)` — Handle status transitions
|
|
64
|
+
- `on_phase_change(step, action)` — Add or update a job phase node
|
|
65
|
+
- `on_invoke_start(child_job_name, kwargs)` — Begin tracking a nested invocation
|
|
66
|
+
- `on_invoke_end(child_job_name, result)` — Complete a nested invocation
|
|
67
|
+
- `on_job_end(job_name, result)` — Finalize the tree and render final state
|
|
68
|
+
- `on_event(event)` — Handle custom structured events
|
|
69
|
+
- `render_log(message, level)` — OutputRenderer protocol: render log
|
|
70
|
+
- `render_phase(phase, status)` — OutputRenderer protocol: render phase update
|
|
71
|
+
- `render_progress(current, total, label)` — OutputRenderer protocol: render progress
|
|
72
|
+
|
|
73
|
+
Internal (non-public) classes used by `FlowVizPlugin`:
|
|
74
|
+
|
|
75
|
+
- `TreeNode` — Dataclass representing a node in the execution tree with label, status, timing, and children
|
|
76
|
+
- `InlineTTYRenderer` — ANSI-based renderer for interactive terminals with background refresh
|
|
77
|
+
- `PlainTextRenderer` — Fallback renderer producing structured plain-text without escape sequences
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
Run plugin tests:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv run pytest plugins/functualize-flow-viz/tests/ -v
|
|
85
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# functualize-flow-viz
|
|
2
|
+
|
|
3
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
4
|
+
|
|
5
|
+
Inline execution tree visualization plugin for functualize. Renders a live job execution tree in the terminal showing step status, durations, and nested invocations updating in real time. Automatically detects TTY capabilities and falls back to structured plain-text output for non-interactive terminals.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install functualize-flow-viz
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from functualize_flow_viz import FlowVizPlugin
|
|
17
|
+
|
|
18
|
+
# Register the plugin with your functualize app
|
|
19
|
+
plugin = FlowVizPlugin()
|
|
20
|
+
plugin.on_job_start("deploy", {"env": "production"})
|
|
21
|
+
plugin.on_log("info", "Starting deployment...")
|
|
22
|
+
plugin.on_invoke_start("migrate", {"target": "latest"})
|
|
23
|
+
plugin.on_invoke_end("migrate", None)
|
|
24
|
+
plugin.on_job_end("deploy", None)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Live execution tree** — Renders a real-time tree of job execution with status icons (⏳ running, ✓ success, ✗ failure, ○ pending, ⊘ timeout)
|
|
30
|
+
- **Nested invocation tracking** — Shows `rc.invoke()` calls as indented children in the tree, preserving parent-child relationships
|
|
31
|
+
- **Real-time duration refresh** — Background thread updates elapsed durations at ≤1s intervals for running nodes
|
|
32
|
+
- **TTY auto-detection** — Uses ANSI inline rendering on interactive terminals; falls back to plain-text output for piped/CI environments
|
|
33
|
+
- **Custom event handling** — Renders domain-specific visualizations including progress bars for events with progress payloads
|
|
34
|
+
- **Log streaming** — Buffers log messages and prints them above the inline tree widget to avoid visual corruption
|
|
35
|
+
|
|
36
|
+
## API Reference
|
|
37
|
+
|
|
38
|
+
Public classes and functions exported by this plugin:
|
|
39
|
+
|
|
40
|
+
- `FlowVizPlugin` — Main plugin class implementing the OutputRenderer protocol. Auto-selects between TTY and plain-text rendering. Methods:
|
|
41
|
+
- `on_job_start(job_name, metadata)` — Initialize the execution tree for a job
|
|
42
|
+
- `on_log(level, message)` — Render a log message
|
|
43
|
+
- `on_status_change(old_status, new_status, message)` — Handle status transitions
|
|
44
|
+
- `on_phase_change(step, action)` — Add or update a job phase node
|
|
45
|
+
- `on_invoke_start(child_job_name, kwargs)` — Begin tracking a nested invocation
|
|
46
|
+
- `on_invoke_end(child_job_name, result)` — Complete a nested invocation
|
|
47
|
+
- `on_job_end(job_name, result)` — Finalize the tree and render final state
|
|
48
|
+
- `on_event(event)` — Handle custom structured events
|
|
49
|
+
- `render_log(message, level)` — OutputRenderer protocol: render log
|
|
50
|
+
- `render_phase(phase, status)` — OutputRenderer protocol: render phase update
|
|
51
|
+
- `render_progress(current, total, label)` — OutputRenderer protocol: render progress
|
|
52
|
+
|
|
53
|
+
Internal (non-public) classes used by `FlowVizPlugin`:
|
|
54
|
+
|
|
55
|
+
- `TreeNode` — Dataclass representing a node in the execution tree with label, status, timing, and children
|
|
56
|
+
- `InlineTTYRenderer` — ANSI-based renderer for interactive terminals with background refresh
|
|
57
|
+
- `PlainTextRenderer` — Fallback renderer producing structured plain-text without escape sequences
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
Run plugin tests:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv run pytest plugins/functualize-flow-viz/tests/ -v
|
|
65
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# functualize-flow-viz Examples
|
|
2
|
+
|
|
3
|
+
Live inline execution tree — zero code changes to your jobs. The plugin subscribes to `invoke_start`, `invoke_end`, and `phase_change` events and renders phase status, durations, and nested invocations.
|
|
4
|
+
|
|
5
|
+
| Directory | Demonstrates |
|
|
6
|
+
|-----------|--------------|
|
|
7
|
+
| [`pipeline/`](pipeline/) | A job invoking sub-jobs with phase tracking — the shape flow-viz visualizes |
|
|
8
|
+
|
|
9
|
+
## See it live
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
cd plugins/functualize-flow-viz/examples/pipeline
|
|
13
|
+
func jobs.py morning_report --city Tokyo
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
With `functualize-flow-viz` installed, the run renders as a live tree:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
⏳ morning_report
|
|
20
|
+
├─ ✓ forecast — Forecast retrieved (0.1s)
|
|
21
|
+
├─ ✓ alerts — Alerts checked (0.1s)
|
|
22
|
+
└─ ✓ morning_report (0.3s)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Uninstall the plugin and the same command prints plain logs — jobs never know the difference.
|
|
26
|
+
|
|
27
|
+
## Tests
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv run pytest plugins/functualize-flow-viz/examples/ -v
|
|
31
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""A nested-invocation pipeline for flow-viz to visualize.
|
|
2
|
+
|
|
3
|
+
Run with:
|
|
4
|
+
func jobs.py morning_report --city Tokyo
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from functualize.job import RunContext
|
|
10
|
+
from functualize.types import RunStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ForecastConfig(BaseModel):
|
|
14
|
+
"""Configuration for weather jobs."""
|
|
15
|
+
|
|
16
|
+
city: str = Field(description="City to check")
|
|
17
|
+
days: int = Field(default=3, ge=1, le=7, description="Days to forecast")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def forecast(config: ForecastConfig, rc: RunContext) -> str:
|
|
21
|
+
"""Fetch the weather forecast for the configured city."""
|
|
22
|
+
rc.log(f"Fetching {config.days}-day forecast for {config.city}...")
|
|
23
|
+
return f"{config.city}: 24°C, sunny"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def alert(config: ForecastConfig, rc: RunContext) -> str:
|
|
27
|
+
"""Check forecast and send alerts if needed."""
|
|
28
|
+
rc.log(f"No severe weather for {config.city} — all clear")
|
|
29
|
+
return "all clear"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def morning_report(config: ForecastConfig, rc: RunContext) -> None:
|
|
33
|
+
"""Run the full morning weather pipeline (watch the tree render)."""
|
|
34
|
+
rc.track_phase("forecast", "Fetching forecast", RunStatus.RUNNING)
|
|
35
|
+
rc.invoke("forecast", city=config.city, days=config.days)
|
|
36
|
+
rc.track_phase("forecast", "Forecast retrieved", RunStatus.SUCCESS)
|
|
37
|
+
|
|
38
|
+
rc.track_phase("alerts", "Checking alerts", RunStatus.RUNNING)
|
|
39
|
+
rc.invoke("alert", city=config.city)
|
|
40
|
+
rc.track_phase("alerts", "Alerts checked", RunStatus.SUCCESS)
|
|
41
|
+
|
|
42
|
+
rc.log("Morning report complete")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tests for the pipeline example (the jobs flow-viz visualizes)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
8
|
+
|
|
9
|
+
from jobs import ForecastConfig, morning_report
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_pipeline_emits_invokes_and_phases():
|
|
13
|
+
rc = MagicMock()
|
|
14
|
+
morning_report(ForecastConfig(city="Tokyo"), rc)
|
|
15
|
+
invoked = [call.args[0] for call in rc.invoke.call_args_list]
|
|
16
|
+
assert invoked == ["forecast", "alert"]
|
|
17
|
+
assert rc.track_phase.call_count == 4
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "functualize-flow-viz"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Inline flow visualization plugin for functualize job execution"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"functualize>=0.1.0,<1.0.0",
|
|
13
|
+
"textual>=0.40.0",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.entry-points."functualize.plugins"]
|
|
25
|
+
flow-viz = "functualize_flow_viz:FlowVizPlugin"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.4.0",
|
|
30
|
+
"pytest-cov>=4.1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
functualize = { workspace = true }
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/functualize_flow_viz"]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Functualize Flow Viz Plugin — inline execution tree visualization.
|
|
2
|
+
|
|
3
|
+
Renders a live job execution tree — status icons, durations, nested
|
|
4
|
+
``rc.invoke()`` children, custom ``rc.emit`` events — as a hosted
|
|
5
|
+
``LiveConstruct``.
|
|
6
|
+
|
|
7
|
+
Architecture note: this plugin used to be a self-rendering ``Surface`` with two
|
|
8
|
+
hand-rolled renderers (a non-TTY plain-text printer and a TTY renderer doing
|
|
9
|
+
its own ANSI cursor math on a 0.5s daemon thread). That made it a second writer
|
|
10
|
+
competing for the cursor with whatever else was drawing — the collision class
|
|
11
|
+
the surface architecture exists to remove.
|
|
12
|
+
|
|
13
|
+
Now it is a construct **hosted by a live zone**. The zone (``StdoutSurface`` on
|
|
14
|
+
a direct run, ``PanelLiveZone`` in the TUI) owns the cursor and the refresh
|
|
15
|
+
cadence, and Rich's own ``Live`` handles non-TTY degradation by printing final
|
|
16
|
+
state only. That deletes the daemon thread, the cursor math, and the two-renderer
|
|
17
|
+
split in one move.
|
|
18
|
+
|
|
19
|
+
It registers as an **ambient construct**: it renders by default for jobs that
|
|
20
|
+
invoke children (where a tree has something to show), with no job-author code.
|
|
21
|
+
Users turn it off with ``[flow-viz] enabled = false``, ``[live] suppress``,
|
|
22
|
+
``@job(suppress_live=["flow-viz"])``, or ``live.suppress("flow-viz")``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
__all__ = ["FlowVizConstruct", "FlowVizPlugin", "TreeNode"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── Status Icons ─────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
ICON_RUNNING = "⏳"
|
|
38
|
+
ICON_SUCCESS = "✓"
|
|
39
|
+
ICON_FAILURE = "✗"
|
|
40
|
+
ICON_PENDING = "○"
|
|
41
|
+
ICON_TIMEOUT = "⊘"
|
|
42
|
+
|
|
43
|
+
_STATUS_STYLES = {
|
|
44
|
+
"running": "yellow",
|
|
45
|
+
"success": "green",
|
|
46
|
+
"failure": "red",
|
|
47
|
+
"timeout": "red",
|
|
48
|
+
"pending": "dim",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _status_icon(status: str) -> str:
|
|
53
|
+
"""Return the icon for a status string."""
|
|
54
|
+
return {
|
|
55
|
+
"running": ICON_RUNNING,
|
|
56
|
+
"success": ICON_SUCCESS,
|
|
57
|
+
"failure": ICON_FAILURE,
|
|
58
|
+
"timeout": ICON_TIMEOUT,
|
|
59
|
+
"pending": ICON_PENDING,
|
|
60
|
+
}.get(status, ICON_PENDING)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_duration(duration_ms: float) -> str:
|
|
64
|
+
"""Format a duration in ms as a compact human string."""
|
|
65
|
+
if duration_ms < 1000:
|
|
66
|
+
return f"{duration_ms:.0f}ms"
|
|
67
|
+
if duration_ms < 60_000:
|
|
68
|
+
return f"{duration_ms / 1000:.1f}s"
|
|
69
|
+
minutes = int(duration_ms // 60_000)
|
|
70
|
+
seconds = (duration_ms % 60_000) / 1000
|
|
71
|
+
return f"{minutes}m{seconds:.1f}s"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─── Tree Node ────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class TreeNode:
|
|
79
|
+
"""A node in the execution tree representing a job or step."""
|
|
80
|
+
|
|
81
|
+
label: str
|
|
82
|
+
status: str = "pending" # pending, running, success, failure, timeout
|
|
83
|
+
start_time: float | None = None
|
|
84
|
+
end_time: float | None = None
|
|
85
|
+
children: list[TreeNode] = field(default_factory=list)
|
|
86
|
+
events: list[str] = field(default_factory=list)
|
|
87
|
+
depth: int = 0
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def duration_ms(self) -> float:
|
|
91
|
+
"""Elapsed duration in milliseconds."""
|
|
92
|
+
if self.start_time is None:
|
|
93
|
+
return 0.0
|
|
94
|
+
end = self.end_time if self.end_time is not None else time.time()
|
|
95
|
+
return (end - self.start_time) * 1000
|
|
96
|
+
|
|
97
|
+
def render_line(self) -> str:
|
|
98
|
+
"""Render this node as a single line with icon, label, and duration."""
|
|
99
|
+
icon = _status_icon(self.status)
|
|
100
|
+
# `is not None`, not truthiness — matches `duration_ms` above, which
|
|
101
|
+
# already treats 0.0 as a real start time rather than "unstarted".
|
|
102
|
+
duration = (
|
|
103
|
+
_format_duration(self.duration_ms) if self.start_time is not None else ""
|
|
104
|
+
)
|
|
105
|
+
suffix = f" ({duration})" if duration else ""
|
|
106
|
+
return f"{icon} {self.label}{suffix}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── Live Construct ───────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FlowVizConstruct:
|
|
113
|
+
"""A ``LiveConstruct`` that renders the execution tree.
|
|
114
|
+
|
|
115
|
+
``__rich__()`` builds a ``rich.tree.Tree`` from current state;
|
|
116
|
+
``handle_event()`` mutates that state from the structured event stream.
|
|
117
|
+
The hosting live zone decides when to repaint, so there is no refresh
|
|
118
|
+
thread and no cursor management here.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
name: str = "flow-viz"
|
|
122
|
+
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
self._root: TreeNode | None = None
|
|
125
|
+
self._node_stack: list[TreeNode] = []
|
|
126
|
+
|
|
127
|
+
# ─── Rendering ────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def __rich__(self) -> Any:
|
|
130
|
+
"""Return a ``rich.tree.Tree`` for the current execution state."""
|
|
131
|
+
from rich.tree import Tree
|
|
132
|
+
|
|
133
|
+
if self._root is None:
|
|
134
|
+
return Tree("")
|
|
135
|
+
tree = Tree(self._styled(self._root))
|
|
136
|
+
self._attach(tree, self._root)
|
|
137
|
+
return tree
|
|
138
|
+
|
|
139
|
+
def _styled(self, node: TreeNode) -> str:
|
|
140
|
+
"""Render one node's line with a status-appropriate style."""
|
|
141
|
+
style = _STATUS_STYLES.get(node.status, "")
|
|
142
|
+
line = node.render_line()
|
|
143
|
+
return f"[{style}]{line}[/{style}]" if style else line
|
|
144
|
+
|
|
145
|
+
def _attach(self, branch: Any, node: TreeNode) -> None:
|
|
146
|
+
"""Recursively attach a node's events and children to a Rich branch."""
|
|
147
|
+
for event in node.events:
|
|
148
|
+
branch.add(f"[dim]◆ {event}[/dim]")
|
|
149
|
+
for child in node.children:
|
|
150
|
+
child_branch = branch.add(self._styled(child))
|
|
151
|
+
self._attach(child_branch, child)
|
|
152
|
+
|
|
153
|
+
# ─── Event handling ───────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def current_node(self) -> TreeNode | None:
|
|
157
|
+
"""The node currently receiving events (deepest open scope)."""
|
|
158
|
+
if self._node_stack:
|
|
159
|
+
return self._node_stack[-1]
|
|
160
|
+
return self._root
|
|
161
|
+
|
|
162
|
+
def handle_event(self, event: Any) -> None:
|
|
163
|
+
"""Update tree state from one structured event.
|
|
164
|
+
|
|
165
|
+
The engine's job vocabulary is ``job.execute.start`` /
|
|
166
|
+
``job.execute.end`` / ``job.execute.error`` (see
|
|
167
|
+
``_events/_catalog_entries.py``). Nesting is **not** a separate
|
|
168
|
+
``invoke.*`` event pair — a child started via ``rc.invoke()`` emits the
|
|
169
|
+
same ``job.execute.*`` names carrying an ``invoke_depth`` payload, so
|
|
170
|
+
the tree is built from that depth rather than from an open/close stack.
|
|
171
|
+
|
|
172
|
+
Caveat — the lifecycle branch is currently unreachable: ``job.execute.``
|
|
173
|
+
is one of ``RunContext._FRAMEWORK_EVENT_PREFIXES``, which
|
|
174
|
+
``_dispatch_to_surfaces`` filters out, so surfaces (and therefore
|
|
175
|
+
hosted constructs) only ever see custom ``rc.emit`` events. The
|
|
176
|
+
handling is kept because it is the correct mapping the moment lifecycle
|
|
177
|
+
events are surfaced, and because it costs nothing meanwhile. See
|
|
178
|
+
``contributor/architecture/event-vocabulary.md``.
|
|
179
|
+
|
|
180
|
+
Unrecognized events are recorded on the current node rather than
|
|
181
|
+
dropped, so a domain's custom ``rc.emit`` still shows up in the tree.
|
|
182
|
+
"""
|
|
183
|
+
event_name = str(getattr(event, "event_name", "") or "")
|
|
184
|
+
payload = getattr(event, "payload", {}) or {}
|
|
185
|
+
resource = getattr(event, "resource", "") or ""
|
|
186
|
+
|
|
187
|
+
if event_name == "job.execute.start":
|
|
188
|
+
self._job_started(event_name, resource, payload)
|
|
189
|
+
elif event_name in ("job.execute.end", "job.execute.error"):
|
|
190
|
+
self._job_ended(event_name, resource, payload)
|
|
191
|
+
else:
|
|
192
|
+
self._record_custom(event_name, resource, payload)
|
|
193
|
+
|
|
194
|
+
# ─── State transitions ────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def _label_for(self, resource: str, payload: dict[str, Any], fallback: str) -> str:
|
|
197
|
+
for key in ("job_name", "name", "step", "child_job_name"):
|
|
198
|
+
value = payload.get(key)
|
|
199
|
+
if value:
|
|
200
|
+
return str(value)
|
|
201
|
+
return resource or fallback
|
|
202
|
+
|
|
203
|
+
def _job_started(
|
|
204
|
+
self, event_name: str, resource: str, payload: dict[str, Any]
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Open a node at the event's ``invoke_depth``.
|
|
207
|
+
|
|
208
|
+
Depth 0 is the top-level job (the root); deeper events are children
|
|
209
|
+
started by ``rc.invoke()``. Unwinding to the right parent by depth is
|
|
210
|
+
what makes nesting work without paired invoke events.
|
|
211
|
+
"""
|
|
212
|
+
label = self._label_for(resource, payload, "job")
|
|
213
|
+
depth = _depth_of(payload)
|
|
214
|
+
node = TreeNode(
|
|
215
|
+
label=label, status="running", start_time=time.time(), depth=depth
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if self._root is None or depth == 0:
|
|
219
|
+
if self._root is None:
|
|
220
|
+
self._root = node
|
|
221
|
+
self._node_stack = [node]
|
|
222
|
+
else:
|
|
223
|
+
# A second depth-0 job in one window (a fresh run against a
|
|
224
|
+
# reused construct): restart rather than graft onto the old
|
|
225
|
+
# tree, which would misreport the previous run as this one.
|
|
226
|
+
self._root = node
|
|
227
|
+
self._node_stack = [node]
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Unwind to this node's parent, then attach.
|
|
231
|
+
while self._node_stack and self._node_stack[-1].depth >= depth:
|
|
232
|
+
self._node_stack.pop()
|
|
233
|
+
parent = self._node_stack[-1] if self._node_stack else self._root
|
|
234
|
+
parent.children.append(node)
|
|
235
|
+
self._node_stack.append(node)
|
|
236
|
+
|
|
237
|
+
def _job_ended(
|
|
238
|
+
self, event_name: str, resource: str, payload: dict[str, Any]
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Close the node this event terminates, matched by name then depth."""
|
|
241
|
+
status = _status_from(event_name, payload)
|
|
242
|
+
label = self._label_for(resource, payload, "job")
|
|
243
|
+
|
|
244
|
+
for index in range(len(self._node_stack) - 1, -1, -1):
|
|
245
|
+
node = self._node_stack[index]
|
|
246
|
+
if node.label == label:
|
|
247
|
+
node.status = status
|
|
248
|
+
node.end_time = time.time()
|
|
249
|
+
del self._node_stack[index:]
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# No open node matched (a end without its start) — fall back to the
|
|
253
|
+
# root so a completed run is never left rendering as still running.
|
|
254
|
+
if self._root is not None and self._root.end_time is None:
|
|
255
|
+
self._root.status = status
|
|
256
|
+
self._root.end_time = time.time()
|
|
257
|
+
|
|
258
|
+
def _record_custom(
|
|
259
|
+
self, event_name: str, resource: str, payload: dict[str, Any]
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Attach a custom ``rc.emit`` event to the current node."""
|
|
262
|
+
node = self.current_node
|
|
263
|
+
if node is None:
|
|
264
|
+
# No job scope yet — start one so custom events are still visible.
|
|
265
|
+
self._root = TreeNode(
|
|
266
|
+
label="events", status="running", start_time=time.time()
|
|
267
|
+
)
|
|
268
|
+
node = self._root
|
|
269
|
+
|
|
270
|
+
if "progress" in payload:
|
|
271
|
+
progress = payload["progress"]
|
|
272
|
+
bar_width = 20
|
|
273
|
+
try:
|
|
274
|
+
filled = int(bar_width * float(progress) / 100)
|
|
275
|
+
except (TypeError, ValueError):
|
|
276
|
+
filled = 0
|
|
277
|
+
filled = max(0, min(bar_width, filled))
|
|
278
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
279
|
+
node.events.append(f"{event_name}: [{bar}] {progress}%")
|
|
280
|
+
elif resource:
|
|
281
|
+
node.events.append(f"{event_name} ({resource})")
|
|
282
|
+
elif event_name:
|
|
283
|
+
node.events.append(event_name)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _depth_of(payload: dict[str, Any]) -> int:
|
|
287
|
+
"""Read ``invoke_depth`` off a job event payload (0 when absent)."""
|
|
288
|
+
try:
|
|
289
|
+
return max(0, int(payload.get("invoke_depth", 0) or 0))
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _status_from(event_name: str, payload: dict[str, Any]) -> str:
|
|
295
|
+
"""Derive a node status from a terminal event name or its payload."""
|
|
296
|
+
if event_name.endswith(".error") or event_name.endswith(".failed"):
|
|
297
|
+
return "failure"
|
|
298
|
+
status = payload.get("status")
|
|
299
|
+
if status is not None:
|
|
300
|
+
value = getattr(status, "value", status)
|
|
301
|
+
text = str(value).lower()
|
|
302
|
+
if text in _STATUS_STYLES:
|
|
303
|
+
return text
|
|
304
|
+
return "success"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ─── Plugin Class ─────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class FlowVizPlugin:
|
|
311
|
+
"""Registers the flow-viz tree as an ambient live construct.
|
|
312
|
+
|
|
313
|
+
Implements PluginConfigProtocol (``__call__``) for auto-registration via
|
|
314
|
+
the ``functualize.plugins`` entry point.
|
|
315
|
+
|
|
316
|
+
Deliberately not a ``PromptCollector``: it draws, it does not ask. Giving
|
|
317
|
+
it a stub ``collect`` would make it win prompt resolution and swallow
|
|
318
|
+
prompts it cannot answer — and it auto-loads, so that would break
|
|
319
|
+
prompting for every project that installs it.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
name: str = "flow-viz"
|
|
323
|
+
version: str = "0.2.0"
|
|
324
|
+
description: str = "Inline flow visualization for job execution"
|
|
325
|
+
|
|
326
|
+
def __call__(self, app: Any) -> None:
|
|
327
|
+
"""Register the construct as an ambient default, unless disabled."""
|
|
328
|
+
if not _enabled(app):
|
|
329
|
+
return
|
|
330
|
+
with contextlib.suppress(Exception):
|
|
331
|
+
app.register_ambient_construct(
|
|
332
|
+
FlowVizConstruct,
|
|
333
|
+
name="flow-viz",
|
|
334
|
+
predicate=_renders_for,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _renders_for(descriptor: Any) -> bool:
|
|
339
|
+
"""Only render for jobs that invoke children — a tree needs branches.
|
|
340
|
+
|
|
341
|
+
A single-step job's tree is one line that repeats what the log already
|
|
342
|
+
says, so the default stays out of the way.
|
|
343
|
+
"""
|
|
344
|
+
if descriptor is None:
|
|
345
|
+
return False
|
|
346
|
+
if getattr(descriptor, "uses_invoke", False):
|
|
347
|
+
return True
|
|
348
|
+
steps = getattr(descriptor, "workflow_steps", None)
|
|
349
|
+
return bool(steps) and len(steps) > 1
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _enabled(app: Any) -> bool:
|
|
353
|
+
"""Whether ``[flow-viz] enabled`` permits registration (default True)."""
|
|
354
|
+
try:
|
|
355
|
+
settings = getattr(app, "settings", None)
|
|
356
|
+
if settings is None:
|
|
357
|
+
return True
|
|
358
|
+
value = settings.get("flow-viz.enabled", True)
|
|
359
|
+
if isinstance(value, str):
|
|
360
|
+
return value.strip().lower() not in {"false", "0", "no", "off"}
|
|
361
|
+
return bool(value)
|
|
362
|
+
except Exception:
|
|
363
|
+
return True
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared fixtures for functualize-flow-viz plugin tests."""
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Tests for the flow-viz plugin as a hosted LiveConstruct.
|
|
2
|
+
|
|
3
|
+
The plugin used to be a self-rendering Surface with two hand-rolled renderers
|
|
4
|
+
(``PlainTextRenderer`` / ``InlineTTYRenderer``); this suite replaced the tests
|
|
5
|
+
written around those. What matters now is:
|
|
6
|
+
|
|
7
|
+
- ``handle_event`` builds the right tree state from the event stream,
|
|
8
|
+
- ``__rich__`` renders that state as a ``rich.tree.Tree``,
|
|
9
|
+
- the plugin registers as an *ambient* construct with a sensible predicate,
|
|
10
|
+
- the config lever actually suppresses registration.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from functualize_flow_viz import FlowVizConstruct, FlowVizPlugin, TreeNode
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Event:
|
|
24
|
+
"""Minimal stand-in for a StructuredEvent."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, event_name: str, resource: str = "", **payload: Any) -> None:
|
|
27
|
+
self.event_name = event_name
|
|
28
|
+
self.resource = resource
|
|
29
|
+
self.payload = payload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _render(construct: FlowVizConstruct) -> str:
|
|
33
|
+
console = Console(file=io.StringIO(), width=100, force_terminal=False)
|
|
34
|
+
console.print(construct.__rich__())
|
|
35
|
+
return console.file.getvalue() # type: ignore[union-attr]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ─── Tree state from events ───────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_job_started_creates_the_root() -> None:
|
|
42
|
+
c = FlowVizConstruct()
|
|
43
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
44
|
+
|
|
45
|
+
assert c._root is not None
|
|
46
|
+
assert c._root.label == "deploy"
|
|
47
|
+
assert c._root.status == "running"
|
|
48
|
+
assert "deploy" in _render(c)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_job_completed_marks_the_root_successful() -> None:
|
|
52
|
+
c = FlowVizConstruct()
|
|
53
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
54
|
+
c.handle_event(_Event("job.execute.end", job_name="deploy", status="success"))
|
|
55
|
+
|
|
56
|
+
assert c._root is not None
|
|
57
|
+
assert c._root.status == "success"
|
|
58
|
+
assert c._root.end_time is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_job_failed_marks_the_root_failed() -> None:
|
|
62
|
+
c = FlowVizConstruct()
|
|
63
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
64
|
+
c.handle_event(_Event("job.execute.error", job_name="deploy"))
|
|
65
|
+
|
|
66
|
+
assert c._root is not None
|
|
67
|
+
assert c._root.status == "failure"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_invoke_depth_nests_a_child_under_the_job() -> None:
|
|
71
|
+
c = FlowVizConstruct()
|
|
72
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
73
|
+
c.handle_event(_Event("job.execute.start", job_name="migrate", invoke_depth=1))
|
|
74
|
+
c.handle_event(_Event("job.execute.end", job_name="migrate", status="success"))
|
|
75
|
+
|
|
76
|
+
assert c._root is not None
|
|
77
|
+
assert [child.label for child in c._root.children] == ["migrate"]
|
|
78
|
+
assert c._root.children[0].status == "success"
|
|
79
|
+
|
|
80
|
+
rendered = _render(c)
|
|
81
|
+
assert "deploy" in rendered
|
|
82
|
+
assert "migrate" in rendered
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_deeper_invoke_depth_nests_and_unwinds() -> None:
|
|
86
|
+
c = FlowVizConstruct()
|
|
87
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
88
|
+
c.handle_event(_Event("job.execute.start", job_name="migrate", invoke_depth=1))
|
|
89
|
+
c.handle_event(_Event("job.execute.start", job_name="seed", invoke_depth=2))
|
|
90
|
+
|
|
91
|
+
assert c.current_node is not None
|
|
92
|
+
assert c.current_node.label == "seed"
|
|
93
|
+
|
|
94
|
+
c.handle_event(_Event("job.execute.end", job_name="seed", status="success"))
|
|
95
|
+
assert c.current_node is not None
|
|
96
|
+
assert c.current_node.label == "migrate"
|
|
97
|
+
|
|
98
|
+
c.handle_event(_Event("job.execute.end", job_name="migrate", status="success"))
|
|
99
|
+
assert c.current_node is c._root
|
|
100
|
+
|
|
101
|
+
assert c._root is not None
|
|
102
|
+
migrate = c._root.children[0]
|
|
103
|
+
assert [g.label for g in migrate.children] == ["seed"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_failed_child_is_marked_failure() -> None:
|
|
107
|
+
c = FlowVizConstruct()
|
|
108
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
109
|
+
c.handle_event(_Event("job.execute.start", job_name="migrate", invoke_depth=1))
|
|
110
|
+
c.handle_event(_Event("job.execute.error", job_name="migrate"))
|
|
111
|
+
|
|
112
|
+
assert c._root is not None
|
|
113
|
+
assert c._root.children[0].status == "failure"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_custom_events_attach_to_the_current_node() -> None:
|
|
117
|
+
c = FlowVizConstruct()
|
|
118
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
119
|
+
c.handle_event(_Event("upload.chunk", resource="s3://bucket"))
|
|
120
|
+
|
|
121
|
+
assert c._root is not None
|
|
122
|
+
assert any("upload.chunk" in e for e in c._root.events)
|
|
123
|
+
assert "upload.chunk" in _render(c)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_progress_payload_renders_a_bar() -> None:
|
|
127
|
+
c = FlowVizConstruct()
|
|
128
|
+
c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
|
|
129
|
+
c.handle_event(_Event("upload.progress", progress=50))
|
|
130
|
+
|
|
131
|
+
assert c._root is not None
|
|
132
|
+
recorded = c._root.events[0]
|
|
133
|
+
assert "█" in recorded and "░" in recorded
|
|
134
|
+
assert "50%" in recorded
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_custom_event_before_any_job_still_renders() -> None:
|
|
138
|
+
"""Events with no job scope must not be silently dropped."""
|
|
139
|
+
c = FlowVizConstruct()
|
|
140
|
+
c.handle_event(_Event("standalone.thing"))
|
|
141
|
+
|
|
142
|
+
assert c._root is not None
|
|
143
|
+
assert "standalone.thing" in _render(c)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_empty_construct_renders_without_error() -> None:
|
|
147
|
+
assert _render(FlowVizConstruct()) is not None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_each_construct_has_independent_state() -> None:
|
|
151
|
+
"""The plugin registers a factory, so runs must not share a tree."""
|
|
152
|
+
a, b = FlowVizConstruct(), FlowVizConstruct()
|
|
153
|
+
a.handle_event(_Event("job.execute.start", job_name="first", invoke_depth=0))
|
|
154
|
+
|
|
155
|
+
assert b._root is None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ─── Node rendering ───────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_node_line_includes_icon_and_duration() -> None:
|
|
162
|
+
node = TreeNode(label="deploy", status="success", start_time=0.0, end_time=1.5)
|
|
163
|
+
line = node.render_line()
|
|
164
|
+
|
|
165
|
+
assert "deploy" in line
|
|
166
|
+
assert "✓" in line
|
|
167
|
+
assert "1.5s" in line
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_pending_node_has_no_duration_suffix() -> None:
|
|
171
|
+
assert TreeNode(label="deploy").render_line().strip().endswith("deploy")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ─── Plugin registration ──────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _FakeSettings:
|
|
178
|
+
def __init__(self, values: dict[str, Any] | None = None) -> None:
|
|
179
|
+
self._values = values or {}
|
|
180
|
+
|
|
181
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
182
|
+
return self._values.get(key, default)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _FakeApp:
|
|
186
|
+
def __init__(self, settings: _FakeSettings | None = None) -> None:
|
|
187
|
+
self.settings = settings
|
|
188
|
+
self.registered: list[tuple[Any, str, Any]] = []
|
|
189
|
+
|
|
190
|
+
def register_ambient_construct(
|
|
191
|
+
self, factory: Any, *, name: str | None = None, predicate: Any = None
|
|
192
|
+
) -> None:
|
|
193
|
+
self.registered.append((factory, name or "", predicate))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Descriptor:
|
|
197
|
+
def __init__(self, uses_invoke: bool = False, workflow_steps: Any = None) -> None:
|
|
198
|
+
self.uses_invoke = uses_invoke
|
|
199
|
+
self.workflow_steps = workflow_steps or []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_plugin_registers_an_ambient_construct() -> None:
|
|
203
|
+
app = _FakeApp()
|
|
204
|
+
FlowVizPlugin()(app)
|
|
205
|
+
|
|
206
|
+
assert len(app.registered) == 1
|
|
207
|
+
factory, name, predicate = app.registered[0]
|
|
208
|
+
assert factory is FlowVizConstruct
|
|
209
|
+
assert name == "flow-viz"
|
|
210
|
+
assert predicate is not None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_plugin_registers_a_factory_not_an_instance() -> None:
|
|
214
|
+
"""Each run needs fresh tree state, so registration must pass the class."""
|
|
215
|
+
app = _FakeApp()
|
|
216
|
+
FlowVizPlugin()(app)
|
|
217
|
+
|
|
218
|
+
factory = app.registered[0][0]
|
|
219
|
+
assert isinstance(factory(), FlowVizConstruct)
|
|
220
|
+
assert factory() is not factory()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.parametrize(
|
|
224
|
+
("descriptor", "expected"),
|
|
225
|
+
[
|
|
226
|
+
(_Descriptor(uses_invoke=True), True),
|
|
227
|
+
(_Descriptor(workflow_steps=["a", "b"]), True),
|
|
228
|
+
(_Descriptor(uses_invoke=False), False),
|
|
229
|
+
(_Descriptor(workflow_steps=["only-one"]), False),
|
|
230
|
+
(None, False),
|
|
231
|
+
],
|
|
232
|
+
)
|
|
233
|
+
def test_predicate_targets_jobs_with_branches(descriptor: Any, expected: bool) -> None:
|
|
234
|
+
"""The tree renders only where it has something to show."""
|
|
235
|
+
app = _FakeApp()
|
|
236
|
+
FlowVizPlugin()(app)
|
|
237
|
+
predicate = app.registered[0][2]
|
|
238
|
+
|
|
239
|
+
assert predicate(descriptor) is expected
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_disabled_by_config_skips_registration() -> None:
|
|
243
|
+
app = _FakeApp(_FakeSettings({"flow-viz.enabled": False}))
|
|
244
|
+
FlowVizPlugin()(app)
|
|
245
|
+
|
|
246
|
+
assert app.registered == []
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_disabled_by_string_config_skips_registration() -> None:
|
|
250
|
+
app = _FakeApp(_FakeSettings({"flow-viz.enabled": "false"}))
|
|
251
|
+
FlowVizPlugin()(app)
|
|
252
|
+
|
|
253
|
+
assert app.registered == []
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_enabled_by_default_without_settings() -> None:
|
|
257
|
+
app = _FakeApp(_FakeSettings({}))
|
|
258
|
+
FlowVizPlugin()(app)
|
|
259
|
+
|
|
260
|
+
assert len(app.registered) == 1
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_plugin_is_not_a_prompt_collector() -> None:
|
|
264
|
+
"""It draws; it must never win prompt resolution."""
|
|
265
|
+
assert not hasattr(FlowVizPlugin(), "collect")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Unit tests for functualize-flow-viz plugin.
|
|
2
|
+
|
|
3
|
+
Tests the flow visualization plugin's graph rendering and export.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestImports:
|
|
10
|
+
"""Verify the plugin is importable."""
|
|
11
|
+
|
|
12
|
+
def test_import_package(self):
|
|
13
|
+
import functualize_flow_viz
|
|
14
|
+
|
|
15
|
+
assert dir(functualize_flow_viz)
|