Skip to content

Commit eb8a5b7

Browse files
Feat: Add support for dot env file to load variables from (#4840)
1 parent f43a4c3 commit eb8a5b7

File tree

6 files changed

+232
-3
lines changed

6 files changed

+232
-3
lines changed

docs/guides/configuration.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,52 @@ All software runs within a system environment that stores information as "enviro
9898

9999
SQLMesh can access environment variables during configuration, which enables approaches like storing passwords/secrets outside the configuration file and changing configuration parameters dynamically based on which user is running SQLMesh.
100100

101-
You can use environment variables in two ways: specifying them in the configuration file or creating properly named variables to override configuration file values.
101+
You can specify environment variables in the configuration file or by storing them in a `.env` file.
102+
103+
### .env files
104+
105+
SQLMesh automatically loads environment variables from a `.env` file in your project directory. This provides a convenient way to manage environment variables without having to set them in your shell.
106+
107+
Create a `.env` file in your project root with key-value pairs:
108+
109+
```bash
110+
# .env file
111+
SNOWFLAKE_PW=my_secret_password
112+
S3_BUCKET=s3://my-data-bucket/warehouse
113+
DATABASE_URL=postgresql://user:pass@localhost/db
114+
115+
# Override specific SQLMesh configuration values
116+
SQLMESH__DEFAULT_GATEWAY=production
117+
SQLMESH__MODEL_DEFAULTS__DIALECT=snowflake
118+
```
119+
120+
See the [overrides](#overrides) section for a detailed explanation of how these are defined.
121+
122+
The rest of the `.env` file variables can be used in your configuration files with `{{ env_var('VARIABLE_NAME') }}` syntax in YAML or accessed via `os.environ['VARIABLE_NAME']` in Python.
123+
124+
#### Custom dot env file location and name
125+
126+
By default, SQLMesh loads `.env` files from each project directory. However, you can specify a custom path using the `--dotenv` CLI flag directly when running a command:
127+
128+
```bash
129+
sqlmesh --dotenv /path/to/custom/.env plan
130+
```
131+
132+
!!! note
133+
The `--dotenv` flag is a global option and must be placed **before** the subcommand (e.g. `plan`, `run`), not after.
134+
135+
Alternatively, you can export the `SQLMESH_DOTENV_PATH` environment variable once, to persist a custom path across all subsequent commands in your shell session:
136+
137+
```bash
138+
export SQLMESH_DOTENV_PATH=/path/to/custom/.custom_env
139+
sqlmesh plan
140+
sqlmesh run
141+
```
142+
143+
**Important considerations:**
144+
- Add `.env` to your `.gitignore` file to avoid committing sensitive information
145+
- SQLMesh will only load the `.env` file if it exists in the project directory (unless a custom path is specified)
146+
- When using a custom path, that specific file takes precedence over any `.env` file in the project directory.
102147

103148
### Configuration file
104149

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ dev = [
7979
"PyAthena[Pandas]",
8080
"PyGithub>=2.6.0",
8181
"pyperf",
82+
"python-dotenv",
8283
"pyspark~=3.5.0",
8384
"pytest",
8485
"pytest-asyncio",

sqlmesh/cli/main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ def _sqlmesh_version() -> str:
8484
type=str,
8585
help="The directory to write log files to.",
8686
)
87+
@click.option(
88+
"--dotenv",
89+
type=click.Path(exists=True, path_type=Path),
90+
help="Path to a custom .env file to load environment variables.",
91+
envvar="SQLMESH_DOTENV_PATH",
92+
)
8793
@click.pass_context
8894
@error_handler
8995
def cli(
@@ -95,6 +101,7 @@ def cli(
95101
debug: bool = False,
96102
log_to_stdout: bool = False,
97103
log_file_dir: t.Optional[str] = None,
104+
dotenv: t.Optional[Path] = None,
98105
) -> None:
99106
"""SQLMesh command line tool."""
100107
if "--help" in sys.argv:
@@ -118,7 +125,7 @@ def cli(
118125
)
119126
configure_console(ignore_warnings=ignore_warnings)
120127

121-
configs = load_configs(config, Context.CONFIG_TYPE, paths)
128+
configs = load_configs(config, Context.CONFIG_TYPE, paths, dotenv_path=dotenv)
122129
log_limit = list(configs.values())[0].log_limit
123130

124131
remove_excess_logs(log_file_dir, log_limit)

sqlmesh/core/config/loader.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77

88
from pydantic import ValidationError
9+
from dotenv import load_dotenv
910
from sqlglot.helper import ensure_list
1011

1112
from sqlmesh.core import constants as c
@@ -25,6 +26,7 @@ def load_configs(
2526
config_type: t.Type[C],
2627
paths: t.Union[str | Path, t.Iterable[str | Path]],
2728
sqlmesh_path: t.Optional[Path] = None,
29+
dotenv_path: t.Optional[Path] = None,
2830
) -> t.Dict[Path, C]:
2931
sqlmesh_path = sqlmesh_path or c.SQLMESH_PATH
3032
config = config or "config"
@@ -35,6 +37,12 @@ def load_configs(
3537
for p in (glob.glob(str(path)) or [str(path)])
3638
]
3739

40+
if dotenv_path:
41+
load_dotenv(dotenv_path=dotenv_path, override=True)
42+
else:
43+
for path in absolute_paths:
44+
load_dotenv(dotenv_path=path / ".env", override=True)
45+
3846
if not isinstance(config, str):
3947
if type(config) != config_type:
4048
config = convert_config_type(config, config_type)

sqlmesh/magics.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from argparse import Namespace, SUPPRESS
99
from collections import defaultdict
1010
from copy import deepcopy
11+
from pathlib import Path
1112

1213
from hyperscript import h
1314

@@ -166,6 +167,9 @@ def _shell(self) -> t.Any:
166167
@argument("--ignore-warnings", action="store_true", help="Ignore warnings.")
167168
@argument("--debug", action="store_true", help="Enable debug mode.")
168169
@argument("--log-file-dir", type=str, help="The directory to write the log file to.")
170+
@argument(
171+
"--dotenv", type=str, help="Path to a custom .env file to load environment variables from."
172+
)
169173
@line_magic
170174
def context(self, line: str) -> None:
171175
"""Sets the context in the user namespace."""
@@ -181,7 +185,10 @@ def context(self, line: str) -> None:
181185
)
182186
configure_console(ignore_warnings=args.ignore_warnings)
183187

184-
configs = load_configs(args.config, Context.CONFIG_TYPE, args.paths)
188+
dotenv_path = Path(args.dotenv) if args.dotenv else None
189+
configs = load_configs(
190+
args.config, Context.CONFIG_TYPE, args.paths, dotenv_path=dotenv_path
191+
)
185192
log_limit = list(configs.values())[0].log_limit
186193

187194
remove_excess_logs(log_file_dir, log_limit)

tests/core/test_config.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
load_config_from_env,
2525
load_config_from_paths,
2626
load_config_from_python_module,
27+
load_configs,
2728
)
2829
from sqlmesh.core.context import Context
2930
from sqlmesh.core.engine_adapter.athena import AthenaEngineAdapter
@@ -1132,3 +1133,163 @@ def test_environment_suffix_target_catalog(tmp_path: Path) -> None:
11321133
Config,
11331134
project_paths=[config_path],
11341135
)
1136+
1137+
1138+
def test_load_python_config_dot_env_vars(tmp_path_factory):
1139+
main_dir = tmp_path_factory.mktemp("python_config")
1140+
config_path = main_dir / "config.py"
1141+
with open(config_path, "w", encoding="utf-8") as fd:
1142+
fd.write(
1143+
"""from sqlmesh.core.config import Config, DuckDBConnectionConfig, GatewayConfig, ModelDefaultsConfig
1144+
config = Config(gateways={"duckdb_gateway": GatewayConfig(connection=DuckDBConnectionConfig())}, model_defaults=ModelDefaultsConfig(dialect=''))
1145+
"""
1146+
)
1147+
1148+
# The environment variable value from the dot env file should be set
1149+
# SQLMESH__ variables override config fields directly if they follow the naming structure
1150+
dot_path = main_dir / ".env"
1151+
with open(dot_path, "w", encoding="utf-8") as fd:
1152+
fd.write(
1153+
"""SQLMESH__GATEWAYS__DUCKDB_GATEWAY__STATE_CONNECTION__TYPE="bigquery"
1154+
SQLMESH__GATEWAYS__DUCKDB_GATEWAY__STATE_CONNECTION__CHECK_IMPORT="false"
1155+
SQLMESH__DEFAULT_GATEWAY="duckdb_gateway"
1156+
"""
1157+
)
1158+
1159+
# Use mock.patch.dict to isolate environment variables between the tests
1160+
with mock.patch.dict(os.environ, {}, clear=True):
1161+
configs = load_configs(
1162+
"config",
1163+
Config,
1164+
paths=[main_dir],
1165+
)
1166+
1167+
assert next(iter(configs.values())) == Config(
1168+
gateways={
1169+
"duckdb_gateway": GatewayConfig(
1170+
connection=DuckDBConnectionConfig(),
1171+
state_connection=BigQueryConnectionConfig(check_import=False),
1172+
),
1173+
},
1174+
model_defaults=ModelDefaultsConfig(dialect=""),
1175+
default_gateway="duckdb_gateway",
1176+
)
1177+
1178+
1179+
def test_load_yaml_config_dot_env_vars(tmp_path_factory):
1180+
main_dir = tmp_path_factory.mktemp("yaml_config")
1181+
config_path = main_dir / "config.yaml"
1182+
with open(config_path, "w", encoding="utf-8") as fd:
1183+
fd.write(
1184+
"""gateways:
1185+
duckdb_gateway:
1186+
connection:
1187+
type: duckdb
1188+
catalogs:
1189+
local: local.db
1190+
cloud_sales: {{ env_var('S3_BUCKET') }}
1191+
extensions:
1192+
- name: httpfs
1193+
secrets:
1194+
- type: "s3"
1195+
key_id: {{ env_var('S3_KEY') }}
1196+
secret: {{ env_var('S3_SECRET') }}
1197+
model_defaults:
1198+
dialect: ""
1199+
"""
1200+
)
1201+
1202+
# This test checks both using SQLMESH__ prefixed environment variables with underscores
1203+
# and setting a regular environment variable for use with env_var().
1204+
dot_path = main_dir / ".env"
1205+
with open(dot_path, "w", encoding="utf-8") as fd:
1206+
fd.write(
1207+
"""S3_BUCKET="s3://metrics_bucket/sales.db"
1208+
S3_KEY="S3_KEY_ID"
1209+
S3_SECRET="XXX_S3_SECRET_XXX"
1210+
SQLMESH__DEFAULT_GATEWAY="duckdb_gateway"
1211+
SQLMESH__MODEL_DEFAULTS__DIALECT="athena"
1212+
"""
1213+
)
1214+
1215+
# Use mock.patch.dict to isolate environment variables between the tests
1216+
with mock.patch.dict(os.environ, {}, clear=True):
1217+
configs = load_configs(
1218+
"config",
1219+
Config,
1220+
paths=[main_dir],
1221+
)
1222+
1223+
assert next(iter(configs.values())) == Config(
1224+
gateways={
1225+
"duckdb_gateway": GatewayConfig(
1226+
connection=DuckDBConnectionConfig(
1227+
catalogs={
1228+
"local": "local.db",
1229+
"cloud_sales": "s3://metrics_bucket/sales.db",
1230+
},
1231+
extensions=[{"name": "httpfs"}],
1232+
secrets=[{"type": "s3", "key_id": "S3_KEY_ID", "secret": "XXX_S3_SECRET_XXX"}],
1233+
),
1234+
),
1235+
},
1236+
default_gateway="duckdb_gateway",
1237+
model_defaults=ModelDefaultsConfig(dialect="athena"),
1238+
)
1239+
1240+
1241+
def test_load_yaml_config_custom_dotenv_path(tmp_path_factory):
1242+
main_dir = tmp_path_factory.mktemp("yaml_config_2")
1243+
config_path = main_dir / "config.yaml"
1244+
with open(config_path, "w", encoding="utf-8") as fd:
1245+
fd.write(
1246+
"""gateways:
1247+
test_gateway:
1248+
connection:
1249+
type: duckdb
1250+
database: {{ env_var('DB_NAME') }}
1251+
"""
1252+
)
1253+
1254+
# Create a custom dot env file in a different location
1255+
custom_env_dir = tmp_path_factory.mktemp("custom_env")
1256+
custom_env_path = custom_env_dir / ".my_env"
1257+
with open(custom_env_path, "w", encoding="utf-8") as fd:
1258+
fd.write(
1259+
"""DB_NAME="custom_database.db"
1260+
SQLMESH__DEFAULT_GATEWAY="test_gateway"
1261+
SQLMESH__MODEL_DEFAULTS__DIALECT="postgres"
1262+
"""
1263+
)
1264+
1265+
# Test that without custom dotenv path, env vars are not loaded
1266+
with mock.patch.dict(os.environ, {}, clear=True):
1267+
with pytest.raises(
1268+
ConfigError, match=r"Default model SQL dialect is a required configuratio*"
1269+
):
1270+
load_configs(
1271+
"config",
1272+
Config,
1273+
paths=[main_dir],
1274+
)
1275+
1276+
# Test that with custom dotenv path, env vars are loaded correctly
1277+
with mock.patch.dict(os.environ, {}, clear=True):
1278+
configs = load_configs(
1279+
"config",
1280+
Config,
1281+
paths=[main_dir],
1282+
dotenv_path=custom_env_path,
1283+
)
1284+
1285+
assert next(iter(configs.values())) == Config(
1286+
gateways={
1287+
"test_gateway": GatewayConfig(
1288+
connection=DuckDBConnectionConfig(
1289+
database="custom_database.db",
1290+
),
1291+
),
1292+
},
1293+
default_gateway="test_gateway",
1294+
model_defaults=ModelDefaultsConfig(dialect="postgres"),
1295+
)

0 commit comments

Comments
 (0)