Skip to content

Commit

Permalink
Fill out implementations.
Browse files Browse the repository at this point in the history
  • Loading branch information
plypaul committed Aug 1, 2024
1 parent bd1410d commit f5f3d77
Show file tree
Hide file tree
Showing 4 changed files with 391 additions and 2 deletions.
224 changes: 224 additions & 0 deletions dbt_semantic_interfaces/parsing/text_input/rendering_helper.py
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 38 additions & 0 deletions dbt_semantic_interfaces/parsing/text_input/ti_exceptions.py
Original file line number Diff line number Diff line change
@@ -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
100 changes: 98 additions & 2 deletions dbt_semantic_interfaces/parsing/text_input/ti_processor.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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,
Expand All @@ -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)
Loading

0 comments on commit f5f3d77

Please sign in to comment.