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/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
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())
|
plotaviz/ui/__init__.py
ADDED
|
@@ -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}")
|