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 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