datanyx 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.
- datanyx/__init__.py +117 -0
- datanyx/__main__.py +5 -0
- datanyx/audit.py +194 -0
- datanyx/check/__init__.py +125 -0
- datanyx/check/model.py +179 -0
- datanyx/check/python_check.py +368 -0
- datanyx/check/sql_check.py +328 -0
- datanyx/cli.py +1220 -0
- datanyx/config.py +349 -0
- datanyx/diff.py +239 -0
- datanyx/evaluation.py +223 -0
- datanyx/fileperms.py +29 -0
- datanyx/formats.py +52 -0
- datanyx/inference.py +275 -0
- datanyx/keys.py +231 -0
- datanyx/mcp_server.py +365 -0
- datanyx/privacy.py +77 -0
- datanyx/protect/__init__.py +21 -0
- datanyx/protect/config.py +91 -0
- datanyx/protect/guard.py +345 -0
- datanyx/protect/settings.py +266 -0
- datanyx/pseudonymize/__init__.py +23 -0
- datanyx/pseudonymize/fakes.py +246 -0
- datanyx/pseudonymize/keys.py +41 -0
- datanyx/pseudonymize/plan.py +204 -0
- datanyx/pseudonymize/tables.py +285 -0
- datanyx/renderers/__init__.py +22 -0
- datanyx/renderers/json_schema.py +114 -0
- datanyx/renderers/naming.py +39 -0
- datanyx/renderers/pydantic.py +139 -0
- datanyx/renderers/sql_ddl.py +108 -0
- datanyx/renderers/text.py +80 -0
- datanyx/renderers/typescript.py +90 -0
- datanyx/scale.py +27 -0
- datanyx/scan.py +304 -0
- datanyx/schema.py +118 -0
- datanyx/scrub.py +200 -0
- datanyx/sources/__init__.py +73 -0
- datanyx/sources/avro_source.py +141 -0
- datanyx/sources/base.py +36 -0
- datanyx/sources/catalog.py +43 -0
- datanyx/sources/delimited_source.py +73 -0
- datanyx/sources/directory.py +140 -0
- datanyx/sources/duckdb_source.py +199 -0
- datanyx/sources/excel_source.py +83 -0
- datanyx/sources/fhir_source.py +90 -0
- datanyx/sources/files.py +119 -0
- datanyx/sources/hl7_source.py +109 -0
- datanyx/sources/json_source.py +158 -0
- datanyx/sources/mongodb_source.py +136 -0
- datanyx/sources/orc_source.py +36 -0
- datanyx/sources/parquet_source.py +113 -0
- datanyx/sources/sql_source.py +392 -0
- datanyx/sources/stats_source.py +132 -0
- datanyx/sources/xml_source.py +101 -0
- datanyx/sources/yaml_source.py +53 -0
- datanyx/synth/__init__.py +8 -0
- datanyx/synth/generator.py +328 -0
- datanyx/synth/writers.py +110 -0
- datanyx/text/__init__.py +29 -0
- datanyx/text/anonymizer.py +285 -0
- datanyx/text/backends.py +259 -0
- datanyx/text/batch.py +164 -0
- datanyx/text/custom.py +166 -0
- datanyx/text/detectors.py +263 -0
- datanyx/text/documents.py +464 -0
- datanyx/text/mapping.py +79 -0
- datanyx/text/packs.py +189 -0
- datanyx/text/secrets.py +55 -0
- datanyx-0.1.0.dist-info/METADATA +146 -0
- datanyx-0.1.0.dist-info/RECORD +75 -0
- datanyx-0.1.0.dist-info/WHEEL +5 -0
- datanyx-0.1.0.dist-info/entry_points.txt +2 -0
- datanyx-0.1.0.dist-info/licenses/LICENSE +21 -0
- datanyx-0.1.0.dist-info/top_level.txt +1 -0
datanyx/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""DataNyx: describe data to an AI agent without showing it the data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from .keys import Evidence, apply_evidence
|
|
10
|
+
from .privacy import KeyPolicy
|
|
11
|
+
from .renderers import RENDERERS
|
|
12
|
+
from .schema import DataSchema
|
|
13
|
+
from .sources import Source, SourceError, describe_folder, resolve_source
|
|
14
|
+
from .text import DEFAULT_MODEL
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract(
|
|
20
|
+
target: str,
|
|
21
|
+
source: str | None = None,
|
|
22
|
+
policy: KeyPolicy | None = None,
|
|
23
|
+
namespace: str | None = None,
|
|
24
|
+
recursive: bool = False,
|
|
25
|
+
) -> DataSchema:
|
|
26
|
+
"""Build a value-free schema for a file path, "-" for stdin, a database URL or a folder.
|
|
27
|
+
|
|
28
|
+
`namespace` picks a database schema; the database's default is used otherwise.
|
|
29
|
+
For a folder, every readable file in it is described (and in its subfolders,
|
|
30
|
+
when `recursive` is set); `source` then limits which files are read.
|
|
31
|
+
"""
|
|
32
|
+
policy = policy or KeyPolicy()
|
|
33
|
+
if policy.infer_keys and policy.evidence is None:
|
|
34
|
+
policy = replace(policy, evidence=Evidence())
|
|
35
|
+
if os.path.isdir(target):
|
|
36
|
+
if namespace is not None:
|
|
37
|
+
raise SourceError("a schema can only be selected for SQL databases")
|
|
38
|
+
schema = describe_folder(target, source, policy, recursive)
|
|
39
|
+
else:
|
|
40
|
+
schema = resolve_source(target, source).extract(target, policy, namespace)
|
|
41
|
+
if policy.infer_keys and policy.evidence is not None:
|
|
42
|
+
schema = apply_evidence(schema, policy.evidence)
|
|
43
|
+
if not any(e.foreign_keys or _has_unique(e) for e in schema.entities):
|
|
44
|
+
schema = replace(schema, notes=(*schema.notes, "no unique columns or joins were inferred; this kind of source may not support it, or no key-looking column qualified"))
|
|
45
|
+
if policy.scale_hints and not any(entity.scale for entity in schema.entities):
|
|
46
|
+
schema = replace(schema, notes=(*schema.notes, "no row-count hints: this kind of source can't give one without reading it"))
|
|
47
|
+
return schema
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _has_unique(entity) -> bool:
|
|
51
|
+
node = getattr(entity.node, "items", entity.node)
|
|
52
|
+
return any(field.unique for field in getattr(node, "fields", {}).values())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render(schema: DataSchema, output_format: str = "json-schema") -> str:
|
|
56
|
+
"""Render a schema in one of the formats listed in `RENDERERS`."""
|
|
57
|
+
return RENDERERS[output_format](schema)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def schema_of(
|
|
61
|
+
target: str,
|
|
62
|
+
output_format: str = "json-schema",
|
|
63
|
+
*,
|
|
64
|
+
source: str | None = None,
|
|
65
|
+
map_paths: Iterable[str] = (),
|
|
66
|
+
keep_names: Iterable[str] = (),
|
|
67
|
+
namespace: str | None = None,
|
|
68
|
+
recursive: bool = False,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""The schema of `target` as text, in one call.
|
|
71
|
+
|
|
72
|
+
>>> print(datanyx.schema_of("customers.csv", "text"))
|
|
73
|
+
|
|
74
|
+
Raises `SourceError` with a message that never quotes the data. Use `extract` and
|
|
75
|
+
`render` instead to get the schema as objects, or to see its `notes`.
|
|
76
|
+
"""
|
|
77
|
+
policy = KeyPolicy(map_paths=frozenset(map_paths), keep_names=frozenset(keep_names))
|
|
78
|
+
schema = extract(target, source, policy, namespace, recursive)
|
|
79
|
+
return render(schema, output_format)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def anonymize_text(
|
|
83
|
+
text: str,
|
|
84
|
+
*,
|
|
85
|
+
model: str = DEFAULT_MODEL,
|
|
86
|
+
hide: Iterable[str] = (),
|
|
87
|
+
keep: Iterable[str] = (),
|
|
88
|
+
keep_entity: Iterable[str] = (),
|
|
89
|
+
) -> str:
|
|
90
|
+
"""`text` with names and identifiers replaced by placeholders such as <PERSON_1>.
|
|
91
|
+
|
|
92
|
+
Needs `datanyx[text]` and a spaCy model. Best effort: review the result.
|
|
93
|
+
"""
|
|
94
|
+
from .text import Detector, PatternDetector, SpacyDetector, anonymize
|
|
95
|
+
|
|
96
|
+
detectors: list[Detector] = [PatternDetector(), SpacyDetector(model)]
|
|
97
|
+
return anonymize(text, detectors, hide, keep, keep_entity)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def synthesize(schema: DataSchema, rows: int = 100, seed: int | None = 0):
|
|
101
|
+
"""Fake data matching `schema`, as a list of `datanyx.synth.Table`. See `datanyx.synth`."""
|
|
102
|
+
from .synth import synthesize as generate
|
|
103
|
+
|
|
104
|
+
return generate(schema, rows, seed)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"DataSchema",
|
|
109
|
+
"KeyPolicy",
|
|
110
|
+
"Source",
|
|
111
|
+
"SourceError",
|
|
112
|
+
"anonymize_text",
|
|
113
|
+
"extract",
|
|
114
|
+
"render",
|
|
115
|
+
"schema_of",
|
|
116
|
+
"synthesize",
|
|
117
|
+
]
|
datanyx/__main__.py
ADDED
datanyx/audit.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""A record of what an agent did through the MCP server: which tool, on what, and how it went.
|
|
2
|
+
|
|
3
|
+
One JSON object per line. It never holds a value or anything the agent was given back:
|
|
4
|
+
only the tool's name, the paths and settings it was called with, whether it worked, and
|
|
5
|
+
the sizes involved. Arguments that could carry data are reduced to a length or a count
|
|
6
|
+
(the SQL an agent asked to have checked, the terms it asked to hide), and a password in a
|
|
7
|
+
database URL is replaced.
|
|
8
|
+
|
|
9
|
+
Keep the file where the agent can't change it. `datanyx audit FILE` summarises it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .fileperms import restrict_to_owner
|
|
25
|
+
from .sources.base import SourceError
|
|
26
|
+
|
|
27
|
+
_CREDENTIALS = re.compile(r"(?<=://)[^/\s:@]+:[^@\s]+(?=@)")
|
|
28
|
+
_MAX_TEXT = 300
|
|
29
|
+
# How each tool's arguments are recorded. Anything not listed is reduced to its size.
|
|
30
|
+
_KEEP = "keep"
|
|
31
|
+
_COUNT = "count"
|
|
32
|
+
_LENGTH = "length"
|
|
33
|
+
_RULES: dict[str, dict[str, str]] = {
|
|
34
|
+
"describe_data": {k: _KEEP for k in ("target", "format", "recursive", "map_paths", "database_schema")},
|
|
35
|
+
"synthetic_sample": {k: _KEEP for k in ("target", "rows", "recursive", "map_paths", "database_schema")},
|
|
36
|
+
"check_code": {"code": _LENGTH, **{k: _KEEP for k in ("target", "language", "dialect", "table", "database_schema")}},
|
|
37
|
+
"scan_folder": {"target": _KEEP, "kinds": _KEEP},
|
|
38
|
+
"schema_diff": {"old": _KEEP, "new": _KEEP},
|
|
39
|
+
"anonymize_file": {"target": _KEEP, "hide": _COUNT},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuditLog:
|
|
44
|
+
"""Appends records to a file. With no path it does nothing."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: str | Path | None) -> None:
|
|
47
|
+
self.path = Path(path) if path else None
|
|
48
|
+
self.session = uuid.uuid4().hex[:12]
|
|
49
|
+
if self.path is not None:
|
|
50
|
+
self._prepare()
|
|
51
|
+
|
|
52
|
+
def _prepare(self) -> None:
|
|
53
|
+
assert self.path is not None
|
|
54
|
+
try:
|
|
55
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
created = not self.path.exists()
|
|
57
|
+
with open(self.path, "a", encoding="utf-8"):
|
|
58
|
+
pass
|
|
59
|
+
if created:
|
|
60
|
+
restrict_to_owner(str(self.path))
|
|
61
|
+
except OSError:
|
|
62
|
+
raise SourceError("cannot write the audit log") from None
|
|
63
|
+
|
|
64
|
+
def record(
|
|
65
|
+
self, tool: str, arguments: Mapping[str, Any], outcome: str, message: str | None, started: float, output_chars: int | None
|
|
66
|
+
) -> None:
|
|
67
|
+
if self.path is None:
|
|
68
|
+
return
|
|
69
|
+
entry: dict[str, Any] = {
|
|
70
|
+
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
71
|
+
"session": self.session,
|
|
72
|
+
"tool": tool,
|
|
73
|
+
"arguments": summarize(tool, arguments),
|
|
74
|
+
"outcome": outcome,
|
|
75
|
+
"seconds": round(time.monotonic() - started, 3),
|
|
76
|
+
}
|
|
77
|
+
if message:
|
|
78
|
+
entry["message"] = message[:_MAX_TEXT]
|
|
79
|
+
if output_chars is not None:
|
|
80
|
+
entry["output_chars"] = output_chars
|
|
81
|
+
try:
|
|
82
|
+
with open(self.path, "a", encoding="utf-8") as stream:
|
|
83
|
+
stream.write(json.dumps(entry, ensure_ascii=True) + chr(10))
|
|
84
|
+
except OSError:
|
|
85
|
+
pass # A full disk shouldn't stop the tool the agent asked for.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def summarize(tool: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
|
89
|
+
rules = _RULES.get(tool, {})
|
|
90
|
+
summary: dict[str, Any] = {}
|
|
91
|
+
for name, value in arguments.items():
|
|
92
|
+
mode = rules.get(name)
|
|
93
|
+
if value is None:
|
|
94
|
+
continue
|
|
95
|
+
if mode == _KEEP:
|
|
96
|
+
summary[name] = _clean(value)
|
|
97
|
+
elif mode == _COUNT or (mode is None and isinstance(value, (list, tuple, dict))):
|
|
98
|
+
summary[f"{name}_count"] = len(value)
|
|
99
|
+
else:
|
|
100
|
+
summary[f"{name}_length"] = len(value) if hasattr(value, "__len__") else 1
|
|
101
|
+
return summary
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _clean(value: Any) -> Any:
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return _CREDENTIALS.sub("<CREDENTIALS>", value)[:_MAX_TEXT]
|
|
107
|
+
if isinstance(value, (list, tuple)):
|
|
108
|
+
return [_clean(item) for item in value][:50]
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- reading a log back ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_log(path: str | Path, since: str | None = None) -> tuple[list[dict[str, Any]], int]:
|
|
116
|
+
"""The records in a log, and how many lines couldn't be read."""
|
|
117
|
+
cutoff = _parse_time(since) if since else None
|
|
118
|
+
records: list[dict[str, Any]] = []
|
|
119
|
+
unreadable = 0
|
|
120
|
+
try:
|
|
121
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
122
|
+
except OSError:
|
|
123
|
+
raise SourceError("cannot read the audit log") from None
|
|
124
|
+
for line in lines:
|
|
125
|
+
if not line.strip():
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
entry = json.loads(line)
|
|
129
|
+
when = _parse_time(entry["time"])
|
|
130
|
+
except (ValueError, KeyError, TypeError):
|
|
131
|
+
unreadable += 1
|
|
132
|
+
continue
|
|
133
|
+
if cutoff is None or when >= cutoff:
|
|
134
|
+
records.append(entry)
|
|
135
|
+
return records, unreadable
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_time(text: str) -> datetime:
|
|
139
|
+
try:
|
|
140
|
+
parsed = datetime.fromisoformat(text)
|
|
141
|
+
except ValueError:
|
|
142
|
+
raise SourceError("times look like 2025-01-31 or 2025-01-31T09:30:00") from None
|
|
143
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def summarize_log(records: list[dict[str, Any]], unreadable: int = 0, output_format: str = "text") -> str:
|
|
147
|
+
tools = Counter(entry.get("tool", "?") for entry in records)
|
|
148
|
+
sessions = {entry.get("session") for entry in records}
|
|
149
|
+
targets: Counter[str] = Counter()
|
|
150
|
+
for entry in records:
|
|
151
|
+
arguments = entry.get("arguments", {})
|
|
152
|
+
for key in ("target", "old", "new"):
|
|
153
|
+
if isinstance(arguments.get(key), str):
|
|
154
|
+
targets[arguments[key]] += 1
|
|
155
|
+
failures = Counter(
|
|
156
|
+
(entry.get("tool", "?"), entry.get("message", "")) for entry in records if entry.get("outcome") != "ok"
|
|
157
|
+
)
|
|
158
|
+
times = sorted(entry["time"] for entry in records if "time" in entry)
|
|
159
|
+
|
|
160
|
+
if output_format == "json":
|
|
161
|
+
return json.dumps(
|
|
162
|
+
{
|
|
163
|
+
"calls": len(records),
|
|
164
|
+
"sessions": len(sessions),
|
|
165
|
+
"first": times[0] if times else None,
|
|
166
|
+
"last": times[-1] if times else None,
|
|
167
|
+
"tools": dict(tools),
|
|
168
|
+
"targets": dict(targets),
|
|
169
|
+
"failures": [{"tool": tool, "message": message, "count": count} for (tool, message), count in failures.items()],
|
|
170
|
+
"unreadable_lines": unreadable,
|
|
171
|
+
},
|
|
172
|
+
indent=2,
|
|
173
|
+
)
|
|
174
|
+
if not records:
|
|
175
|
+
return "The audit log has no calls" + (" in that period." if unreadable == 0 else f" ({unreadable} unreadable lines).")
|
|
176
|
+
lines = [
|
|
177
|
+
f"{len(records):,} call{'s' if len(records) != 1 else ''} in {len(sessions)} session{'s' if len(sessions) != 1 else ''}, "
|
|
178
|
+
f"{times[0]} to {times[-1]}",
|
|
179
|
+
"",
|
|
180
|
+
"By tool: " + " · ".join(f"{tool} {count:,}" for tool, count in tools.most_common()),
|
|
181
|
+
]
|
|
182
|
+
if targets:
|
|
183
|
+
lines += ["", "Paths asked about:"]
|
|
184
|
+
lines += [f" {target} ×{count}" for target, count in targets.most_common(15)]
|
|
185
|
+
if failures:
|
|
186
|
+
lines += ["", f"{sum(failures.values())} call{'s' if sum(failures.values()) != 1 else ''} failed or were refused:"]
|
|
187
|
+
lines += [f" {tool}: {message} ×{count}" for (tool, message), count in failures.most_common(10)]
|
|
188
|
+
if unreadable:
|
|
189
|
+
lines += ["", f"note: {unreadable} line{'s' if unreadable != 1 else ''} couldn't be read."]
|
|
190
|
+
return chr(10).join(lines)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
__all__ = ["AuditLog", "read_log", "summarize", "summarize_log"]
|
|
194
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Check code against a schema before it runs: do the tables and columns it uses exist?
|
|
2
|
+
|
|
3
|
+
A schema lists the names that are visible, so this can catch a misspelled column, a
|
|
4
|
+
wrong table, an ambiguous join or a comparison between incompatible types without any
|
|
5
|
+
data being read by the code or by the checker. The code being checked is the agent's own,
|
|
6
|
+
so quoting it back in a message reveals nothing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from ..privacy import KeyPolicy
|
|
17
|
+
from ..sources.base import SourceError
|
|
18
|
+
from .model import Column, Diagnostic, Table, tables_from_schema
|
|
19
|
+
from .python_check import check_python
|
|
20
|
+
from .sql_check import check_sql
|
|
21
|
+
|
|
22
|
+
LANGUAGES = ("sql", "python")
|
|
23
|
+
_EXTENSIONS = {".sql": "sql", ".py": "python"}
|
|
24
|
+
_DIALECTS = {
|
|
25
|
+
"postgres": "postgres",
|
|
26
|
+
"postgresql": "postgres",
|
|
27
|
+
"mysql": "mysql",
|
|
28
|
+
"mariadb": "mysql",
|
|
29
|
+
"mssql": "tsql",
|
|
30
|
+
"oracle": "oracle",
|
|
31
|
+
"sqlite": "sqlite",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_language(path: str) -> str | None:
|
|
36
|
+
return _EXTENSIONS.get(os.path.splitext(path)[1].lower())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_code(
|
|
40
|
+
code: str, tables: Sequence[Table], language: str, dialect: str | None = None
|
|
41
|
+
) -> list[Diagnostic]:
|
|
42
|
+
"""Findings for `code`, in line order. `language` is `sql` or `python`."""
|
|
43
|
+
if language == "sql":
|
|
44
|
+
found = check_sql(code, tables, dialect)
|
|
45
|
+
elif language == "python":
|
|
46
|
+
found = check_python(code, tables, dialect)
|
|
47
|
+
else:
|
|
48
|
+
raise SourceError(f"language must be one of {list(LANGUAGES)}")
|
|
49
|
+
return sorted(found, key=lambda item: (item.line, item.column or 0))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_tables(
|
|
53
|
+
targets: Sequence[str],
|
|
54
|
+
table_names: Sequence[str] = (),
|
|
55
|
+
source: str | None = None,
|
|
56
|
+
policy: KeyPolicy | None = None,
|
|
57
|
+
namespace: str | None = None,
|
|
58
|
+
recursive: bool = False,
|
|
59
|
+
) -> tuple[list[Table], str | None]:
|
|
60
|
+
"""The tables the targets describe, and the SQL dialect they suggest (if they agree).
|
|
61
|
+
|
|
62
|
+
A single file has no table name of its own (the schema leaves file names out), so it
|
|
63
|
+
is called `table_names[i]`, or its file name if that is safe to show, or `data`. Its
|
|
64
|
+
real file name still works when code refers to it.
|
|
65
|
+
"""
|
|
66
|
+
from .. import extract # Imported here: the package imports this module.
|
|
67
|
+
|
|
68
|
+
policy = policy or KeyPolicy()
|
|
69
|
+
tables: list[Table] = []
|
|
70
|
+
dialects: set[str | None] = set()
|
|
71
|
+
for position, target in enumerate(targets):
|
|
72
|
+
schema = extract(target, source, policy, namespace, recursive)
|
|
73
|
+
dialects.add(_dialect_for(target))
|
|
74
|
+
is_file = os.path.isfile(target)
|
|
75
|
+
stem = os.path.splitext(os.path.basename(target))[0] if is_file else ""
|
|
76
|
+
given = table_names[position] if position < len(table_names) else None
|
|
77
|
+
name = given or (stem if stem and policy.shows_name(stem) else None)
|
|
78
|
+
found = tables_from_schema(schema, name)
|
|
79
|
+
if is_file:
|
|
80
|
+
base = os.path.basename(target)
|
|
81
|
+
for table in found:
|
|
82
|
+
table.aliases = table.aliases | {base, stem}
|
|
83
|
+
tables.extend(found)
|
|
84
|
+
return tables, (dialects.pop() if len(dialects) == 1 else None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dialect_for(target: str) -> str | None:
|
|
88
|
+
value = os.environ.get(target[4:], "") if target.startswith("env:") else target
|
|
89
|
+
match = re.match(r"([A-Za-z][A-Za-z0-9+.-]*)://", value)
|
|
90
|
+
if match:
|
|
91
|
+
return _DIALECTS.get(match.group(1).split("+")[0].lower())
|
|
92
|
+
if os.path.splitext(value)[1].lower() in (".db", ".sqlite", ".sqlite3"):
|
|
93
|
+
return "sqlite"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def format_diagnostics(diagnostics: Sequence[Diagnostic], path: str = "<stdin>", output_format: str = "text") -> str:
|
|
98
|
+
"""Findings as text, one per line with an optional hint, or as JSON."""
|
|
99
|
+
if output_format == "json":
|
|
100
|
+
return json.dumps([item.as_dict() for item in diagnostics], indent=2)
|
|
101
|
+
if not diagnostics:
|
|
102
|
+
return "No problems found."
|
|
103
|
+
lines = []
|
|
104
|
+
for item in diagnostics:
|
|
105
|
+
where = f"{path}:{item.line}" + (f":{item.column}" if item.column else "")
|
|
106
|
+
lines.append(f"{where}: {item.severity}: {item.message}")
|
|
107
|
+
if item.hint:
|
|
108
|
+
lines.append(f" hint: {item.hint}")
|
|
109
|
+
errors = sum(item.severity == "error" for item in diagnostics)
|
|
110
|
+
warnings = len(diagnostics) - errors
|
|
111
|
+
lines.append(f"{errors} error{'s' if errors != 1 else ''}, {warnings} warning{'s' if warnings != 1 else ''}")
|
|
112
|
+
return chr(10).join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = [
|
|
116
|
+
"LANGUAGES",
|
|
117
|
+
"Column",
|
|
118
|
+
"Diagnostic",
|
|
119
|
+
"Table",
|
|
120
|
+
"check_code",
|
|
121
|
+
"detect_language",
|
|
122
|
+
"format_diagnostics",
|
|
123
|
+
"load_tables",
|
|
124
|
+
"tables_from_schema",
|
|
125
|
+
]
|
datanyx/check/model.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""What the checkers know about the data: tables, columns and their kinds.
|
|
2
|
+
|
|
3
|
+
Everything here comes from a `DataSchema`, so it holds only names the schema shows.
|
|
4
|
+
That matters for what a check can reveal: a name that is hidden is simply absent, so
|
|
5
|
+
asking about it gives the same answer as asking about a name that doesn't exist. A
|
|
6
|
+
checker can't be used to test guesses about hidden names.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from ..schema import (
|
|
18
|
+
Array,
|
|
19
|
+
Boolean,
|
|
20
|
+
DataSchema,
|
|
21
|
+
Entity,
|
|
22
|
+
Integer,
|
|
23
|
+
Node,
|
|
24
|
+
Null,
|
|
25
|
+
Number,
|
|
26
|
+
Object,
|
|
27
|
+
String,
|
|
28
|
+
Union,
|
|
29
|
+
Unknown,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
NUMERIC = frozenset({"integer", "number"})
|
|
33
|
+
TEXT = frozenset({"string", "uuid"})
|
|
34
|
+
TEMPORAL = frozenset({"date", "datetime", "time"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Diagnostic:
|
|
39
|
+
"""One finding. `error` means the code can't work; `warning` means it probably doesn't."""
|
|
40
|
+
|
|
41
|
+
severity: str
|
|
42
|
+
message: str
|
|
43
|
+
line: int = 1
|
|
44
|
+
column: int | None = None
|
|
45
|
+
hint: str | None = None
|
|
46
|
+
|
|
47
|
+
def as_dict(self) -> dict[str, object]:
|
|
48
|
+
return {
|
|
49
|
+
"severity": self.severity,
|
|
50
|
+
"message": self.message,
|
|
51
|
+
"line": self.line,
|
|
52
|
+
"column": self.column,
|
|
53
|
+
"hint": self.hint,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Column:
|
|
59
|
+
"""`kind` is one of integer, number, boolean, string, uuid, date, datetime, time, binary,
|
|
60
|
+
array, object, mixed or unknown."""
|
|
61
|
+
|
|
62
|
+
kind: str = "unknown"
|
|
63
|
+
nullable: bool = True
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def known(self) -> bool:
|
|
67
|
+
return self.kind not in ("unknown", "mixed")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Table:
|
|
72
|
+
"""A table, or anything table-shaped: a CSV file, a sheet, a list of JSON records."""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
columns: dict[str, Column]
|
|
76
|
+
#: True when some column names are hidden, so a name missing here may still exist.
|
|
77
|
+
open: bool = False
|
|
78
|
+
#: Other names the table answers to, such as a file's name with its extension.
|
|
79
|
+
aliases: frozenset[str] = frozenset()
|
|
80
|
+
#: Where the data came from (`csv`, `json`, `sql`...), which says how a reader will type it.
|
|
81
|
+
source_kind: str = ""
|
|
82
|
+
_lower: dict[str, str] = field(default_factory=dict, repr=False)
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
self._lower = {name.lower(): name for name in self.columns}
|
|
86
|
+
|
|
87
|
+
def column(self, name: str, case_sensitive: bool = False) -> Column | None:
|
|
88
|
+
if name in self.columns:
|
|
89
|
+
return self.columns[name]
|
|
90
|
+
if not case_sensitive and name.lower() in self._lower:
|
|
91
|
+
return self.columns[self._lower[name.lower()]]
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def answers_to(self, name: str) -> bool:
|
|
95
|
+
wanted = name.lower()
|
|
96
|
+
return wanted == self.name.lower() or wanted in {alias.lower() for alias in self.aliases}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tables_from_schema(schema: DataSchema, single_name: str | None = None) -> list[Table]:
|
|
100
|
+
"""One `Table` per entity. An unnamed entity is called `single_name`, or `data`."""
|
|
101
|
+
tables = []
|
|
102
|
+
for entity in schema.entities:
|
|
103
|
+
node = entity.node.items if isinstance(entity.node, Array) else entity.node
|
|
104
|
+
if not isinstance(node, Object):
|
|
105
|
+
continue # A list of plain values has no columns to check.
|
|
106
|
+
name = entity.name or single_name or "data"
|
|
107
|
+
tables.append(
|
|
108
|
+
Table(
|
|
109
|
+
name,
|
|
110
|
+
{column: _column(field.node) for column, field in node.fields.items()},
|
|
111
|
+
node.extra is not None,
|
|
112
|
+
frozenset(_aliases(name)),
|
|
113
|
+
_source_kind(schema.source_kind, name),
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return tables
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_EXTENSION_KINDS = {
|
|
120
|
+
".csv": "csv", ".tsv": "tsv", ".json": "json", ".jsonl": "jsonl", ".ndjson": "jsonl", ".yaml": "yaml", ".yml": "yaml", ".xml": "xml",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _source_kind(kind: str, name: str) -> str:
|
|
125
|
+
"""For a folder, the kind comes from each file's extension."""
|
|
126
|
+
if kind != "directory":
|
|
127
|
+
return kind
|
|
128
|
+
return _EXTENSION_KINDS.get(os.path.splitext(name)[1].lower(), "")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _aliases(name: str) -> set[str]:
|
|
132
|
+
"""`reports/orders.csv` also answers to `orders.csv` and `orders`; `a.db#items` to `items`."""
|
|
133
|
+
last = re.split(r"[/#\\]", name)[-1]
|
|
134
|
+
return {name, last, last.split(".")[0] if "." in last else last}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _column(node: Node) -> Column:
|
|
138
|
+
options = node.options if isinstance(node, Union) else (node,)
|
|
139
|
+
nullable = any(isinstance(option, (Null, Unknown)) for option in options)
|
|
140
|
+
kinds = {_kind(option) for option in options if not isinstance(option, (Null, Unknown))}
|
|
141
|
+
if not kinds:
|
|
142
|
+
return Column("unknown", nullable)
|
|
143
|
+
if len(kinds) == 1:
|
|
144
|
+
return Column(next(iter(kinds)), nullable)
|
|
145
|
+
return Column("number" if kinds <= NUMERIC else "mixed", nullable)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _kind(node: Node) -> str:
|
|
149
|
+
if isinstance(node, Integer):
|
|
150
|
+
return "integer"
|
|
151
|
+
if isinstance(node, Number):
|
|
152
|
+
return "number"
|
|
153
|
+
if isinstance(node, Boolean):
|
|
154
|
+
return "boolean"
|
|
155
|
+
if isinstance(node, String):
|
|
156
|
+
return {"date": "date", "date-time": "datetime", "time": "time", "uuid": "uuid", "binary": "binary"}.get(
|
|
157
|
+
node.format or "", "string"
|
|
158
|
+
)
|
|
159
|
+
if isinstance(node, Array):
|
|
160
|
+
return "array"
|
|
161
|
+
if isinstance(node, Object):
|
|
162
|
+
return "object"
|
|
163
|
+
return "unknown"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def suggest(name: str, candidates: Iterable[str], case_sensitive: bool = False) -> str | None:
|
|
167
|
+
"""A hint for a name that wasn't found: the same name in other case, or a close one."""
|
|
168
|
+
options = list(candidates)
|
|
169
|
+
for option in options:
|
|
170
|
+
if option.lower() == name.lower() and option != name:
|
|
171
|
+
return f"did you mean {option!r}? Names are case-sensitive here" if case_sensitive else f"did you mean {option!r}?"
|
|
172
|
+
close = difflib.get_close_matches(name, options, n=3, cutoff=0.6)
|
|
173
|
+
if not close:
|
|
174
|
+
return None
|
|
175
|
+
return "did you mean " + " or ".join(repr(match) for match in close) + "?"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def describe(entity: Entity) -> str:
|
|
179
|
+
return entity.name or "data"
|