Skip to content

Commit 6ebd58e

Browse files
committed
feat(cli): Multi-Config Resolution by Env Var or pyproject.toml
1 parent 7b6486a commit 6ebd58e

File tree

9 files changed

+1104
-16
lines changed

9 files changed

+1104
-16
lines changed

AGENTS.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,46 @@ 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+
**Reference implementation:** `sqlspec/cli.py` (lines 26-65), `sqlspec/utils/config_discovery.py`
457+
418458
### CLI Sync/Async Dispatch Pattern
419459

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

docs/usage/cli.rst

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,24 @@ 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 a dotted path to either:
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()``
218225

219226
Example configuration file (``myapp/config.py``):
220227

@@ -225,11 +232,66 @@ Example configuration file (``myapp/config.py``):
225232
:end-before: # end-example
226233
:caption: `configuration loading`
227234

235+
Config Discovery Methods
236+
^^^^^^^^^^^^^^^^^^^^^^^^
237+
238+
**Method 1: CLI Flag (Highest Priority)**
239+
240+
.. code-block:: bash
241+
242+
sqlspec --config myapp.config:get_configs upgrade head
243+
244+
Use for one-off commands or to override other config sources.
245+
246+
**Method 2: Environment Variable**
247+
248+
.. code-block:: bash
249+
250+
export SQLSPEC_CONFIG=myapp.config:get_configs
251+
sqlspec upgrade head # Uses environment variable
252+
253+
Convenient for development. Add to your shell profile:
254+
255+
.. code-block:: bash
256+
257+
# ~/.bashrc or ~/.zshrc
258+
export SQLSPEC_CONFIG=myapp.config:get_configs
259+
260+
Multiple configs (comma-separated):
261+
262+
.. code-block:: bash
263+
264+
export SQLSPEC_CONFIG="app.db:primary_config,app.db:analytics_config"
265+
266+
**Method 3: pyproject.toml (Project Default)**
267+
268+
.. code-block:: toml
269+
270+
[tool.sqlspec]
271+
config = "myapp.config:get_configs"
272+
273+
Best for team projects - config is version controlled.
274+
275+
Multiple configs (array):
276+
277+
.. code-block:: toml
278+
279+
[tool.sqlspec]
280+
config = [
281+
"myapp.config:primary_config",
282+
"myapp.config:analytics_config"
283+
]
284+
285+
**Precedence:** CLI flag > Environment variable > pyproject.toml
286+
287+
If no config is found from any source, SQLSpec will show a helpful error message with examples.
288+
228289
Global Options
229290
--------------
230291

231292
``--config PATH``
232-
**Required**. Dotted path to SQLSpec config(s) or callable function.
293+
Dotted path to SQLSpec config(s) or callable function. Optional when using
294+
environment variable or pyproject.toml config discovery.
233295

234296
Example: ``--config myapp.config.get_configs``
235297

pyproject.toml

Lines changed: 1 addition & 1 deletion
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"]

sqlspec/cli.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,44 @@ def get_sqlspec_group() -> "Group":
2626
@click.group(name="sqlspec")
2727
@click.option(
2828
"--config",
29-
help="Dotted path to SQLSpec config(s) or callable function (e.g. 'myapp.config.get_configs')",
30-
required=True,
29+
help="Dotted path to SQLSpec config(s) or callable function (env: SQLSPEC_CONFIG)",
30+
required=False,
31+
default=None,
3132
type=str,
33+
envvar="SQLSPEC_CONFIG",
3234
)
3335
@click.option(
3436
"--validate-config", is_flag=True, default=False, help="Validate configuration before executing migrations"
3537
)
3638
@click.pass_context
37-
def sqlspec_group(ctx: "click.Context", config: str, validate_config: bool) -> None:
39+
def sqlspec_group(ctx: "click.Context", config: str | None, validate_config: bool) -> None:
3840
"""SQLSpec CLI commands."""
3941
from rich import get_console
4042

4143
from sqlspec.exceptions import ConfigResolverError
44+
from sqlspec.utils.config_discovery import discover_config_from_pyproject
4245
from sqlspec.utils.config_resolver import resolve_config_sync
4346

4447
console = get_console()
4548
ctx.ensure_object(dict)
4649

50+
# Click already handled: CLI flag > SQLSPEC_CONFIG env var
51+
# Now check pyproject.toml as final fallback
52+
if config is None:
53+
config = discover_config_from_pyproject()
54+
if config:
55+
console.print("[dim]Using config from pyproject.toml[/]")
56+
57+
# No config from any source - show helpful error
58+
if config is None:
59+
console.print("[red]Error: No SQLSpec config found.[/]")
60+
console.print("\nSpecify config using one of:")
61+
console.print(" 1. CLI flag: sqlspec --config myapp.config:get_configs <command>")
62+
console.print(" 2. Environment var: export SQLSPEC_CONFIG=myapp.config:get_configs")
63+
console.print(" 3. pyproject.toml: [tool.sqlspec]")
64+
console.print(' config = "myapp.config:get_configs"')
65+
ctx.exit(1)
66+
4767
# Add current working directory to sys.path to allow loading local config modules
4868
cwd = str(Path.cwd())
4969
cwd_added = False
@@ -52,11 +72,34 @@ def sqlspec_group(ctx: "click.Context", config: str, validate_config: bool) -> N
5272
cwd_added = True
5373

