jinjatest 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jinjatest-0.1.0/.coveragerc +20 -0
- jinjatest-0.1.0/.github/workflows/ci.yml +29 -0
- jinjatest-0.1.0/.github/workflows/publish.yml +43 -0
- jinjatest-0.1.0/.gitignore +21 -0
- jinjatest-0.1.0/.python-version +1 -0
- jinjatest-0.1.0/LICENSE +21 -0
- jinjatest-0.1.0/Makefile +15 -0
- jinjatest-0.1.0/PKG-INFO +444 -0
- jinjatest-0.1.0/README.md +408 -0
- jinjatest-0.1.0/jinjatest/__init__.py +107 -0
- jinjatest-0.1.0/jinjatest/asserts.py +402 -0
- jinjatest-0.1.0/jinjatest/instrumentation.py +214 -0
- jinjatest-0.1.0/jinjatest/parsers/__init__.py +42 -0
- jinjatest-0.1.0/jinjatest/parsers/fenced_blocks.py +96 -0
- jinjatest-0.1.0/jinjatest/parsers/json_parser.py +35 -0
- jinjatest-0.1.0/jinjatest/parsers/markdown.py +104 -0
- jinjatest-0.1.0/jinjatest/parsers/xml_parser.py +169 -0
- jinjatest-0.1.0/jinjatest/parsers/yaml_parser.py +41 -0
- jinjatest-0.1.0/jinjatest/py.typed +0 -0
- jinjatest-0.1.0/jinjatest/pytest_plugin.py +242 -0
- jinjatest-0.1.0/jinjatest/rendered.py +321 -0
- jinjatest-0.1.0/jinjatest/spec.py +466 -0
- jinjatest-0.1.0/pyproject.toml +78 -0
- jinjatest-0.1.0/tests/__init__.py +1 -0
- jinjatest-0.1.0/tests/templates/anchored.j2 +19 -0
- jinjatest-0.1.0/tests/templates/markdown.j2 +17 -0
- jinjatest-0.1.0/tests/templates/welcome.j2 +9 -0
- jinjatest-0.1.0/tests/test_basic.py +1889 -0
- jinjatest-0.1.0/tests/test_coverage_boost.py +901 -0
- jinjatest-0.1.0/tests/test_fenced_blocks.py +315 -0
- jinjatest-0.1.0/tests/test_imports.py +238 -0
- jinjatest-0.1.0/tests/test_instrumentation.py +439 -0
- jinjatest-0.1.0/tests/test_macros.py +108 -0
- jinjatest-0.1.0/tests/test_markdown.py +171 -0
- jinjatest-0.1.0/tests/test_parsers.py +169 -0
- jinjatest-0.1.0/tests/test_pytest_plugin.py +175 -0
- jinjatest-0.1.0/tests/test_xml_parser.py +164 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[run]
|
|
2
|
+
source = jinjatest
|
|
3
|
+
branch = True
|
|
4
|
+
omit =
|
|
5
|
+
*/tests/*
|
|
6
|
+
*/__pycache__/*
|
|
7
|
+
*/__init__.py
|
|
8
|
+
|
|
9
|
+
[report]
|
|
10
|
+
exclude_lines =
|
|
11
|
+
pragma: no cover
|
|
12
|
+
def __repr__
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
if __name__ == .__main__.:
|
|
16
|
+
from __future__ import
|
|
17
|
+
@(abc\.)?abstractmethod
|
|
18
|
+
class \w+\(Exception\):
|
|
19
|
+
@dataclass
|
|
20
|
+
@overload
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, develop]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main, develop]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: astral-sh/setup-uv@v7
|
|
18
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
19
|
+
- run: uv sync --all-extras --dev
|
|
20
|
+
- run: uv run coverage run -m pytest -q && uv run coverage report --fail-under=90
|
|
21
|
+
|
|
22
|
+
lint:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v5
|
|
26
|
+
- uses: astral-sh/setup-uv@v7
|
|
27
|
+
- run: uv sync --all-extras --dev
|
|
28
|
+
- run: uv run ruff check .
|
|
29
|
+
- run: uv run ruff format --check .
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
|
+
- uses: astral-sh/setup-uv@v7
|
|
17
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
18
|
+
- run: uv sync --all-extras --dev
|
|
19
|
+
- run: uv run coverage run -m pytest -q && uv run coverage report --fail-under=90
|
|
20
|
+
|
|
21
|
+
lint:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v5
|
|
25
|
+
- uses: astral-sh/setup-uv@v7
|
|
26
|
+
- run: uv sync --all-extras --dev
|
|
27
|
+
- run: uv run ruff check .
|
|
28
|
+
- run: uv run ruff format --check .
|
|
29
|
+
|
|
30
|
+
publish:
|
|
31
|
+
needs: [test, lint]
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write
|
|
37
|
+
contents: read
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v5
|
|
40
|
+
- uses: astral-sh/setup-uv@v7
|
|
41
|
+
- run: uv python install 3.14
|
|
42
|
+
- run: uv build
|
|
43
|
+
- run: uv publish
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Development
|
|
13
|
+
.idea/
|
|
14
|
+
|
|
15
|
+
# Lock files
|
|
16
|
+
uv.lock
|
|
17
|
+
|
|
18
|
+
# Coverage
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
*,cover
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
jinjatest-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simplify Jobs Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jinjatest-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.PHONY: lint lint-fix test test-coverage
|
|
2
|
+
|
|
3
|
+
lint:
|
|
4
|
+
uv run ruff check .
|
|
5
|
+
uv run ruff format --check .
|
|
6
|
+
|
|
7
|
+
lint-fix:
|
|
8
|
+
uv run ruff check --fix .
|
|
9
|
+
uv run ruff format .
|
|
10
|
+
|
|
11
|
+
test:
|
|
12
|
+
uv run pytest
|
|
13
|
+
|
|
14
|
+
test-coverage:
|
|
15
|
+
uv run coverage run -m pytest -q && uv run coverage report --fail-under=90
|
jinjatest-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jinjatest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A type-safe, structured testing library for Jinja templates
|
|
5
|
+
Project-URL: Homepage, https://github.com/jinjatest/jinjatest
|
|
6
|
+
Project-URL: Documentation, https://github.com/jinjatest/jinjatest#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/jinjatest/jinjatest
|
|
8
|
+
Project-URL: Issues, https://github.com/jinjatest/jinjatest/issues
|
|
9
|
+
Author: Kevin Castro <hola@kev.pe>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: jinja,jinja2,json,markdown,pytest,templates,testing,xml,yaml
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: jinja2<4.0.0,>=3.1.3
|
|
27
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest~=9.0.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: pyyaml<7.0.0,>=6.0.1; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff~=0.14.13; extra == 'dev'
|
|
32
|
+
Requires-Dist: ty>=0.0.12; extra == 'dev'
|
|
33
|
+
Provides-Extra: yaml
|
|
34
|
+
Requires-Dist: pyyaml<7.0.0,>=6.0.1; extra == 'yaml'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# jinjatest
|
|
38
|
+
|
|
39
|
+
A type-safe, structured testing library for Jinja templates.
|
|
40
|
+
|
|
41
|
+
Stop writing brittle substring assertions. Test your templates with structure, validation, and confidence.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install jinjatest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
With YAML support:
|
|
50
|
+
```bash
|
|
51
|
+
pip install jinjatest[yaml]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Why jinjatest?
|
|
55
|
+
|
|
56
|
+
Testing Jinja templates typically means rendering and asserting on raw strings. This is brittle (whitespace, ordering, punctuation) and doesn't scale. **jinjatest** provides:
|
|
57
|
+
|
|
58
|
+
- **Type-safe context validation** with Pydantic
|
|
59
|
+
- **Structured output parsing** (JSON, YAML, XML, markdown, fenced code blocks)
|
|
60
|
+
- **Test instrumentation** with anchors and traces
|
|
61
|
+
- **Pytest integration** with fixtures and snapshots
|
|
62
|
+
- **StrictUndefined by default** - missing variables fail loudly
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
### Basic Usage
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pydantic import BaseModel
|
|
70
|
+
from jinjatest import TemplateSpec, PromptAsserts
|
|
71
|
+
|
|
72
|
+
class Ctx(BaseModel):
|
|
73
|
+
user_name: str
|
|
74
|
+
plan: str # "free" | "pro"
|
|
75
|
+
|
|
76
|
+
# Load template with context validation
|
|
77
|
+
spec = TemplateSpec.from_file("prompts/welcome.j2", context_model=Ctx)
|
|
78
|
+
|
|
79
|
+
def test_welcome_pro_user():
|
|
80
|
+
rendered = spec.render({"user_name": "Ada", "plan": "pro"})
|
|
81
|
+
|
|
82
|
+
a = PromptAsserts(rendered).normalized()
|
|
83
|
+
a.contains("Hello, Ada")
|
|
84
|
+
a.not_contains("Upgrade now") # pro users shouldn't see this
|
|
85
|
+
a.regex(r"Plan:\s*pro")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Structured Output (JSON)
|
|
89
|
+
|
|
90
|
+
The most robust way to test templates - assert on structure, not strings:
|
|
91
|
+
|
|
92
|
+
```jinja2
|
|
93
|
+
{# prompts/config.j2 #}
|
|
94
|
+
{% set config = {
|
|
95
|
+
"model": model_name,
|
|
96
|
+
"temperature": temperature,
|
|
97
|
+
"features": features | default([])
|
|
98
|
+
} %}
|
|
99
|
+
{{ config | tojson }}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
def test_config_output():
|
|
104
|
+
rendered = spec.render({
|
|
105
|
+
"model_name": "gpt-4",
|
|
106
|
+
"temperature": 0.7,
|
|
107
|
+
"features": ["streaming", "tools"]
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
config = rendered.as_json()
|
|
111
|
+
assert config["model"] == "gpt-4"
|
|
112
|
+
assert config["temperature"] == 0.7
|
|
113
|
+
assert "streaming" in config["features"]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Structured Output (XML)
|
|
117
|
+
|
|
118
|
+
Parse XML output, including fragments with multiple root elements:
|
|
119
|
+
|
|
120
|
+
```jinja2
|
|
121
|
+
{# prompts/tool_calls.j2 #}
|
|
122
|
+
<tool name="search">
|
|
123
|
+
<query>{{ query }}</query>
|
|
124
|
+
</tool>
|
|
125
|
+
{% if include_filter %}
|
|
126
|
+
<tool name="filter">
|
|
127
|
+
<criteria>{{ filter_criteria }}</criteria>
|
|
128
|
+
</tool>
|
|
129
|
+
{% endif %}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
def test_xml_tool_calls():
|
|
134
|
+
rendered = spec.render({
|
|
135
|
+
"query": "python tutorials",
|
|
136
|
+
"include_filter": True,
|
|
137
|
+
"filter_criteria": "beginner"
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
# Parse as fragments (multiple roots allowed)
|
|
141
|
+
tools = rendered.as_xml() # Returns list[XMLElement]
|
|
142
|
+
assert len(tools) == 2
|
|
143
|
+
assert tools[0].attrib["name"] == "search"
|
|
144
|
+
assert tools[0].find("query").text == "python tutorials"
|
|
145
|
+
|
|
146
|
+
# For single-root XML, use strict=True
|
|
147
|
+
# rendered.as_xml(strict=True) # Returns XMLElement or raises
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Fenced Code Blocks
|
|
151
|
+
|
|
152
|
+
Extract and parse code blocks from markdown-style output:
|
|
153
|
+
|
|
154
|
+
```jinja2
|
|
155
|
+
{# prompts/assistant.j2 #}
|
|
156
|
+
Here's the configuration:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{"setting": "{{ setting_name }}", "value": {{ setting_value }}}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
And here's an alternative:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{"setting": "{{ setting_name }}", "value": {{ setting_value * 2 }}}
|
|
166
|
+
```
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
def test_fenced_json_blocks():
|
|
171
|
+
rendered = spec.render({"setting_name": "timeout", "setting_value": 30})
|
|
172
|
+
|
|
173
|
+
# Extract all ```json blocks
|
|
174
|
+
configs = rendered.as_json_blocks()
|
|
175
|
+
assert len(configs) == 2
|
|
176
|
+
assert configs[0]["value"] == 30
|
|
177
|
+
assert configs[1]["value"] == 60
|
|
178
|
+
|
|
179
|
+
# Also available:
|
|
180
|
+
# rendered.as_yaml_blocks() # Extract ```yaml blocks
|
|
181
|
+
# rendered.as_xml_blocks() # Extract ```xml blocks
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Section Testing with Anchors
|
|
185
|
+
|
|
186
|
+
Test specific sections without fragile delimiters:
|
|
187
|
+
|
|
188
|
+
```jinja2
|
|
189
|
+
{# prompts/chat.j2 #}
|
|
190
|
+
{{ jt.anchor("system") }}
|
|
191
|
+
System rules:
|
|
192
|
+
- Be helpful
|
|
193
|
+
- Be concise
|
|
194
|
+
|
|
195
|
+
{{ jt.anchor("user") }}
|
|
196
|
+
User: {{ user_name }}
|
|
197
|
+
Request: {{ request }}
|
|
198
|
+
|
|
199
|
+
{{ jt.anchor("context") }}
|
|
200
|
+
{% if context_items %}
|
|
201
|
+
Context:
|
|
202
|
+
{% for item in context_items %}
|
|
203
|
+
- {{ item }}
|
|
204
|
+
{% endfor %}
|
|
205
|
+
{% else %}
|
|
206
|
+
{{ jt.trace("no_context") }}
|
|
207
|
+
No additional context.
|
|
208
|
+
{% endif %}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
def test_sections():
|
|
213
|
+
rendered = spec.render({
|
|
214
|
+
"user_name": "Ada",
|
|
215
|
+
"request": "Help me code",
|
|
216
|
+
"context_items": ["doc1", "doc2"],
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
# Test sections in isolation
|
|
220
|
+
assert rendered.section("user").contains("Ada")
|
|
221
|
+
assert rendered.section("system").not_contains("Ada")
|
|
222
|
+
assert rendered.section("context").contains("doc1")
|
|
223
|
+
|
|
224
|
+
def test_branch_coverage():
|
|
225
|
+
rendered = spec.render({
|
|
226
|
+
"user_name": "Ada",
|
|
227
|
+
"request": "Help",
|
|
228
|
+
"context_items": [], # Empty - triggers no_context branch
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
# Verify which branches were taken
|
|
232
|
+
assert rendered.has_trace("no_context")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Macros as Functions
|
|
236
|
+
|
|
237
|
+
Test macros like regular Python functions:
|
|
238
|
+
|
|
239
|
+
```jinja2
|
|
240
|
+
{% macro build_prompt(user_input, context=None) %}
|
|
241
|
+
{% set parts = [] %}
|
|
242
|
+
{% do parts.append("You are a helpful assistant.") %}
|
|
243
|
+
{% do parts.append("User: " ~ user_input) %}
|
|
244
|
+
{% if context %}
|
|
245
|
+
{% do parts.append("Context: " ~ context) %}
|
|
246
|
+
{% endif %}
|
|
247
|
+
{{ parts | join("\n") }}
|
|
248
|
+
{% endmacro %}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
def test_prompt_builder():
|
|
253
|
+
build_prompt = spec.macro("build_prompt")
|
|
254
|
+
|
|
255
|
+
result = build_prompt("Hello")
|
|
256
|
+
assert "User: Hello" in result
|
|
257
|
+
assert "Context:" not in result
|
|
258
|
+
|
|
259
|
+
result = build_prompt("Hello", context="Background info")
|
|
260
|
+
assert "Context: Background info" in result
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## API Reference
|
|
264
|
+
|
|
265
|
+
### TemplateSpec
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
# From file
|
|
269
|
+
spec = TemplateSpec.from_file(
|
|
270
|
+
"template.j2",
|
|
271
|
+
context_model=MyModel, # Optional Pydantic model
|
|
272
|
+
template_dir="templates/", # Optional base directory
|
|
273
|
+
strict_undefined=True, # Default: True
|
|
274
|
+
test_mode=True, # Enable instrumentation
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# From string
|
|
278
|
+
spec = TemplateSpec.from_string(
|
|
279
|
+
"Hello {{ name }}!",
|
|
280
|
+
context_model=MyModel,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Render
|
|
284
|
+
rendered = spec.render({"name": "World"})
|
|
285
|
+
rendered = spec.render(MyModel(name="World"))
|
|
286
|
+
|
|
287
|
+
# Access macro
|
|
288
|
+
my_macro = spec.macro("macro_name")
|
|
289
|
+
result = my_macro("arg1", "arg2")
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### RenderedPrompt
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
rendered.text # Raw rendered text
|
|
296
|
+
rendered.normalized # Whitespace-normalized text
|
|
297
|
+
rendered.clean_text # Text with anchor markers removed
|
|
298
|
+
rendered.lines # List of lines
|
|
299
|
+
rendered.normalized_lines # List of normalized lines
|
|
300
|
+
|
|
301
|
+
# Parsing - Full Document
|
|
302
|
+
rendered.as_json() # Parse as JSON
|
|
303
|
+
rendered.as_yaml() # Parse as YAML (requires pyyaml)
|
|
304
|
+
rendered.as_xml(strict=False) # Parse as XML (strict=True for single root)
|
|
305
|
+
rendered.as_markdown_sections() # Parse markdown headings
|
|
306
|
+
rendered.markdown_section("title") # Find markdown section by title
|
|
307
|
+
|
|
308
|
+
# Parsing - Fenced Code Blocks
|
|
309
|
+
rendered.as_json_blocks() # Extract all ```json blocks
|
|
310
|
+
rendered.as_yaml_blocks() # Extract all ```yaml blocks
|
|
311
|
+
rendered.as_xml_blocks() # Extract all ```xml blocks
|
|
312
|
+
|
|
313
|
+
# Sections (with instrumentation)
|
|
314
|
+
rendered.section("name") # Get section by anchor name
|
|
315
|
+
rendered.sections() # Get all sections
|
|
316
|
+
rendered.has_section("name") # Check if section exists
|
|
317
|
+
|
|
318
|
+
# Traces
|
|
319
|
+
rendered.has_trace("event") # Check if trace was recorded
|
|
320
|
+
rendered.trace_count("event") # Count occurrences of trace event
|
|
321
|
+
rendered.trace_events # List of all trace events
|
|
322
|
+
|
|
323
|
+
# Query helpers
|
|
324
|
+
rendered.contains("text") # Check substring exists
|
|
325
|
+
rendered.not_contains("text") # Check substring doesn't exist
|
|
326
|
+
rendered.contains_line("text") # Check if any line contains text
|
|
327
|
+
rendered.has_line("exact line") # Check if exact line exists
|
|
328
|
+
rendered.matches(r"pattern") # Check regex matches
|
|
329
|
+
rendered.find_all(r"pattern") # Find all regex matches
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### PromptAsserts
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
a = PromptAsserts(rendered)
|
|
336
|
+
a = PromptAsserts(rendered).normalized() # Use normalized text
|
|
337
|
+
|
|
338
|
+
# Chainable assertions
|
|
339
|
+
a.contains("text")
|
|
340
|
+
a.not_contains("text")
|
|
341
|
+
a.contains_line("partial line match")
|
|
342
|
+
a.has_exact_line("exact line")
|
|
343
|
+
a.regex(r"pattern")
|
|
344
|
+
a.not_regex(r"pattern")
|
|
345
|
+
a.equals("exact text")
|
|
346
|
+
a.equals_json({"key": "value"})
|
|
347
|
+
a.line_count(5)
|
|
348
|
+
a.line_count_between(3, 10)
|
|
349
|
+
|
|
350
|
+
# Trace assertions
|
|
351
|
+
a.has_trace("event")
|
|
352
|
+
a.not_has_trace("event")
|
|
353
|
+
|
|
354
|
+
# Snapshots
|
|
355
|
+
a.snapshot("snapshot_name", update=False)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Instrumentation
|
|
359
|
+
|
|
360
|
+
In templates:
|
|
361
|
+
```jinja2
|
|
362
|
+
{{ jt.anchor("section_name") }} {# Mark section start #}
|
|
363
|
+
{{ jt.trace("event_name") }} {# Record trace event #}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
#### Using with Any Jinja Environment
|
|
367
|
+
|
|
368
|
+
You can add instrumentation to any Jinja environment using `instrument()`:
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
from jinja2 import Environment, FileSystemLoader
|
|
372
|
+
from jinjatest import instrument
|
|
373
|
+
|
|
374
|
+
# Patch any existing Jinja environment
|
|
375
|
+
env = Environment(loader=FileSystemLoader("templates/"))
|
|
376
|
+
inst = instrument(env) # Adds `jt` global
|
|
377
|
+
|
|
378
|
+
# Now templates can use {{ jt.anchor("x") }} and {{ jt.trace("y") }}
|
|
379
|
+
template = env.get_template("my_template.j2")
|
|
380
|
+
result = template.render({"name": "World"})
|
|
381
|
+
|
|
382
|
+
# Check traces after rendering
|
|
383
|
+
if inst.has_trace("some_event"):
|
|
384
|
+
print("Event was triggered")
|
|
385
|
+
|
|
386
|
+
# For production, use test_mode=False (anchors/traces become no-ops)
|
|
387
|
+
instrument(env, test_mode=False)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
This is useful when you want to add instrumentation to an existing Jinja setup without using `TemplateSpec`.
|
|
391
|
+
|
|
392
|
+
## Pytest Integration
|
|
393
|
+
|
|
394
|
+
jinjatest provides pytest fixtures automatically:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
def test_with_fixtures(template_from_string, jinja_env):
|
|
398
|
+
spec = template_from_string("Hello {{ name }}!")
|
|
399
|
+
rendered = spec.render({"name": "World"})
|
|
400
|
+
assert rendered.text == "Hello World!"
|
|
401
|
+
|
|
402
|
+
def test_with_snapshots(snapshot_manager, template_from_string):
|
|
403
|
+
spec = template_from_string("Hello {{ name }}!")
|
|
404
|
+
rendered = spec.render({"name": "World"})
|
|
405
|
+
snapshot_manager.compare_or_update("greeting", rendered.text)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Update snapshots:
|
|
409
|
+
```bash
|
|
410
|
+
pytest --update-snapshots
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Advanced Configuration
|
|
414
|
+
|
|
415
|
+
### Custom Environment
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
from jinjatest import create_environment, TemplateSpec
|
|
419
|
+
|
|
420
|
+
env = create_environment(
|
|
421
|
+
template_paths=["templates/", "shared/"],
|
|
422
|
+
mock_templates={"header.j2": "Mock Header"},
|
|
423
|
+
strict_undefined=True,
|
|
424
|
+
enable_do_extension=True,
|
|
425
|
+
sandboxed=False,
|
|
426
|
+
filters={"my_filter": lambda x: x.upper()},
|
|
427
|
+
globals={"version": "1.0"},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
spec = TemplateSpec.from_file("template.j2", env=env)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Variable Validation (CI Guardrails)
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
spec = TemplateSpec.from_file("template.j2")
|
|
437
|
+
|
|
438
|
+
# Fail if template uses unexpected variables
|
|
439
|
+
spec.assert_variables_subset_of({"user_name", "plan", "items"})
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## License
|
|
443
|
+
|
|
444
|
+
MIT
|