fullscript.spellbook 3.0.0__tar.gz → 3.0.1__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.
Files changed (48) hide show
  1. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/AGENTS.md +9 -0
  2. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/PKG-INFO +1 -1
  3. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/PKG-INFO +1 -1
  4. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/scm_version.json +2 -2
  5. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/db_magic.py +49 -3
  6. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/utils.py +31 -11
  7. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/tests/test_db_magic.py +95 -0
  8. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/tests/test_utils.py +49 -16
  9. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/.gitignore +0 -0
  10. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/.gitlab-ci.yml +0 -0
  11. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/.python-version +0 -0
  12. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/CLAUDE.md +0 -0
  13. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/LICENSE +0 -0
  14. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/MANIFEST.in +0 -0
  15. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/README.md +0 -0
  16. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/documentation/db_magic.md +0 -0
  17. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/documentation/drive_magic.md +0 -0
  18. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/documentation/sheet_magic.md +0 -0
  19. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/documentation/snowflake_key_pair_setup.md +0 -0
  20. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/documentation/stats_magic.md +0 -0
  21. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/SOURCES.txt +0 -0
  22. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/dependency_links.txt +0 -0
  23. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/requires.txt +0 -0
  24. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/scm_file_list.json +0 -0
  25. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/fullscript.spellbook.egg-info/top_level.txt +0 -0
  26. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/pyproject.toml +0 -0
  27. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/requirements.txt +0 -0
  28. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/setup.cfg +0 -0
  29. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/__init__.py +0 -0
  30. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/drive_magic.py +0 -0
  31. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/exceptions.py +0 -0
  32. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/py.typed +0 -0
  33. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/sheet_magic.py +0 -0
  34. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/stats_magic.py +0 -0
  35. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/__init__.py +0 -0
  36. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard1.txt +0 -0
  37. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard10.txt +0 -0
  38. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard11.txt +0 -0
  39. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard12.txt +0 -0
  40. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard2.txt +0 -0
  41. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard3.txt +0 -0
  42. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard4.txt +0 -0
  43. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard5.txt +0 -0
  44. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard6.txt +0 -0
  45. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard7.txt +0 -0
  46. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard8.txt +0 -0
  47. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook/wizards/wizard9.txt +0 -0
  48. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.1}/spellbook_config.yaml +0 -0
@@ -58,6 +58,15 @@ pattern instead.
58
58
 
59
59
  ## Traps that have cost time before
60
60
 
61
+ **Validate the database being used, not the whole config.** The entire `databases` section
62
+ is re-parsed on every lookup, so any check that runs during parsing applies to databases the
63
+ caller never asked for. Release 3.0.0 raised on the first unset `${VAR}` anywhere in the
64
+ section, which meant an unrelated Snowflake entry with no key passphrase blocked every MySQL
65
+ query. `parse_env_variables` now substitutes permissively and records unresolved names under
66
+ `_unresolved_env`; `db_magic._validate_db_config` turns those into an error for the selected
67
+ database only, and only for settings in `REQUIRED_FIELDS`. Settings that are legitimately
68
+ empty - a Snowflake passphrase, `ssl_ca`, `port`, `schema` - must stay out of that list.
69
+
61
70
  **Config file precedence.** `utils.find_config_file()` prefers `spellbook_config.yaml` in
62
71
  the *current working directory* over the one in your home directory. Running anything from
63
72
  this repository picks up the placeholder example config, not your real one. If a connection
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fullscript.spellbook
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: A simple package to make your life easier.
5
5
  Author-email: Meinhard Mare <meinhard.mare@fullscript.com>, Thomas Edwards <thomas.edwards@fullscript.com>, Gui Chagas <gui.chagas@fullscript.com>, David King <david.king@fullscript.com>, Karsten Melchers <karsten.melchers@fullscript.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fullscript.spellbook
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: A simple package to make your life easier.
5
5
  Author-email: Meinhard Mare <meinhard.mare@fullscript.com>, Thomas Edwards <thomas.edwards@fullscript.com>, Gui Chagas <gui.chagas@fullscript.com>, David King <david.king@fullscript.com>, Karsten Melchers <karsten.melchers@fullscript.com>
