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.
Files changed (75) hide show
  1. datanyx/__init__.py +117 -0
  2. datanyx/__main__.py +5 -0
  3. datanyx/audit.py +194 -0
  4. datanyx/check/__init__.py +125 -0
  5. datanyx/check/model.py +179 -0
  6. datanyx/check/python_check.py +368 -0
  7. datanyx/check/sql_check.py +328 -0
  8. datanyx/cli.py +1220 -0
  9. datanyx/config.py +349 -0
  10. datanyx/diff.py +239 -0
  11. datanyx/evaluation.py +223 -0
  12. datanyx/fileperms.py +29 -0
  13. datanyx/formats.py +52 -0
  14. datanyx/inference.py +275 -0
  15. datanyx/keys.py +231 -0
  16. datanyx/mcp_server.py +365 -0
  17. datanyx/privacy.py +77 -0
  18. datanyx/protect/__init__.py +21 -0
  19. datanyx/protect/config.py +91 -0
  20. datanyx/protect/guard.py +345 -0
  21. datanyx/protect/settings.py +266 -0
  22. datanyx/pseudonymize/__init__.py +23 -0
  23. datanyx/pseudonymize/fakes.py +246 -0
  24. datanyx/pseudonymize/keys.py +41 -0
  25. datanyx/pseudonymize/plan.py +204 -0
  26. datanyx/pseudonymize/tables.py +285 -0
  27. datanyx/renderers/__init__.py +22 -0
  28. datanyx/renderers/json_schema.py +114 -0
  29. datanyx/renderers/naming.py +39 -0
  30. datanyx/renderers/pydantic.py +139 -0
  31. datanyx/renderers/sql_ddl.py +108 -0
  32. datanyx/renderers/text.py +80 -0
  33. datanyx/renderers/typescript.py +90 -0
  34. datanyx/scale.py +27 -0
  35. datanyx/scan.py +304 -0
  36. datanyx/schema.py +118 -0
  37. datanyx/scrub.py +200 -0
  38. datanyx/sources/__init__.py +73 -0
  39. datanyx/sources/avro_source.py +141 -0
  40. datanyx/sources/base.py +36 -0
  41. datanyx/sources/catalog.py +43 -0
  42. datanyx/sources/delimited_source.py +73 -0
  43. datanyx/sources/directory.py +140 -0
  44. datanyx/sources/duckdb_source.py +199 -0
  45. datanyx/sources/excel_source.py +83 -0
  46. datanyx/sources/fhir_source.py +90 -0
  47. datanyx/sources/files.py +119 -0
  48. datanyx/sources/hl7_source.py +109 -0
  49. datanyx/sources/json_source.py +158 -0
  50. datanyx/sources/mongodb_source.py +136 -0
  51. datanyx/sources/orc_source.py +36 -0
  52. datanyx/sources/parquet_source.py +113 -0
  53. datanyx/sources/sql_source.py +392 -0
  54. datanyx/sources/stats_source.py +132 -0
  55. datanyx/sources/xml_source.py +101 -0
  56. datanyx/sources/yaml_source.py +53 -0
  57. datanyx/synth/__init__.py +8 -0
  58. datanyx/synth/generator.py +328 -0
  59. datanyx/synth/writers.py +110 -0
  60. datanyx/text/__init__.py +29 -0
  61. datanyx/text/anonymizer.py +285 -0
  62. datanyx/text/backends.py +259 -0
  63. datanyx/text/batch.py +164 -0
  64. datanyx/text/custom.py +166 -0
  65. datanyx/text/detectors.py +263 -0
  66. datanyx/text/documents.py +464 -0
  67. datanyx/text/mapping.py +79 -0
  68. datanyx/text/packs.py +189 -0
  69. datanyx/text/secrets.py +55 -0
  70. datanyx-0.1.0.dist-info/METADATA +146 -0
  71. datanyx-0.1.0.dist-info/RECORD +75 -0
  72. datanyx-0.1.0.dist-info/WHEEL +5 -0
  73. datanyx-0.1.0.dist-info/entry_points.txt +2 -0
  74. datanyx-0.1.0.dist-info/licenses/LICENSE +21 -0
  75. 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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"