Skip to content

Commit b3a79fd

Browse files
authored
fix tests, update CI requirement (#14)
* fix failing tests * add ci helper * ruff * fix false positives * fix import error * fix CI trigger: add pull_request event so checks report to PR workflow_dispatch runs don't appear in the PR Checks tab — only pull_request-triggered runs are associated with PRs. Add pull_request: [opened, reopened] so the check runs once when a PR is opened (not on every push). Keep workflow_dispatch for manual re-runs. Also fix branch protection context name: the check run is named 'test' (job name), not 'test / test'.
1 parent 73693b8 commit b3a79fd

19 files changed

Lines changed: 408 additions & 109 deletions

File tree

.github/workflows/test.yml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
name: test
22

33
on:
4-
push:
5-
branches: ["main"]
64
pull_request:
7-
branches: ["main"]
5+
types: [opened, reopened]
6+
workflow_dispatch:
7+
8+
# Runs automatically when a PR is opened or reopened, but NOT on every push.
9+
# To re-run after pushing new commits, use "Re-run all checks" in the PR's
10+
# Checks tab, or trigger manually from Actions → test → Run workflow.
11+
#
12+
# Branch protection on `main` requires the `test` check to pass before merge:
13+
# gh api -X PUT repos/jeffbrennan/sparkparse/branches/main/protection --input - <<'EOF'
14+
# {
15+
# "required_status_checks": {
16+
# "strict": false,
17+
# "contexts": ["test"]
18+
# },
19+
# "enforce_admins": false,
20+
# "restrictions": null,
21+
# "required_pull_request_reviews": null
22+
# }
23+
# EOF
824

925
jobs:
1026
test:
@@ -30,7 +46,7 @@ jobs:
3046
run: uv run ruff format --check sparkparse/ tests/
3147

3248
- name: Type check
33-
run: uv run pyrefly check sparkparse/
49+
run: uv run pyrefly check sparkparse/ tests/
3450

3551
- name: Unit tests
3652
run: uv run pytest tests/ --ignore=tests/test.py --ignore=tests/test_capture.py -v

CLAUDE.md

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,26 @@ Spark event log (JSONL)
3131

3232
## Key files
3333

34-
| File | Purpose |
35-
|---|---|
36-
| `sparkparse/models.py` | All Pydantic models. `NodeType` enum (30+ values), `ParsedLog`, `ParsedLogDataFrames`, `NODE_TYPE_DETAIL_MAP` (node type → detail model class) |
37-
| `sparkparse/parse.py` | `get_parsed_metrics()` is the main entry point. `parse_spark_ui_tree()` converts indented ASCII plans to node graphs. `parse_log()` orchestrates everything. |
38-
| `sparkparse/clean.py` | `log_to_dag_df()` and `log_to_combined_df()` produce the two output DataFrames. `get_readable_size()` and `get_readable_timing()` are Polars expression helpers. |
39-
| `sparkparse/app.py` | Typer CLI. `get` → parses and writes output files. `viz` → launches dashboard. |
40-
| `sparkparse/capture.py` | `SparkparseCapture` context manager/decorator. Stops the active session, restarts it with event logging enabled, then processes logs on `__exit__`. |
34+
| File | Purpose |
35+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36+
| `sparkparse/models.py` | All Pydantic models. `NodeType` enum (30+ values), `ParsedLog`, `ParsedLogDataFrames`, `NODE_TYPE_DETAIL_MAP` (node type → detail model class) |
37+
| `sparkparse/parse.py` | `get_parsed_metrics()` is the main entry point. `parse_spark_ui_tree()` converts indented ASCII plans to node graphs. `parse_log()` orchestrates everything. |
38+
| `sparkparse/clean.py` | `log_to_dag_df()` and `log_to_combined_df()` produce the two output DataFrames. `get_readable_size()` and `get_readable_timing()` are Polars expression helpers. |
39+
| `sparkparse/app.py` | Typer CLI. `get` → parses and writes output files. `viz` → launches dashboard. |
40+
| `sparkparse/capture.py` | `SparkparseCapture` context manager/decorator. Stops the active session, restarts it with event logging enabled, then processes logs on `__exit__`. |
4141

4242
## Data model
4343

4444
### `dag` DataFrame columns (per physical plan node per query)
45+
4546
- `query_id`, `query_function`, `query_header`, `query_start/end_timestamp`, `query_duration_seconds`
4647
- `node_id`, `node_type`, `node_name`, `child_nodes` (comma-separated string), `whole_stage_codegen_id`
4748
- `details` — JSON string; deserialize with the appropriate model from `NODE_TYPE_DETAIL_MAP`
4849
- `accumulator_totals` — list of structs: `{metric_name, metric_type, value, readable_str, unit}`
4950
- `node_duration_minutes`
5051

5152
### `combined` DataFrame columns (per task)
53+
5254
- `log_name`, `parsed_log_name`, `query_id`, `query_function`
5355
- `query/job/stage/task` start/end timestamps and duration_seconds
5456
- `task_id`, `executor_id`, `nodes` (list of physical plan nodes for this task)
@@ -84,6 +86,7 @@ uv run pytest tests/test.py tests/test_capture.py -v
8486
```
8587

8688
Test fixtures live in `tests/data/`:
89+
8790
- `tests/data/full_logs/` — three real Spark event logs for end-to-end testing
8891
- `tests/data/test_*_parsing/` — extracted JSON/TXT fragments for unit testing specific parsing steps
8992

@@ -93,57 +96,63 @@ Test fixtures live in `tests/data/`:
9396
uv sync --dev # install including dev deps
9497
uv run ruff check sparkparse/ tests/ # lint
9598
uv run ruff format sparkparse/ tests/ # format
96-
uv run pyrefly check sparkparse/ # type check
99+
uv run pyrefly check sparkparse/ tests/ # type check
97100
```
98101

99102
## Planned improvements (see IMPLEMENTATION.md for full detail)
100103

101104
### PR 1 — Tooling modernization
105+
102106
- Move dev deps (`pytest`, `ruff`, `ipykernel`) to `[tool.uv.dev-dependencies]`
103107
- Add `pyrefly` for type checking
104108
- Add `[tool.ruff]` config to `pyproject.toml`
105109
- Add `.github/workflows/test.yml` CI workflow
106110

107111
### PR 2 — Unit test expansion
112+
108113
- Add `tests/test_clean.py` covering `get_readable_size`, `get_readable_timing`, `clean_jobs`,
109114
`clean_stages`, `clean_tasks`, `get_job_idle_time`
110115

111116
### PR 3 — LLM-friendly analysis output
117+
112118
- Add `sparkparse/analyze.py` with two distinct concerns:
113-
- `to_plan_summary(dfs, log_name) -> dict` — token-efficient structured plan data for LLM
114-
piping; presents raw facts (nodes, durations, bytes, join types, paths) without pre-assigned
115-
severity or pre-classified findings; the LLM draws its own conclusions. The value over
116-
`df.explain()` is runtime metrics (per-node durations, scan bytes/records) correlated from
117-
accumulator updates — plan structure alone is not worth re-encoding.
118-
- `find_*()` helpers — programmatic analysis functions for interactive/notebook use
119-
(`find_cartesian_joins`, `find_largest_scans`, `find_repeated_scans`, `find_spill`, etc.)
119+
- `to_plan_summary(dfs, log_name) -> dict` — token-efficient structured plan data for LLM
120+
piping; presents raw facts (nodes, durations, bytes, join types, paths) without pre-assigned
121+
severity or pre-classified findings; the LLM draws its own conclusions. The value over
122+
`df.explain()` is runtime metrics (per-node durations, scan bytes/records) correlated from
123+
accumulator updates — plan structure alone is not worth re-encoding.
124+
- `find_*()` helpers — programmatic analysis functions for interactive/notebook use
125+
(`find_cartesian_joins`, `find_largest_scans`, `find_repeated_scans`, `find_spill`, etc.)
120126
- Add `tests/test_analyze.py`
121127

122128
### PR 4 — CLI improvements
129+
123130
- Add `sparkparse analyze` command (prints JSON findings, pipeable to LLM)
124131
- Improve help strings on existing commands
125132
- Add `"analyze"` action to `capture.py`
126133
- Replace `print()` with `logging` in `capture.py`
127134

128135
### PR 7 — Complete Pydantic coverage
136+
129137
- Add `NodeType.Unknown` sentinel and `RawDetail` fallback model so unrecognized node types
130138
degrade gracefully instead of raising (currently `parse_spark_ui_tree` and `get_plan_details`
131139
both hard-fail on unknown types — see `parse.py:278` and `parse.py:145`)
132140
- Wrap `NodeType(...)` and `QueryFunction(...)` calls in try/except with warning logs
133141
- Add detail models for the standard Spark 3.5 operator set not yet covered:
134-
- Aggregate variants: `ObjectHashAggregate`, `SortAggregate`, `Expand`
135-
- Python/Pandas UDF nodes: `ArrowEvalPython`, `BatchEvalPython`, `MapInPandas`,
136-
`MapInArrow`, `FlatMapGroupsInPandas`, `FlatMapCoGroupsInPandas`
137-
- DataSource V2: `BatchScanExec`, `WriteToDatasourceV2`, `AppendData`,
138-
`OverwriteByExpression`, `OverwritePartitionsDynamic`
139-
- Subquery: `SubqueryExec`, `ReusedSubqueryExec`, `SubqueryBroadcast`
140-
- Misc: `RepartitionByExpression`, `Sample`, `Range`, `CartesianProduct`
142+
- Aggregate variants: `ObjectHashAggregate`, `SortAggregate`, `Expand`
143+
- Python/Pandas UDF nodes: `ArrowEvalPython`, `BatchEvalPython`, `MapInPandas`,
144+
`MapInArrow`, `FlatMapGroupsInPandas`, `FlatMapCoGroupsInPandas`
145+
- DataSource V2: `BatchScanExec`, `WriteToDatasourceV2`, `AppendData`,
146+
`OverwriteByExpression`, `OverwritePartitionsDynamic`
147+
- Subquery: `SubqueryExec`, `ReusedSubqueryExec`, `SubqueryBroadcast`
148+
- Misc: `RepartitionByExpression`, `Sample`, `Range`, `CartesianProduct`
141149
- Extend `ExchangeType` with `REPARTITION_BY_COL`, `REPARTITION_BY_NUM`, `REPARTITION`
142150
- Extend `QueryFunction` with `show`, `collect`, `first`, `head`, `take`, etc.
143151
- Databricks/Photon-specific nodes handled by `Unknown` fallback until real fixtures available
144152
- Add test fixtures and cases for each new node type in `tests/test_detail_parsing.py`
145153

146154
### PR 5 — Cloud storage support
155+
147156
- Add `sparkparse/storage.py` with path-agnostic I/O (`open_file`, `write_text`, `list_files`,
148157
`copy_file`, `remove_dir`) backed by `fsspec` for cloud URIs and stdlib for local paths
149158
- Update `parse.py`, `common.py`, `capture.py` to route all file I/O through `storage.py`
@@ -154,6 +163,7 @@ uv run pyrefly check sparkparse/ # type check
154163
cluster termination
155164

156165
### PR 6 — Performance history and alerts
166+
157167
- Add `sparkparse/history.py` — append-only run history: `record_from_dfs()` derives a compact
158168
`RunRecord` (duration, bytes, spill, shuffle, cartesian join count, etc.) from
159169
`ParsedLogDataFrames`; `append()` writes to Delta (primary) or JSONL (fallback); `read()`

Justfile

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
ci:
22
uv run ruff check sparkparse/ tests/
33
uv run ruff format --check sparkparse/ tests/
4-
uv run pyrefly check sparkparse/
4+
uv run pyrefly check sparkparse/ tests/
55
uv run pytest tests/ --ignore=tests/test.py --ignore=tests/test_capture.py -v
66

7+
# Trigger the test workflow on the current branch and watch it run.
8+
# The workflow also auto-runs when a PR is opened/reopened; use this for
9+
# manual re-runs after pushing new commits. Requires gh auth login.
10+
run-ci:
11+
@branch=$(git branch --show-current); \
12+
echo "Triggering test workflow on branch: $branch"; \
13+
gh workflow run test.yml --ref $branch || { echo "Failed to trigger workflow. Is the branch pushed to GitHub?"; exit 1; }; \
14+
sleep 3; \
15+
run_id=$(gh run list --workflow=test.yml --branch=$branch --limit=1 --json databaseId --jq '.[0].databaseId'); \
16+
if [ -z "$run_id" ]; then echo "Could not find the triggered run. Check: gh run list --workflow=test.yml"; exit 1; fi; \
17+
echo "Watching run $run_id..."; \
18+
gh run watch $run_id --exit-status && echo "CI passed" || { echo "CI failed"; gh run view $run_id --log-failed; exit 1; }
19+
720
jsonformat:
821
mkdir -p data/logs/sandbox
922
for file in data/logs/raw/*; do \
@@ -15,4 +28,4 @@ live:
1528
open http://localhost:4040/
1629

1730
history:
18-
($SPARK_HOME/sbin/start-history-server.sh || open http://localhost:18080/)
31+
($SPARK_HOME/sbin/start-history-server.sh || open http://localhost:18080/)

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dev-dependencies = [
4646
[tool.ruff]
4747
target-version = "py311"
4848
line-length = 88
49+
exclude = ["typings/**"]
4950

5051
[tool.ruff.lint]
5152
select = ["E", "F", "I", "UP"]
@@ -59,3 +60,6 @@ project-includes = [
5960
"**/*.py*",
6061
"**/*.ipynb",
6162
]
63+
project-excludes = ["typings/**"]
64+
search-path = ["typings"]
65+
ignore-missing-imports = ["deltalake"]