6
6
  License: MIT
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "3.0.0",
2
+ "tag": "3.0.1",
3
3
  "distance": 0,
4
- "node": "g5c9c10546f95ea303f36c2cc2e273cf241b92215",
4
+ "node": "g4c7e282904d3f4b9436cecd6265dbc228d36c8de",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-22"
@@ -20,7 +20,13 @@ from spellbook.exceptions import (
20
20
  DatabaseNotFoundError,
21
21
  QueryError,
22
22
  )
23
- from spellbook.utils import load_and_parse_config, load_sql_file, show_wizard
23
+ from spellbook.utils import (
24
+ CONFIG_FILENAME,
25
+ describe_unresolved,
26
+ load_and_parse_config,
27
+ load_sql_file,
28
+ show_wizard,
29
+ )
24
30
 
25
31
  logger = logging.getLogger(__name__)
26
32
 
@@ -40,6 +46,15 @@ _IAM_CURSOR_CLASS = None
40
46
 
41
47
  SUPPORTED_DB_TYPES = ('snowflake', 'mysql')
42
48
 
49
+ # Settings a connection genuinely cannot be made without, checked only for the database
50
+ # being used. Anything absent from here is optional and may resolve to an empty value:
51
+ # `private_key_passphrase`, `schema`, `port`, `ssl_ca` and `rds_db_name` all have defaults
52
+ # or meaningful empty behaviour. MySQL also requires `password` unless `iam_auth` is set.
53
+ REQUIRED_FIELDS = {
54
+ 'mysql': ('host', 'user', 'database'),
55
+ 'snowflake': ('account', 'user', 'role', 'warehouse', 'database'),
56
+ }
57
+
43
58
 
44
59
  def _strip_ansi(text_value: str | None) -> str:
45
60
  """
@@ -51,13 +66,44 @@ def _strip_ansi(text_value: str | None) -> str:
51
66
  return _ANSI_ESCAPE.sub('', text_value or '')
52
67
 
53
68
 
69
+ def _validate_db_config(db_name: str, db_config: dict[str, Any]) -> dict[str, Any]:
70
+ """
71
+ Check that the settings this database cannot work without are actually filled in.
72
+
73
+ Only the database being used is checked. Settings that are legitimately optional, such as
74
+ a Snowflake private key passphrase or an `ssl_ca` bundle, are allowed to stay empty.
75
+
76
+ :param db_name: The name of the database, for the error message.
77
+ :param db_config: The configuration block for that database.
78
+ :return: The same configuration block.
79
+ :raises ConfigError: If a required setting is missing or resolved to an empty value.
80
+ """
81
+ unresolved = db_config.pop('_unresolved_env', {})
82
+
83
+ required = REQUIRED_FIELDS.get(db_config.get('type'), ())
84
+ if db_config.get('type') == 'mysql' and not db_config.get('iam_auth'):
85
+ required = required + ('password',)
86
+
87
+ for key in required:
88
+ if db_config.get(key):
89
+ continue
90
+ if key in unresolved:
91
+ raise ConfigError(describe_unresolved(db_name, key, unresolved[key]))
92
+ raise ConfigError(
93
+ f"Database '{db_name}' is missing the required '{key}' setting. "
94
+ f"Add it to {CONFIG_FILENAME}."
95
+ )
96
+
97
+ return db_config
98
+
99
+
54
100
  def get_db_config(db_name: str) -> dict[str, Any]:
55
101
  """
56
102
  Look up a single database block from the configuration file.
57
103
 
58
104
  :param db_name: The name of the database as defined in the config file.
59
105
  :return: The configuration for that database.
60
- :raises ConfigError: If the configuration file cannot be read.
106
+ :raises ConfigError: If the config cannot be read, or this database is incomplete.
61
107
  :raises DatabaseNotFoundError: If no database matches the given name.
62
108
  """
63
109
  config = load_and_parse_config(configurations='databases')
@@ -65,7 +111,7 @@ def get_db_config(db_name: str) -> dict[str, Any]:
65
111
  databases = config.get('databases') or []
66
112
  for db in databases:
67
113
  if db.get('name') == db_name:
68
- return db
114
+ return _validate_db_config(db_name, db)
69
115
 
70
116
  available = ', '.join(sorted(str(db.get('name')) for db in databases)) or '<none>'
71
117
  raise DatabaseNotFoundError(
@@ -69,13 +69,16 @@ def parse_env_variables(config: dict[str, Any], configurations: str) -> dict[str
69
69
  """
