plotaviz 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,495 @@
1
+ """Chart selection — a rules + scoring hybrid.
2
+
3
+ Two layers, deliberately separated:
4
+
5
+ **Rules** (``rules.yaml``) map a data *shape* to candidate chart types. They are broad and
6
+ generous: a datetime plus a measure proposes line, area, and scatter, and does not try to decide
7
+ between them.
8
+
9
+ **Scoring** ranks those candidates against the actual data — cardinality, missingness,
10
+ distribution skew, correlation strength, and readability limits. This is where "you have 400
11
+ categories, a bar chart will be unreadable" gets expressed, as a penalty rather than a
12
+ prohibition.
13
+
14
+ Every returned :class:`~plotaviz.core.spec.ChartSpec` carries a ``why`` string assembled from the
15
+ rule's rationale plus whatever the scoring layer had to say. The user always sees the reasoning,
16
+ and the ranked alternatives are all real specs they can switch to.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import itertools
22
+ from dataclasses import dataclass
23
+ from functools import partial
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import yaml
28
+
29
+ from .errors import SelectionError
30
+ from .profiler import BOOLEAN, CATEGORICAL, DATETIME, NUMERIC, TEXT, DatasetProfile, as_float
31
+ from .spec import MATRIX_CHARTS, ChartSpec
32
+
33
+ #: Location of the shipped rules file.
34
+ RULES_PATH = Path(__file__).with_name("rules.yaml")
35
+
36
+ #: How many columns per role a rule slot is allowed to explore. Keeps the candidate space small.
37
+ _MAX_COLUMNS_PER_SLOT = 3
38
+
39
+ #: Cap on returned recommendations.
40
+ DEFAULT_TOP_K = 8
41
+
42
+ _ROLE_TOKENS = {
43
+ "numeric": NUMERIC,
44
+ "categorical": CATEGORICAL,
45
+ "datetime": DATETIME,
46
+ "boolean": BOOLEAN,
47
+ "text": TEXT,
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class RulesConfig:
53
+ """The parsed contents of ``rules.yaml``.
54
+
55
+ Attributes:
56
+ rules: Rule definitions in file order.
57
+ scoring: Scoring caps and weights.
58
+ version: Config schema version.
59
+ """
60
+
61
+ rules: list[dict[str, Any]]
62
+ scoring: dict[str, Any]
63
+ version: int = 1
64
+
65
+ @property
66
+ def weights(self) -> dict[str, float]:
67
+ """Scoring weights, with sane defaults if the file omits any."""
68
+ defaults = {
69
+ "cardinality_penalty": 0.35,
70
+ "missing_penalty": 0.25,
71
+ "correlation_bonus": 0.30,
72
+ "skew_bonus": 0.15,
73
+ "timespan_bonus": 0.10,
74
+ "identifier_penalty": 0.60,
75
+ "constant_penalty": 0.50,
76
+ }
77
+ return {**defaults, **(self.scoring.get("weights") or {})}
78
+
79
+ def cap(self, name: str, default: int) -> int:
80
+ """Read a readability cap such as ``max_bar_categories``."""
81
+ try:
82
+ return int(self.scoring.get(name, default))
83
+ except (TypeError, ValueError):
84
+ return default
85
+
86
+
87
+ def load_rules(path: str | Path | None = None) -> RulesConfig:
88
+ """Load the selection rules.
89
+
90
+ Args:
91
+ path: Alternate rules file. Defaults to the one shipped with the package, so users can
92
+ point at their own without editing the install.
93
+
94
+ Raises:
95
+ SelectionError: If the file is missing or is not valid YAML with a ``rules`` list.
96
+ """
97
+ target = Path(path) if path else RULES_PATH
98
+ try:
99
+ with open(target, encoding="utf-8") as handle:
100
+ data = yaml.safe_load(handle) or {}
101
+ except FileNotFoundError as exc:
102
+ raise SelectionError(
103
+ f"Chart rules file not found at {target}.",
104
+ hint="Reinstall PlotaViz, or point at your own rules file.",
105
+ ) from exc
106
+ except yaml.YAMLError as exc:
107
+ raise SelectionError(
108
+ f"Chart rules file {target.name} is not valid YAML.", hint=str(exc)
109
+ ) from exc
110
+
111
+ rules = data.get("rules")
112
+ if not isinstance(rules, list) or not rules:
113
+ raise SelectionError(f"{target.name} contains no rules.")
114
+
115
+ return RulesConfig(
116
+ rules=rules, scoring=data.get("scoring") or {}, version=int(data.get("version", 1))
117
+ )
118
+
119
+
120
+ class ChartSelector:
121
+ """Turns a :class:`~plotaviz.core.profiler.DatasetProfile` into ranked chart specs.
122
+
123
+ Args:
124
+ config: Loaded rules. Defaults to the shipped ``rules.yaml``.
125
+ """
126
+
127
+ #: At most this many variants of one chart type survive into the ranked alternatives. Without
128
+ #: a cap, a wide dataset returns eight line charts and the user has no real choice to make.
129
+ max_per_chart_type: int = 2
130
+
131
+ def __init__(self, config: RulesConfig | None = None) -> None:
132
+ self.config = config or load_rules()
133
+
134
+ # ------------------------------------------------------------------ public API
135
+
136
+ def recommend(
137
+ self,
138
+ profile: DatasetProfile,
139
+ *,
140
+ top_k: int = DEFAULT_TOP_K,
141
+ prefer_columns: list[str] | None = None,
142
+ ) -> list[ChartSpec]:
143
+ """Rank chart specs for a dataset, best first.
144
+
145
+ Args:
146
+ profile: The dataset profile.
147
+ top_k: Maximum number of recommendations to return.
148
+ prefer_columns: Columns the user has signalled interest in (selected in the UI, or
149
+ named in a natural-language query). Candidates using them are boosted.
150
+
151
+ Returns:
152
+ Ranked :class:`ChartSpec` objects, each with a populated ``why`` and ``score``.
153
+
154
+ Raises:
155
+ SelectionError: If no rule matches — a dataset of nothing but identifiers, say.
156
+ """
157
+ columns_by_role = self._rank_columns(profile)
158
+ candidates = self._candidates_for(profile, columns_by_role)
159
+
160
+ if not candidates and any(prof.is_identifier for prof in profile.columns.values()):
161
+ # Every usable column was filtered out as an identifier. Rather than refusing to draw
162
+ # anything — which is what a table of IDs and a dense integer key looks like — offer
163
+ # the identifier columns too, and say so in the justification. A chart the user can
164
+ # correct beats a dead end.
165
+ columns_by_role = self._rank_columns(profile, include_identifiers=True)
166
+ candidates = self._candidates_for(profile, columns_by_role)
167
+ for spec in candidates:
168
+ spec.why += (
169
+ " Every column in this dataset looks like an identifier, so this chart uses "
170
+ "one anyway — use the type override panel if that is wrong."
171
+ )
172
+
173
+ if not candidates:
174
+ raise SelectionError(
175
+ "PlotaViz could not find a chart that suits this dataset.",
176
+ hint=(
177
+ "This usually means every column looks like an identifier or free text. "
178
+ "Use the type override panel to mark a column as numeric, categorical, or a "
179
+ "date."
180
+ ),
181
+ )
182
+
183
+ # Rank on the unclipped score. `ChartSpec.score` is a 0–1 confidence for display, and
184
+ # several strong candidates can all saturate at 1.0 — sorting on that would scramble a
185
+ # ranking the raw numbers get right.
186
+ scored = [self._score(spec, profile, prefer_columns or []) for spec in candidates]
187
+ scored.sort(key=lambda pair: pair[1], reverse=True)
188
+ return self._dedupe([spec for spec, _ in scored])[:top_k]
189
+
190
+ def best(self, profile: DatasetProfile, **kwargs: Any) -> ChartSpec:
191
+ """The single top recommendation. Convenience for CLI ``--auto`` mode."""
192
+ return self.recommend(profile, **kwargs)[0]
193
+
194
+ def is_ambiguous(self, ranked: list[ChartSpec], *, margin: float = 0.06) -> bool:
195
+ """Whether the top two recommendations are close enough to be a coin flip.
196
+
197
+ The UI uses this to decide when consulting an LLM is worth the latency: a clear winner
198
+ needs no second opinion.
199
+ """
200
+ return len(ranked) >= 2 and (ranked[0].score - ranked[1].score) < margin
201
+
202
+ # ------------------------------------------------------------------ internals
203
+
204
+ def _candidates_for(
205
+ self, profile: DatasetProfile, columns: dict[str, list[str]]
206
+ ) -> list[ChartSpec]:
207
+ """Expand every rule that matches the available columns into concrete specs."""
208
+ candidates: list[ChartSpec] = []
209
+ for rule in self.config.rules:
210
+ if not self._rule_matches(rule, columns):
211
+ continue
212
+ for candidate in rule.get("candidates", []):
213
+ candidates.extend(self._expand(rule, candidate, columns, profile))
214
+ return candidates
215
+
216
+ def _rank_columns(
217
+ self, profile: DatasetProfile, *, include_identifiers: bool = False
218
+ ) -> dict[str, list[str]]:
219
+ """Order each role's columns by how good an axis they would make.
220
+
221
+ Identifiers are excluded unless the caller asks for them back. Within a role, prefer
222
+ complete columns, then moderate cardinality (a 4-category column beats a 400-category one
223
+ for an axis).
224
+ """
225
+
226
+ def sort_key(name: str, *, role: str) -> tuple[float, float]:
227
+ prof = profile.columns[name]
228
+ if role in (CATEGORICAL, TEXT):
229
+ # Distance from an ideal ~6 categories, so tiny and huge both rank lower.
230
+ cardinality_cost = abs(prof.n_unique - 6) / 100.0
231
+ else:
232
+ cardinality_cost = 0.0
233
+ return (prof.pct_missing / 100.0, cardinality_cost)
234
+
235
+ ranked: dict[str, list[str]] = {}
236
+ for role in _ROLE_TOKENS.values():
237
+ names = profile.by_role(role, include_identifiers=include_identifiers)
238
+ ranked[role] = sorted(names, key=partial(sort_key, role=role))
239
+ return ranked
240
+
241
+ def _rule_matches(self, rule: dict[str, Any], columns: dict[str, list[str]]) -> bool:
242
+ """Whether the dataset has at least the columns a rule requires."""
243
+ requires = rule.get("requires") or {}
244
+ for token, needed in requires.items():
245
+ role = _ROLE_TOKENS.get(str(token))
246
+ if role is None:
247
+ return False
248
+ if len(columns.get(role, [])) < int(needed):
249
+ return False
250
+ return True
251
+
252
+ def _expand(
253
+ self,
254
+ rule: dict[str, Any],
255
+ candidate: dict[str, Any],
256
+ columns: dict[str, list[str]],
257
+ profile: DatasetProfile,
258
+ ) -> list[ChartSpec]:
259
+ """Turn one rule candidate into concrete specs by resolving column placeholders.
260
+
261
+ A candidate like ``{x: categorical.0, y: numeric.0}`` produces several specs — the top
262
+ few columns of each role — so the user gets real alternatives rather than one guess.
263
+ """
264
+ mapping = candidate.get("map") or {}
265
+ if not mapping and candidate.get("chart") in MATRIX_CHARTS:
266
+ return [
267
+ ChartSpec(
268
+ chart=str(candidate["chart"]),
269
+ why=str(candidate.get("why", "")),
270
+ score=float(candidate.get("base", 0.5)),
271
+ options=dict(candidate.get("options") or {}),
272
+ agg=candidate.get("agg"),
273
+ )
274
+ ]
275
+
276
+ # Resolve every placeholder to the list of columns it could mean.
277
+ options_per_slot: dict[str, list[str | None]] = {}
278
+ for slot, token in mapping.items():
279
+ token = str(token)
280
+ optional = token.startswith("?")
281
+ role_name, _, index_text = token.lstrip("?").partition(".")
282
+ role = _ROLE_TOKENS.get(role_name)
283
+ if role is None:
284
+ return []
285
+ available = columns.get(role, [])
286
+ try:
287
+ start = int(index_text) if index_text else 0
288
+ except ValueError:
289
+ start = 0
290
+
291
+ picks: list[str | None] = list(available[start : start + _MAX_COLUMNS_PER_SLOT])
292
+ if optional:
293
+ picks = [None, *picks[:1]] # no grouping, or the single best grouping column
294
+ if not picks:
295
+ if not optional:
296
+ return []
297
+ picks = [None]
298
+ options_per_slot[slot] = picks
299
+
300
+ specs: list[ChartSpec] = []
301
+ slots = list(options_per_slot)
302
+ for combo in itertools.product(*(options_per_slot[s] for s in slots)):
303
+ assignment = dict(zip(slots, combo, strict=True))
304
+ chosen = [c for c in combo if c is not None]
305
+ if len(set(chosen)) != len(chosen):
306
+ continue # the same column mapped twice is never meaningful
307
+
308
+ spec = ChartSpec(
309
+ chart=str(candidate["chart"]),
310
+ x=assignment.get("x"),
311
+ y=assignment.get("y"),
312
+ color=assignment.get("color"),
313
+ agg=candidate.get("agg"),
314
+ options=dict(candidate.get("options") or {}),
315
+ why=str(candidate.get("why", "")),
316
+ score=float(candidate.get("base", 0.5)),
317
+ source="rules",
318
+ )
319
+ spec.options.setdefault("rule", rule.get("name", ""))
320
+ try:
321
+ spec.validate(list(profile.columns))
322
+ except Exception:
323
+ continue
324
+ specs.append(spec)
325
+
326
+ return specs
327
+
328
+ def _score(
329
+ self, spec: ChartSpec, profile: DatasetProfile, prefer: list[str]
330
+ ) -> tuple[ChartSpec, float]:
331
+ """Adjust a candidate's base score against the real data, recording the reasons.
332
+
333
+ Returns:
334
+ ``(spec, raw_score)``. The spec carries the clipped 0–1 confidence for display; the
335
+ raw score is unbounded and is what the ranking sorts on.
336
+ """
337
+ weights = self.config.weights
338
+ score = spec.score
339
+ notes: list[str] = []
340
+
341
+ # --- readability: too many categories on a categorical axis
342
+ cap_name = {
343
+ "pie": "max_pie_categories",
344
+ "treemap": "max_treemap_categories",
345
+ }.get(spec.chart, "max_bar_categories")
346
+ cap = self.config.cap(cap_name, 30)
347
+
348
+ axis_col = spec.x if spec.chart not in MATRIX_CHARTS else None
349
+ if axis_col and axis_col in profile.columns:
350
+ prof = profile.columns[axis_col]
351
+ if prof.role in (CATEGORICAL, TEXT, BOOLEAN):
352
+ if prof.n_unique > cap:
353
+ overflow = min(1.0, (prof.n_unique - cap) / max(cap, 1))
354
+ score -= weights["cardinality_penalty"] * overflow
355
+ notes.append(
356
+ f"{axis_col!r} has {prof.n_unique:,} categories, well past the {cap} "
357
+ "that stay readable, so this ranks lower."
358
+ )
359
+ elif spec.chart == "treemap" and prof.n_unique > 12:
360
+ score += 0.12
361
+ notes.append(
362
+ f"{prof.n_unique} categories is more than a bar chart handles "
363
+ "comfortably, which suits a treemap."
364
+ )
365
+ if prof.n_unique <= 1:
366
+ score -= weights["constant_penalty"]
367
+ notes.append(f"{axis_col!r} has a single value, so there is nothing to compare.")
368
+
369
+ # --- grouping series count
370
+ if spec.color and spec.color in profile.columns:
371
+ n_series = profile.columns[spec.color].n_unique
372
+ max_series = self.config.cap("max_grouped_series", 8)
373
+ if n_series > max_series:
374
+ score -= weights["cardinality_penalty"] * min(1.0, n_series / (max_series * 4))
375
+ notes.append(
376
+ f"Splitting by {spec.color!r} would draw {n_series:,} series, which is too "
377
+ "many to distinguish."
378
+ )
379
+ elif 2 <= n_series <= max_series:
380
+ score += 0.05
381
+ notes.append(f"Splitting by {spec.color!r} adds {n_series} comparable series.")
382
+
383
+ # --- missing data across the mapped columns
384
+ used = [c for c in spec.columns_used if c in profile.columns]
385
+ if used:
386
+ worst = max(profile.columns[c].pct_missing for c in used)
387
+ if worst > 5:
388
+ score -= weights["missing_penalty"] * min(1.0, worst / 100.0)
389
+ notes.append(f"Up to {worst:.0f}% of values in the columns used are missing.")
390
+
391
+ # --- identifiers should almost never be plotted
392
+ if any(profile.columns[c].is_identifier for c in used):
393
+ score -= weights["identifier_penalty"]
394
+ notes.append(
395
+ "One of the mapped columns looks like an identifier rather than a measure."
396
+ )
397
+
398
+ # --- correlation strength for scatter
399
+ if spec.chart == "scatter" and spec.x and spec.y and profile.correlations is not None:
400
+ corr = profile.correlations
401
+ if spec.x in corr.columns and spec.y in corr.columns:
402
+ r = as_float(corr.loc[spec.x, spec.y])
403
+ if r is not None:
404
+ score += weights["correlation_bonus"] * abs(r)
405
+ strength = "strong" if abs(r) > 0.7 else "moderate" if abs(r) > 0.4 else "weak"
406
+ direction = "positive" if r > 0 else "negative"
407
+ notes.append(
408
+ f"These two columns have a {strength} {direction} correlation "
409
+ f"(r = {r:.2f})."
410
+ )
411
+ if abs(r) > 0.4:
412
+ spec.options.setdefault("trendline", True)
413
+
414
+ # --- skew favours distribution-shape charts and log scales
415
+ if spec.y and spec.y in profile.columns:
416
+ skew = abs(profile.columns[spec.y].stats.get("skew", 0.0))
417
+ if skew > 2:
418
+ if spec.chart in {"box", "violin"}:
419
+ score += weights["skew_bonus"]
420
+ notes.append(
421
+ f"{spec.y!r} is heavily skewed, which this chart shows better than "
422
+ "an average."
423
+ )
424
+ elif spec.chart in {"bar", "line", "scatter"}:
425
+ spec.options.setdefault("log_y", True)
426
+ notes.append(f"{spec.y!r} is heavily skewed, so a log y axis is suggested.")
427
+
428
+ # --- a real time span makes a time series more compelling
429
+ if spec.chart in {"line", "area"} and spec.x and spec.x in profile.columns:
430
+ prof = profile.columns[spec.x]
431
+ if prof.role == DATETIME:
432
+ periods = prof.stats.get("n_periods", 0)
433
+ if periods >= 10:
434
+ score += weights["timespan_bonus"]
435
+ notes.append(
436
+ f"{int(periods):,} distinct time points give the trend real shape."
437
+ )
438
+ elif periods < 3:
439
+ score -= 0.25
440
+ notes.append(
441
+ f"Only {int(periods)} distinct time point(s) — too few to show a trend."
442
+ )
443
+
444
+ # --- histogram bin sanity
445
+ if spec.chart in {"histogram", "kde"} and profile.n_rows < self.config.cap(
446
+ "ideal_hist_rows", 30
447
+ ):
448
+ score -= 0.15
449
+ notes.append(f"Only {profile.n_rows} rows, which makes the distribution shape noisy.")
450
+
451
+ # --- user or NL query interest
452
+ if prefer:
453
+ hits = [c for c in used if c in prefer]
454
+ if hits:
455
+ score += 0.15 * len(hits)
456
+ notes.append(f"Uses the column(s) you asked about: {', '.join(hits)}.")
457
+
458
+ spec.score = max(0.0, min(1.0, score))
459
+ spec.why = " ".join([spec.why, *notes]).strip()
460
+ return spec, score
461
+
462
+ def _dedupe(self, specs: list[ChartSpec]) -> list[ChartSpec]:
463
+ """Drop duplicate mappings and keep the alternatives genuinely varied.
464
+
465
+ Two passes: exact duplicate mappings go, then each chart type is capped so the list the
466
+ user picks from offers different *kinds* of chart rather than eight near-identical ones.
467
+ Anything squeezed out by the cap is appended after the varied set, so nothing is lost.
468
+ """
469
+ seen: set[tuple[Any, ...]] = set()
470
+ unique: list[ChartSpec] = []
471
+ for spec in specs:
472
+ key = (spec.chart, spec.x, spec.y, spec.color, spec.agg)
473
+ if key in seen:
474
+ continue
475
+ seen.add(key)
476
+ unique.append(spec)
477
+
478
+ per_type: dict[str, int] = {}
479
+ varied: list[ChartSpec] = []
480
+ overflow: list[ChartSpec] = []
481
+ for spec in unique:
482
+ count = per_type.get(spec.chart, 0)
483
+ if count < self.max_per_chart_type:
484
+ per_type[spec.chart] = count + 1
485
+ varied.append(spec)
486
+ else:
487
+ overflow.append(spec)
488
+ return varied + overflow
489
+
490
+
491
+ def recommend(
492
+ profile: DatasetProfile, *, top_k: int = DEFAULT_TOP_K, rules_path: str | Path | None = None
493
+ ) -> list[ChartSpec]:
494
+ """Convenience wrapper: build a selector and rank charts in one call."""
495
+ return ChartSelector(load_rules(rules_path)).recommend(profile, top_k=top_k)
@@ -0,0 +1,174 @@
1
+ """Session files — ``.pviz`` projects.
2
+
3
+ A session stores the *recipe*, never the data: source path and hash, type overrides, the
4
+ preprocessing step list, active filters, the chart spec, and view settings. Reopening replays the
5
+ recipe against the source file.
6
+
7
+ That design has one consequence worth being explicit about: if the source file changed after the
8
+ session was saved, the replay may produce something different. The stored SHA-256 is checked on
9
+ load and the mismatch is reported rather than swallowed.
10
+
11
+ Sessions never contain credentials. API keys live in the OS keyring.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .errors import SessionError
23
+ from .loader import file_hash
24
+ from .preprocess import Pipeline
25
+ from .spec import ChartSpec
26
+
27
+ #: Bump when the on-disk shape changes incompatibly.
28
+ SESSION_VERSION = 1
29
+
30
+ #: Session file extension.
31
+ SESSION_SUFFIX = ".pviz"
32
+
33
+
34
+ @dataclass
35
+ class Session:
36
+ """A saved PlotaViz project.
37
+
38
+ Attributes:
39
+ source_path: Dataset the session was built from.
40
+ source_hash: SHA-256 of that file at save time.
41
+ type_overrides: ``{column: role}`` corrections the user made.
42
+ pipeline: The preprocessing steps, including filters.
43
+ spec: The chart that was on screen.
44
+ view: View settings — window geometry, active panel, renderer choice.
45
+ app_version: PlotaViz version that wrote the file.
46
+ saved_at: ISO-8601 UTC timestamp.
47
+ version: Session schema version.
48
+ """
49
+
50
+ source_path: str
51
+ source_hash: str = ""
52
+ type_overrides: dict[str, str] = field(default_factory=dict)
53
+ pipeline: Pipeline = field(default_factory=Pipeline)
54
+ spec: ChartSpec | None = None
55
+ view: dict[str, Any] = field(default_factory=dict)
56
+ app_version: str = "0.1.0"
57
+ saved_at: str = ""
58
+ version: int = SESSION_VERSION
59
+
60
+ # ------------------------------------------------------------------ writing
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ """Serialize to the JSON structure written to disk."""
64
+ return {
65
+ "version": self.version,
66
+ "app_version": self.app_version,
67
+ "saved_at": self.saved_at or datetime.now(UTC).isoformat(timespec="seconds"),
68
+ "source": {"path": str(self.source_path), "sha256": self.source_hash},
69
+ "type_overrides": dict(self.type_overrides),
70
+ "pipeline": self.pipeline.to_list(),
71
+ "chart": self.spec.to_dict() if self.spec else None,
72
+ "view": dict(self.view),
73
+ }
74
+
75
+ def save(self, path: str | Path) -> Path:
76
+ """Write the session to ``path``, adding the ``.pviz`` suffix if absent.
77
+
78
+ Raises:
79
+ SessionError: If the file cannot be written.
80
+ """
81
+ target = Path(path).expanduser()
82
+ if target.suffix != SESSION_SUFFIX:
83
+ target = target.with_suffix(SESSION_SUFFIX)
84
+
85
+ source = Path(self.source_path)
86
+ if source.exists() and not self.source_hash:
87
+ self.source_hash = file_hash(source)
88
+ self.saved_at = datetime.now(UTC).isoformat(timespec="seconds")
89
+
90
+ try:
91
+ target.parent.mkdir(parents=True, exist_ok=True)
92
+ target.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
93
+ except OSError as exc:
94
+ raise SessionError(f"Could not save the session to {target}.", hint=str(exc)) from exc
95
+ return target
96
+
97
+ # ------------------------------------------------------------------ reading
98
+
99
+ @classmethod
100
+ def load(cls, path: str | Path) -> Session:
101
+ """Read a ``.pviz`` file.
102
+
103
+ Raises:
104
+ SessionError: If the file is missing, is not valid JSON, or was written by a newer
105
+ incompatible version.
106
+ """
107
+ target = Path(path).expanduser()
108
+ try:
109
+ payload = json.loads(target.read_text(encoding="utf-8"))
110
+ except FileNotFoundError as exc:
111
+ raise SessionError(f"No session file at {target}.") from exc
112
+ except json.JSONDecodeError as exc:
113
+ raise SessionError(
114
+ f"{target.name} is not a valid PlotaViz session file.", hint=str(exc)
115
+ ) from exc
116
+ except OSError as exc:
117
+ raise SessionError(f"Could not read {target}.", hint=str(exc)) from exc
118
+
119
+ version = int(payload.get("version", 0))
120
+ if version > SESSION_VERSION:
121
+ raise SessionError(
122
+ f"{target.name} was saved by a newer version of PlotaViz "
123
+ f"(session format {version}, this build understands {SESSION_VERSION}).",
124
+ hint="Update PlotaViz to open it.",
125
+ )
126
+
127
+ source = payload.get("source") or {}
128
+ chart = payload.get("chart")
129
+
130
+ try:
131
+ pipeline = Pipeline.from_list(payload.get("pipeline") or [])
132
+ except Exception as exc:
133
+ raise SessionError(
134
+ f"The preprocessing steps in {target.name} could not be restored.",
135
+ hint=str(exc),
136
+ ) from exc
137
+
138
+ return cls(
139
+ source_path=str(source.get("path", "")),
140
+ source_hash=str(source.get("sha256", "")),
141
+ type_overrides=dict(payload.get("type_overrides") or {}),
142
+ pipeline=pipeline,
143
+ spec=ChartSpec.from_dict(chart) if chart else None,
144
+ view=dict(payload.get("view") or {}),
145
+ app_version=str(payload.get("app_version", "")),
146
+ saved_at=str(payload.get("saved_at", "")),
147
+ version=version,
148
+ )
149
+
150
+ # ------------------------------------------------------------------ validation
151
+
152
+ def check_source(self) -> str | None:
153
+ """Verify the source file is where it was and unchanged.
154
+
155
+ Returns:
156
+ ``None`` when everything matches, otherwise a user-facing warning describing the
157
+ discrepancy. The caller decides whether to continue — usually it should, because a
158
+ changed source is normal and only worth mentioning.
159
+ """
160
+ source = Path(self.source_path)
161
+ if not self.source_path:
162
+ return "This session does not record which file it came from."
163
+ if not source.exists():
164
+ return (
165
+ f"The source file {source} is missing. Open the dataset again to restore this "
166
+ "session."
167
+ )
168
+ if self.source_hash and file_hash(source) != self.source_hash:
169
+ saved = self.saved_at or "the time it was saved"
170
+ return (
171
+ f"{source.name} has changed since {saved}. The cleaning steps and chart will be "
172
+ "replayed against the new contents, so results may differ."
173
+ )
174
+ return None