vilopa 0.0.1__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.
mita/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mita: execute and verify data-erasure and access requests."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("vilopa")
mita/cli.py ADDED
@@ -0,0 +1,220 @@
1
+ """mita command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Sequence
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+
12
+ import psycopg
13
+ import typer
14
+
15
+ from mita import __version__
16
+ from mita.connectors import postgres
17
+ from mita.core import datamap
18
+ from mita.core.detect import ColumnReport, Confidence
19
+ from mita.core.lint import LintResult, lint
20
+
21
+ app = typer.Typer(
22
+ no_args_is_help=True,
23
+ help="Execute and verify data-erasure and access requests.",
24
+ )
25
+ datamap_app = typer.Typer(no_args_is_help=True, help="Validate and inspect data maps.")
26
+ app.add_typer(datamap_app, name="datamap")
27
+
28
+
29
+ def _print_version(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"mita {__version__}")
32
+ raise typer.Exit
33
+
34
+
35
+ @app.callback()
36
+ def main(
37
+ version: Annotated[
38
+ bool,
39
+ typer.Option("--version", callback=_print_version, is_eager=True, help="Show version."),
40
+ ] = False,
41
+ ) -> None:
42
+ """mita: delete a person's data from every system you mapped, and prove it."""
43
+
44
+
45
+ @datamap_app.command("validate")
46
+ def validate(
47
+ path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
48
+ ) -> None:
49
+ """Check a data map for structural and cross-reference problems."""
50
+ try:
51
+ dm = datamap.load(path)
52
+ except datamap.DataMapError as exc:
53
+ for problem in exc.problems:
54
+ typer.echo(f"{path}: {problem}", err=True)
55
+ typer.echo(f"{path}: {len(exc.problems)} problem(s)", err=True)
56
+ raise typer.Exit(1) from exc
57
+ typer.echo(f"{path}: ok ({len(dm.tables)} tables, {len(dm.connectors)} connector(s))")
58
+
59
+
60
+ @datamap_app.command("schema")
61
+ def schema() -> None:
62
+ """Print the data map JSON Schema."""
63
+ typer.echo(json.dumps(datamap.json_schema(), indent=2))
64
+
65
+
66
+ class Threshold(StrEnum):
67
+ LOW = "low"
68
+ MEDIUM = "medium"
69
+ HIGH = "high"
70
+
71
+
72
+ SampleRows = Annotated[
73
+ int, typer.Option(min=0, help="Rows sampled per table. 0 classifies by name and type only.")
74
+ ]
75
+ AsJson = Annotated[bool, typer.Option("--json", help="Print JSON instead of text.")]
76
+
77
+
78
+ @app.command("scan")
79
+ def scan(
80
+ dsn_env: Annotated[
81
+ str, typer.Option(help="Environment variable holding the Postgres connection string.")
82
+ ] = "DATABASE_URL",
83
+ connector: Annotated[
84
+ str, typer.Option(help="Connector name to prefix refs with, as in your data map.")
85
+ ] = "db",
86
+ schemas: Annotated[
87
+ list[str] | None, typer.Option("--schema", help="Only scan this schema. Repeatable.")
88
+ ] = None,
89
+ sample_rows: SampleRows = 200,
90
+ show_all: Annotated[
91
+ bool, typer.Option("--all", help="Also list columns with no finding.")
92
+ ] = False,
93
+ as_json: AsJson = False,
94
+ ) -> None:
95
+ """Find columns that look like personal data in a live Postgres database.
96
+
97
+ Reads the schema and samples values in a read-only transaction. Values are never
98
+ printed: the report shows categories, confidences and counts only.
99
+ """
100
+ reports = _scan(dsn_env, schemas, sample_rows)
101
+ shown = [r for r in reports if show_all or r.finding is not None]
102
+ if as_json:
103
+ typer.echo(json.dumps([_report_json(r, connector) for r in shown], indent=2))
104
+ return
105
+ rows = [
106
+ (
107
+ r.column.ref(connector),
108
+ str(r.finding.category) if r.finding else "-",
109
+ str(r.finding.confidence) if r.finding else "-",
110
+ "; ".join(r.finding.reasons) if r.finding else "",
111
+ )
112
+ for r in shown
113
+ ]
114
+ _echo_table(("COLUMN", "CATEGORY", "CONFIDENCE", "WHY"), rows)
115
+ found = [r.finding for r in reports if r.finding is not None]
116
+ by_level = ", ".join(
117
+ f"{sum(1 for f in found if f.confidence is level)} {level}"
118
+ for level in reversed(Confidence)
119
+ )
120
+ typer.echo(
121
+ f"\n{len(reports)} columns scanned, {len(found)} look like personal data ({by_level})"
122
+ )
123
+
124
+
125
+ @app.command("lint")
126
+ def lint_command(
127
+ path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
128
+ connectors: Annotated[
129
+ list[str] | None, typer.Option("--connector", help="Only lint this connector. Repeatable.")
130
+ ] = None,
131
+ fail_on: Annotated[
132
+ Threshold, typer.Option(help="Lowest confidence that fails the lint when unmapped.")
133
+ ] = Threshold.MEDIUM,
134
+ sample_rows: SampleRows = 200,
135
+ as_json: AsJson = False,
136
+ ) -> None:
137
+ """Fail when the database has personal data the data map does not cover, or the map
138
+ names tables and columns the database no longer has."""
139
+ try:
140
+ dm = datamap.load(path)
141
+ except datamap.DataMapError as exc:
142
+ for problem in exc.problems:
143
+ typer.echo(f"{path}: {problem}", err=True)
144
+ raise typer.Exit(1) from exc
145
+
146
+ declared = {c.name: c for c in dm.connectors}
147
+ for name in connectors or ():
148
+ if name not in declared:
149
+ typer.echo(f"{path}: no connector named {name!r}", err=True)
150
+ raise typer.Exit(2)
151
+ selected = [declared[name] for name in connectors] if connectors else list(dm.connectors)
152
+
153
+ threshold = Confidence[fail_on.name]
154
+ results: dict[str, LintResult] = {}
155
+ for connector in selected:
156
+ reports = _scan(connector.dsn_env, None, sample_rows)
157
+ results[connector.name] = lint(dm, connector.name, reports, fail_on=threshold)
158
+
159
+ failed = any(result.errors for result in results.values())
160
+ if as_json:
161
+ out: dict[str, Any] = {
162
+ "ok": not failed,
163
+ "connectors": {
164
+ name: {
165
+ "issues": [
166
+ {"severity": str(i.severity), "ref": i.ref, "message": i.message}
167
+ for i in result.issues
168
+ ],
169
+ "below_threshold": result.below_threshold,
170
+ }
171
+ for name, result in results.items()
172
+ },
173
+ }
174
+ typer.echo(json.dumps(out, indent=2))
175
+ else:
176
+ for name, result in results.items():
177
+ for issue in result.issues:
178
+ typer.echo(f"{path}: {issue}", err=True)
179
+ warnings = len(result.issues) - len(result.errors)
180
+ summary = f"{path}: {name}: {len(result.errors)} error(s), {warnings} warning(s)"
181
+ if result.below_threshold:
182
+ summary += (
183
+ f"; {result.below_threshold} finding(s) below {fail_on} confidence"
184
+ " not checked (see mita scan)"
185
+ )
186
+ typer.echo(summary)
187
+ if failed:
188
+ raise typer.Exit(1)
189
+
190
+
191
+ def _scan(dsn_env: str, schemas: Sequence[str] | None, sample_rows: int) -> list[ColumnReport]:
192
+ dsn = os.environ.get(dsn_env)
193
+ if not dsn:
194
+ typer.echo(f"environment variable {dsn_env} is not set", err=True)
195
+ raise typer.Exit(2)
196
+ try:
197
+ with postgres.connect(dsn) as conn:
198
+ return postgres.scan(conn, schemas=schemas, sample_rows=sample_rows)
199
+ except psycopg.Error as exc:
200
+ typer.echo(f"cannot scan the database in ${dsn_env}: {exc}", err=True)
201
+ raise typer.Exit(2) from exc
202
+
203
+
204
+ def _report_json(report: ColumnReport, connector: str) -> dict[str, Any]:
205
+ finding = report.finding
206
+ return {
207
+ "ref": report.column.ref(connector),
208
+ "data_type": report.column.data_type,
209
+ "sampled": report.sampled,
210
+ "category": str(finding.category) if finding else None,
211
+ "confidence": str(finding.confidence) if finding else None,
212
+ "reasons": list(finding.reasons) if finding else [],
213
+ }
214
+
215
+
216
+ def _echo_table(header: tuple[str, ...], rows: Sequence[tuple[str, ...]]) -> None:
217
+ widths = [max(len(row[i]) for row in (header, *rows)) for i in range(len(header) - 1)]
218
+ for row in (header, *rows):
219
+ cells = [cell.ljust(width) for cell, width in zip(row, widths, strict=False)]
220
+ typer.echo(" ".join([*cells, row[-1]]).rstrip())
File without changes
@@ -0,0 +1,101 @@
1
+ """Read a live Postgres schema and sample column values for detection.
2
+
3
+ `connect` opens a read-only session with a statement timeout, so a scan can be pointed at a
4
+ production replica without risk of writes or runaway queries. Identifiers only ever reach
5
+ the database through `psycopg.sql.Identifier`. Sampled values go straight to detection and
6
+ are dropped; they are never returned.
7
+
8
+ Sampling reads the first rows the table returns (no ORDER BY), which is cheap on large
9
+ tables but not random. Columns that sampling misses are still classified by name.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ import psycopg
18
+ from psycopg import sql
19
+
20
+ from mita.core import detect
21
+ from mita.core.catalog import ColumnInfo
22
+
23
+ Connection = psycopg.Connection[tuple[Any, ...]]
24
+
25
+ SAMPLED_TYPES = detect.TEXT_TYPES | {"bigint", "numeric"}
26
+ """Types whose values can hold an identifier detection recognises (phones fit in bigint)."""
27
+ MAX_VALUE_CHARS = 2000
28
+ """Longer values are truncated in the query, so one huge document cannot exhaust memory."""
29
+
30
+ _COLUMNS_SQL = r"""
31
+ SELECT c.table_schema, c.table_name, c.column_name,
32
+ CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END
33
+ FROM information_schema.columns c
34
+ JOIN information_schema.tables t
35
+ ON t.table_schema = c.table_schema AND t.table_name = c.table_name
36
+ WHERE t.table_type = 'BASE TABLE'
37
+ AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
38
+ AND c.table_schema NOT LIKE 'pg\_%%'
39
+ AND (%(schemas)s::text[] IS NULL OR c.table_schema = ANY (%(schemas)s::text[]))
40
+ ORDER BY c.table_schema, c.table_name, c.ordinal_position
41
+ """
42
+
43
+
44
+ def connect(dsn: str, *, statement_timeout_ms: int = 30_000) -> Connection:
45
+ conn = psycopg.connect(
46
+ dsn,
47
+ application_name="mita",
48
+ options=f"-c statement_timeout={int(statement_timeout_ms)}",
49
+ )
50
+ conn.read_only = True
51
+ return conn
52
+
53
+
54
+ def columns(conn: Connection, schemas: Sequence[str] | None = None) -> list[ColumnInfo]:
55
+ """Every column of every ordinary table, outside the system schemas."""
56
+ rows = conn.execute(_COLUMNS_SQL, {"schemas": list(schemas) if schemas else None})
57
+ return [ColumnInfo(str(s), str(t), str(c), str(d)) for s, t, c, d in rows.fetchall()]
58
+
59
+
60
+ def scan(
61
+ conn: Connection, *, schemas: Sequence[str] | None = None, sample_rows: int = 200
62
+ ) -> list[detect.ColumnReport]:
63
+ """Classify every column. `sample_rows=0` classifies by name and type only."""
64
+ by_table: dict[tuple[str, str], list[ColumnInfo]] = {}
65
+ for column in columns(conn, schemas):
66
+ by_table.setdefault((column.schema, column.table), []).append(column)
67
+
68
+ reports: list[detect.ColumnReport] = []
69
+ for (schema, table), table_columns in by_table.items():
70
+ names = [c.name for c in table_columns if c.data_type in SAMPLED_TYPES]
71
+ samples = _sample(conn, schema, table, names, sample_rows) if names and sample_rows else {}
72
+ for column in table_columns:
73
+ values = samples.get(column.name, [])
74
+ reports.append(
75
+ detect.ColumnReport(column, len(values), detect.classify(column, values))
76
+ )
77
+ return reports
78
+
79
+
80
+ def _sample(
81
+ conn: Connection, schema: str, table: str, names: Sequence[str], limit: int
82
+ ) -> dict[str, list[str]]:
83
+ query = sql.SQL("SELECT {} FROM {} LIMIT {}").format(
84
+ sql.SQL(", ").join(
85
+ sql.SQL("left({}::text, {})").format(sql.Identifier(name), sql.Literal(MAX_VALUE_CHARS))
86
+ for name in names
87
+ ),
88
+ sql.Identifier(schema, table),
89
+ sql.Literal(limit),
90
+ )
91
+ out: dict[str, list[str]] = {name: [] for name in names}
92
+ try:
93
+ # A savepoint, so one unreadable table does not abort the whole scan.
94
+ with conn.transaction():
95
+ for row in conn.execute(query):
96
+ for name, value in zip(names, row, strict=True):
97
+ if value is not None:
98
+ out[name].append(value)
99
+ except psycopg.errors.InsufficientPrivilege:
100
+ return {}
101
+ return out
mita/core/__init__.py ADDED
File without changes
mita/core/catalog.py ADDED
@@ -0,0 +1,21 @@
1
+ """What a connector's live schema looks like, independent of how it was read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ColumnInfo:
10
+ """One column of one table, as the database reports it."""
11
+
12
+ schema: str
13
+ table: str
14
+ name: str
15
+ data_type: str
16
+
17
+ def table_ref(self, connector: str) -> str:
18
+ return f"{connector}.{self.schema}.{self.table}"
19
+
20
+ def ref(self, connector: str) -> str:
21
+ return f"{connector}.{self.schema}.{self.table}.{self.name}"
mita/core/datamap.py ADDED
@@ -0,0 +1,321 @@
1
+ """The data map: where a person's data lives and what erasure does to it.
2
+
3
+ The map is a YAML file kept in the user's repository and reviewed in pull requests.
4
+ Loading happens in two stages:
5
+
6
+ 1. Structure (pydantic): shapes, enums and required fields; unknown keys are rejected.
7
+ 2. Semantics (`check`): cross-references a JSON Schema cannot express. Every problem is
8
+ reported at once, so a user can fix their map in a single pass.
9
+
10
+ Names are addressed as `connector.schema.table` and `connector.schema.table.column`.
11
+ Schema, table and column names may hold any character except `.` and whitespace; they
12
+ are only ever sent to a database through `psycopg.sql.Identifier`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+ from typing import Annotated, Any, Literal
21
+
22
+ import yaml
23
+ from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
24
+
25
+ _PART = r"[^.\s]+"
26
+ NAME_PATTERN = r"^[a-z][a-z0-9_]{0,62}$"
27
+ TABLE_REF_PATTERN = rf"^[a-z][a-z0-9_]{{0,62}}\.{_PART}\.{_PART}$"
28
+ COLUMN_REF_PATTERN = rf"^[a-z][a-z0-9_]{{0,62}}\.{_PART}\.{_PART}\.{_PART}$"
29
+
30
+ Name = Annotated[str, StringConstraints(pattern=NAME_PATTERN)]
31
+ TableRef = Annotated[str, StringConstraints(pattern=TABLE_REF_PATTERN)]
32
+ ColumnRef = Annotated[str, StringConstraints(pattern=COLUMN_REF_PATTERN)]
33
+ NonEmpty = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
34
+ # ISO 8601 date period with at least one part (P6Y, P18M, P1Y6M, P30D). No lookahead:
35
+ # pydantic's regex engine does not support it.
36
+ IsoPeriod = Annotated[str, StringConstraints(pattern=r"^P(\d+Y(\d+M)?(\d+D)?|\d+M(\d+D)?|\d+D)$")]
37
+ EnvVar = Annotated[str, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]*$")]
38
+
39
+
40
+ class PiiCategory(StrEnum):
41
+ """What kind of personal data a column holds. `none` marks a reviewed, non-PII column."""
42
+
43
+ EMAIL = "email"
44
+ PHONE = "phone"
45
+ NAME = "name"
46
+ ADDRESS = "address"
47
+ PIN_CODE = "pin_code"
48
+ PAN = "pan"
49
+ AADHAAR = "aadhaar"
50
+ DOB = "dob"
51
+ IP_ADDRESS = "ip_address"
52
+ GSTIN = "gstin"
53
+ UPI_ID = "upi_id"
54
+ PAYMENT_REF = "payment_ref"
55
+ FREE_TEXT = "free_text"
56
+ OTHER = "other"
57
+ NONE = "none"
58
+
59
+
60
+ class Normalizer(StrEnum):
61
+ """How an identifier is normalised before it is matched or hashed."""
62
+
63
+ EXACT = "exact"
64
+ EMAIL = "email"
65
+ IN_MOBILE = "in_mobile"
66
+
67
+
68
+ class TableErase(StrEnum):
69
+ """What erasure does to a matching row."""
70
+
71
+ DELETE_ROW = "delete_row"
72
+ UPDATE_COLUMNS = "update_columns"
73
+ RETAIN = "retain"
74
+
75
+
76
+ class ColumnStrategy(StrEnum):
77
+ """What erasure does to one column of a row that is kept (`update_columns`)."""
78
+
79
+ NULLIFY = "nullify"
80
+ PSEUDONYMIZE = "pseudonymize"
81
+ RETAIN = "retain"
82
+
83
+
84
+ class _Model(BaseModel):
85
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
86
+
87
+
88
+ class Retention(_Model):
89
+ """Why data is kept despite an erasure request, and for how long."""
90
+
91
+ reason: NonEmpty = Field(description="Plain-language reason. Shown in evidence records.")
92
+ period: IsoPeriod = Field(description="ISO 8601 period, e.g. P6Y or P18M.")
93
+ from_column: str | None = Field(
94
+ default=None, description="Timestamp column the period counts from."
95
+ )
96
+ rule_ref: str | None = Field(
97
+ default=None, description="The law or policy clause this relies on."
98
+ )
99
+
100
+
101
+ class Column(_Model):
102
+ pii: PiiCategory
103
+ strategy: ColumnStrategy | None = Field(
104
+ default=None, description="Required for PII columns in `update_columns` tables."
105
+ )
106
+ retain: Retention | None = Field(default=None, description="Required when strategy is retain.")
107
+ note: str | None = None
108
+
109
+
110
+ class Match(_Model):
111
+ """A column that identifies the principal directly, without a foreign-key path."""
112
+
113
+ key: Name = Field(description="A key declared under `principal.keys`.")
114
+ column: str
115
+
116
+
117
+ class Table(_Model):
118
+ erase: TableErase
119
+ retain: Retention | None = Field(
120
+ default=None, description="Required when erase is retain; the whole row is kept."
121
+ )
122
+ match: tuple[Match, ...] = ()
123
+ columns: dict[str, Column] = Field(default_factory=dict)
124
+
125
+
126
+ class PrincipalKey(_Model):
127
+ column: str = Field(description="Column of the root table holding this identifier.")
128
+ normalize: Normalizer = Normalizer.EXACT
129
+
130
+
131
+ class Principal(_Model):
132
+ root: TableRef = Field(description="The table whose row is the person.")
133
+ keys: dict[Name, PrincipalKey] = Field(min_length=1)
134
+
135
+
136
+ class Connector(_Model):
137
+ name: Name
138
+ kind: Literal["postgres"]
139
+ dsn_env: EnvVar = Field(description="Environment variable holding the connection string.")
140
+
141
+
142
+ class Relation(_Model):
143
+ """A link the database does not enforce as a foreign key."""
144
+
145
+ from_: ColumnRef = Field(alias="from")
146
+ to: ColumnRef
147
+
148
+
149
+ class DataMap(_Model):
150
+ version: Literal[0]
151
+ connectors: tuple[Connector, ...] = Field(min_length=1)
152
+ principal: Principal
153
+ tables: dict[TableRef, Table] = Field(min_length=1)
154
+ relations: tuple[Relation, ...] = ()
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class Problem:
159
+ path: str
160
+ message: str
161
+
162
+ def __str__(self) -> str:
163
+ return f"{self.path}: {self.message}"
164
+
165
+
166
+ class DataMapError(Exception):
167
+ def __init__(self, problems: list[Problem]) -> None:
168
+ self.problems = problems
169
+ super().__init__(f"{len(problems)} problem(s) in data map")
170
+
171
+
172
+ def load(path: Path) -> DataMap:
173
+ """Read, parse and validate a data map. Raises DataMapError listing every problem."""
174
+ try:
175
+ raw = yaml.load(path.read_text(encoding="utf-8"), Loader=_StrictLoader) # noqa: S506
176
+ except yaml.YAMLError as exc:
177
+ raise DataMapError([Problem("$", f"invalid YAML: {exc}")]) from exc
178
+ return from_obj(raw)
179
+
180
+
181
+ def from_obj(raw: object) -> DataMap:
182
+ """Validate an already-parsed object. Raises DataMapError listing every problem."""
183
+ try:
184
+ datamap = DataMap.model_validate(raw)
185
+ except ValidationError as exc:
186
+ raise DataMapError(
187
+ [Problem(_format_loc(err["loc"]), err["msg"]) for err in exc.errors()]
188
+ ) from exc
189
+ problems = check(datamap)
190
+ if problems:
191
+ raise DataMapError(problems)
192
+ return datamap
193
+
194
+
195
+ def check(dm: DataMap) -> list[Problem]:
196
+ """Cross-reference checks. Returns every problem found, in document order."""
197
+ problems: list[Problem] = []
198
+
199
+ def add(path: str, message: str) -> None:
200
+ problems.append(Problem(path, message))
201
+
202
+ connector_names: set[str] = set()
203
+ for i, connector in enumerate(dm.connectors):
204
+ if connector.name in connector_names:
205
+ add(f"connectors[{i}].name", f"duplicate connector {connector.name!r}")
206
+ connector_names.add(connector.name)
207
+
208
+ root = dm.tables.get(dm.principal.root)
209
+ if root is None:
210
+ add("principal.root", f"table {dm.principal.root!r} is not declared under tables")
211
+ else:
212
+ if root.erase is TableErase.RETAIN:
213
+ add("principal.root", "the root table cannot use erase: retain")
214
+ for key, spec in dm.principal.keys.items():
215
+ if spec.column not in root.columns:
216
+ add(
217
+ f"principal.keys.{key}.column",
218
+ f"column {spec.column!r} is not listed under the root table's columns",
219
+ )
220
+
221
+ for ref, table in dm.tables.items():
222
+ base = f"tables[{ref!r}]"
223
+ connector_name = ref.split(".", 1)[0]
224
+ if connector_name not in connector_names:
225
+ add(base, f"connector {connector_name!r} is not declared")
226
+ problems.extend(_check_table(base, table))
227
+ for i, match in enumerate(table.match):
228
+ if match.key not in dm.principal.keys:
229
+ add(
230
+ f"{base}.match[{i}].key", f"key {match.key!r} is not declared in principal.keys"
231
+ )
232
+ if match.column not in table.columns:
233
+ add(
234
+ f"{base}.match[{i}].column",
235
+ f"column {match.column!r} is not listed under this table's columns",
236
+ )
237
+
238
+ for i, relation in enumerate(dm.relations):
239
+ if relation.from_ == relation.to:
240
+ add(f"relations[{i}]", "a relation cannot point at itself")
241
+ for side, column_ref in (("from", relation.from_), ("to", relation.to)):
242
+ table_ref = column_ref.rsplit(".", 1)[0]
243
+ if table_ref not in dm.tables:
244
+ add(f"relations[{i}].{side}", f"table {table_ref!r} is not declared under tables")
245
+
246
+ return problems
247
+
248
+
249
+ def _check_table(base: str, table: Table) -> list[Problem]:
250
+ problems: list[Problem] = []
251
+
252
+ if table.erase is TableErase.RETAIN and table.retain is None:
253
+ problems.append(Problem(base, "erase: retain needs a retain block with a reason"))
254
+ if table.erase is not TableErase.RETAIN and table.retain is not None:
255
+ problems.append(Problem(f"{base}.retain", "a table retain block needs erase: retain"))
256
+
257
+ has_strategy = False
258
+ for name, column in table.columns.items():
259
+ path = f"{base}.columns.{name}"
260
+ if column.strategy is not None:
261
+ has_strategy = True
262
+ if table.erase is not TableErase.UPDATE_COLUMNS:
263
+ problems.append(
264
+ Problem(f"{path}.strategy", "strategy only applies to update_columns tables")
265
+ )
266
+ elif table.erase is TableErase.UPDATE_COLUMNS and column.pii is not PiiCategory.NONE:
267
+ problems.append(Problem(path, f"{column.pii} column needs a strategy"))
268
+
269
+ if column.strategy is ColumnStrategy.RETAIN and column.retain is None:
270
+ problems.append(Problem(path, "strategy: retain needs a retain block with a reason"))
271
+ if column.strategy is not ColumnStrategy.RETAIN and column.retain is not None:
272
+ problems.append(Problem(f"{path}.retain", "a retain block needs strategy: retain"))
273
+
274
+ if table.erase is TableErase.UPDATE_COLUMNS and not has_strategy:
275
+ problems.append(Problem(base, "update_columns table has no column with a strategy"))
276
+
277
+ return problems
278
+
279
+
280
+ def json_schema() -> dict[str, Any]:
281
+ """The JSON Schema for data map files, as committed in schemas/datamap.schema.json."""
282
+ schema = DataMap.model_json_schema(by_alias=True)
283
+ return {
284
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
285
+ "title": "mita data map",
286
+ **{k: v for k, v in schema.items() if k != "title"},
287
+ }
288
+
289
+
290
+ def _format_loc(loc: tuple[int | str, ...]) -> str:
291
+ out = ""
292
+ for part in loc:
293
+ if isinstance(part, int):
294
+ out += f"[{part}]"
295
+ elif "." in part:
296
+ out += f"[{part!r}]"
297
+ else:
298
+ out += f".{part}" if out else part
299
+ return out or "$"
300
+
301
+
302
+ class _StrictLoader(yaml.SafeLoader):
303
+ """SafeLoader that rejects duplicate keys, which PyYAML otherwise silently overwrites.
304
+
305
+ A duplicated table key would make a whole table vanish from the map without warning.
306
+ """
307
+
308
+
309
+ def _construct_mapping(loader: yaml.SafeLoader, node: yaml.MappingNode) -> dict[Any, Any]:
310
+ seen: set[object] = set()
311
+ for key_node, _ in node.value:
312
+ key = loader.construct_object(key_node)
313
+ if key in seen:
314
+ raise yaml.constructor.ConstructorError(
315
+ None, None, f"duplicate key {key!r}", key_node.start_mark
316
+ )
317
+ seen.add(key)
318
+ return loader.construct_mapping(node)
319
+
320
+
321
+ _StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)