70
70
  Replace ${VAR} placeholders in a configuration block with environment variable values.
71
71
 
72
- An unset variable is an error rather than an empty string, because an empty username or
73
- host produces a confusing driver-level failure much later on.
72
+ An unset variable becomes an empty string here rather than an error. The whole section is
73
+ parsed on every lookup, so refusing to parse would mean an unrelated database you never
74
+ asked for could stop you connecting to the one you did. Unresolved names are recorded
75
+ under `_unresolved_env` so that whoever actually uses the block can report them, and so
76
+ that genuinely optional settings - a Snowflake key with no passphrase, say - still work.
74
77
 
75
78
  :param config: The configuration dictionary.
76
79
  :param configurations: The key of the configuration block to parse.
77
80
  :return: The configuration with placeholders substituted.
78
- :raises ConfigError: If the block is missing, or a referenced variable is not set.
81
+ :raises ConfigError: If the section is missing.
79
82
  """
80
83
  if configurations not in config:
81
84
  raise ConfigError(
@@ -85,7 +88,9 @@ def parse_env_variables(config: dict[str, Any], configurations: str) -> dict[str
85
88
 
86
89
  for block in config[configurations]:
87
90
  name = block.get('name', '<unnamed>')
88
- for key, value in block.items():
91
+ unresolved: dict[str, str] = {}
92
+
93
+ for key, value in list(block.items()):
89
94
  if not isinstance(value, str):
90
95
  continue
91
96
 
@@ -102,18 +107,33 @@ def parse_env_variables(config: dict[str, Any], configurations: str) -> dict[str
102
107
  continue
103
108
 
104
109
  for match in matches:
105
- env_value = os.getenv(match)
106
- if env_value is None or env_value == '':
107
- raise ConfigError(
108
- f"Environment variable '{match}' is referenced by '{key}' on "
109
- f"'{name}' but is not set. Export it, or replace ${{{match}}} in "
110
- f"{CONFIG_FILENAME} with a literal value."
111
- )
110
+ env_value = os.getenv(match, '')
111
+ if env_value == '':
112
+ unresolved[key] = match
112
113
  block[key] = block[key].replace(f'${{{match}}}', env_value)
113
114
 
115
+ if unresolved:
116
+ block['_unresolved_env'] = unresolved
117
+
114
118
  return config
115
119
 
116
120
 
121
+ def describe_unresolved(name: str, key: str, variable: str) -> str:
122
+ """
123
+ Build the error message for a required setting whose environment variable is not set.
124
+
125
+ :param name: The name of the database the setting belongs to.
126
+ :param key: The configuration key that is empty.
127
+ :param variable: The environment variable that was referenced but not set.
128
+ :return: The message to raise.
129
+ """
130
+ return (
131
+ f"Environment variable '{variable}' is referenced by '{key}' on '{name}' but is "
132
+ f"not set. Export it, or replace ${{{variable}}} in {CONFIG_FILENAME} with a "
133
+ "literal value."
134
+ )
135
+
136
+
117
137
  def find_config_file(config_file_path: str | None = None) -> str:
118
138
  """
119
139
  Work out which configuration file will be used.
@@ -346,6 +346,101 @@ def test_missing_required_field_raises_config_error(config):
346
346
  db_magic.mysql_connect('legacy_mysql')
347
347
 
348
348
 
