Skip to content

Commit

Permalink
Merge pull request #25 from volfpeter/more-JinjaContext-utilities
Browse files Browse the repository at this point in the history
More JinjaContext utilities
  • Loading branch information
volfpeter authored Jul 31, 2024
2 parents 593d75a + 511998a commit 10bf17b
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 22 deletions.
76 changes: 61 additions & 15 deletions fasthx/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ class JinjaContext:
"""

@classmethod
def unpack_result(cls, *, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]:
def unpack_object(cls, obj: Any) -> dict[str, Any]:
"""
Jinja context factory that tries to reasonably convert non-`dict` route results
to valid Jinja contexts (the `route_context` argument is ignored).
Utility function that unpacks an object into a `dict`.
Supports `dict` and `Collection` instances, plus anything with `__dict__` or `__slots__`
attributes, for example Pydantic models, dataclasses, or "standard" class instances.
Expand All @@ -34,34 +33,48 @@ def unpack_result(cls, *, route_result: Any, route_context: dict[str, Any]) -> d
- `None` is converted into an empty context.
Raises:
ValueError: If `route_result` can not be handled by any of the conversion rules.
ValueError: If the given object can not be handled by any of the conversion rules.
"""
if isinstance(route_result, dict):
return route_result
if isinstance(obj, dict):
return obj

# Covers lists, tuples, sets, etc..
if isinstance(route_result, Collection):
return {"items": route_result}
if isinstance(obj, Collection):
return {"items": obj}

object_keys: Iterable[str] | None = None

# __dict__ should take priority if an object has both this and __slots__.
if hasattr(route_result, "__dict__"):
if hasattr(obj, "__dict__"):
# Covers Pydantic models and standard classes.
object_keys = route_result.__dict__.keys()
elif hasattr(route_result, "__slots__"):
object_keys = obj.__dict__.keys()
elif hasattr(obj, "__slots__"):
# Covers classes with with __slots__.
object_keys = route_result.__slots__
object_keys = obj.__slots__

if object_keys is not None:
return {key: getattr(route_result, key) for key in object_keys if not key.startswith("_")}
return {key: getattr(obj, key) for key in object_keys if not key.startswith("_")}

if route_result is None:
if obj is None:
# Convert no response to empty context.
return {}

raise ValueError("Result conversion failed, unknown result type.")

@classmethod
def unpack_result(cls, *, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]:
"""
Jinja context factory that tries to reasonably convert non-`dict` route results
to valid Jinja contexts (the `route_context` argument is ignored).
Supports everything that `JinjaContext.unpack_object()` does and follows the same
conversion rules.
Raises:
ValueError: If `route_result` can not be handled by any of the conversion rules.
"""
return cls.unpack_object(route_result)

@classmethod
def unpack_result_with_route_context(
cls,
Expand All @@ -73,7 +86,7 @@ def unpack_result_with_route_context(
Jinja context factory that tries to reasonably convert non-`dict` route results
to valid Jinja contexts, also including every key-value pair from `route_context`.
Supports everything that `JinjaContext.unpack_result()` does and follows the same
Supports everything that `JinjaContext.unpack_object()` does and follows the same
conversion rules.
Raises:
Expand All @@ -90,6 +103,39 @@ def unpack_result_with_route_context(
route_context.update(result)
return route_context

@classmethod
def use_converters(
cls,
convert_route_result: Callable[[Any], dict[str, Any]] | None,
convert_route_context: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
) -> JinjaContextFactory:
"""
Creates a `JinjaContextFactory` that uses the provided functions to convert
`route_result` and `route_context` to a Jinja context.
The returned `JinjaContextFactory` raises a `ValueError` if the overlapping keys are found.
Arguments:
convert_route_result: Function that takes `route_result` and converts it into a `dict`.
See `JinjaContextFactory` for `route_result` details.
convert_route_context: Function that takes `route_context` and converts it into a `dict`.
See `JinjaContextFactory` for `route_context` details.
Returns:
The created `JinjaContextFactory`.
"""

def make_jinja_context(*, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]:
rr = {} if convert_route_result is None else convert_route_result(route_result)
rc = {} if convert_route_context is None else convert_route_context(route_context)
if len(set(rr.keys()) & set(rc.keys())) > 0:
raise ValueError("Overlapping keys in route result and route context.")

rr.update(rc)
return rr

return make_jinja_context

@classmethod
@lru_cache
def wrap_as(cls, result_key: str, context_key: str | None = None) -> JinjaContextFactory:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fasthx"
version = "1.1.0"
version = "1.1.1"
description = "FastAPI data APIs with HTMX support."
authors = ["Peter Volf <[email protected]>"]
readme = "README.md"
Expand Down
30 changes: 24 additions & 6 deletions tests/test_jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ def test_unpack_methods(self, route_result: Any, route_converted: dict[str, Any]
)
assert result == {**route_context, **route_converted}

def test_unpack_result_with_route_context_conflict(self) -> None:
with pytest.raises(ValueError):
JinjaContext.unpack_result_with_route_context(
route_result=billy, route_context={"name": "Not Billy"}
)

def test_use_converters(self) -> None:
context_factory = JinjaContext.use_converters(
lambda _: {"route_result": 1},
lambda _: {"route_context": 2},
)
assert context_factory(route_result=None, route_context={}) == {
"route_result": 1,
"route_context": 2,
}

def test_use_converters_name_conflict(self) -> None:
context_factory = JinjaContext.use_converters(
lambda _: {"x": 1},
lambda _: {"x": 2},
)
with pytest.raises(ValueError):
context_factory(route_result=None, route_context={})

def test_wrap_as(self) -> None:
result_only = JinjaContext.wrap_as("item")
assert result_only is JinjaContext.wrap_as("item")
Expand All @@ -203,9 +227,3 @@ def test_wrap_as(self) -> None:
def test_wrap_as_name_conflict(self) -> None:
with pytest.raises(ValueError):
JinjaContext.wrap_as("foo", "foo")

def test_unpack_result_with_route_context_conflict(self) -> None:
with pytest.raises(ValueError):
JinjaContext.unpack_result_with_route_context(
route_result=billy, route_context={"name": "Not Billy"}
)

0 comments on commit 10bf17b

Please sign in to comment.