rivage-clair 0.1.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.
- clair/__init__.py +98 -0
- clair/_logging.py +61 -0
- clair/adapters/__init__.py +0 -0
- clair/adapters/base.py +82 -0
- clair/adapters/snowflake.py +192 -0
- clair/cli/__init__.py +0 -0
- clair/cli/main.py +688 -0
- clair/core/__init__.py +0 -0
- clair/core/compiler.py +203 -0
- clair/core/dag.py +127 -0
- clair/core/dag_render.py +202 -0
- clair/core/discovery.py +382 -0
- clair/core/runner.py +428 -0
- clair/core/scaffold.py +134 -0
- clair/core/selector.py +38 -0
- clair/core/test_runner.py +236 -0
- clair/docs/__init__.py +13 -0
- clair/docs/catalog.py +56 -0
- clair/docs/columns.py +256 -0
- clair/docs/server.py +117 -0
- clair/docs/static/DEPENDENCIES.md +9 -0
- clair/docs/static/app.js +802 -0
- clair/docs/static/graph.js +335 -0
- clair/docs/static/index.html +651 -0
- clair/environments/__init__.py +0 -0
- clair/environments/environments.py +137 -0
- clair/environments/routing.py +121 -0
- clair/exceptions.py +75 -0
- clair/lineage.py +27 -0
- clair/trouves/__init__.py +26 -0
- clair/trouves/_refs.py +26 -0
- clair/trouves/column.py +46 -0
- clair/trouves/config.py +35 -0
- clair/trouves/run_config.py +79 -0
- clair/trouves/test.py +193 -0
- clair/trouves/trouve.py +216 -0
- rivage_clair-0.1.1.dist-info/METADATA +491 -0
- rivage_clair-0.1.1.dist-info/RECORD +40 -0
- rivage_clair-0.1.1.dist-info/WHEEL +4 -0
- rivage_clair-0.1.1.dist-info/entry_points.txt +2 -0
clair/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Clair -- Python-native data transformation framework for Snowflake.
|
|
2
|
+
|
|
3
|
+
Public API exports. Users import from here in their Trouve files:
|
|
4
|
+
|
|
5
|
+
from clair import Trouve, TrouveType, Column, ColumnType
|
|
6
|
+
|
|
7
|
+
Runtime context
|
|
8
|
+
---------------
|
|
9
|
+
``clair.env`` is set to the active :class:`~clair.environments.environments.Environment`
|
|
10
|
+
before Trouve modules are loaded during discovery. Import it to implement
|
|
11
|
+
feature flags based on the active environment::
|
|
12
|
+
|
|
13
|
+
import clair
|
|
14
|
+
|
|
15
|
+
trouve = Trouve(
|
|
16
|
+
sql=f"SELECT * FROM {upstream} {'WHERE is_beta = 1' if clair.env.role == 'DEV' else ''}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
``clair.run_mode`` is set to the active :class:`~clair.trouves.run_config.RunMode`
|
|
20
|
+
before Trouve modules are loaded during discovery. Use it to make SQL conditional
|
|
21
|
+
on run mode (analogous to dbt's ``is_incremental()``)::
|
|
22
|
+
|
|
23
|
+
import clair
|
|
24
|
+
from clair import RunMode
|
|
25
|
+
|
|
26
|
+
trouve = Trouve(
|
|
27
|
+
sql=f\"\"\"
|
|
28
|
+
SELECT * FROM {upstream}
|
|
29
|
+
{'WHERE created_at > dateadd(\\'day\\', -3, current_timestamp())' if clair.run_mode == RunMode.INCREMENTAL else ''}
|
|
30
|
+
\"\"\"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
When ``clair.run_mode`` is ``None`` (e.g. during ``clair dag`` or ``clair docs``),
|
|
34
|
+
the expression ``clair.run_mode == RunMode.INCREMENTAL`` evaluates to ``False``,
|
|
35
|
+
so no WHERE clause is applied — the safe default for non-run commands.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import TYPE_CHECKING
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from clair.environments.environments import Environment
|
|
44
|
+
|
|
45
|
+
from clair.trouves.column import Column, ColumnType
|
|
46
|
+
from clair.trouves.config import DatabaseDefaults, SchemaDefaults
|
|
47
|
+
from clair.trouves.run_config import (
|
|
48
|
+
IncrementalMode,
|
|
49
|
+
UpsertConfig,
|
|
50
|
+
RunConfig,
|
|
51
|
+
RunMode,
|
|
52
|
+
SOURCE,
|
|
53
|
+
TARGET,
|
|
54
|
+
)
|
|
55
|
+
from clair.trouves.test import (
|
|
56
|
+
AnyTest,
|
|
57
|
+
Test,
|
|
58
|
+
TestNotNull,
|
|
59
|
+
TestRowCount,
|
|
60
|
+
TestSql,
|
|
61
|
+
TestUnique,
|
|
62
|
+
TestUniqueColumns,
|
|
63
|
+
THIS,
|
|
64
|
+
)
|
|
65
|
+
from clair.trouves.trouve import Trouve, TrouveType
|
|
66
|
+
|
|
67
|
+
__version__ = "0.1.0"
|
|
68
|
+
|
|
69
|
+
# Set by discover_project() before Trouve modules are loaded.
|
|
70
|
+
# None when running outside of a clair discovery run.
|
|
71
|
+
env: Environment | None = None
|
|
72
|
+
|
|
73
|
+
# Set by discover_project() before Trouve modules are loaded.
|
|
74
|
+
# None when running outside of a clair discovery run.
|
|
75
|
+
run_mode: RunMode | None = None
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"AnyTest",
|
|
79
|
+
"Column",
|
|
80
|
+
"ColumnType",
|
|
81
|
+
"DatabaseDefaults",
|
|
82
|
+
"IncrementalMode",
|
|
83
|
+
"UpsertConfig",
|
|
84
|
+
"RunConfig",
|
|
85
|
+
"RunMode",
|
|
86
|
+
"SOURCE",
|
|
87
|
+
"SchemaDefaults",
|
|
88
|
+
"TARGET",
|
|
89
|
+
"Test",
|
|
90
|
+
"TestNotNull",
|
|
91
|
+
"TestRowCount",
|
|
92
|
+
"TestSql",
|
|
93
|
+
"TestUnique",
|
|
94
|
+
"TestUniqueColumns",
|
|
95
|
+
"THIS",
|
|
96
|
+
"Trouve",
|
|
97
|
+
"TrouveType",
|
|
98
|
+
]
|
clair/_logging.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Structlog configuration for the Clair CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import MutableMapping
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
_LEVEL_FG = {"debug": "blue", "info": "green", "warning": "yellow", "error": "red", "critical": "red"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _multiline_renderer(_logger: Any, _method: str, event_dict: MutableMapping[str, Any]) -> str:
|
|
18
|
+
level = event_dict.pop("level", "info")
|
|
19
|
+
event = event_dict.pop("event", "")
|
|
20
|
+
event_dict.pop("timestamp", None) # replaced by our own below
|
|
21
|
+
|
|
22
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] # millisecond precision
|
|
23
|
+
kv = {"timestamp": ts, **event_dict}
|
|
24
|
+
|
|
25
|
+
if sys.stderr.isatty():
|
|
26
|
+
level_str = click.style(f"{level:<8}", fg=_LEVEL_FG.get(level, "white"), bold=True)
|
|
27
|
+
header = f"[{level_str}] {click.style(event, bold=True)}"
|
|
28
|
+
kv_lines = "\n".join(f" {click.style(k, fg='cyan')}={click.style(str(v), fg='magenta')}" for k, v in kv.items())
|
|
29
|
+
else:
|
|
30
|
+
header = f"[{level:<8}] {event}"
|
|
31
|
+
kv_lines = "\n".join(f" {k}={v}" for k, v in kv.items())
|
|
32
|
+
|
|
33
|
+
return f"{header}\n{kv_lines}" if kv_lines else header
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def configure_logging() -> None:
|
|
37
|
+
log_format = os.environ.get("CLAIR_LOG_FORMAT", "").lower()
|
|
38
|
+
use_json = log_format == "json"
|
|
39
|
+
|
|
40
|
+
if use_json:
|
|
41
|
+
structlog.configure(
|
|
42
|
+
processors=[
|
|
43
|
+
structlog.stdlib.add_log_level,
|
|
44
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
45
|
+
structlog.processors.JSONRenderer(),
|
|
46
|
+
],
|
|
47
|
+
wrapper_class=structlog.make_filtering_bound_logger(20), # INFO
|
|
48
|
+
context_class=dict,
|
|
49
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
structlog.configure(
|
|
53
|
+
processors=[
|
|
54
|
+
structlog.stdlib.add_log_level,
|
|
55
|
+
structlog.processors.TimeStamper(fmt="%H:%M:%S", utc=False),
|
|
56
|
+
_multiline_renderer,
|
|
57
|
+
],
|
|
58
|
+
wrapper_class=structlog.make_filtering_bound_logger(20), # INFO
|
|
59
|
+
context_class=dict,
|
|
60
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
61
|
+
)
|
|
File without changes
|
clair/adapters/base.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Abstract warehouse adapter interface.
|
|
2
|
+
|
|
3
|
+
Abstracts the database connection so that future adapters (e.g. BigQuery,
|
|
4
|
+
Databricks) can be added without rewriting the runner.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QueryResult(BaseModel):
|
|
17
|
+
"""Result of executing a single SQL query against the warehouse.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
query_id: Warehouse-assigned identifier for the executed query.
|
|
21
|
+
query_url: URL to the query detail page in the warehouse console.
|
|
22
|
+
success: Whether the query completed without error.
|
|
23
|
+
error: Error message if the query failed.
|
|
24
|
+
row_count: Number of rows returned (or affected) by the query.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
query_id: str
|
|
28
|
+
query_url: str
|
|
29
|
+
success: bool
|
|
30
|
+
error: str | None = None
|
|
31
|
+
row_count: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WarehouseAdapter(ABC):
|
|
35
|
+
"""Abstract interface for warehouse connections."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def connect(self, profile: dict[str, Any]) -> None:
|
|
39
|
+
"""Establish a connection using profile credentials."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def execute(self, sql: str) -> QueryResult:
|
|
44
|
+
"""Execute a SQL statement and return the result."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def set_context(
|
|
49
|
+
self,
|
|
50
|
+
warehouse: str | None = None,
|
|
51
|
+
role: str | None = None,
|
|
52
|
+
database_name: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Set the session context (warehouse, role, database)."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def table_exists(self, database_name: str, schema_name: str, table_name: str) -> bool:
|
|
59
|
+
"""Check whether a table exists in the warehouse."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Close the connection."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def fetch_dataframe(self, full_name: str) -> pd.DataFrame:
|
|
69
|
+
"""Fetch a table as a pandas DataFrame."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def write_dataframe(
|
|
74
|
+
self,
|
|
75
|
+
dataframe: pd.DataFrame,
|
|
76
|
+
full_name: str,
|
|
77
|
+
database_name: str,
|
|
78
|
+
schema_name: str,
|
|
79
|
+
table_name: str,
|
|
80
|
+
) -> QueryResult:
|
|
81
|
+
"""Write a DataFrame to the warehouse, creating or replacing the table."""
|
|
82
|
+
...
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Snowflake adapter implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import snowflake.connector
|
|
10
|
+
from snowflake.connector.pandas_tools import write_pandas
|
|
11
|
+
from cryptography.hazmat.primitives import serialization
|
|
12
|
+
|
|
13
|
+
from clair.adapters.base import QueryResult, WarehouseAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SnowflakeAdapter(WarehouseAdapter):
|
|
17
|
+
"""Snowflake warehouse adapter using snowflake-connector-python."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._conn: snowflake.connector.SnowflakeConnection | None = None
|
|
21
|
+
self._region: str = ""
|
|
22
|
+
self._account_locator: str = ""
|
|
23
|
+
|
|
24
|
+
def connect(self, profile: dict[str, Any]) -> None:
|
|
25
|
+
"""Connect to Snowflake using profile credentials.
|
|
26
|
+
|
|
27
|
+
Supports:
|
|
28
|
+
- SSO via authenticator=externalbrowser
|
|
29
|
+
- Key pair auth via private_key_path
|
|
30
|
+
- Standard username/password
|
|
31
|
+
"""
|
|
32
|
+
self._region = profile.get("region", "")
|
|
33
|
+
self._account_locator = profile.get("account_locator", "")
|
|
34
|
+
|
|
35
|
+
connect_args: dict[str, Any] = {
|
|
36
|
+
"account": profile["account"],
|
|
37
|
+
"user": profile["user"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Auth method
|
|
41
|
+
if "authenticator" in profile:
|
|
42
|
+
connect_args["authenticator"] = profile["authenticator"]
|
|
43
|
+
elif "private_key_pem" in profile:
|
|
44
|
+
pem_content = profile["private_key_pem"]
|
|
45
|
+
passphrase = profile.get("private_key_passphrase")
|
|
46
|
+
password = passphrase.encode() if isinstance(passphrase, str) else passphrase
|
|
47
|
+
pem_bytes = pem_content.encode() if isinstance(pem_content, str) else pem_content
|
|
48
|
+
p_key = serialization.load_pem_private_key(pem_bytes, password=password)
|
|
49
|
+
connect_args["private_key"] = p_key.private_bytes(
|
|
50
|
+
encoding=serialization.Encoding.DER,
|
|
51
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
52
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
53
|
+
)
|
|
54
|
+
elif "private_key_path" in profile:
|
|
55
|
+
key_path = Path(profile["private_key_path"]).expanduser()
|
|
56
|
+
passphrase = profile.get("private_key_passphrase")
|
|
57
|
+
password = passphrase.encode() if isinstance(passphrase, str) else passphrase
|
|
58
|
+
with open(key_path, "rb") as f:
|
|
59
|
+
p_key = serialization.load_pem_private_key(f.read(), password=password)
|
|
60
|
+
connect_args["private_key"] = p_key.private_bytes(
|
|
61
|
+
encoding=serialization.Encoding.DER,
|
|
62
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
63
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
64
|
+
)
|
|
65
|
+
elif "password" in profile:
|
|
66
|
+
connect_args["password"] = profile["password"]
|
|
67
|
+
|
|
68
|
+
# Optional context
|
|
69
|
+
for key in ("warehouse", "role", "database"):
|
|
70
|
+
if key in profile:
|
|
71
|
+
connect_args[key] = profile[key]
|
|
72
|
+
|
|
73
|
+
self._conn = snowflake.connector.connect(**connect_args)
|
|
74
|
+
|
|
75
|
+
def execute(self, sql: str) -> QueryResult:
|
|
76
|
+
"""Execute SQL and return a QueryResult with query ID and URL."""
|
|
77
|
+
if self._conn is None:
|
|
78
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
79
|
+
|
|
80
|
+
cursor = self._conn.cursor()
|
|
81
|
+
try:
|
|
82
|
+
cursor.execute(sql)
|
|
83
|
+
query_id = cursor.sfqid or "unknown"
|
|
84
|
+
return QueryResult(
|
|
85
|
+
query_id=query_id,
|
|
86
|
+
query_url=self._build_query_url(query_id),
|
|
87
|
+
success=True,
|
|
88
|
+
row_count=cursor.rowcount or 0,
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
query_id = getattr(cursor, "sfqid", None) or "unknown"
|
|
92
|
+
return QueryResult(
|
|
93
|
+
query_id=query_id,
|
|
94
|
+
query_url=self._build_query_url(query_id),
|
|
95
|
+
success=False,
|
|
96
|
+
error=str(e),
|
|
97
|
+
)
|
|
98
|
+
finally:
|
|
99
|
+
cursor.close()
|
|
100
|
+
|
|
101
|
+
def table_exists(self, database_name: str, schema_name: str, table_name: str) -> bool:
|
|
102
|
+
"""Check whether a table exists in Snowflake via INFORMATION_SCHEMA."""
|
|
103
|
+
result = self.execute(
|
|
104
|
+
f"SELECT 1 FROM {database_name}.INFORMATION_SCHEMA.TABLES "
|
|
105
|
+
f"WHERE TABLE_CATALOG = '{database_name.upper()}' "
|
|
106
|
+
f"AND TABLE_SCHEMA = '{schema_name.upper()}' "
|
|
107
|
+
f"AND TABLE_NAME = '{table_name.upper()}'"
|
|
108
|
+
)
|
|
109
|
+
return result.row_count > 0
|
|
110
|
+
|
|
111
|
+
def set_context(
|
|
112
|
+
self,
|
|
113
|
+
warehouse: str | None = None,
|
|
114
|
+
role: str | None = None,
|
|
115
|
+
database_name: str | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Set session context by executing USE commands.
|
|
118
|
+
|
|
119
|
+
Only issues USE statements for values that are non-None and non-empty.
|
|
120
|
+
Order: ROLE first (affects permissions), then WAREHOUSE, then DATABASE.
|
|
121
|
+
"""
|
|
122
|
+
if self._conn is None:
|
|
123
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
124
|
+
|
|
125
|
+
cursor = self._conn.cursor()
|
|
126
|
+
try:
|
|
127
|
+
if role and role.strip():
|
|
128
|
+
cursor.execute(f"USE ROLE {role}")
|
|
129
|
+
if warehouse and warehouse.strip():
|
|
130
|
+
cursor.execute(f"USE WAREHOUSE {warehouse}")
|
|
131
|
+
if database_name and database_name.strip():
|
|
132
|
+
cursor.execute(f"USE DATABASE {database_name}")
|
|
133
|
+
finally:
|
|
134
|
+
cursor.close()
|
|
135
|
+
|
|
136
|
+
def fetch_dataframe(self, full_name: str) -> pd.DataFrame:
|
|
137
|
+
"""Fetch an entire Snowflake table as a pandas DataFrame."""
|
|
138
|
+
if self._conn is None:
|
|
139
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
140
|
+
|
|
141
|
+
cursor = self._conn.cursor()
|
|
142
|
+
try:
|
|
143
|
+
cursor.execute(f"SELECT * FROM {full_name}")
|
|
144
|
+
dataframe = cursor.fetch_pandas_all()
|
|
145
|
+
dataframe.columns = dataframe.columns.str.lower()
|
|
146
|
+
return dataframe
|
|
147
|
+
finally:
|
|
148
|
+
cursor.close()
|
|
149
|
+
|
|
150
|
+
def write_dataframe(
|
|
151
|
+
self,
|
|
152
|
+
dataframe: pd.DataFrame,
|
|
153
|
+
full_name: str,
|
|
154
|
+
database_name: str,
|
|
155
|
+
schema_name: str,
|
|
156
|
+
table_name: str,
|
|
157
|
+
) -> QueryResult:
|
|
158
|
+
"""Write a DataFrame to Snowflake, creating or replacing the table."""
|
|
159
|
+
if self._conn is None:
|
|
160
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
161
|
+
|
|
162
|
+
success, num_chunks, num_rows, output = write_pandas(
|
|
163
|
+
conn=self._conn,
|
|
164
|
+
df=dataframe,
|
|
165
|
+
table_name=table_name.upper(),
|
|
166
|
+
database=database_name.upper(),
|
|
167
|
+
schema=schema_name.upper(),
|
|
168
|
+
auto_create_table=True,
|
|
169
|
+
overwrite=True,
|
|
170
|
+
quote_identifiers=False,
|
|
171
|
+
)
|
|
172
|
+
# query_id and query_url are empty: write_dataframe uses CREATE TEMP STAGE
|
|
173
|
+
# and PUT under the hood, not a single queryable SQL statement.
|
|
174
|
+
return QueryResult(
|
|
175
|
+
query_id="",
|
|
176
|
+
query_url="",
|
|
177
|
+
success=success,
|
|
178
|
+
row_count=num_rows,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
"""Close the Snowflake connection."""
|
|
183
|
+
if self._conn:
|
|
184
|
+
self._conn.close()
|
|
185
|
+
self._conn = None
|
|
186
|
+
|
|
187
|
+
def _build_query_url(self, query_id: str) -> str:
|
|
188
|
+
"""Build a Snowflake console URL for a query ID."""
|
|
189
|
+
return (
|
|
190
|
+
f"https://app.snowflake.com/{self._region}/{self._account_locator}"
|
|
191
|
+
f"/#/compute/history/queries/{query_id}/detail"
|
|
192
|
+
)
|
clair/cli/__init__.py
ADDED
|
File without changes
|