Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@

All notable changes to this project will be documented in this file.

## [0.3.7] - 2026-05-11
## [0.4.0] - 2026-05-22

### Added
- **Pydantic Auto-Detection Enhancement**: Improved Pydantic schema auto-detection to handle cases where the schema is imported within a Python file located in the `settings_files` directory. The system now automatically registers Pydantic classes found in these files, allowing them to be referenced directly by name in the markdown frontmatter (e.g., `response_schema: MyPydanticClass`).
- **Nested Prompt Support**: Prompts can now be included inside other prompts using the `{{prompt_name}}` syntax.
- **Recursion Protection**: Implemented a render stack to prevent circular references between prompts (e.g., A includes B, B includes A). Detected loops are replaced with a safe warning message instead of a `RecursionError`.
- **Enhanced Variable Registry**: The directory scanner now skips internal and hidden directories (like `.venv`, `.git`, `__pycache__`) by default to prevent self-loading or `ImportError`.

### Fixed
- **Auto-Render Locking**: Fixed a regression where enabling `auto_render` would "lock" the template and prevent subsequent overrides in `.render()` calls.
- **Synchronous Rendering Compatibility**: Fixed a bug where `enable_async=True` in Jinja2 was causing synchronous `render()` calls to return coroutines instead of text. Sync and Async rendering now utilize separate pre-compiled templates.
- **Auto-Render Lifecycle**: Refactored `auto_render` to use a "silent" internal render. This ensures variables and schemas are rendered during initialization without triggering lifecycle hooks twice.
- **Schema Context Injection**: Global schemas (from shared `.py` or `.json` files) are now automatically injected into all prompt contexts, including nested prompts.

## [0.3.6] - 2026-05-11

## [0.3.5] - 2026-05-11

### Fixed
- **Python Variable Stability**: Fixed a crash (`TypeError: cannot pickle 'module' object`) when loading Python files that contain standard imports (e.g. `import math`). Modules are now automatically excluded from the variable registry.

## [0.3.4] - 2026-05-11

Expand Down
37 changes: 37 additions & 0 deletions debug_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pathlib
import shutil

from dynaprompt import DynaPrompt


def test_debug_render():
prompts_dir = pathlib.Path("debug_prompts")
prompts_dir.mkdir(exist_ok=True)

with open(prompts_dir / "child.md", "w") as f:
f.write("Child content")

with open(prompts_dir / "parent.md", "w") as f:
f.write("---\nvar: 'hello'\n---\nParent: {{var}}, {{child}}")

try:
dp = DynaPrompt(settings_files=[str(prompts_dir)], auto_render=True)

print(f"Parent raw template: {dp.parent.raw_template!r}")
print(f"Parent rendered text (auto): {dp.parent.text!r}")

rendered = dp.parent.render()
print(f"Parent rendered text (explicit): {rendered.text!r}")

except Exception as e:
print(f"Error: {e}")
import traceback

traceback.print_exc()
finally:
if prompts_dir.exists():
shutil.rmtree(prompts_dir)


