Skip to content

Commit 2dd01a4

Browse files
authored
Feat(sqlmesh_dbt): Add support for --project-dir and --profiles-dir (#5524)
1 parent 0530520 commit 2dd01a4

File tree

7 files changed

+115
-7
lines changed

7 files changed

+115
-7
lines changed

sqlmesh/core/config/loader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def load_config_from_paths(
178178

179179
dbt_python_config = sqlmesh_config(
180180
project_root=dbt_project_file.parent,
181+
profiles_dir=kwargs.pop("profiles_dir", None),
181182
dbt_profile_name=kwargs.pop("profile", None),
182183
dbt_target_name=kwargs.pop("target", None),
183184
variables=variables,

sqlmesh/dbt/context.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class DbtContext:
3737
"""Context for DBT environment"""
3838

3939
project_root: Path = Path()
40+
profiles_dir: t.Optional[Path] = None
41+
"""Optional override to specify the directory where profiles.yml is located, if not at the :project_root"""
4042
target_name: t.Optional[str] = None
4143
profile_name: t.Optional[str] = None
4244
project_schema: t.Optional[str] = None

sqlmesh/dbt/loader.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,18 @@ def sqlmesh_config(
5353
threads: t.Optional[int] = None,
5454
register_comments: t.Optional[bool] = None,
5555
infer_state_schema_name: bool = False,
56+
profiles_dir: t.Optional[Path] = None,
5657
**kwargs: t.Any,
5758
) -> Config:
5859
project_root = project_root or Path()
59-
context = DbtContext(project_root=project_root, profile_name=dbt_profile_name)
60+
context = DbtContext(
61+
project_root=project_root, profiles_dir=profiles_dir, profile_name=dbt_profile_name
62+
)
63+
64+
# note: Profile.load() is called twice with different DbtContext's:
65+
# - once here with the above DbtContext (to determine connnection / gateway config which has to be set up before everything else)
66+
# - again on the SQLMesh side via GenericContext.load() -> DbtLoader._load_projects() -> Project.load() which constructs a fresh DbtContext and ignores the above one
67+
# it's important to ensure that the DbtContext created within the DbtLoader uses the same project root / profiles dir that we use here
6068
profile = Profile.load(context, target_name=dbt_target_name)
6169
model_defaults = kwargs.pop("model_defaults", ModelDefaultsConfig())
6270
if model_defaults.dialect is None:
@@ -98,6 +106,7 @@ def sqlmesh_config(
98106

99107
return Config(
100108
loader=loader,
109+
loader_kwargs=dict(profiles_dir=profiles_dir),
101110
model_defaults=model_defaults,
102111
variables=variables or {},
103112
dbt=RootDbtConfig(infer_state_schema_name=infer_state_schema_name),
@@ -116,9 +125,12 @@ def sqlmesh_config(
116125

117126

118127
class DbtLoader(Loader):
119-
def __init__(self, context: GenericContext, path: Path) -> None:
128+
def __init__(
129+
self, context: GenericContext, path: Path, profiles_dir: t.Optional[Path] = None
130+
) -> None:
120131
self._projects: t.List[Project] = []
121132
self._macros_max_mtime: t.Optional[float] = None
133+
self._profiles_dir = profiles_dir
122134
super().__init__(context, path)
123135

124136
def load(self) -> LoadedProject:
@@ -225,6 +237,7 @@ def _load_projects(self) -> t.List[Project]:
225237
project = Project.load(
226238
DbtContext(
227239
project_root=self.config_path,
240+
profiles_dir=self._profiles_dir,
228241
target_name=target_name,
229242
sqlmesh_config=self.config,
230243
),

sqlmesh/dbt/profile.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,16 @@ def load(cls, context: DbtContext, target_name: t.Optional[str] = None) -> Profi
6060
if not context.profile_name:
6161
raise ConfigError(f"{project_file.stem} must include project name.")
6262

63-
profile_filepath = cls._find_profile(context.project_root)
63+
profile_filepath = cls._find_profile(context.project_root, context.profiles_dir)
6464
if not profile_filepath:
6565
raise ConfigError(f"{cls.PROFILE_FILE} not found.")
6666

6767
target_name, target = cls._read_profile(profile_filepath, context, target_name)
6868
return Profile(profile_filepath, target_name, target)
6969

7070
@classmethod
71-
def _find_profile(cls, project_root: Path) -> t.Optional[Path]:
72-
dir = os.environ.get("DBT_PROFILES_DIR", "")
71+
def _find_profile(cls, project_root: Path, profiles_dir: t.Optional[Path]) -> t.Optional[Path]:
72+
dir = os.environ.get("DBT_PROFILES_DIR", profiles_dir or "")
7373
path = Path(project_root, dir, cls.PROFILE_FILE)
7474
if path.exists():
7575
return path

sqlmesh_dbt/cli.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ def _cleanup() -> None:
8484
type=click.Choice(["debug", "info", "warn", "error", "none"]),
8585
help="Specify the minimum severity of events that are logged to the console and the log file.",
8686
)
87+
@click.option(
88+
"--profiles-dir",
89+
type=click.Path(exists=True, file_okay=False, path_type=Path),
90+
help="Which directory to look in for the profiles.yml file. If not set, dbt will look in the current working directory first, then HOME/.dbt/",
91+
)
92+
@click.option(
93+
"--project-dir",
94+
type=click.Path(exists=True, file_okay=False, path_type=Path),
95+
help="Which directory to look in for the dbt_project.yml file. Default is the current working directory and its parents.",
96+
)
8797
@click.pass_context
8898
@cli_global_error_handler
8999
def dbt(
@@ -92,6 +102,8 @@ def dbt(
92102
target: t.Optional[str] = None,
93103
debug: bool = False,
94104
log_level: t.Optional[str] = None,
105+
profiles_dir: t.Optional[Path] = None,
106+
project_dir: t.Optional[Path] = None,
95107
) -> None:
96108
"""
97109
An ELT tool for managing your SQL transformations and data models, powered by the SQLMesh engine.
@@ -105,7 +117,8 @@ def dbt(
105117
# that need to be known before we attempt to load the project
106118
ctx.obj = functools.partial(
107119
create,
108-
project_dir=Path.cwd(),
120+
project_dir=project_dir,
121+
profiles_dir=profiles_dir,
109122
profile=profile,
110123
target=target,
111124
debug=debug,

sqlmesh_dbt/operations.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def close(self) -> None:
232232

233233
def create(
234234
project_dir: t.Optional[Path] = None,
235+
profiles_dir: t.Optional[Path] = None,
235236
profile: t.Optional[str] = None,
236237
target: t.Optional[str] = None,
237238
vars: t.Optional[t.Dict[str, t.Any]] = None,
@@ -268,7 +269,11 @@ def create(
268269
sqlmesh_context = Context(
269270
paths=[project_dir],
270271
config_loader_kwargs=dict(
271-
profile=profile, target=target, variables=vars, threads=threads
272+
profile=profile,
273+
target=target,
274+
variables=vars,
275+
threads=threads,
276+
profiles_dir=profiles_dir,
272277
),
273278
load=True,
274279
# DbtSelector selects based on dbt model fqn's rather than SQLMesh model names

tests/dbt/cli/test_global_flags.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,77 @@ def test_log_level(invoke_cli: t.Callable[..., Result], create_empty_project: Em
107107
result = invoke_cli(["--log-level", "debug", "list"])
108108
assert result.exit_code == 0
109109
assert logging.getLogger("sqlmesh").getEffectiveLevel() == logging.DEBUG
110+
111+
112+
def test_profiles_dir(
113+
invoke_cli: t.Callable[..., Result], create_empty_project: EmptyProjectCreator, tmp_path: Path
114+
):
115+
project_dir, _ = create_empty_project(project_name="test_profiles_dir")
116+
117+
orig_profiles_yml = project_dir / "profiles.yml"
118+
assert orig_profiles_yml.exists()
119+
120+
new_profiles_yml = tmp_path / "some_other_place" / "profiles.yml"
121+
new_profiles_yml.parent.mkdir(parents=True)
122+
123+
orig_profiles_yml.rename(new_profiles_yml)
124+
assert not orig_profiles_yml.exists()
125+
assert new_profiles_yml.exists()
126+
127+
# should fail if we don't specify --profiles-dir
128+
result = invoke_cli(["list"])
129+
assert result.exit_code > 0, result.output
130+
assert "profiles.yml not found" in result.output
131+
132+
# should pass if we specify --profiles-dir
133+
result = invoke_cli(["--profiles-dir", str(new_profiles_yml.parent), "list"])
134+
assert result.exit_code == 0, result.output
135+
assert "Models in project" in result.output
136+
137+
138+
def test_project_dir(
139+
invoke_cli: t.Callable[..., Result], create_empty_project: EmptyProjectCreator
140+
):
141+
orig_project_dir, _ = create_empty_project(project_name="test_project_dir")
142+
143+
orig_project_yml = orig_project_dir / "dbt_project.yml"
144+
assert orig_project_yml.exists()
145+
146+
new_project_yml = orig_project_dir / "nested" / "dbt_project.yml"
147+
new_project_yml.parent.mkdir(parents=True)
148+
149+
orig_project_yml.rename(new_project_yml)
150+
assert not orig_project_yml.exists()
151+
assert new_project_yml.exists()
152+
153+
# should fail if we don't specify --project-dir
154+
result = invoke_cli(["list"])
155+
assert result.exit_code != 0, result.output
156+
assert "Error:" in result.output
157+
158+
# should fail if the profiles.yml also doesnt exist at that --project-dir
159+
result = invoke_cli(["--project-dir", str(new_project_yml.parent), "list"])
160+
assert result.exit_code != 0, result.output
161+
assert "profiles.yml not found" in result.output
162+
163+
# should pass if it can find both files, either because we specified --profiles-dir explicitly or the profiles.yml was found in --project-dir
164+
result = invoke_cli(
165+
[
166+
"--project-dir",
167+
str(new_project_yml.parent),
168+
"--profiles-dir",
169+
str(orig_project_dir),
170+
"list",
171+
]
172+
)
173+
assert result.exit_code == 0, result.output
174+
assert "Models in project" in result.output
175+
176+
orig_profiles_yml = orig_project_dir / "profiles.yml"
177+
new_profiles_yml = new_project_yml.parent / "profiles.yml"
178+
assert orig_profiles_yml.exists()
179+
orig_profiles_yml.rename(new_profiles_yml)
180+
181+
result = invoke_cli(["--project-dir", str(new_project_yml.parent), "list"])
182+
assert result.exit_code == 0, result.output
183+
assert "Models in project" in result.output

0 commit comments

Comments
 (0)