Skip to content

Commit 1878c5a

Browse files
sfc-gh-pczajkasfc-gh-turbaszek
authored andcommitted
Add option to exclude boto3 and botocore from dependencies (#2525)
1 parent 012eed6 commit 1878c5a

File tree

9 files changed

+301
-14
lines changed

9 files changed

+301
-14
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Test Installation
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- main
8+
pull_request:
9+
branches:
10+
- '**'
11+
workflow_dispatch:
12+
13+
concurrency:
14+
# older builds for the same pull request number or branch should be cancelled
15+
cancel-in-progress: true
16+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
17+
18+
jobs:
19+
test-installation:
20+
name: Test Boto Dependency
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@v4
24+
25+
- name: Set up Python
26+
uses: actions/setup-python@v4
27+
with:
28+
python-version: 3.12
29+
30+
- name: Test default installation (should include boto)
31+
shell: bash
32+
run: |
33+
python -m venv test_default_env
34+
source test_default_env/bin/activate
35+
36+
python -m pip install .
37+
pip freeze | grep boto || exit 1 # boto3/botocore should be installed by default
38+
39+
# Deactivate and clean up
40+
deactivate
41+
rm -rf test_default_env
42+
43+
- name: Test installation with SNOWFLAKE_NO_BOTO=1 (should exclude boto)
44+
shell: bash
45+
run: |
46+
python -m venv test_no_boto_env
47+
source test_no_boto_env/bin/activate
48+
49+
SNOWFLAKE_NO_BOTO=1 python -m pip install .
50+
51+
# Check that boto3 and botocore are NOT installed
52+
pip freeze | grep boto && exit 1 # boto3 and botocore should be not installed
53+
54+
# Deactivate and clean up
55+
deactivate
56+
rm -rf test_no_boto_env

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ repos:
6161
src/snowflake/connector/vendored/.*
6262
)$
6363
args: [--show-fixes]
64+
- id: check-optional-imports
65+
name: Check for direct imports of modules which might be unavailable
66+
entry: python ci/pre-commit/check_optional_imports.py
67+
language: system
68+
files: ^src/snowflake/connector/.*\.py$
69+
exclude: src/snowflake/connector/options.py
70+
args: [--show-fixes]
6471
- repo: https://github.com/PyCQA/flake8
6572
rev: 7.1.1
6673
hooks:

DESCRIPTION.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1010
- v3.18.0(TBD)
1111
- Added the `workload_identity_impersonation_path` parameter to support service account impersonation for Workload Identity Federation on GCP and AWS workloads only
1212
- Fixed `get_results_from_sfqid` when using `DictCursor` and executing multiple statements at once
13+
- Added the `oauth_credentials_in_body` parameter supporting an option to send the oauth client credentials in the request body
14+
- Fix retry behavior for `ECONNRESET` error
15+
- Added an option to exclude `botocore` and `boto3` dependencies by setting `SNOWFLAKE_NO_BOTO` environment variable during installation
16+
17+
- v3.17.4(September 22,2025)
18+
- Added support for intermediate certificates as roots when they are stored in the trust store
19+
- Bumped up vendored `urllib3` to `2.5.0` and `requests` to `v2.32.5`
20+
- Dropped support for OpenSSL versions older than 1.1.1
1321