if __name__ == "__main__":
test_debug_render()
9 changes: 9 additions & 0 deletions dynaprompt/engine/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ def scan_directory(
seen_names: dict[str, pathlib.Path] = {}

for child in sorted(directory.rglob("*")):
# Skip hidden directories and common environment/cache folders
rel_parts = child.relative_to(directory).parts
if any(
part.startswith(".")
or part in ("__pycache__", "venv", "env", "dynaprompt")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The "dynaprompt" exclusion is overly broad and may silently skip legitimate prompt files stored in a subdirectory named dynaprompt. This is the library's own name, not a standard system/environment directory like __pycache__ or venv. Consider removing it from the exclusion list.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dynaprompt/engine/resolver.py, line 44:

<comment>The `"dynaprompt"` exclusion is overly broad and may silently skip legitimate prompt files stored in a subdirectory named `dynaprompt`. This is the library's own name, not a standard system/environment directory like `__pycache__` or `venv`. Consider removing it from the exclusion list.</comment>

<file context>
@@ -37,6 +37,15 @@ def scan_directory(
+            rel_parts = child.relative_to(directory).parts
+            if any(
+                part.startswith(".")
+                or part in ("__pycache__", "venv", "env", "dynaprompt")
+                for part in rel_parts
+            ):
</file context>

for part in rel_parts
):
continue

if not child.is_file() or child.name == "__init__.py":
continue
if child.suffix not in supported_suffixes:
Expand Down
2 changes: 2 additions & 0 deletions dynaprompt/engine/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ def get_node(self, name: str, context: dict[str, Any]) -> PromptNode:
hooks=context["hooks"],
current_env=context["current_env"],
auto_render=context["auto_render"],
store=self,
parent_context=context,
)

if self._cache_enabled:
Expand Down
208 changes: 154 additions & 54 deletions dynaprompt/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@

import datetime
import os
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any

import jinja2
from pydantic import BaseModel

from .hooking import hookable
from .hooking import async_hookable, hookable
from .secrets import SecretStore
from .validator import ValidatorList

_render_stack: ContextVar[frozenset[str]] = ContextVar(
"_render_stack", default=frozenset()
)


@dataclass
class SourceMetadata:
Expand Down Expand Up @@ -83,6 +88,32 @@ def __getattr__(self, name: str) -> Any:
raise AttributeError(f"'VariableDict' object has no attribute '{name}'")


class PromptsProxy:
"""Lazy loader for other prompts within a Jinja context."""

def __init__(self, store: Any, parent_context: dict[str, Any]):
self._store = store
self._parent_context = parent_context
self._cache: dict[str, PromptNode] = {}

def __getitem__(self, name: str) -> Any:
if name in self._cache:
return self._cache[name]

if not self._store or name not in self._store:
raise KeyError(name)

node = self._store.get_node(name, self._parent_context)
self._cache[name] = node
return node

def __getattr__(self, name: str) -> Any:
try:
return self[name]
except KeyError:
raise AttributeError(f"Prompt '{name}' not found in store.")


class PromptNode:
"""
Represents a single parsed prompt. Supports fluent config overrides and
Expand All @@ -102,6 +133,8 @@ def __init__(
hooks: dict[str, list] = None,
current_env: str = "default",
auto_render: bool = False,
store: Any = None,
parent_context: dict[str, Any] = None,
):
self.name = name
self.text = text
Expand All @@ -115,6 +148,8 @@ def __init__(
self._hooks = hooks or {}
self._current_env = current_env
self._auto_render = auto_render
self._store = store
self._parent_context = parent_context
self._overrides: dict[str, Any] = {}
self.bound_kwargs: dict[str, Any] = {}

Expand All @@ -137,19 +172,29 @@ def __init__(

self._setup_template()

def __str__(self) -> str:
"""Auto-render when used in a string context (e.g. nested prompts)."""
return self.render().text

def _setup_template(self) -> None:
"""Pre-compile Jinja2 template and handle auto-rendering."""
jinja_env = jinja2.Environment(undefined=jinja2.Undefined, enable_async=True)
jinja_env = jinja2.Environment(undefined=jinja2.Undefined)
jinja_env_async = jinja2.Environment(
undefined=jinja2.Undefined, enable_async=True
)
template_str = self.raw_template
if self._parent_template and "{{ super() }}" in template_str:
template_str = template_str.replace("{{ super() }}", self._parent_template)

self._compiled_template = jinja_env.from_string(template_str)
self._compiled_template_async = jinja_env_async.from_string(template_str)

if self._auto_render:
context = self._build_render_context()
jinja_env = jinja2.Environment(undefined=jinja2.DebugUndefined)
try:
self.text = jinja_env.from_string(self.raw_template).render(**context)
# Use internal render to benefit from recursion stack protection
# while avoiding triggering lifecycle hooks during initialization.
rendered = self._render_internal()
self.text = rendered.text
except Exception as exc:
import warnings

Expand Down Expand Up @@ -208,6 +253,31 @@ def flatten(d):
# 3. Render-time keyword arguments
inject_and_flatten(extra_kwargs)

# 4. Nested prompts support
if self._store:
# Provide explicit 'prompts' accessor
proxy = PromptsProxy(self._store, self._parent_context)
context["prompts"] = proxy

# Inject top-level names lazily via property-like behavior isn't easy
# in a dict, so we'll just inject them all. To avoid the RecursionError
# during auto_render, we rely on the _render_stack check in render().
for p_name in self._store.keys():
if p_name != self.name and p_name not in context:
# We inject the proxy indexer itself? No, Jinja needs the object.
# But if we access it now, it triggers render().
# However, render() is now protected!
try:
context[p_name] = proxy[p_name]
except (KeyError, AttributeError):
pass

# 5. Global schemas support
if self._parent_context and "schemas" in self._parent_context:
for s_name, s_obj in self._parent_context["schemas"].items():
if s_name not in context:
context[s_name] = s_obj

# Auto-inject JSON schema if a response_schema was resolved
if self.response_schema:
context["response_schema"] = self.schema_json
Expand Down Expand Up @@ -303,71 +373,99 @@ def render(self, *args, **kwargs) -> RenderedPrompt:
Render the prompt template with the provided variables.
Runs validators → Jinja2 → after_render hooks.
"""
for arg in args:
if isinstance(arg, dict):
kwargs.update(arg)
return self._render_internal(*args, **kwargs)

self.bound_kwargs.update(kwargs)
def _render_internal(self, *args, **kwargs) -> RenderedPrompt:
"""Core rendering logic with recursion protection but no hooks."""
stack = _render_stack.get()
if self.name in stack:
# Short-circuit recursion by returning a notice instead of failing
msg = f"[Recursive reference to '{self.name}' detected]"
return RenderedPrompt(text=msg, config={})

self._validators.validate(
self, self.bound_kwargs, current_env=self._current_env
)
token = _render_stack.set(stack | {self.name})
try:
for arg in args:
if isinstance(arg, dict):
kwargs.update(arg)

context = self._build_render_context(self.bound_kwargs)
self.bound_kwargs.update(kwargs)

try:
rendered_text = self._compiled_template.render(**context)
except Exception as exc:
raise RuntimeError(f"Failed to render prompt '{self.name}': {exc}") from exc

final_config = {**self.metadata, **self._overrides}
p_hash = self._compute_hash(rendered_text, final_config)

return RenderedPrompt(
text=rendered_text,
config=final_config,
response_schema=self.response_schema,
source_history=self._history,
prompt_hash=p_hash,
)
self._validators.validate(
self, self.bound_kwargs, current_env=self._current_env
)

context = self._build_render_context(self.bound_kwargs)

try:
rendered_text = self._compiled_template.render(**context)
except Exception as exc:
raise RuntimeError(
f"Failed to render prompt '{self.name}': {exc}"
) from exc

final_config = {**self.metadata, **self._overrides}
p_hash = self._compute_hash(rendered_text, final_config)

return RenderedPrompt(
text=rendered_text,
config=final_config,
response_schema=self.response_schema,
source_history=self._history,
prompt_hash=p_hash,
)
finally:
_render_stack.reset(token)

from .hooking import async_hookable

@async_hookable
async def async_render(self, *args, **kwargs) -> RenderedPrompt:
"""
Asynchronously render the prompt template (I/O non-blocking).
Runs validators → Jinja2 async → after_render hooks.
"""
for arg in args:
if isinstance(arg, dict):
kwargs.update(arg)
return await self._async_render_internal(*args, **kwargs)

self.bound_kwargs.update(kwargs)
async def _async_render_internal(self, *args, **kwargs) -> RenderedPrompt:
"""Core async rendering logic with recursion protection but no hooks."""
stack = _render_stack.get()
if self.name in stack:
msg = f"[Recursive reference to '{self.name}' detected]"
return RenderedPrompt(text=msg, config={})

self._validators.validate(
self, self.bound_kwargs, current_env=self._current_env
)
token = _render_stack.set(stack | {self.name})
try:
for arg in args:
if isinstance(arg, dict):
kwargs.update(arg)

context = self._build_render_context(self.bound_kwargs)
self.bound_kwargs.update(kwargs)

try:
rendered_text = await self._compiled_template.render_async(**context)
except Exception as exc:
raise RuntimeError(
f"Failed to async-render prompt '{self.name}': {exc}"
) from exc

final_config = {**self.metadata, **self._overrides}
p_hash = self._compute_hash(rendered_text, final_config)

return RenderedPrompt(
text=rendered_text,
config=final_config,
response_schema=self.response_schema,
source_history=self._history,
prompt_hash=p_hash,
)
self._validators.validate(
self, self.bound_kwargs, current_env=self._current_env
)

context = self._build_render_context(self.bound_kwargs)

try:
rendered_text = await self._compiled_template_async.render_async(**context)
except Exception as exc:
raise RuntimeError(
f"Failed to async-render prompt '{self.name}': {exc}"
) from exc

final_config = {**self.metadata, **self._overrides}
p_hash = self._compute_hash(rendered_text, final_config)

return RenderedPrompt(
text=rendered_text,
config=final_config,
response_schema=self.response_schema,
source_history=self._history,
prompt_hash=p_hash,
)
finally:
_render_stack.reset(token)

def rerender(self, **kwargs) -> RenderedPrompt:
"""
Expand All @@ -379,6 +477,8 @@ def rerender(self, **kwargs) -> RenderedPrompt:
async def async_rerender(self, **kwargs) -> RenderedPrompt:
"""Async alias for rerender()."""
return await self.async_render(**kwargs)

def call(self, **kwargs) -> Any:
"""
Render and (in the future) call an LLM provider directly.
"""
Expand Down
Loading
Loading