349
+ # --- Required settings are validated per database, not across the whole config -----------
350
+ # Regression: validation ran over every block while parsing, so an unrelated Snowflake
351
+ # entry with an unset ${SNOW_PK_PASSPHRASE} made every MySQL query fail.
352
+
353
+ @pytest.fixture
354
+ def config_file(tmp_path, monkeypatch):
355
+ """Write a real config file and make Spellbook load it."""
356
+ import yaml
357
+
358
+ def write(*databases):
359
+ path = tmp_path / 'spellbook_config.yaml'
360
+ path.write_text(yaml.safe_dump({'databases': list(databases)}))
361
+ monkeypatch.setattr(
362
+ db_magic, 'load_and_parse_config',
363
+ lambda **kw: __import__('spellbook.utils', fromlist=['x'])
364
+ .load_and_parse_config(config_file_path=str(path), **kw),
365
+ )
366
+ return write
367
+
368
+
369
+ def test_an_unrelated_database_does_not_block_the_one_you_asked_for(
370
+ monkeypatch, config_file,
371
+ ):
372
+ monkeypatch.delenv('SNOW_PK_PASSPHRASE', raising=False)
373
+ config_file(
374
+ {'name': 'snow_s', 'type': 'snowflake', 'account': 'a', 'user': 'u', 'role': 'r',
375
+ 'warehouse': 'w', 'database': 'd',
376
+ 'private_key_passphrase': '${SNOW_PK_PASSPHRASE}'},
377
+ IAM_DB,
378
+ )
379
+
380
+ db = db_magic.get_db_config('reporting_db')
381
+
382
+ assert db['user'] == 'iam_user'
383
+ assert '_unresolved_env' not in db, 'the internal marker must not leak to callers'
384
+
385
+
386
+ def test_an_optional_unset_setting_does_not_block_its_own_database(monkeypatch, config_file):
387
+ """A Snowflake key with no passphrase is a valid setup, so it must not raise."""
388
+ monkeypatch.delenv('SNOW_PK_PASSPHRASE', raising=False)
389
+ config_file({
390
+ 'name': 'snow_s', 'type': 'snowflake', 'account': 'a', 'user': 'u', 'role': 'r',
391
+ 'warehouse': 'w', 'database': 'd', 'private_key_passphrase': '${SNOW_PK_PASSPHRASE}',
392
+ })
393
+
394
+ db = db_magic.get_db_config('snow_s')
395
+
396
+ assert db['private_key_passphrase'] == ''
397
+
398
+
399
+ def test_a_required_setting_with_an_unset_variable_raises_and_names_it(
400
+ monkeypatch, config_file,
401
+ ):
402
+ monkeypatch.delenv('DB_USER_NOT_SET', raising=False)
403
+ config_file({
404
+ 'name': 'broken_db', 'type': 'mysql', 'host': 'h',
405
+ 'user': '${DB_USER_NOT_SET}', 'password': 'p', 'database': 'd',
406
+ })
407
+
408
+ with pytest.raises(ConfigError) as error:
409
+ db_magic.get_db_config('broken_db')
410
+
411
+ message = str(error.value)
412
+ assert 'DB_USER_NOT_SET' in message
413
+ assert 'broken_db' in message
414
+ assert 'user' in message
415
+
416
+
417
+ @pytest.mark.parametrize('missing', ['host', 'user', 'database'])
418
+ def test_a_missing_required_mysql_setting_raises(config_file, missing):
419
+ config_file({
420
+ key: value for key, value in
421
+ {'name': 'db', 'type': 'mysql', 'host': 'h', 'user': 'u',
422
+ 'password': 'p', 'database': 'd'}.items()
423
+ if key != missing
424
+ })
425
+
426
+ with pytest.raises(ConfigError, match=f"required '{missing}'"):
427
+ db_magic.get_db_config('db')
428
+
429
+
430
+ def test_password_is_required_without_iam_auth(config_file):
431
+ config_file({'name': 'db', 'type': 'mysql', 'host': 'h', 'user': 'u', 'database': 'd'})
432
+
433
+ with pytest.raises(ConfigError, match="required 'password'"):
434
+ db_magic.get_db_config('db')
435
+
436
+
437
+ def test_password_is_not_required_with_iam_auth(config_file):
438
+ config_file({'name': 'db', 'type': 'mysql', 'host': 'h', 'user': 'u',
439
+ 'database': 'd', 'iam_auth': True})
440
+
441
+ assert db_magic.get_db_config('db')['iam_auth'] is True
442
+
443
+
349
444
  def test_every_spellbook_error_shares_one_base_class():
