Skip to content

Commit 511154b

Browse files
SNOW-873456 config file slicing (#1660)
1 parent e3ead72 commit 511154b

File tree

7 files changed

+358
-55
lines changed

7 files changed

+358
-55
lines changed

DESCRIPTION.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
99
# Release Notes
1010

1111
- v3.0.5(TBD)
12-
- Added a feature that lets you add connection definitions to the `config.toml` configuration file. A connection definition refers to a collection of connection parameters. The connection configuration name must begin with **connections**, similar to the following that defines the parameters for the `prod` connection:
13-
12+
- Added a feature that lets you add connection definitions to the `connections.toml` configuration file. A connection definition refers to a collection of connection parameters, for example, if you wanted to define a connection named `prod``:
1413
```toml
15-
[connections.prod]
14+
[prod]
1615
account = "my_account"
1716
user = "my_user"
1817
password = "my_password"
1918
```
20-
By default, we look for the `config.toml` file in the location specified in the `SNOWFLAKE_HOME` environment variable (default: `~/.snowflake`). If this folder does not exist, the Python connector looks for the file in the `platformdirs` location, as follows:
19+
By default, we look for the `connections.toml` file in the location specified in the `SNOWFLAKE_HOME` environment variable (default: `~/.snowflake`). If this folder does not exist, the Python connector looks for the file in the [platformdirs](https://github.com/platformdirs/platformdirs/blob/main/README.rst) location, as follows:
2120

2221
- On Linux: `~/.config/snowflake/`, but follows XDG settings
2322
- On Mac: `~/Library/Application Support/snowflake/`
@@ -26,7 +25,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
2625
You can determine which file is used by running the following command:
2726

2827
```
29-
python -c "from snowflake.connector.constants import CONFIG_FILE; print(str(CONFIG_FILE))"
28+
python -c "from snowflake.connector.constants import CONNECTIONS_FILE; print(str(CONNECTIONS_FILE))"
3029
```
3130
- Bumped cryptography dependency from <41.0.0,>=3.1.0 to >=3.1.0,<42.0.0.
3231
- Improved OCSP response caching to remove tmp cache files on Windows.

src/snowflake/connector/config_manager.py

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,45 @@
44

55
from __future__ import annotations
66

7+
import itertools
78
import logging
89
import os
910
import stat
1011
from collections.abc import Iterable
1112
from operator import methodcaller
1213
from pathlib import Path
13-
from typing import Any, Callable, Literal, TypeVar
14+
from typing import Any, Callable, Literal, NamedTuple, TypeVar
1415
from warnings import warn
1516

1617
import tomlkit
1718
from tomlkit.items import Table
1819

19-
from snowflake.connector.constants import CONFIG_FILE
20-
from snowflake.connector.errors import ConfigManagerError, ConfigSourceError
20+
from snowflake.connector.constants import CONFIG_FILE, CONNECTIONS_FILE
21+
from snowflake.connector.errors import (
22+
ConfigManagerError,
23+
ConfigSourceError,
24+
MissingConfigOptionError,
25+
)
2126

2227
_T = TypeVar("_T")
2328

2429
LOGGER = logging.getLogger(__name__)
2530
READABLE_BY_OTHERS = stat.S_IRGRP | stat.S_IROTH
2631

2732

33+
class ConfigSliceOptions(NamedTuple):
34+
"""Class that defines settings individual configuration files."""
35+
36+
check_permissions: bool = True
37+
only_in_slice: bool = False
38+
39+
40+
class ConfigSlice(NamedTuple):
41+
path: Path
42+
options: ConfigSliceOptions
43+
section: str
44+
45+
2846
class ConfigOption:
2947
"""ConfigOption represents a flag/setting.
3048
@@ -138,10 +156,9 @@ def _get_env(self) -> tuple[bool, str | _T | None]:
138156

139157
def _get_config(self) -> Any:
140158
"""Get value from the cached config file."""
141-
if self._root_manager.conf_file_cache is None and (
142-
self._root_manager.file_path is not None
143-
and self._root_manager.file_path.exists()
144-
and self._root_manager.file_path.is_file()
159+
if (
160+
self._root_manager.conf_file_cache is None
161+
and self._root_manager.file_path is not None
145162
):
146163
self._root_manager.read_config()
147164
e = self._root_manager.conf_file_cache
@@ -150,7 +167,15 @@ def _get_config(self) -> Any:
150167
f"Root parser '{self._root_manager.name}' is missing file_path",
151168
)
152169
for k in self._nest_path[1:]:
153-
e = e[k]
170+
try:
171+
e = e[k]
172+
except tomlkit.exceptions.NonExistentKey:
173+
raise MissingConfigOptionError( # TOOO: maybe a child Exception for missing option?
174+
f"Configuration option '{self.option_name}' is not defined anywhere, "
175+
"have you forgotten to set it in a configuration file, "
176+
"or environmental variable?"
177+
)
178+
154179
if isinstance(e, (Table, tomlkit.TOMLDocument)):
155180
# If we got a TOML table we probably want it in dictionary form
156181
return e.value
@@ -190,6 +215,7 @@ def __init__(
190215
*,
191216
name: str,
192217
file_path: Path | None = None,
218+
_slices: list[ConfigSlice] | None = None,
193219
):
194220
"""Create a new ConfigManager.
195221
@@ -198,8 +224,11 @@ def __init__(
198224
file_path: File this parser should read values from. Can be omitted
199225
for all child parsers.
200226
"""
227+
if _slices is None:
228+
_slices = list()
201229
self.name = name
202230
self.file_path = file_path
231+
self._slices = _slices
203232
# Objects holding subparsers and options
204233
self._options: dict[str, ConfigOption] = dict()
205234
self._sub_parsers: dict[str, ConfigManager] = dict()
@@ -225,29 +254,42 @@ def read_config(
225254
"ConfigManager is trying to read config file, but it doesn't "
226255
"have one"
227256
)
228-
if not self.file_path.exists():
229-
raise ConfigSourceError(
230-
f"The config file '{self.file_path}' does not exist"
231-
)
232-
if (
233-
# Same check as openssh does for permissions
234-
# https://github.com/openssh/openssh-portable/blob/2709809fd616a0991dc18e3a58dea10fb383c3f0/readconf.c#LL2264C1-L2264C1
235-
self.file_path.stat().st_mode & READABLE_BY_OTHERS != 0
236-
or (
237-
# TODO: Windows doesn't have getuid, skip checking
238-
hasattr(os, "getuid")
239-
and self.file_path.stat().st_uid != 0
240-
and self.file_path.stat().st_uid != os.getuid()
241-
)
257+
read_config_file = tomlkit.TOMLDocument()
258+
259+
# Read in all of the config slices
260+
for filep, sliceoptions, section in itertools.chain(
261+
((self.file_path, ConfigSliceOptions(), None),),
262+
self._slices,
242263
):
243-
warn(f"Bad owner or permissions on {self.file_path}")
244-
LOGGER.debug(f"reading configuration file from {str(self.file_path)}")
245-
try:
246-
self.conf_file_cache = tomlkit.parse(self.file_path.read_text())
247-
except Exception as e:
248-
raise ConfigSourceError(
249-
"An unknown error happened while loading " f"'{str(self.file_path)}'"
250-
) from e
264+
if sliceoptions.only_in_slice:
265+
del read_config_file[section]
266+
if not filep.exists():
267+
continue
268+
if (
269+
sliceoptions.check_permissions # Skip checking if this file couldn't hold sensitive information
270+
# Same check as openssh does for permissions
271+
# https://github.com/openssh/openssh-portable/blob/2709809fd616a0991dc18e3a58dea10fb383c3f0/readconf.c#LL2264C1-L2264C1
272+
and filep.stat().st_mode & READABLE_BY_OTHERS != 0
273+
or (
274+
# Windows doesn't have getuid, skip checking
275+
hasattr(os, "getuid")
276+
and filep.stat().st_uid != 0
277+
and filep.stat().st_uid != os.getuid()
278+
)
279+
):
280+
warn(f"Bad owner or permissions on {str(filep)}")
281+
LOGGER.debug(f"reading configuration file from {str(filep)}")
282+
try:
283+
read_config_piece = tomlkit.parse(filep.read_text())
284+
except Exception as e:
285+
raise ConfigSourceError(
286+
"An unknown error happened while loading " f"'{str(filep)}'"
287+
) from e
288+
if section is None:
289+
read_config_file = read_config_piece
290+
else:
291+
read_config_file[section] = read_config_piece
292+
self.conf_file_cache = read_config_file
251293

252294
def add_option(
253295
self,
@@ -331,6 +373,15 @@ def __getitem__(self, name: str) -> ConfigOption | ConfigManager:
331373
CONFIG_PARSER = ConfigManager(
332374
name="CONFIG_PARSER",
333375
file_path=CONFIG_FILE,
376+
_slices=[
377+
ConfigSlice( # Optional connections file to read in connections from
378+
CONNECTIONS_FILE,
379+
ConfigSliceOptions(
380+
check_permissions=True, # connections could live here, check permissions
381+
),
382+
"connections",
383+
),
384+
],
334385
)
335386
CONFIG_PARSER.add_option(
336387
name="connections",

src/snowflake/connector/connection.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ class SnowflakeConnection:
297297
def __init__(
298298
self,
299299
connection_name: str | None = None,
300-
config_file_path: pathlib.Path | None = None,
300+
connections_file_path: pathlib.Path | None = None,
301301
**kwargs,
302302
) -> None:
303303
self._lock_sequence_counter = Lock()
@@ -332,10 +332,13 @@ def __init__(
332332
self.converter = None
333333
self.query_context_cache: QueryContextCache | None = None
334334
self.query_context_cache_size = 5
335-
if config_file_path is not None:
335+
if connections_file_path is not None:
336336
# Change config file path and force update cache
337-
CONFIG_PARSER.file_path = config_file_path
338-
CONFIG_PARSER.read_config()
337+
for i, s in enumerate(CONFIG_PARSER._slices):
338+
if s.section == "connections":
339+
CONFIG_PARSER._slices[i] = s._replace(path=connections_file_path)
340+
CONFIG_PARSER.read_config()
341+
break
339342
if connection_name is not None:
340343
connections = CONFIG_PARSER["connections"]
341344
if connection_name not in connections:
@@ -366,7 +369,8 @@ def ocsp_fail_open(self) -> bool:
366369
return self._ocsp_fail_open
367370

368371
def _ocsp_mode(self) -> OCSPMode:
369-
"""OCSP mode. INSECURE, FAIL_OPEN or FAIL_CLOSED."""
372+
"""OCSP mode. INSEC
373+
URE, FAIL_OPEN or FAIL_CLOSED."""
370374
if self.insecure_mode:
371375
return OCSPMode.INSECURE
372376
elif self.ocsp_fail_open:

src/snowflake/connector/constants.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@
2121
# defaults. Please see comments in sf_dir.py for more information.
2222
DIRS = _resolve_platform_dirs()
2323

24-
# Snowflake's central configuration file. By default, platformdirs will resolve
24+
# Snowflake's configuration files. By default, platformdirs will resolve
2525
# them to these places depending on OS:
26-
# * Linux: `~/.config/snowflake/config.toml` but can be updated with XDG vars
27-
# * Windows: `%USERPROFILE%\AppData\Local\snowflake\config.toml`
28-
# * Mac: `~/Library/Application Support/snowflake/config.toml`
26+
# * Linux: `~/.config/snowflake/filename` but can be updated with XDG vars
27+
# * Windows: `%USERPROFILE%\AppData\Local\snowflake\filename`
28+
# * Mac: `~/Library/Application Support/snowflake/filename`
29+
CONNECTIONS_FILE = DIRS.user_config_path / "connections.toml"
2930
CONFIG_FILE = DIRS.user_config_path / "config.toml"
3031

3132
DBAPI_TYPE_STRING = 0

src/snowflake/connector/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,13 @@ class ConfigSourceError(Error):
612612
"""
613613

614614

615+
class MissingConfigOptionError(ConfigSourceError):
616+
"""When a configuration option is missing from the final, resolved configurations.
617+
618+
This is a special-case of ConfigSourceError.
619+
"""
620+
621+
615622
class ConfigManagerError(Error):
616623
"""Configuration parser related errors.
617624

test/integ/test_connection.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,18 +1231,16 @@ def test_connection_name_loading(monkeypatch, db_parameters, tmp_path, mode):
12311231
# If anything unexpected fails here, don't want to expose password
12321232
for k, v in db_parameters.items():
12331233
default_con[k] = v
1234+
doc["default"] = default_con
12341235
with monkeypatch.context() as m:
12351236
if mode == "env":
1236-
doc["default"] = default_con
12371237
m.setenv("SF_CONNECTIONS", tomlkit.dumps(doc))
12381238
else:
1239-
doc["connections"] = tomlkit.table()
1240-
doc["connections"]["default"] = default_con
1241-
tmp_config_file = tmp_path / "config.toml"
1239+
tmp_config_file = tmp_path / "connections.toml"
12421240
tmp_config_file.write_text(tomlkit.dumps(doc))
12431241
with snowflake.connector.connect(
12441242
connection_name="default",
1245-
config_file_path=tmp_config_file,
1243+
connections_file_path=tmp_config_file,
12461244
) as conn:
12471245
with conn.cursor() as cur:
12481246
assert cur.execute("select 1;").fetchall() == [

0 commit comments

Comments
 (0)