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 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
+ ]
@@ -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())
@@ -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,5 @@
1
+ """Connection module exports."""
2
+
3
+ from .connection import SnowflakeConnector, SnowparkConnector
4
+
5
+ __all__ = ["SnowflakeConnector", "SnowparkConnector"]
@@ -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