Skip to content

Commit 99b34e5

Browse files
committed
feat(py/dotprompt): render and compile implementations
1 parent 55aff53 commit 99b34e5

File tree

7 files changed

+141
-46
lines changed

7 files changed

+141
-46
lines changed

js/src/dotprompt.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ import * as Handlebars from 'handlebars';
2020
import * as builtinHelpers from './helpers';
2121
import { parseDocument, toMessages } from './parse';
2222
import { picoschema } from './picoschema';
23-
import {
24-
type DataArgument,
25-
type JSONSchema,
26-
type ParsedPrompt,
27-
type PromptFunction,
28-
type PromptMetadata,
29-
type PromptStore,
30-
type RenderedPrompt,
31-
type Schema,
32-
type SchemaResolver,
33-
type ToolDefinition,
34-
type ToolResolver,
23+
import type {
24+
DataArgument,
25+
JSONSchema,
26+
ParsedPrompt,
27+
PromptFunction,
28+
PromptMetadata,
29+
PromptStore,
30+
RenderedPrompt,
31+
Schema,
32+
SchemaResolver,
33+
ToolDefinition,
34+
ToolResolver,
3535
} from './types';
3636
import { removeUndefinedFields } from './util';
3737

@@ -196,6 +196,7 @@ export class Dotprompt {
196196
}
197197
);
198198

199+
// Create an instance of a PromptFunction.
199200
const renderFunc = async (
200201
data: DataArgument,
201202
options?: PromptMetadata<ModelConfig>
@@ -223,7 +224,10 @@ export class Dotprompt {
223224
messages: toMessages<ModelConfig>(renderedString, data),
224225
};
225226
};
227+
228+
// Add the parsed source to the prompt function as a property.
226229
(renderFunc as PromptFunction<ModelConfig>).prompt = parsedSource;
230+
227231
return renderFunc as PromptFunction<ModelConfig>;
228232
}
229233

python/dotpromptz/src/dotpromptz/dotprompt.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
import anyio
4646

4747
from dotpromptz.helpers import BUILTIN_HELPERS
48-
from dotpromptz.parse import parse_document
48+
from dotpromptz.parse import parse_document, to_messages
4949
from dotpromptz.picoschema import picoschema_to_json_schema
5050
from dotpromptz.resolvers import resolve_json_schema, resolve_partial, resolve_tool
5151
from dotpromptz.typing import (
@@ -64,7 +64,7 @@
6464
VariablesT,
6565
)
6666
from dotpromptz.util import remove_undefined_fields
67-
from handlebarrz import EscapeFunction, Handlebars, HelperFn
67+
from handlebarrz import Context, EscapeFunction, Handlebars, HelperFn, RuntimeOptions
6868

6969
# Pre-compiled regex for finding partial references in handlebars templates
7070

@@ -117,7 +117,7 @@ def _identify_partials(template: str) -> set[str]:
117117
return set(_PARTIAL_PATTERN.findall(template))
118118

119119

120-
class CompiledRenderer(PromptFunction[ModelConfigT]):
120+
class RenderFunc(PromptFunction[ModelConfigT]):
121121
"""A compiled prompt function with the prompt as a property.
122122
123123
This is the Python equivalent of the renderFunc nested function
@@ -151,9 +151,42 @@ async def __call__(
151151
Returns:
152152
The rendered prompt.
153153
"""
154+
# Discard the input schema as once rendered it doesn't make sense.
155+
merged_metadata: PromptMetadata[ModelConfigT] = await self._dotprompt.render_metadata(self.prompt, options)
156+
merged_metadata.input = None
157+
158+
# Prepare input data, merging defaults from options if available.
159+
context: Context = {
160+
**((options.input.default or {}) if options and options.input else {}),
161+
**(data.input if data.input is not None else {}),
162+
}
163+
164+
# Prepare runtime options.
165+
# TODO: options are currently ignored; need to add support for it.
166+
runtime_options: RuntimeOptions = {
167+
'data': {
168+
'metadata': {
169+
'prompt': merged_metadata.model_dump(exclude_none=True, by_alias=True),
170+
'docs': data.docs,
171+
'messages': data.messages,
172+
},
173+
**(data.context or {}),
174+
},
175+
}
176+
177+
# Render the string.
178+
render_string = self._handlebars.compile(self.prompt.template)
179+
rendered_string = render_string(context, runtime_options)
180+
181+
# Parse the rendered string into messages.
182+
messages = to_messages(rendered_string, data)
183+
154184
# Construct and return the final RenderedPrompt.
155-
# TODO: Stub
156-
return RenderedPrompt[ModelConfigT](messages=[])
185+
return RenderedPrompt[ModelConfigT](
186+
# Spread the metadata fields into the RenderedPrompt constructor.
187+
**merged_metadata.model_dump(exclude_none=True, by_alias=True),
188+
messages=messages,
189+
)
157190

