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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 24 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,23 +151,23 @@ Test specific sections without fragile delimiters:

```jinja2
{# prompts/chat.j2 #}
{{ jt.anchor("system") }}
{#jt:anchor:system#}
System rules:
- Be helpful
- Be concise

{{ jt.anchor("user") }}
{#jt:anchor:user#}
User: {{ user_name }}
Request: {{ request }}

{{ jt.anchor("context") }}
{#jt:anchor:context#}
{% if context_items %}
Context:
{% for item in context_items %}
- {{ item }}
{% endfor %}
{% else %}
{{ jt.trace("no_context") }}
{#jt:trace:no_context#}
No additional context.
{% endif %}
```
Expand Down Expand Up @@ -232,16 +232,18 @@ def test_prompt_builder():
# From file
spec = TemplateSpec.from_file(
"template.j2",
context_model=MyModel, # Optional Pydantic model
template_dir="templates/", # Optional base directory
strict_undefined=True, # Default: True
test_mode=True, # Enable instrumentation
context_model=MyModel, # Optional Pydantic model
template_dir="templates/", # Optional base directory
strict_undefined=True, # Default: True
test_mode=True, # Enable instrumentation
use_comment_markers=True, # Transform {#jt:...#} comments (default: True)
)

# From string
spec = TemplateSpec.from_string(
"Hello {{ name }}!",
context_model=MyModel,
use_comment_markers=True, # Transform {#jt:...#} comments (default: True)
)

# Render
Expand Down Expand Up @@ -321,37 +323,40 @@ a.snapshot("snapshot_name", update=False)

### Instrumentation

In templates:
In templates, use comment-based markers to define sections and trace events:

```jinja2
{{ jt.anchor("section_name") }} {# Mark section start #}
{{ jt.trace("event_name") }} {# Record trace event #}
{#jt:anchor:section_name#} {# Mark section start #}
{#jt:trace:event_name#} {# Record trace event #}
```

Comment markers are automatically transformed when `test_mode=True`. This allows jinjatest to be a dev-only dependency since the comments are valid Jinja syntax that render as empty strings in production.

#### Using with Any Jinja Environment

You can add instrumentation to any Jinja environment using `instrument()`:

```python
from jinja2 import Environment, FileSystemLoader
from jinjatest import instrument
from jinjatest import TemplateSpec, instrument

# Patch any existing Jinja environment
env = Environment(loader=FileSystemLoader("templates/"))
inst = instrument(env) # Adds `jt` global
instrument(env) # Adds `jt` global

# Now templates can use {{ jt.anchor("x") }} and {{ jt.trace("y") }}
template = env.get_template("my_template.j2")
result = template.render({"name": "World"})
# Load template with comment markers transformed
spec = TemplateSpec.from_file("my_template.j2", env=env)
rendered = spec.render({"name": "World"})

# Check traces after rendering
if inst.has_trace("some_event"):
if rendered.has_trace("some_event"):
print("Event was triggered")

# For production, use test_mode=False (anchors/traces become no-ops)
# For production, use test_mode=False (markers become no-ops)
instrument(env, test_mode=False)
```

This is useful when you want to add instrumentation to an existing Jinja setup without using `TemplateSpec`.
This is useful when you want to add instrumentation to an existing Jinja setup.

## Pytest Integration

Expand Down
15 changes: 15 additions & 0 deletions jinjatest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ def test_welcome_pro_user():
create_instrumentation,
instrument,
)
from jinjatest.markers import (
MarkerTransform,
TemplateMarkers,
discover_markers,
has_markers,
load_template_with_markers,
transform_markers,
)
from jinjatest.parsers import (
FencedBlock,
JSONParseError,
Expand Down Expand Up @@ -94,6 +102,13 @@ def test_welcome_pro_user():
"ProductionInstrumentation",
"TraceRecorder",
"AnchorIndex",
# Markers (comment-based)
"transform_markers",
"has_markers",
"discover_markers",
"load_template_with_markers",
"MarkerTransform",
"TemplateMarkers",
# Utilities
"normalize_text",
]
Expand Down
179 changes: 179 additions & 0 deletions jinjatest/markers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""
Comment-based markers for Jinja templates.

This module provides functionality to transform Jinja comment markers into
instrumentation function calls, enabling jinjatest to be a dev-only dependency.

