augplot 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.
augplot/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Notebook visualizations with inspectable, reusable Python source."""
2
+
3
+ from .core import plot
4
+ from .datasets import SNS_DATASETS, load_sns_dataset
5
+ from .errors import (
6
+ AugplotError,
7
+ ConfigurationError,
8
+ DataError,
9
+ GenerationError,
10
+ ProviderError,
11
+ ScopeError,
12
+ )
13
+
14
+ __all__ = [
15
+ "plot",
16
+ "load_sns_dataset",
17
+ "SNS_DATASETS",
18
+ "AugplotError",
19
+ "ConfigurationError",
20
+ "DataError",
21
+ "GenerationError",
22
+ "ProviderError",
23
+ "ScopeError",
24
+ ]
25
+ __version__ = "0.1.0"
augplot/core.py ADDED
@@ -0,0 +1,307 @@
1
+ """Public notebook workflow, independent of the inference transport."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from . import provider
8
+ from .errors import ConfigurationError, GenerationError
9
+ from .execution import API_MANIFEST_VERSION, execute, parse_response
10
+ from .exporting import write_python_function
11
+ from .history import HISTORY_VERSION, History, fingerprint, request_key
12
+ from .profiling import copy_data, profile_data, validate_data
13
+ from .prompts import PROMPT_VERSION, RESPONSE_FORMAT, system_prompt_for
14
+
15
+
16
+ class _Visualization:
17
+ """Generate, refine, and reuse a plot with inspectable Python source.
18
+
19
+ Provider credentials are read by LiteLLM when a request is made. Generated Python
20
+ executes locally after conservative checks; it is not sandboxed. Data samples are
21
+ sent to the configured model. See the README for the supported data and trust model.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ model: str | None = None,
28
+ backend: str = "auto",
29
+ display_format: str = "retina",
30
+ api_base: str | None = None,
31
+ sample_rows: int = 5,
32
+ max_profile_chars: int = 20_000,
33
+ timeout: float = 60,
34
+ max_repairs: int = 1,
35
+ cache_dir: str | Path | None = ".augplot/plots",
36
+ ):
37
+ if backend not in {"auto", "matplotlib", "seaborn"}:
38
+ raise ConfigurationError("backend must be auto, matplotlib, or seaborn.")
39
+ if display_format not in {"retina", "png", "svg"}:
40
+ raise ConfigurationError("display_format must be retina, png, or svg.")
41
+ if not isinstance(sample_rows, int) or not 0 <= sample_rows <= 100:
42
+ raise ConfigurationError("sample_rows must be an integer between 0 and 100.")
43
+ if not isinstance(max_profile_chars, int) or not 500 <= max_profile_chars <= 100_000:
44
+ raise ConfigurationError("max_profile_chars must be between 500 and 100,000.")
45
+ if not isinstance(max_repairs, int) or max_repairs not in (0, 1):
46
+ raise ConfigurationError("max_repairs must be 0 or 1.")
47
+ if not isinstance(timeout, (int, float)) or not 0 < timeout < float("inf"):
48
+ raise ConfigurationError("timeout must be a finite positive number of seconds.")
49
+ self.model = model
50
+ self.backend = backend
51
+ self.display_format = display_format
52
+ self.api_base = api_base
53
+ self.sample_rows = sample_rows
54
+ self.max_profile_chars = max_profile_chars
55
+ self.timeout = timeout
56
+ self.max_repairs = max_repairs
57
+ self._history = History(cache_dir) if cache_dir is not None else None
58
+ self.history_path: Path | None = None
59
+ self.cache_hit = False
60
+ self._record = None
61
+ self._root = None
62
+ self._data_fingerprint = None
63
+ self.code: str | None = None
64
+ self.figure = None
65
+ self.explanation: str | None = None
66
+ self.profile: dict | None = None
67
+ self._data = None
68
+ self._prompt = "auto"
69
+
70
+ def _configuration(self):
71
+ model = self.model if self.model is not None else os.getenv("AUGPLOT_MODEL")
72
+ if not isinstance(model, str) or not model.strip():
73
+ raise ConfigurationError("Set AUGPLOT_MODEL or pass model='provider/model-name'.")
74
+ api_base = self.api_base if self.api_base is not None else os.getenv("AUGPLOT_API_BASE")
75
+ return model, api_base
76
+
77
+ @staticmethod
78
+ def _validate_prompt(prompt):
79
+ if not isinstance(prompt, str) or not prompt.strip():
80
+ raise ConfigurationError("prompt must be 'auto' or a nonempty instruction string.")
81
+
82
+ def _generate(self, data, profile, prompt, *, previous_code=None, original_prompt=None):
83
+ model, api_base = self._configuration()
84
+ context = {
85
+ "prompt_version": PROMPT_VERSION,
86
+ "operation": "refine" if previous_code is not None else "generate",
87
+ "backend": self.backend,
88
+ "request": prompt,
89
+ "data_profile": profile,
90
+ }
91
+ if previous_code is not None:
92
+ context.update(
93
+ previous_code=previous_code,
94
+ original_request=original_prompt,
95
+ )
96
+ messages = [
97
+ {"role": "system", "content": system_prompt_for(request=prompt, profile=profile)},
98
+ {"role": "user", "content": json.dumps(context)},
99
+ ]
100
+ last_error = None
101
+ code = None
102
+ for attempt in range(self.max_repairs + 1):
103
+ response = provider.complete(
104
+ model=model,
105
+ messages=messages,
106
+ api_base=api_base,
107
+ timeout=self.timeout,
108
+ response_format=RESPONSE_FORMAT,
109
+ )
110
+ try:
111
+ code, explanation = parse_response(response)
112
+ figure = execute(code, data, backend=self.backend)
113
+ return code, explanation, figure
114
+ except GenerationError as exc:
115
+ last_error = exc
116
+ if attempt < self.max_repairs:
117
+ messages.extend(
118
+ [
119
+ {"role": "assistant", "content": response[:60_000]},
120
+ {
121
+ "role": "user",
122
+ "content": json.dumps(
123
+ {
124
+ "repair": "Return corrected JSON per the contract.",
125
+ "diagnostic": str(exc)[:500],
126
+ }
127
+ ),
128
+ },
129
+ ]
130
+ )
131
+ raise GenerationError(
132
+ f"Could not generate a working visualization after {self.max_repairs + 1} "
133
+ f"attempt(s). {last_error}",
134
+ code=code,
135
+ violations=last_error.violations if last_error is not None else None,
136
+ ) from None
137
+
138
+ def _resolve(self, data, profile, prompt, *, data_hash, regenerate, refining=False):
139
+ """Resolve this exact step; descendants never replace their parent's lookup."""
140
+ model, api_base = self._configuration()
141
+ settings = {
142
+ "history_version": HISTORY_VERSION,
143
+ "api_manifest_version": API_MANIFEST_VERSION,
144
+ "prompt_version": PROMPT_VERSION,
145
+ "data": data_hash,
146
+ "model": model,
147
+ "api_base": api_base,
148
+ "backend": self.backend,
149
+ "sample_rows": self.sample_rows,
150
+ "max_profile_chars": self.max_profile_chars,
151
+ "prompt": prompt,
152
+ }
153
+ if refining:
154
+ settings.update(
155
+ parent=self._record["revision"] if self._record else None,
156
+ previous_code=self.code,
157
+ original_prompt=self._prompt,
158
+ )
159
+ key = request_key(settings)
160
+ root = self._root if refining else key
161
+ if self._history is not None and not regenerate:
162
+ saved = self._history.load(root, key)
163
+ if saved is not None:
164
+ record, code, path = saved
165
+ # Apply the same validation and defensive execution as freshly generated code.
166
+ code, explanation = parse_response(
167
+ json.dumps(
168
+ {"status": "ok", "code": code, "explanation": record["explanation"]}
169
+ )
170
+ )
171
+ try:
172
+ figure = execute(code, data, backend=self.backend)
173
+ except GenerationError as exc:
174
+ raise GenerationError(
175
+ "Saved visualization failed; no LLM request was made. Restore the "
176
+ "compatible environment or use regenerate=True explicitly. " + str(exc),
177
+ code=code,
178
+ violations=exc.violations,
179
+ ) from None
180
+ return code, explanation, figure, record, path, root, True
181
+ code, explanation, figure = self._generate(
182
+ data,
183
+ profile,
184
+ prompt,
185
+ previous_code=self.code if refining else None,
186
+ original_prompt=self._prompt if refining else None,
187
+ )
188
+ record, path = None, None
189
+ if self._history is not None:
190
+ record, _, path = self._history.save(
191
+ root,
192
+ key,
193
+ code=code,
194
+ explanation=explanation,
195
+ depth=self._record["depth"] + 1 if refining else 0,
196
+ parent=self._record["revision"] if refining else None,
197
+ data_fingerprint=data_hash,
198
+ )
199
+ return code, explanation, figure, record, path, root, False
200
+
201
+ def _accept(self, resolved):
202
+ (
203
+ self.code, self.explanation, self.figure, self._record,
204
+ self.history_path, self._root, self.cache_hit,
205
+ ) = resolved
206
+
207
+ def _display(self, figure):
208
+ """Render static figures explicitly, without altering notebook formatters or DPI."""
209
+ from IPython import get_ipython
210
+ from IPython.core.pylabtools import print_figure, retina_figure
211
+ from IPython.display import SVG, Image, display
212
+
213
+ # Do not open browser windows or print Figure reprs from scripts.
214
+ shell = get_ipython()
215
+ if shell is not None and getattr(shell, "kernel", None) is not None:
216
+ if self.display_format == "retina":
217
+ rendered = retina_figure(figure)
218
+ if rendered is not None:
219
+ png, dimensions = rendered
220
+ display(Image(data=png, format="png", **dimensions))
221
+ elif self.display_format == "svg":
222
+ svg = print_figure(figure, fmt="svg")
223
+ if svg is not None:
224
+ display(SVG(data=svg))
225
+ else:
226
+ png = print_figure(figure, fmt="png")
227
+ if png is not None:
228
+ display(Image(data=png, format="png"))
229
+
230
+ def fit(self, data, prompt: str = "auto", *, show: bool = True, regenerate: bool = False):
231
+ """Reuse or generate the original plot. Replace existing state only after success."""
232
+ self._validate_prompt(prompt)
233
+ self._configuration()
234
+ profile = profile_data(data, sample_rows=self.sample_rows, max_chars=self.max_profile_chars)
235
+ snapshot = copy_data(data)
236
+ data_hash = fingerprint(snapshot) if self._history is not None else None
237
+ resolved = self._resolve(
238
+ snapshot, profile, prompt, data_hash=data_hash, regenerate=regenerate
239
+ )
240
+ self._data, self._prompt = snapshot, prompt
241
+ self.profile, self._data_fingerprint = profile, data_hash
242
+ self._accept(resolved)
243
+ if show:
244
+ self._display(self.figure)
245
+ return self
246
+
247
+ def _require_fit(self):
248
+ if self.code is None:
249
+ raise ConfigurationError(
250
+ "Call fit(data) before refining, rendering, or writing Python code."
251
+ )
252
+
253
+ @property
254
+ def data_fingerprint(self):
255
+ """Full fitted-data hash, or None before fit / when persistence is disabled."""
256
+ return self._data_fingerprint
257
+
258
+ def refine(self, prompt: str, *, show: bool = True, regenerate: bool = False):
259
+ """Reuse or generate a revision identified by its parent and instruction."""
260
+ self._require_fit()
261
+ self._validate_prompt(prompt)
262
+ resolved = self._resolve(
263
+ self._data,
264
+ self.profile,
265
+ prompt,
266
+ data_hash=self._data_fingerprint,
267
+ regenerate=regenerate,
268
+ refining=True,
269
+ )
270
+ self._accept(resolved)
271
+ if show:
272
+ self._display(self.figure)
273
+ return self
274
+
275
+ def render(self, data=None, *, title=None, figsize=None, show: bool = True):
276
+ """Run the saved function locally on matching data; return self, with no LLM call.
277
+
278
+ The fitted data remains the basis for future refinement. Pass new data to fit()
279
+ if the schema or the data used for refinement should change.
280
+ """
281
+ self._require_fit()
282
+ selected = self._data if data is None else data
283
+ validate_data(selected)
284
+ figure = execute(self.code, selected, backend=self.backend, title=title, figsize=figsize)
285
+ self.figure = figure
286
+ if show:
287
+ self._display(figure)
288
+ return self
289
+
290
+ def to_python(
291
+ self, path: str | Path | None = None, *, function_name: str = "plot_visualization"
292
+ ) -> Path:
293
+ """Write reusable Python to a local module, without an LLM call."""
294
+ self._require_fit()
295
+ target = Path("augplot_utils.py") if path is None else Path(path)
296
+ return write_python_function(self.code, target, function_name, backend=self.backend)
297
+
298
+ def __repr__(self):
299
+ state = "fitted" if self.code is not None else "unfitted"
300
+ return f"Augplot(backend={self.backend!r}, state={state!r})"
301
+
302
+
303
+ def plot(
304
+ data, prompt: str = "auto", *, show: bool = True, regenerate: bool = False, **kwargs
305
+ ) -> _Visualization:
306
+ """Create a visualization that can be refined, rendered, and written as Python."""
307
+ return _Visualization(**kwargs).fit(data, prompt=prompt, show=show, regenerate=regenerate)
augplot/datasets.py ADDED
@@ -0,0 +1,51 @@
1
+ """Convenient access to Seaborn's online example datasets."""
2
+
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ import seaborn as sns
7
+
8
+ SNS_DATASETS = (
9
+ "anagrams",
10
+ "anscombe",
11
+ "attention",
12
+ "brain_networks",
13
+ "car_crashes",
14
+ "diamonds",
15
+ "dots",
16
+ "dowjones",
17
+ "exercise",
18
+ "flights",
19
+ "fmri",
20
+ "geyser",
21
+ "glue",
22
+ "healthexp",
23
+ "iris",
24
+ "mpg",
25
+ "penguins",
26
+ "planets",
27
+ "seaice",
28
+ "taxis",
29
+ "tips",
30
+ "titanic",
31
+ )
32
+
33
+
34
+ def load_sns_dataset(
35
+ name: str,
36
+ *,
37
+ cache: bool = True,
38
+ data_home: str | None = None,
39
+ **kwargs: Any,
40
+ ) -> pd.DataFrame:
41
+ """Load one of Seaborn's example datasets without importing Seaborn yourself.
42
+
43
+ The first load may download the dataset from Seaborn's public data repository.
44
+ Seaborn caches downloads locally by default. Extra keyword arguments are passed
45
+ to :func:`seaborn.load_dataset` and then to ``pandas.read_csv``.
46
+ """
47
+ if name not in SNS_DATASETS:
48
+ available = ", ".join(SNS_DATASETS)
49
+ raise ValueError(f"Unknown Seaborn dataset {name!r}. Available datasets: {available}.")
50
+
51
+ return sns.load_dataset(name, cache=cache, data_home=data_home, **kwargs)
augplot/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """Public exceptions. Provider/runtime diagnostics never include raw data or credentials."""
2
+
3
+ from typing import TypedDict
4
+
5
+
6
+ class ValidationViolation(TypedDict):
7
+ """A machine-readable generated-code validation failure."""
8
+
9
+ rule: str
10
+ message: str
11
+
12
+
13
+ class AugplotError(Exception):
14
+ """Base class for Augplot errors."""
15
+
16
+
17
+ class ConfigurationError(AugplotError, ValueError):
18
+ """Missing or invalid configuration."""
19
+
20
+
21
+ class DataError(AugplotError, ValueError):
22
+ """Unsupported or unusable input data."""
23
+
24
+
25
+ class ProviderError(AugplotError):
26
+ """The configured model could not be called."""
27
+
28
+
29
+ class ScopeError(AugplotError, ValueError):
30
+ """The request needs upstream modeling or prediction inputs, not plotting code."""
31
+
32
+
33
+ class GenerationError(AugplotError):
34
+ """The model did not produce a valid, executable plot."""
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ *,
40
+ code: str | None = None,
41
+ violations: list[ValidationViolation] | None = None,
42
+ ) -> None:
43
+ super().__init__(message)
44
+ self.code = code
45
+ self.violations = list(violations or [])