From 6a79111047a838b2e7daee0861570ffe01c38d02 Mon Sep 17 00:00:00 2001 From: Saugat Pachhai Date: Wed, 14 Oct 2020 05:32:05 -0700 Subject: [PATCH] Parametrization support on dvc.yaml (#4679) * Custom parametrization support * log generated data * Add tests --- dvc/config.py | 2 + dvc/dvcfile.py | 8 +++ dvc/parsing/__init__.py | 27 ++++++++ dvc/parsing/context.py | 61 ++++++++++++++++++ dvc/parsing/interpolate.py | 61 ++++++++++++++++++ tests/func/test_feature_flags.py | 34 ++++++++++ tests/unit/test_interpolate.py | 101 ++++++++++++++++++++++++++++++ tests/unit/test_stage_resolver.py | 39 ++++++++++++ 8 files changed, 333 insertions(+) create mode 100644 dvc/parsing/__init__.py create mode 100644 dvc/parsing/context.py create mode 100644 dvc/parsing/interpolate.py create mode 100644 tests/func/test_feature_flags.py create mode 100644 tests/unit/test_interpolate.py create mode 100644 tests/unit/test_stage_resolver.py diff --git a/dvc/config.py b/dvc/config.py index 372a51d1f..090d065cd 100644 --- a/dvc/config.py +++ b/dvc/config.py @@ -224,6 +224,8 @@ SCHEMA = { "row_limit": All(Coerce(int), Range(1)), "row_cleanup_quota": All(Coerce(int), Range(0, 100)), }, + # section for experimental features + "feature": {Optional("parametrization", default=False): Bool}, } COMPILED_SCHEMA = Schema(SCHEMA) diff --git a/dvc/dvcfile.py b/dvc/dvcfile.py index 972287362..555cd71b6 100644 --- a/dvc/dvcfile.py +++ b/dvc/dvcfile.py @@ -3,9 +3,11 @@ import contextlib import logging import os +from funcy import log_durations from voluptuous import MultipleInvalid from dvc.exceptions import DvcException +from dvc.parsing import DataResolver from dvc.stage import serialize from dvc.stage.exceptions import ( StageFileBadNameError, @@ -226,6 +228,12 @@ class PipelineFile(FileMixin): @property def stages(self): data, _ = self._load() + + if self.repo.config["feature"]["parametrization"]: + with log_durations(logger.debug, "resolving values"): + resolver = DataResolver(data) + data = resolver.resolve() + lockfile_data = self._lockfile.load() return StageLoader(self, data.get("stages", {}), lockfile_data) diff --git a/dvc/parsing/__init__.py b/dvc/parsing/__init__.py new file mode 100644 index 000000000..b24345cfa --- /dev/null +++ b/dvc/parsing/__init__.py @@ -0,0 +1,27 @@ +import logging +from itertools import starmap + +from funcy import join + +from .context import Context +from .interpolate import resolve + +logger = logging.getLogger(__name__) + +STAGES = "stages" + + +class DataResolver: + def __init__(self, d): + self.context = Context() + self.data = d + + def _resolve_entry(self, name, definition): + stage_d = resolve(definition, self.context) + logger.trace("Resolved stage data for '%s': %s", name, stage_d) + return {name: stage_d} + + def resolve(self): + stages = self.data.get(STAGES, {}) + data = join(starmap(self._resolve_entry, stages.items())) + return {**self.data, STAGES: data} diff --git a/dvc/parsing/context.py b/dvc/parsing/context.py new file mode 100644 index 000000000..3fdbc2a78 --- /dev/null +++ b/dvc/parsing/context.py @@ -0,0 +1,61 @@ +from collections.abc import Collection, Mapping, Sequence + +# for testing purpose +# FIXME: after implementing of reading of "params". +TEST_DATA = { + "__test__": { + "dict": {"one": 1, "two": 2, "three": "three", "four": "4"}, + "list": [1, 2, 3, 4, 3.14], + "set": {1, 2, 3}, + "tuple": (1, 2), + "bool": True, + "none": None, + "float": 3.14, + "nomnom": 1000, + } +} + + +class Context: + def __init__(self, data=None): + self.data = data or TEST_DATA + + def select(self, key): + return _get_value(self.data, key) + + +def _get_item(data, idx): + if isinstance(data, Sequence): + idx = int(idx) + + if isinstance(data, (Mapping, Sequence)): + return data[idx] + + raise ValueError( + f"Cannot get item '{idx}' from data of type '{type(data).__name__}'" + ) + + +def _get_value(data, key): + obj_and_attrs = key.strip().split(".") + value = data + for attr in obj_and_attrs: + if attr == "": + raise ValueError("Syntax error!") + + try: + value = _get_item(value, attr) + except KeyError: + msg = ( + f"Could not find '{attr}' " + "while substituting " + f"'{key}'.\n" + f"Interpolating with: {data}" + ) + raise ValueError(msg) + + if not isinstance(value, str) and isinstance(value, Collection): + raise ValueError( + f"Cannot interpolate value of type '{type(value).__name__}'" + ) + return value diff --git a/dvc/parsing/interpolate.py b/dvc/parsing/interpolate.py new file mode 100644 index 000000000..56e72f31d --- /dev/null +++ b/dvc/parsing/interpolate.py @@ -0,0 +1,61 @@ +import re +from collections.abc import Mapping + +from funcy import rpartial + +KEYCRE = re.compile( + r""" + (?