pretensor 0.1.0a0__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 (138) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/cli/__init__.py +1 -0
  3. pretensor/cli/commands/connections/__init__.py +7 -0
  4. pretensor/cli/commands/connections/add_remove.py +122 -0
  5. pretensor/cli/commands/connections/register.py +12 -0
  6. pretensor/cli/commands/export.py +120 -0
  7. pretensor/cli/commands/index.py +551 -0
  8. pretensor/cli/commands/list.py +65 -0
  9. pretensor/cli/commands/quickstart.py +171 -0
  10. pretensor/cli/commands/reindex.py +581 -0
  11. pretensor/cli/commands/semantic.py +190 -0
  12. pretensor/cli/commands/serve.py +129 -0
  13. pretensor/cli/commands/sync_grants.py +149 -0
  14. pretensor/cli/commands/validate.py +156 -0
  15. pretensor/cli/config_file.py +412 -0
  16. pretensor/cli/constants.py +10 -0
  17. pretensor/cli/dbt_enrichment.py +96 -0
  18. pretensor/cli/main.py +81 -0
  19. pretensor/cli/paths.py +43 -0
  20. pretensor/cli/plugin.py +52 -0
  21. pretensor/config.py +145 -0
  22. pretensor/connectors/SKILL.md +16 -0
  23. pretensor/connectors/__init__.py +29 -0
  24. pretensor/connectors/base.py +165 -0
  25. pretensor/connectors/bigquery.py +463 -0
  26. pretensor/connectors/inspect.py +321 -0
  27. pretensor/connectors/lineage_sqlglot.py +91 -0
  28. pretensor/connectors/models.py +130 -0
  29. pretensor/connectors/pg_array_parse.py +53 -0
  30. pretensor/connectors/postgres.py +921 -0
  31. pretensor/connectors/registry.py +51 -0
  32. pretensor/connectors/snapshot.py +244 -0
  33. pretensor/connectors/snowflake.py +840 -0
  34. pretensor/core/__init__.py +1 -0
  35. pretensor/core/builder.py +268 -0
  36. pretensor/core/dsn_crypto.py +40 -0
  37. pretensor/core/ids.py +68 -0
  38. pretensor/core/portable_export.py +253 -0
  39. pretensor/core/registry.py +204 -0
  40. pretensor/core/schema.py +368 -0
  41. pretensor/core/store.py +1343 -0
  42. pretensor/enrichment/__init__.py +1 -0
  43. pretensor/enrichment/dbt/__init__.py +30 -0
  44. pretensor/enrichment/dbt/lineage.py +100 -0
  45. pretensor/enrichment/dbt/manifest.py +298 -0
  46. pretensor/enrichment/dbt/metadata.py +263 -0
  47. pretensor/enrichment/dbt/pipeline.py +77 -0
  48. pretensor/enrichment/dbt/resolution.py +101 -0
  49. pretensor/enrichment/dbt/signals.py +305 -0
  50. pretensor/entities/__init__.py +27 -0
  51. pretensor/entities/builder.py +63 -0
  52. pretensor/entities/classifier.py +359 -0
  53. pretensor/entities/llm_extract.py +66 -0
  54. pretensor/graph_models/__init__.py +14 -0
  55. pretensor/graph_models/base.py +11 -0
  56. pretensor/graph_models/edge.py +35 -0
  57. pretensor/graph_models/entity.py +21 -0
  58. pretensor/graph_models/node.py +75 -0
  59. pretensor/graph_models/relationship.py +27 -0
  60. pretensor/intelligence/__init__.py +33 -0
  61. pretensor/intelligence/cluster_labeler.py +335 -0
  62. pretensor/intelligence/clustering.py +162 -0
  63. pretensor/intelligence/combining.py +32 -0
  64. pretensor/intelligence/discovery.py +114 -0
  65. pretensor/intelligence/embeddings.py +198 -0
  66. pretensor/intelligence/graph_export.py +139 -0
  67. pretensor/intelligence/heuristic.py +540 -0
  68. pretensor/intelligence/join_paths/__init__.py +130 -0
  69. pretensor/intelligence/join_paths/on_demand.py +516 -0
  70. pretensor/intelligence/join_paths/storage.py +70 -0
  71. pretensor/intelligence/llm_infer.py +78 -0
  72. pretensor/intelligence/llm_runtime.py +60 -0
  73. pretensor/intelligence/metric_templates.py +193 -0
  74. pretensor/intelligence/pipeline.py +254 -0
  75. pretensor/intelligence/schema_classification.py +306 -0
  76. pretensor/intelligence/scoring.py +66 -0
  77. pretensor/intelligence/shadow_alias.py +101 -0
  78. pretensor/intelligence/statistical.py +50 -0
  79. pretensor/intelligence/steps.py +161 -0
  80. pretensor/introspection/__init__.py +6 -0
  81. pretensor/introspection/inspector.py +5 -0
  82. pretensor/introspection/models/__init__.py +0 -0
  83. pretensor/introspection/models/base.py +5 -0
  84. pretensor/introspection/models/config.py +236 -0
  85. pretensor/introspection/models/dsn.py +442 -0
  86. pretensor/introspection/models/plan.py +116 -0
  87. pretensor/introspection/models/schema.py +10 -0
  88. pretensor/introspection/models/semantic.py +121 -0
  89. pretensor/introspection/models/validation.py +116 -0
  90. pretensor/introspection/snapshot.py +46 -0
  91. pretensor/mcp/__init__.py +16 -0
  92. pretensor/mcp/config_json.py +24 -0
  93. pretensor/mcp/payload_types.py +248 -0
  94. pretensor/mcp/resources/__init__.py +19 -0
  95. pretensor/mcp/resources/markdown.py +420 -0
  96. pretensor/mcp/server.py +723 -0
  97. pretensor/mcp/service.py +47 -0
  98. pretensor/mcp/service_context.py +126 -0
  99. pretensor/mcp/service_registry.py +242 -0
  100. pretensor/mcp/tool_registry.py +126 -0
  101. pretensor/mcp/tools/__init__.py +1 -0
  102. pretensor/mcp/tools/compile_metric.py +87 -0
  103. pretensor/mcp/tools/context.py +894 -0
  104. pretensor/mcp/tools/cypher.py +333 -0
  105. pretensor/mcp/tools/detect_changes.py +226 -0
  106. pretensor/mcp/tools/impact.py +165 -0
  107. pretensor/mcp/tools/list.py +110 -0
  108. pretensor/mcp/tools/schema.py +128 -0
  109. pretensor/mcp/tools/search.py +108 -0
  110. pretensor/mcp/tools/traverse.py +753 -0
  111. pretensor/mcp/tools/validate_sql.py +74 -0
  112. pretensor/observability.py +203 -0
  113. pretensor/search/__init__.py +6 -0
  114. pretensor/search/base.py +80 -0
  115. pretensor/search/index.py +435 -0
  116. pretensor/semantic/__init__.py +24 -0
  117. pretensor/semantic/base.py +123 -0
  118. pretensor/semantic/compiler.py +346 -0
  119. pretensor/semantic/yaml_layer.py +182 -0
  120. pretensor/skills/__init__.py +5 -0
  121. pretensor/skills/generator.py +235 -0
  122. pretensor/staleness/__init__.py +15 -0
  123. pretensor/staleness/graph_patcher.py +355 -0
  124. pretensor/staleness/impact_analyzer.py +162 -0
  125. pretensor/staleness/snapshot_store.py +38 -0
  126. pretensor/validation/__init__.py +9 -0
  127. pretensor/validation/query_validator.py +436 -0
  128. pretensor/visibility/__init__.py +23 -0
  129. pretensor/visibility/config.py +126 -0
  130. pretensor/visibility/filter.py +143 -0
  131. pretensor/visibility/kuzu_helpers.py +32 -0
  132. pretensor/visibility/runtime.py +36 -0
  133. pretensor/visibility/sync_grants.py +188 -0
  134. pretensor-0.1.0a0.dist-info/METADATA +175 -0
  135. pretensor-0.1.0a0.dist-info/RECORD +138 -0
  136. pretensor-0.1.0a0.dist-info/WHEEL +4 -0
  137. pretensor-0.1.0a0.dist-info/entry_points.txt +2 -0
  138. pretensor-0.1.0a0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,126 @@
