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.
- pretensor/__init__.py +50 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +122 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +120 -0
- pretensor/cli/commands/index.py +551 -0
- pretensor/cli/commands/list.py +65 -0
- pretensor/cli/commands/quickstart.py +171 -0
- pretensor/cli/commands/reindex.py +581 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +129 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +156 -0
- pretensor/cli/config_file.py +412 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +81 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +145 -0
- pretensor/connectors/SKILL.md +16 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +463 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +91 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +921 -0
- pretensor/connectors/registry.py +51 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +840 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +268 -0
- pretensor/core/dsn_crypto.py +40 -0
- pretensor/core/ids.py +68 -0
- pretensor/core/portable_export.py +253 -0
- pretensor/core/registry.py +204 -0
- pretensor/core/schema.py +368 -0
- pretensor/core/store.py +1343 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +298 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +359 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/graph_models/__init__.py +14 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +75 -0
- pretensor/graph_models/relationship.py +27 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +335 -0
- pretensor/intelligence/clustering.py +162 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +198 -0
- pretensor/intelligence/graph_export.py +139 -0
- pretensor/intelligence/heuristic.py +540 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +60 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +254 -0
- pretensor/intelligence/schema_classification.py +306 -0
- pretensor/intelligence/scoring.py +66 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +161 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +236 -0
- pretensor/introspection/models/dsn.py +442 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +248 -0
- pretensor/mcp/resources/__init__.py +19 -0
- pretensor/mcp/resources/markdown.py +420 -0
- pretensor/mcp/server.py +723 -0
- pretensor/mcp/service.py +47 -0
- pretensor/mcp/service_context.py +126 -0
- pretensor/mcp/service_registry.py +242 -0
- pretensor/mcp/tool_registry.py +126 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/compile_metric.py +87 -0
- pretensor/mcp/tools/context.py +894 -0
- pretensor/mcp/tools/cypher.py +333 -0
- pretensor/mcp/tools/detect_changes.py +226 -0
- pretensor/mcp/tools/impact.py +165 -0
- pretensor/mcp/tools/list.py +110 -0
- pretensor/mcp/tools/schema.py +128 -0
- pretensor/mcp/tools/search.py +108 -0
- pretensor/mcp/tools/traverse.py +753 -0
- pretensor/mcp/tools/validate_sql.py +74 -0
- pretensor/observability.py +203 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +346 -0
- pretensor/semantic/yaml_layer.py +182 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0a0.dist-info/METADATA +175 -0
- pretensor-0.1.0a0.dist-info/RECORD +138 -0
- pretensor-0.1.0a0.dist-info/WHEEL +4 -0
- pretensor-0.1.0a0.dist-info/entry_points.txt +2 -0
- 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)
|