Skip to content

Commit 0ffb0d4

Browse files
authored
feat(cli): Multi-Config Resolution by Env Var or pyproject.toml (#277)
Enable multiple configuration conveniences. - Ability to set the default `--config` - `SQLSPEC_CONFIG` environment variable - `[tools.sqlspec]` section in pyproject.
1 parent 7b6486a commit 0ffb0d4

File tree

11 files changed

+1774
-532
lines changed

11 files changed

+1774
-532
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ repos:
1717
- id: mixed-line-ending
1818
- id: trailing-whitespace
1919
- repo: https://github.com/charliermarsh/ruff-pre-commit
20-
rev: "v0.14.7"
20+
rev: "v0.14.8"
2121
hooks:
2222
- id: ruff
2323
args: ["--fix"]

AGENTS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,52 @@ Dialect.classes["custom"] = CustomDialect
415415
- Use `wrap_exceptions` context manager in adapter layer
416416
- Two-tier pattern: graceful skip (DEBUG) for expected conditions, hard errors for malformed input
417417

418+
### Click Environment Variable Pattern
419+
420+
When adding CLI options that should support environment variables:
421+
422+
**Use Click's native `envvar` parameter instead of custom parsing:**
423+
424+
```python
425+
# Good - Click handles env var automatically
426+
@click.option(
427+
"--config",
428+
help="Dotted path to SQLSpec config(s) (env: SQLSPEC_CONFIG)",
429+
required=False,
430+
type=str,
431+
envvar="SQLSPEC_CONFIG", # Click handles precedence: CLI flag > env var
432+
)
433+
def command(config: str | None):
434+
pass
435+
436+
# Bad - Custom env var parsing
437+
import os
438+
439+
@click.option("--config", required=False, type=str)
440+
def command(config: str | None):
441+
if config is None:
442+
config = os.getenv("SQLSPEC_CONFIG") # Don't do this!
443+
```
444+
445+
**Benefits:**
446+
- Click automatically handles precedence (CLI flag always overrides env var)
447+
- Help text automatically shows env var name
448+
- Support for multiple fallback env vars via `envvar=["VAR1", "VAR2"]`
449+
- Less code, fewer bugs
450+
451+
**For project file discovery (pyproject.toml, etc.):**
452+
- Use custom logic as fallback after Click env var handling
453+
- Walk filesystem from cwd to find config files
454+
- Return `None` if not found to trigger helpful error message
455+
456+
**Multi-config support:**
457+
- Split comma-separated values from CLI flag, env var, or pyproject.toml
458+
- Resolve each config path independently
459+
- Flatten results if callables return lists
460+
- Deduplicate by `bind_key` (later configs override earlier ones with same key)
461+
462+
**Reference implementation:** `sqlspec/cli.py` (lines 26-110), `sqlspec/utils/config_discovery.py`
463+
418464
### CLI Sync/Async Dispatch Pattern
419465

420466
When implementing CLI commands that support both sync and async database adapters:

docs/examples/usage/usage_cli_1.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ def test_single_and_multiple_configs() -> None:
2525
def get_configs() -> list[AsyncpgConfig]:
2626
return [db_config]
2727

28+
# Usage with CLI:
29+
# --config "myapp.config.db_config" # Single config
30+
# --config "myapp.config.configs" # Config list
31+
# --config "myapp.config.get_configs" # Callable
32+
# --config "myapp.config.db_config,myapp.config.configs" # Multiple paths (comma-separated)
33+
2834
# end-example
2935
assert isinstance(db_config, AsyncpgConfig)
3036
assert isinstance(configs, list)

docs/usage/cli.rst

Lines changed: 200 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,27 @@ that generate static completion files (system-wide bash, zsh completion director
204204
Available Commands
205205
==================
206206

207-
The SQLSpec CLI provides commands for managing database migrations. All commands
208-
require a ``--config`` option pointing to your SQLSpec configuration.
207+
The SQLSpec CLI provides commands for managing database migrations. Configure the CLI
208+
using ``--config`` flag, ``SQLSPEC_CONFIG`` environment variable, or ``[tool.sqlspec]``
209+
section in pyproject.toml.
209210

210211
Configuration Loading
211212
---------------------
212213

213-
The ``--config`` option accepts a dotted path to either:
214+
SQLSpec CLI supports three ways to specify your database configuration, in order of precedence:
214215

215-
1. **A single config object**: ``myapp.config.db_config``
216-
2. **A config list**: ``myapp.config.configs``
217-
3. **A callable function**: ``myapp.config.get_configs()``
216+
1. **CLI Flag** (``--config``) - Explicit override for one-off commands
217+
2. **Environment Variable** (``SQLSPEC_CONFIG``) - Convenient for development workflows
218+
3. **pyproject.toml** (``[tool.sqlspec]``) - Project-wide default configuration
219+
220+
The ``--config`` option and ``SQLSPEC_CONFIG`` environment variable accept:
221+
222+
- **A single config object**: ``myapp.config.db_config``
223+
- **A config list**: ``myapp.config.configs``
224+
- **A callable function**: ``myapp.config.get_configs()``
225+
- **Multiple config paths (comma-separated)**: ``myapp.config.primary_config,myapp.config.analytics_config``
226+
227+
Each config path is resolved independently, and if a callable returns a list of configs, all configs are collected.
218228

219229
Example configuration file (``myapp/config.py``):
220230

@@ -225,13 +235,128 @@ Example configuration file (``myapp/config.py``):
225235
:end-before: # end-example
226236
:caption: `configuration loading`
227237

238+
Config Discovery Methods
239+
^^^^^^^^^^^^^^^^^^^^^^^^
240+
241+
**Method 1: CLI Flag (Highest Priority)**
242+
243+
.. code-block:: bash
244+
245+
sqlspec --config myapp.config.get_configs upgrade head
246+
247+
Use for one-off commands or to override other config sources.
248+
249+
**Method 2: Environment Variable**
250+
251+
.. code-block:: bash
252+
253+
export SQLSPEC_CONFIG=myapp.config.get_configs
254+
sqlspec upgrade head # Uses environment variable
255+
256+
Convenient for development. Add to your shell profile:
257+
258+
.. code-block:: bash
259+
260+
# ~/.bashrc or ~/.zshrc
261+
export SQLSPEC_CONFIG=myapp.config.get_configs
262+
263+
Multiple configs (comma-separated):
264+
265+
.. code-block:: bash
266+
267+
export SQLSPEC_CONFIG="app.db.primary_config,app.db.analytics_config"
268+
269+
**Method 3: pyproject.toml (Project Default)**
270+
271+
.. code-block:: toml
272+
273+
[tool.sqlspec]
274+
config = "myapp.config.get_configs"
275+
276+
Best for team projects - config is version controlled.
277+
278+
Multiple configs (array):
279+
280+
.. code-block:: toml
281+
282+
[tool.sqlspec]
283+
config = [
284+
"myapp.config.primary_config",
285+
"myapp.config.analytics_config"
286+
]
287+
288+
**Precedence:** CLI flag > Environment variable > pyproject.toml
289+
290+
If no config is found from any source, SQLSpec will show a helpful error message with examples.
291+
292+
Multi-Config Resolution Details
293+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
294+
295+
When using comma-separated config paths or list format in pyproject.toml, SQLSpec:
296+
297+
1. **Resolves each path independently**: Each dotted path is imported and resolved
298+
2. **Flattens callable results**: If a callable returns a list, all configs are collected
299+
3. **Deduplicates by bind_key**: Later configs override earlier ones with the same ``bind_key``
300+
4. **Validates final list**: Empty config lists result in an error
301+
302+
**Deduplication Example:**
303+
304+
.. code-block:: bash
305+
306+
# If primary_config has bind_key="db" and backup_config has bind_key="db"
307+
export SQLSPEC_CONFIG="app.primary_config,app.backup_config"
308+
309+
# Result: Only backup_config is used (last wins)
310+
311+
**Combined callable and list:**
312+
313+
.. code-block:: python
314+
315+
# myapp/config.py
316+
def get_all_configs():
317+
"""Returns list of configs."""
318+
return [primary_config, analytics_config]
319+
320+
single_config = AsyncpgConfig(bind_key="backup", ...)
321+
322+
.. code-block:: bash
323+
324+
# This resolves to 3 configs: primary, analytics, and backup
325+
export SQLSPEC_CONFIG="myapp.config.get_all_configs,myapp.config.single_config"
326+
327+
**Deduplication with callables:**
328+
329+
.. code-block:: python
330+
331+
# myapp/config.py
332+
primary = AsyncpgConfig(bind_key="db", ...)
333+
updated = AsyncpgConfig(bind_key="db", ...) # Same bind_key
334+
335+
def get_primary():
336+
return primary
337+
338+
def get_updated():
339+
return updated
340+
341+
.. code-block:: bash
342+
343+
# Only updated config is used (last wins for bind_key="db")
344+
sqlspec --config "myapp.config.get_primary,myapp.config.get_updated" upgrade
345+
228346
Global Options
229347
--------------
230348

231349
``--config PATH``
232-
**Required**. Dotted path to SQLSpec config(s) or callable function.
350+
Dotted path to SQLSpec config(s) or callable function. Supports comma-separated
351+
multiple paths. Optional when using environment variable or pyproject.toml config discovery.
352+
353+
Examples:
233354

234-
Example: ``--config myapp.config.get_configs``
355+
- Single config: ``--config myapp.config.db_config``
356+
- Callable: ``--config myapp.config.get_configs``
357+
- Multiple paths: ``--config "myapp.config.primary_config,myapp.config.analytics_config"``
358+
359+
Configs with duplicate ``bind_key`` values are deduplicated (last wins).
235360

236361
``--validate-config``
237362
Validate configuration before executing migrations. Shows loaded configs
@@ -746,6 +871,41 @@ Multi-Config Operations
746871
When you have multiple database configurations, SQLSpec provides options to manage
747872
them collectively or selectively.
748873

874+
Quick Reference: Multi-Config Patterns
875+
---------------------------------------
876+
877+
.. list-table::
878+
:header-rows: 1
879+
:widths: 30 35 35
880+
881+
* - Pattern
882+
- Example
883+
- Behavior
884+
* - Single config
885+
- ``--config "app.config.db"``
886+
- Load one config
887+
* - Config list
888+
- ``--config "app.config.configs"``
889+
- Load all configs in list
890+
* - Callable returning list
891+
- ``--config "app.config.get_configs"``
892+
- Call function, load returned configs
893+
* - Comma-separated paths
894+
- ``--config "app.config.db1,app.config.db2"``
895+
- Load multiple configs, deduplicate by bind_key
896+
* - Env var (comma-separated)
897+
- ``SQLSPEC_CONFIG="app.config.db1,app.config.db2"``
898+
- Same as comma-separated CLI flag
899+
* - pyproject.toml (list)
900+
- ``config = ["app.config.db1", "app.config.db2"]``
901+
- Load all paths in array
902+
* - Mixed callables and configs
903+
- ``--config "app.config.get_configs,app.config.backup"``
904+
- Flatten callable results + direct configs
905+
* - Duplicate bind_key
906+
- ``--config "app.config.old,app.config.new"``
907+
- Later config overrides (new wins)
908+
749909
Scenario: Multiple Databases
750910
-----------------------------
751911

@@ -892,6 +1052,38 @@ Best Practices
8921052
8931053
sqlspec --config myapp.config upgrade --dry-run
8941054
1055+
7. **Use Unique bind_key for Multi-Config**
1056+
1057+
When managing multiple databases, always specify unique ``bind_key`` values:
1058+
1059+
.. code-block:: python
1060+
1061+
# Good - unique bind_keys
1062+
configs = [
1063+
AsyncpgConfig(bind_key="primary", ...),
1064+
AsyncpgConfig(bind_key="analytics", ...),
1065+
]
1066+
1067+
# Problematic - configs will overwrite each other
1068+
configs = [
1069+
AsyncpgConfig(bind_key="db", ...), # Same key
1070+
AsyncmyConfig(bind_key="db", ...), # Will override above
1071+
]
1072+
1073+
8. **Prefer pyproject.toml for Team Projects**
1074+
1075+
Store config paths in version control for consistency:
1076+
1077+
.. code-block:: toml
1078+
1079+
[tool.sqlspec]
1080+
config = [
1081+
"myapp.config.primary_db",
1082+
"myapp.config.analytics_db"
1083+
]
1084+
1085+
Team members automatically use the same config without manual setup.
1086+
8951087
Framework Integration
8961088
=====================
8971089

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ asyncmy = ["asyncmy"]
2525
asyncpg = ["asyncpg"]
2626
attrs = ["attrs", "cattrs"]
2727
bigquery = ["google-cloud-bigquery", "google-cloud-storage"]
28-
cli = ["rich-click"]
28+
cli = ["rich-click", "tomli>=2.0.0; python_version < '3.11'"]
2929
cloud-sql = ["cloud-sql-python-connector"]
3030
duckdb = ["duckdb"]
3131
fastapi = ["fastapi"]
@@ -107,7 +107,7 @@ lint = [
107107
"asyncpg-stubs",
108108
"pyarrow-stubs",
109109
"pandas-stubs",
110-
110+
"tomli>=2.0.0",
111111
]
112112
test = [
113113
"aiohttp",

0 commit comments

Comments
 (0)