Skip to content

Commit 42f2eea

Browse files
jeffbrennanclaude
andauthored
Add analyze CLI command and harden capture.py (PR 4) (#11)
- Add `sparkparse analyze` command: calls get_parsed_metrics then to_plan_summary; supports --format json/text and --out-file - Add --version eager callback; remove dead `welcome` command - Add help strings to all typer.Option/Argument calls in get and viz - Replace print() with logging in capture.py - Narrow action type to Literal["viz","get","analyze"] in capture.py - Add "analyze" action in SparkparseCapture.__exit__; stores result in self._analysis - Rename SparkSession app name from "temp" to "sparkparse_capture" https://claude.ai/code/session_01U1F1a7vVfbxnBeU6oNwnxz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d39986 commit 42f2eea

2 files changed

Lines changed: 156 additions & 29 deletions

File tree

sparkparse/app.py

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,83 @@
1+
import json
2+
import sys
3+
from enum import StrEnum
4+
from pathlib import Path
5+
from typing import Annotated
6+
17
import typer
28

9+
from sparkparse.analyze import to_plan_summary
310
from sparkparse.dashboard import init_dashboard, run_app
411
from sparkparse.models import OutputFormat, ParsedLogDataFrames
512
from sparkparse.parse import get_parsed_metrics
613

14+
__version__ = "0.1.0"
15+
716
app = typer.Typer(pretty_exceptions_enable=False)
817

918

19+
class AnalysisFormat(StrEnum):
20+
json = "json"
21+
text = "text"
22+
23+
24+
def _version_callback(value: bool) -> None:
25+
if value:
26+
typer.echo(f"sparkparse {__version__}")
27+
raise typer.Exit()
28+
29+
30+
@app.callback()
31+
def main(
32+
version: Annotated[
33+
bool | None,
34+
typer.Option(
35+
"--version",
36+
callback=_version_callback,
37+
is_eager=True,
38+
help="Show version and exit.",
39+
),
40+
] = None,
41+
) -> None:
42+
pass
43+
44+
1045
@app.command("viz")
1146
def viz_parsed_metrics(
12-
log_dir: str = "data/logs/raw",
47+
log_dir: Annotated[
48+
str, typer.Argument(help="Directory containing raw Spark event logs.")
49+
] = "data/logs/raw",
1350
force_port: bool = typer.Option(
14-
default=False, help="force kill processes using port 8050"
51+
default=False, help="Force kill any process using port 8050 before starting."
1552
),
1653
) -> None:
54+
"""Launch the interactive Dash dashboard for a directory of Spark event logs."""
1755
app = init_dashboard(log_dir)
1856
run_app(app=app, force_port=force_port)
1957

2058

2159
@app.command("get")
2260
def get(
23-
log_dir: str = "data/logs/raw",
24-
log_file: str | None = None,
25-
out_dir: str | None = "data/logs/parsed",
26-
out_name: str | None = None,
27-
out_format: OutputFormat | None = OutputFormat.csv,
28-
verbose: bool = False,
61+
log_dir: Annotated[
62+
str, typer.Argument(help="Directory containing raw Spark event logs.")
63+
] = "data/logs/raw",
64+
log_file: Annotated[
65+
str | None,
66+
typer.Option(help="Parse a single log file instead of the whole directory."),
67+
] = None,
68+
out_dir: Annotated[
69+
str | None, typer.Option(help="Directory to write parsed output files.")
70+
] = "data/logs/parsed",
71+
out_name: Annotated[
72+
str | None,
73+
typer.Option(help="Base name for output files (defaults to log file stem)."),
74+
] = None,
75+
out_format: Annotated[
76+
OutputFormat | None, typer.Option(help="Output file format.")
77+
] = OutputFormat.csv,
78+
verbose: Annotated[bool, typer.Option(help="Print extra parsing details.")] = False,
2979
) -> ParsedLogDataFrames:
80+
"""Parse Spark event logs and write structured DataFrames to disk."""
3081
return get_parsed_metrics(
3182
log_dir=log_dir,
3283
log_file=log_file,
@@ -37,10 +88,72 @@ def get(
3788
)
3889

3990

40-
@app.command()
41-
def welcome():
42-
typer.echo("Welcome to sparkparse CLI")
43-
typer.echo("Use --help to see available commands")
91+
@app.command("analyze")
92+
def analyze(
93+
log_dir: Annotated[
94+
str, typer.Argument(help="Directory containing raw Spark event logs.")
95+
] = "data/logs/raw",
96+
log_file: Annotated[
97+
str | None,
98+
typer.Option(help="Analyze a single log file instead of the whole directory."),
99+
] = None,
100+
out_file: Annotated[
101+
Path | None,
102+
typer.Option(help="Write analysis JSON to this file instead of stdout."),
103+
] = None,
104+
format: Annotated[
105+
AnalysisFormat,
106+
typer.Option(help="Output format: 'json' (default) or human-readable 'text'."),
107+
] = AnalysisFormat.json,
108+
) -> None:
109+
"""Analyze Spark event logs and emit a token-efficient summary suitable for LLM piping."""
110+
dfs = get_parsed_metrics(
111+
log_dir=log_dir,
112+
log_file=log_file,
113+
out_dir=None,
114+
out_name=None,
115+
out_format=None,
116+
verbose=False,
117+
)
118+
119+
log_name = Path(log_file).stem if log_file else Path(log_dir).name
120+
summary = to_plan_summary(dfs, log_name)
121+
122+
if format == AnalysisFormat.json:
123+
output = json.dumps(summary, indent=2, default=str)
124+
else:
125+
lines: list[str] = [f"Log: {summary['log_name']}"]
126+
totals = summary.get("totals", {})
127+
lines.append(f"Queries: {len(summary.get('queries', []))}")
128+
lines.append(f"Bytes read: {totals.get('bytes_read', 0):,}")
129+
lines.append(f"Bytes written: {totals.get('bytes_written', 0):,}")
130+
lines.append(f"Shuffle bytes read: {totals.get('shuffle_bytes_read', 0):,}")
131+
lines.append(
132+
f"Shuffle bytes written: {totals.get('shuffle_bytes_written', 0):,}"
133+
)
134+
lines.append(f"Memory spilled: {totals.get('memory_bytes_spilled', 0):,}")
135+
lines.append(f"Disk spilled: {totals.get('disk_bytes_spilled', 0):,}")
136+
for q in summary.get("queries", []):
137+
lines.append(
138+
f"\nQuery {q['query_id']} ({q['query_function']})"
139+
f" duration={q['duration_seconds']:.1f}s"
140+
f" nodes={len(q['nodes'])}"
141+
)
142+
for n in q["nodes"]:
143+
dur = (
144+
f"{n['duration_minutes']:.3f}min"
145+
if n.get("duration_minutes") is not None
146+
else "?"
147+
)
148+
lines.append(f" [{n['node_id']}] {n['node_type']} {dur}")
149+
output = "\n".join(lines)
150+
151+
if out_file is not None:
152+
out_file.parent.mkdir(parents=True, exist_ok=True)
153+
out_file.write_text(output)
154+
typer.echo(f"Analysis written to {out_file}", err=True)
155+
else:
156+
sys.stdout.write(output + "\n")
44157

45158

46159
if __name__ == "__main__":

sparkparse/capture.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import functools
2+
import logging
23
import os
34
import shutil
45
import subprocess
@@ -8,13 +9,18 @@
89
import webbrowser
910
from collections.abc import Callable
1011
from pathlib import Path
11-
from typing import Any, TypeVar, overload
12+
from typing import Any, Literal, TypeVar, overload
1213

1314
from pyspark.sql import SparkSession
1415

16+
from sparkparse.analyze import to_plan_summary
1517
from sparkparse.app import get
1618
from sparkparse.models import ParsedLogDataFrames
1719

20+
_log = logging.getLogger(__name__)
21+
22+
CaptureAction = Literal["viz", "get", "analyze"]
23+
1824
R = TypeVar("R")
1925
F = TypeVar("F", bound=Callable[..., Any])
2026

@@ -25,7 +31,7 @@ class SparkparseCapture:
2531

2632
def __init__(
2733
self,
28-
action: str,
34+
action: CaptureAction,
2935
spark: SparkSession,
3036
temp_dir: str | None = None,
3137
headless: bool = False,
@@ -38,6 +44,7 @@ def __init__(
3844
self._should_cleanup = temp_dir is None
3945
self._headless = headless
4046
self._parsed_logs = None
47+
self._analysis: dict[str, Any] | None = None
4148

4249
def __call__(
4350
self, func: Callable[..., R]
@@ -84,8 +91,8 @@ def __enter__(self):
8491

8592
self.spark = builder.getOrCreate()
8693

87-
print(f"Log enabled: {self.spark.conf.get('spark.eventLog.enabled')}")
88-
print(f"Log dir config: {self.spark.conf.get('spark.eventLog.dir')}")
94+
_log.info("Log enabled: %s", self.spark.conf.get("spark.eventLog.enabled"))
95+
_log.info("Log dir config: %s", self.spark.conf.get("spark.eventLog.dir"))
8996
return self
9097

9198
def _run_dashboard_in_background(self):
@@ -140,25 +147,32 @@ def __exit__(self, exc_type, *args):
140147
raise ValueError("log directory is not set")
141148
result = get(log_dir=self._log_dir)
142149
self._parsed_logs = result
143-
144-
if (
145-
self._should_cleanup
146-
and self._log_dir is not None
147-
and os.path.exists(self._log_dir)
148-
):
149-
shutil.rmtree(self._log_dir)
150+
elif self.action == "analyze":
151+
if self._log_dir is None:
152+
raise ValueError("log directory is not set")
153+
result = get(log_dir=self._log_dir)
154+
self._parsed_logs = result
155+
log_name = Path(self._log_dir).name
156+
self._analysis = to_plan_summary(result, log_name)
150157
else:
151158
raise ValueError(f"Invalid action: {self.action}")
152159

160+
if (
161+
self._should_cleanup
162+
and self._log_dir is not None
163+
and os.path.exists(self._log_dir)
164+
):
165+
shutil.rmtree(self._log_dir)
166+
153167

154168
def capture_context(
155-
action: str = "viz",
169+
action: CaptureAction = "viz",
156170
temp_dir: str | None = None,
157171
spark: SparkSession | None = None,
158172
headless: bool = False,
159173
) -> SparkparseCapture:
160174
if spark is None:
161-
_spark = SparkSession.builder.appName("temp").getOrCreate() # type: ignore
175+
_spark = SparkSession.builder.appName("sparkparse_capture").getOrCreate() # type: ignore
162176
else:
163177
_spark = spark
164178

@@ -169,7 +183,7 @@ def capture_context(
169183
def capture(
170184
func: Callable[..., R],
171185
*,
172-
action: str = ...,
186+
action: CaptureAction = ...,
173187
temp_dir: str | None = ...,
174188
spark: SparkSession | None = ...,
175189
headless: bool = ...,
@@ -180,7 +194,7 @@ def capture(
180194
def capture(
181195
func: None = None,
182196
*,
183-
action: str = ...,
197+
action: CaptureAction = ...,
184198
temp_dir: str | None = ...,
185199
spark: SparkSession | None = ...,
186200
headless: bool = ...,
@@ -190,7 +204,7 @@ def capture(
190204
def capture(
191205
func=None,
192206
*,
193-
action: str = "viz",
207+
action: CaptureAction = "viz",
194208
temp_dir: str | None = None,
195209
spark: SparkSession | None = None,
196210
headless: bool = False,
@@ -199,7 +213,7 @@ def decorator(
199213
func: Callable[..., R],
200214
) -> Callable[..., tuple[Any, SparkparseCapture]]:
201215
if spark is None:
202-
_spark = SparkSession.builder.appName("temp").getOrCreate() # type: ignore
216+
_spark = SparkSession.builder.appName("sparkparse_capture").getOrCreate() # type: ignore
203217
else:
204218
_spark = spark
205219

0 commit comments

Comments
 (0)