snowlib 0.1.0__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.
- snowlib/__init__.py +82 -0
- snowlib/_data/connections.toml.example +73 -0
- snowlib/_data/test_config.toml.example +20 -0
- snowlib/config/__init__.py +12 -0
- snowlib/config/config.py +89 -0
- snowlib/config/paths.py +133 -0
- snowlib/connection/__init__.py +5 -0
- snowlib/connection/base.py +181 -0
- snowlib/connection/connection.py +161 -0
- snowlib/io/__init__.py +12 -0
- snowlib/io/names.py +242 -0
- snowlib/io/read.py +125 -0
- snowlib/io/write.py +103 -0
- snowlib/primitives/__init__.py +60 -0
- snowlib/primitives/context.py +150 -0
- snowlib/primitives/data.py +176 -0
- snowlib/primitives/execution.py +274 -0
- snowlib/primitives/job.py +121 -0
- snowlib/primitives/metadata.py +277 -0
- snowlib/primitives/result.py +134 -0
- snowlib/primitives/streaming.py +63 -0
- snowlib/py.typed +0 -0
- snowlib-0.1.0.dist-info/METADATA +93 -0
- snowlib-0.1.0.dist-info/RECORD +28 -0
- snowlib-0.1.0.dist-info/WHEEL +5 -0
- snowlib-0.1.0.dist-info/licenses/LICENSE +21 -0
- snowlib-0.1.0.dist-info/licenses/licenses/THIRD_PARTY.md +130 -0
- snowlib-0.1.0.dist-info/top_level.txt +1 -0
snowlib/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snowlib - Python-Snowflake utilities
|
|
3
|
+
|
|
4
|
+
Code is organized in layers
|
|
5
|
+
- config/ and connection/ as the interface for Snowflake packages
|
|
6
|
+
- primitives/ wraps these in low-level functions
|
|
7
|
+
- io/ is the beginning of higher-level convenience functions
|
|
8
|
+
|
|
9
|
+
Likely to expand to cortex and leaning more into snowpark
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Layer 1: Core connectivity
|
|
13
|
+
from snowlib.config import load_profile, list_profiles
|
|
14
|
+
from snowlib.connection import SnowflakeConnector, SnowparkConnector
|
|
15
|
+
|
|
16
|
+
# Layer 2: Primitives
|
|
17
|
+
from snowlib.primitives import (
|
|
18
|
+
# Context
|
|
19
|
+
SnowflakeContext,
|
|
20
|
+
QueryResult,
|
|
21
|
+
QueryJob,
|
|
22
|
+
# Execution
|
|
23
|
+
execute_sql,
|
|
24
|
+
execute_sql_async,
|
|
25
|
+
fetch_one,
|
|
26
|
+
fetch_all,
|
|
27
|
+
fetch_df,
|
|
28
|
+
execute_block,
|
|
29
|
+
# Data I/O
|
|
30
|
+
read_table,
|
|
31
|
+
write_table,
|
|
32
|
+
# Metadata
|
|
33
|
+
get_columns,
|
|
34
|
+
table_exists,
|
|
35
|
+
list_tables,
|
|
36
|
+
get_current_database,
|
|
37
|
+
get_current_schema,
|
|
38
|
+
get_current_warehouse,
|
|
39
|
+
get_current_role,
|
|
40
|
+
# Streaming
|
|
41
|
+
fetch_batches,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Layer 3: I/O with name resolution
|
|
45
|
+
from snowlib.io import read, write, query
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.0"
|
|
48
|
+
__all__ = [
|
|
49
|
+
# Layer 1
|
|
50
|
+
"load_profile",
|
|
51
|
+
"list_profiles",
|
|
52
|
+
"SnowflakeConnector",
|
|
53
|
+
"SnowparkConnector",
|
|
54
|
+
# Layer 2: Context
|
|
55
|
+
"SnowflakeContext",
|
|
56
|
+
"QueryResult",
|
|
57
|
+
"QueryJob",
|
|
58
|
+
# Layer 2: Execution
|
|
59
|
+
"execute_sql",
|
|
60
|
+
"execute_sql_async",
|
|
61
|
+
"fetch_one",
|
|
62
|
+
"fetch_all",
|
|
63
|
+
"fetch_df",
|
|
64
|
+
"execute_block",
|
|
65
|
+
# Layer 2: Data I/O
|
|
66
|
+
"read_table",
|
|
67
|
+
"write_table",
|
|
68
|
+
# Layer 2: Metadata
|
|
69
|
+
"get_columns",
|
|
70
|
+
"table_exists",
|
|
71
|
+
"list_tables",
|
|
72
|
+
"get_current_database",
|
|
73
|
+
"get_current_schema",
|
|
74
|
+
"get_current_warehouse",
|
|
75
|
+
"get_current_role",
|
|
76
|
+
# Layer 2: Streaming
|
|
77
|
+
"fetch_batches",
|
|
78
|
+
# Layer 3: I/O
|
|
79
|
+
"read",
|
|
80
|
+
"write",
|
|
81
|
+
"query",
|
|
82
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Snowflake Connection Profiles
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to 'connections.toml' and fill in your connectivity details.
|
|
4
|
+
#
|
|
5
|
+
# These are just examples- you can name the profiles as you like.
|
|
6
|
+
|
|
7
|
+
# Default profile - SSO/browser authentication
|
|
8
|
+
[default]
|
|
9
|
+
account = "your-account.region"
|
|
10
|
+
user = "your_username"
|
|
11
|
+
warehouse = "YOUR_WAREHOUSE"
|
|
12
|
+
database = "YOUR_DATABASE"
|
|
13
|
+
authenticator = "externalbrowser"
|
|
14
|
+
|
|
15
|
+
# Development environment
|
|
16
|
+
[dev]
|
|
17
|
+
account = "your-account.region"
|
|
18
|
+
user = "your_username"
|
|
19
|
+
warehouse = "DEV_WH"
|
|
20
|
+
database = "DEV_DATABASE"
|
|
21
|
+
schema = "DEV_SCHEMA"
|
|
22
|
+
authenticator = "externalbrowser"
|
|
23
|
+
|
|
24
|
+
# Production environment
|
|
25
|
+
[prod]
|
|
26
|
+
account = "your-account.region"
|
|
27
|
+
user = "your_username"
|
|
28
|
+
warehouse = "PROD_WH"
|
|
29
|
+
database = "PROD_DATABASE"
|
|
30
|
+
role = "PROD_ROLE"
|
|
31
|
+
authenticator = "externalbrowser"
|
|
32
|
+
|
|
33
|
+
# Testing - password authentication
|
|
34
|
+
[test]
|
|
35
|
+
account = "your-account.region"
|
|
36
|
+
user = "test_user"
|
|
37
|
+
warehouse = "TEST_WH"
|
|
38
|
+
database = "TEST_DATABASE"
|
|
39
|
+
authenticator = "snowflake"
|
|
40
|
+
|
|
41
|
+
# Interactive debugging
|
|
42
|
+
[debug]
|
|
43
|
+
account = "your-account.region"
|
|
44
|
+
user = "your_username"
|
|
45
|
+
warehouse = "DEV_WH"
|
|
46
|
+
database = "DEV_DATABASE"
|
|
47
|
+
schema = "SCRATCH"
|
|
48
|
+
role = "DEV_ROLE"
|
|
49
|
+
authenticator = "externalbrowser"
|
|
50
|
+
|
|
51
|
+
# Keypair authentication - environment variable
|
|
52
|
+
[service]
|
|
53
|
+
account = "your-account.region"
|
|
54
|
+
user = "service_account"
|
|
55
|
+
warehouse = "SERVICE_WH"
|
|
56
|
+
database = "SERVICE_DATABASE"
|
|
57
|
+
role = "SERVICE_ROLE"
|
|
58
|
+
authenticator = "SNOWFLAKE_JWT"
|
|
59
|
+
private_key_file = "~/.snowflake/rsa_key.p8"
|
|
60
|
+
private_key_passphrase_env = "ENV_VAR_WITH_PASSPHRASE"
|
|
61
|
+
|
|
62
|
+
# Keypair authentication - Windows Credential Manager
|
|
63
|
+
[service_keyring]
|
|
64
|
+
account = "your-account.region"
|
|
65
|
+
user = "service_account"
|
|
66
|
+
warehouse = "SERVICE_WH"
|
|
67
|
+
database = "SERVICE_DATABASE"
|
|
68
|
+
role = "SERVICE_ROLE"
|
|
69
|
+
authenticator = "SNOWFLAKE_JWT"
|
|
70
|
+
private_key_file = "~/.snowflake/rsa_key.p8"
|
|
71
|
+
use_keyring = true
|
|
72
|
+
keyring_service = "snowflake"
|
|
73
|
+
keyring_username = "service_account_keypair"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Example Test Configuration for pytest
|
|
2
|
+
# Copy this to test_config.toml and customize for your environment
|
|
3
|
+
#
|
|
4
|
+
# This file is used by the snowlib test suite (pytest) to configure
|
|
5
|
+
# test database connections and test data locations.
|
|
6
|
+
# NOT for general-purpose connection configuration - use connections.toml for that.
|
|
7
|
+
|
|
8
|
+
[test]
|
|
9
|
+
# Profile to use for tests (from connections.toml)
|
|
10
|
+
# This profile should have permissions to create/drop/truncate tables
|
|
11
|
+
profile = "test"
|
|
12
|
+
|
|
13
|
+
# Database and schema for test operations
|
|
14
|
+
# You may want to provide a dedicated test schema, as tests will create and drop tables
|
|
15
|
+
database = "YOUR_TEST_DATABASE"
|
|
16
|
+
schema = "YOUR_TEST_SCHEMA"
|
|
17
|
+
|
|
18
|
+
# Test table names (will be created/dropped during tests)
|
|
19
|
+
write_table = "TEST_WRITE_TABLE"
|
|
20
|
+
temp_table = "TEST_TEMP_TABLE"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Configuration module exports."""
|
|
2
|
+
|
|
3
|
+
from .config import load_profile, list_profiles
|
|
4
|
+
from .paths import resolve_config_path, get_default_config_path, CONF_DIR
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"load_profile",
|
|
8
|
+
"list_profiles",
|
|
9
|
+
"resolve_config_path",
|
|
10
|
+
"get_default_config_path",
|
|
11
|
+
"CONF_DIR",
|
|
12
|
+
]
|
snowlib/config/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration loading for Snowflake connection profiles."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Union, Optional, Any
|
|
6
|
+
|
|
7
|
+
# Use tomllib for Python 3.11+, fallback to tomli for older versions
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else:
|
|
11
|
+
try:
|
|
12
|
+
import tomli as tomllib
|
|
13
|
+
except ImportError:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"Python < 3.11 requires 'tomli' package. " +
|
|
16
|
+
"Install it with: pip install tomli"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .paths import resolve_config_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_profile(
|
|
23
|
+
profile: str,
|
|
24
|
+
path: Optional[Union[str, Path]] = None
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Load a Snowflake connection profile from connections.toml file.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
profile: Name of the profile to load
|
|
31
|
+
path: Optional explicit path to connections.toml file.
|
|
32
|
+
If None, searches in standard locations.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dictionary containing connection parameters for the profile
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
FileNotFoundError: If connections.toml file is not found
|
|
39
|
+
KeyError: If the specified profile doesn't exist in the file
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> config = load_profile("dev")
|
|
43
|
+
>>> config
|
|
44
|
+
{'account': 'myaccount', 'user': 'myuser', ...}
|
|
45
|
+
"""
|
|
46
|
+
config_file = resolve_config_path(path)
|
|
47
|
+
|
|
48
|
+
if not config_file.exists():
|
|
49
|
+
raise FileNotFoundError(
|
|
50
|
+
f"Snowflake configuration file not found at {config_file}. " +
|
|
51
|
+
"Create a connections.toml file or see connections.toml.example for template."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
with open(config_file, "rb") as f:
|
|
55
|
+
all_profiles = tomllib.load(f)
|
|
56
|
+
|
|
57
|
+
if profile not in all_profiles:
|
|
58
|
+
available = ", ".join(all_profiles.keys())
|
|
59
|
+
raise KeyError(
|
|
60
|
+
f"Profile '{profile}' not found in {config_file}. " +
|
|
61
|
+
f"Available profiles: {available}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return all_profiles[profile]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_profiles(path: Optional[Union[str, Path]] = None) -> list[str]:
|
|
68
|
+
"""
|
|
69
|
+
List all available profiles in connections.toml file.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
path: Optional explicit path to connections.toml file
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of profile names
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> list_profiles()
|
|
79
|
+
['default', 'dev', 'prod']
|
|
80
|
+
"""
|
|
81
|
+
config_file = resolve_config_path(path)
|
|
82
|
+
|
|
83
|
+
if not config_file.exists():
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
with open(config_file, "rb") as f:
|
|
87
|
+
all_profiles = tomllib.load(f)
|
|
88
|
+
|
|
89
|
+
return list(all_profiles.keys())
|
snowlib/config/paths.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Path resolution for snowlib configuration files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from importlib.resources import files as importlib_files
|
|
9
|
+
except ImportError:
|
|
10
|
+
# Python < 3.9 fallback
|
|
11
|
+
from importlib_resources import files as importlib_files # type: ignore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_config_directory() -> Path:
|
|
15
|
+
"""
|
|
16
|
+
Get the configuration directory for snowlib.
|
|
17
|
+
|
|
18
|
+
Uses the dotfile approach (~/.snowlib) to align with other Snowflake ecosystem
|
|
19
|
+
tools like snowsql (~/.snowsql) and snowflake CLI (~/.snowflake).
|
|
20
|
+
|
|
21
|
+
Priority order:
|
|
22
|
+
1. SNOWLIB_CONFIG_DIR environment variable (override)
|
|
23
|
+
2. ~/.snowlib/ (dotfile directory in user home)
|
|
24
|
+
|
|
25
|
+
The ~/.snowlib path resolves to:
|
|
26
|
+
- Windows: C:\\Users\\username\\.snowlib
|
|
27
|
+
- Linux: /home/username/.snowlib
|
|
28
|
+
- macOS: /Users/username/.snowlib
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Path: Configuration directory path
|
|
32
|
+
"""
|
|
33
|
+
# 1. Environment variable override (highest priority)
|
|
34
|
+
env_config_dir = os.getenv("SNOWLIB_CONFIG_DIR")
|
|
35
|
+
if env_config_dir:
|
|
36
|
+
return Path(env_config_dir)
|
|
37
|
+
|
|
38
|
+
# 2. Use dotfile directory in user home (like .snowsql, .snowflake)
|
|
39
|
+
dotfile_config = Path.home() / ".snowlib"
|
|
40
|
+
dotfile_config.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return dotfile_config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_example_files_dir() -> Path:
|
|
45
|
+
"""
|
|
46
|
+
Get the directory containing example configuration files from the installed package.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Path: Directory containing .example files
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ModuleNotFoundError: If the snowlib package is not installed
|
|
53
|
+
FileNotFoundError: If the _data directory is not found in the package
|
|
54
|
+
"""
|
|
55
|
+
package_data = importlib_files("snowlib") / "_data"
|
|
56
|
+
return Path(str(package_data))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Configuration directory (dynamically resolved)
|
|
60
|
+
CONF_DIR = _get_config_directory()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_default_config_path() -> Path:
|
|
64
|
+
"""
|
|
65
|
+
Get the path to connections.toml configuration file.
|
|
66
|
+
|
|
67
|
+
Searches in priority order:
|
|
68
|
+
1. SNOWLIB_CONFIG_DIR/connections.toml (environment override)
|
|
69
|
+
2. ./SNOWLIB_HOME_DIR/connections.toml (development mode)
|
|
70
|
+
3. Platform user config/snowlib/connections.toml (production mode)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Path: The path to connections.toml
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
FileNotFoundError: If connections.toml doesn't exist in any location
|
|
77
|
+
"""
|
|
78
|
+
config_path = CONF_DIR / "connections.toml"
|
|
79
|
+
|
|
80
|
+
if not config_path.exists():
|
|
81
|
+
# Try to help user by showing where example files are
|
|
82
|
+
example_dir = _get_example_files_dir()
|
|
83
|
+
example_file = example_dir / "connections.toml.example"
|
|
84
|
+
|
|
85
|
+
error_msg = (
|
|
86
|
+
f"Configuration file 'connections.toml' not found at: {config_path}\n\n"
|
|
87
|
+
f"To create it:\n"
|
|
88
|
+
f"1. Copy example: {example_file}\n"
|
|
89
|
+
f"2. To: {config_path}\n"
|
|
90
|
+
f"3. Edit with your connection details\n\n"
|
|
91
|
+
f"Configuration directory priority:\n"
|
|
92
|
+
f" 1. SNOWLIB_CONFIG_DIR environment variable (if set)\n"
|
|
93
|
+
f" 2. ~/.snowlib/ (dotfile directory)\n"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
raise FileNotFoundError(error_msg)
|
|
97
|
+
|
|
98
|
+
return config_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_config_path(path: Optional[Union[str, Path]] = None) -> Path:
|
|
102
|
+
"""
|
|
103
|
+
Resolve the configuration file path.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: Optional explicit path to connections.toml file.
|
|
107
|
+
If None, uses default resolution logic.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Path: Resolved path object
|
|
111
|
+
"""
|
|
112
|
+
if path:
|
|
113
|
+
return Path(path)
|
|
114
|
+
return get_default_config_path()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_example_config_path(filename: str = "connections.toml.example") -> Path:
|
|
118
|
+
"""
|
|
119
|
+
Get path to an example configuration file from the package.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
filename: Name of the example file (default: connections.toml.example)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Path: Path to the example file
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
FileNotFoundError: If the example file doesn't exist
|
|
129
|
+
"""
|
|
130
|
+
example_path = _get_example_files_dir() / filename
|
|
131
|
+
if not example_path.exists():
|
|
132
|
+
raise FileNotFoundError(f"Example file not found: {example_path}")
|
|
133
|
+
return example_path
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Base connector class with shared profile and authentication logic."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Any, Dict
|
|
6
|
+
from pydantic import SecretStr
|
|
7
|
+
import keyring
|
|
8
|
+
|
|
9
|
+
from ..config import load_profile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseConnector:
|
|
13
|
+
"""
|
|
14
|
+
Base class for Snowflake connectors with TOML profile support.
|
|
15
|
+
|
|
16
|
+
This class handles common functionality for both SnowflakeConnector and
|
|
17
|
+
SnowparkConnector, including:
|
|
18
|
+
- Loading connection profiles from TOML configuration
|
|
19
|
+
- Merging runtime parameter overrides
|
|
20
|
+
- Processing keypair authentication with secure passphrase handling
|
|
21
|
+
|
|
22
|
+
Derived classes should implement their specific connection logic.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
profile: Name of the profile to load from connections.toml
|
|
26
|
+
**kwargs: Additional parameters to override profile settings
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, profile: str, **kwargs: Any) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Initialize the connector with a configuration profile.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
profile: Name of the connection profile to load
|
|
35
|
+
**kwargs: Override any connection parameters from the profile
|
|
36
|
+
"""
|
|
37
|
+
# Load configuration from TOML file
|
|
38
|
+
self._cfg: Dict[str, Any] = load_profile(profile)
|
|
39
|
+
|
|
40
|
+
# Allow runtime overrides
|
|
41
|
+
self._cfg.update(kwargs)
|
|
42
|
+
|
|
43
|
+
# Store profile name for debugging/repr
|
|
44
|
+
self._profile = profile
|
|
45
|
+
|
|
46
|
+
# Process keypair authentication if specified
|
|
47
|
+
self._process_keypair_auth()
|
|
48
|
+
|
|
49
|
+
def _process_keypair_auth(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Process keypair authentication configuration.
|
|
52
|
+
|
|
53
|
+
If authenticator is SNOWFLAKE_JWT and private_key_file is specified,
|
|
54
|
+
retrieves the passphrase from configured sources and prepares parameters
|
|
55
|
+
for the Snowflake connector.
|
|
56
|
+
|
|
57
|
+
Supported passphrase sources (opt-in only):
|
|
58
|
+
- Environment variable (if private_key_passphrase_env is set)
|
|
59
|
+
- System keyring/Windows Credential Manager (if use_keyring or keyring_* params are set)
|
|
60
|
+
- None (for unencrypted keys on encrypted drives)
|
|
61
|
+
|
|
62
|
+
Security:
|
|
63
|
+
- Only absolute paths or home directory (~) are allowed
|
|
64
|
+
- Relative paths are rejected to prevent keys in version control
|
|
65
|
+
- Direct passphrase in config is NOT supported
|
|
66
|
+
|
|
67
|
+
Note: Snowflake connector handles the actual key loading,
|
|
68
|
+
we just provide private_key_file and private_key_file_pwd.
|
|
69
|
+
"""
|
|
70
|
+
auth = self._cfg.get("authenticator", "").upper()
|
|
71
|
+
|
|
72
|
+
if auth != "SNOWFLAKE_JWT":
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
private_key_file = self._cfg.get("private_key_file")
|
|
76
|
+
if not private_key_file:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Keypair authentication requires 'private_key_file' in profile configuration"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Resolve file path
|
|
82
|
+
key_path = Path(private_key_file).expanduser()
|
|
83
|
+
|
|
84
|
+
# Only allow absolute paths or home directory expansion
|
|
85
|
+
# Reject relative paths to prevent keys being stored in version control
|
|
86
|
+
if not key_path.is_absolute():
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Private key path must be absolute or use ~ for home directory.\n" +
|
|
89
|
+
f"Got: {private_key_file}\n\n" +
|
|
90
|
+
f"Relative paths are not supported to prevent accidentally committing " +
|
|
91
|
+
f"keys to version control.\n\n" +
|
|
92
|
+
f"Valid examples:\n" +
|
|
93
|
+
f" - Absolute path: K:/snow2.p8 or C:/Users/you/.snowflake/key.p8\n" +
|
|
94
|
+
f" - Home directory: ~/.snowflake/rsa_key.p8\n" +
|
|
95
|
+
f" - Environment variable: $HOME/.snowflake/rsa_key.p8"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if not key_path.exists():
|
|
99
|
+
raise FileNotFoundError(
|
|
100
|
+
f"Private key file not found: {key_path}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Update the path to the resolved absolute path
|
|
104
|
+
self._cfg["private_key_file"] = str(key_path)
|
|
105
|
+
|
|
106
|
+
# Get passphrase from configured sources
|
|
107
|
+
passphrase = self._get_key_passphrase()
|
|
108
|
+
|
|
109
|
+
# Set private_key_file_pwd for Snowflake connector
|
|
110
|
+
if passphrase:
|
|
111
|
+
self._cfg["private_key_file_pwd"] = passphrase.get_secret_value()
|
|
112
|
+
|
|
113
|
+
# Remove our custom config keys that Snowflake doesn't understand
|
|
114
|
+
self._cfg.pop("private_key_passphrase_env", None)
|
|
115
|
+
self._cfg.pop("use_keyring", None)
|
|
116
|
+
self._cfg.pop("keyring_service", None)
|
|
117
|
+
self._cfg.pop("keyring_username", None)
|
|
118
|
+
|
|
119
|
+
def _get_key_passphrase(self) -> Optional[SecretStr]:
|
|
120
|
+
"""
|
|
121
|
+
Get the private key passphrase from configured sources.
|
|
122
|
+
|
|
123
|
+
Priority order (only uses sources explicitly configured):
|
|
124
|
+
1. Custom environment variable (if private_key_passphrase_env is set)
|
|
125
|
+
2. System keyring (if use_keyring=true or keyring_* params are set)
|
|
126
|
+
3. None (for unencrypted keys)
|
|
127
|
+
|
|
128
|
+
Note: Direct passphrase in config is NOT supported for security reasons.
|
|
129
|
+
Use environment variables or Windows Credential Manager instead.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Passphrase as SecretStr, or None if not found/not configured
|
|
133
|
+
"""
|
|
134
|
+
# 1. Custom environment variable (only if explicitly configured)
|
|
135
|
+
if "private_key_passphrase_env" in self._cfg:
|
|
136
|
+
env_var = self._cfg["private_key_passphrase_env"]
|
|
137
|
+
pwd = os.getenv(env_var)
|
|
138
|
+
if pwd:
|
|
139
|
+
return SecretStr(pwd)
|
|
140
|
+
else:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Environment variable '{env_var}' specified in " +
|
|
143
|
+
f"'private_key_passphrase_env' but not found in environment"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# 2. System keyring (only if explicitly configured)
|
|
147
|
+
use_keyring = self._cfg.get("use_keyring", False)
|
|
148
|
+
keyring_service = self._cfg.get("keyring_service")
|
|
149
|
+
keyring_username = self._cfg.get("keyring_username")
|
|
150
|
+
|
|
151
|
+
if use_keyring or keyring_service or keyring_username:
|
|
152
|
+
# Both service and username are REQUIRED when using keyring
|
|
153
|
+
if not keyring_service:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"Keyring enabled but 'keyring_service' not specified in config. " +
|
|
156
|
+
"Example: keyring_service = 'snowflake'"
|
|
157
|
+
)
|
|
158
|
+
if not keyring_username:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Keyring enabled but 'keyring_username' not specified in config. " +
|
|
161
|
+
"Example: keyring_username = 'myuser_keypair'"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
pwd = keyring.get_password(keyring_service, keyring_username)
|
|
166
|
+
if pwd:
|
|
167
|
+
return SecretStr(pwd)
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Keyring configured but no password found for " +
|
|
171
|
+
f"service='{keyring_service}', username='{keyring_username}'. " +
|
|
172
|
+
f"Store it with: keyring.set_password('{keyring_service}', '{keyring_username}', 'your-passphrase')"
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Failed to retrieve passphrase from keyring " +
|
|
177
|
+
f"(service='{keyring_service}', username='{keyring_username}'): {e}"
|
|
178
|
+
) from e
|
|
179
|
+
|
|
180
|
+
# 3. No passphrase configured (unencrypted key)
|
|
181
|
+
return None
|