Skip to content

Commit

Permalink
add recursion handling
Browse files Browse the repository at this point in the history
  • Loading branch information
Jacob Beck committed Oct 22, 2018
1 parent bd40ff3 commit f344166
Show file tree
Hide file tree
Showing 4 changed files with 67 additions and 25 deletions.
29 changes: 22 additions & 7 deletions dbt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from dbt.contracts.connection import Connection, create_credentials
from dbt.contracts.project import Project as ProjectContract, Configuration, \
PackageConfig, ProfileConfig
from dbt.exceptions import DbtProjectError, DbtProfileError
from dbt.exceptions import DbtProjectError, DbtProfileError, RecursionException
from dbt.context.common import env_var, Var
from dbt import compat
from dbt.adapters.factory import get_relation_class_by_name
Expand Down Expand Up @@ -167,17 +167,26 @@ def _render_profile_data(self, value, keypath):
pass
return result

def render(self, as_parsed):
return dbt.utils.deep_map(self.render_value, as_parsed)

def render_project(self, as_parsed):
"""Render the parsed data, returning a new dict (or whatever was read).
"""
return dbt.utils.deep_map(self._render_project_entry, as_parsed)
try:
return dbt.utils.deep_map(self._render_project_entry, as_parsed)
except RecursionException:
raise DbtProjectError(
'Cycle detected: Project input has a reference to itself',
project=project_dict
)

def render_profile_data(self, as_parsed):
"""Render the chosen profile entry, as it was parsed."""
return dbt.utils.deep_map(self._render_profile_data, as_parsed)
try:
return dbt.utils.deep_map(self._render_profile_data, as_parsed)
except RecursionException:
raise DbtProfileError(
'Cycle detected: Profile input has a reference to itself',
project=as_parsed
)


class Project(object):
Expand Down Expand Up @@ -246,7 +255,13 @@ def from_project_config(cls, project_dict, packages_dict=None):
the packages file exists and is invalid.
:returns Project: The project, with defaults populated.
"""
project_dict = cls._preprocess(project_dict)
try:
project_dict = cls._preprocess(project_dict)
except RecursionException:
raise DbtProjectError(
'Cycle detected: Project input has a reference to itself',
project=project_dict
)
# just for validation.
try:
ProjectContract(**project_dict)
Expand Down
4 changes: 4 additions & 0 deletions dbt/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ def type(self):
return 'Compilation'


class RecursionException(RuntimeException):
pass


class ValidationException(RuntimeException):
pass

Expand Down
48 changes: 30 additions & 18 deletions dbt/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,45 +265,57 @@ def deep_merge_item(destination, key, value):
destination[key] = value


def deep_map(func, value, keypath=()):
"""map the function func() onto each non-container value in 'value'
recursively, returning a new value. As long as func does not manipulate
value, then deep_map will also not manipulate it.
value should be a value returned by `yaml.safe_load` or `json.load` - the
only expected types are list, dict, native python number, str, NoneType,
and bool.
func() will be called on numbers, strings, Nones, and booleans. Its first
parameter will be the value, and the second will be its keypath, an
iterable over the __getitem__ keys needed to get to it.
If there are cycles in the value, this will cause an infinite loop.
"""
def _deep_map(func, value, keypath):
atomic_types = (int, float, basestring, type(None), bool)

if isinstance(value, list):
ret = [
deep_map(func, v, (keypath + (idx,)))
_deep_map(func, v, (keypath + (idx,)))
for idx, v in enumerate(value)
]
elif isinstance(value, dict):
ret = {
k: deep_map(func, v, (keypath + (k,)))
k: _deep_map(func, v, (keypath + (k,)))
for k, v in value.items()
}
elif isinstance(value, atomic_types):
ret = func(value, keypath)
else:
ok_types = (list, dict) + atomic_types
raise dbt.exceptions.DbtConfigError(
'in deep_map, expected one of {!r}, got {!r}'
'in _deep_map, expected one of {!r}, got {!r}'
.format(ok_types, type(value))
)

return ret


def deep_map(func, value):
"""map the function func() onto each non-container value in 'value'
recursively, returning a new value. As long as func does not manipulate
value, then deep_map will also not manipulate it.
value should be a value returned by `yaml.safe_load` or `json.load` - the
only expected types are list, dict, native python number, str, NoneType,
and bool.
func() will be called on numbers, strings, Nones, and booleans. Its first
parameter will be the value, and the second will be its keypath, an
iterable over the __getitem__ keys needed to get to it.
:raises: If there are cycles in the value, raises a
dbt.exceptions.RecursionException
"""
try:
return _deep_map(func, value, ())
except RuntimeError as exc:
if 'maximum recursion depth exceeded' in str(exc):
raise dbt.exceptions.RecursionException(
'Cycle detected in deep_map'
)
raise


class AttrDict(dict):
def __init__(self, *args, **kwargs):
super(AttrDict, self).__init__(*args, **kwargs)
Expand Down
11 changes: 11 additions & 0 deletions test/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,17 @@ def test_nested_none_values(self):
self.assertEqual(project.models, {'vars': {}, 'pre-hook': [], 'post-hook': []})
self.assertEqual(project.seeds, {'vars': {}, 'pre-hook': [], 'post-hook': [], 'column_types': {}})

def test_cycle(self):
models = {}
models['models'] = models
self.default_project_data.update({
'models': models,
})
with self.assertRaises(dbt.exceptions.DbtProjectError):
dbt.config.Project.from_project_config(
self.default_project_data
)


class TestProjectWithConfigs(BaseConfigTest):
def setUp(self):
Expand Down

0 comments on commit f344166

Please sign in to comment.