sparkparse/alerts.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ def load_alert_config(path: str) -> list[AlertConfig]:
7777
return [AlertConfig(**a) for a in raw_alerts]
7878

7979

80-
def _compute_baseline(history: pl.DataFrame, alert: AlertConfig, current_run_id: str) -> float:
80+
def _compute_baseline(
81+
history: pl.DataFrame, alert: AlertConfig, current_run_id: str
82+
) -> float:
8183
"""Mean of the alert metric over the last ``window`` runs, excluding the
8284
current run. Returns 0.0 when no history is available.
8385
"""
@@ -86,7 +88,8 @@ def _compute_baseline(history: pl.DataFrame, alert: AlertConfig, current_run_id:
8688

8789
hist = (
8890
history.filter(
89-
(pl.col("log_name") == alert.log_name) & (pl.col("run_id") != current_run_id)
91+
(pl.col("log_name") == alert.log_name)
92+
& (pl.col("run_id") != current_run_id)
9093
)
9194
.sort("run_at", descending=True)
9295
.head(alert.window)
@@ -96,10 +99,14 @@ def _compute_baseline(history: pl.DataFrame, alert: AlertConfig, current_run_id:
9699
return 0.0
97100

98101
mean_val = hist[alert.metric].mean()
99-
return float(mean_val) if mean_val is not None else 0.0
102+
if mean_val is not None and isinstance(mean_val, int | float):
103+
return float(mean_val)
104+
return 0.0
100105

101106

102-
def _dispatch(alert: AlertConfig, alert_dict: dict, alert_output_path: str | None) -> None:
107+
def _dispatch(
108+
alert: AlertConfig, alert_dict: dict, alert_output_path: str | None
109+
) -> None:
103110
if alert.on_trigger == "raise":
104111
raise SparkparseAlertError(
105112
alert_name=alert.name,

sparkparse/analyze.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,16 @@ def find_largest_scans(dfs: ParsedLogDataFrames, n: int = 10) -> pl.DataFrame:
199199
)
200200

201201
return (
202-
scan_nodes.select("query_id", "node_id", "node_name", "details", "node_duration_minutes")
202+
scan_nodes.select(
203+
"query_id", "node_id", "node_name", "details", "node_duration_minutes"
204+
)
203205
.join(node_bytes, on=["query_id", "node_name"], how="left")
204206
.with_columns(
205207
pl.col("details")
206208
.map_elements(
207-
lambda s: json.loads(s)["detail"]["location"]["location"] if s is not None else [],
209+
lambda s: json.loads(s)["detail"]["location"]["location"]
210+
if s is not None
211+
else [],
208212
return_dtype=pl.List(pl.String),
209213
)
210214
.alias("paths")

sparkparse/app.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ def analyze(
111111
] = None,
112112
out_file: Annotated[
113113
str | None,
114-
typer.Option(help="Write analysis output to this file (local path or cloud URI)."),
114+
typer.Option(
115+
help="Write analysis output to this file (local path or cloud URI)."
116+
),
115117
] = None,
116118
format: Annotated[
117119
AnalysisFormat,
@@ -140,7 +142,9 @@ def analyze(
140142
lines.append(f"Bytes read: {totals.get('bytes_read', 0):,}")
141143
lines.append(f"Bytes written: {totals.get('bytes_written', 0):,}")
142144
lines.append(f"Shuffle bytes read: {totals.get('shuffle_bytes_read', 0):,}")
143-
lines.append(f"Shuffle bytes written: {totals.get('shuffle_bytes_written', 0):,}")
145+
lines.append(
146+
f"Shuffle bytes written: {totals.get('shuffle_bytes_written', 0):,}"
147+
)
144148
lines.append(f"Memory spilled: {totals.get('memory_bytes_spilled', 0):,}")
145149
lines.append(f"Disk spilled: {totals.get('disk_bytes_spilled', 0):,}")
146150
for q in summary.get("queries", []):
@@ -202,11 +206,17 @@ def check_alerts_cmd(
202206
history_path: Annotated[
203207
str, typer.Argument(help="Path to history store (Delta dir or JSONL file).")
204208
],
205-
log_name: Annotated[str, typer.Argument(help="Stable job identifier to check alerts for.")],
206-
alert_config: Annotated[str, typer.Argument(help="Path to alert configuration TOML file.")],
209+
log_name: Annotated[
210+
str, typer.Argument(help="Stable job identifier to check alerts for.")
211+
],
212+
alert_config: Annotated[
213+
str, typer.Argument(help="Path to alert configuration TOML file.")
214+
],
207215
alert_output_path: Annotated[
208216
str | None,
209-
typer.Option(help="File to write triggered alerts (for on_trigger='file' rules)."),
217+
typer.Option(
218+
help="File to write triggered alerts (for on_trigger='file' rules)."
219+
),
210220
] = None,
211221
) -> None:
212222
"""Run alert checks against the latest run in history."""

sparkparse/capture.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ def __init__(
6363
self._last_record: RunRecord | None = None
6464
self._triggered_alerts: list[dict] = []
6565

66-
def __call__(self, func: Callable[..., R]) -> Callable[..., tuple[R, "SparkparseCapture"]]:
66+
def __call__(
67+
self, func: Callable[..., R]
68+
) -> Callable[..., tuple[R, "SparkparseCapture"]]:
6769
@functools.wraps(func)
6870
def get_wrapper(*args: Any, **kwargs: Any) -> tuple[R, "SparkparseCapture"]:
6971
with self:
@@ -88,13 +90,14 @@ def __enter__(self):
8890
ensure_dir(self.temp_dir)
8991
self._log_dir = self.temp_dir
9092

93+
orig_conf: dict[str, str] = {}
9194
if self.spark or SparkSession.getActiveSession():
9295
self._orig_spark = self.spark
9396
self._orig_log_dir = self._orig_spark.conf.get("spark.eventLog.dir")
9497
orig_conf = dict(self._orig_spark.sparkContext._conf.getAll())
9598
self.spark.stop()
9699

97-
builder = SparkSession.builder.appName("sparkparse") # type: ignore
100+
builder = SparkSession.builder.appName("sparkparse")
98101
if hasattr(self, "_orig_spark"):
99102
for key, value in orig_conf.items():
100103
if key not in ["spark.eventLog.enabled", "spark.eventLog.dir"]:
@@ -139,6 +142,10 @@ def _record_history_and_alerts(self) -> None:
139142
if self._parsed_logs is None or self._history_path is None:
140143
return
141144

145+
if self._log_dir is None:
146+
_log.warning("log directory is not set, skipping history and alerts")
147+
return
148+
142149
effective_log_name = self._log_name or get_path_name(self._log_dir)
143150
record = history.record_from_dfs(self._parsed_logs, effective_log_name)
144151
self._last_record = record
@@ -197,7 +204,11 @@ def __exit__(self, exc_type, *args):
197204
if self._history_path is not None:
198205
self._record_history_and_alerts()
199206

200-
if self._should_cleanup and self._log_dir is not None and path_exists(self._log_dir):
207+
if (
208+
self._should_cleanup
209+
and self._log_dir is not None
210+
and path_exists(self._log_dir)
211+
):
201212
remove_dir(self._log_dir)
202213

203214

@@ -211,7 +222,7 @@ def capture_context(
211222
alert_config: str | None = None,
212223
) -> SparkparseCapture:
213224
if spark is None:
214-
_spark = SparkSession.builder.appName("sparkparse_capture").getOrCreate() # type: ignore
225+
_spark = SparkSession.builder.appName("sparkparse_capture").getOrCreate()
215226
else:
216227
_spark = spark
217228

@@ -269,7 +280,7 @@ def decorator(
269280
func: Callable[..., R],
270281
) -> Callable[..., tuple[Any, SparkparseCapture]]:
271282
if spark is None:
272-
_spark = SparkSession.builder.appName("sparkparse_capture").getOrCreate() # type: ignore
283+
_spark = SparkSession.builder.appName("sparkparse_capture").getOrCreate()
273284
else:
274285
_spark = spark
275286

0 commit comments

Comments
 (0)