1422
- v3.17.3(September 02,2025)
1523
- Enhanced configuration file permission warning messages.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Pre-commit hook to ensure optional dependencies are always imported from .options module.
4+
This ensures that the connector can operate in environments where these optional libraries are not available.
5+
"""
6+
import argparse
7+
import ast
8+
import sys
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
from typing import List
12+
13+
CHECKED_MODULES = ["boto3", "botocore", "pandas", "pyarrow", "keyring"]
14+
15+
16+
@dataclass(frozen=True)
17+
class ImportViolation:
18+
"""Pretty prints a violation import restrictions."""
19+
20+
filename: str
21+
line: int
22+
col: int
23+
message: str
24+
25+
def __str__(self):
26+
return f"{self.filename}:{self.line}:{self.col}: {self.message}"
27+
28+
29+
class ImportChecker(ast.NodeVisitor):
30+
"""Checks that optional imports are only imported from .options module."""
31+
32+
def __init__(self, filename: str):
33+
self.filename = filename
34+
self.violations: List[ImportViolation] = []
35+
36+
def visit_If(self, node: ast.If):
37+
# Always visit the condition, but ignore imports inside "if TYPE_CHECKING:" blocks
38+
if getattr(node.test, "id", None) == "TYPE_CHECKING":
39+
# Skip the body and orelse for TYPE_CHECKING blocks
40+
pass
41+
else:
42+
self.generic_visit(node)
43+
44+
def visit_Import(self, node: ast.Import):
45+
"""Check import statements."""
46+
for alias in node.names:
47+
self._check_import(alias.name, node.lineno, node.col_offset)
48+
self.generic_visit(node)
49+
50+
def visit_ImportFrom(self, node: ast.ImportFrom):
51+
"""Check from...import statements."""
52+
if node.module:
53+
# Check if importing from a checked module directly
54+
for module in CHECKED_MODULES:
55+
if node.module.startswith(module):
56+
self.violations.append(
57+
ImportViolation(
58+
self.filename,
59+
node.lineno,
60+
node.col_offset,
61+
f"Import from '{node.module}' is not allowed. Use 'from .options import {module}' instead",
62+
)
63+
)
64+
65+
# Check if importing checked modules from .options (this is allowed)
66+
if node.module == ".options":
67+
# This is the correct way to import these modules
68+
pass
69+
self.generic_visit(node)
70+
71+
def _check_import(self, module_name: str, line: int, col: int):
72+
"""Check if a module import is for checked modules and not from .options."""
73+
for module in CHECKED_MODULES:
74+
if module_name.startswith(module):
75+
self.violations.append(
76+
ImportViolation(
77+
self.filename,
78+
line,
79+
col,
80+
f"Direct import of '{module_name}' is not allowed. Use 'from .options import {module}' instead",
81+
)
82+
)
83+
break
84+
85+
86+
def check_file(filename: str) -> List[ImportViolation]:
87+
"""Check a file for optional import violations."""
88+
try:
89+
tree = ast.parse(Path(filename).read_text())
90+
except SyntaxError:
91+
# gracefully handle syntax errors
92+
return []
93+
checker = ImportChecker(filename)
94+
checker.visit(tree)
95+
return checker.violations
96+
97+
98+
def main():
99+
"""Main function for pre-commit hook."""
100+
parser = argparse.ArgumentParser(
101+
description="Check that optional imports are only imported from .options module"
102+
)
103+
parser.add_argument("filenames", nargs="*", help="Filenames to check")
104+
parser.add_argument(
105+
"--show-fixes", action="store_true", help="Show suggested fixes"
106+
)
107+
args = parser.parse_args()
108+
109+
all_violations = []
110+
for filename in args.filenames:
111+
if not filename.endswith(".py"):
112+
continue
113+
all_violations.extend(check_file(filename))
114+
115+
# Show violations
116+
if all_violations:
117+
print("Optional import violations found:")
118+
print()
119+
120+
for violation in all_violations:
121+
print(f" {violation}")
122+
123+
if args.show_fixes:
124+
print()
125+
print("How to fix:")
126+
print(" - Import optional modules only from .options module")
127+
print(" - Example:")
128+
print(" # CORRECT:")
129+
print(" from .options import boto3, botocore, installed_boto")
130+
print(" if installed_boto:")
131+
print(" SigV4Auth = botocore.auth.SigV4Auth")
132+
print()
133+
print(" # INCORRECT:")
134+
print(" import boto3")
135+
print(" from botocore.auth import SigV4Auth")
136+
print()
137+
print(
138+
" - This ensures the connector works in environments where optional libraries are not installed"
139+
)
140+
141+
print()
142+
print(f"Found {len(all_violations)} violation(s)")
143+
return 1
144+
145+
return 0
146+
147+
148+
if __name__ == "__main__":
149+
sys.exit(main())

setup.cfg

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ project_urls =
4343
python_requires = >=3.9
4444
packages = find_namespace:
4545
install_requires =
46+
# [boto] extension is added by default unless SNOWFLAKE_NO_BOTO variable is set
47+
# check setup.py
4648
asn1crypto>0.24.0,<2.0.0
47-
boto3>=1.24
48-
botocore>=1.24
4949
cffi>=1.9,<2.0.0
5050
cryptography>=3.1.0
5151
pyOpenSSL>=22.0.0,<25.0.0
@@ -79,6 +79,9 @@ console_scripts =
7979
snowflake-dump-certs = snowflake.connector.tool.dump_certs:main
8080

8181
[options.extras_require]
82+
boto =
83+
boto3>=1.24
84+
botocore>=1.24
8285
development =
8386
Cython
8487
coverage

setup.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import warnings
66

77
from setuptools import Extension, setup
8+
from setuptools.command.egg_info import egg_info
89

910
CONNECTOR_SRC_DIR = os.path.join("src", "snowflake", "connector")
1011
NANOARROW_SRC_DIR = os.path.join(CONNECTOR_SRC_DIR, "nanoarrow_cpp", "ArrowIterator")
@@ -38,9 +39,14 @@
3839
extensions = None
3940
cmd_class = {}
4041

41-
SNOWFLAKE_DISABLE_COMPILE_ARROW_EXTENSIONS = os.environ.get(
42-
"SNOWFLAKE_DISABLE_COMPILE_ARROW_EXTENSIONS", "false"
43-
).lower() in ("y", "yes", "t", "true", "1", "on")
42+
_POSITIVE_VALUES = ("y", "yes", "t", "true", "1", "on")
43+
SNOWFLAKE_DISABLE_COMPILE_ARROW_EXTENSIONS = (
44+
os.environ.get("SNOWFLAKE_DISABLE_COMPILE_ARROW_EXTENSIONS", "false").lower()
45+
in _POSITIVE_VALUES
46+
)
47+
SNOWFLAKE_NO_BOTO = (
48+
os.environ.get("SNOWFLAKE_NO_BOTO", "false").lower() in _POSITIVE_VALUES
49+
)
4450

4551
try:
4652
from Cython.Build import cythonize
@@ -88,7 +94,7 @@ def build_extension(self, ext):
8894
ext.sources += [
8995
os.path.join(
9096
NANOARROW_ARROW_ITERATOR_SRC_DIR,
91-
*((file,) if isinstance(file, str) else file)
97+
*((file,) if isinstance(file, str) else file),
9298
)
9399
for file in {
94100
"ArrayConverter.cpp",
@@ -174,6 +180,22 @@ def new__compile(obj, src: str, ext, cc_args, extra_postargs, pp_opts):
174180

175181
cmd_class = {"build_ext": MyBuildExt}
176182

183+
184+
class SetDefaultInstallationExtras(egg_info):
185+
"""Adds AWS extra unless SNOWFLAKE_NO_BOTO is specified."""
186+
187+
def finalize_options(self):
188+
super().finalize_options()
189+
190+
# if not explicitly excluded, add boto dependencies to install_requires
191+
if not SNOWFLAKE_NO_BOTO:
192+
boto_extras = self.distribution.extras_require.get("boto", [])
193+
self.distribution.install_requires += boto_extras
194+
195+
196+
# Update command classes
197+
cmd_class["egg_info"] = SetDefaultInstallationExtras
198+
177199
setup(
178200
version=version,
179201
ext_modules=extensions,

src/snowflake/connector/options.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ class MissingKeyring(MissingOptionalDependency):
4848
_dep_name = "keyring"
4949

5050

51+
class MissingBotocore(MissingOptionalDependency):
52+
"""The class is specifically for boto optional dependency."""
53+
54+
_dep_name = "botocore"
55+
56+
57+
class MissingBoto3(MissingOptionalDependency):
58+
"""The class is specifically for boto3 optional dependency."""
59+
60+
_dep_name = "boto3"
61+
62+
5163
ModuleLikeObject = Union[ModuleType, MissingOptionalDependency]
5264

5365

@@ -126,6 +138,17 @@ def _import_or_missing_keyring_option() -> tuple[ModuleLikeObject, bool]:
126138
return MissingKeyring(), False
127139

128140

141+
def _import_or_missing_boto_option() -> tuple[ModuleLikeObject, ModuleLikeObject, bool]:
142+
"""This function tries importing the following packages: botocore and boto3."""
143+
try:
144+
botocore = importlib.import_module("botocore")
145+
boto3 = importlib.import_module("boto3")
146+
return botocore, boto3, True
147+
except ImportError:
148+
return MissingBotocore(), MissingBoto3(), False
149+
150+
129151
# Create actual constants to be imported from this file
130152
pandas, pyarrow, installed_pandas = _import_or_missing_pandas_option()
131153
keyring, installed_keyring = _import_or_missing_keyring_option()
154+
botocore, boto3, installed_boto = _import_or_missing_boto_option()

src/snowflake/connector/platform_detection.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
from enum import Enum
88
from functools import cache
99

10-
import boto3
11-
from botocore.config import Config
12-
from botocore.utils import IMDSFetcher
10+
from .options import boto3, botocore, installed_boto
11+
12+
if installed_boto:
13+
Config = botocore.config.Config
14+
IMDSFetcher = botocore.utils.IMDSFetcher
1315

1416
from .session_manager import SessionManager
1517
from .vendored.requests import RequestException, Timeout
@@ -40,6 +42,10 @@ def is_ec2_instance(platform_detection_timeout_seconds: float):
4042
Returns:
4143
_DetectionState: DETECTED if running on EC2, NOT_DETECTED otherwise.
4244
"""
45+
if not installed_boto:
46+
logger.debug("boto3 is not installed, skipping EC2 instance detection")
47+
return _DetectionState.NOT_DETECTED
48+
4349
try:
4450
fetcher = IMDSFetcher(
4551
timeout=platform_detection_timeout_seconds, num_attempts=1
@@ -105,6 +111,10 @@ def has_aws_identity(platform_detection_timeout_seconds: float):
105111
Returns:
106112
_DetectionState: DETECTED if valid AWS identity exists, NOT_DETECTED otherwise.
107113
"""
114+
if not installed_boto:
115+
logger.debug("boto3 is not installed, skipping AWS identity detection")
116+
return _DetectionState.NOT_DETECTED
117+
108118
try:
109119
config = Config(
110120
connect_timeout=platform_detection_timeout_seconds,

0 commit comments

Comments
 (0)