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/exporting.py ADDED
@@ -0,0 +1,105 @@
1
+ """Write self-contained functions while preserving user-authored definitions."""
2
+
3
+ import ast
4
+ import keyword
5
+ import os
6
+ import symtable
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ _GENERATED_MARKER = "# Generated by Augplot. Review before running on new data."
11
+
12
+
13
+ def write_python_function(
14
+ code: str, path: str | Path, function_name: str, *, backend="auto"
15
+ ) -> Path:
16
+ if (
17
+ not function_name.isidentifier()
18
+ or keyword.iskeyword(function_name)
19
+ or function_name.startswith("_")
20
+ ):
21
+ raise ValueError("function_name must be a public Python identifier.")
22
+ target = Path(path)
23
+ if target.suffix != ".py":
24
+ raise ValueError("Python output path must end in .py.")
25
+ if target.is_symlink():
26
+ raise ValueError("Refusing to replace a symlink; use a regular Python file.")
27
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
28
+ try:
29
+ existing_tree = ast.parse(existing)
30
+ symbols = symtable.symtable(existing, str(target), "exec")
31
+ except SyntaxError:
32
+ raise ValueError(
33
+ "Existing Python module is not valid Python; it was not changed."
34
+ ) from None
35
+ replacement = None
36
+ if function_name in symbols.get_identifiers():
37
+ for node in existing_tree.body:
38
+ is_matching_function = isinstance(
39
+ node, (ast.FunctionDef, ast.AsyncFunctionDef)
40
+ ) and node.name == function_name
41
+ if is_matching_function:
42
+ marker_index = node.lineno - 2
43
+ lines = existing.splitlines(keepends=True)
44
+ if marker_index >= 0 and lines[marker_index].rstrip("\r\n") == _GENERATED_MARKER:
45
+ replacement = (marker_index, node.end_lineno)
46
+ break
47
+ if replacement is None:
48
+ raise ValueError(
49
+ f"Name {function_name!r} already exists and was not generated by Augplot; "
50
+ "choose a different function_name."
51
+ )
52
+ tree = ast.parse(code)
53
+ tree.body[0].name = function_name
54
+ # Reproduce the runtime style contexts in standalone source, without Augplot.
55
+ preamble = "import matplotlib as augplot_mpl\n"
56
+ context = "augplot_mpl.rc_context()"
57
+ if backend in ("auto", "seaborn"):
58
+ preamble += "import seaborn as augplot_sns\n"
59
+ context += (
60
+ ", augplot_sns.axes_style('whitegrid'), augplot_sns.plotting_context('notebook')"
61
+ )
62
+ wrapper = ast.parse(preamble + f"with {context}:\n pass\n").body
63
+ wrapper[-1].body = tree.body[0].body
64
+ tree.body[0].body = wrapper
65
+ ast.fix_missing_locations(tree)
66
+ source = ast.unparse(tree) + "\n"
67
+ generated = _GENERATED_MARKER + "\n" + source
68
+ if replacement is None:
69
+ content = existing.rstrip() + "\n\n\n" if existing.strip() else ""
70
+ content += generated
71
+ else:
72
+ lines = existing.splitlines(keepends=True)
73
+ start, end = replacement
74
+ content = "".join(lines[:start]) + generated + "".join(lines[end:])
75
+ # Verify the entire module before touching the destination.
76
+ compile(content, str(target), "exec")
77
+ temporary = None
78
+ try:
79
+ with tempfile.NamedTemporaryFile(
80
+ mode="w",
81
+ encoding="utf-8",
82
+ dir=target.parent,
83
+ prefix=".augplot-",
84
+ suffix=".tmp",
85
+ delete=False,
86
+ ) as handle:
87
+ temporary = Path(handle.name)
88
+ handle.write(content)
89
+ handle.flush()
90
+ os.fsync(handle.fileno())
91
+ if target.exists():
92
+ temporary.chmod(target.stat().st_mode & 0o777)
93
+ os.replace(temporary, target)
94
+ finally:
95
+ if temporary is not None:
96
+ temporary.unlink(missing_ok=True)
97
+ if target.stem.isidentifier() and not keyword.iskeyword(target.stem):
98
+ print(
99
+ f"# With {str(target.parent)!r} on your Python import path:\n"
100
+ f"from {target.stem} import {function_name}\n"
101
+ f"fig = {function_name}(your_data)\nfig"
102
+ )
103
+ else:
104
+ print(f"Saved {function_name} to {target}. Use a valid module filename for direct imports.")
105
+ return target
augplot/history.py ADDED
@@ -0,0 +1,193 @@
1
+ """Persistent, versioned source history. Never deserialize executable Python objects."""
2
+
3
+ import datetime as dt
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import struct
8
+ import tempfile
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from .errors import ConfigurationError, DataError
16
+ from .execution import API_MANIFEST_VERSION
17
+
18
+ HISTORY_VERSION = 1
19
+
20
+
21
+ def fingerprint(data):
22
+ """Hash all values, ordering, and plotting-relevant schema, not a sampled profile."""
23
+ digest = hashlib.sha256()
24
+
25
+ def token(value):
26
+ payload = value if isinstance(value, bytes) else str(value).encode("utf-8")
27
+ digest.update(len(payload).to_bytes(8, "big"))
28
+ digest.update(payload)
29
+
30
+ def dtype(value):
31
+ token(str(value))
32
+ if isinstance(value, pd.CategoricalDtype):
33
+ visit(value.categories)
34
+ visit(value.ordered)
35
+
36
+ def visit(value):
37
+ token(f"{type(value).__module__}.{type(value).__qualname__}")
38
+ if value is None or value is pd.NA or value is pd.NaT:
39
+ return
40
+ if isinstance(value, pd.DataFrame):
41
+ visit(value.index)
42
+ visit(value.columns)
43
+ for i in range(len(value.columns)):
44
+ visit(value.iloc[:, i])
45
+ elif isinstance(value, (pd.Series, pd.Index)):
46
+ if isinstance(value, pd.Series):
47
+ visit(value.index)
48
+ visit(value.name)
49
+ else:
50
+ visit(list(value.names))
51
+ token(str(getattr(value, "freqstr", None)))
52
+ if isinstance(value, pd.MultiIndex):
53
+ for level in value.levels:
54
+ visit(level)
55
+ dtype(value.dtype)
56
+ token(len(value))
57
+ for item in value:
58
+ visit(item)
59
+ elif isinstance(value, np.ndarray):
60
+ token(value.dtype.str)
61
+ token(repr(value.dtype.descr))
62
+ visit(value.shape)
63
+ if value.dtype.hasobject:
64
+ for item in value.flat:
65
+ visit(item)
66
+ else:
67
+ token(value.tobytes(order="C"))
68
+ elif isinstance(value, np.generic):
69
+ token(value.dtype.str)
70
+ token(repr(value.dtype.descr))
71
+ if value.dtype.hasobject:
72
+ visit(value.tolist())
73
+ else:
74
+ token(value.tobytes())
75
+ elif isinstance(value, dict):
76
+ token(len(value))
77
+ for key, item in value.items():
78
+ visit(key)
79
+ visit(item)
80
+ elif isinstance(value, (tuple, list)):
81
+ token(len(value))
82
+ for item in value:
83
+ visit(item)
84
+ elif isinstance(value, (str, bool, int)):
85
+ token(value)
86
+ elif isinstance(value, float):
87
+ token(struct.pack("!d", value))
88
+ elif isinstance(value, complex):
89
+ token(struct.pack("!dd", value.real, value.imag))
90
+ elif isinstance(value, pd.Period):
91
+ token(value.ordinal)
92
+ token(value.freqstr)
93
+ elif isinstance(value, pd.Interval):
94
+ visit(value.left)
95
+ visit(value.right)
96
+ token(value.closed)
97
+ elif isinstance(value, dt.date):
98
+ token(value.isoformat())
99
+ token(getattr(value, "fold", 0))
100
+ token(str(getattr(value, "tzinfo", None)))
101
+ elif isinstance(value, dt.timedelta):
102
+ token(str(value))
103
+ else:
104
+ raise DataError("Cannot fingerprint this input; convert it to ordinary data first.")
105
+
106
+ visit(data)
107
+ return digest.hexdigest()
108
+
109
+
110
+ def request_key(settings):
111
+ payload = json.dumps(settings, sort_keys=True, ensure_ascii=True, allow_nan=False)
112
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
113
+
114
+
115
+ class History:
116
+ def __init__(self, directory):
117
+ self.directory = Path(directory).expanduser().resolve()
118
+
119
+ def load(self, root, key):
120
+ entry = self.directory / root / f"{key}.json"
121
+ if not entry.exists():
122
+ return None
123
+ try:
124
+ record = json.loads(entry.read_text(encoding="utf-8"))
125
+ if (
126
+ record["history_version"] != HISTORY_VERSION
127
+ or record["api_manifest_version"] != API_MANIFEST_VERSION
128
+ or record["request_key"] != key
129
+ or record["root"] != root
130
+ or not isinstance(record["explanation"], str)
131
+ or not isinstance(record["depth"], int)
132
+ or record["depth"] < 0
133
+ or not isinstance(record["revision"], str)
134
+ ):
135
+ raise ValueError("Invalid history record")
136
+ filename = record["filename"]
137
+ if not isinstance(filename, str) or Path(filename).name != filename:
138
+ raise ValueError("Invalid source path")
139
+ source = entry.parent / filename
140
+ code = source.read_text(encoding="utf-8")
141
+ if hashlib.sha256(code.encode("utf-8")).hexdigest() != record["code_hash"]:
142
+ raise ValueError("Source checksum mismatch")
143
+ return record, code, source
144
+ except (OSError, ValueError, KeyError, TypeError):
145
+ raise ConfigurationError(
146
+ "Saved visualization history is unreadable or changed. Restore it or "
147
+ "pass regenerate=True to explicitly generate a new version."
148
+ ) from None
149
+
150
+ def save(self, root, key, *, code, explanation, depth, parent, data_fingerprint):
151
+ directory = self.directory / root
152
+ revision = uuid.uuid4().hex
153
+ label = "initial" if depth == 0 else f"refined_{depth}"
154
+ filename = f"{label}_{revision}.py"
155
+ record = {
156
+ "history_version": HISTORY_VERSION,
157
+ "api_manifest_version": API_MANIFEST_VERSION,
158
+ "root": root,
159
+ "request_key": key,
160
+ "revision": revision,
161
+ "parent": parent,
162
+ "data_fingerprint": data_fingerprint,
163
+ "depth": depth,
164
+ "filename": filename,
165
+ "code_hash": hashlib.sha256(code.encode("utf-8")).hexdigest(),
166
+ "explanation": explanation,
167
+ }
168
+ temporary = None
169
+ try:
170
+ directory.mkdir(parents=True, exist_ok=True)
171
+ source = directory / filename
172
+ # Immutable source and metadata preserve earlier explicitly regenerated versions.
173
+ source.write_text(code, encoding="utf-8")
174
+ serialized = json.dumps(record, indent=2) + "\n"
175
+ (directory / f"{label}_{revision}.json").write_text(serialized, encoding="utf-8")
176
+ with tempfile.NamedTemporaryFile(
177
+ mode="w", encoding="utf-8", dir=directory, delete=False
178
+ ) as handle:
179
+ temporary = Path(handle.name)
180
+ handle.write(serialized)
181
+ handle.flush()
182
+ os.fsync(handle.fileno())
183
+ # Publish the lookup only after the complete source has been written.
184
+ os.replace(temporary, directory / f"{key}.json")
185
+ except OSError:
186
+ raise ConfigurationError(
187
+ "Could not save visualization history. Choose a writable cache_dir "
188
+ "or use cache_dir=None to disable persistence."
189
+ ) from None
190
+ finally:
191
+ if temporary is not None:
192
+ temporary.unlink(missing_ok=True)
193
+ return record, code, source
augplot/profiling.py ADDED
@@ -0,0 +1,273 @@
1
+ """Bounded, JSON-safe profiles of ordinary in-memory data science objects."""
2
+
3
+ import copy
4
+ import datetime as dt
5
+ import json
6
+ import math
7
+ from itertools import islice
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .errors import DataError
13
+
14
+ _SCALARS = (str, bool, int, float, dt.date, dt.timedelta, np.generic)
15
+
16
+
17
+ def validate_data(data):
18
+ """Reject cycles/custom objects before copying or inspecting their representations."""
19
+ if not isinstance(data, (dict, list, tuple, np.ndarray, pd.DataFrame, pd.Series)):
20
+ raise DataError("Expected a dictionary, record list, NumPy array, DataFrame, or Series.")
21
+ if isinstance(data, np.ndarray) and data.ndim not in (1, 2):
22
+ raise DataError("Only one- and two-dimensional NumPy arrays are supported.")
23
+ if len(data) == 0 or isinstance(data, pd.DataFrame) and data.empty:
24
+ raise DataError("Cannot visualize empty data.")
25
+
26
+ def visit(value, ancestors, depth):
27
+ if depth > 32:
28
+ raise DataError("Input nesting exceeds 32 levels.")
29
+ if value is None or value is pd.NA or value is pd.NaT:
30
+ return
31
+ if isinstance(value, _SCALARS):
32
+ return
33
+ identity = id(value)
34
+ if identity in ancestors:
35
+ raise DataError("Cyclic input containers are not supported.")
36
+ chain = ancestors | {identity}
37
+ if isinstance(value, pd.DataFrame):
38
+ for column in value.columns:
39
+ visit(column, chain, depth + 1)
40
+ visit(value.index.tolist(), chain, depth + 1)
41
+ for index in range(len(value.columns)):
42
+ visit(value.iloc[:, index], chain, depth + 1)
43
+ elif isinstance(value, pd.Series):
44
+ visit(value.name, chain, depth + 1)
45
+ visit(value.index.tolist(), chain, depth + 1)
46
+ if value.dtype == object or isinstance(value.dtype, pd.CategoricalDtype):
47
+ for item in value:
48
+ visit(item, chain, depth + 1)
49
+ elif isinstance(value, np.ndarray):
50
+ if value.ndim not in (1, 2):
51
+ raise DataError("Only one- and two-dimensional NumPy arrays are supported.")
52
+ if value.dtype.hasobject:
53
+ for item in value.flat:
54
+ visit(item, chain, depth + 1)
55
+ elif isinstance(value, dict):
56
+ for key, item in value.items():
57
+ if not isinstance(key, (str, int, float, bool, tuple, np.generic)):
58
+ raise DataError("Dictionary keys must be scalar values or tuples.")
59
+ visit(key, chain, depth + 1)
60
+ visit(item, chain, depth + 1)
61
+ elif isinstance(value, (list, tuple)):
62
+ for item in value:
63
+ visit(item, chain, depth + 1)
64
+ else:
65
+ raise DataError(
66
+ "Input contains an unsupported object; convert it to ordinary data first."
67
+ )
68
+
69
+ visit(data, set(), 0)
70
+
71
+
72
+ def copy_data(data):
73
+ """Copy nested values too: Pandas' deep copy leaves object-dtype cells shared."""
74
+ if isinstance(data, pd.DataFrame):
75
+ result = data.copy(deep=True)
76
+ for index in range(len(data.columns)):
77
+ column = data.iloc[:, index]
78
+ if column.dtype == object:
79
+ result.isetitem(index, column.map(copy_data))
80
+ return result
81
+ if isinstance(data, pd.Series):
82
+ result = data.copy(deep=True)
83
+ if data.dtype == object:
84
+ for index in range(len(data)):
85
+ result.iloc[index] = copy_data(data.iloc[index])
86
+ return result
87
+ if isinstance(data, dict):
88
+ return {key: copy_data(value) for key, value in data.items()}
89
+ if isinstance(data, list):
90
+ return [copy_data(value) for value in data]
91
+ if isinstance(data, tuple):
92
+ return tuple(copy_data(value) for value in data)
93
+ if isinstance(data, np.ndarray) and data.dtype.hasobject:
94
+ result = data.copy()
95
+ for index in range(data.size):
96
+ result.flat[index] = copy_data(data.flat[index])
97
+ return result
98
+ return copy.deepcopy(data)
99
+
100
+
101
+ def _indices(length, count):
102
+ return np.linspace(0, length - 1, min(length, count), dtype=int).tolist() if length else []
103
+
104
+
105
+ def _value(value, depth=0):
106
+ if value is None or value is pd.NA or value is pd.NaT:
107
+ return None
108
+ if isinstance(value, np.generic):
109
+ if isinstance(value, (np.datetime64, np.timedelta64)):
110
+ return str(value)
111
+ if isinstance(value, np.floating):
112
+ return _value(float(value), depth)
113
+ return _value(value.item(), depth)
114
+ if isinstance(value, float):
115
+ return value if math.isfinite(value) else None
116
+ if isinstance(value, (bool, int)):
117
+ return value
118
+ if isinstance(value, str):
119
+ return value if len(value) <= 200 else value[:200] + "…[truncated]"
120
+ if isinstance(value, (dt.date, dt.timedelta)):
121
+ return str(value)
122
+ if depth >= 4:
123
+ return {"type": type(value).__name__, "truncated": True}
124
+ if isinstance(value, dict):
125
+ return {
126
+ "items": [
127
+ [_value(k, depth + 1), _value(v, depth + 1)] for k, v in islice(value.items(), 5)
128
+ ],
129
+ "length": len(value),
130
+ }
131
+ if isinstance(value, (list, tuple, np.ndarray)):
132
+ return [_value(value[i], depth + 1) for i in _indices(len(value), 5)]
133
+ return {"type": type(value).__name__}
134
+
135
+
136
+ def _stats(values):
137
+ """Bound statistical work to 1,000 evenly spaced observations; label the sample."""
138
+ if not len(values):
139
+ return {}
140
+ sample = pd.Series(values).iloc[_indices(len(values), 1000)]
141
+ result = {
142
+ "observations": len(sample),
143
+ "sampled": len(sample) < len(values),
144
+ "missing": int(sample.isna().sum()),
145
+ }
146
+ if pd.api.types.is_numeric_dtype(sample.dtype) and not pd.api.types.is_complex_dtype(
147
+ sample.dtype
148
+ ):
149
+ numeric = sample.to_numpy(dtype=float, na_value=np.nan)
150
+ finite = numeric[np.isfinite(numeric)]
151
+ if len(finite):
152
+ # Scale first to avoid overflow while averaging large, finite values.
153
+ scale = float(np.abs(finite).max()) or 1.0
154
+ normalized = finite / scale
155
+ result.update(
156
+ min=float(finite.min()),
157
+ max=float(finite.max()),
158
+ mean=float(normalized.mean()) * scale,
159
+ )
160
+ if len(finite) > 1:
161
+ result["std"] = _value(float(normalized.std(ddof=1)) * scale)
162
+ return result
163
+
164
+
165
+ def profile_data(data, *, sample_rows=5, max_chars=20_000):
166
+ """Return a profile capped by serialized character count, never a sliced JSON string."""
167
+ if not isinstance(sample_rows, int) or not 0 <= sample_rows <= 100:
168
+ raise ValueError("sample_rows must be an integer between 0 and 100.")
169
+ if not isinstance(max_chars, int) or not 500 <= max_chars <= 100_000:
170
+ raise ValueError("max_chars must be between 500 and 100,000.")
171
+ validate_data(data)
172
+ node_count = 0
173
+
174
+ def describe(value, depth=0):
175
+ nonlocal node_count
176
+ node_count += 1
177
+ result = {"type": type(value).__name__}
178
+ if depth >= 6 or node_count > 300:
179
+ return {**result, "truncated": True}
180
+ if isinstance(value, pd.DataFrame):
181
+ result.update(
182
+ shape=list(value.shape),
183
+ index_type=type(value.index).__name__,
184
+ index_names=[_value(name) for name in value.index.names],
185
+ )
186
+ result["columns"] = [
187
+ {
188
+ "name": _value(value.columns[i]),
189
+ "dtype": str(value.iloc[:, i].dtype),
190
+ "stats": _stats(value.iloc[:, i]),
191
+ }
192
+ for i in range(min(len(value.columns), 100))
193
+ ]
194
+ result["sample_rows"] = [
195
+ {
196
+ "index": _value(value.index[i]),
197
+ "values": [_value(x) for x in value.iloc[i, :100]],
198
+ }
199
+ for i in _indices(len(value), sample_rows)
200
+ ]
201
+ if len(value.columns) > 100:
202
+ result["truncated"] = True
203
+ elif isinstance(value, pd.Series):
204
+ result.update(
205
+ length=len(value),
206
+ name=_value(value.name),
207
+ dtype=str(value.dtype),
208
+ stats=_stats(value),
209
+ sample=[
210
+ {"index": _value(value.index[i]), "value": _value(value.iloc[i])}
211
+ for i in _indices(len(value), sample_rows)
212
+ ],
213
+ )
214
+ elif isinstance(value, np.ndarray):
215
+ result.update(
216
+ shape=list(value.shape),
217
+ dtype=str(value.dtype),
218
+ sample=[_value(value[i]) for i in _indices(len(value), sample_rows)],
219
+ )
220
+ if value.ndim == 1:
221
+ result["stats"] = _stats(value)
222
+ elif isinstance(value, dict):
223
+ result.update(
224
+ length=len(value),
225
+ items=[
226
+ {"key": _value(key), "value": describe(item, depth + 1)}
227
+ for key, item in islice(value.items(), 100)
228
+ ],
229
+ )
230
+ if len(value) > 100:
231
+ result["truncated"] = True
232
+ elif isinstance(value, (list, tuple)):
233
+ result.update(
234
+ length=len(value),
235
+ sample=[
236
+ {"position": i, "value": describe(value[i], depth + 1)}
237
+ for i in _indices(len(value), sample_rows)
238
+ ],
239
+ )
240
+ if value and all(isinstance(v, (int, float, np.number)) for v in value):
241
+ result["stats"] = _stats(value)
242
+ else:
243
+ result["value"] = _value(value)
244
+ return result
245
+
246
+ profile = {"profile_version": 1, "data": describe(data)}
247
+
248
+ def shrink(node):
249
+ candidates = []
250
+ if isinstance(node, dict):
251
+ for key, value in node.items():
252
+ if isinstance(value, list) and value:
253
+ candidates.append((len(json.dumps(value, default=str)), node, key))
254
+ candidates.extend(shrink(value))
255
+ elif isinstance(node, list):
256
+ for value in node:
257
+ candidates.extend(shrink(value))
258
+ return candidates
259
+
260
+ while len(json.dumps(profile, ensure_ascii=True)) > max_chars:
261
+ candidates = shrink(profile)
262
+ profile["truncated"] = True
263
+ if not candidates:
264
+ profile = {
265
+ "profile_version": 1,
266
+ "data": {"type": type(data).__name__},
267
+ "truncated": True,
268
+ }
269
+ break
270
+ _, parent, key = max(candidates, key=lambda candidate: candidate[0])
271
+ parent[key] = parent[key][: len(parent[key]) // 2]
272
+ parent["truncated"] = True
273
+ return profile