Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2f72c0e

Browse files
authoredOct 8, 2018
Merge pull request #1033 from fishtown-analytics/feature/render-more-config-fields
Render more config fields (#960)
2 parents 389c4af + 45ddd3d commit 2f72c0e

File tree

9 files changed

+517
-138
lines changed

9 files changed

+517
-138
lines changed
 

‎dbt/config.py

Lines changed: 153 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from dbt.contracts.project import Project as ProjectContract, Configuration, \
1212
PackageConfig, ProfileConfig
1313
from dbt.exceptions import DbtProjectError, DbtProfileError
14-
from dbt.context.common import env_var
14+
from dbt.context.common import env_var, Var
1515
from dbt import compat
1616
from dbt.adapters.factory import get_relation_class_by_name
1717

@@ -90,27 +90,77 @@ def colorize_output(config):
9090
return config.get('use_colors', True)
9191

9292

93-
def _render(key, value, ctx):
94-
"""Render an entry in the credentials dictionary, in case it's jinja.
93+
class ConfigRenderer(object):
94+
"""A renderer provides configuration rendering for a given set of cli
95+
variables and a render type.
96+
"""
97+
def __init__(self, cli_vars):
98+
self.context = {'env_var': env_var}
99+
self.context['var'] = Var(None, self.context, cli_vars)
95100

96-
If the parsed entry is a string and has the name 'port', this will attempt
97-
to cast it to an int, and on failure will return the parsed string.
101+
@staticmethod
102+
def _is_hook_path(keypath):
103+
if not keypath:
104+
return False
98105

99-
:param key str: The key to convert on.
100-
:param value Any: The value to potentially render
101-
:param ctx dict: The context dictionary, mapping function names to
102-
functions that take a str and return a value
103-
:return Any: The rendered entry.
104-
"""
105-
if not isinstance(value, compat.basestring):
106-
return value
107-
result = dbt.clients.jinja.get_rendered(value, ctx)
108-
if key == 'port':
109-
try:
110-
return int(result)
111-
except ValueError:
112-
pass # let the validator or connection handle this
113-
return result
106+
first = keypath[0]
107+
# run hooks
108+
if first in {'on-run-start', 'on-run-end'}:
109+
return True
110+
# model hooks
111+
if first in {'seeds', 'models'}:
112+
if 'pre-hook' in keypath or 'post-hook' in keypath:
113+
return True
114+
115+
return False
116+
117+
def _render_project_entry(self, value, keypath):
118+
"""Render an entry, in case it's jinja. This is meant to be passed to
119+
dbt.utils.deep_map.
120+
121+
If the parsed entry is a string and has the name 'port', this will
122+
attempt to cast it to an int, and on failure will return the parsed
123+
string.
124+
125+
:param value Any: The value to potentially render
126+
:param key str: The key to convert on.
127+
:return Any: The rendered entry.
128+
"""
129+
# hooks should be treated as raw sql, they'll get rendered later
130+
if self._is_hook_path(keypath):
131+
return value
132+
133+
return self.render_value(value)
134+
135+
def render_value(self, value, keypath=None):
136+
# keypath is ignored.
137+
# if it wasn't read as a string, ignore it
138+
if not isinstance(value, compat.basestring):
139+
return value
140+
141+
return dbt.clients.jinja.get_rendered(value, self.context)
142+
143+
def _render_profile_data(self, value, keypath):
144+
result = self.render_value(value)
145+
if len(keypath) == 1 and keypath[-1] == 'port':
146+
try:
147+
result = int(result)
148+
except ValueError:
149+
# let the validator or connection handle this
150+
pass
151+
return result
152+
153+
def render(self, as_parsed):
154+
return dbt.utils.deep_map(self.render_value, as_parsed)
155+
156+
def render_project(self, as_parsed):
157+
"""Render the parsed data, returning a new dict (or whatever was read).
158+
"""
159+
return dbt.utils.deep_map(self._render_project_entry, as_parsed)
160+
161+
def render_profile_data(self, as_parsed):
162+
"""Render the chosen profile entry, as it was parsed."""
163+
return dbt.utils.deep_map(self._render_profile_data, as_parsed)
114164

115165

116166
class Project(object):
@@ -269,7 +319,7 @@ def validate(self):
269319
raise DbtProjectError(str(exc))
270320

271321
@classmethod
272-
def from_project_root(cls, project_root):
322+
def from_project_root(cls, project_root, cli_vars):
273323
"""Create a project from a root directory. Reads in dbt_project.yml and
274324
packages.yml, if it exists.
275325
@@ -288,14 +338,19 @@ def from_project_root(cls, project_root):
288338
.format(project_yaml_filepath)
289339
)
290340

341+
if isinstance(cli_vars, compat.basestring):
342+
cli_vars = dbt.utils.parse_cli_vars(cli_vars)
343+
renderer = ConfigRenderer(cli_vars)
344+
291345
project_dict = _load_yaml(project_yaml_filepath)
292-
project_dict['project-root'] = project_root
346+
rendered_project = renderer.render_project(project_dict)
347+
rendered_project['project-root'] = project_root
293348
packages_dict = package_data_from_root(project_root)
294-
return cls.from_project_config(project_dict, packages_dict)
349+
return cls.from_project_config(rendered_project, packages_dict)
295350

296351
@classmethod
297-
def from_current_directory(cls):
298-
return cls.from_project_root(os.getcwd())
352+
def from_current_directory(cls, cli_vars):
353+
return cls.from_project_root(os.getcwd(), cli_vars)
299354

300355
def hashed_name(self):
301356
return hashlib.md5(self.project_name.encode('utf-8')).hexdigest()
@@ -348,16 +403,6 @@ def validate(self):
348403
except dbt.exceptions.ValidationException as exc:
349404
raise DbtProfileError(str(exc))
350405

351-
@staticmethod
352-
def _rendered_profile(profile):
353-
# if entries are strings, we want to render them so we can get any
354-
# environment variables that might store important credentials
355-
# elements.
356-
return {
357-
k: _render(k, v, {'env_var': env_var})
358-
for k, v in profile.items()
359-
}
360-
361406
@staticmethod
362407
def _credentials_from_profile(profile, profile_name, target_name):
363408
# credentials carry their 'type' in their actual type, not their
@@ -387,25 +432,12 @@ def _pick_profile_name(args_profile_name, project_profile_name=None):
387432
return profile_name
388433

389434
@staticmethod
390-
def _pick_target(raw_profile, profile_name, target_override=None):
391-
392-
if target_override is not None:
393-
target_name = target_override
394-
elif 'target' in raw_profile:
395-
target_name = raw_profile['target']
396-
else:
397-
raise DbtProfileError(
398-
"target not specified in profile '{}'".format(profile_name)
399-
)
400-
return target_name
401-
402-
@staticmethod
403-
def _get_profile_data(raw_profile, profile_name, target_name):
404-
if 'outputs' not in raw_profile:
435+
def _get_profile_data(profile, profile_name, target_name):
436+
if 'outputs' not in profile:
405437
raise DbtProfileError(
406438
"outputs not specified in profile '{}'".format(profile_name)
407439
)
408-
outputs = raw_profile['outputs']
440+
outputs = profile['outputs']
409441

410442
if target_name not in outputs:
411443
outputs = '\n'.join(' - {}'.format(output)
@@ -454,15 +486,50 @@ def from_credentials(cls, credentials, threads, profile_name, target_name,
454486
return profile
455487

456488
@classmethod
457-
def from_raw_profile_info(cls, raw_profile, profile_name, user_cfg=None,
458-
target_override=None, threads_override=None):
489+
def _render_profile(cls, raw_profile, profile_name, target_override,
490+
cli_vars):
491+
"""This is a containment zone for the hateful way we're rendering
492+
profiles.
493+
"""
494+
renderer = ConfigRenderer(cli_vars=cli_vars)
495+
496+
# rendering profiles is a bit complex. Two constraints cause trouble:
497+
# 1) users should be able to use environment/cli variables to specify
498+
# the target in their profile.
499+
# 2) Missing environment/cli variables in profiles/targets that don't
500+
# end up getting selected should not cause errors.
501+
# so first we'll just render the target name, then we use that rendered
502+
# name to extract a profile that we can render.
503+
if target_override is not None:
504+
target_name = target_override
505+
elif 'target' in raw_profile:
506+
# render the target if it was parsed from yaml
507+
target_name = renderer.render_value(raw_profile['target'])
508+
else:
509+
raise DbtProfileError(
510+
"target not specified in profile '{}'".format(profile_name)
511+
)
512+
513+
raw_profile_data = cls._get_profile_data(
514+
raw_profile, profile_name, target_name
515+
)
516+
517+
profile_data = renderer.render_profile_data(raw_profile_data)
518+
return target_name, profile_data
519+
520+
@classmethod
521+
def from_raw_profile_info(cls, raw_profile, profile_name, cli_vars,
522+
user_cfg=None, target_override=None,
523+
threads_override=None):
459524
"""Create a profile from its raw profile information.
460525
461526
(this is an intermediate step, mostly useful for unit testing)
462527
463-
:param raw_profiles dict: The profile data for a single profile, from
464-
disk as yaml.
528+
:param raw_profile dict: The profile data for a single profile, from
529+
disk as yaml and its values rendered with jinja.
465530
:param profile_name str: The profile name used.
531+
:param cli_vars dict: The command-line variables passed as arguments,
532+
as a dict.
466533
:param user_cfg Optional[dict]: The global config for the user, if it
467534
was present.
468535
:param target_override Optional[str]: The target to use, if provided on
@@ -473,22 +540,20 @@ def from_raw_profile_info(cls, raw_profile, profile_name, user_cfg=None,
473540
target could not be found
474541
:returns Profile: The new Profile object.
475542
"""
476-
target_name = cls._pick_target(
477-
raw_profile, profile_name, target_override
543+
# user_cfg is not rendered since it only contains booleans.
544+
# TODO: should it be, and the values coerced to bool?
545+
target_name, profile_data = cls._render_profile(
546+
raw_profile, profile_name, target_override, cli_vars
478547
)
479-
profile_data = cls._get_profile_data(
480-
raw_profile, profile_name, target_name
481-
)
482-
rendered_profile = cls._rendered_profile(profile_data)
483548

484549
# valid connections never include the number of threads, but it's
485550
# stored on a per-connection level in the raw configs
486-
threads = rendered_profile.pop('threads', DEFAULT_THREADS)
551+
threads = profile_data.pop('threads', DEFAULT_THREADS)
487552
if threads_override is not None:
488553
threads = threads_override
489554

490555
credentials = cls._credentials_from_profile(
491-
rendered_profile, profile_name, target_name
556+
profile_data, profile_name, target_name
492557
)
493558
return cls.from_credentials(
494559
credentials=credentials,
@@ -499,11 +564,13 @@ def from_raw_profile_info(cls, raw_profile, profile_name, user_cfg=None,
499564
)
500565

501566
@classmethod
502-
def from_raw_profiles(cls, raw_profiles, profile_name,
567+
def from_raw_profiles(cls, raw_profiles, profile_name, cli_vars,
503568
target_override=None, threads_override=None):
504569
"""
505570
:param raw_profiles dict: The profile data, from disk as yaml.
506571
:param profile_name str: The profile name to use.
572+
:param cli_vars dict: The command-line variables passed as arguments,
573+
as a dict.
507574
:param target_override Optional[str]: The target to use, if provided on
508575
the command line.
509576
:param threads_override Optional[str]: The thread count to use, if
@@ -514,29 +581,35 @@ def from_raw_profiles(cls, raw_profiles, profile_name,
514581
target could not be found
515582
:returns Profile: The new Profile object.
516583
"""
517-
# TODO(jeb): Validate the raw_profiles structure right here
518584
if profile_name not in raw_profiles:
519585
raise DbtProjectError(
520586
"Could not find profile named '{}'".format(profile_name)
521587
)
588+
589+
# First, we've already got our final decision on profile name, and we
590+
# don't render keys, so we can pluck that out
522591
raw_profile = raw_profiles[profile_name]
592+
523593
user_cfg = raw_profiles.get('config')
524594

525595
return cls.from_raw_profile_info(
526596
raw_profile=raw_profile,
527597
profile_name=profile_name,
598+
cli_vars=cli_vars,
528599
user_cfg=user_cfg,
529600
target_override=target_override,
530601
threads_override=threads_override,
531602
)
532603

533604
@classmethod
534-
def from_args(cls, args, project_profile_name=None):
605+
def from_args(cls, args, project_profile_name=None, cli_vars=None):
535606
"""Given the raw profiles as read from disk and the name of the desired
536607
profile if specified, return the profile component of the runtime
537608
config.
538609
539610
:param args argparse.Namespace: The arguments as parsed from the cli.
611+
:param cli_vars dict: The command-line variables passed as arguments,
612+
as a dict.
540613
:param project_profile_name Optional[str]: The profile name, if
541614
specified in a project.
542615
:raises DbtProjectError: If there is no profile name specified in the
@@ -546,6 +619,9 @@ def from_args(cls, args, project_profile_name=None):
546619
target could not be found.
547620
:returns Profile: The new Profile object.
548621
"""
622+
if cli_vars is None:
623+
cli_vars = dbt.utils.parse_cli_vars(getattr(args, 'vars', '{}'))
624+
549625
threads_override = getattr(args, 'threads', None)
550626
# TODO(jeb): is it even possible for this to not be set?
551627
profiles_dir = getattr(args, 'profiles_dir', DEFAULT_PROFILES_DIR)
@@ -557,6 +633,7 @@ def from_args(cls, args, project_profile_name=None):
557633
return cls.from_raw_profiles(
558634
raw_profiles=raw_profiles,
559635
profile_name=profile_name,
636+
cli_vars=cli_vars,
560637
target_override=target_override,
561638
threads_override=threads_override
562639
)
@@ -697,8 +774,8 @@ def new_project(self, project_root):
697774
# copy profile
698775
profile = Profile(**self.to_profile_info())
699776
profile.validate()
700-
# load the new project and its packages
701-
project = Project.from_project_root(project_root)
777+
# load the new project and its packages. Don't pass cli variables.
778+
project = Project.from_project_root(project_root, {})
702779

703780
cfg = self.from_parts(
704781
project=project,
@@ -745,13 +822,18 @@ def from_args(cls, args):
745822
:raises DbtProfileError: If the profile is invalid or missing.
746823
:raises ValidationException: If the cli variables are invalid.
747824
"""
825+
cli_vars = dbt.utils.parse_cli_vars(getattr(args, 'vars', '{}'))
826+
748827
# build the project and read in packages.yml
749-
project = Project.from_current_directory()
828+
project = Project.from_current_directory(cli_vars)
750829

751830
# build the profile
752-
profile = Profile.from_args(args, project.profile_name)
831+
profile = Profile.from_args(
832+
args=args,
833+
project_profile_name=project.profile_name,
834+
cli_vars=cli_vars
835+
)
753836

754-
cli_vars = dbt.utils.parse_cli_vars(getattr(args, 'vars', '{}'))
755837
return cls.from_parts(
756838
project=project,
757839
profile=profile,

‎dbt/context/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ def __init__(self, model, context, overrides):
229229
elif isinstance(model, ParsedNode):
230230
local_vars = model.config.get('vars', {})
231231
self.model_name = model.name
232+
elif model is None:
233+
# during config parsing we have no model and no local vars
234+
self.model_name = '<Configuration>'
235+
local_vars = {}
232236
else:
233237
# still used for wrapping
234238
self.model_name = model.nice_name

‎dbt/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def invoke_dbt(parsed):
212212
try:
213213
if parsed.which in {'deps', 'clean'}:
214214
# deps doesn't need a profile, so don't require one.
215-
cfg = Project.from_current_directory()
215+
cfg = Project.from_current_directory(getattr(parsed, 'vars', '{}'))
216216
elif parsed.which != 'debug':
217217
# for debug, we will attempt to load the various configurations as
218218
# part of the task, so just leave cfg=None.

0 commit comments

Comments
 (0)