fullscript.spellbook 3.0.0__tar.gz → 3.0.2__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 (49) hide show
  1. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/AGENTS.md +9 -0
  2. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/PKG-INFO +19 -10
  3. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/README.md +18 -9
  4. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/documentation/db_magic.md +4 -2
  5. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/PKG-INFO +19 -10
  6. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/scm_version.json +2 -2
  7. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/db_magic.py +101 -5
  8. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/utils.py +31 -11
  9. fullscript_spellbook-3.0.2/spellbook_config.yaml +55 -0
  10. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/tests/test_db_magic.py +183 -0
  11. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/tests/test_utils.py +49 -16
  12. fullscript_spellbook-3.0.0/spellbook_config.yaml +0 -54
  13. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/.gitignore +0 -0
  14. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/.gitlab-ci.yml +0 -0
  15. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/.python-version +0 -0
  16. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/CLAUDE.md +0 -0
  17. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/LICENSE +0 -0
  18. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/MANIFEST.in +0 -0
  19. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/documentation/drive_magic.md +0 -0
  20. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/documentation/sheet_magic.md +0 -0
  21. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/documentation/snowflake_key_pair_setup.md +0 -0
  22. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/documentation/stats_magic.md +0 -0
  23. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/SOURCES.txt +0 -0
  24. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/dependency_links.txt +0 -0
  25. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/requires.txt +0 -0
  26. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/scm_file_list.json +0 -0
  27. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/fullscript.spellbook.egg-info/top_level.txt +0 -0
  28. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/pyproject.toml +0 -0
  29. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/requirements.txt +0 -0
  30. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/setup.cfg +0 -0
  31. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/__init__.py +0 -0
  32. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/drive_magic.py +0 -0
  33. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/exceptions.py +0 -0
  34. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/py.typed +0 -0
  35. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/sheet_magic.py +0 -0
  36. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/stats_magic.py +0 -0
  37. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/__init__.py +0 -0
  38. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard1.txt +0 -0
  39. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard10.txt +0 -0
  40. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard11.txt +0 -0
  41. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard12.txt +0 -0
  42. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard2.txt +0 -0
  43. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard3.txt +0 -0
  44. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard4.txt +0 -0
  45. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard5.txt +0 -0
  46. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard6.txt +0 -0
  47. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard7.txt +0 -0
  48. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard8.txt +0 -0
  49. {fullscript_spellbook-3.0.0 → fullscript_spellbook-3.0.2}/spellbook/wizards/wizard9.txt +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.2
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
@@ -177,25 +177,34 @@ An example of the `spellbook_config.yaml` file is shown below:
177
177
  ```yaml
178
178
  databases:
179
179
  # Snowflake Database Connection
180
- - name: alias_of_your_connection
180
+ - name: snowflake_example
181
181
  type: snowflake
182
- account: your_account
183
- user: snowflake_user_name # Optional for private key auth
184
- password: snowflake_password # Optional, used if private key is not present
185
- private_key: snowflake_pk # Base64-encoded private key
186
- private_key_passphrase: snowflake_pk_phrase # Optional
182
+ account: ${SNOWFLAKE_ACCOUNT}
183
+ user: ${SNOWFLAKE_USER}
184
+ password: ${SNOWFLAKE_PASSWORD} # Optional when a private key is given
185
+ private_key: ${SNOW_PK} # Base64-encoded; used in preference to the password
186
+ private_key_passphrase: ${SNOW_PK_PASSPHRASE} # Optional
187
187
  role: snowflake_role
188
188
  warehouse: snowflake_warehouse
189
189
  database: snowflake_database
190
- schema: snowflake_schema
190
+ schema: snowflake_schema # Optional, defaults to 'public'
191
+
192
+ # MySQL Database Connection using a stored password
193
+ - name: mysql_example
194
+ type: mysql
195
+ host: your_host
196
+ port: 3306
197
+ user: mysql_user_name
198
+ password: ${MYSQL_PASSWORD}
199
+ database: mysql_database
191
200
 
192
201
  # MySQL Database Connection using AWS RDS IAM authentication (no stored password).
193
202
  # Fullscript internal only: requires the `rx` CLI. See documentation/db_magic.md.
194
- - name: alias_of_your_connection
203
+ # No `user` is needed - the IAM token names the user it was issued for.
204
+ - name: mysql_iam_example
195
205
  type: mysql
196
206
  host: your_host.rds.amazonaws.com
197
207
  port: 3306
198
- user: ${DB_USER}
199
208
  iam_auth: true
200
209
  database: ${MYSQL_DATABASE}
201
210
 
@@ -54,25 +54,34 @@ An example of the `spellbook_config.yaml` file is shown below:
54
54
  ```yaml
