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 +25 -0
- augplot/core.py +307 -0
- augplot/datasets.py +51 -0
- augplot/errors.py +45 -0
- augplot/execution.py +1285 -0
- augplot/exporting.py +105 -0
- augplot/history.py +193 -0
- augplot/profiling.py +273 -0
- augplot/prompts.py +208 -0
- augplot/provider.py +57 -0
- augplot-0.1.0.dist-info/METADATA +157 -0
- augplot-0.1.0.dist-info/RECORD +15 -0
- augplot-0.1.0.dist-info/WHEEL +4 -0
- augplot-0.1.0.dist-info/licenses/LICENSE +201 -0
- augplot-0.1.0.dist-info/licenses/NOTICE +3 -0
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
|