1
+ """YAML visibility rules: hidden schemas/tables/columns and named profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+ from ruamel.yaml import YAML
9
+
10
+ __all__ = [
11
+ "VisibilityProfileRule",
12
+ "VisibilityConfig",
13
+ "load_visibility_config",
14
+ "default_visibility_path",
15
+ "merge_profile_into_base",
16
+ ]
17
+
18
+
19
+ def default_visibility_path(graph_dir: Path) -> Path:
20
+ """Default location for visibility rules under the graph state directory."""
21
+ return graph_dir / "visibility.yml"
22
+
23
+
24
+ class VisibilityProfileRule(BaseModel):
25
+ """Overlay merged onto base visibility when ``--profile`` is set."""
26
+
27
+ model_config = ConfigDict(extra="ignore")
28
+
29
+ hidden_schemas: list[str] = Field(default_factory=list)
30
+ hidden_tables: list[str] = Field(default_factory=list)
31
+ hidden_columns: list[str] = Field(default_factory=list)
32
+ hidden_table_types: list[str] = Field(default_factory=list)
33
+ allowed_schemas: list[str] = Field(default_factory=list)
34
+ allowed_tables: list[str] = Field(default_factory=list)
35
+
36
+
37
+ class VisibilityConfig(BaseModel):
38
+ """Parsed ``visibility.yml`` (base section)."""
39
+
40
+ model_config = ConfigDict(extra="ignore")
41
+
42
+ hidden_schemas: list[str] = Field(default_factory=list)
43
+ hidden_tables: list[str] = Field(default_factory=list)
44
+ hidden_columns: list[str] = Field(default_factory=list)
45
+ hidden_table_types: list[str] = Field(default_factory=list)
46
+ allowed_schemas: list[str] = Field(default_factory=list)
47
+ allowed_tables: list[str] = Field(default_factory=list)
48
+ profiles: dict[str, VisibilityProfileRule] = Field(default_factory=dict)
49
+
50
+
51
+ def load_visibility_config(path: Path) -> VisibilityConfig:
52
+ """Load and validate visibility YAML from disk.
53
+
54
+ Args:
55
+ path: File to read (typically ``.pretensor/visibility.yml``).
56
+
57
+ Returns:
58
+ Parsed config. Missing file yields an empty config.
59
+
60
+ Raises:
61
+ ValueError: If YAML is present but invalid.
62
+ """
63
+ if not path.exists():
64
+ return VisibilityConfig()
65
+ raw_text = path.read_text(encoding="utf-8")
66
+ if not raw_text.strip():
67
+ return VisibilityConfig()
68
+ yaml = YAML(typ="safe")
69
+ try:
70
+ data = yaml.load(raw_text)
71
+ except Exception as exc:
72
+ raise ValueError(f"Invalid visibility YAML: {path}: {exc}") from exc
73
+ if data is None:
74
+ return VisibilityConfig()
75
+ if not isinstance(data, dict):
76
+ raise ValueError(f"visibility.yml must be a mapping at root: {path}")
77
+ profiles_raw = data.get("profiles")
78
+ base_data = {k: v for k, v in data.items() if k != "profiles"}
79
+ cfg = VisibilityConfig.model_validate(base_data)
80
+ if profiles_raw is None:
81
+ return cfg
82
+ if not isinstance(profiles_raw, dict):
83
+ raise ValueError(f"`profiles` must be a mapping in {path}")
84
+ profiles: dict[str, VisibilityProfileRule] = {}
85
+ for name, body in profiles_raw.items():
86
+ if not isinstance(name, str) or not name.strip():
87
+ continue
88
+ if body is None:
89
+ profiles[name.strip()] = VisibilityProfileRule()
90
+ elif isinstance(body, dict):
91
+ profiles[name.strip()] = VisibilityProfileRule.model_validate(body)
92
+ else:
93
+ raise ValueError(f"Profile {name!r} must be a mapping in {path}")
94
+ return cfg.model_copy(update={"profiles": profiles})
95
+
96
+
97
+ def merge_profile_into_base(
98
+ base: VisibilityConfig,
99
+ profile_name: str | None,
100
+ ) -> VisibilityConfig:
101
+ """Return a new config: base lists + optional profile overlay."""
102
+ if not profile_name or not profile_name.strip():
103
+ return base
104
+ key = profile_name.strip()
105
+ prof = base.profiles.get(key)
106
+ if prof is None:
107
+ raise ValueError(f"Unknown visibility profile: {key!r}")
108
+ merged_allowed = (
109
+ [*base.allowed_schemas, *prof.allowed_schemas]
110
+ if prof.allowed_schemas
111
+ else list(base.allowed_schemas)
112
+ )
113
+ merged_allowed_tables = (
114
+ [*base.allowed_tables, *prof.allowed_tables]
115
+ if prof.allowed_tables
116
+ else list(base.allowed_tables)
117
+ )
118
+ return VisibilityConfig(
119
+ hidden_schemas=[*base.hidden_schemas, *prof.hidden_schemas],
120
+ hidden_tables=[*base.hidden_tables, *prof.hidden_tables],
121
+ hidden_columns=[*base.hidden_columns, *prof.hidden_columns],
122
+ hidden_table_types=[*base.hidden_table_types, *prof.hidden_table_types],
123
+ allowed_schemas=merged_allowed,
124
+ allowed_tables=merged_allowed_tables,
125
+ profiles=base.profiles,
126
+ )
@@ -0,0 +1,143 @@
1
+ """Runtime visibility checks for tables and columns (fnmatch globs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from dataclasses import dataclass
7
+
8
+ from pretensor.visibility.config import VisibilityConfig
9
+
10
+ __all__ = ["VisibilityFilter"]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class VisibilityFilter:
15
+ """Evaluates table/column visibility for one merged :class:`VisibilityConfig`."""
16
+
17
+ _config: VisibilityConfig
18
+
19
+ @classmethod
20
+ def from_config(cls, config: VisibilityConfig) -> VisibilityFilter:
21
+ return cls(_config=config)
22
+
23
+ def is_table_visible(
24
+ self,
25
+ connection_name: str,
26
+ schema_name: str,
27
+ table_name: str,
28
+ *,
29
+ table_type: str | None = None,
30
+ ) -> bool:
31
+ """True if the physical table may appear in MCP responses and traversal."""
32
+ if table_type and self._table_type_hidden(table_type):
33
+ return False
34
+ if self._schema_denied(schema_name):
35
+ return False
36
+ if self._allowed_schemas_active() and not self._schema_allowed(schema_name):
37
+ return False
38
+ if self._allowed_tables_active() and not self._table_allowed(
39
+ connection_name, schema_name, table_name
40
+ ):
41
+ return False
42
+ return not self._table_pattern_matches(connection_name, schema_name, table_name)
43
+
44
+ def visible_columns(
45
+ self,
46
+ connection_name: str,
47
+ schema_name: str,
48
+ table_name: str,
49
+ column_names: list[str],
50
+ ) -> list[str]:
51
+ """Return column names that are not hidden for this table."""
52
+ return [
53
+ c
54
+ for c in column_names
55
+ if not self._column_hidden(schema_name, table_name, c)
56
+ ]
57
+
58
+ def is_schema_table_node_id_visible(self, node_id: str) -> bool:
59
+ """Interpret ``connection::schema::table`` node ids (SchemaTable)."""
60
+ parts = node_id.split("::")
61
+ if len(parts) != 3:
62
+ return True
63
+ connection_name, schema_name, table_name = parts
64
+ return self.is_table_visible(connection_name, schema_name, table_name)
65
+
66
+ def _allowed_schemas_active(self) -> bool:
67
+ return bool(self._config.allowed_schemas)
68
+
69
+ def _schema_allowed(self, schema_name: str) -> bool:
70
+ for pat in self._config.allowed_schemas:
71
+ if fnmatch.fnmatchcase(schema_name, pat):
72
+ return True
73
+ return False
74
+
75
+ def _allowed_tables_active(self) -> bool:
76
+ return bool(self._config.allowed_tables)
77
+
78
+ def _table_allowed(
79
+ self, connection_name: str, schema_name: str, table_name: str
80
+ ) -> bool:
81
+ qualified = f"{schema_name}.{table_name}"
82
+ conn_qualified = f"{connection_name}::{schema_name}.{table_name}"
83
+ for pat in self._config.allowed_tables:
84
+ if not pat.strip():
85
+ continue
86
+ if "::" in pat:
87
+ if fnmatch.fnmatchcase(conn_qualified, pat):
88
+ return True
89
+ continue
90
+ if "." in pat:
91
+ if fnmatch.fnmatchcase(qualified, pat):
92
+ return True
93
+ continue
94
+ if fnmatch.fnmatchcase(table_name, pat):
95
+ return True
96
+ return False
97
+
98
+ def _schema_denied(self, schema_name: str) -> bool:
99
+ for pat in self._config.hidden_schemas:
100
+ if fnmatch.fnmatchcase(schema_name, pat):
101
+ return True
102
+ return False
103
+
104
+ def _table_pattern_matches(
105
+ self, connection_name: str, schema_name: str, table_name: str
106
+ ) -> bool:
107
+ qualified = f"{schema_name}.{table_name}"
108
+ conn_qualified = f"{connection_name}::{schema_name}.{table_name}"
109
+ for pat in self._config.hidden_tables:
110
+ if not pat.strip():
111
+ continue
112
+ if "::" in pat:
113
+ if fnmatch.fnmatchcase(conn_qualified, pat):
114
+ return True
115
+ continue
116
+ if "." in pat:
117
+ if fnmatch.fnmatchcase(qualified, pat):
118
+ return True
119
+ continue
120
+ if fnmatch.fnmatchcase(table_name, pat):
121
+ return True
122
+ return False
123
+
124
+ def _table_type_hidden(self, table_type: str) -> bool:
125
+ tt = table_type.strip().lower()
126
+ return tt in {t.strip().lower() for t in self._config.hidden_table_types}
127
+
128
+ def _column_hidden(self, schema_name: str, table_name: str, column_name: str) -> bool:
129
+ qualified_3 = f"{schema_name}.{table_name}.{column_name}"
130
+ qualified_2 = f"{table_name}.{column_name}"
131
+ for pat in self._config.hidden_columns:
132
+ if not pat.strip():
133
+ continue
134
+ if pat.count(".") >= 2:
135
+ if fnmatch.fnmatchcase(qualified_3, pat):
136
+ return True
137
+ elif "." in pat:
138
+ if fnmatch.fnmatchcase(qualified_2, pat):
139
+ return True
140
+ else:
141
+ if fnmatch.fnmatchcase(column_name, pat):
142
+ return True
143
+ return False
@@ -0,0 +1,32 @@
1
+ """Kuzu helpers for visibility-scoped node sets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pretensor.core.store import KuzuStore
6
+ from pretensor.visibility.filter import VisibilityFilter
7
+
8
+ __all__ = ["visible_schema_table_node_ids"]
9
+
10
+
11
+ def visible_schema_table_node_ids(
12
+ store: KuzuStore,
13
+ connection_name: str,
14
+ visibility_filter: VisibilityFilter | None,
15
+ ) -> set[str] | None:
16
+ """Return visible ``SchemaTable`` node ids, or None when filtering is disabled."""
17
+ if visibility_filter is None:
18
+ return None
19
+ rows = store.query_all_rows(
20
+ """
21
+ MATCH (t:SchemaTable {connection_name: $cn})
22
+ RETURN t.node_id, t.schema_name, t.table_name
23
+ """,
24
+ {"cn": connection_name},
25
+ )
26
+ out: set[str] = set()
27
+ for nid, sn, tn in rows:
28
+ if visibility_filter.is_table_visible(
29
+ connection_name, str(sn or ""), str(tn or "")
30
+ ):
31
+ out.add(str(nid))
32
+ return out
@@ -0,0 +1,36 @@
1
+ """Load merged visibility rules for a graph state directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pretensor.visibility.config import (
8
+ default_visibility_path,
9
+ load_visibility_config,
10
+ merge_profile_into_base,
11
+ )
12
+ from pretensor.visibility.filter import VisibilityFilter
13
+
14
+ __all__ = ["load_visibility_filter_for_graph_dir"]
15
+
16
+
17
+ def load_visibility_filter_for_graph_dir(
18
+ graph_dir: Path,
19
+ *,
20
+ visibility_path: Path | None = None,
21
+ profile: str | None = None,
22
+ ) -> VisibilityFilter:
23
+ """Load ``visibility.yml`` and return a :class:`VisibilityFilter`.
24
+
25
+ Args:
26
+ graph_dir: State directory (``--graph-dir`` / ``.pretensor``).
27
+ visibility_path: Optional explicit file; defaults to ``graph_dir/visibility.yml``.
28
+ profile: Optional named profile merged onto the base section.
29
+
30
+ Returns:
31
+ Filter instance (allows all tables when no file or empty rules).
32
+ """
33
+ path = visibility_path if visibility_path is not None else default_visibility_path(graph_dir)
34
+ base = load_visibility_config(path)
35
+ merged = merge_profile_into_base(base, profile)
36
+ return VisibilityFilter.from_config(merged)
@@ -0,0 +1,188 @@
1
+ """Merge DB SELECT grants into ``visibility.yml`` profile ``allowed_tables``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ from ruamel.yaml import YAML
12
+
13
+ from pretensor.connectors.base import BaseConnector, TableGrant
14
+
15
+ __all__ = [
16
+ "allowed_table_patterns_for_grants",
17
+ "merge_grant_profiles_into_visibility_doc",
18
+ "write_visibility_yaml_document",
19
+ ]
20
+
21
+
22
+ def allowed_table_patterns_for_grants(
23
+ grants: list[TableGrant],
24
+ connection_name: str,
25
+ ) -> dict[str, list[str]]:
26
+ """Group grants by grantee; each value is sorted ``connection::schema.table`` patterns."""
27
+ by_grantee: dict[str, set[str]] = defaultdict(set)
28
+ for row in grants:
29
+ g = row.grantee.strip()
30
+ if not g:
31
+ continue
32
+ schema = row.schema_name.strip()
33
+ table = row.table_name.strip()
34
+ if not schema or not table:
35
+ continue
36
+ by_grantee[g].add(f"{connection_name}::{schema}.{table}")
37
+ return {k: sorted(v) for k, v in sorted(by_grantee.items(), key=lambda kv: kv[0])}
38
+
39
+
40
+ def _is_grant_only_profile(body: Mapping[str, Any]) -> bool:
41
+ """True if the profile body only carries ``allowed_tables`` (grant-sync shape)."""
42
+ keys = frozenset(body.keys())
43
+ return keys == frozenset({"allowed_tables"})
44
+
45
+
46
+ def merge_grant_profiles_into_visibility_doc(
47
+ *,
48
+ existing_root: Mapping[str, Any] | None,
49
+ grantee_to_tables: Mapping[str, list[str]],
50
+ roles_filter: frozenset[str] | None,
51
+ ) -> dict[str, Any]:
52
+ """Return a YAML-root mapping: base keys preserved, ``profiles`` merged for grant sync.
53
+
54
+ Profiles not targeted by this sync pass are copied verbatim. For each targeted
55
+ grantee, ``allowed_tables`` is set to the computed list; other keys on that profile
56
+ are preserved. Grantees with no tables omit ``allowed_tables`` unless the profile
57
+ has other keys (then ``allowed_tables`` is removed to avoid an empty allowlist).
58
+
59
+ Args:
60
+ existing_root: Parsed root mapping from the current file, or ``None``.
61
+ grantee_to_tables: Grantee name -> ``allowed_tables`` patterns (sorted).
62
+ roles_filter: When set, only these grantees receive grant-driven updates.
63
+
64
+ Returns:
65
+ New root dict suitable for YAML serialization.
66
+ """
67
+ root: dict[str, Any] = (
68
+ copy.deepcopy(dict(existing_root)) if existing_root else {}
69
+ )
70
+ profiles_any = root.get("profiles")
71
+ profiles: dict[str, Any] = (
72
+ copy.deepcopy(dict(profiles_any)) if isinstance(profiles_any, dict) else {}
73
+ )
74
+
75
+ grant_keys = frozenset(grantee_to_tables.keys())
76
+ if roles_filter is None:
77
+ stale = [
78
+ name
79
+ for name, body in list(profiles.items())
80
+ if name not in grant_keys
81
+ and isinstance(body, dict)
82
+ and _is_grant_only_profile(body)
83
+ ]
84
+ for name in stale:
85
+ profiles.pop(name, None)
86
+
87
+ if roles_filter is not None:
88
+ targets = sorted(roles_filter)
89
+ else:
90
+ targets = sorted(grant_keys)
91
+
92
+ for grantee in targets:
93
+ tables = list(grantee_to_tables.get(grantee, []))
94
+ existing_body = profiles.get(grantee)
95
+ body: dict[str, Any] = (
96
+ copy.deepcopy(dict(existing_body))
97
+ if isinstance(existing_body, dict)
98
+ else {}
99
+ )
100
+ other_keys = {k for k in body if k != "allowed_tables"}
101
+ if tables:
102
+ body["allowed_tables"] = tables
103
+ else:
104
+ body.pop("allowed_tables", None)
105
+ if not other_keys:
106
+ profiles.pop(grantee, None)
107
+ continue
108
+ profiles[grantee] = body
109
+
110
+ if profiles:
111
+ root["profiles"] = profiles
112
+ else:
113
+ root.pop("profiles", None)
114
+
115
+ return root
116
+
117
+
118
+ def write_visibility_yaml_document(path: Path, document: Mapping[str, Any]) -> None:
119
+ """Atomically write a mapping to ``path`` using block-style YAML.
120
+
121
+ Writes to a sibling ``.tmp`` file first and renames on success, so an
122
+ interrupted run (Ctrl-C, crash, disk full) never leaves ``visibility.yml``
123
+ truncated — the file is either the previous content or the new content,
124
+ never a partial write. ``os.replace`` is atomic on POSIX and Windows.
125
+ """
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ yaml = YAML()
128
+ yaml.default_flow_style = False
129
+ yaml.indent(mapping=2, sequence=4, offset=2)
130
+ tmp_path = path.with_name(path.name + ".tmp")
131
+ try:
132
+ with tmp_path.open("w", encoding="utf-8") as handle:
133
+ yaml.dump(dict(document), handle)
134
+ handle.flush()
135
+ os.fsync(handle.fileno())
136
+ os.replace(tmp_path, path)
137
+ except BaseException:
138
+ # Clean up partial temp file so a retry starts from a clean slate.
139
+ try:
140
+ tmp_path.unlink()
141
+ except FileNotFoundError:
142
+ pass
143
+ raise
144
+
145
+
146
+ def load_visibility_yaml_root(path: Path) -> dict[str, Any] | None:
147
+ """Load the raw YAML root mapping, or ``None`` if missing/empty."""
148
+ if not path.exists():
149
+ return None
150
+ text = path.read_text(encoding="utf-8")
151
+ if not text.strip():
152
+ return None
153
+ yaml = YAML(typ="safe")
154
+ data = yaml.load(text)
155
+ if data is None:
156
+ return None
157
+ if not isinstance(data, dict):
158
+ msg = f"visibility.yml must be a mapping at root: {path}"
159
+ raise ValueError(msg)
160
+ return data
161
+
162
+
163
+ def run_sync_grants(
164
+ connector: BaseConnector,
165
+ *,
166
+ output_path: Path,
167
+ connection_name: str,
168
+ roles_filter: frozenset[str] | None,
169
+ ) -> int:
170
+ """Read grants from ``connector``, merge into ``output_path``, return grantee count.
171
+
172
+ The connector must already be connected. The file on disk is replaced atomically
173
+ with merged content; base visibility keys and non-target profiles are preserved.
174
+
175
+ Returns the number of distinct grantees for which ``allowed_tables`` was written
176
+ (0 if the connector returned no grants). Callers that need the parsed
177
+ :class:`VisibilityConfig` should call :func:`load_visibility_config` afterward.
178
+ """
179
+ grants = connector.get_table_grants()
180
+ grantee_to_tables = allowed_table_patterns_for_grants(grants, connection_name)
181
+ existing_root = load_visibility_yaml_root(output_path)
182
+ merged = merge_grant_profiles_into_visibility_doc(
183
+ existing_root=existing_root,
184
+ grantee_to_tables=grantee_to_tables,
185
+ roles_filter=roles_filter,
186
+ )
187
+ write_visibility_yaml_document(output_path, merged)
188
+ return len(grantee_to_tables)