55
55
  databases:
56
56
  # Snowflake Database Connection
57
- - name: alias_of_your_connection
57
+ - name: snowflake_example
58
58
  type: snowflake
59
- account: your_account
60
- user: snowflake_user_name # Optional for private key auth
61
- password: snowflake_password # Optional, used if private key is not present
62
- private_key: snowflake_pk # Base64-encoded private key
63
- private_key_passphrase: snowflake_pk_phrase # Optional
59
+ account: ${SNOWFLAKE_ACCOUNT}
60
+ user: ${SNOWFLAKE_USER}
61
+ password: ${SNOWFLAKE_PASSWORD} # Optional when a private key is given
62
+ private_key: ${SNOW_PK} # Base64-encoded; used in preference to the password
63
+ private_key_passphrase: ${SNOW_PK_PASSPHRASE} # Optional
64
64
  role: snowflake_role
65
65
  warehouse: snowflake_warehouse
66
66
  database: snowflake_database
67
- schema: snowflake_schema
67
+ schema: snowflake_schema # Optional, defaults to 'public'
68
+
69
+ # MySQL Database Connection using a stored password
70
+ - name: mysql_example
71
+ type: mysql
72
+ host: your_host
73
+ port: 3306
74
+ user: mysql_user_name
75
+ password: ${MYSQL_PASSWORD}
76
+ database: mysql_database
68
77
 
69
78
  # MySQL Database Connection using AWS RDS IAM authentication (no stored password).
70
79
  # Fullscript internal only: requires the `rx` CLI. See documentation/db_magic.md.
71
- - name: alias_of_your_connection
80
+ # No `user` is needed - the IAM token names the user it was issued for.
81
+ - name: mysql_iam_example
72
82
  type: mysql
73
83
  host: your_host.rds.amazonaws.com
74
84
  port: 3306
75
- user: ${DB_USER}
76
85
  iam_auth: true
77
86
  database: ${MYSQL_DATABASE}
78
87
 
@@ -81,16 +81,18 @@ tokens expire after about 15 minutes:
81
81
  type: mysql
82
82
  host: ${MYSQL_HOST} # e.g. your-instance.abc123.us-east-1.rds.amazonaws.com
83
83
  port: 3306
84
- user: ${DB_USER}
85
84
  iam_auth: true
86
85
  rds_db_name: your_rds_instance_name # Optional, defaults to the value of `name`
87
86
  database: ${MYSQL_DATABASE}
88
87
  ```
89
88
 
89
+ No `user` is needed: the IAM token names the user it was issued for, and Spellbook reads it
90
+ from the token. Set `user` only if you want to pin it, in which case it must match the token.
91
+
90
92
  | Field | Required | Notes |
91
93
  | --- | --- | --- |
92
94
  | `iam_auth` | No | `true` switches the database to IAM tokens. Omitting it keeps password authentication. |
93
- | `user` | Yes | Your IAM database user. |
95
+ | `user` | No | Read from the token's `DBUser`. Set it only to pin a specific user; a value that disagrees with the token is reported as a config error. |
94
96
  | `rds_db_name` | No | The remote database as named by `rx rds list`. Defaults to `name`, so set it whenever your config alias differs from the RDS name. |
95
97
  | `ssl_ca` | No | Not needed in normal use; see below. |
96
98
  | `password` | No | Ignored when `iam_auth` is true. |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fullscript.spellbook
3
- Version: 3.0.0
3
+ Version: 3.0.2
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
@@ -177,25 +177,34 @@ An example of the `spellbook_config.yaml` file is shown below:
177
177
  ```yaml