350
445
  for error in (
351
446
  ConfigError, DatabaseNotFoundError, AuthenticationError,
@@ -38,31 +38,64 @@ def forget_hardcoded_warnings():
38
38
  utils._WARNED_HARDCODED.clear()
39
39
 
40
40
 
41
- # --- Unresolved environment variables are an error ---------------------------------------
42
- # Previously ${VAR} for an unset variable became '', which surfaced much later as a
43
- # confusing driver error, e.g. an empty username being rejected by the server.
44
-
45
- def test_unset_environment_variable_raises(monkeypatch, config_file):
46
- monkeypatch.delenv('DB_USER', raising=False)
41
+ # --- Unresolved environment variables are recorded, not raised ---------------------------
42
+ # Parsing must stay permissive: the whole section is re-parsed on every lookup, so raising
43
+ # here would let an unrelated database stop you connecting to the one you asked for.
44
+ # db_magic.get_db_config turns these records into an error for the database actually used.
45
+
46
+ @pytest.mark.parametrize('unset_value', [None, ''])
47
+ def test_unset_environment_variable_is_recorded_not_raised(
48
+ monkeypatch, config_file, unset_value,
49
+ ):
47
50
  monkeypatch.setenv('MYSQL_HOST', 'db.example.com')
51
+ if unset_value is None:
52
+ monkeypatch.delenv('DB_USER', raising=False)
53
+ else:
54
+ monkeypatch.setenv('DB_USER', unset_value)
48
55
  path = config_file(CONFIG)
49
56
 
50
- with pytest.raises(ConfigError) as error:
51
- utils.load_and_parse_config(config_file_path=path, configurations='databases')
57
+ config = utils.load_and_parse_config(config_file_path=path, configurations='databases')
52
58
 
53
- message = str(error.value)
54
- assert 'DB_USER' in message
55
- assert 'reporting_db' in message, 'the error should name the database involved'
56
- assert 'user' in message
59
+ db = config['databases'][0]
60
+ assert db['user'] == '', 'the placeholder is substituted away'
61
+ assert db['_unresolved_env'] == {'user': 'DB_USER'}
62
+ assert db['host'] == 'db.example.com', 'resolved values are unaffected'
57
63
 
58
64
 
59
- def test_empty_environment_variable_raises(monkeypatch, config_file):
65
+ def test_blocks_with_everything_set_carry_no_unresolved_record(monkeypatch, config_file):
60
66
  monkeypatch.setenv('MYSQL_HOST', 'db.example.com')
61
- monkeypatch.setenv('DB_USER', '')
67
+ monkeypatch.setenv('DB_USER', 'iam_user')
62
68
  path = config_file(CONFIG)
63
69
 
64
- with pytest.raises(ConfigError, match='DB_USER'):
65
- utils.load_and_parse_config(config_file_path=path, configurations='databases')
70
+ config = utils.load_and_parse_config(config_file_path=path, configurations='databases')
71
+
72
+ assert '_unresolved_env' not in config['databases'][0]
73
+
74
+
75
+ def test_an_unrelated_block_does_not_break_parsing(monkeypatch, config_file):
76
+ """The reported regression: one database's unset variable blocked every other database."""
77
+ monkeypatch.delenv('SNOW_PK_PASSPHRASE', raising=False)
78
+ path = config_file({'databases': [
79
+ {'name': 'snow_s', 'type': 'snowflake',
80
+ 'private_key_passphrase': '${SNOW_PK_PASSPHRASE}'},
81
+ {'name': 'reporting_db', 'type': 'mysql', 'host': 'db.example.com',
82
+ 'user': 'iam_user', 'database': 'd', 'iam_auth': True},
83
+ ]})
84
+
85
+ config = utils.load_and_parse_config(config_file_path=path, configurations='databases')
86
+
87
+ usable = config['databases'][1]
88
+ assert usable['user'] == 'iam_user'
89
+ assert '_unresolved_env' not in usable
90
+
91
+
92
+ def test_describe_unresolved_names_the_variable_key_and_database():
93
+ message = utils.describe_unresolved('reporting_db', 'user', 'DB_USER')
94
+
95
+ assert 'DB_USER' in message
96
+ assert 'user' in message
97
+ assert 'reporting_db' in message
98
+ assert utils.CONFIG_FILENAME in message
66
99
 
67
100
 
68
101
  def test_placeholders_are_substituted_when_set(monkeypatch, config_file):