diff --git a/js/src/dotprompt.ts b/js/src/dotprompt.ts index fc3cbd110..406d447c7 100644 --- a/js/src/dotprompt.ts +++ b/js/src/dotprompt.ts @@ -20,18 +20,18 @@ import * as Handlebars from 'handlebars'; import * as builtinHelpers from './helpers'; import { parseDocument, toMessages } from './parse'; import { picoschema } from './picoschema'; -import { - type DataArgument, - type JSONSchema, - type ParsedPrompt, - type PromptFunction, - type PromptMetadata, - type PromptStore, - type RenderedPrompt, - type Schema, - type SchemaResolver, - type ToolDefinition, - type ToolResolver, +import type { + DataArgument, + JSONSchema, + ParsedPrompt, + PromptFunction, + PromptMetadata, + PromptStore, + RenderedPrompt, + Schema, + SchemaResolver, + ToolDefinition, + ToolResolver, } from './types'; import { removeUndefinedFields } from './util'; @@ -196,6 +196,7 @@ export class Dotprompt { } ); + // Create an instance of a PromptFunction. const renderFunc = async ( data: DataArgument, options?: PromptMetadata @@ -223,7 +224,10 @@ export class Dotprompt { messages: toMessages(renderedString, data), }; }; + + // Add the parsed source to the prompt function as a property. (renderFunc as PromptFunction).prompt = parsedSource; + return renderFunc as PromptFunction; } diff --git a/python/dotpromptz/src/dotpromptz/dotprompt.py b/python/dotpromptz/src/dotpromptz/dotprompt.py index b442c7f8a..d441af420 100644 --- a/python/dotpromptz/src/dotpromptz/dotprompt.py +++ b/python/dotpromptz/src/dotpromptz/dotprompt.py @@ -45,7 +45,7 @@ import anyio from dotpromptz.helpers import BUILTIN_HELPERS -from dotpromptz.parse import parse_document +from dotpromptz.parse import parse_document, to_messages from dotpromptz.picoschema import picoschema_to_json_schema from dotpromptz.resolvers import resolve_json_schema, resolve_partial, resolve_tool from dotpromptz.typing import ( @@ -64,7 +64,7 @@ VariablesT, ) from dotpromptz.util import remove_undefined_fields -from handlebarrz import EscapeFunction, Handlebars, HelperFn +from handlebarrz import Context, EscapeFunction, Handlebars, HelperFn, RuntimeOptions # Pre-compiled regex for finding partial references in handlebars templates @@ -117,7 +117,7 @@ def _identify_partials(template: str) -> set[str]: return set(_PARTIAL_PATTERN.findall(template)) -class CompiledRenderer(PromptFunction[ModelConfigT]): +class RenderFunc(PromptFunction[ModelConfigT]): """A compiled prompt function with the prompt as a property. This is the Python equivalent of the renderFunc nested function @@ -151,9 +151,42 @@ async def __call__( Returns: The rendered prompt. """ + # Discard the input schema as once rendered it doesn't make sense. + merged_metadata: PromptMetadata[ModelConfigT] = await self._dotprompt.render_metadata(self.prompt, options) + merged_metadata.input = None + + # Prepare input data, merging defaults from options if available. + context: Context = { + **((options.input.default or {}) if options and options.input else {}), + **(data.input if data.input is not None else {}), + } + + # Prepare runtime options. + # TODO: options are currently ignored; need to add support for it. + runtime_options: RuntimeOptions = { + 'data': { + 'metadata': { + 'prompt': merged_metadata.model_dump(exclude_none=True, by_alias=True), + 'docs': data.docs, + 'messages': data.messages, + }, + **(data.context or {}), + }, + } + + # Render the string. + render_string = self._handlebars.compile(self.prompt.template) + rendered_string = render_string(context, runtime_options) + + # Parse the rendered string into messages. + messages = to_messages(rendered_string, data) + # Construct and return the final RenderedPrompt. - # TODO: Stub - return RenderedPrompt[ModelConfigT](messages=[]) + return RenderedPrompt[ModelConfigT]( + # Spread the metadata fields into the RenderedPrompt constructor. + **merged_metadata.model_dump(exclude_none=True, by_alias=True), + messages=messages, + ) class Dotprompt: @@ -294,7 +327,7 @@ async def compile( # Resolve partials before compiling. await self._resolve_partials(prompt.template) - return CompiledRenderer(self, self._handlebars, prompt) + return RenderFunc(self, self._handlebars, prompt) async def render_metadata( self, @@ -453,12 +486,13 @@ async def _resolve_tools(self, metadata: PromptMetadata[ModelConfigT]) -> Prompt # Found locally. out.tool_defs.append(self._tools[name]) elif have_resolver: - # Resolve from the tool resolver. + # Resolve using the tool resolver. to_resolve.append(name) else: # Unregistered tool. unregistered_names.append(name) + # Resolve all the tools to be resolved using the resolver. if to_resolve: async def resolve_and_append(tool_name: str) -> None: diff --git a/python/handlebarrz/src/handlebarrz/__init__.py b/python/handlebarrz/src/handlebarrz/__init__.py index 6c6200186..674cabffb 100644 --- a/python/handlebarrz/src/handlebarrz/__init__.py +++ b/python/handlebarrz/src/handlebarrz/__init__.py @@ -67,11 +67,13 @@ def format_name(params, hash, ctx): ``` """ +from __future__ import annotations + import json import sys # noqa from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, TypedDict import structlog @@ -91,6 +93,22 @@ def format_name(params, hash, ctx): HelperFn = Callable[[list[Any], dict[str, Any], dict[str, Any]], str] NativeHelperFn = Callable[[str, str, str], str] +Context = dict[str, Any] + + +class RuntimeOptions(TypedDict): + """Options for the runtime of a Handlebars template. + + These options are used to configure the runtime behavior of a Handlebars + template. They can be passed to the compiled template function to customize + the rendering process. + """ + + data: dict[str, Any] | None + # TODO: Add other options based on supported features. + + +CompiledRenderer = Callable[[Context, RuntimeOptions | None], str] class EscapeFunction(StrEnum): @@ -427,7 +445,7 @@ def unregister_template(self, name: str) -> None: self._template.unregister_template(name) logger.debug({'event': 'template_unregistered', 'name': name}) - def render(self, name: str, data: dict[str, Any]) -> str: + def render(self, name: str, data: dict[str, Any], options: RuntimeOptions | None = None) -> str: """Render a template with the given data. Renders a previously registered template using the provided data @@ -437,6 +455,7 @@ def render(self, name: str, data: dict[str, Any]) -> str: Args: name: The name of the template to render data: The data to render the template with + options: Additional options for the template. Returns: str: The rendered template string @@ -445,6 +464,8 @@ def render(self, name: str, data: dict[str, Any]) -> str: ValueError: If the template does not exist or there is a rendering error. """ + # TODO: options is currently ignored; need to add support for it. + try: result = self._template.render(name, json.dumps(data)) logger.debug({'event': 'template_rendered', 'name': name}) @@ -457,7 +478,7 @@ def render(self, name: str, data: dict[str, Any]) -> str: }) raise - def render_template(self, template_string: str, data: dict[str, Any]) -> str: + def render_template(self, template_string: str, data: dict[str, Any], options: RuntimeOptions | None = None) -> str: """Render a template string directly without registering it. Parses and renders the template string in one step. This is useful for @@ -467,6 +488,7 @@ def render_template(self, template_string: str, data: dict[str, Any]) -> str: Args: template_string: The template string to render data: The data to render the template with + options: Additional options for the template. Returns: Rendered template string. @@ -475,8 +497,15 @@ def render_template(self, template_string: str, data: dict[str, Any]) -> str: ValueError: If there is a syntax error in the template or a rendering error. """ + # TODO: options is currently ignored; need to add support for it. try: - result = self._template.render_template(template_string, json.dumps(data)) + # Serialize options if provided, focusing on the '@data' part + options_json = None + if options: + # Pass the whole options dict as JSON + options_json = json.dumps(options) + + result = self._template.render_template(template_string, json.dumps(data), options_json) logger.debug({'event': 'template_string_rendered'}) return result except ValueError as e: @@ -486,7 +515,7 @@ def render_template(self, template_string: str, data: dict[str, Any]) -> str: }) raise - def compile(self, template_string: str) -> Callable[[dict[str, Any]], str]: + def compile(self, template_string: str) -> CompiledRenderer: """Compile a template string into a reusable function. This method provides an interface similar to Handlebars.js's `compile`. @@ -502,8 +531,8 @@ def compile(self, template_string: str) -> Callable[[dict[str, Any]], str]: template_string: The Handlebars template string to compile. Returns: - A callable function that takes a data dictionary and returns the - rendered string. + A callable function that takes a data dictionary and some runtime + options and returns the rendered string. Raises: ValueError: If there is a syntax error during the initial parse @@ -512,8 +541,17 @@ def compile(self, template_string: str) -> Callable[[dict[str, Any]], str]: called. """ - def compiled(data: dict[str, Any]) -> str: - return self.render_template(template_string, data) + def compiled(context: Context, options: RuntimeOptions | None = None) -> str: + """Compiled template function. + + Args: + context: The data to render the template with. + options: Additional options for the template. + + Returns: + The rendered template string. + """ + return self.render_template(template_string, context, options) return compiled diff --git a/python/handlebarrz/src/handlebarrz/_native.pyi b/python/handlebarrz/src/handlebarrz/_native.pyi index 1f588356f..86eeb7fc1 100644 --- a/python/handlebarrz/src/handlebarrz/_native.pyi +++ b/python/handlebarrz/src/handlebarrz/_native.pyi @@ -17,6 +17,7 @@ """Stub type annotations for native Handlebars.""" from collections.abc import Callable +from typing import Any def html_escape(text: str) -> str: ... def no_escape(text: str) -> str: ... @@ -52,7 +53,7 @@ class HandlebarrzTemplate: # Rendering. def render(self, name: str, data_json: str) -> str: ... - def render_template(self, template_str: str, data_json: str) -> str: ... + def render_template(self, template_str: str, data_json: str, options_json: str | None = None) -> str: ... # Extra helper registration. def register_extra_helpers(self) -> None: ... diff --git a/python/handlebarrz/src/lib.rs b/python/handlebarrz/src/lib.rs index 2ca5d168e..98b2cfde9 100644 --- a/python/handlebarrz/src/lib.rs +++ b/python/handlebarrz/src/lib.rs @@ -450,7 +450,8 @@ impl HandlebarrzTemplate { /// # Arguments /// /// * `template_string` - The template source code. - /// * `data` - The data to use for rendering (as JSON). + /// * `data_json` - The data to use for rendering (as JSON). + /// * `options_json` - Optional. If provided, the data will be merged with this JSON object. /// /// # Raises /// @@ -459,11 +460,28 @@ impl HandlebarrzTemplate { /// # Returns /// /// Rendered template as a string. - #[pyo3(text_signature = "($self, template_string, data)")] - fn render_template(&self, template_string: &str, data: &str) -> PyResult { - let data: Value = serde_json::from_str(data) + #[pyo3(text_signature = "($self, template_string, data_json, options_json = None)")] + fn render_template( + &self, + template_string: &str, + data_json: &str, + _options_json: Option<&str>, + ) -> PyResult { + let data: Value = serde_json::from_str(data_json) .map_err(|e| PyValueError::new_err(format!("invalid JSON: {}", e)))?; + // TODO: Implement setting the data attribute of runtime options. + // if let Some(options_str) = options_json { + // let options_data: Value = serde_json::from_str(options_str) + // .map_err(|e| PyValueError::new_err(format!("invalid options JSON: {}", e)))?; + + // if let (Some(data_map), Some(_options_map)) = + // (data.as_object_mut(), options_data.as_object()) + // { + // data_map.insert("@data".to_string(), options_data.clone()); + // } + // } + self.registry .render_template(template_string, &data) .map_err(|e| PyValueError::new_err(e.to_string())) diff --git a/python/handlebarrz/tests/template_test.py b/python/handlebarrz/tests/template_test.py index 9cf1d1f66..181ebc51c 100644 --- a/python/handlebarrz/tests/template_test.py +++ b/python/handlebarrz/tests/template_test.py @@ -23,6 +23,7 @@ import pytest from handlebarrz import ( + CompiledRenderer, EscapeFunction, Handlebars, Template, @@ -212,23 +213,23 @@ def test_template_with_file(self) -> None: def test_compile_basic(self) -> None: """Test basic template compilation and execution.""" template = Template() - compiled_func = template.compile('Hello {{name}}!') - result = compiled_func({'name': 'Compiled World'}) + compiled_func: CompiledRenderer = template.compile('Hello {{name}}!') + result = compiled_func({'name': 'Compiled World'}, None) self.assertEqual(result, 'Hello Compiled World!') def test_compile_with_data_changes(self) -> None: """Test that the compiled function works with different data.""" template = Template() - compiled_func = template.compile('Value: {{val}}') - result1 = compiled_func({'val': 10}) - result2 = compiled_func({'val': 'abc'}) + compiled_func: CompiledRenderer = template.compile('Value: {{val}}') + result1 = compiled_func({'val': 10}, None) + result2 = compiled_func({'val': 'abc'}, None) self.assertEqual(result1, 'Value: 10') self.assertEqual(result2, 'Value: abc') def test_compile_uses_current_template_state(self) -> None: """Test that compiled function uses the template state at call time.""" template = Template() - compiled_func = template.compile('Helper: {{my_helper val}}') + compiled_func: CompiledRenderer = template.compile('Helper: {{my_helper val}}') # Register the helper AFTER compiling. def simple_upper(params: list[Any], hash: dict[str, Any], ctx: dict[str, Any]) -> str: @@ -237,25 +238,25 @@ def simple_upper(params: list[Any], hash: dict[str, Any], ctx: dict[str, Any]) - template.register_helper('my_helper', simple_upper) # Call again AFTER helper is registered. - result_after = compiled_func({'val': 'test'}) + result_after = compiled_func({'val': 'test'}, None) self.assertEqual(result_after, 'Helper: TEST') # Change strict mode AFTER compiling. template.strict_mode = True - compiled_strict = template.compile('{{missing}}') + compiled_strict: CompiledRenderer = template.compile('{{missing}}') with pytest.raises(ValueError, match=r'Failed to access variable.*missing.*'): - compiled_strict({}) + compiled_strict({}, None) def test_compile_invalid_syntax(self) -> None: """Test that compiling invalid syntax raises ValueError when called.""" template = Template() # Compile should succeed, but the returned function should fail. - compiled_func = template.compile('Hello {{name!') + compiled_func: CompiledRenderer = template.compile('Hello {{name!') # Expect ValueError when the compiled function is executed. with pytest.raises(ValueError, match=r'Failed to parse template.*'): - compiled_func({}) + compiled_func({}, None) class TestHandlebarsAlias(unittest.TestCase): diff --git a/python/tox.ini b/python/tox.ini index 23a91f708..b6d2317ac 100644 --- a/python/tox.ini +++ b/python/tox.ini @@ -3,8 +3,7 @@ envlist = py310, py311, py312, - py313, - py314 + py313 isolated_build = True skipsdist = True # We install editable, so don't build sdist.