178
178
  databases:
179
179
  # Snowflake Database Connection
180
- - name: alias_of_your_connection
180
+ - name: snowflake_example
181
181
  type: snowflake
182
- account: your_account
183
- user: snowflake_user_name # Optional for private key auth
184
- password: snowflake_password # Optional, used if private key is not present
185
- private_key: snowflake_pk # Base64-encoded private key
186
- private_key_passphrase: snowflake_pk_phrase # Optional
182
+ account: ${SNOWFLAKE_ACCOUNT}
183
+ user: ${SNOWFLAKE_USER}
184
+ password: ${SNOWFLAKE_PASSWORD} # Optional when a private key is given
185
+ private_key: ${SNOW_PK} # Base64-encoded; used in preference to the password
186
+ private_key_passphrase: ${SNOW_PK_PASSPHRASE} # Optional
187
187
  role: snowflake_role
188
188
  warehouse: snowflake_warehouse
189
189
  database: snowflake_database
190
- schema: snowflake_schema
190
+ schema: snowflake_schema # Optional, defaults to 'public'
191
+
192
+ # MySQL Database Connection using a stored password
193
+ - name: mysql_example
194
+ type: mysql
195
+ host: your_host
196
+ port: 3306
197
+ user: mysql_user_name
198
+ password: ${MYSQL_PASSWORD}
199
+ database: mysql_database
191
200
 
192
201
  # MySQL Database Connection using AWS RDS IAM authentication (no stored password).
193
202
  # Fullscript internal only: requires the `rx` CLI. See documentation/db_magic.md.
194
- - name: alias_of_your_connection
203
+ # No `user` is needed - the IAM token names the user it was issued for.
204
+ - name: mysql_iam_example
195
205
  type: mysql
196
206
  host: your_host.rds.amazonaws.com
197
207
  port: 3306
198
- user: ${DB_USER}
199
208
  iam_auth: true
200
209
  database: ${MYSQL_DATABASE}
201
210
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "3.0.0",
2
+ "tag": "3.0.2",
3
3
  "distance": 0,
4
- "node": "g5c9c10546f95ea303f36c2cc2e273cf241b92215",
4
+ "node": "gdb663ba3a57f8425421f12ce4d2b484ea72f5022",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-22"
@@ -4,7 +4,7 @@ import os
4
4
  import re
5
5
  import subprocess
6
6
  from typing import Any
7
- from urllib.parse import quote_plus
7
+ from urllib.parse import parse_qs, quote_plus
8
8
 
9
9
  import pandas as pd
10
10
  from cryptography.hazmat.backends import default_backend
@@ -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,50 @@ _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. See required_fields() for the per-database adjustments.
53
+ REQUIRED_FIELDS = {
54
+ 'mysql': ('host', 'user', 'database'),
55
+ 'snowflake': ('account', 'user', 'role', 'warehouse', 'database'),
56
+ }
57
+
58
+
59
+ def required_fields(db_config: dict[str, Any]) -> tuple[str, ...]:
60
+ """
61
+ Work out which settings this particular database cannot do without.
62
+
63
+ MySQL needs a `password` unless it authenticates with an IAM token, and conversely an
64
+ IAM database does not need a `user`, because the token says who it was issued to.
65
+
66
+ :param db_config: The configuration block for the database.
67
+ :return: The names of the settings that must be present and non-empty.
68
+ """
69
+ fields = REQUIRED_FIELDS.get(db_config.get('type'), ())
70
+
71
+ if db_config.get('type') == 'mysql':
72
+ if db_config.get('iam_auth'):
73
+ return tuple(field for field in fields if field != 'user')
74
+ return fields + ('password',)
75
+
76
+ return fields
77
+
78
+
79
+ def db_user_from_token(token: str) -> str | None:
80
+ """
81
+ Read the database user out of an AWS RDS IAM token.
82
+
83
+ The token is a presigned URL and carries the user it was issued for as its `DBUser`
84
+ parameter, so the user does not have to be repeated in the configuration file.
85
+
86
+ :param token: The token returned by `rx rds password`.
87
+ :return: The database user, or None if the token does not name one.
88
+ """
89
+ _, _, query = (token or '').partition('?')
90
+ users = parse_qs(query).get('DBUser') or []
91
+ return users[0] or None if users else None
92
+
43
93
 
