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/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""PlotaViz — automatic data analytics and visualization.
|
|
2
|
+
|
|
3
|
+
The package is split into two halves that must not blur together:
|
|
4
|
+
|
|
5
|
+
``plotaviz.core``
|
|
6
|
+
Fully headless. No Qt imports anywhere. Loading, cleaning, profiling, chart selection,
|
|
7
|
+
plotting, code generation, and session persistence all live here, which is what makes the
|
|
8
|
+
CLI mode and the unit tests cheap.
|
|
9
|
+
|
|
10
|
+
``plotaviz.ui``
|
|
11
|
+
PySide6 widgets. Talks to ``core`` through plain data objects only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__app_name__ = "PlotaViz"
|
|
18
|
+
|
|
19
|
+
__all__ = ["__app_name__", "__version__"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Headless core — everything PlotaViz can do without a GUI.
|
|
2
|
+
|
|
3
|
+
Nothing in this package imports Qt. That is enforced by convention, by the test suite, and by CI
|
|
4
|
+
running ``mypy`` over this directory specifically. It is what makes ``plotaviz --input data.csv
|
|
5
|
+
--auto --export chart.png`` nearly free, and what makes the whole engine unit-testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .analysis import Analysis
|
|
11
|
+
from .errors import (
|
|
12
|
+
ExportError,
|
|
13
|
+
LLMError,
|
|
14
|
+
LoadError,
|
|
15
|
+
PlotaVizError,
|
|
16
|
+
PlotError,
|
|
17
|
+
PreprocessError,
|
|
18
|
+
ProfileError,
|
|
19
|
+
SelectionError,
|
|
20
|
+
SessionError,
|
|
21
|
+
SpecError,
|
|
22
|
+
)
|
|
23
|
+
from .loader import LoadResult, load
|
|
24
|
+
from .preprocess import Pipeline, Step, default_pipeline
|
|
25
|
+
from .profiler import DatasetProfile, profile
|
|
26
|
+
from .selector import ChartSelector
|
|
27
|
+
from .session import Session
|
|
28
|
+
from .spec import CHART_TYPES, ChartSpec
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CHART_TYPES",
|
|
32
|
+
"Analysis",
|
|
33
|
+
"ChartSelector",
|
|
34
|
+
"ChartSpec",
|
|
35
|
+
"DatasetProfile",
|
|
36
|
+
"ExportError",
|
|
37
|
+
"LLMError",
|
|
38
|
+
"LoadError",
|
|
39
|
+
"LoadResult",
|
|
40
|
+
"Pipeline",
|
|
41
|
+
"PlotError",
|
|
42
|
+
"PlotaVizError",
|
|
43
|
+
"PreprocessError",
|
|
44
|
+
"ProfileError",
|
|
45
|
+
"SelectionError",
|
|
46
|
+
"Session",
|
|
47
|
+
"SessionError",
|
|
48
|
+
"SpecError",
|
|
49
|
+
"Step",
|
|
50
|
+
"default_pipeline",
|
|
51
|
+
"load",
|
|
52
|
+
"profile",
|
|
53
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Orchestration facade: load → clean → profile → recommend → render.
|
|
2
|
+
|
|
3
|
+
The CLI and the GUI both need the same sequence, and duplicating it in two places is how the two
|
|
4
|
+
drift apart. :class:`Analysis` owns that sequence and nothing else — it holds state, calls the
|
|
5
|
+
single-responsibility modules around it, and stays free of Qt so the CLI can use it directly.
|
|
6
|
+
|
|
7
|
+
Everything is re-derivable: :meth:`Analysis.rerun` replays the pipeline from the raw frame, which
|
|
8
|
+
is what makes type overrides and filter changes cheap.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from . import codegen, exporter, loader, plotter, selector
|
|
20
|
+
from .errors import PlotaVizError
|
|
21
|
+
from .preprocess import Pipeline, PipelineResult, Step, default_pipeline
|
|
22
|
+
from .profiler import DatasetProfile, profile
|
|
23
|
+
from .session import Session
|
|
24
|
+
from .spec import ChartSpec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Analysis:
|
|
29
|
+
"""One dataset, its cleaning recipe, its profile, and its chart recommendations.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
load_result: What :func:`plotaviz.core.loader.load` returned.
|
|
33
|
+
pipeline: The active preprocessing recipe.
|
|
34
|
+
type_overrides: User corrections to inferred column roles.
|
|
35
|
+
result: Output of the last pipeline run.
|
|
36
|
+
profile: Profile of the cleaned frame.
|
|
37
|
+
recommendations: Ranked chart specs, best first.
|
|
38
|
+
spec: The chart currently selected.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
load_result: loader.LoadResult
|
|
42
|
+
pipeline: Pipeline = field(default_factory=Pipeline)
|
|
43
|
+
type_overrides: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
result: PipelineResult | None = None
|
|
45
|
+
profile: DatasetProfile | None = None
|
|
46
|
+
recommendations: list[ChartSpec] = field(default_factory=list)
|
|
47
|
+
spec: ChartSpec | None = None
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------ construction
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_file(
|
|
53
|
+
cls,
|
|
54
|
+
path: str | Path,
|
|
55
|
+
*,
|
|
56
|
+
missing_strategy: str = "median",
|
|
57
|
+
flag_outliers: bool = True,
|
|
58
|
+
**load_kwargs: Any,
|
|
59
|
+
) -> Analysis:
|
|
60
|
+
"""Load a dataset and run the default cleaning pipeline against it.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: Dataset to open.
|
|
64
|
+
missing_strategy: Missing-value strategy for the default pipeline.
|
|
65
|
+
flag_outliers: Whether to add outlier flag columns.
|
|
66
|
+
**load_kwargs: Passed through to :func:`plotaviz.core.loader.load`.
|
|
67
|
+
"""
|
|
68
|
+
load_result = loader.load(path, **load_kwargs)
|
|
69
|
+
analysis = cls(load_result=load_result)
|
|
70
|
+
|
|
71
|
+
# Profile the raw frame first so the pipeline knows which columns need coercing.
|
|
72
|
+
raw_profile = profile(load_result.df, total_rows=load_result.total_rows)
|
|
73
|
+
inferred = {name: prof.role for name, prof in raw_profile.columns.items()}
|
|
74
|
+
|
|
75
|
+
analysis.pipeline = default_pipeline(
|
|
76
|
+
_normalized_types(inferred),
|
|
77
|
+
missing_strategy=missing_strategy,
|
|
78
|
+
flag_outliers=flag_outliers,
|
|
79
|
+
)
|
|
80
|
+
analysis.rerun()
|
|
81
|
+
return analysis
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ pipeline
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def raw(self) -> pd.DataFrame:
|
|
87
|
+
"""The frame as loaded, before any cleaning."""
|
|
88
|
+
return self.load_result.df
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def df(self) -> pd.DataFrame:
|
|
92
|
+
"""The cleaned, filtered frame — what gets charted."""
|
|
93
|
+
return self.result.df if self.result else self.load_result.df
|
|
94
|
+
|
|
95
|
+
def rerun(self) -> PipelineResult:
|
|
96
|
+
"""Replay the pipeline from the raw frame, then re-profile and re-recommend.
|
|
97
|
+
|
|
98
|
+
This is the single path for every state change: a type override, a new filter, a changed
|
|
99
|
+
missing-value strategy. Replaying is cheap because steps are pure.
|
|
100
|
+
"""
|
|
101
|
+
self.result = self.pipeline.run(self.raw)
|
|
102
|
+
self.profile = profile(
|
|
103
|
+
self.result.df,
|
|
104
|
+
overrides=self.type_overrides,
|
|
105
|
+
total_rows=self.load_result.total_rows,
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
self.recommendations = selector.ChartSelector().recommend(self.profile)
|
|
109
|
+
except PlotaVizError:
|
|
110
|
+
self.recommendations = []
|
|
111
|
+
if self.recommendations and (self.spec is None or self.spec.source == "rules"):
|
|
112
|
+
self.spec = self.recommendations[0]
|
|
113
|
+
return self.result
|
|
114
|
+
|
|
115
|
+
def set_type_override(self, column: str, role: str) -> PipelineResult:
|
|
116
|
+
"""Correct one column's role and replay everything downstream."""
|
|
117
|
+
self.type_overrides[column] = role
|
|
118
|
+
for step in self.pipeline:
|
|
119
|
+
if hasattr(step, "types"):
|
|
120
|
+
step.types[column] = role # type: ignore[attr-defined]
|
|
121
|
+
break
|
|
122
|
+
return self.rerun()
|
|
123
|
+
|
|
124
|
+
def set_filters(self, filters: list[Step]) -> PipelineResult:
|
|
125
|
+
"""Replace the active filters and replay."""
|
|
126
|
+
self.pipeline.replace_filters(filters)
|
|
127
|
+
return self.rerun()
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ charting
|
|
130
|
+
|
|
131
|
+
def choose(self, spec: ChartSpec) -> ChartSpec:
|
|
132
|
+
"""Make ``spec`` the active chart after validating it against the real schema."""
|
|
133
|
+
spec.validate(list(self.df.columns))
|
|
134
|
+
self.spec = spec
|
|
135
|
+
return spec
|
|
136
|
+
|
|
137
|
+
def prepared(self, spec: ChartSpec | None = None) -> plotter.PreparedData:
|
|
138
|
+
"""Aggregate and downsample for a spec — shared by both renderers."""
|
|
139
|
+
return plotter.prepare(self.df, self._require_spec(spec))
|
|
140
|
+
|
|
141
|
+
def figure_plotly(self, spec: ChartSpec | None = None) -> Any:
|
|
142
|
+
"""Build the interactive Plotly figure."""
|
|
143
|
+
return plotter.build_plotly(self.df, self._require_spec(spec))
|
|
144
|
+
|
|
145
|
+
def figure_matplotlib(self, spec: ChartSpec | None = None, **kwargs: Any) -> Any:
|
|
146
|
+
"""Build the static matplotlib figure."""
|
|
147
|
+
return plotter.build_matplotlib(self.df, self._require_spec(spec), **kwargs)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------ output
|
|
150
|
+
|
|
151
|
+
def export_image(
|
|
152
|
+
self, path: str | Path, *, spec: ChartSpec | None = None, **kwargs: Any
|
|
153
|
+
) -> Path:
|
|
154
|
+
"""Write the chart to PNG/SVG/PDF."""
|
|
155
|
+
options = exporter.ExportOptions(**kwargs) if kwargs else None
|
|
156
|
+
return exporter.export_image(self.df, self._require_spec(spec), path, options=options)
|
|
157
|
+
|
|
158
|
+
def generate_code(self, *, spec: ChartSpec | None = None, flavour: str = "matplotlib") -> str:
|
|
159
|
+
"""Emit a standalone script reproducing the current chart."""
|
|
160
|
+
from .. import __version__
|
|
161
|
+
|
|
162
|
+
return codegen.generate(
|
|
163
|
+
self._require_spec(spec),
|
|
164
|
+
self.pipeline,
|
|
165
|
+
self.load_result.path,
|
|
166
|
+
flavour=flavour,
|
|
167
|
+
version=__version__,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def to_session(self, view: dict[str, Any] | None = None) -> Session:
|
|
171
|
+
"""Capture the current state as a savable session."""
|
|
172
|
+
from .. import __version__
|
|
173
|
+
|
|
174
|
+
return Session(
|
|
175
|
+
source_path=str(self.load_result.path),
|
|
176
|
+
source_hash=self.load_result.file_hash,
|
|
177
|
+
type_overrides=dict(self.type_overrides),
|
|
178
|
+
pipeline=self.pipeline,
|
|
179
|
+
spec=self.spec,
|
|
180
|
+
view=dict(view or {}),
|
|
181
|
+
app_version=__version__,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_session(cls, session: Session, **load_kwargs: Any) -> tuple[Analysis, str | None]:
|
|
186
|
+
"""Restore an analysis from a session file.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
``(analysis, warning)`` where ``warning`` is ``None`` unless the source file moved or
|
|
190
|
+
changed since the session was saved.
|
|
191
|
+
"""
|
|
192
|
+
warning = session.check_source()
|
|
193
|
+
load_result = loader.load(session.source_path, **load_kwargs)
|
|
194
|
+
analysis = cls(
|
|
195
|
+
load_result=load_result,
|
|
196
|
+
pipeline=session.pipeline,
|
|
197
|
+
type_overrides=dict(session.type_overrides),
|
|
198
|
+
)
|
|
199
|
+
analysis.rerun()
|
|
200
|
+
if session.spec is not None:
|
|
201
|
+
try:
|
|
202
|
+
analysis.choose(session.spec)
|
|
203
|
+
except PlotaVizError as exc:
|
|
204
|
+
warning = (warning + "\n\n" if warning else "") + (
|
|
205
|
+
f"The saved chart could not be restored: {exc.message}"
|
|
206
|
+
)
|
|
207
|
+
return analysis, warning
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ helpers
|
|
210
|
+
|
|
211
|
+
def _require_spec(self, spec: ChartSpec | None) -> ChartSpec:
|
|
212
|
+
"""Return the given spec, or the active one, or raise a clear error."""
|
|
213
|
+
chosen = spec or self.spec
|
|
214
|
+
if chosen is None:
|
|
215
|
+
raise PlotaVizError(
|
|
216
|
+
"No chart has been chosen yet.",
|
|
217
|
+
hint="Pick one of the recommendations, or describe what you want to see.",
|
|
218
|
+
)
|
|
219
|
+
return chosen
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _normalized_types(inferred: dict[str, str]) -> dict[str, str]:
|
|
223
|
+
"""Re-key inferred roles onto the snake_case names the pipeline will produce.
|
|
224
|
+
|
|
225
|
+
``NormalizeColumnNames`` runs before ``CoerceTypes``, so the type map has to speak in the
|
|
226
|
+
post-rename vocabulary or it silently matches nothing.
|
|
227
|
+
"""
|
|
228
|
+
from .preprocess import NormalizeColumnNames
|
|
229
|
+
|
|
230
|
+
return {NormalizeColumnNames.normalize(name): role for name, role in inferred.items()}
|