5474
try:
55-
config_result = resolve_config_sync(config)
56-
if isinstance(config_result, Sequence) and not isinstance(config_result, str):
57-
ctx.obj["configs"] = list(config_result)
58-
else:
59-
ctx.obj["configs"] = [config_result]
75+
# Split comma-separated config paths and resolve each
76+
all_configs: list[AsyncDatabaseConfig[Any, Any, Any] | SyncDatabaseConfig[Any, Any, Any]] = []
77+
for config_path in config.split(","):
78+
config_path = config_path.strip()
79+
if not config_path:
80+
continue
81+
config_result = resolve_config_sync(config_path)
82+
if isinstance(config_result, Sequence) and not isinstance(config_result, str):
83+
all_configs.extend(config_result)
84+
else:
85+
all_configs.append(
86+
cast("AsyncDatabaseConfig[Any, Any, Any] | SyncDatabaseConfig[Any, Any, Any]", config_result)
87+
)
88+
89+
# Deduplicate by bind_key (later configs override earlier ones)
90+
configs_by_key: dict[
91+
str | None, AsyncDatabaseConfig[Any, Any, Any] | SyncDatabaseConfig[Any, Any, Any]
92+
] = {}
93+
for cfg in all_configs:
94+
configs_by_key[cfg.bind_key] = cfg
95+
96+
ctx.obj["configs"] = list(configs_by_key.values())
97+
98+
# Check for empty configs after resolution
99+
if not ctx.obj["configs"]:
100+
console.print("[red]Error: No valid configs found after resolution.[/]")
101+
console.print("\nEnsure your config path returns valid config instance(s).")
102+
ctx.exit(1)
60103

61104
ctx.obj["validate_config"] = validate_config
62105

sqlspec/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
from sqlspec.utils import (
10+
config_discovery,
1011
deprecation,
1112
fixtures,
1213
logging,
@@ -19,6 +20,7 @@
1920
)
2021

2122
__all__ = (
23+
"config_discovery",
2224
"deprecation",
2325
"fixtures",
2426
"logging",

sqlspec/utils/config_discovery.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Config discovery for SQLSpec CLI.
2+
3+
Provides pyproject.toml config discovery for CLI convenience.
4+
Environment variable support is handled natively by Click via envvar parameter.
5+
"""
6+
7+
import sys
8+
from pathlib import Path
9+
10+
if sys.version_info >= (3, 11):
11+
import tomllib
12+
else:
13+
import tomli as tomllib
14+
15+
16+
__all__ = ("discover_config_from_pyproject", "find_pyproject_toml", "parse_pyproject_config")
17+
18+
19+
def discover_config_from_pyproject() -> str | None:
20+
"""Find and parse pyproject.toml for SQLSpec config.
21+
22+
Walks filesystem upward from current directory to find pyproject.toml.
23+
Parses [tool.sqlspec] section for 'config' key.
24+
25+
Returns:
26+
Config path(s) as string (comma-separated if list), or None if not found.
27+
"""
28+
pyproject_path = find_pyproject_toml()
29+
if pyproject_path is None:
30+
return None
31+
32+
return parse_pyproject_config(pyproject_path)
33+
34+
35+
def find_pyproject_toml() -> "Path | None":
36+
"""Walk filesystem upward to find pyproject.toml.
37+
38+
Starts from current working directory and walks up to filesystem root.
39+
Stops at .git directory boundary (repository root) if found.
40+
41+
Returns:
42+
Path to pyproject.toml, or None if not found.
43+
"""
44+
current = Path.cwd()
45+
46+
while True:
47+
pyproject = current / "pyproject.toml"
48+
if pyproject.exists():
49+
return pyproject
50+
51+
# Stop at .git boundary (repository root)
52+
if (current / ".git").exists():
53+
return None
54+
55+
# Stop at filesystem root
56+
if current == current.parent:
57+
return None
58+
59+
current = current.parent
60+
61+
62+
def parse_pyproject_config(pyproject_path: "Path") -> str | None:
63+
"""Parse pyproject.toml for [tool.sqlspec] config.
64+
65+
Args:
66+
pyproject_path: Path to pyproject.toml file.
67+
68+
Returns:
69+
Config path(s) as string (converts list to comma-separated), or None if not found.
70+
71+
Raises:
72+
ValueError: If [tool.sqlspec].config has invalid type (not str or list[str]).
73+
"""
74+
try:
75+
with pyproject_path.open("rb") as f:
76+
data = tomllib.load(f)
77+
except Exception as e:
78+
msg = f"Failed to parse {pyproject_path}: {e}"
79+
raise ValueError(msg) from e
80+
81+
# Navigate to [tool.sqlspec] section
82+
tool_section = data.get("tool", {})
83+
if not isinstance(tool_section, dict):
84+
return None
85+
86+
sqlspec_section = tool_section.get("sqlspec", {})
87+
if not isinstance(sqlspec_section, dict):
88+
return None
89+
90+
# Extract config value
91+
config = sqlspec_section.get("config")
92+
if config is None:
93+
return None
94+
95+
# Handle string config
96+
if isinstance(config, str):
97+
return config
98+
99+
# Handle list config (convert to comma-separated)
100+
if isinstance(config, list):
101+
if not all(isinstance(item, str) for item in config):
102+
msg = f"Invalid [tool.sqlspec].config in {pyproject_path}: list items must be strings"
103+
raise ValueError(msg)
104+
return ",".join(config)
105+
106+
# Invalid type
107+
msg = f"Invalid [tool.sqlspec].config in {pyproject_path}: must be string or list of strings, got {type(config).__name__}"
108+
raise ValueError(msg)

0 commit comments

Comments
 (0)