44
94
  def _strip_ansi(text_value: str | None) -> str:
45
95
  """
@@ -51,13 +101,40 @@ def _strip_ansi(text_value: str | None) -> str:
51
101
  return _ANSI_ESCAPE.sub('', text_value or '')
52
102
 
53
103
 
104
+ def _validate_db_config(db_name: str, db_config: dict[str, Any]) -> dict[str, Any]:
105
+ """
106
+ Check that the settings this database cannot work without are actually filled in.
107
+
108
+ Only the database being used is checked. Settings that are legitimately optional, such as
109
+ a Snowflake private key passphrase or an `ssl_ca` bundle, are allowed to stay empty.
110
+
111
+ :param db_name: The name of the database, for the error message.
112
+ :param db_config: The configuration block for that database.
113
+ :return: The same configuration block.
114
+ :raises ConfigError: If a required setting is missing or resolved to an empty value.
115
+ """
116
+ unresolved = db_config.pop('_unresolved_env', {})
117
+
118
+ for key in required_fields(db_config):
119
+ if db_config.get(key):
120
+ continue
121
+ if key in unresolved:
122
+ raise ConfigError(describe_unresolved(db_name, key, unresolved[key]))
123
+ raise ConfigError(
124
+ f"Database '{db_name}' is missing the required '{key}' setting. "
125
+ f"Add it to {CONFIG_FILENAME}."
126
+ )
127
+
128
+ return db_config
129
+
130
+
54
131
  def get_db_config(db_name: str) -> dict[str, Any]:
55
132
  """
56
133
  Look up a single database block from the configuration file.
57
134
 
58
135
  :param db_name: The name of the database as defined in the config file.
59
136
  :return: The configuration for that database.
60
- :raises ConfigError: If the configuration file cannot be read.
137
+ :raises ConfigError: If the config cannot be read, or this database is incomplete.
61
138
  :raises DatabaseNotFoundError: If no database matches the given name.
