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 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()}