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.
plotaviz/core/spec.py ADDED
@@ -0,0 +1,236 @@
1
+ """The chart spec — the single contract shared by every part of PlotaViz.
2
+
3
+ ``plotter.py`` renders one, ``codegen.py`` emits a script for one, ``session.py`` serializes one,
4
+ the selector produces them, and the LLM layer returns one as JSON. Nothing passes chart state
5
+ through side channels; if a chart capability needs new information, it goes here.
6
+
7
+ A spec is *declarative and validated*: :meth:`ChartSpec.validate` checks it against a real
8
+ dataframe's schema before anything is rendered, which is what makes it safe to accept a spec from
9
+ a language model.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass, field
15
+ from typing import Any
16
+
17
+ from .errors import SpecError
18
+
19
+ #: Chart types the plotter and code generator both understand.
20
+ CHART_TYPES: tuple[str, ...] = (
21
+ "histogram",
22
+ "kde",
23
+ "bar",
24
+ "grouped_bar",
25
+ "stacked_bar",
26
+ "treemap",
27
+ "pie",
28
+ "scatter",
29
+ "line",
30
+ "area",
31
+ "box",
32
+ "violin",
33
+ "heatmap",
34
+ "correlation_heatmap",
35
+ "pair_plot",
36
+ )
37
+
38
+ #: Aggregations usable when ``y`` is collapsed over ``x``/``color``.
39
+ AGGREGATIONS: tuple[str, ...] = ("sum", "mean", "median", "min", "max", "count", "nunique")
40
+
41
+ #: Charts that consume a single column and need no ``y``.
42
+ UNIVARIATE_CHARTS: frozenset[str] = frozenset({"histogram", "kde", "bar", "treemap", "pie"})
43
+
44
+ #: Charts that ignore ``x``/``y`` and operate on the whole numeric frame.
45
+ MATRIX_CHARTS: frozenset[str] = frozenset({"correlation_heatmap", "pair_plot"})
46
+
47
+
48
+ @dataclass
49
+ class ChartSpec:
50
+ """A declarative description of one chart.
51
+
52
+ Attributes:
53
+ chart: One of :data:`CHART_TYPES`.
54
+ x: Column mapped to the x axis (or the single column for univariate charts).
55
+ y: Column mapped to the y axis. ``None`` for univariate and matrix charts.
56
+ color: Column used for grouping / series colour. Optional.
57
+ agg: Aggregation applied to ``y`` grouped by ``x`` (and ``color``). ``None`` means the
58
+ rows are plotted as they are.
59
+ title: Chart title. Generated from the mapping when omitted.
60
+ options: Renderer hints — ``log_y``, ``trendline``, ``orientation``, ``bins``,
61
+ ``top_n``, ``stacked``, ``sort``, ``palette``. Unknown keys are ignored, so this is
62
+ the safe place for renderer-specific extras.
63
+ why: Human-readable justification. The selector always fills this in; it is what the UI
64
+ shows next to a recommendation.
65
+ score: Selector confidence in ``[0, 1]``. ``0.0`` for hand-built or LLM-built specs that
66
+ were never scored.
67
+ source: Where the spec came from — ``"rules"``, ``"llm"``, ``"user"``, or ``"session"``.
68
+ """
69
+
70
+ chart: str
71
+ x: str | None = None
72
+ y: str | None = None
73
+ color: str | None = None
74
+ agg: str | None = None
75
+ title: str | None = None
76
+ options: dict[str, Any] = field(default_factory=dict)
77
+ why: str = ""
78
+ score: float = 0.0
79
+ source: str = "rules"
80
+
81
+ # ---------------------------------------------------------------- validation
82
+
83
+ def validate(self, columns: list[str] | None = None) -> ChartSpec:
84
+ """Check the spec is internally coherent and, if given, matches a real schema.
85
+
86
+ Args:
87
+ columns: The dataframe's column names. When provided, every referenced column must
88
+ exist — this is the check that makes an LLM-returned spec safe to render.
89
+
90
+ Returns:
91
+ The spec itself, so calls can be chained.
92
+
93
+ Raises:
94
+ SpecError: If the chart type is unknown, the aggregation is unknown, a required
95
+ mapping is missing, or a referenced column is absent from ``columns``.
96
+ """
97
+ if self.chart not in CHART_TYPES:
98
+ raise SpecError(
99
+ f"Unknown chart type {self.chart!r}.",
100
+ hint=f"Supported types: {', '.join(CHART_TYPES)}.",
101
+ )
102
+
103
+ if self.agg is not None and self.agg not in AGGREGATIONS:
104
+ raise SpecError(
105
+ f"Unknown aggregation {self.agg!r}.",
106
+ hint=f"Supported aggregations: {', '.join(AGGREGATIONS)}.",
107
+ )
108
+
109
+ if self.chart not in MATRIX_CHARTS and self.x is None:
110
+ raise SpecError(
111
+ f"A {self.chart} chart needs an x column.",
112
+ hint="Pick a column for the x axis and try again.",
113
+ )
114
+
115
+ # A counting chart's measure is implicit — "count of rows per x" needs no y column.
116
+ needs_y = (
117
+ self.chart not in UNIVARIATE_CHARTS
118
+ and self.chart not in MATRIX_CHARTS
119
+ and self.agg != "count"
120
+ )
121
+ if needs_y and self.y is None:
122
+ raise SpecError(
123
+ f"A {self.chart} chart needs a y column.",
124
+ hint="Pick a column for the y axis and try again.",
125
+ )
126
+
127
+ if columns is not None:
128
+ known = set(columns)
129
+ for role, col in (("x", self.x), ("y", self.y), ("color", self.color)):
130
+ if col is not None and col not in known:
131
+ preview = ", ".join(sorted(known)[:12])
132
+ raise SpecError(
133
+ f"Column {col!r} (used as {role}) is not in this dataset.",
134
+ hint=f"Available columns: {preview}" + (" …" if len(known) > 12 else ""),
135
+ )
136
+
137
+ if not 0.0 <= self.score <= 1.0:
138
+ raise SpecError(f"Score {self.score} is outside the range 0.0–1.0.")
139
+
140
+ return self
141
+
142
+ # ---------------------------------------------------------------- convenience
143
+
144
+ @property
145
+ def columns_used(self) -> list[str]:
146
+ """The dataset columns this spec references, in mapping order, without duplicates."""
147
+ seen: list[str] = []
148
+ for col in (self.x, self.y, self.color):
149
+ if col and col not in seen:
150
+ seen.append(col)
151
+ return seen
152
+
153
+ def display_title(self) -> str:
154
+ """The title to render — the explicit one, or a readable fallback from the mapping."""
155
+ if self.title:
156
+ return self.title
157
+
158
+ def pretty(name: str | None) -> str:
159
+ return (name or "").replace("_", " ").strip()
160
+
161
+ if self.chart in MATRIX_CHARTS:
162
+ return "Correlation matrix" if self.chart == "correlation_heatmap" else "Pair plot"
163
+ if self.chart in UNIVARIATE_CHARTS:
164
+ base = f"Distribution of {pretty(self.x)}"
165
+ if self.chart in {"bar", "treemap", "pie"}:
166
+ base = f"Count by {pretty(self.x)}"
167
+ if self.agg and self.y:
168
+ base = f"{self.agg.title()} of {pretty(self.y)} by {pretty(self.x)}"
169
+ return base
170
+
171
+ lead = f"{self.agg.title()} of {pretty(self.y)}" if self.agg else pretty(self.y)
172
+ title = f"{lead} by {pretty(self.x)}"
173
+ if self.color:
174
+ title += f", split by {pretty(self.color)}"
175
+ return title
176
+
177
+ def copy(self, **changes: Any) -> ChartSpec:
178
+ """Return an independent copy, optionally with fields replaced.
179
+
180
+ Specs are passed around freely — into the plotter, the code generator, a session file —
181
+ so anything that needs to adjust one takes a copy instead of mutating a spec the caller
182
+ still holds. ``options`` is copied too, not shared.
183
+ """
184
+ fields: dict[str, Any] = {**asdict(self), **changes}
185
+ fields["options"] = dict(fields.get("options") or {})
186
+ return ChartSpec(**fields)
187
+
188
+ def with_options(self, **kwargs: Any) -> ChartSpec:
189
+ """Return a copy with ``options`` merged — the spec itself is treated as immutable."""
190
+ return self.copy(options={**self.options, **kwargs})
191
+
192
+ # ---------------------------------------------------------------- (de)serialization
193
+
194
+ def to_dict(self) -> dict[str, Any]:
195
+ """Serialize to a JSON-safe dict (used by session files and the LLM layer)."""
196
+ return asdict(self)
197
+
198
+ @classmethod
199
+ def from_dict(cls, data: dict[str, Any]) -> ChartSpec:
200
+ """Build a spec from a dict, ignoring unknown keys.
201
+
202
+ Models and older session files both produce extra or missing keys; tolerating that here
203
+ keeps the failure mode a clear validation message rather than a ``TypeError``.
204
+
205
+ Raises:
206
+ SpecError: If ``data`` is not a mapping or has no ``chart`` key.
207
+ """
208
+ if not isinstance(data, dict):
209
+ raise SpecError("Chart spec must be a JSON object.")
210
+
211
+ known = set(cls.__dataclass_fields__)
212
+ # Accept a couple of friendly aliases models tend to emit.
213
+ aliases = {"chart_type": "chart", "group": "color", "aggregation": "agg", "type": "chart"}
214
+ cleaned: dict[str, Any] = {}
215
+ for key, value in data.items():
216
+ key = aliases.get(key, key)
217
+ if key in known:
218
+ cleaned[key] = value
219
+
220
+ if "chart" not in cleaned:
221
+ raise SpecError(
222
+ "Chart spec is missing the 'chart' field.",
223
+ hint=f"Expected one of: {', '.join(CHART_TYPES)}.",
224
+ )
225
+
226
+ options = cleaned.get("options") or {}
227
+ if not isinstance(options, dict):
228
+ options = {}
229
+ cleaned["options"] = options
230
+
231
+ try:
232
+ cleaned["score"] = float(cleaned.get("score", 0.0) or 0.0)
233
+ except (TypeError, ValueError):
234
+ cleaned["score"] = 0.0
235
+
236
+ return cls(**cleaned)
plotaviz/main.py ADDED
@@ -0,0 +1,329 @@
1
+ """Entry point — launches the GUI, or runs headless from the command line.
2
+
3
+ ``plotaviz`` with no arguments opens the window. ``plotaviz --input data.csv --auto --export
4
+ chart.png`` does the whole pipeline without a display, which is nearly free because ``core`` has
5
+ no Qt in it: load, clean, profile, rank, render, write. That makes PlotaViz usable from a
6
+ Makefile or a CI job, not just from a desktop.
7
+
8
+ Qt is imported only on the GUI path. A headless machine never touches it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __app_name__, __version__
20
+
21
+ logger = logging.getLogger("plotaviz")
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ """Build the command-line parser."""
26
+ parser = argparse.ArgumentParser(
27
+ prog="plotaviz",
28
+ description=(
29
+ f"{__app_name__} — automatic data analytics and visualization. "
30
+ "Run with no arguments to open the desktop app."
31
+ ),
32
+ epilog=(
33
+ "Examples:\n"
34
+ " plotaviz\n"
35
+ " plotaviz data.csv\n"
36
+ " plotaviz --input data.csv --auto --export chart.png\n"
37
+ " plotaviz --input data.csv --auto --export-code plot.py --code-flavour plotly\n"
38
+ " plotaviz --input data.csv --describe\n"
39
+ ),
40
+ formatter_class=argparse.RawDescriptionHelpFormatter,
41
+ )
42
+
43
+ parser.add_argument(
44
+ "file", nargs="?", help="dataset or .pviz session to open in the GUI", default=None
45
+ )
46
+ parser.add_argument("-i", "--input", help="dataset to process headlessly")
47
+ parser.add_argument(
48
+ "--auto",
49
+ action="store_true",
50
+ help="pick the top-ranked chart automatically (implied by --export/--export-code)",
51
+ )
52
+ parser.add_argument(
53
+ "-e", "--export", metavar="PATH", help="write the chart to PNG/SVG/PDF/HTML"
54
+ )
55
+ parser.add_argument(
56
+ "--export-code", metavar="PATH", help="write a standalone Python script that reproduces it"
57
+ )
58
+ parser.add_argument(
59
+ "--code-flavour",
60
+ choices=("matplotlib", "plotly"),
61
+ default="matplotlib",
62
+ help="library the generated script uses (default: matplotlib)",
63
+ )
64
+
65
+ chart = parser.add_argument_group("chart selection")
66
+ chart.add_argument("--chart", help="force a chart type instead of using the recommendation")
67
+ chart.add_argument("--x", help="column for the x axis")
68
+ chart.add_argument("--y", help="column for the y axis")
69
+ chart.add_argument("--color", help="column to split series by")
70
+ chart.add_argument("--agg", help="aggregation: sum, mean, median, min, max, count, nunique")
71
+
72
+ cleaning = parser.add_argument_group("cleaning")
73
+ cleaning.add_argument(
74
+ "--missing",
75
+ default="median",
76
+ choices=("drop", "mean", "median", "mode", "ffill", "bfill", "zero"),
77
+ help="missing-value strategy (default: median)",
78
+ )
79
+ cleaning.add_argument(
80
+ "--no-outlier-flags", action="store_true", help="skip adding outlier flag columns"
81
+ )
82
+ cleaning.add_argument("--query", help='pandas query applied as a filter, e.g. "revenue > 1000"')
83
+
84
+ output = parser.add_argument_group("output")
85
+ output.add_argument("--dpi", type=int, default=300, help="export resolution (default: 300)")
86
+ output.add_argument(
87
+ "--size", default="10x6", help="export size in inches, WIDTHxHEIGHT (default: 10x6)"
88
+ )
89
+ output.add_argument(
90
+ "--describe", action="store_true", help="print the dataset profile and exit"
91
+ )
92
+ output.add_argument(
93
+ "--recommend", action="store_true", help="print the ranked chart recommendations and exit"
94
+ )
95
+ output.add_argument(
96
+ "--json", action="store_true", help="machine-readable output for --describe/--recommend"
97
+ )
98
+ output.add_argument("-q", "--quiet", action="store_true", help="only print errors")
99
+ output.add_argument("-v", "--verbose", action="store_true", help="print debug logging")
100
+ parser.add_argument("--version", action="version", version=f"{__app_name__} {__version__}")
101
+
102
+ return parser
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ """Run PlotaViz.
107
+
108
+ Args:
109
+ argv: Command-line arguments. Defaults to ``sys.argv[1:]``.
110
+
111
+ Returns:
112
+ A process exit code: 0 on success, 1 on a handled error, 2 on bad usage.
113
+ """
114
+ parser = build_parser()
115
+ args = parser.parse_args(argv)
116
+
117
+ logging.basicConfig(
118
+ level=logging.DEBUG if args.verbose else logging.WARNING if args.quiet else logging.INFO,
119
+ format="%(levelname)s %(name)s: %(message)s",
120
+ )
121
+
122
+ headless = bool(args.input or args.describe or args.recommend)
123
+ if not headless:
124
+ return _run_gui(args.file)
125
+ return _run_cli(args, parser)
126
+
127
+
128
+ # ---------------------------------------------------------------------------- GUI
129
+
130
+
131
+ def _run_gui(initial_file: str | None) -> int:
132
+ """Open the desktop app."""
133
+ try:
134
+ from .ui.main_window import launch
135
+ except ImportError as exc: # pragma: no cover - PySide6 is a hard dependency
136
+ print(
137
+ f"The {__app_name__} window needs PySide6, which is not installed.\n"
138
+ f"Install it with: pip install PySide6\n\nUnderlying error: {exc}",
139
+ file=sys.stderr,
140
+ )
141
+ return 1
142
+ return launch(initial_file)
143
+
144
+
145
+ # ---------------------------------------------------------------------------- CLI
146
+
147
+
148
+ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
149
+ """Run the headless pipeline."""
150
+ from .core.analysis import Analysis
151
+ from .core.errors import PlotaVizError
152
+ from .core.preprocess import QueryFilter
153
+ from .core.spec import ChartSpec
154
+
155
+ source = args.input or args.file
156
+ if not source:
157
+ parser.error("--describe and --recommend need --input")
158
+ return 2
159
+
160
+ try:
161
+ analysis = Analysis.from_file(
162
+ source,
163
+ missing_strategy=args.missing,
164
+ flag_outliers=not args.no_outlier_flags,
165
+ )
166
+
167
+ if args.query:
168
+ analysis.set_filters([QueryFilter(args.query)])
169
+
170
+ if not args.quiet:
171
+ print(analysis.load_result.summary(), file=sys.stderr)
172
+
173
+ if args.describe:
174
+ _print_profile(analysis, as_json=args.json)
175
+ return 0
176
+
177
+ if args.recommend:
178
+ _print_recommendations(analysis, as_json=args.json)
179
+ return 0
180
+
181
+ # --- choose a chart
182
+ if args.chart:
183
+ spec = ChartSpec(
184
+ chart=args.chart,
185
+ x=args.x,
186
+ y=args.y,
187
+ color=args.color,
188
+ agg=args.agg,
189
+ why="Specified on the command line.",
190
+ source="user",
191
+ )
192
+ analysis.choose(spec)
193
+ elif args.x or args.y:
194
+ # Partial mapping: rank charts, preferring the columns the user named.
195
+ from .core.selector import ChartSelector
196
+
197
+ prefer = [c for c in (args.x, args.y, args.color) if c]
198
+ if analysis.profile is None:
199
+ raise PlotaVizError("The dataset could not be profiled.")
200
+ ranked = ChartSelector().recommend(analysis.profile, prefer_columns=prefer)
201
+ analysis.choose(ranked[0])
202
+ elif analysis.spec is None:
203
+ raise PlotaVizError(
204
+ "PlotaViz could not recommend a chart for this dataset.",
205
+ hint="Pass --chart, --x, and --y to specify one.",
206
+ )
207
+
208
+ spec = analysis.spec
209
+ assert spec is not None
210
+ if not args.quiet:
211
+ print(
212
+ f"Chart: {spec.chart} x={spec.x} y={spec.y} colour={spec.color}", file=sys.stderr
213
+ )
214
+ if spec.why:
215
+ print(f"Why: {spec.why}", file=sys.stderr)
216
+
217
+ wrote_something = False
218
+
219
+ if args.export:
220
+ width, height = _parse_size(args.size, parser)
221
+ target = Path(args.export)
222
+ if target.suffix.lower() == ".html":
223
+ from .core.exporter import export_html
224
+
225
+ written = export_html(analysis.df, spec, target)
226
+ else:
227
+ written = analysis.export_image(target, dpi=args.dpi, width=width, height=height)
228
+ print(written)
229
+ wrote_something = True
230
+
231
+ if args.export_code:
232
+ from .core import codegen
233
+
234
+ written = codegen.write(
235
+ args.export_code, analysis.generate_code(flavour=args.code_flavour)
236
+ )
237
+ print(written)
238
+ wrote_something = True
239
+
240
+ if not wrote_something:
241
+ _print_recommendations(analysis, as_json=args.json)
242
+
243
+ return 0
244
+
245
+ except PlotaVizError as exc:
246
+ print(f"error: {exc}", file=sys.stderr)
247
+ return 1
248
+ except KeyboardInterrupt:
249
+ print("Interrupted.", file=sys.stderr)
250
+ return 130
251
+ except Exception as exc:
252
+ logger.debug("Unhandled error", exc_info=True)
253
+ print(f"error: {exc}", file=sys.stderr)
254
+ return 1
255
+
256
+
257
+ def _parse_size(text: str, parser: argparse.ArgumentParser) -> tuple[float, float]:
258
+ """Parse a ``WIDTHxHEIGHT`` size string into inches."""
259
+ try:
260
+ width, _, height = text.lower().partition("x")
261
+ return float(width), float(height)
262
+ except ValueError:
263
+ parser.error(f"--size expects WIDTHxHEIGHT in inches, e.g. 10x6 (got {text!r})")
264
+ raise # unreachable; parser.error exits
265
+
266
+
267
+ def _print_profile(analysis: object, *, as_json: bool) -> None:
268
+ """Print the dataset profile."""
269
+ profile = getattr(analysis, "profile", None)
270
+ if profile is None:
271
+ print("No profile is available.", file=sys.stderr)
272
+ return
273
+
274
+ if as_json:
275
+ print(json.dumps(profile.schema_summary(), indent=2, default=str))
276
+ return
277
+
278
+ print(f"\n{profile.n_rows:,} rows × {profile.n_cols} columns")
279
+ if profile.sampled:
280
+ print(f"(profiled on a sample of {profile.n_rows:,} of {profile.total_rows:,} rows)")
281
+ if profile.n_duplicate_rows:
282
+ print(f"{profile.n_duplicate_rows:,} duplicate rows")
283
+
284
+ header = f"\n{'column':<28} {'role':<12} {'unique':>10} {'missing':>9} notes"
285
+ print(header)
286
+ print("-" * len(header))
287
+ for prof in profile.columns.values():
288
+ note = prof.note or ("identifier" if prof.is_identifier else "")
289
+ print(
290
+ f"{prof.name[:28]:<28} {prof.role:<12} {prof.n_unique:>10,} "
291
+ f"{prof.pct_missing:>8.1f}% {note}"
292
+ )
293
+
294
+ best = profile.strongest_correlation()
295
+ if best:
296
+ print(f"\nStrongest correlation: {best[0]} ↔ {best[1]} (r = {best[2]:.2f})")
297
+
298
+
299
+ def _print_recommendations(analysis: object, *, as_json: bool) -> None:
300
+ """Print the ranked chart recommendations."""
301
+ recommendations = list(getattr(analysis, "recommendations", []))
302
+ if not recommendations:
303
+ print("No chart recommendations for this dataset.", file=sys.stderr)
304
+ return
305
+
306
+ if as_json:
307
+ print(json.dumps([spec.to_dict() for spec in recommendations], indent=2, default=str))
308
+ return
309
+
310
+ print("\nRecommended charts, best first:\n")
311
+ for i, spec in enumerate(recommendations, start=1):
312
+ mapping = ", ".join(
313
+ part
314
+ for part in (
315
+ f"x={spec.x}" if spec.x else "",
316
+ f"y={spec.y}" if spec.y else "",
317
+ f"colour={spec.color}" if spec.color else "",
318
+ f"agg={spec.agg}" if spec.agg else "",
319
+ )
320
+ if part
321
+ )
322
+ print(f"{i}. {spec.chart} ({spec.score:.0%}) {mapping}")
323
+ if spec.why:
324
+ print(f" {spec.why}")
325
+ print()
326
+
327
+
328
+ if __name__ == "__main__":
329
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ """PySide6 widgets.
2
+
3
+ Everything in this package may import Qt; nothing in :mod:`plotaviz.core` may. The boundary runs
4
+ one way: the UI calls into the core through plain data objects — a
5
+ :class:`~plotaviz.core.analysis.Analysis`, a :class:`~plotaviz.core.spec.ChartSpec`, a
6
+ :class:`~plotaviz.core.profiler.DatasetProfile` — and the core knows nothing about widgets.
7
+
8
+ Imports here are lazy at the module level so ``import plotaviz`` stays cheap for CLI use, which
9
+ never touches Qt at all.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = ["MainWindow", "launch"]
15
+
16
+
17
+ def __getattr__(name: str) -> object:
18
+ """Import the main window on first use, so importing this package does not start Qt."""
19
+ if name in __all__:
20
+ from .main_window import MainWindow, launch
21
+
22
+ return {"MainWindow": MainWindow, "launch": launch}[name]
23
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")