62
139
  """
63
140
  config = load_and_parse_config(configurations='databases')
@@ -65,7 +142,7 @@ def get_db_config(db_name: str) -> dict[str, Any]:
65
142
  databases = config.get('databases') or []
66
143
  for db in databases:
67
144
  if db.get('name') == db_name:
68
- return db
145
+ return _validate_db_config(db_name, db)
69
146
 
70
147
  available = ', '.join(sorted(str(db.get('name')) for db in databases)) or '<none>'
71
148
  raise DatabaseNotFoundError(
@@ -224,11 +301,30 @@ def mysql_connect(db_name: str) -> Connection:
224
301
  # RDS instance names rarely match the alias used in the config file, so allow an
225
302
  # override and fall back to the alias when the two happen to line up.
226
303
  token = get_rds_iam_token(db_config.get('rds_db_name', db_config['name']))
304
+
305
+ # The token names the user it was issued for, so `user` is optional in the config.
306
+ token_user = db_user_from_token(token)
307
+ user = db_config.get('user') or token_user
308
+ if not user:
309
+ raise ConfigError(
310
+ f"Database '{db_name}' has no 'user', and the token from 'rx rds password' "
311
+ f"did not name one either. Add a 'user' to {CONFIG_FILENAME}."
312
+ )
313
+ if token_user and db_config.get('user') and db_config['user'] != token_user:
314
+ # The token is signed for one user, so connecting as another is rejected by RDS
315
+ # with a bare access-denied. Say what is actually wrong instead.
316
+ raise ConfigError(
317
+ f"Database '{db_name}' is configured for user "
318
+ f"{db_config['user']!r}, but 'rx rds password' issued a token for "
319
+ f"{token_user!r}. Remove 'user' from {CONFIG_FILENAME} to use the token's "
320
+ "own user, or correct it to match."
321
+ )
322
+
227
323
  # IAM tokens are URL shaped, so they have to be escaped inside the connection
228
324
  # string. AWS also requires TLS, and only pymysql completes the token handshake.
229
325
  ssl_ca = db_config.get('ssl_ca')
230
326
  connection_string = (
231
- f"mysql+pymysql://{quote_plus(db_config['user'])}:{quote_plus(token)}"
327
+ f"mysql+pymysql://{quote_plus(user)}:{quote_plus(token)}"
232
328
  f"@{host}:{port}/{database}"
233
329
  )
234
330
  # A CA bundle gets the certificate verified too; without one we still insist on
@@ -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.
@@ -0,0 +1,55 @@
1
+ databases:
2
+ # Snowflake connection, with values written straight into this file.
3
+ # Spellbook warns about hardcoded credentials, so prefer the next example.
4
+ - name: snowflake_example
5
+ type: snowflake
6
+ account: your_account
7
+ user: snowflake_user_name
8
+ password: snowflake_password # Optional when a private key is given
9
+ private_key: snowflake_pk # Base64-encoded; used in preference to the password
10
+ private_key_passphrase: snowflake_pk_phrase # Optional, omit if the key has none
11
+ role: snowflake_role
12
+ warehouse: snowflake_warehouse
13
+ database: snowflake_database
14
+ schema: snowflake_schema # Optional, defaults to 'public'
15
+
16
+ # The same connection, reading its sensitive values from environment variables.
17
+ - name: snowflake_env_example
18
+ type: snowflake
19
+ account: ${SNOWFLAKE_ACCOUNT}
20
+ user: ${SNOWFLAKE_USER}
21
+ password: ${SNOWFLAKE_PASSWORD}
22
+ private_key: ${SNOW_PK}
23
+ private_key_passphrase: ${SNOW_PK_PASSPHRASE} # Optional
24
+ role: snowflake_role
25
+ warehouse: snowflake_warehouse
26
+ database: snowflake_database
27
+ schema: snowflake_schema
28
+
29
+ # MySQL connection using a stored password.
30
+ - name: mysql_example
31
+ type: mysql
32
+ host: your_host
33
+ port: 3306 # Optional, defaults to 3306
34
+ user: mysql_user_name
35
+ password: ${MYSQL_PASSWORD}
36
+ database: mysql_database
37
+
38
+ # MySQL connection using AWS RDS IAM authentication, so no password is stored.
39
+ # Spellbook fetches a temporary token on every connection, because they expire
40
+ # after about 15 minutes. Requires the `rx` CLI, which is Fullscript internal tooling.
41
+ - name: mysql_iam_example
42
+ type: mysql
43
+ host: ${MYSQL_HOST} # e.g. your-instance.abc123.us-east-1.rds.amazonaws.com
44
+ port: 3306
45
+ iam_auth: true # Use an AWS RDS IAM token instead of a password
46
+ rds_db_name: your_rds_instance_name # Optional, as named by `rx rds list`; defaults to `name`
47
+ database: ${MYSQL_DATABASE}
48
+
49
+ google_accounts:
50
+ # Google Sheets Service Account 1
51
+ - name: google_sheets_service_account_1
52
+ service_account: "your_base64_encoded_service_account_credentials_here" # Add your Base64-encoded service account JSON here
53
+ # Google Sheets Service Account 2
54
+ - name: google_sheets_service_account_2
55
+ service_account: "your_base64_encoded_service_account_credentials_here"
@@ -211,6 +211,94 @@ def test_iam_auth_fetches_a_fresh_token_per_connection(monkeypatch, config, engi
211
211
  assert 'token-two' in engines[1][0]
212
212
 
213
213
 
214
+ # --- The IAM token names its own user ----------------------------------------------------
215
+ # An RDS IAM token is a presigned URL carrying DBUser, so `user` need not be configured.
216
+
217
+ @pytest.mark.parametrize('token, expected', [
218
+ (FAKE_TOKEN, 'iam_user'),
219
+ ('host:3306/?Action=connect&DBUser=first.last&X-Amz-Signature=x', 'first.last'),
220
+ ('host:3306/?DBUser=needs%40escaping', 'needs@escaping'),
221
+ ('host:3306/?Action=connect&X-Amz-Signature=x', None), # no DBUser at all
222
+ ('host:3306/?DBUser=', None), # present but empty
223
+ ('not-a-url-at-all', None),
224
+ ('', None),
225
+ ])
226
+ def test_db_user_is_read_from_the_token(token, expected):
227
+ assert db_magic.db_user_from_token(token) == expected
228
+
229
+
230
+ def test_user_is_derived_from_the_token_when_not_configured(monkeypatch, config, engines):
231
+ without_user = {key: value for key, value in IAM_DB.items() if key != 'user'}
232
+ config(without_user)
233
+ monkeypatch.setattr(subprocess, 'run', fake_rx(stdout=FAKE_TOKEN))
234
+
235
+ db_magic.mysql_connect('reporting_db')
236
+
237
+ # FAKE_TOKEN carries DBUser=iam_user
238
+ assert engines[0][0].startswith('mysql+pymysql://iam_user:')
239
+
240
+
241
+ def test_a_configured_user_is_still_honoured(monkeypatch, config, engines):
242
+ config(IAM_DB)
243
+ monkeypatch.setattr(subprocess, 'run', fake_rx(stdout=FAKE_TOKEN))
244
+
245
+ db_magic.mysql_connect('reporting_db')
246
+
247
+ assert engines[0][0].startswith('mysql+pymysql://iam_user:')
248
+
249
+
250
+ def test_a_user_that_disagrees_with_the_token_is_reported_clearly(
251
+ monkeypatch, config, engines,
252
+ ):
253
+ """RDS rejects a mismatch with a bare access-denied, so catch it with a real message."""
254
+ config({**IAM_DB, 'user': 'someone.else'})
255
+ monkeypatch.setattr(subprocess, 'run', fake_rx(stdout=FAKE_TOKEN))
256
+
257
+ with pytest.raises(ConfigError) as error:
258
+ db_magic.mysql_connect('reporting_db')
259
+
260
+ message = str(error.value)
261
+ assert 'someone.else' in message
262
+ assert 'iam_user' in message
263
+ assert engines == [], 'no connection should be attempted'
264
+
265
+
266
+ def test_a_token_without_a_user_and_no_config_user_raises(monkeypatch, config, engines):
267
+ without_user = {key: value for key, value in IAM_DB.items() if key != 'user'}
268
+ config(without_user)
269
+ monkeypatch.setattr(subprocess, 'run', fake_rx(
270
+ stdout='host:3306/?Action=connect&X-Amz-Signature=x',
271
+ ))
272
+
273
+ with pytest.raises(ConfigError, match="did not name one either"):
274
+ db_magic.mysql_connect('reporting_db')
275
+
276
+
277
+ def test_user_is_not_required_in_config_for_iam_databases(config_file):
278
+ config_file({'name': 'db', 'type': 'mysql', 'host': 'h',
279
+ 'database': 'd', 'iam_auth': True})
280
+
281
+ assert db_magic.get_db_config('db').get('user') is None
282
+
283
+
284
+ def test_user_is_still_required_for_password_databases(config_file):
285
+ config_file({'name': 'db', 'type': 'mysql', 'host': 'h',
286
+ 'database': 'd', 'password': 'p'})
287
+
288
+ with pytest.raises(ConfigError, match="required 'user'"):
289
+ db_magic.get_db_config('db')
290
+
291
+
292
+ @pytest.mark.parametrize('db_config, expected', [
293
+ ({'type': 'mysql', 'iam_auth': True}, ('host', 'database')),
294
+ ({'type': 'mysql'}, ('host', 'user', 'database', 'password')),
295
+ ({'type': 'snowflake'}, ('account', 'user', 'role', 'warehouse', 'database')),
296
+ ({'type': 'postgres'}, ()),
297
+ ])
298
+ def test_required_fields_per_database_type(db_config, expected):
299
+ assert db_magic.required_fields(db_config) == expected
300
+
301
+
214
302
  # --- Literal '%' must survive on the pymysql path ----------------------------------------
215
303
  # pymysql substitutes whenever args is not None, so the empty parameter set pandas passes
216
304
  # would turn `like '%x%'` into a format string. mysql-connector guards on `if params:`,
@@ -346,6 +434,101 @@ def test_missing_required_field_raises_config_error(config):
346
434
  db_magic.mysql_connect('legacy_mysql')
347
435
 
348
436
 
437
+ # --- Required settings are validated per database, not across the whole config -----------
438
+ # Regression: validation ran over every block while parsing, so an unrelated Snowflake
439
+ # entry with an unset ${SNOW_PK_PASSPHRASE} made every MySQL query fail.
440
+
441
+ @pytest.fixture
442
+ def config_file(tmp_path, monkeypatch):
443
+ """Write a real config file and make Spellbook load it."""
444
+ import yaml
445
+
446
+ def write(*databases):
447
+ path = tmp_path / 'spellbook_config.yaml'
448
+ path.write_text(yaml.safe_dump({'databases': list(databases)}))
449
+ monkeypatch.setattr(
450
+ db_magic, 'load_and_parse_config',
451
+ lambda **kw: __import__('spellbook.utils', fromlist=['x'])
452
+ .load_and_parse_config(config_file_path=str(path), **kw),
453
+ )
454
+ return write
455
+
456
+
457
+ def test_an_unrelated_database_does_not_block_the_one_you_asked_for(
458
+ monkeypatch, config_file,
459
+ ):
460
+ monkeypatch.delenv('SNOW_PK_PASSPHRASE', raising=False)
461
+ config_file(
462
+ {'name': 'snow_s', 'type': 'snowflake', 'account': 'a', 'user': 'u', 'role': 'r',
463
+ 'warehouse': 'w', 'database': 'd',
464
+ 'private_key_passphrase': '${SNOW_PK_PASSPHRASE}'},
465
+ IAM_DB,
466
+ )
467
+
468
+ db = db_magic.get_db_config('reporting_db')
469
+
470
+ assert db['user'] == 'iam_user'
471
+ assert '_unresolved_env' not in db, 'the internal marker must not leak to callers'
472
+
473
+
474
+ def test_an_optional_unset_setting_does_not_block_its_own_database(monkeypatch, config_file):
475
+ """A Snowflake key with no passphrase is a valid setup, so it must not raise."""
476
+ monkeypatch.delenv('SNOW_PK_PASSPHRASE', raising=False)
477
+ config_file({
478
+ 'name': 'snow_s', 'type': 'snowflake', 'account': 'a', 'user': 'u', 'role': 'r',
479
+ 'warehouse': 'w', 'database': 'd', 'private_key_passphrase': '${SNOW_PK_PASSPHRASE}',
480
+ })
481
+
482
+ db = db_magic.get_db_config('snow_s')
483
+
484
+ assert db['private_key_passphrase'] == ''
485
+
486
+
487
+ def test_a_required_setting_with_an_unset_variable_raises_and_names_it(
488
+ monkeypatch, config_file,
489
+ ):
490
+ monkeypatch.delenv('DB_USER_NOT_SET', raising=False)
491
+ config_file({
492
+ 'name': 'broken_db', 'type': 'mysql', 'host': 'h',
493
+ 'user': '${DB_USER_NOT_SET}', 'password': 'p', 'database': 'd',
494
+ })
495
+
496
+ with pytest.raises(ConfigError) as error:
497
+ db_magic.get_db_config('broken_db')
498
+
499
+ message = str(error.value)
500
+ assert 'DB_USER_NOT_SET' in message
501
+ assert 'broken_db' in message
502
+ assert 'user' in message
503
+
504
+
505
+ @pytest.mark.parametrize('missing', ['host', 'user', 'database'])
506
+ def test_a_missing_required_mysql_setting_raises(config_file, missing):
507
+ config_file({
508
+ key: value for key, value in
509
+ {'name': 'db', 'type': 'mysql', 'host': 'h', 'user': 'u',
510
+ 'password': 'p', 'database': 'd'}.items()
511
+ if key != missing
512
+ })
513
+
514
+ with pytest.raises(ConfigError, match=f"required '{missing}'"):
515
+ db_magic.get_db_config('db')
516
+
517
+
518
+ def test_password_is_required_without_iam_auth(config_file):
519
+ config_file({'name': 'db', 'type': 'mysql', 'host': 'h', 'user': 'u', 'database': 'd'})
520
+
521
+ with pytest.raises(ConfigError, match="required 'password'"):
522
+ db_magic.get_db_config('db')
523
+
524
+
525
+ def test_password_is_not_required_with_iam_auth(config_file):
526
+ config_file({'name': 'db', 'type': 'mysql', 'host': 'h', 'user': 'u',
527
+ 'database': 'd', 'iam_auth': True})
528
+
529
+ assert db_magic.get_db_config('db')['iam_auth'] is True
530
+
531
+
349
532
  def test_every_spellbook_error_shares_one_base_class():
350
533
  for error in (
351
534
  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):
@@ -1,54 +0,0 @@
1
- databases:
2
- # Snowflake Database Connection
3
- - name: alias_of_your_connection
4
- type: snowflake
5
- account: your_account
6
- user: snowflake_user_name # Optional for private key auth
7
- password: snowflake_password # Optional, used if private key is not present
8
- private_key: snowflake_pk # Base64-encoded private key
9
- private_key_passphrase: snowflake_pk_phrase # Optional
10
- role: snowflake_role
11
- warehouse: snowflake_warehouse
12
- database: snowflake_database
13
- schema: snowflake_schema
14
-
15
- - name: alias_of_your_connection_2
16
- type: snowflake
17
- account: ${SNOWFLAKE_ACCOUNT}
18
- user: ${SNOWFLAKE_USER} # Example of using environment variables
19
- password: ${SNOWFLAKE_PASSWORD} # Example of using environment variables
20
- private_key: snowflake_pk # Example of using environment variables
21
- private_key_passphrase: snowflake_pk_phrase # Optional
22
- role: snowflake_role
23
- warehouse: snowflake_warehouse
24
- database: snowflake_database
25
- schema: snowflake_schema
26
-
27
- # MySQL Database Connection
28
- - name: alias_of_your_connection
29
- type: mysql
30
- host: your_host
31
- port: 3306
32
- user: mysql_user_name
33
- password: mysql_password
34
- database: mysql_database
35
-
36
- # MySQL Database Connection using AWS RDS IAM authentication.
37
- # Requires the `rx` CLI, which is Fullscript internal tooling.
38
- - name: alias_of_your_connection_3
39
- type: mysql
40
- host: ${MYSQL_HOST} # e.g. your-instance.abc123.us-east-1.rds.amazonaws.com
41
- port: 3306
42
- user: ${DB_USER} # Your IAM database user
43
- iam_auth: true # Use an AWS RDS IAM token instead of a password
44
- rds_db_name: your_rds_instance_name # Optional, as named by `rx rds list`; defaults to `name`
45
- database: ${MYSQL_DATABASE}
46
-
47
- google_accounts:
48
- # Google Sheets Service Account 1
49
- - name: google_sheets_service_account_1
50
- service_account: "your_base64_encoded_service_account_credentials_here" # Add your Base64-encoded service account JSON here
51
- # Google Sheets Service Account 2
52
- - name: google_sheets_service_account_2
53
- service_account: "your_base64_encoded_service_account_credentials_here"
54
-