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
|
@@ -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 ""
|
plotaviz/core/loader.py
ADDED
|
@@ -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})"
|