158191

159192
class Dotprompt:
@@ -294,7 +327,7 @@ async def compile(
294327

295328
# Resolve partials before compiling.
296329
await self._resolve_partials(prompt.template)
297-
return CompiledRenderer(self, self._handlebars, prompt)
330+
return RenderFunc(self, self._handlebars, prompt)
298331

299332
async def render_metadata(
300333
self,
@@ -453,12 +486,13 @@ async def _resolve_tools(self, metadata: PromptMetadata[ModelConfigT]) -> Prompt
453486
# Found locally.
454487
out.tool_defs.append(self._tools[name])
455488
elif have_resolver:
456-
# Resolve from the tool resolver.
489+
# Resolve using the tool resolver.
457490
to_resolve.append(name)
458491
else:
459492
# Unregistered tool.
460493
unregistered_names.append(name)
461494

495+
# Resolve all the tools to be resolved using the resolver.
462496
if to_resolve:
463497

464498
async def resolve_and_append(tool_name: str) -> None:

python/handlebarrz/src/handlebarrz/__init__.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ def format_name(params, hash, ctx):
6767
```
6868
"""
6969

70+
from __future__ import annotations
71+
7072
import json
7173
import sys # noqa
7274
from collections.abc import Callable
7375
from pathlib import Path
74-
from typing import Any
76+
from typing import Any, TypedDict
7577

7678
import structlog
7779

@@ -91,6 +93,22 @@ def format_name(params, hash, ctx):
9193

9294
HelperFn = Callable[[list[Any], dict[str, Any], dict[str, Any]], str]
9395
NativeHelperFn = Callable[[str, str, str], str]
96+
Context = dict[str, Any]
97+
98+
99+
class RuntimeOptions(TypedDict):
100+
"""Options for the runtime of a Handlebars template.
101+
102+
These options are used to configure the runtime behavior of a Handlebars
103+
template. They can be passed to the compiled template function to customize
104+
the rendering process.
105+
"""
106+
107+
data: dict[str, Any] | None
108+
# TODO: Add other options based on supported features.
109+
110+
111+
CompiledRenderer = Callable[[Context, RuntimeOptions | None], str]
94112

95113

96114
class EscapeFunction(StrEnum):
@@ -427,7 +445,7 @@ def unregister_template(self, name: str) -> None:
427445
self._template.unregister_template(name)
428446
logger.debug({'event': 'template_unregistered', 'name': name})
429447

430-
def render(self, name: str, data: dict[str, Any]) -> str:
448+
def render(self, name: str, data: dict[str, Any], options: RuntimeOptions | None = None) -> str:
431449
"""Render a template with the given data.
432450
433451
Renders a previously registered template using the provided data
@@ -437,6 +455,7 @@ def render(self, name: str, data: dict[str, Any]) -> str:
437455
Args:
438456
name: The name of the template to render
439457
data: The data to render the template with
458+
options: Additional options for the template.
440459
441460
Returns:
442461
str: The rendered template string
@@ -445,6 +464,8 @@ def render(self, name: str, data: dict[str, Any]) -> str:
445464
ValueError: If the template does not exist or there is a rendering
446465
error.
447466
"""
467+
# TODO: options is currently ignored; need to add support for it.
468+
448469
try:
449470
result = self._template.render(name, json.dumps(data))
450471
logger.debug({'event': 'template_rendered', 'name': name})
@@ -457,7 +478,7 @@ def render(self, name: str, data: dict[str, Any]) -> str:
457478
})
458479
raise
459480

460-
def render_template(self, template_string: str, data: dict[str, Any]) -> str:
481+
def render_template(self, template_string: str, data: dict[str, Any], options: RuntimeOptions | None = None) -> str:
461482
"""Render a template string directly without registering it.
462483
463484
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:
467488
Args:
468489
template_string: The template string to render
469490
data: The data to render the template with
491+
options: Additional options for the template.
470492
471493
Returns:
472494
Rendered template string.
@@ -475,8 +497,15 @@ def render_template(self, template_string: str, data: dict[str, Any]) -> str:
475497
ValueError: If there is a syntax error in the template or a
476498
rendering error.
477499
"""
500+
# TODO: options is currently ignored; need to add support for it.
478501
try:
479-
result = self._template.render_template(template_string, json.dumps(data))
502+
# Serialize options if provided, focusing on the '@data' part
503+
options_json = None
504+
if options:
505+
# Pass the whole options dict as JSON
506+
options_json = json.dumps(options)
507+
508+
result = self._template.render_template(template_string, json.dumps(data), options_json)
480509
logger.debug({'event': 'template_string_rendered'})
481510
return result
482511
except ValueError as e:
@@ -486,7 +515,7 @@ def render_template(self, template_string: str, data: dict[str, Any]) -> str:
486515
})
487516
raise
488517

489-
def compile(self, template_string: str) -> Callable[[dict[str, Any]], str]:
518+
def compile(self, template_string: str) -> CompiledRenderer:
490519
"""Compile a template string into a reusable function.
491520
492521
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]:
502531
template_string: The Handlebars template string to compile.
503532
504533
Returns:
505-
A callable function that takes a data dictionary and returns the
506-
rendered string.
534+
A callable function that takes a data dictionary and some runtime
535+
options and returns the rendered string.
507536
508537
Raises:
509538
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]:
512541
called.
513542
"""
514543

515-
def compiled(data: dict[str, Any]) -> str:
516-
return self.render_template(template_string, data)
544+
def compiled(context: Context, options: RuntimeOptions | None = None) -> str:
545+
"""Compiled template function.
546+
547+
Args:
548+
context: The data to render the template with.
549+
options: Additional options for the template.
550+
551+
Returns:
552+
The rendered template string.
553+
"""
554+
return self.render_template(template_string, context, options)
517555

518556
return compiled
519557

python/handlebarrz/src/handlebarrz/_native.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""Stub type annotations for native Handlebars."""
1818

1919
from collections.abc import Callable
20+
from typing import Any
2021

2122
def html_escape(text: str) -> str: ...
2223
def no_escape(text: str) -> str: ...
@@ -52,7 +53,7 @@ class HandlebarrzTemplate:
5253

5354
# Rendering.
5455
def render(self, name: str, data_json: str) -> str: ...
55-
def render_template(self, template_str: str, data_json: str) -> str: ...
56+
def render_template(self, template_str: str, data_json: str, options_json: str | None = None) -> str: ...
5657

5758
# Extra helper registration.
5859
def register_extra_helpers(self) -> None: ...

python/handlebarrz/src/lib.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,8 @@ impl HandlebarrzTemplate {
450450
/// # Arguments
451451
///
452452
/// * `template_string` - The template source code.
453-
/// * `data` - The data to use for rendering (as JSON).
453+
/// * `data_json` - The data to use for rendering (as JSON).
454+
/// * `options_json` - Optional. If provided, the data will be merged with this JSON object.
454455
///
455456
/// # Raises
456457
///
@@ -459,11 +460,28 @@ impl HandlebarrzTemplate {
459460
/// # Returns
460461
///
461462
/// Rendered template as a string.
462-
#[pyo3(text_signature = "($self, template_string, data)")]
463-
fn render_template(&self, template_string: &str, data: &str) -> PyResult<String> {
464-
let data: Value = serde_json::from_str(data)
463+
#[pyo3(text_signature = "($self, template_string, data_json, options_json = None)")]
464+
fn render_template(
465+
&self,
466+
template_string: &str,
467+
data_json: &str,
468+
_options_json: Option<&str>,
469+
) -> PyResult<String> {
470+
let data: Value = serde_json::from_str(data_json)
465471
.map_err(|e| PyValueError::new_err(format!("invalid JSON: {}", e)))?;
466472

473+
// TODO: Implement setting the data attribute of runtime options.
474+
// if let Some(options_str) = options_json {
475+
// let options_data: Value = serde_json::from_str(options_str)
476+
// .map_err(|e| PyValueError::new_err(format!("invalid options JSON: {}", e)))?;
477+
478+
// if let (Some(data_map), Some(_options_map)) =
479+
// (data.as_object_mut(), options_data.as_object())
480+
// {
481+
// data_map.insert("@data".to_string(), options_data.clone());
482+
// }
483+
// }
484+
467485
self.registry
468486
.render_template(template_string, &data)
469487
.map_err(|e| PyValueError::new_err(e.to_string()))

0 commit comments

Comments
 (0)