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.
@@ -0,0 +1,113 @@
1
+ """Ollama provider — local models, no network egress.
2
+
3
+ This is the privacy-preserving path. Ollama runs on the user's own machine, so nothing about
4
+ their dataset leaves it, and no API key exists to leak. For sensitive data this is the provider
5
+ to recommend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from ..errors import LLMError
13
+ from .base import DEFAULT_TIMEOUT, Provider
14
+
15
+ #: Where Ollama listens by default.
16
+ DEFAULT_HOST = "http://localhost:11434"
17
+
18
+
19
+ class OllamaProvider(Provider):
20
+ """Talks to a local Ollama server.
21
+
22
+ Args:
23
+ model: Model tag, e.g. ``llama3.1`` or ``qwen2.5``.
24
+ host: Base URL of the Ollama server.
25
+ timeout: Per-request timeout. Local models on CPU are slow, so this defaults higher than
26
+ the hosted providers.
27
+ """
28
+
29
+ name = "ollama"
30
+ default_model = "llama3.1"
31
+ available_models = ("llama3.1", "llama3.2", "qwen2.5", "mistral", "gemma2", "phi4")
32
+ sends_data_off_machine = False
33
+
34
+ def __init__(
35
+ self,
36
+ model: str | None = None,
37
+ api_key: str | None = None,
38
+ timeout: float = DEFAULT_TIMEOUT * 4,
39
+ host: str = DEFAULT_HOST,
40
+ ) -> None:
41
+ super().__init__(model=model, api_key=api_key, timeout=timeout)
42
+ self.host = host.rstrip("/")
43
+
44
+ def is_configured(self) -> bool:
45
+ """Ollama needs no key — it is configured if the server answers."""
46
+ return self.is_running()
47
+
48
+ def is_running(self) -> bool:
49
+ """Whether an Ollama server is reachable at :attr:`host`."""
50
+ try:
51
+ import httpx
52
+
53
+ response = httpx.get(f"{self.host}/api/tags", timeout=2.0)
54
+ return response.status_code == 200
55
+ except Exception:
56
+ return False
57
+
58
+ def list_models(self) -> list[str]:
59
+ """Model tags the local server has pulled. Empty when it is not running."""
60
+ try:
61
+ import httpx
62
+
63
+ response = httpx.get(f"{self.host}/api/tags", timeout=3.0)
64
+ response.raise_for_status()
65
+ return [m["name"] for m in response.json().get("models", [])]
66
+ except Exception:
67
+ return []
68
+
69
+ def complete(self, system: str, user: str, *, timeout: float) -> str:
70
+ """Send one message to the local model and return the text of the reply."""
71
+ try:
72
+ import httpx
73
+ except ImportError as exc:
74
+ raise LLMError(
75
+ "The Ollama provider needs the httpx package.",
76
+ hint='Install it with: pip install "plotaviz[llm]"',
77
+ ) from exc
78
+
79
+ payload = {
80
+ "model": self.model,
81
+ "system": system,
82
+ "prompt": user,
83
+ "stream": False,
84
+ "format": "json",
85
+ "options": {"temperature": 0.2},
86
+ }
87
+
88
+ try:
89
+ response = httpx.post(f"{self.host}/api/generate", json=payload, timeout=timeout)
90
+ response.raise_for_status()
91
+ except httpx.ConnectError as exc:
92
+ raise LLMError(
93
+ f"No Ollama server is running at {self.host}.",
94
+ hint="Start it with `ollama serve`, then pull a model: `ollama pull llama3.1`.",
95
+ ) from exc
96
+ except httpx.TimeoutException as exc:
97
+ raise LLMError(
98
+ f"The local model did not answer within {timeout:.0f} seconds.",
99
+ hint="Local models can be slow on CPU. Try a smaller model, or raise the timeout.",
100
+ ) from exc
101
+ except httpx.HTTPStatusError as exc:
102
+ detail = exc.response.text[:200]
103
+ if exc.response.status_code == 404:
104
+ raise LLMError(
105
+ f"Ollama does not have the model {self.model!r}.",
106
+ hint=f"Pull it first: ollama pull {self.model}",
107
+ ) from exc
108
+ raise LLMError("The Ollama request failed.", hint=detail) from exc
109
+
110
+ try:
111
+ return str(response.json().get("response", ""))
112
+ except json.JSONDecodeError as exc:
113
+ raise LLMError("Ollama returned a malformed response.", hint=str(exc)) from exc
@@ -0,0 +1,58 @@
1
+ """OpenAI provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import LLMError
6
+ from .base import Provider, redact
7
+
8
+
9
+ class OpenAIProvider(Provider):
10
+ """Talks to the OpenAI Chat Completions API.
11
+
12
+ Requires the ``openai`` package: ``pip install "plotaviz[llm]"``.
13
+ """
14
+
15
+ name = "openai"
16
+ default_model = "gpt-4o-mini"
17
+ available_models = ("gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o4-mini")
18
+
19
+ def complete(self, system: str, user: str, *, timeout: float) -> str:
20
+ """Send one message and return the text of the reply."""
21
+ try:
22
+ import openai
23
+ except ImportError as exc:
24
+ raise LLMError(
25
+ "The OpenAI provider needs the openai package.",
26
+ hint='Install it with: pip install "plotaviz[llm]"',
27
+ ) from exc
28
+
29
+ client = openai.OpenAI(api_key=self.require_key(), timeout=timeout)
30
+ try:
31
+ response = client.chat.completions.create(
32
+ model=self.model,
33
+ messages=[
34
+ {"role": "system", "content": system},
35
+ {"role": "user", "content": user},
36
+ ],
37
+ # Ask for JSON explicitly; the base class still tolerates a chatty reply.
38
+ response_format={"type": "json_object"},
39
+ max_tokens=1024,
40
+ )
41
+ except openai.AuthenticationError as exc:
42
+ raise LLMError(
43
+ "OpenAI rejected the API key.", hint="Check the key in Settings → LLM provider."
44
+ ) from exc
45
+ except openai.RateLimitError as exc:
46
+ raise LLMError(
47
+ "OpenAI is rate limiting this key.", hint="Wait a moment and try again."
48
+ ) from exc
49
+ except openai.APITimeoutError as exc:
50
+ raise LLMError(
51
+ f"OpenAI did not respond within {timeout:.0f} seconds.",
52
+ hint="PlotaViz will use its own rules engine instead.",
53
+ ) from exc
54
+ except openai.APIError as exc:
55
+ raise LLMError("The OpenAI request failed.", hint=redact(str(exc))) from exc
56
+
57
+ choice = response.choices[0] if response.choices else None
58
+ return (choice.message.content or "") if choice else ""
@@ -0,0 +1,366 @@
1
+ """Dataset loading — CSV, TSV, Excel, JSON/NDJSON, and Parquet into a pandas DataFrame.
2
+
3
+ Small files go through pandas because it is convenient and its type inference is good. Files
4
+ above a configurable size threshold go through **polars + pyarrow**, which reads them
5
+ dramatically faster and lets us take a sample without materializing the whole frame. Either way
6
+ the caller gets a pandas DataFrame and a :class:`LoadResult` describing what actually happened,
7
+ including whether it is a sample — the UI has to be able to say "showing 200,000 of 8,400,000
8
+ rows" rather than quietly lying.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import hashlib
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pandas as pd
21
+
22
+ from .errors import LoadError
23
+
24
+ #: Extensions recognised by :func:`load`, grouped by the reader used.
25
+ CSV_SUFFIXES = {".csv", ".tsv", ".txt", ".tab"}
26
+ EXCEL_SUFFIXES = {".xlsx", ".xls", ".xlsm"}
27
+ JSON_SUFFIXES = {".json", ".ndjson", ".jsonl"}
28
+ PARQUET_SUFFIXES = {".parquet", ".pq"}
29
+ SUPPORTED_SUFFIXES = CSV_SUFFIXES | EXCEL_SUFFIXES | JSON_SUFFIXES | PARQUET_SUFFIXES
30
+
31
+ #: Above this size, switch to the polars reader and profile on a sample.
32
+ DEFAULT_LARGE_FILE_MB: float = 500.0
33
+
34
+ #: How many rows to keep when a file is too large to hold comfortably in memory.
35
+ DEFAULT_SAMPLE_ROWS: int = 200_000
36
+
37
+ #: Encodings tried in order when the default UTF-8 read fails.
38
+ _ENCODING_FALLBACKS = ("utf-8-sig", "latin-1", "cp1252")
39
+
40
+
41
+ @dataclass
42
+ class LoadResult:
43
+ """A loaded dataset plus the provenance the rest of the app needs.
44
+
45
+ Attributes:
46
+ df: The data, always as a pandas DataFrame.
47
+ path: Absolute path the data came from.
48
+ engine: ``"pandas"`` or ``"polars"``.
49
+ total_rows: Row count of the *source*, which exceeds ``len(df)`` when sampled.
50
+ sampled: Whether ``df`` is a sample rather than the full dataset.
51
+ file_hash: SHA-256 of the source file, used by sessions to detect that the data changed
52
+ underneath a saved project.
53
+ size_bytes: Source file size.
54
+ notes: Human-readable remarks worth surfacing (fallback encoding used, sheet chosen).
55
+ """
56
+
57
+ df: pd.DataFrame
58
+ path: Path
59
+ engine: str = "pandas"
60
+ total_rows: int = 0
61
+ sampled: bool = False
62
+ file_hash: str = ""
63
+ size_bytes: int = 0
64
+ notes: list[str] = field(default_factory=list)
65
+
66
+ @property
67
+ def rows_loaded(self) -> int:
68
+ """Number of rows actually present in :attr:`df`."""
69
+ return len(self.df)
70
+
71
+ def summary(self) -> str:
72
+ """One-line description for a status bar."""
73
+ cols = len(self.df.columns)
74
+ if self.sampled:
75
+ return (
76
+ f"{self.path.name} — showing a sample of {self.rows_loaded:,} "
77
+ f"of {self.total_rows:,} rows × {cols} columns ({self.engine})"
78
+ )
79
+ return f"{self.path.name} — {self.rows_loaded:,} rows × {cols} columns ({self.engine})"
80
+
81
+
82
+ def file_hash(path: str | Path, *, chunk_size: int = 1 << 20) -> str:
83
+ """Return the SHA-256 hex digest of a file, read in chunks so size does not matter."""
84
+ digest = hashlib.sha256()
85
+ with open(path, "rb") as handle:
86
+ while chunk := handle.read(chunk_size):
87
+ digest.update(chunk)
88
+ return digest.hexdigest()
89
+
90
+
91
+ def sniff_delimiter(path: Path, encoding: str = "utf-8") -> str:
92
+ """Guess a delimited file's separator from its first few KB.
93
+
94
+ Falls back to the extension convention (tab for ``.tsv``/``.tab``, comma otherwise) when
95
+ :mod:`csv`'s sniffer cannot decide, which it frequently cannot for single-column files.
96
+ """
97
+ default = "\t" if path.suffix.lower() in {".tsv", ".tab"} else ","
98
+ try:
99
+ with open(path, encoding=encoding, errors="replace") as handle:
100
+ sample = handle.read(8192)
101
+ except OSError:
102
+ return default
103
+ if not sample.strip():
104
+ return default
105
+ try:
106
+ return csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
107
+ except csv.Error:
108
+ return default
109
+
110
+
111
+ def load(
112
+ path: str | Path,
113
+ *,
114
+ large_file_mb: float = DEFAULT_LARGE_FILE_MB,
115
+ sample_rows: int = DEFAULT_SAMPLE_ROWS,
116
+ sheet: str | int | None = None,
117
+ force_engine: str | None = None,
118
+ ) -> LoadResult:
119
+ """Read a dataset from disk.
120
+
121
+ Args:
122
+ path: File to read. The extension decides the reader.
123
+ large_file_mb: Above this size, use polars and sample. Configurable because "large"
124
+ depends entirely on the machine.
125
+ sample_rows: Rows to keep when sampling a large file.
126
+ sheet: Excel sheet name or index. Defaults to the first sheet.
127
+ force_engine: ``"pandas"`` or ``"polars"`` to override the size heuristic. Mostly for
128
+ tests and for users who know their machine.
129
+
130
+ Returns:
131
+ A :class:`LoadResult`.
132
+
133
+ Raises:
134
+ LoadError: If the file is missing, empty, of an unsupported type, or unreadable.
135
+ """
136
+ path = Path(path).expanduser().resolve()
137
+
138
+ if not path.exists():
139
+ raise LoadError(f"No such file: {path}", hint="Check the path and try again.")
140
+ if path.is_dir():
141
+ raise LoadError(f"{path.name} is a folder, not a data file.")
142
+
143
+ size_bytes = path.stat().st_size
144
+ if size_bytes == 0:
145
+ raise LoadError(f"{path.name} is empty.")
146
+
147
+ suffix = path.suffix.lower()
148
+ if suffix not in SUPPORTED_SUFFIXES:
149
+ raise LoadError(
150
+ f"PlotaViz cannot read {suffix or 'files without an extension'} yet.",
151
+ hint="Supported formats: CSV, TSV, Excel (.xlsx/.xls), JSON, NDJSON, Parquet.",
152
+ )
153
+
154
+ is_large = (size_bytes / (1024 * 1024)) > large_file_mb
155
+ use_polars = force_engine == "polars" or (force_engine is None and is_large)
156
+
157
+ if suffix in EXCEL_SUFFIXES:
158
+ result = _load_excel(path, sheet=sheet)
159
+ elif suffix in PARQUET_SUFFIXES:
160
+ result = _load_parquet(path, use_polars=use_polars, sample_rows=sample_rows)
161
+ elif suffix in JSON_SUFFIXES:
162
+ result = _load_json(path)
163
+ else:
164
+ result = _load_csv(path, use_polars=use_polars, sample_rows=sample_rows)
165
+
166
+ if result.df.empty:
167
+ raise LoadError(
168
+ f"{path.name} parsed to zero rows.",
169
+ hint="The file may have only a header row, or the wrong delimiter was detected.",
170
+ )
171
+ if len(result.df.columns) == 0:
172
+ raise LoadError(f"{path.name} has no columns.")
173
+
174
+ result.path = path
175
+ result.size_bytes = size_bytes
176
+ result.file_hash = file_hash(path)
177
+ if not result.total_rows:
178
+ result.total_rows = len(result.df)
179
+ return result
180
+
181
+
182
+ # ---------------------------------------------------------------------------- readers
183
+
184
+
185
+ def _load_csv(path: Path, *, use_polars: bool, sample_rows: int) -> LoadResult:
186
+ """Read a delimited text file, sniffing the separator and retrying on encoding errors."""
187
+ delimiter = sniff_delimiter(path)
188
+
189
+ if use_polars:
190
+ try:
191
+ import polars as pl
192
+
193
+ lazy = pl.scan_csv(
194
+ path,
195
+ separator=delimiter,
196
+ infer_schema_length=10_000,
197
+ ignore_errors=True,
198
+ try_parse_dates=True,
199
+ )
200
+ total = int(lazy.select(pl.len()).collect().item())
201
+ if total > sample_rows:
202
+ frame = lazy.head(sample_rows).collect()
203
+ return LoadResult(
204
+ df=frame.to_pandas(),
205
+ path=path,
206
+ engine="polars",
207
+ total_rows=total,
208
+ sampled=True,
209
+ notes=[
210
+ f"Large file — loaded the first {sample_rows:,} of {total:,} rows. "
211
+ "Statistics and charts are computed on this sample."
212
+ ],
213
+ )
214
+ return LoadResult(
215
+ df=lazy.collect().to_pandas(), path=path, engine="polars", total_rows=total
216
+ )
217
+ except ImportError:
218
+ pass # polars missing; fall through to pandas
219
+ except Exception as exc:
220
+ raise LoadError(
221
+ f"Could not read {path.name} with the fast reader.",
222
+ hint=f"Underlying error: {exc}",
223
+ ) from exc
224
+
225
+ notes: list[str] = []
226
+ last_error: Exception | None = None
227
+ for encoding in ("utf-8", *_ENCODING_FALLBACKS):
228
+ try:
229
+ df = pd.read_csv(path, sep=delimiter, encoding=encoding, low_memory=False)
230
+ except UnicodeDecodeError as exc:
231
+ last_error = exc
232
+ continue
233
+ except pd.errors.EmptyDataError as exc:
234
+ raise LoadError(f"{path.name} contains no parsable data.") from exc
235
+ except pd.errors.ParserError as exc:
236
+ raise LoadError(
237
+ f"{path.name} is malformed and could not be parsed.",
238
+ hint=f"Detected delimiter {delimiter!r}. Underlying error: {exc}",
239
+ ) from exc
240
+ if encoding != "utf-8":
241
+ notes.append(f"File is not UTF-8; read it as {encoding}.")
242
+ return LoadResult(df=df, path=path, engine="pandas", notes=notes)
243
+
244
+ raise LoadError(
245
+ f"Could not decode {path.name} with any supported encoding.",
246
+ hint=f"Tried UTF-8, {', '.join(_ENCODING_FALLBACKS)}. Underlying error: {last_error}",
247
+ )
248
+
249
+
250
+ def _load_excel(path: Path, *, sheet: str | int | None) -> LoadResult:
251
+ """Read one sheet of an Excel workbook, defaulting to the first."""
252
+ try:
253
+ book = pd.ExcelFile(path)
254
+ except ImportError as exc:
255
+ raise LoadError(
256
+ "Reading Excel files needs the openpyxl package.",
257
+ hint="Install it with: pip install openpyxl",
258
+ ) from exc
259
+ except Exception as exc:
260
+ raise LoadError(f"Could not open {path.name}.", hint=str(exc)) from exc
261
+
262
+ target = sheet if sheet is not None else 0
263
+ try:
264
+ df = pd.read_excel(book, sheet_name=target)
265
+ except Exception as exc:
266
+ raise LoadError(
267
+ f"Could not read sheet {target!r} from {path.name}.",
268
+ hint=f"Sheets in this workbook: {', '.join(map(str, book.sheet_names))}",
269
+ ) from exc
270
+
271
+ notes = []
272
+ if len(book.sheet_names) > 1:
273
+ chosen = book.sheet_names[target] if isinstance(target, int) else target
274
+ notes.append(f"Workbook has {len(book.sheet_names)} sheets; loaded {chosen!r}.")
275
+ return LoadResult(df=df, path=path, engine="pandas", notes=notes)
276
+
277
+
278
+ def _load_json(path: Path) -> LoadResult:
279
+ """Read JSON or newline-delimited JSON, flattening nested objects one level."""
280
+ suffix = path.suffix.lower()
281
+ try:
282
+ if suffix in {".ndjson", ".jsonl"}:
283
+ df = pd.read_json(path, lines=True)
284
+ else:
285
+ with open(path, encoding="utf-8") as handle:
286
+ payload: Any = json.load(handle)
287
+ if isinstance(payload, dict):
288
+ # A single object of arrays, or a wrapper like {"data": [...]}.
289
+ for key in ("data", "records", "rows", "results", "items"):
290
+ if isinstance(payload.get(key), list):
291
+ payload = payload[key]
292
+ break
293
+ df = pd.json_normalize(payload)
294
+ except ValueError as exc:
295
+ raise LoadError(
296
+ f"{path.name} is not valid JSON that maps onto a table.",
297
+ hint=(
298
+ "PlotaViz expects an array of objects, an object of arrays, or NDJSON. "
299
+ f"Underlying error: {exc}"
300
+ ),
301
+ ) from exc
302
+ except OSError as exc:
303
+ raise LoadError(f"Could not read {path.name}.", hint=str(exc)) from exc
304
+
305
+ return LoadResult(df=df, path=path, engine="pandas")
306
+
307
+
308
+ def _load_parquet(path: Path, *, use_polars: bool, sample_rows: int) -> LoadResult:
309
+ """Read a Parquet file, sampling through polars when it is large."""
310
+ if use_polars:
311
+ try:
312
+ import polars as pl
313
+
314
+ lazy = pl.scan_parquet(path)
315
+ total = int(lazy.select(pl.len()).collect().item())
316
+ if total > sample_rows:
317
+ return LoadResult(
318
+ df=lazy.head(sample_rows).collect().to_pandas(),
319
+ path=path,
320
+ engine="polars",
321
+ total_rows=total,
322
+ sampled=True,
323
+ notes=[f"Large file — loaded the first {sample_rows:,} of {total:,} rows."],
324
+ )
325
+ return LoadResult(
326
+ df=lazy.collect().to_pandas(), path=path, engine="polars", total_rows=total
327
+ )
328
+ except ImportError:
329
+ pass
330
+ except Exception as exc:
331
+ raise LoadError(f"Could not read {path.name}.", hint=str(exc)) from exc
332
+
333
+ try:
334
+ df = pd.read_parquet(path)
335
+ except ImportError as exc:
336
+ raise LoadError(
337
+ "Reading Parquet needs pyarrow.",
338
+ hint="Install it with: pip install pyarrow",
339
+ ) from exc
340
+ except Exception as exc:
341
+ raise LoadError(f"Could not read {path.name}.", hint=str(exc)) from exc
342
+
343
+ return LoadResult(df=df, path=path, engine="pandas")
344
+
345
+
346
+ def read_code_for(path: str | Path) -> str:
347
+ """Return the pandas one-liner that reads ``path``, for the generated script.
348
+
349
+ Keeping this next to the readers means generated code stays in step with how PlotaViz
350
+ actually loads a file instead of drifting from it.
351
+ """
352
+ path = Path(path)
353
+ suffix = path.suffix.lower()
354
+ literal = repr(str(path))
355
+ if suffix in EXCEL_SUFFIXES:
356
+ return f"pd.read_excel({literal})"
357
+ if suffix in PARQUET_SUFFIXES:
358
+ return f"pd.read_parquet({literal})"
359
+ if suffix in {".ndjson", ".jsonl"}:
360
+ return f"pd.read_json({literal}, lines=True)"
361
+ if suffix == ".json":
362
+ return f"pd.read_json({literal})"
363
+ delimiter = sniff_delimiter(path) if path.exists() else ","
364
+ if delimiter == ",":
365
+ return f"pd.read_csv({literal})"
366
+ return f"pd.read_csv({literal}, sep={delimiter!r})"