sqliac-snowflake 0.1.0__tar.gz
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.
- sqliac_snowflake-0.1.0/PKG-INFO +7 -0
- sqliac_snowflake-0.1.0/pyproject.toml +22 -0
- sqliac_snowflake-0.1.0/setup.cfg +4 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake/__init__.py +15 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake/adapter.py +169 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake.egg-info/PKG-INFO +7 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake.egg-info/SOURCES.txt +9 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake.egg-info/dependency_links.txt +1 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake.egg-info/entry_points.txt +2 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake.egg-info/requires.txt +3 -0
- sqliac_snowflake-0.1.0/src/sqliac_snowflake.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqliac-snowflake"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"sqliac>=0.1.0,<0.2",
|
|
7
|
+
"snowflake-connector-python[secure-local-storage]",
|
|
8
|
+
"cryptography",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[tool.setuptools.packages.find]
|
|
12
|
+
where = ["src"]
|
|
13
|
+
|
|
14
|
+
[project.entry-points."sqliac.adapters"]
|
|
15
|
+
snowflake = "sqliac_snowflake.adapter:SnowflakeAdapter"
|
|
16
|
+
|
|
17
|
+
[tool.uv.sources]
|
|
18
|
+
sqliac = { workspace = true }
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Snowflake adapter package for sqliac."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .src.adapter import (
|
|
6
|
+
SnowflakeAdapter,
|
|
7
|
+
SnowflakeConnectionManager,
|
|
8
|
+
SnowflakeCredentials,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SnowflakeAdapter",
|
|
13
|
+
"SnowflakeConnectionManager",
|
|
14
|
+
"SnowflakeCredentials",
|
|
15
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Type
|
|
5
|
+
|
|
6
|
+
import snowflake.connector as sc
|
|
7
|
+
from cryptography.hazmat.backends import default_backend
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
from snowflake.connector.cursor import SnowflakeCursor
|
|
10
|
+
|
|
11
|
+
from sqliac.adapters.base import BaseAdapter, BaseCredentials
|
|
12
|
+
from sqliac.adapters.connection_manager import ConnectionState, ThreadConnectionManager
|
|
13
|
+
from sqliac.constants import Paths
|
|
14
|
+
from sqliac.errors import RustyError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SnowflakeCredentials(BaseCredentials):
|
|
21
|
+
account: str
|
|
22
|
+
user: str
|
|
23
|
+
warehouse: str | None = None
|
|
24
|
+
database: str | None = None
|
|
25
|
+
schema: str | None = None
|
|
26
|
+
role: str | None = None
|
|
27
|
+
password: str | None = None
|
|
28
|
+
private_key_path: str | None = None
|
|
29
|
+
private_key: str | None = None
|
|
30
|
+
private_key_passphrase: str | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def dialect(self) -> str:
|
|
34
|
+
return "snowflake"
|
|
35
|
+
|
|
36
|
+
def __post_init__(self):
|
|
37
|
+
if not all([self.account, self.user]):
|
|
38
|
+
raise RustyError(error="Snowflake credentials must include account, user.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SnowflakeConnectionManager(ThreadConnectionManager):
|
|
42
|
+
"""Manages Snowflake connections with thread safety."""
|
|
43
|
+
|
|
44
|
+
CredentialsClass = SnowflakeCredentials
|
|
45
|
+
|
|
46
|
+
def open(self, connection: ConnectionState) -> ConnectionState:
|
|
47
|
+
"""Open a new Snowflake database connection."""
|
|
48
|
+
try:
|
|
49
|
+
conn_kwargs = self._build_connect_args()
|
|
50
|
+
account = conn_kwargs.get("account", "unknown-account")
|
|
51
|
+
logger.info(f"'{account}' account connection thread: '{connection.name}'")
|
|
52
|
+
conn = sc.connect(**conn_kwargs)
|
|
53
|
+
connection.handle = conn
|
|
54
|
+
connection.state = "open"
|
|
55
|
+
return connection
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(f"failed to open connection for thread {connection.name}: {e}")
|
|
59
|
+
connection.state = "fail"
|
|
60
|
+
connection.handle = None
|
|
61
|
+
raise RustyError(
|
|
62
|
+
error="Failed to open Snowflake connection", help=str(e)
|
|
63
|
+
) from e
|
|
64
|
+
|
|
65
|
+
def _build_connect_args(self) -> dict[str, Any]:
|
|
66
|
+
"""Build connection arguments for Snowflake."""
|
|
67
|
+
connect_args: dict[str, Any] = {
|
|
68
|
+
"account": self.credentials.account,
|
|
69
|
+
"user": self.credentials.user,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Add optional parameters if provided
|
|
73
|
+
if self.credentials.database:
|
|
74
|
+
connect_args["database"] = self.credentials.database
|
|
75
|
+
if self.credentials.schema:
|
|
76
|
+
connect_args["schema"] = self.credentials.schema
|
|
77
|
+
if self.credentials.warehouse:
|
|
78
|
+
connect_args["warehouse"] = self.credentials.warehouse
|
|
79
|
+
if self.credentials.role:
|
|
80
|
+
connect_args["role"] = self.credentials.role
|
|
81
|
+
|
|
82
|
+
# Authentication: prioritize private key over password
|
|
83
|
+
private_key_bytes = self._get_private_key_bytes()
|
|
84
|
+
if private_key_bytes:
|
|
85
|
+
connect_args["private_key"] = private_key_bytes
|
|
86
|
+
connect_args["authenticator"] = "SNOWFLAKE_JWT"
|
|
87
|
+
elif self.credentials.password:
|
|
88
|
+
connect_args["password"] = self.credentials.password
|
|
89
|
+
else:
|
|
90
|
+
raise RustyError(
|
|
91
|
+
error="credentials error",
|
|
92
|
+
help="either password or private_key must be provided for authentication",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return connect_args
|
|
96
|
+
|
|
97
|
+
def _get_private_key_bytes(self) -> bytes | None:
|
|
98
|
+
"""Process private key from path or string."""
|
|
99
|
+
private_key_path = self.credentials.private_key_path
|
|
100
|
+
private_key_str = self.credentials.private_key
|
|
101
|
+
passphrase_str = self.credentials.private_key_passphrase
|
|
102
|
+
|
|
103
|
+
if not private_key_path and not private_key_str:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
passphrase: bytes | None = None
|
|
107
|
+
if passphrase_str:
|
|
108
|
+
passphrase = passphrase_str.encode("utf-8")
|
|
109
|
+
|
|
110
|
+
p_key = None
|
|
111
|
+
if private_key_path:
|
|
112
|
+
# Resolve path relative to project config directory
|
|
113
|
+
path_obj = Paths.CONFIG_DIR / Paths.PROVIDER_DIR / private_key_path
|
|
114
|
+
target_dir = self._resolve_path(path_obj)
|
|
115
|
+
|
|
116
|
+
if not target_dir.exists() or not target_dir.is_file():
|
|
117
|
+
raise RustyError(
|
|
118
|
+
error="not a valid `private_key_path`",
|
|
119
|
+
file=str(target_dir),
|
|
120
|
+
help=f"save the key file in the configuration directory: `{Paths.CONFIG_DIR / Paths.PROVIDER_DIR}`",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
with open(target_dir, "rb") as key_file:
|
|
124
|
+
p_key = serialization.load_pem_private_key(
|
|
125
|
+
key_file.read(),
|
|
126
|
+
password=passphrase,
|
|
127
|
+
backend=default_backend(),
|
|
128
|
+
)
|
|
129
|
+
elif private_key_str:
|
|
130
|
+
private_key_clean = private_key_str.replace("\\n", "\n")
|
|
131
|
+
p_key_bytes = private_key_clean.encode("utf-8")
|
|
132
|
+
p_key = serialization.load_pem_private_key(
|
|
133
|
+
p_key_bytes,
|
|
134
|
+
password=passphrase,
|
|
135
|
+
backend=default_backend(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if p_key is None:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
return p_key.private_bytes(
|
|
142
|
+
encoding=serialization.Encoding.DER,
|
|
143
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
144
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _resolve_path(path: Path):
|
|
149
|
+
"""Resolve path relative to current working directory."""
|
|
150
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SnowflakeAdapter(BaseAdapter):
|
|
154
|
+
"""Snowflake implementation handling enterprise credential variables."""
|
|
155
|
+
|
|
156
|
+
credentials: SnowflakeCredentials
|
|
157
|
+
ConnectionManager = SnowflakeConnectionManager
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def dialect(self) -> str:
|
|
161
|
+
return "snowflake"
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def credentials_class(self) -> Type[SnowflakeCredentials]:
|
|
165
|
+
return SnowflakeCredentials
|
|
166
|
+
|
|
167
|
+
def _fetch_row(self, cursor: SnowflakeCursor) -> BaseAdapter.Row:
|
|
168
|
+
"""Fetch row from the Snowflake cursor."""
|
|
169
|
+
return cursor.fetchone()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/sqliac_snowflake/__init__.py
|
|
3
|
+
src/sqliac_snowflake/adapter.py
|
|
4
|
+
src/sqliac_snowflake.egg-info/PKG-INFO
|
|
5
|
+
src/sqliac_snowflake.egg-info/SOURCES.txt
|
|
6
|
+
src/sqliac_snowflake.egg-info/dependency_links.txt
|
|
7
|
+
src/sqliac_snowflake.egg-info/entry_points.txt
|
|
8
|
+
src/sqliac_snowflake.egg-info/requires.txt
|
|
9
|
+
src/sqliac_snowflake.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqliac_snowflake
|