From f5f3d77bbe7f9e529c86733bc0bc4b1ea0c7a47f Mon Sep 17 00:00:00 2001 From: Paul Yang Date: Thu, 1 Aug 2024 16:34:12 -0700 Subject: [PATCH] Fill out implementations. --- .../parsing/text_input/rendering_helper.py | 224 ++++++++++++++++++ .../parsing/text_input/ti_exceptions.py | 38 +++ .../parsing/text_input/ti_processor.py | 100 +++++++- .../parsing/text_input/valid_method.py | 31 +++ 4 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 dbt_semantic_interfaces/parsing/text_input/rendering_helper.py create mode 100644 dbt_semantic_interfaces/parsing/text_input/ti_exceptions.py create mode 100644 dbt_semantic_interfaces/parsing/text_input/valid_method.py diff --git a/dbt_semantic_interfaces/parsing/text_input/rendering_helper.py b/dbt_semantic_interfaces/parsing/text_input/rendering_helper.py new file mode 100644 index 00000000..4d1a6ac6 --- /dev/null +++ b/dbt_semantic_interfaces/parsing/text_input/rendering_helper.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import typing +from typing import Callable, FrozenSet, Optional, Sequence + +from typing_extensions import override + +from dbt_semantic_interfaces.parsing.text_input.ti_description import ( + ObjectBuilderMethod, + QueryItemDescription, + QueryItemType, +) +from dbt_semantic_interfaces.parsing.text_input.ti_exceptions import ( + InvalidBuilderMethodException, +) + +if typing.TYPE_CHECKING: + from dbt_semantic_interfaces.parsing.text_input.ti_processor import ( + QueryItemDescriptionProcessor, + ) + +from dbt_semantic_interfaces.parsing.text_input.valid_method import ValidMethodMapping + + +class ObjectBuilderJinjaRenderHelper: + """Helps to build the methods that go into the Jinja template `.render()` call. + + e.g. + + SandboxedEnvironment(undefined=StrictUndefined) + .from_string(jinja_template) + .render( + Dimension=render_helper.get_function_for_dimension(), + TimeDimension=render_helper.get_function_for_time_dimension(), + Entity=render_helper.get_function_for_entity(), + Metric=render_helper.get_function_for_metric(), + ) + ) + """ + + def __init__( # noqa: D107 + self, + description_processor: QueryItemDescriptionProcessor, + valid_method_mapping: ValidMethodMapping, + ) -> None: + self._description_processor = description_processor + self._valid_method_mapping = valid_method_mapping + + def get_function_for_dimension(self) -> Callable: + """Returns the function that should be passed in to `.render(Dimension=...)`.""" + description_processor = self._description_processor + item_type = QueryItemType.DIMENSION + allowed_methods = self._valid_method_mapping[item_type] + + def _create(name: str, entity_path: Sequence[str] = ()) -> _RenderingClassForJinjaTemplate: + return _RenderingClassForJinjaTemplate( + description_processor=description_processor, + allowed_methods=allowed_methods, + initial_item_description=QueryItemDescription( + item_type=item_type, + item_name=name, + entity_path=tuple(entity_path), + time_granularity_name=None, + date_part_name=None, + group_by_for_metric_item=(), + descending=None, + ), + ) + + return _create + + def get_function_for_time_dimension(self) -> Callable: + """Returns the function that should be passed in to `.render(TimeDimension=...)`.""" + description_processor = self._description_processor + item_type = QueryItemType.TIME_DIMENSION + allowed_methods = self._valid_method_mapping[item_type] + + def _create( + time_dimension_name: str, + time_granularity_name: Optional[str] = None, + entity_path: Sequence[str] = (), + descending: Optional[bool] = None, + date_part_name: Optional[str] = None, + ) -> _RenderingClassForJinjaTemplate: + return _RenderingClassForJinjaTemplate( + description_processor=description_processor, + allowed_methods=allowed_methods, + initial_item_description=QueryItemDescription( + item_type=item_type, + item_name=time_dimension_name, + entity_path=tuple(entity_path), + time_granularity_name=time_granularity_name, + date_part_name=date_part_name, + group_by_for_metric_item=(), + descending=descending, + ), + ) + + return _create + + def get_function_for_entity(self) -> Callable: + """Returns the function that should be passed in to `.render(Entity=...)`.""" + description_processor = self._description_processor + item_type = QueryItemType.ENTITY + allowed_methods = self._valid_method_mapping[item_type] + + def _create(entity_name: str, entity_path: Sequence[str] = ()) -> _RenderingClassForJinjaTemplate: + return _RenderingClassForJinjaTemplate( + description_processor=description_processor, + allowed_methods=allowed_methods, + initial_item_description=QueryItemDescription( + item_type=item_type, + item_name=entity_name, + entity_path=tuple(entity_path), + time_granularity_name=None, + date_part_name=None, + group_by_for_metric_item=(), + descending=None, + ), + ) + + return _create + + def get_function_for_metric(self) -> Callable: + """Returns the function that should be passed in to `.render(Metric=...)`.""" + description_processor = self._description_processor + item_type = QueryItemType.METRIC + allowed_methods = self._valid_method_mapping[item_type] + + def _create(metric_name: str, group_by: Sequence[str] = ()) -> _RenderingClassForJinjaTemplate: + return _RenderingClassForJinjaTemplate( + description_processor=description_processor, + allowed_methods=allowed_methods, + initial_item_description=QueryItemDescription( + item_type=item_type, + item_name=metric_name, + entity_path=(), + time_granularity_name=None, + date_part_name=None, + group_by_for_metric_item=tuple(group_by), + descending=None, + ), + ) + + return _create + + +class _RenderingClassForJinjaTemplate: + """Helper class that behaves like a builder object as used in a Jinja template. + + e.g. in the Jinja template: + + {{ Dimension('listing__created_at').grain('day').date_part('month') }} + + The `Dimension('listing__created_at')` is an instance of this class and when builder methods like `.grain()` are + called on it, the state of the instance is updated and returns itself so that additional builder methods can be + chained. + """ + + def __init__( + self, + description_processor: QueryItemDescriptionProcessor, + allowed_methods: FrozenSet[ObjectBuilderMethod], + initial_item_description: QueryItemDescription, + ) -> None: + """Initializer. + + Args: + description_processor: The description processor that will run using the query-item description described + in the builder call. It will run after all builder methods are called. + allowed_methods: Builder methods that can be used. Otherwise, an `InvalidBuilderMethodException` is raised. + initial_item_description: The starting description. Usually it contains the element name and entity path. + """ + self._description_processor = description_processor + self._allowed_builder_methods = allowed_methods + self._current_description = initial_item_description + + def _update_current_description( + self, + time_granularity_name: Optional[str] = None, + date_part_name: Optional[str] = None, + descending: Optional[bool] = None, + ) -> None: + args = (time_granularity_name, date_part_name, descending) + assert sum(1 for arg in args if arg is not None) == 1, f"Expected exactly 1 argument set, but got {args}" + + builder_method: Optional[ObjectBuilderMethod] + if time_granularity_name is not None: + builder_method = ObjectBuilderMethod.GRAIN + elif date_part_name is not None: + builder_method = ObjectBuilderMethod.DATE_PART + elif descending is not None: + builder_method = ObjectBuilderMethod.DESCENDING + else: + assert False, "Exactly 1 argument should have been set as previously checked." + + if builder_method not in self._allowed_builder_methods: + raise InvalidBuilderMethodException( + f"`{builder_method.value}` can't be used with `{self._current_description.item_type.value}`" + f" in this context.", + item_type=self._current_description.item_type, + invalid_builder_method=builder_method, + ) + self._current_description = self._current_description.create_modified( + time_granularity_name=time_granularity_name, + date_part_name=date_part_name, + descending=descending, + ) + + def grain(self, time_granularity: str) -> _RenderingClassForJinjaTemplate: + self._update_current_description(time_granularity_name=time_granularity) + return self + + def descending(self, _is_descending: bool) -> _RenderingClassForJinjaTemplate: + self._update_current_description(descending=_is_descending) + return self + + def date_part(self, date_part_name: str) -> _RenderingClassForJinjaTemplate: + self._update_current_description(date_part_name=date_part_name) + return self + + @override + def __str__(self) -> str: + return self._description_processor.process_description(self._current_description) diff --git a/dbt_semantic_interfaces/parsing/text_input/ti_exceptions.py b/dbt_semantic_interfaces/parsing/text_input/ti_exceptions.py new file mode 100644 index 00000000..d5d35e57 --- /dev/null +++ b/dbt_semantic_interfaces/parsing/text_input/ti_exceptions.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dbt_semantic_interfaces.errors import InvalidQuerySyntax +from dbt_semantic_interfaces.parsing.text_input.ti_description import ( + ObjectBuilderMethod, + QueryItemType, +) + + +class QueryItemJinjaException(Exception): + """Raised when there is an exception when calling Jinja package methods on the query item input.""" + + pass + + +class InvalidBuilderMethodException(InvalidQuerySyntax): + """Raised when a query item using the object-builder format uses a disallowed method. + + For example, `Entity('listing').grain('day')` should raise this exception since `grain` is only applicable to + `Dimension()`. + """ + + def __init__( # noqa: D107 + self, message: str, item_type: QueryItemType, invalid_builder_method: ObjectBuilderMethod + ) -> None: + super().__init__(message) + self._item_type = item_type + self._invalid_builder_method = invalid_builder_method + + @property + def item_type(self) -> QueryItemType: + """Return the item that was used with the invalid method.""" + return self._item_type + + @property + def invalid_builder_method(self) -> ObjectBuilderMethod: + """Return the invalid builder method that was used.""" + return self._invalid_builder_method diff --git a/dbt_semantic_interfaces/parsing/text_input/ti_processor.py b/dbt_semantic_interfaces/parsing/text_input/ti_processor.py index f7ee22c7..541f89d6 100644 --- a/dbt_semantic_interfaces/parsing/text_input/ti_processor.py +++ b/dbt_semantic_interfaces/parsing/text_input/ti_processor.py @@ -1,13 +1,26 @@ from __future__ import annotations -from typing import Sequence +from abc import ABC, abstractmethod +from textwrap import indent +from typing import List, Sequence + +from jinja2 import StrictUndefined, TemplateSyntaxError, UndefinedError +from jinja2.exceptions import SecurityError +from jinja2.sandbox import SandboxedEnvironment +from typing_extensions import override from dbt_semantic_interfaces.parsing.text_input.description_renderer import ( QueryItemDescriptionRenderer, ) +from dbt_semantic_interfaces.parsing.text_input.rendering_helper import ( + ObjectBuilderJinjaRenderHelper, +) from dbt_semantic_interfaces.parsing.text_input.ti_description import ( QueryItemDescription, ) +from dbt_semantic_interfaces.parsing.text_input.ti_exceptions import ( + QueryItemJinjaException, +) from dbt_semantic_interfaces.parsing.text_input.valid_method import ValidMethodMapping @@ -38,7 +51,13 @@ def collect_descriptions_from_template( QueryItemJinjaException: See definition. InvalidBuilderMethodException: See definition. """ - raise NotImplementedError + description_collector = _CollectDescriptionProcessor() + self._process_template( + jinja_template=jinja_template, + valid_method_mapping=valid_method_mapping, + description_processor=description_collector, + ) + return description_collector.collected_descriptions() def render_template( self, @@ -61,4 +80,81 @@ def render_template( QueryItemJinjaException: See definition. InvalidBuilderMethodException: See definition. """ + render_processor = _RendererProcessor(renderer) + return self._process_template( + jinja_template=jinja_template, + valid_method_mapping=valid_method_mapping, + description_processor=render_processor, + ) + + def _process_template( + self, + jinja_template: str, + valid_method_mapping: ValidMethodMapping, + description_processor: QueryItemDescriptionProcessor, + ) -> str: + """Helper to run a `QueryItemDescriptionProcessor` on a Jinja template.""" + render_helper = ObjectBuilderJinjaRenderHelper( + description_processor=description_processor, + valid_method_mapping=valid_method_mapping, + ) + try: + # the string that the sandbox renders is unused + rendered = ( + SandboxedEnvironment(undefined=StrictUndefined) + .from_string(jinja_template) + .render( + Dimension=render_helper.get_function_for_dimension(), + TimeDimension=render_helper.get_function_for_time_dimension(), + Entity=render_helper.get_function_for_entity(), + Metric=render_helper.get_function_for_metric(), + ) + ) + except (UndefinedError, TemplateSyntaxError, SecurityError) as e: + raise QueryItemJinjaException( + f"Error while processing Jinja template:" f"\n{indent(jinja_template, prefix=' ')}" + ) from e + + return rendered + + +class QueryItemDescriptionProcessor(ABC): + """General processor that does something to a query-item description seen in a Jinja template.""" + + @abstractmethod + def process_description(self, item_description: QueryItemDescription) -> str: + """Process the given description, and return a string that would be substituted into the Jinja template.""" raise NotImplementedError + + +class _CollectDescriptionProcessor(QueryItemDescriptionProcessor): + """Processor that collects all descriptions that were processed.""" + + def __init__(self) -> None: # noqa: D107 + self._items: List[QueryItemDescription] = [] + + def collected_descriptions(self) -> Sequence[QueryItemDescription]: + """Return all descriptions that were processed so far.""" + return self._items + + @override + def process_description(self, item_description: QueryItemDescription) -> str: + if item_description not in self._items: + self._items.append(item_description) + + return "" + + +class _RendererProcessor(QueryItemDescriptionProcessor): + """Processor that renders the descriptions in a Jinja template using the given renderer. + + This is just a pass-through, but it allows `QueryItemDescriptionRenderer` to be a facade that has more appropriate + method names. + """ + + def __init__(self, renderer: QueryItemDescriptionRenderer) -> None: # noqa: D107 + self._renderer = renderer + + @override + def process_description(self, item_description: QueryItemDescription) -> str: + return self._renderer.render_description(item_description) diff --git a/dbt_semantic_interfaces/parsing/text_input/valid_method.py b/dbt_semantic_interfaces/parsing/text_input/valid_method.py new file mode 100644 index 00000000..56f7802c --- /dev/null +++ b/dbt_semantic_interfaces/parsing/text_input/valid_method.py @@ -0,0 +1,31 @@ +from typing import FrozenSet, Mapping + +from dbt_semantic_interfaces.parsing.text_input.ti_description import ( + ObjectBuilderMethod, + QueryItemType, +) + +ValidMethodMapping = Mapping[QueryItemType, FrozenSet[ObjectBuilderMethod]] + + +class ConfiguredValidMethodMapping: + """Default mappings for methods valid for the object-builder syntax.""" + + # In an order-by item, `.descending(...)` is allowed. + DEFAULT_MAPPING_FOR_ORDER_BY: ValidMethodMapping = { + QueryItemType.METRIC: frozenset({ObjectBuilderMethod.DESCENDING}), + QueryItemType.ENTITY: frozenset({ObjectBuilderMethod.DESCENDING}), + QueryItemType.DIMENSION: frozenset( + {ObjectBuilderMethod.DESCENDING, ObjectBuilderMethod.GRAIN, ObjectBuilderMethod.DATE_PART} + ), + QueryItemType.TIME_DIMENSION: frozenset({ObjectBuilderMethod.DESCENDING}), + } + + DEFAULT_MAPPING: ValidMethodMapping = { + QueryItemType.METRIC: frozenset(), + QueryItemType.ENTITY: frozenset(), + QueryItemType.DIMENSION: frozenset( + {ObjectBuilderMethod.DESCENDING, ObjectBuilderMethod.GRAIN, ObjectBuilderMethod.DATE_PART} + ), + QueryItemType.TIME_DIMENSION: frozenset(), + }