Marker syntax:
{#jt:anchor:<name>#} - Section anchor marker
{#jt:trace:<name>#} - Trace event marker

Where <name> must be a valid Python identifier: [a-zA-Z_][a-zA-Z0-9_]*
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, NamedTuple

if TYPE_CHECKING:
from jinja2 import Environment, Template

from jinjatest.instrumentation import TestInstrumentation

# Regex patterns for comment markers
# Name must be a valid Python identifier
ANCHOR_PATTERN = re.compile(r"\{#jt:anchor:([a-zA-Z_][a-zA-Z0-9_]*)#\}")
TRACE_PATTERN = re.compile(r"\{#jt:trace:([a-zA-Z_][a-zA-Z0-9_]*)#\}")


class MarkerTransform(NamedTuple):
"""Result of transforming a template source."""

source: str
anchor_names: list[str]
trace_names: list[str]


@dataclass
class TemplateMarkers:
"""Information about markers in a template."""

anchors: list[str]
traces: list[str]

@property
def has_markers(self) -> bool:
"""Check if any markers were found."""
return bool(self.anchors or self.traces)


def transform_markers(source: str) -> MarkerTransform:
"""
Transform comment-based markers into jt function calls.

Args:
source: Raw template source with comment markers

Returns:
MarkerTransform with transformed source and discovered marker names

Example:
>>> result = transform_markers("{#jt:anchor:intro#}\\nHello")
>>> '{{ jt.anchor("intro") }}' in result.source
True
>>> result.anchor_names
['intro']
"""
anchor_names: list[str] = []
trace_names: list[str] = []

def replace_anchor(match: re.Match[str]) -> str:
name = match.group(1)
anchor_names.append(name)
return '{{ jt.anchor("' + name + '") }}'

def replace_trace(match: re.Match[str]) -> str:
name = match.group(1)
trace_names.append(name)
return '{{ jt.trace("' + name + '") }}'

transformed = ANCHOR_PATTERN.sub(replace_anchor, source)
transformed = TRACE_PATTERN.sub(replace_trace, transformed)

return MarkerTransform(
source=transformed,
anchor_names=anchor_names,
trace_names=trace_names,
)


def has_markers(source: str) -> bool:
"""
Check if source contains any jt comment markers.

Args:
source: Template source to check

Returns:
True if any markers are found, False otherwise

Example:
>>> has_markers("{#jt:anchor:x#}")
True
>>> has_markers("regular {# comment #}")
False
"""
return bool(ANCHOR_PATTERN.search(source) or TRACE_PATTERN.search(source))


def discover_markers(source: str) -> TemplateMarkers:
"""
Discover all jt markers in a template without transforming.

Useful for:
- Validating marker names
- Generating documentation
- CI checks for marker consistency

Args:
source: Template source to scan

Returns:
TemplateMarkers with lists of anchor and trace names

Example:
>>> markers = discover_markers("{#jt:anchor:system#}\\n{#jt:trace:debug#}")
>>> markers.anchors
['system']
>>> markers.traces
['debug']
"""
anchors = ANCHOR_PATTERN.findall(source)
traces = TRACE_PATTERN.findall(source)
return TemplateMarkers(anchors=anchors, traces=traces)


def load_template_with_markers(
env: Environment,
template_path: str,
instrumentation: TestInstrumentation | None = None,
) -> Template:
"""
Load a template from an environment, transforming comment markers.

This allows using comment markers with any Jinja environment.

Args:
env: The Jinja environment (should already have jt global if instrumentation needed)
template_path: Path to template (relative to env's loader)
instrumentation: Optional instrumentation instance for trace capture

Returns:
Compiled Template with markers transformed

Raises:
ValueError: If the environment has no loader configured

Example:
from jinja2 import Environment, FileSystemLoader
from jinjatest import instrument
from jinjatest.markers import load_template_with_markers

env = Environment(loader=FileSystemLoader("templates/"))
inst = instrument(env)
template = load_template_with_markers(env, "my_prompt.j2", inst)
result = template.render({"name": "World"})
"""
if env.loader is None:
raise ValueError("Environment must have a loader configured")

# Get the raw source from the environment's loader
source, _, _ = env.loader.get_source(env, template_path)

# Transform markers
transform_result = transform_markers(source)

# Compile from transformed source
return env.from_string(transform_result.source)
Loading