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.
Files changed (37) hide show
  1. jinjatest-0.1.0/.coveragerc +20 -0
  2. jinjatest-0.1.0/.github/workflows/ci.yml +29 -0
  3. jinjatest-0.1.0/.github/workflows/publish.yml +43 -0
  4. jinjatest-0.1.0/.gitignore +21 -0
  5. jinjatest-0.1.0/.python-version +1 -0
  6. jinjatest-0.1.0/LICENSE +21 -0
  7. jinjatest-0.1.0/Makefile +15 -0
  8. jinjatest-0.1.0/PKG-INFO +444 -0
  9. jinjatest-0.1.0/README.md +408 -0
  10. jinjatest-0.1.0/jinjatest/__init__.py +107 -0
  11. jinjatest-0.1.0/jinjatest/asserts.py +402 -0
  12. jinjatest-0.1.0/jinjatest/instrumentation.py +214 -0
  13. jinjatest-0.1.0/jinjatest/parsers/__init__.py +42 -0
  14. jinjatest-0.1.0/jinjatest/parsers/fenced_blocks.py +96 -0
  15. jinjatest-0.1.0/jinjatest/parsers/json_parser.py +35 -0
  16. jinjatest-0.1.0/jinjatest/parsers/markdown.py +104 -0
  17. jinjatest-0.1.0/jinjatest/parsers/xml_parser.py +169 -0
  18. jinjatest-0.1.0/jinjatest/parsers/yaml_parser.py +41 -0
  19. jinjatest-0.1.0/jinjatest/py.typed +0 -0
  20. jinjatest-0.1.0/jinjatest/pytest_plugin.py +242 -0
  21. jinjatest-0.1.0/jinjatest/rendered.py +321 -0
  22. jinjatest-0.1.0/jinjatest/spec.py +466 -0
  23. jinjatest-0.1.0/pyproject.toml +78 -0
  24. jinjatest-0.1.0/tests/__init__.py +1 -0
  25. jinjatest-0.1.0/tests/templates/anchored.j2 +19 -0
  26. jinjatest-0.1.0/tests/templates/markdown.j2 +17 -0
  27. jinjatest-0.1.0/tests/templates/welcome.j2 +9 -0
  28. jinjatest-0.1.0/tests/test_basic.py +1889 -0
  29. jinjatest-0.1.0/tests/test_coverage_boost.py +901 -0
  30. jinjatest-0.1.0/tests/test_fenced_blocks.py +315 -0
  31. jinjatest-0.1.0/tests/test_imports.py +238 -0
  32. jinjatest-0.1.0/tests/test_instrumentation.py +439 -0
  33. jinjatest-0.1.0/tests/test_macros.py +108 -0
  34. jinjatest-0.1.0/tests/test_markdown.py +171 -0
  35. jinjatest-0.1.0/tests/test_parsers.py +169 -0
  36. jinjatest-0.1.0/tests/test_pytest_plugin.py +175 -0
  37. 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
@@ -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.
@@ -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
@@ -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