pax_global_header00006660000000000000000000000064152047204110014506gustar00rootroot0000000000000052 comment=9812951da4fcb56d6beb1c04512e77fe392ac2e4 pydantic-handlebars-0.2.1/000077500000000000000000000000001520472041100154225ustar00rootroot00000000000000pydantic-handlebars-0.2.1/.github/000077500000000000000000000000001520472041100167625ustar00rootroot00000000000000pydantic-handlebars-0.2.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001520472041100211455ustar00rootroot00000000000000pydantic-handlebars-0.2.1/.github/ISSUE_TEMPLATE/bug.yaml000066400000000000000000000015321520472041100226070ustar00rootroot00000000000000name: Bug Report description: Report a bug or unexpected behavior labels: [bug] body: - type: textarea id: description attributes: label: Description description: | Please explain what you're seeing and what you would expect to see. validations: required: true - type: textarea id: example attributes: label: Example Code description: > If possible, add a minimal, [reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) that demonstrates the bug. render: Python - type: textarea id: version attributes: label: Version Info description: | Which versions of Python and pydantic-handlebars are you using? value: | - Python: - pydantic-handlebars: validations: required: true pydantic-handlebars-0.2.1/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000000331520472041100231310ustar00rootroot00000000000000blank_issues_enabled: true pydantic-handlebars-0.2.1/.github/ISSUE_TEMPLATE/feature-request.yaml000066400000000000000000000005461520472041100251570ustar00rootroot00000000000000name: Feature Request description: Suggest a new feature labels: [feature] body: - type: textarea id: description attributes: label: Description description: | Please describe the feature you'd like to see. Demo code, use cases, and references to similar projects are all helpful. validations: required: true pydantic-handlebars-0.2.1/.github/pull_request_template.md000066400000000000000000000003111520472041100237160ustar00rootroot00000000000000- Closes # ### Checklist - [ ] **Linting and type checking** pass (`make lint` and `make typecheck`) - [ ] **Tests** maintain 100% coverage - [ ] **PR title** is a clear summary of the change pydantic-handlebars-0.2.1/.github/workflows/000077500000000000000000000000001520472041100210175ustar00rootroot00000000000000pydantic-handlebars-0.2.1/.github/workflows/main.yml000066400000000000000000000032421520472041100224670ustar00rootroot00000000000000name: CI on: push: branches: - main tags: - '**' pull_request: {} jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --frozen --all-groups - run: make lint - run: make typecheck test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - run: uv sync --frozen --all-groups # 1. Test with oldest compatible pydantic - run: uv pip install --resolution lowest --only-binary pydantic-core 'pydantic>=2.0' - run: uv pip freeze - run: uv run --no-sync pytest # 2. Test with latest pydantic + coverage (uv sync restores lockfile versions) - run: uv sync --frozen --all-groups - run: uv pip freeze - run: make testcov check: if: always() needs: [lint, test] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} release: needs: [check] if: success() && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - uses: samuelcolvin/check-python-version@v4.1 id: check-python-version with: version_file_path: pyproject.toml - run: uv build - uses: pypa/gh-action-pypi-publish@release/v1 pydantic-handlebars-0.2.1/.gitignore000066400000000000000000000002741520472041100174150ustar00rootroot00000000000000# IDE .idea/ # Python-generated files __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info # Virtual environments .venv # MkDocs site/ # Coverage .coverage # Hypothesis .hypothesis/ pydantic-handlebars-0.2.1/.pre-commit-config.yaml000066400000000000000000000014751520472041100217120ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: no-commit-to-branch # prevent direct commits to main branch - id: check-yaml - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace exclude: '^docs/.*\.md$' - repo: local hooks: - id: lint name: Ruff Lint entry: make args: [lint] types: [python] language: system pass_filenames: false - id: format name: Ruff Format entry: make args: [format] types: [python] language: system pass_filenames: false - id: typecheck name: Pyright entry: make args: [typecheck] types: [python] language: system pass_filenames: false pydantic-handlebars-0.2.1/.python-version000066400000000000000000000000051520472041100204220ustar00rootroot000000000000003.13 pydantic-handlebars-0.2.1/CLAUDE.md000066400000000000000000000030701520472041100167010ustar00rootroot00000000000000# pydantic-handlebars Handlebars template engine for composing LLM prompts, built on Pydantic. ## Quick Reference - `make all` — format, lint, typecheck, test with coverage - `make test` — run tests (parallel with pytest-xdist) - `make lint` — ruff format check + ruff check - `make typecheck` — pyright strict mode ## Code Rules - Only runtime dependency is pydantic (2.0+) - 100% test coverage required - All implementation modules are private (`_tokenizer.py`, etc.) - Public API is exported from `__init__.py` - Tests are ported from the Handlebars.js spec suite — maintain 1:1 correspondence - Single quotes for code, double quotes for docstrings - Google-style docstrings - Python 3.10+ (use `X | Y` union syntax in annotations, but `isinstance(x, (X, Y))` in runtime checks) - No `except Exception` — catch specific types - No imports inside functions ## Architecture 1. Tokenizer (`_tokenizer.py`) → Token stream 2. Parser (`_parser.py`) → AST (`_ast_nodes.py`) 3. Compiler (`_compiler.py`) → Output string ## Security Templates may be untrusted. All context data is serialized to JSON-safe types via `pydantic_core.to_jsonable_python` before rendering — the template engine only sees dicts, lists, strings, numbers, bools, and None. Enforce depth limits for nesting. ## Testing Tests are ported from https://github.com/handlebars-lang/handlebars.js/tree/master/spec. When adding new features, find the corresponding JS spec tests and port them. Use `assert render(template, context) == expected` pattern. No test classes — standalone functions only. pydantic-handlebars-0.2.1/LICENSE000066400000000000000000000021011520472041100164210ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2026 Pydantic Services Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pydantic-handlebars-0.2.1/Makefile000066400000000000000000000012411520472041100170600ustar00rootroot00000000000000.PHONY: .uv .prek install format lint typecheck test testcov fuzz all .uv: @uv --version || echo 'Please install uv: https://docs.astral.sh/uv/getting-started/installation/' .prek: @prek --version || echo 'Please install prek: https://github.com/j178/pre-commit-rs' install: .uv .prek uv sync --frozen --all-groups prek install --install-hooks format: uv run ruff format uv run ruff check --fix lint: uv run ruff format --check uv run ruff check typecheck: uv run pyright test: uv run pytest testcov: uv run coverage run -m pytest uv run coverage report fuzz: FUZZ_EXTENDED=1 uv run pytest tests/test_fuzz.py -x -s all: format lint typecheck testcov pydantic-handlebars-0.2.1/README.md000066400000000000000000000627131520472041100167120ustar00rootroot00000000000000# pydantic-handlebars [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) A Handlebars template engine for composing LLM prompts, built on Pydantic. Designed for AI/agent developers who build system prompts from dynamic data — few-shot examples, user-specific context, tool descriptions, and other content that shouldn't be hardcoded. Templates are validated against Pydantic models at compile time, catching typos and missing fields before any data is rendered. The only runtime dependency is Pydantic (2.0+). ## Installation ```bash pip install pydantic-handlebars ``` Requires Python 3.10+ and Pydantic 2.0+. ## Quick Start ```python from pydantic_handlebars import render # Simple variable interpolation print(render('Hello {{name}}!', {'name': 'World'})) #> Hello World! # Dot-notation paths print(render('{{person.name}}', {'person': {'name': 'Alice'}})) #> Alice # Block helpers print(render('{{#each items}}{{this}}{{#unless @last}} {{/unless}}{{/each}}', {'items': ['a', 'b', 'c']})) #> a b c # Conditionals print(render('{{#if show}}yes{{else}}no{{/if}}', {'show': True})) #> yes ``` ## Prompt Composition Templates are a natural fit for building LLM prompts from structured data. This example builds a system prompt for a support agent — the guidelines and few-shot examples are data-driven, so they can be stored in a database, customized per customer, and iterated on without code changes: ```python {title="agent_config.py"} from pydantic import BaseModel from pydantic_handlebars import compile class Turn(BaseModel): role: str content: str class AgentConfig(BaseModel): company: str guidelines: list[str] dos: list[Turn] donts: list[Turn] system_prompt = compile( """\ You are a support agent for {{company}}. {{#each guidelines~}} - {{this}} {{/each}} Follow the patterns shown in these examples: {{#each dos~}} <{{role}}> {{content}} {{/each~}} {{#each donts~}} <{{role}}> {{content}} {{/each~}} \ """, AgentConfig, ) prompt = system_prompt.render( AgentConfig( company='Acme Corp', guidelines=[ 'Be concise and helpful', 'Link to docs when relevant', "If unsure, escalate — don't guess", ], dos=[ Turn(role='user', content='How do I reset my password?'), Turn( role='assistant', content='Go to Settings → Security → Reset Password. ' "You'll get a confirmation email within 2 minutes.\n" 'Docs: https://docs.acme.com/password-reset', ), ], donts=[ Turn(role='user', content='How do I reset my password?'), Turn( role='assistant', content="I'm not sure, maybe check the settings page? " 'Let me know if you find it!', ), ], ) ) print(prompt) """ You are a support agent for Acme Corp. - Be concise and helpful - Link to docs when relevant - If unsure, escalate — don't guess Follow the patterns shown in these examples: How do I reset my password? Go to Settings → Security → Reset Password. You'll get a confirmation email within 2 minutes. Docs: https://docs.acme.com/password-reset How do I reset my password? I'm not sure, maybe check the settings page? Let me know if you find it! """ ``` The template references `company`, `guidelines`, `dos`, `donts`, `role`, and `content` — all validated against `AgentConfig`'s schema at compile time. A typo like `{{guidlines}}` raises `TemplateSchemaError` immediately: ```python {title="agent_config_error.py" requires="agent_config.py"} from agent_config import AgentConfig from pydantic_handlebars import TemplateSchemaError, compile try: compile('Hello {{guidlines}}!', AgentConfig) # typo! except TemplateSchemaError as e: print(e) """ 1 error(s) found: - guidlines: Field 'guidlines' not found in schema """ ``` For user-provided templates where you want to check for errors without raising, use `check_template_compatibility` instead — it returns a result object with `is_compatible` and `issues`: ```python {title="agent_config_check.py" requires="agent_config.py"} from agent_config import AgentConfig from pydantic import TypeAdapter from pydantic_handlebars import check_template_compatibility schema = TypeAdapter(AgentConfig).json_schema(mode='serialization') result = check_template_compatibility('Hello {{guidlines}}!', schema) print(result.is_compatible) #> False print(result.issues) """ [ TemplateIssue( severity='error', message="Field 'guidlines' not found in schema", field_path='guidlines', template_index=0 ) ] """ ``` ## Type-Safe Templates Templates can be validated against a data type at compile time, catching typos, missing fields, and structural mismatches before any data is rendered. ### Compile with a type Pass a Pydantic model (or any type supported by [`TypeAdapter`](https://docs.pydantic.dev/latest/api/type_adapter/)) as the second argument to `compile`. The template is validated against the model's JSON schema at compile time: ```python from pydantic import BaseModel from pydantic_handlebars import compile class User(BaseModel): name: str age: int # Validates template fields against User's schema template = compile('Hello {{name}}, age {{age}}!', User) # Render with typed data — serialization handled automatically print(template.render(User(name='Alice', age=30))) #> Hello Alice, age 30! ``` If the template references a field that doesn't exist in the schema, compilation fails: ```python from pydantic import BaseModel from pydantic_handlebars import TemplateSchemaError, compile class User(BaseModel): name: str age: int try: compile('Hello {{username}}!', User) # 'username' not in User except TemplateSchemaError as e: print(e) """ 1 error(s) found: - username: Field 'username' not found in schema """ ``` ### TypedCompiler When compiling multiple templates against the same type, `TypedCompiler` caches the type adapter and JSON schema: ```python from pydantic import BaseModel from pydantic_handlebars import typed_compiler class User(BaseModel): name: str age: int compiler = typed_compiler(User) greeting = compiler.compile('Hello {{name}}!') info = compiler.compile('{{name}} is {{age}} years old.') user = User(name='Alice', age=30) print(greeting.render(user)) #> Hello Alice! print(info.render(user)) #> Alice is 30 years old. ``` ### Data validation and rendering Typed templates offer two rendering modes: ```python from pydantic import BaseModel from pydantic_handlebars import compile class User(BaseModel): name: str age: int template = compile('Hello {{name}}!', User) # .render() — expects an already-validated instance of the type print(template.render(User(name='Alice', age=30))) #> Hello Alice! # validate_and_render — validates input through Pydantic first, # useful for unvalidated data (e.g., from a JSON API) print(template.validate_and_render({'name': 'Bob', 'age': 25})) #> Hello Bob! ``` ### Subclass serialization By default, typed templates only serialize the fields declared on the type you compile against — extra fields from subclasses are stripped. This is the safer default since a subclass might add sensitive fields (e.g. `password`). To include subclass fields, pass `serialize_as_any=True`: ```python from pydantic import BaseModel from pydantic_handlebars import typed_compiler class Animal(BaseModel): name: str class Dog(Animal): breed: str # serialize_as_any=True: subclass fields are preserved compiler = typed_compiler(Animal, serialize_as_any=True) template = compiler.compile('{{name}} ({{breed}})', raise_on_error=False) print(template.render(Dog(name='Rex', breed='Labrador'))) #> Rex (Labrador) ``` The same option is available on the `compile()` shortcut via `compile(source, tp, serialize_as_any=True)`. ### Schema checking API For lower-level control, use `check_template_compatibility` directly with a JSON schema: ```python from pydantic_handlebars import check_template_compatibility schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'items': { 'type': 'array', 'items': { 'type': 'object', 'properties': {'label': {'type': 'string'}}, }, }, }, 'required': ['name', 'items'], } # Valid template — all referenced fields exist result = check_template_compatibility('{{name}}: {{#each items}}{{label}} {{/each}}', schema) print(result.is_compatible) #> True # Invalid template — 'missing_field' not in schema result = check_template_compatibility('{{missing_field}}', schema) print(result.is_compatible) #> False print(result.issues[0].field_path) #> missing_field ``` You can also check multiple templates at once, which is useful for systems that pair a system prompt with a user prompt template: ```python from pydantic_handlebars import check_template_compatibility schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility( ['System: you are helping {{name}}', 'User said: {{name}} wants help'], schema, ) print(result.is_compatible) #> True ``` ### What gets checked The schema checker statically walks the template AST and validates: - **Field existence** — `{{name}}` errors if `name` is not in the schema's `properties` (unless `additionalProperties` allows it) - **Nested path walking** — `{{user.address.city}}` is validated through each level of the schema hierarchy - **`#each` targets** — Verifies the target resolves to an array or object type; descends into the `items` schema for the loop body - **`#with` targets** — Verifies the target resolves to an object and validates the body against it - **`#if`/`#unless` conditions** — Validates the condition field exists in the schema - **`@root` paths** — `{{@root.name}}` is validated against the root schema - **`$ref` resolution** — Follows `$ref` pointers including Pydantic's `$defs` - **Nullable types** — Unwraps `anyOf: [{...}, {type: 'null'}]` patterns from `Optional[X]` - **Union types** — Checks field access across `anyOf`/`oneOf` variants - **`default` helper** — `{{default field "fallback"}}` relaxes checking for its first argument, since the fallback guards against missing values - **Custom helpers** — Arguments to registered helpers are validated; the helper body is treated as opaque (the checker can't know what context a custom helper provides) ## Why Handlebars? In the age of agent development, templates are everywhere — system prompts, user-facing messages, tool descriptions. But templates have traditionally had a weakness: **no type checking**. A typo in a field name silently renders an empty string rather than failing loudly. This library captures more of the confidence you get from using Pydantic for data validation, applied to the template layer. Because Handlebars is a widely adopted standard, LLMs are already familiar with its syntax — making it a natural choice when AI agents need to read, write, or modify templates. ### Security Jinja2 is the most popular Python template engine, but its own documentation for the [sandbox](https://jinja.palletsprojects.com/en/stable/sandbox/) states: *"The sandbox alone is not a solution for perfect security."* The sandbox docs further warn about error handling, resource exhaustion, and data exposure risks. Jinja2 is also one of the most commonly exploited template engines for [Server-Side Template Injection (SSTI)](https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/07-Input_Validation_Testing/18-Testing_for_Server-side_Template_Injection) — because Jinja2 expressions can evaluate arbitrary Python, an attacker who controls template content can achieve remote code execution. Handlebars has **no code evaluation path by design**. There is no mechanism in the Handlebars syntax to call arbitrary functions, access Python internals, or traverse object hierarchies beyond simple property access. The attack surface for template injection does not exist at the language level. ### vs. PEP 750 template strings Python 3.14 introduces [template strings (PEP 750)](https://peps.python.org/pep-0750/) — a new `t"..."` prefix that captures interpolation structure instead of immediately evaluating. While useful for developer-authored strings in source code, t-strings are **compile-time source-code literals**. You cannot load a template from a database, accept one from user input, or read one from a configuration file and use it as a t-string. The PEP itself acknowledges this limitation: *"Projects such as Jinja are still needed in cases where the template is less part of the software by the developers, and more part of customization by designers or even content created by users."* pydantic-handlebars is designed for exactly this case — dynamic templates from any source, validated against a known schema. ### vs. Mustache and f-strings **Mustache** is the logic-less subset of Handlebars. All Mustache templates are valid Handlebars. However, Mustache lacks `#if`/`#else`, `#unless`, block parameters, and custom helpers — features that are important for real-world prompt construction (conditional sections, iteration metadata, domain-specific formatting). **Python f-strings and `str.format()`** support only flat variable substitution — no dot-notation paths, no loops, no conditionals, no custom logic. They also execute in the caller's scope, making them unsuitable for untrusted templates. ### Static analyzability Because the Handlebars grammar is constrained, templates can be parsed and analyzed without execution. This is what makes the schema validation feature possible — you can know at compile time whether a template is compatible with your data type. Template expressivity is in tension with static analysis, and Handlebars strikes a good balance between the two. ### Industry adoption Handlebars (or its Mustache subset) is widely used across the AI/agent ecosystem — in agent frameworks, LLM observability platforms, prompt management tools, and API playgrounds. Its familiarity and simplicity make it a common default when these tools need a template language for prompt construction. ### JSON Schema as the validation medium We use JSON Schema — rather than Python type reflection — as the validation medium. This makes the checking portable: the same schema can validate templates regardless of what language the type was originally defined in. A schema generated from a Rust struct, TypeScript interface, or Go struct works identically. Pydantic models generate JSON schemas automatically via `model_json_schema()`, so the integration is seamless. All context data is automatically serialized to JSON-safe types before rendering — the template engine only ever sees dicts, lists, strings, numbers, bools, and None. JSON Schema naturally describes this serialized form. The typed template API (`compile(source, Type)`) handles serialization via Pydantic's `dump_python(mode='json')`. ## Features ### Handlebars syntax - Variables and paths: `{{name}}`, `{{person.name}}`, `{{../parent}}` - Blocks: `{{#if}}`, `{{#unless}}`, `{{#each}}`, `{{#with}}` - Comments: `{{! comment }}`, `{{!-- long comment --}}` - Literals: strings, numbers, booleans, null - Subexpressions: `{{helper (other arg)}}` - Hash arguments: `{{helper key=value}}` - Raw blocks: `{{{{raw}}}}...{{{{/raw}}}}` - HTML escaping (opt-in): `{{escaped}}` vs `{{{unescaped}}}` - Whitespace control: `{{~expression~}}` - `@data` variables: `@root`, `@index`, `@key`, `@first`, `@last` - Block parameters: `{{#each items as |item index|}}` - Chained else: `{{else if condition}}` ### Built-in helpers | Category | Helpers | Availability | |---|---|---| | **Block** | `if`, `unless`, `each`, `with` | Standard | | **Utility** | `lookup`, `log` | Standard | | **String** | `json`, `uppercase`, `lowercase`, `trim`, `join`, `truncate`, `default` | Extra | | **Comparison** | `eq`, `ne`, `gt`, `gte`, `lt`, `lte` | Extra | | **Boolean** | `and`, `or`, `not` | Extra | Standard helpers are always available. Extra helpers require `HandlebarsEnvironment(extra_helpers=True)`. ## Custom Helpers Register helpers on a `HandlebarsEnvironment`: ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions env = HandlebarsEnvironment() @env.helper def shout(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() + '!!!' print(env.render('{{shout name}}', {'name': 'world'})) #> WORLD!!! ``` You can also register with a custom name: ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions env = HandlebarsEnvironment() @env.helper('loud') def make_loud(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() print(env.render('{{loud name}}', {'name': 'world'})) #> WORLD ``` To use custom helpers with typed templates, pass the environment to `typed_compiler`: ```python from pydantic import BaseModel from pydantic_handlebars import HandlebarsEnvironment, HelperOptions, typed_compiler env = HandlebarsEnvironment() @env.helper def shout(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() + '!!!' class User(BaseModel): name: str compiler = typed_compiler(User, env=env) template = compiler.compile('{{shout name}}') print(template.render(User(name='world'))) #> WORLD!!! ``` ## Configuration ### HTML escaping HTML escaping is **disabled by default** (`auto_escape=False`). This library is designed for prompt generation and other non-HTML use cases where escaping `<`, `>`, `&`, etc. would corrupt the output. > **Warning:** If you use this library to render **HTML that will be displayed in a browser**, you > **must** enable `auto_escape=True` to prevent cross-site scripting (XSS) vulnerabilities. To enable escaping, create an environment with `auto_escape=True`: ```python from pydantic_handlebars import HandlebarsEnvironment env = HandlebarsEnvironment(auto_escape=True) print(env.render('{{content}}', {'content': 'bold'})) #> <b>bold</b> ``` Triple-stache `{{{expression}}}` always outputs unescaped content regardless of the `auto_escape` setting. ### Unlisted field strictness The `optional_field_severity` parameter controls how the schema checker handles references to fields not explicitly listed in the schema's `properties`. By default, these are errors: ```python from pydantic_handlebars import check_template_compatibility schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } # Default: unlisted fields are errors result = check_template_compatibility('{{nickname}}', schema) print(result.is_compatible) #> False # Lenient: unlisted fields are warnings (don't block compatibility) result = check_template_compatibility('{{nickname}}', schema, optional_field_severity='warning') print(result.is_compatible) #> True print(result.issues[0].severity) #> warning ``` The `default` helper automatically relaxes this for its arguments, since the fallback value guards against the field being missing: ```python from pydantic_handlebars import check_template_compatibility schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } # 'nickname' is not in the schema, but default provides a fallback result = check_template_compatibility( '{{default nickname "Anonymous"}}', schema, helpers={'default'} ) print(result.is_compatible) #> True ``` ### Strict mode Pass `strict=True` to fail loudly when a template references a path that does not exist on the context, instead of silently rendering as the empty string: ```python from pydantic_handlebars import HandlebarsRuntimeError, render try: render('Hello {{name}}!', {}, strict=True) except HandlebarsRuntimeError as e: print(e) #> Strict mode: 'name' references missing context key 'name' ``` Strict mode treats *missing* keys as errors but lets explicit `None` values through (so `{'name': None}` still renders as `Hello !`). It applies to block-helper arguments (`{{#if missing}}`) and `@data` variables (`{{@nope}}`) the same way it applies to direct lookups — helper *names*, block params bound by `as |name|`, and `{{this}}` are unaffected. Strict mode can also be set at the environment level: `HandlebarsEnvironment(strict=True)`. ### Static dependency extraction `extract_dependencies` statically returns the set of top-level context fields a template depends on — no rendering required: ```python from pydantic_handlebars import extract_dependencies extract_dependencies('Hello {{user_name}}!') #> {'user_name'} extract_dependencies('{{user.name}} <{{user.email}}>') #> {'user'} extract_dependencies('{{#each items}}{{name}}{{/each}}') #> {'items'} extract_dependencies('{{#each items}}{{../top}}{{/each}}') #> {'items', 'top'} extract_dependencies('{{#each items as |item|}}{{item.name}}{{/each}}') #> {'items'} ``` The walker is scope-aware: `each` and `with` shift the context (their bodies' refs are not top-level deps), `if`/`unless` do not, block params shadow context lookups locally, `../` walks the scope chain back through opaque blocks, and `@root.foo` jumps to the root context directly. Standard helpers (`if`, `unless`, `each`, `with`, `lookup`, `log`) are recognised by default; pass `include_extra_helpers=True` or `helpers={...}` to extend the set. ### Configurable delimiters `HandlebarsEnvironment`, `render`, `compile`, and `extract_dependencies` all accept `open_delim` and `close_delim` parameters (defaulting to `{{` / `}}`). Custom delimiter pairs let you run a rendering pass that consumes one set of placeholders without disturbing another set intended for a later pass: ```python from pydantic_handlebars import render # Compose with @{...}@ — leaves the {{...}} placeholders for the next pass. after_composition = render( 'You are helping @{role}@. Greet {{user_name}}.', {'role': 'a designer'}, open_delim='@{', close_delim='}@', ) print(after_composition) #> You are helping a designer. Greet {{user_name}}. # Default pass picks up the remaining {{...}}. print(render(after_composition, {'user_name': 'Alice'})) #> You are helping a designer. Greet Alice. ``` All standard syntax works under custom delimiters: block helpers (`@{#if x}@…@{/if}@`), whitespace control (`@{~x~}@`), comments (`@{! … }@`, `@{!-- … --}@`), escape sequences (`\@{x}@` produces literal `@{x}@`), and the ampersand-unescape variant (`@{&x}@` opts out of HTML escaping when `auto_escape=True`). Triple-stache (`{{{...}}}`) and raw blocks (`{{{{...}}}}`) are only available with the default delimiters — they have no unambiguous extension for arbitrary pairs. Use `@{&x}@` for the per-expression escape opt-out, or set `auto_escape=False` on the environment. The public delimiter parameters reject empty, identical, whitespace-edged, and expression-syntax- conflicting (`"'()=|~`) pairs. ## Security ### Design principles pydantic-handlebars is designed to safely render untrusted templates: - **No code evaluation** — The Handlebars expression language cannot call arbitrary Python functions, import modules, or execute code. Only explicitly registered helpers can be invoked. - **Data serialization** — All context data is serialized to JSON-safe types via `pydantic_core.to_jsonable_python` before rendering. The template engine only ever sees dicts, lists, strings, numbers, bools, and None — there are no dangerous Python objects to exploit, no dunders, no methods, no properties with side effects. - **Depth limits** — Template nesting is capped (default 100 levels) to prevent stack overflow from recursive or deeply nested templates. - **Output size limits** — Rendered output is capped (default 10MB) to prevent memory exhaustion from template expansion attacks. ### Recommendations - **Register only trusted helpers** — Custom helpers execute as Python code. Only register helpers you control and trust. - **Validate templates against schemas** — Use the typed compilation API or `check_template_compatibility` to catch field reference errors before rendering, especially when templates come from external sources. ## What's Not (Yet) Implemented If any of these features would be useful to you, please [open an issue](https://github.com/pydantic/pydantic-handlebars/issues) and let us know. - **Partials** — `{{> partialName}}` for template composition. Out-of-line partials registered on the environment (schema-opaque at check time), and inline partials (`{{#*inline "name"}}...{{/inline}}`) that *are* schema-checked since their body is visible. An `allow_partials` flag to optionally disallow partial syntax. - **Error messages with source context** — Show the relevant template snippet with a caret pointing to the problematic expression. Most of the infrastructure (token positions, source access) is already in place. - **Non-primitive rendering detection** — Warning when `{{expression}}` would render a complex object (dict, nested model) rather than a primitive. - **Conditional type checking** — Warning when `#if` is used on a field whose type is guaranteed truthy. ## License MIT pydantic-handlebars-0.2.1/docs/000077500000000000000000000000001520472041100163525ustar00rootroot00000000000000pydantic-handlebars-0.2.1/docs/api.md000066400000000000000000000233131520472041100174470ustar00rootroot00000000000000# Python API Reference ## Module Functions ### `render` Render a template string with a context dictionary: ```python from pydantic_handlebars import render print(render('Hello {{name}}!', {'name': 'World'})) #> Hello World! ``` Uses a default environment with standard Handlebars helpers (`if`, `unless`, `each`, `with`, `lookup`, `log`). ::: pydantic_handlebars.render ### `compile` Compile a template into a reusable callable. When called with just a source string, returns a `CompiledTemplate`. When called with a source string and a type, validates the template against the type's JSON schema and returns a `CompiledTypedTemplate`: ```python from pydantic_handlebars import compile # Untyped compilation template = compile('Hello {{name}}!') print(template.render({'name': 'World'})) #> Hello World! ``` ```python from pydantic import BaseModel from pydantic_handlebars import compile class User(BaseModel): name: str age: int # Typed compilation — validates template against User's schema template = compile('Hello {{name}}, age {{age}}!', User) print(template.render(User(name='Alice', age=30))) #> Hello Alice, age 30! ``` ::: pydantic_handlebars.compile ### `typed_compiler` Create a `TypedCompiler` for compiling multiple templates against the same type. Caches the type adapter and JSON schema for efficiency: ```python from pydantic import BaseModel from pydantic_handlebars import typed_compiler class User(BaseModel): name: str age: int compiler = typed_compiler(User) greeting = compiler.compile('Hello {{name}}!') info = compiler.compile('{{name}} is {{age}} years old.') user = User(name='Alice', age=30) print(greeting.render(user)) #> Hello Alice! print(info.render(user)) #> Alice is 30 years old. ``` ::: pydantic_handlebars.typed_compiler ### `check_template_compatibility` Validate a template (or list of templates) against a JSON schema without compiling: ```python from pydantic_handlebars import check_template_compatibility schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('Hello {{name}}!', schema) print(result.is_compatible) #> True result = check_template_compatibility('{{missing_field}}', schema) print(result.is_compatible) #> False print(result.issues[0].field_path) #> missing_field ``` ::: pydantic_handlebars.check_template_compatibility ### `extract_dependencies` Statically walk a template and return the set of top-level context field names it depends on. No rendering, no context required: ```python from pydantic_handlebars import extract_dependencies extract_dependencies('{{greeting}}, {{user.name}}!') #> {'greeting', 'user'} # Scope-aware: `each` / `with` shift the context, so inner refs are not top-level deps. extract_dependencies('{{#each items}}{{name}}{{/each}}') #> {'items'} # `../` walks back to the top scope and contributes a top-level dep: extract_dependencies('{{#each items}}{{../top}}{{/each}}') #> {'items', 'top'} ``` ::: pydantic_handlebars.extract_dependencies ## Custom delimiters `HandlebarsEnvironment`, `render`, `compile`, and `extract_dependencies` all accept matching `open_delim` and `close_delim` keyword parameters (defaulting to `{{` and `}}`). Custom delimiter pairs let you embed a Handlebars rendering pass without disturbing a different set of placeholders intended for a later pass: ```python from pydantic_handlebars import render # Compose with @{...}@ — leaves {{...}} literals untouched for the next pass. after_composition = render( 'You are helping @{role}@. Greet {{user_name}}.', {'role': 'a designer'}, open_delim='@{', close_delim='}@', ) print(after_composition) #> You are helping a designer. Greet {{user_name}}. print(render(after_composition, {'user_name': 'Alice'})) #> You are helping a designer. Greet Alice. ``` All standard syntax — block helpers, whitespace control, comments, escape sequences, and the ampersand-unescape variant — works under custom delimiters. Triple-stache (`{{{...}}}`) and raw blocks (`{{{{...}}}}`) are only available with the default delimiter pair; under custom delimiters use `@{&x}@` for per-expression escape opt-out, or `auto_escape=False` on the environment. The public delimiter parameters reject empty, identical, whitespace-edged, and expression-syntax- conflicting (`"'()=|~`) pairs. ## Strict mode Pass `strict=True` to fail loudly when a template references a path that does not exist on the context, instead of silently rendering as the empty string: ```python from pydantic_handlebars import HandlebarsRuntimeError, render try: render('Hello {{name}}!', {}, strict=True) except HandlebarsRuntimeError as e: print(e) #> Strict mode: 'name' references missing context key 'name' ``` Strict mode treats *missing* keys as errors but lets explicit `None` values through. It applies to block-helper arguments (`{{#if missing}}`) and `@data` variables the same way it applies to direct lookups. Helper *names*, block params, and `{{this}}` are unaffected. Strict mode can also be set at the environment level: `HandlebarsEnvironment(strict=True)`. ## Classes ### `HandlebarsEnvironment` An environment for rendering templates with custom helpers. ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions env = HandlebarsEnvironment(extra_helpers=True) @env.helper def shout(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() + '!!!' print(env.render('{{shout name}}', {'name': 'world'})) #> WORLD!!! ``` Pass `extra_helpers=True` to include additional utility, comparison, and boolean helpers beyond the 6 standard Handlebars.js helpers. See [Built-in Helpers](helpers.md) for the full list. ::: pydantic_handlebars.HandlebarsEnvironment ### `CompiledTemplate` Returned by `compile(source)` (without a type). Call `.render()` with a context dict. ::: pydantic_handlebars.CompiledTemplate ### `CompiledTypedTemplate` Returned by `compile(source, Type)`. Call `.render()` with an instance of the type, or `.validate_and_render()` with unvalidated data: ```python from pydantic import BaseModel from pydantic_handlebars import compile class User(BaseModel): name: str age: int template = compile('Hello {{name}}!', User) # .render() — expects a validated instance print(template.render(User(name='Alice', age=30))) #> Hello Alice! # .validate_and_render() — validates input through Pydantic first print(template.validate_and_render({'name': 'Bob', 'age': 25})) #> Hello Bob! ``` ::: pydantic_handlebars.CompiledTypedTemplate ### `TypedCompiler` Returned by `typed_compiler()`. Caches the type adapter and JSON schema for compiling multiple templates against the same type. ::: pydantic_handlebars.TypedCompiler ### `HelperOptions` Passed to block helpers as the `options` keyword argument. Provides access to the block body, inverse block, hash arguments, and data variables. ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions env = HandlebarsEnvironment() @env.helper('repeat') def repeat_helper(*args: object, options: HelperOptions) -> str: times = int(options.hash.get('times', 1)) return ''.join(options.fn() for _ in range(times)) print(env.render('{{#repeat times=3}}Ho {{/repeat}}', {})) #> Ho Ho Ho ``` ::: pydantic_handlebars.HelperOptions ### `SafeString` Marks a string as already HTML-safe when returned from helpers. When `auto_escape=True`, helper return values wrapped in `SafeString` bypass HTML escaping: ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions, SafeString env = HandlebarsEnvironment(auto_escape=True) @env.helper def bold(*args: object, options: HelperOptions) -> SafeString: return SafeString(f'{args[0]}') print(env.render('{{bold name}}', {'name': 'Alice'})) #> Alice ``` !!! note `SafeString` values in context dicts are serialized to plain `str` before rendering, losing the safe marker. `SafeString` is primarily useful as a return type from helpers. ::: pydantic_handlebars.SafeString ### `CompatibilityResult` Returned by `check_template_compatibility`. Contains `is_compatible` (bool) and `issues` (list of `TemplateIssue`). ::: pydantic_handlebars.CompatibilityResult ### `TemplateIssue` A single issue found during schema compatibility checking. Contains `field_path`, `message`, and `severity`. ::: pydantic_handlebars.TemplateIssue ## Exceptions ### `HandlebarsError` Base exception for all pydantic_handlebars errors. ::: pydantic_handlebars.HandlebarsError ### `HandlebarsParseError` Raised when a template cannot be parsed. Includes `line` and `column` attributes: ```python from pydantic_handlebars import HandlebarsParseError, render try: render('{{{unclosed}', {}) except HandlebarsParseError as e: assert e.line is not None assert e.column is not None ``` ::: pydantic_handlebars.HandlebarsParseError ### `HandlebarsRuntimeError` Raised during template rendering (e.g., exceeding depth limits). ::: pydantic_handlebars.HandlebarsRuntimeError ### `TemplateSchemaError` Raised by `compile(source, Type)` when the template references fields not present in the type's JSON schema: ```python from pydantic import BaseModel from pydantic_handlebars import TemplateSchemaError, compile class User(BaseModel): name: str try: compile('{{missing}}', User) except TemplateSchemaError as e: print(e) """ 1 error(s) found: - missing: Field 'missing' not found in schema """ ``` ::: pydantic_handlebars.TemplateSchemaError ## Utilities ### `escape_expression` HTML-escape a string using Handlebars escaping rules: ```python from pydantic_handlebars import escape_expression print(escape_expression('bold')) #> <b>bold</b> ``` ::: pydantic_handlebars.escape_expression pydantic-handlebars-0.2.1/docs/helpers.md000066400000000000000000000106501520472041100203400ustar00rootroot00000000000000# Built-in Helpers pydantic_handlebars includes all standard Handlebars.js helpers. Additional utility, comparison, and boolean helpers are available as opt-in extras. ## Standard Helpers These 6 helpers are always available — they're included in every `HandlebarsEnvironment` and in the module-level `render()` function. ### `#if` Conditionally renders the block if the value is truthy. Falsy values: `false`, `None`, `""`, `0`, `[]`. Note: empty dict `{}` is **truthy** (matching Handlebars.js behavior). ```handlebars {{#if author}}

{{firstName}} {{lastName}}

{{else}}

Unknown Author

{{/if}} ``` ```python from pydantic_handlebars import render print(render('{{#if show}}visible{{else}}hidden{{/if}}', {'show': True})) #> visible ``` Supports chained conditions: ```handlebars {{#if a}}A{{else if b}}B{{else}}C{{/if}} ``` ### `#unless` The inverse of `#if`. Renders the block when the value is falsy. ```handlebars {{#unless license}} WARNING: No license specified! {{/unless}} ``` ### `#each` Iterates over arrays and objects. For arrays, provides `@index`, `@first`, and `@last`: ```handlebars {{#each items}} {{@index}}: {{this}} {{/each}} ``` For objects, also provides `@key`: ```handlebars {{#each person}} {{@key}}: {{this}} {{/each}} ``` Supports `{{else}}` for empty collections: ```handlebars {{#each items}} {{this}} {{else}} No items found. {{/each}} ``` Block parameters capture the value and index/key: ```handlebars {{#each items as |item i|}} {{i}}: {{item.name}} {{/each}} ``` Access the parent context with `../`: ```handlebars {{#each items}} {{../title}}: {{name}} {{/each}} ``` ### `#with` Changes the context for the block body. ```handlebars {{#with person}} {{name}} lives in {{city}} {{/with}} ``` Supports `{{else}}` for falsy contexts: ```handlebars {{#with author}} {{name}} {{else}} Unknown author {{/with}} ``` ### `lookup` Dynamic property lookup: ```handlebars {{lookup object key}} ``` ```python from pydantic_handlebars import render print(render('{{lookup colors 0}}', {'colors': ['red', 'green', 'blue']})) #> red print(render('{{lookup person field}}', {'person': {'name': 'Alice'}, 'field': 'name'})) #> Alice ``` ### `log` Logs values using Python's `warnings.warn`: ```handlebars {{log "debug info" level="warn"}} ``` ## Extra Helpers The helpers below are **not** part of the Handlebars.js spec. They must be explicitly enabled: ```python from pydantic_handlebars import HandlebarsEnvironment env = HandlebarsEnvironment(extra_helpers=True) ``` ### Utility Helpers #### `json` Serialize a value to a JSON string: ```handlebars {{json data}} ``` ```python from pydantic_handlebars import HandlebarsEnvironment env = HandlebarsEnvironment(extra_helpers=True) print(env.render('{{{json obj}}}', {'obj': {'a': 1}})) #> {"a": 1} ``` #### `uppercase` Convert a value to uppercase: ```handlebars {{uppercase name}} ``` #### `lowercase` Convert a value to lowercase: ```handlebars {{lowercase name}} ``` #### `trim` Strip leading and trailing whitespace: ```handlebars {{trim value}} ``` #### `join` Join array elements with a separator: ```handlebars {{join items ", "}} ``` ```python from pydantic_handlebars import HandlebarsEnvironment env = HandlebarsEnvironment(extra_helpers=True) print(env.render('{{join colors ", "}}', {'colors': ['red', 'green', 'blue']})) #> red, green, blue ``` The default separator is `,`. #### `truncate` Truncate a string to N characters: ```handlebars {{truncate description 50}} ``` The default length is 100. #### `default` Use a fallback value when the primary value is falsy: ```handlebars {{default name "Anonymous"}} ``` ### Comparison Helpers These return boolean values and are typically used inside `#if` via subexpressions. #### `eq` / `ne` Equality and inequality: ```handlebars {{#if (eq status "active")}}Active{{/if}} {{#if (ne count 0)}}Has items{{/if}} ``` #### `gt` / `gte` / `lt` / `lte` Numeric comparisons: ```handlebars {{#if (gt age 18)}}Adult{{/if}} {{#if (lte score 100)}}Valid{{/if}} ``` ### Boolean Helpers #### `and` / `or` / `not` Boolean combinators for use in subexpressions: ```handlebars {{#if (and hasPermission isActive)}} Allowed {{/if}} {{#if (or isAdmin isModerator)}} Has access {{/if}} {{#if (not isDisabled)}} Enabled {{/if}} ``` `and` and `or` accept multiple arguments: ```handlebars {{#if (and a b c)}}All truthy{{/if}} {{#if (or a b c)}}At least one truthy{{/if}} ``` pydantic-handlebars-0.2.1/docs/index.md000066400000000000000000000175651520472041100200210ustar00rootroot00000000000000# pydantic-handlebars A [Handlebars](https://handlebarsjs.com/) template engine for composing LLM prompts, built on Pydantic. Designed for AI/agent developers who build system prompts from dynamic data — few-shot examples, user-specific context, tool descriptions, and other content that shouldn't be hardcoded. Templates are validated against Pydantic models at compile time, catching typos and missing fields before any data is rendered. The only runtime dependency is Pydantic (2.0+). ## Installation ```bash pip install pydantic-handlebars ``` Or with uv: ```bash uv add pydantic-handlebars ``` Requires Python 3.10+ and Pydantic 2.0+. ## Quick Start Render a template with a context dictionary: ```python from pydantic_handlebars import render print(render('Hello {{name}}!', {'name': 'World'})) #> Hello World! ``` For repeated rendering, compile the template once and reuse it: ```python from pydantic_handlebars import compile template = compile('Hello {{name}}!') print(template.render({'name': 'Alice'})) #> Hello Alice! print(template.render({'name': 'Bob'})) #> Hello Bob! ``` ## Prompt Composition Templates are a natural fit for building LLM prompts from structured data. This example builds a system prompt for a support agent — the guidelines and few-shot examples are data-driven, so they can be stored in a database, customized per customer, and iterated on without code changes: ```python {title="agent_config.py"} from pydantic import BaseModel from pydantic_handlebars import compile class Turn(BaseModel): role: str content: str class AgentConfig(BaseModel): company: str guidelines: list[str] dos: list[Turn] donts: list[Turn] system_prompt = compile( """\ You are a support agent for {{company}}. {{#each guidelines~}} - {{this}} {{/each}} Follow the patterns shown in these examples: {{#each dos~}} <{{role}}> {{content}} {{/each~}} {{#each donts~}} <{{role}}> {{content}} {{/each~}} \ """, AgentConfig, ) prompt = system_prompt.render( AgentConfig( company='Acme Corp', guidelines=[ 'Be concise and helpful', 'Link to docs when relevant', "If unsure, escalate — don't guess", ], dos=[ Turn(role='user', content='How do I reset my password?'), Turn( role='assistant', content='Go to Settings → Security → Reset Password. ' "You'll get a confirmation email within 2 minutes.\n" 'Docs: https://docs.acme.com/password-reset', ), ], donts=[ Turn(role='user', content='How do I reset my password?'), Turn( role='assistant', content="I'm not sure, maybe check the settings page? " 'Let me know if you find it!', ), ], ) ) print(prompt) """ You are a support agent for Acme Corp. - Be concise and helpful - Link to docs when relevant - If unsure, escalate — don't guess Follow the patterns shown in these examples: How do I reset my password? Go to Settings → Security → Reset Password. You'll get a confirmation email within 2 minutes. Docs: https://docs.acme.com/password-reset How do I reset my password? I'm not sure, maybe check the settings page? Let me know if you find it! """ ``` ## Type-Safe Templates The template references `company`, `guidelines`, `dos`, `donts`, `role`, and `content` — all validated against `AgentConfig`'s schema at compile time. A typo like `{{guidlines}}` raises `TemplateSchemaError` immediately: ```python {title="agent_config_error.py" requires="agent_config.py"} from agent_config import AgentConfig from pydantic_handlebars import TemplateSchemaError, compile try: compile('Hello {{guidlines}}!', AgentConfig) # typo! except TemplateSchemaError as e: print(e) """ 1 error(s) found: - guidlines: Field 'guidlines' not found in schema """ ``` For user-provided templates where you want to check for errors without raising, use `check_template_compatibility` — it returns a result object: ```python {title="agent_config_check.py" requires="agent_config.py"} from agent_config import AgentConfig from pydantic import TypeAdapter from pydantic_handlebars import check_template_compatibility schema = TypeAdapter(AgentConfig).json_schema(mode='serialization') result = check_template_compatibility('Hello {{guidlines}}!', schema) print(result.is_compatible) #> False print(result.issues) """ [ TemplateIssue( severity='error', message="Field 'guidlines' not found in schema", field_path='guidlines', template_index=0 ) ] """ ``` See [API Reference](api.md) for full details on typed compilation and schema checking. ## Custom Helpers Use `HandlebarsEnvironment` to register custom helper functions: ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions env = HandlebarsEnvironment() @env.helper def shout(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() + '!!!' print(env.render('{{shout name}}', {'name': 'world'})) #> WORLD!!! ``` You can also register with a custom name: ```python from pydantic_handlebars import HandlebarsEnvironment, HelperOptions env = HandlebarsEnvironment() @env.helper('loud') def make_loud(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() print(env.render('{{loud name}}', {'name': 'world'})) #> WORLD ``` ## HTML Escaping By default, HTML escaping is **disabled** (`auto_escape=False`) since this library is designed for prompt generation and other non-HTML use cases. To enable HTML escaping, create an environment with `auto_escape=True`: ```python from pydantic_handlebars import HandlebarsEnvironment, render # No escaping by default print(render('{{html}}', {'html': 'bold'})) #> bold # Enable escaping for HTML use cases env = HandlebarsEnvironment(auto_escape=True) print(env.render('{{html}}', {'html': 'bold'})) #> <b>bold</b> ``` !!! warning If you use this library to render **HTML that will be displayed in a browser**, you **must** enable `auto_escape=True` to prevent cross-site scripting (XSS) vulnerabilities. Triple-stache `{{{expression}}}` and `{{&expression}}` always output unescaped content regardless of the `auto_escape` setting. ## Error Handling Parse and runtime errors include line and column information: ```python from pydantic_handlebars import HandlebarsParseError, render try: render('{{#if}}', {}) except HandlebarsParseError as e: assert e.line is not None ``` ## What's Included - Full Handlebars syntax: variables, paths, blocks, comments, literals, subexpressions - All standard block helpers: `#if`, `#unless`, `#each`, `#with`, `lookup`, `log` - Extra helpers (opt-in via `extra_helpers=True`): `json`, `uppercase`, `lowercase`, `trim`, `join`, `truncate`, `default`, comparison operators, boolean combinators - Pydantic-powered template validation against JSON schemas - Whitespace control with `~` markers - `@data` variables: `@root`, `@index`, `@key`, `@first`, `@last` - Block parameters: `{{#each items as |item index|}}` - Chained else: `{{#if a}}...{{else if b}}...{{else}}...{{/if}}` - Raw blocks: `{{{{raw}}}}...{{{{/raw}}}}` - Security: context data serialized to JSON-safe types, depth limits enforced ## What's Not (Yet) Included If any of these features would be useful to you, please [open an issue](https://github.com/pydantic/pydantic-handlebars/issues) and let us know. - Partials and inline partials (`{{> partial}}`, `{{#*inline}}`) - Strict mode (error on missing variables instead of empty string) - Decorators - Precompilation pydantic-handlebars-0.2.1/docs/security.md000066400000000000000000000071431520472041100205500ustar00rootroot00000000000000# Security pydantic-handlebars is designed to safely render untrusted templates: ## No Code Evaluation The Handlebars expression language cannot call arbitrary Python functions, import modules, or execute code. Only explicitly registered helpers can be invoked from templates. There is no `eval`, `exec`, or dynamic code generation. ## Data Serialization All context data is serialized to JSON-safe types via `pydantic_core.to_jsonable_python` before rendering. The template engine only ever sees dicts, lists, strings, numbers, bools, and None — there are no dangerous Python objects to exploit, no dunder attributes to traverse, no methods to call, and no properties with side effects. This eliminates entire categories of attacks: - **No object traversal** — `__class__.__mro__.__subclasses__` chains are impossible because Python objects are converted to their JSON representation before the template sees them. - **No method calls** — Callables in context are not passed through; they are serialized like any other value. - **No side effects** — Properties with side effects are evaluated once during serialization, not during template rendering. ```python from datetime import datetime from pydantic_handlebars import render # datetime is serialized to an ISO format string print(render('{{date}}', {'date': datetime(2024, 1, 15)})) #> 2024-01-15T00:00:00 ``` Types that `pydantic_core` cannot serialize (arbitrary classes, functions, etc.) will raise a `HandlebarsRuntimeError` at render time, ensuring that only safe, serializable data reaches the template engine. ## Nesting Depth Limits A maximum nesting depth (default: 100 levels) prevents stack overflow from deeply nested templates. When exceeded, a `HandlebarsRuntimeError` is raised. ```python from pydantic_handlebars import HandlebarsRuntimeError, render # Extremely deep nesting will raise an error deeply_nested = '{{#each a}}' * 200 + '{{this}}' + '{{/each}}' * 200 try: render(deeply_nested, {'a': [1]}) except HandlebarsRuntimeError: pass ``` ## Output Size Limits An optional maximum output size (default: 10 MB) prevents memory exhaustion from malicious templates that produce extremely large output (e.g., nested `#each` over large arrays). ## HTML Escaping By default, HTML escaping is **disabled** (`auto_escape=False`) since this library is designed for prompt generation and other non-HTML use cases. When `auto_escape=True` is enabled, all `{{expression}}` output is HTML-escaped. Characters escaped: | Character | Escaped As | |-----------|-----------| | `&` | `&` | | `<` | `<` | | `>` | `>` | | `"` | `"` | | `'` | `'` | | `` ` `` | ``` | | `=` | `=` | `{{{triple}}}` and `{{&ersand}}` syntax always output unescaped content regardless of the `auto_escape` setting. !!! warning If you use this library to render **HTML that will be displayed in a browser**, you **must** enable `auto_escape=True` to prevent cross-site scripting (XSS) vulnerabilities. ## Recommendations - **Register only trusted helpers.** Custom helpers execute as Python code. Only register helpers you control and trust. - **Validate templates against schemas.** Use the typed compilation API or `check_template_compatibility` to catch field reference errors before rendering, especially when templates come from external sources. - **Monitor output size.** The default 10 MB limit is generous. Reduce it for your use case if appropriate. - **Enable HTML escaping for HTML output.** If rendering templates into HTML for browser display, create an environment with `auto_escape=True`. Only use triple-stache for content you trust. pydantic-handlebars-0.2.1/docs/syntax.md000066400000000000000000000064311520472041100202260ustar00rootroot00000000000000# Syntax Reference This page covers all supported Handlebars template syntax. ## Variable Interpolation Use double curly braces to output a value: ```handlebars {{name}} ``` Values are output as-is by default. When `auto_escape=True` is enabled, values are HTML-escaped. Triple-stache and `&` always output unescaped content regardless of the `auto_escape` setting: ```handlebars {{{rawHtml}}} {{&rawHtml}} ``` ## Paths ### Dot Notation Access nested properties with dots: ```handlebars {{person.name}} {{article.author.name}} ``` ### Slash Notation Slashes work the same as dots: ```handlebars {{person/name}} ``` ### `this` Keyword Refer to the current context: ```handlebars {{this}} {{this.name}} {{./name}} ``` ### Parent Scope Access parent contexts with `../`: ```handlebars {{#each items}} {{../title}} - {{name}} {{/each}} ``` Multiple levels work too: `{{../../grandparent}}`. ### `@data` Variables Special variables prefixed with `@`: | Variable | Description | |----------|-------------| | `@root` | The root context | | `@index` | Current array index (in `#each`) | | `@key` | Current object key (in `#each`) | | `@first` | `true` for the first iteration | | `@last` | `true` for the last iteration | ```handlebars {{#each items}} {{@index}}: {{this}} {{/each}} ``` ## Literals Use literal values directly in expressions: ```handlebars {{helper "string"}} {{helper 'single quoted'}} {{helper 42}} {{helper 3.14}} {{helper true}} {{helper false}} {{helper null}} {{helper undefined}} ``` ## Comments Comments are stripped from output: ```handlebars {{! This is a short comment }} {{!-- This is a long comment that can span multiple lines --}} ``` ## Whitespace Control Add `~` to strip whitespace on that side of a tag: ```handlebars {{~expression}} {{!-- strips whitespace before --}} {{expression~}} {{!-- strips whitespace after --}} {{~expression~}} {{!-- strips both sides --}} ``` This works on all tag types including blocks: ```handlebars {{~#each items~}} {{this}} {{~/each~}} ``` ## Block Helpers Block helpers wrap a section of the template: ```handlebars {{#if condition}} Rendered when truthy {{/if}} ``` ### Inverse / Else Use `{{else}}` or `{{^}}` for the inverse block: ```handlebars {{#if condition}} Truthy {{else}} Falsy {{/if}} {{#if condition}} Truthy {{^}} Falsy (same as else) {{/if}} ``` ### Chained Else Chain conditions with `{{else if}}`: ```handlebars {{#if a}} A is truthy {{else if b}} B is truthy {{else}} Neither {{/if}} ``` ### Block Parameters Capture block values with `as |param|`: ```handlebars {{#each items as |item index|}} {{index}}: {{item.name}} {{/each}} {{#with person as |p|}} {{p.name}} {{/with}} ``` ## Hash Arguments Pass named arguments to helpers: ```handlebars {{helper key="value" count=42}} ``` ## Subexpressions Nest helper calls as arguments using parentheses: ```handlebars {{helper (subhelper arg)}} {{#if (eq status "active")}} Active! {{/if}} ``` ## Raw Blocks Raw blocks pass content through without processing: ```handlebars {{{{raw}}}} {{this is not processed}} {{{{/raw}}}} ``` The content between raw block tags is output as-is. ## Escaped Delimiters To output literal `{{`, escape with a backslash: ```handlebars \{{not a variable}} ``` This renders as `{{not a variable}}`. pydantic-handlebars-0.2.1/mkdocs.yml000066400000000000000000000022771520472041100174350ustar00rootroot00000000000000site_name: pydantic-handlebars site_description: Handlebars template engine for composing LLM prompts, built on Pydantic repo_url: https://github.com/pydantic/pydantic-handlebars repo_name: pydantic/pydantic-handlebars theme: name: material palette: - media: '(prefers-color-scheme: light)' scheme: default primary: custom toggle: icon: material/brightness-7 name: Switch to dark mode - media: '(prefers-color-scheme: dark)' scheme: slate primary: custom toggle: icon: material/brightness-4 name: Switch to light mode features: - content.code.copy - navigation.footer markdown_extensions: - admonition - pymdownx.details - pymdownx.superfences - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - toc: permalink: true plugins: - search - mkdocstrings: handlers: python: options: show_source: false show_bases: false members_order: source docstring_style: google nav: - Overview: index.md - Syntax Reference: syntax.md - Built-in Helpers: helpers.md - Python API: api.md - Security: security.md pydantic-handlebars-0.2.1/pyproject.toml000066400000000000000000000043061520472041100203410ustar00rootroot00000000000000[build-system] requires = ['hatchling'] build-backend = 'hatchling.build' [project] name = 'pydantic-handlebars' version = '0.2.1' description = 'Handlebars template engine for composing LLM prompts, built on Pydantic' readme = 'README.md' requires-python = '>=3.10' license = 'MIT' authors = [ { name = 'Pydantic Services Inc.' }, ] classifiers = [ 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Topic :: Software Development :: Libraries', 'Topic :: Text Processing :: Markup', 'Typing :: Typed', ] dependencies = ['pydantic>=2.0'] [project.urls] Homepage = 'https://github.com/pydantic/pydantic-handlebars' Source = 'https://github.com/pydantic/pydantic-handlebars' Issues = 'https://github.com/pydantic/pydantic-handlebars/issues' [dependency-groups] dev = [ 'pytest', 'pytest-xdist', 'pytest-examples', 'coverage', 'inline-snapshot', 'dirty-equals', 'hypothesis', 'pydantic>=2.0', ] lint = [ 'ruff>=0.14', 'pyright>=1.1.408', ] docs = [ 'mkdocs-material', 'mkdocstrings-python', ] [tool.hatch.build.targets.wheel] packages = ['src/pydantic_handlebars'] [tool.ruff] line-length = 120 target-version = 'py310' [tool.ruff.lint] extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I', 'D', 'TID251'] [tool.ruff.lint.per-file-ignores] 'tests/**/*.py' = ['D'] 'docs/**/*.py' = ['D'] [tool.ruff.lint.flake8-quotes] inline-quotes = 'single' [tool.ruff.lint.mccabe] max-complexity = 15 [tool.ruff.lint.pydocstyle] convention = 'google' [tool.ruff.format] quote-style = 'single' [tool.pyright] pythonVersion = '3.10' typeCheckingMode = 'strict' [tool.pytest.ini_options] xfail_strict = true filterwarnings = ['error'] [tool.coverage.run] branch = true source = ['pydantic_handlebars', 'tests'] omit = ['tests/test_fuzz.py'] [tool.coverage.report] fail_under = 100 show_missing = true exclude_lines = [ 'pragma: no cover', 'assert_never', 'if TYPE_CHECKING:', ] pydantic-handlebars-0.2.1/src/000077500000000000000000000000001520472041100162115ustar00rootroot00000000000000pydantic-handlebars-0.2.1/src/pydantic_handlebars/000077500000000000000000000000001520472041100222075ustar00rootroot00000000000000pydantic-handlebars-0.2.1/src/pydantic_handlebars/__init__.py000066400000000000000000000135611520472041100243260ustar00rootroot00000000000000"""Handlebars template engine for composing LLM prompts, built on Pydantic.""" from __future__ import annotations from importlib.metadata import version from typing import Any, TypeVar, overload from pydantic import TypeAdapter from pydantic_handlebars._compiler import HelperFunc, HelperOptions from pydantic_handlebars._environment import CompiledTemplate, HandlebarsEnvironment from pydantic_handlebars._exceptions import ( HandlebarsError, HandlebarsParseError, HandlebarsRuntimeError, TemplateSchemaError, ) from pydantic_handlebars._extract import extract_dependencies from pydantic_handlebars._pydantic import CompiledTypedTemplate, TypedCompiler from pydantic_handlebars._schema import ( CompatibilityResult, IssueSeverity, TemplateIssue, check_template_compatibility, ) from pydantic_handlebars._utils import SafeString, escape_expression T = TypeVar('T') __version__ = version('pydantic-handlebars') __all__ = [ '__version__', 'compile', 'render', 'typed_compiler', 'SafeString', 'HandlebarsError', 'HandlebarsParseError', 'HandlebarsRuntimeError', 'TemplateSchemaError', 'HandlebarsEnvironment', 'CompiledTemplate', 'CompiledTypedTemplate', 'TypedCompiler', 'HelperFunc', 'HelperOptions', 'escape_expression', 'check_template_compatibility', 'extract_dependencies', 'CompatibilityResult', 'TemplateIssue', 'IssueSeverity', ] # Default environment for module-level functions _default_env = HandlebarsEnvironment() def render( source: str, context: dict[str, Any] | None = None, *, strict: bool = False, open_delim: str = '{{', close_delim: str = '}}', ) -> str: """Render a Handlebars template string with the given context. This is a convenience function that uses a default environment with standard Handlebars helpers registered. Args: source: The Handlebars template string. context: The data context for rendering. strict: If `True`, raise :class:`HandlebarsRuntimeError` when a referenced path is not present on the context. An explicit `None` value is still allowed. Defaults to `False` (missing references render as empty strings, matching Handlebars.js default behaviour). open_delim: Open mustache delimiter. Defaults to `{{`. Non-default pairs let you embed Handlebars output that should preserve `{{...}}`-shaped placeholders for a later rendering pass. close_delim: Close mustache delimiter. Defaults to `}}`. Returns: The rendered string. Example: ```python result = render('Hello {{name}}!', {'name': 'World'}) assert result == 'Hello World!' # Non-default delimiters leave the literal `{{user}}` alone: result = render('Hi @{name}@, see {{user}} later', {'name': 'A'}, open_delim='@{', close_delim='}@') assert result == 'Hi A, see {{user}} later' ``` """ if strict or open_delim != '{{' or close_delim != '}}': env = HandlebarsEnvironment(strict=strict, open_delim=open_delim, close_delim=close_delim) return env.render(source, context if context is not None else {}) return _default_env.render(source, context if context is not None else {}) @overload def compile(source: str) -> CompiledTemplate: ... # pragma: no cover @overload def compile( source: str, tp: type[T], *, serialize_as_any: bool = ... ) -> CompiledTypedTemplate[T]: ... # pragma: no cover def compile( source: str, tp: type[Any] | None = None, *, serialize_as_any: bool = False ) -> CompiledTemplate | CompiledTypedTemplate[Any]: """Compile a Handlebars template string into a reusable callable. When called with just a source string, returns a `CompiledTemplate`. When called with a source string and a type, validates the template against the type's JSON schema and returns a `CompiledTypedTemplate`. Args: source: The Handlebars template string. tp: Optional Pydantic model type or any type supported by TypeAdapter. serialize_as_any: If True, serialize subclass fields instead of only the declared type's fields. Only applies when `tp` is provided. Returns: A `CompiledTemplate` or `CompiledTypedTemplate` callable. Example: ```python template = compile('Hello {{name}}!') result = template.render({'name': 'World'}) assert result == 'Hello World!' ``` """ if tp is not None: type_adapter: TypeAdapter[Any] = TypeAdapter(tp) json_schema = type_adapter.json_schema(mode='serialization') helper_names = set(_default_env._helpers.keys()) # pyright: ignore[reportPrivateUsage] check_template_compatibility(source, json_schema, raise_on_error=True, helpers=helper_names) fn = _default_env._compile_fn(source) # pyright: ignore[reportPrivateUsage] return CompiledTypedTemplate(fn=fn, type_adapter=type_adapter, serialize_as_any=serialize_as_any) return _default_env.compile(source) def typed_compiler( tp: type[T], *, env: HandlebarsEnvironment | None = None, optional_field_severity: IssueSeverity = 'error', serialize_as_any: bool = False, ) -> TypedCompiler[T]: """Create a typed compiler for repeated compilation against a type. Args: tp: The Pydantic model type or any type supported by TypeAdapter. env: Optional HandlebarsEnvironment. Uses a default if not provided. optional_field_severity: Severity for optional field access issues. serialize_as_any: If True, serialize subclass fields instead of only the declared type's fields. Returns: A `TypedCompiler` instance. """ return TypedCompiler( tp, env=env, optional_field_severity=optional_field_severity, serialize_as_any=serialize_as_any ) pydantic-handlebars-0.2.1/src/pydantic_handlebars/_ast_nodes.py000066400000000000000000000121061520472041100246770ustar00rootroot00000000000000"""AST node definitions for the Handlebars parser.""" from __future__ import annotations from dataclasses import dataclass, field @dataclass(slots=True) class PathExpression: """A path expression like `foo`, `foo.bar`, `../foo`, `this`, `@root`. Attributes: parts: The individual path segments (e.g., ['foo', 'bar'] for 'foo.bar'). original: The original text of the path. depth: Number of parent scope references (../ count). is_this: Whether the path starts with 'this' or '.'. data: Whether this is a @data variable (e.g., @root, @index). """ parts: list[str] original: str depth: int = 0 is_this: bool = False data: bool = False @dataclass(slots=True) class StringLiteral: """A string literal like `"hello"` or `'hello'`.""" value: str original: str @dataclass(slots=True) class NumberLiteral: """A numeric literal like `42` or `3.14` or `-1`.""" value: int | float original: str @dataclass(slots=True) class BooleanLiteral: """A boolean literal: `true` or `false`.""" value: bool original: str @dataclass(slots=True) class UndefinedLiteral: """The `undefined` literal.""" original: str = 'undefined' @dataclass(slots=True) class NullLiteral: """The `null` literal.""" original: str = 'null' @dataclass(slots=True) class SubExpression: """A subexpression like `(helper arg1 arg2 key=value)`. Attributes: path: The helper path. params: Positional arguments. hash_pairs: Keyword arguments. """ path: PathExpression params: list[Expression] hash_pairs: dict[str, Expression] # Union type for all expression nodes Expression = ( PathExpression | StringLiteral | NumberLiteral | BooleanLiteral | UndefinedLiteral | NullLiteral | SubExpression ) @dataclass(slots=True) class StripFlags: """Whitespace control flags. Attributes: open_standalone: Strip whitespace before open tag. close_standalone: Strip whitespace after close tag. """ open_standalone: bool = False close_standalone: bool = False @dataclass(slots=True) class ContentStatement: """Raw text content between Handlebars expressions. Attributes: value: The text content. original: The original text (before whitespace stripping). """ value: str original: str @dataclass(slots=True) class MustacheStatement: """A mustache expression like `{{foo}}`, `{{{foo}}}`, or `{{&foo}}`. Attributes: path: The expression path or helper name. params: Positional arguments to a helper. hash_pairs: Hash arguments (key=value pairs). escaped: Whether the output should be HTML-escaped. strip: Whitespace stripping configuration. """ path: Expression params: list[Expression] hash_pairs: dict[str, Expression] escaped: bool = True strip: StripFlags = field(default_factory=StripFlags) @dataclass(slots=True) class CommentStatement: """A comment: `{{! ... }}` or `{{!-- ... --}}`. Attributes: value: The comment text. strip: Whitespace stripping configuration. """ value: str strip: StripFlags = field(default_factory=StripFlags) @dataclass(slots=True) class BlockStatement: """A block expression like `{{#if condition}}...{{/if}}`. Attributes: path: The block helper path. params: Positional arguments. hash_pairs: Hash arguments. body: The main body program. inverse: The else/inverse body program (if any). open_strip: Whitespace stripping for the open tag. close_strip: Whitespace stripping for the close tag. inverse_strip: Whitespace stripping for the inverse tag. block_params: Block parameter names (e.g., |item index|). chained: Whether this block is part of a chained else-if. """ path: Expression params: list[Expression] hash_pairs: dict[str, Expression] body: Program inverse: Program | None = None open_strip: StripFlags = field(default_factory=StripFlags) close_strip: StripFlags = field(default_factory=StripFlags) inverse_strip: StripFlags = field(default_factory=StripFlags) block_params: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] chained: bool = False @dataclass(slots=True) class RawBlock: """A raw block like `{{{{raw}}}}...{{{{/raw}}}}`. Content inside is not processed. Attributes: path: The block name path. body: The raw content string. """ path: PathExpression body: str # Union type for all statement nodes Statement = MustacheStatement | ContentStatement | CommentStatement | BlockStatement | RawBlock @dataclass(slots=True) class Program: """The top-level AST node representing a complete template or block body. Attributes: body: List of statements in this program. block_params: Block parameter names if this is a block body. """ body: list[Statement] block_params: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] pydantic-handlebars-0.2.1/src/pydantic_handlebars/_compiler.py000066400000000000000000000547731520472041100245520ustar00rootroot00000000000000"""Compiler/renderer for Handlebars templates. Walks the AST and produces output given a context. """ from __future__ import annotations from typing import Any, Protocol, cast from pydantic_handlebars._ast_nodes import ( BlockStatement, BooleanLiteral, CommentStatement, ContentStatement, Expression, MustacheStatement, NullLiteral, NumberLiteral, PathExpression, Program, Statement, StringLiteral, SubExpression, UndefinedLiteral, ) from pydantic_handlebars._exceptions import HandlebarsRuntimeError from pydantic_handlebars._utils import SafeString, escape_expression, is_falsy, to_string # Maximum nesting depth to prevent stack overflow MAX_DEPTH = 100 # Maximum output size (10MB default) MAX_OUTPUT_SIZE = 10 * 1024 * 1024 # Sentinel returned by `_get_property` when the requested key/index does not # exist on the underlying container. Distinct from an explicit `None` value, # which is allowed even in strict mode. _MISSING: Any = object() class HelperOptions: """Options passed to helper functions. Attributes: fn: Function to render the block content with a given context. inverse: Function to render the else/inverse content with a given context. hash: Hash arguments passed to the helper. data: Data variables (@root, @index, etc.). block_params: Block parameter values. """ def __init__( self, *, fn: _BlockFn | None = None, inverse: _BlockFn | None = None, hash: dict[str, Any] | None = None, data: dict[str, Any] | None = None, block_params: list[str] | None = None, ) -> None: self.fn = fn or _noop_block self.inverse = inverse or _noop_block self.hash: dict[str, Any] = hash or {} self.data: dict[str, Any] = data or {} self.block_params: list[str] = block_params or [] @property def name(self) -> str: """Get the helper name (for the log helper).""" return self.data.get('_name', '') class _BlockFn(Protocol): def __call__( # pragma: no cover self, context: Any = None, *, data: dict[str, Any] | None = None, block_params: list[Any] | None = None ) -> str: ... def _noop_block( context: Any = None, *, data: dict[str, Any] | None = None, block_params: list[Any] | None = None, ) -> str: return '' class HelperFunc(Protocol): """Protocol for helper functions. All helpers receive positional args (resolved from template expressions against the user's context) and an `options` keyword providing block rendering capabilities and hash arguments. Using `object` rather than `Any` for positional args prevents helpers from falsely claiming args are pre-validated as specific types — the type checker will reject e.g. `def my_helper(val: float, ...)` since args could be anything from the user's context. Helpers that want to opt out can use `Any` explicitly. """ def __call__(self, /, *args: object, options: HelperOptions) -> Any: ... # pragma: no cover class Scope: """Represents a scope in the template execution. Manages the context chain, data variables, and block parameters. """ def __init__( self, context: Any, parent: Scope | None = None, data: dict[str, Any] | None = None, block_params: dict[str, Any] | None = None, ) -> None: self.context = context self.parent = parent self.data: dict[str, Any] = dict(data) if data else {} self.block_params: dict[str, Any] = block_params or {} # Set @root if parent is None: self.data['root'] = context elif 'root' not in self.data: self.data['root'] = parent.data.get('root', context) def child( self, context: Any, data: dict[str, Any] | None = None, block_params: dict[str, Any] | None = None, ) -> Scope: """Create a child scope.""" merged_data = dict(self.data) if data: merged_data.update(data) return Scope(context, parent=self, data=merged_data, block_params=block_params) def lookup_data(self, name: str) -> Any: """Look up a @data variable.""" if name in self.data: return self.data[name] if self.parent is not None: return self.parent.lookup_data(name) return None def lookup_data_strict(self, name: str) -> tuple[Any, bool]: """Look up a @data variable, returning `(value, found)`. The `found` flag lets strict-mode callers distinguish a missing key from one whose explicit value is `None`. """ if name in self.data: return self.data[name], True if self.parent is not None: return self.parent.lookup_data_strict(name) return None, False def lookup_block_param(self, name: str) -> tuple[bool, Any]: """Look up a block parameter by name. Returns: Tuple of (found, value). """ if name in self.block_params: return True, self.block_params[name] if self.parent is not None: return self.parent.lookup_block_param(name) return False, None def get_parent_context(self, depth: int) -> Any: """Walk up the scope chain to get a parent context.""" scope: Scope = self for _ in range(depth): if scope.parent is None: return None scope = scope.parent return scope.context def get_parent_data(self, depth: int) -> dict[str, Any]: """Walk up the scope chain to get parent data.""" scope: Scope = self for _ in range(depth): if scope.parent is None: return {} scope = scope.parent return scope.data class Compiler: """Compiles/renders a Handlebars AST into a string.""" def __init__( self, helpers: dict[str, HelperFunc] | None = None, *, auto_escape: bool = False, max_depth: int = MAX_DEPTH, max_output_size: int = MAX_OUTPUT_SIZE, strict: bool = False, ) -> None: self._helpers: dict[str, HelperFunc] = helpers or {} self._auto_escape = auto_escape self._max_depth = max_depth self._max_output_size = max_output_size self._strict = strict self._depth = 0 self._output_size = 0 def render(self, program: Program, context: Any) -> str: """Render a program AST with the given context. Args: program: The AST to render. context: The data context. Returns: The rendered string. """ self._output_size = 0 scope = Scope(context) return self._render_program(program, scope) def _render_program(self, program: Program, scope: Scope) -> str: """Render a program (sequence of statements).""" self._depth += 1 try: if self._depth > self._max_depth: raise HandlebarsRuntimeError(f'Maximum nesting depth of {self._max_depth} exceeded') parts: list[str] = [] for stmt in program.body: size_before = self._output_size rendered = self._render_statement(stmt, scope) # Use size_before + len(rendered) instead of incrementing, to avoid # double-counting bytes already tracked by nested _render_program calls. self._output_size = size_before + len(rendered) if self._output_size > self._max_output_size: raise HandlebarsRuntimeError(f'Output size exceeds maximum of {self._max_output_size} bytes') parts.append(rendered) # Apply whitespace control return self._apply_whitespace_control(program.body, parts) finally: self._depth -= 1 def _apply_whitespace_control(self, body: list[Statement], parts: list[str]) -> str: """Apply whitespace control (~ markers) to rendered parts.""" adjusted = list(parts) for i, stmt in enumerate(body): strip = _get_strip_flags(stmt) if strip is None: continue open_strip, close_strip = strip # Strip whitespace from preceding content if open_strip and i > 0: adjusted[i - 1] = _rstrip_whitespace(adjusted[i - 1]) # Strip whitespace from following content if close_strip and i < len(adjusted) - 1: adjusted[i + 1] = _lstrip_whitespace(adjusted[i + 1]) return ''.join(adjusted) def _render_statement(self, stmt: Statement, scope: Scope) -> str: """Render a single statement.""" if isinstance(stmt, ContentStatement): return stmt.value if isinstance(stmt, MustacheStatement): return self._render_mustache(stmt, scope) if isinstance(stmt, CommentStatement): return '' if isinstance(stmt, BlockStatement): return self._render_block(stmt, scope) # stmt is narrowed to RawBlock at this point return stmt.body def _render_mustache(self, stmt: MustacheStatement, scope: Scope) -> str: """Render a mustache expression.""" # Check if this is a helper call (not a data variable, not a complex path) if ( isinstance(stmt.path, PathExpression) and not stmt.path.data and stmt.path.depth == 0 and not stmt.path.is_this ): helper_name = stmt.path.original if helper_name in self._helpers: # Helper always takes priority when there are params/hash # or when it's a single-part name registered as helper if stmt.params or stmt.hash_pairs or len(stmt.path.parts) == 1: return self._call_helper_mustache(helper_name, stmt, scope) # Check for subexpression if isinstance(stmt.path, SubExpression): value = self._eval_subexpression(stmt.path, scope) else: value = self._eval_expression(stmt.path, scope) result = to_string(value) if self._auto_escape and stmt.escaped and not isinstance(value, SafeString): result = escape_expression(result) return result def _call_helper_mustache(self, name: str, stmt: MustacheStatement, scope: Scope) -> str: """Call a helper from a mustache expression.""" helper = self._helpers[name] args = [self._eval_expression(p, scope) for p in stmt.params] hash_args = {k: self._eval_expression(v, scope) for k, v in stmt.hash_pairs.items()} options = HelperOptions( hash=hash_args, data=dict(scope.data, _name=name), ) if args: result = helper(*args, options=options) else: result = helper(scope.context, options=options) result_str = to_string(result) if self._auto_escape and stmt.escaped and not isinstance(result, SafeString): result_str = escape_expression(result_str) return result_str def _render_block(self, stmt: BlockStatement, scope: Scope) -> str: """Render a block statement.""" if isinstance(stmt.path, PathExpression): helper_name = stmt.path.original # Check for registered block helpers if helper_name in self._helpers: return self._call_block_helper(helper_name, stmt, scope) # No helper - use context-based block behavior value = self._eval_expression(stmt.path, scope) if is_falsy(value): # Render inverse if stmt.inverse is not None: result = self._render_program(stmt.inverse, scope) return _apply_inverse_strip(stmt, result) return '' if isinstance(value, list): # Iterate return self._render_each_inline(cast('list[Any]', value), stmt, scope) if isinstance(value, dict): # Change context child = scope.child(value) result = self._render_program(stmt.body, child) return _apply_body_strip(stmt, result) if value is True: result = self._render_program(stmt.body, scope) return _apply_body_strip(stmt, result) # Use value as context child = scope.child(value) result = self._render_program(stmt.body, child) return _apply_body_strip(stmt, result) def _render_each_inline(self, items: list[Any], stmt: BlockStatement, scope: Scope) -> str: """Render a list using inline block iteration.""" if not items: if stmt.inverse is not None: result = self._render_program(stmt.inverse, scope) return _apply_inverse_strip(stmt, result) return '' parts: list[str] = [] for i, item in enumerate(items): data = { 'index': i, 'first': i == 0, 'last': i == len(items) - 1, } bp: dict[str, Any] = {} if stmt.block_params: bp[stmt.block_params[0]] = item if len(stmt.block_params) > 1: bp[stmt.block_params[1]] = i child = scope.child(item, data=data, block_params=bp) parts.append(self._render_program(stmt.body, child)) return _apply_body_strip(stmt, ''.join(parts)) def _call_block_helper(self, name: str, stmt: BlockStatement, scope: Scope) -> str: """Call a registered block helper.""" helper = self._helpers[name] args = [self._eval_expression(p, scope) for p in stmt.params] hash_args = {k: self._eval_expression(v, scope) for k, v in stmt.hash_pairs.items()} compiler = self def fn( context: Any = None, *, data: dict[str, Any] | None = None, block_params: list[Any] | None = None ) -> str: ctx = context if context is not None else scope.context extra_data = data or {} bp: dict[str, Any] = {} if block_params and stmt.block_params: for j, bp_name in enumerate(stmt.block_params): if j < len(block_params): bp[bp_name] = block_params[j] child = scope.child(ctx, data=extra_data, block_params=bp) result = compiler._render_program(stmt.body, child) return _apply_body_strip(stmt, result) def inverse( context: Any = None, *, data: dict[str, Any] | None = None, block_params: list[Any] | None = None ) -> str: if stmt.inverse is None: return '' ctx = context if context is not None else scope.context extra_data = data or {} bp: dict[str, Any] = {} if block_params and stmt.block_params: for j, bp_name in enumerate(stmt.block_params): if j < len(block_params): bp[bp_name] = block_params[j] # Handle chained blocks (else if) if stmt.inverse.body and isinstance(stmt.inverse.body[0], BlockStatement) and stmt.inverse.body[0].chained: result = compiler._render_block(stmt.inverse.body[0], scope) return _apply_inverse_strip(stmt, result) child = scope.child(ctx, data=extra_data, block_params=bp) result = compiler._render_program(stmt.inverse, child) return _apply_inverse_strip(stmt, result) options = HelperOptions( fn=fn, inverse=inverse, hash=hash_args, data=dict(scope.data, _name=name), block_params=stmt.block_params, ) result = helper(scope.context, *args, options=options) return to_string(result) def _eval_expression(self, expr: Expression, scope: Scope) -> Any: """Evaluate an expression and return its value.""" if isinstance(expr, StringLiteral): return expr.value if isinstance(expr, NumberLiteral): return expr.value if isinstance(expr, BooleanLiteral): return expr.value if isinstance(expr, (NullLiteral, UndefinedLiteral)): return None if isinstance(expr, SubExpression): return self._eval_subexpression(expr, scope) # After narrowing away all other Expression types, only PathExpression remains return self._resolve_path(expr, scope) def _eval_subexpression(self, expr: SubExpression, scope: Scope) -> Any: """Evaluate a subexpression (nested helper call).""" helper_name = expr.path.original if helper_name not in self._helpers: raise HandlebarsRuntimeError(f'Unknown helper: {helper_name}') helper = self._helpers[helper_name] args = [self._eval_expression(p, scope) for p in expr.params] hash_args = {k: self._eval_expression(v, scope) for k, v in expr.hash_pairs.items()} options = HelperOptions(hash=hash_args, data=scope.data) return helper(*args, options=options) def _resolve_path(self, path: PathExpression, scope: Scope) -> Any: """Resolve a path expression to its value.""" # @data variables if path.data: return self._resolve_data_path(path, scope) # Block parameters take priority if path.parts and path.depth == 0 and not path.is_this: found, value = scope.lookup_block_param(path.parts[0]) if found: for part in path.parts[1:]: value = _get_property(value, part) if value is _MISSING: if self._strict: self._raise_missing(path, part) return None return value # Get the starting context if path.depth > 0: context = scope.get_parent_context(path.depth) else: context = scope.context # 'this' with no parts returns the context itself if path.is_this and not path.parts: return context # Resolve parts value: Any = context for part in path.parts: value = _get_property(value, part) if value is _MISSING: if self._strict: self._raise_missing(path, part) return None if value is None: return None return value def _resolve_data_path(self, path: PathExpression, scope: Scope) -> Any: """Resolve a @data path, handling parent references.""" # Handle parent data access: @../index if path.depth > 0: data = scope.get_parent_data(path.depth) if path.parts: value: Any = data.get(path.parts[0], _MISSING) if value is _MISSING: if self._strict: self._raise_missing(path, path.parts[0]) return None for part in path.parts[1:]: value = _get_property(value, part) if value is _MISSING: if self._strict: self._raise_missing(path, part) return None return value return None if path.parts: value, found = scope.lookup_data_strict(path.parts[0]) if not found: if self._strict: self._raise_missing(path, path.parts[0]) return None for part in path.parts[1:]: value = _get_property(value, part) if value is _MISSING: if self._strict: self._raise_missing(path, part) return None return value return None def _raise_missing(self, path: PathExpression, missing_part: str) -> None: """Raise a strict-mode error for a missing context lookup.""" prefix = '@' if path.data else '' raise HandlebarsRuntimeError( f'Strict mode: {prefix}{path.original!r} references missing context key {missing_part!r}' ) def _get_property(obj: Any, name: str) -> Any: """Safely get a property from an object, returning `_MISSING` if absent. The sentinel distinguishes "no such key/index" from "key present, value is `None`". Non-strict callers should treat the sentinel as `None`; strict callers should raise. """ if obj is None or obj is _MISSING: return _MISSING if isinstance(obj, dict): return cast('dict[str, Any]', obj).get(name, _MISSING) if isinstance(obj, (list, tuple)): try: return cast('list[Any]', obj)[int(name)] except (ValueError, IndexError): return _MISSING return _MISSING def _get_strip_flags(stmt: Statement) -> tuple[bool, bool] | None: """Get the strip flags (open, close) for a statement.""" if isinstance(stmt, MustacheStatement): return stmt.strip.open_standalone, stmt.strip.close_standalone if isinstance(stmt, BlockStatement): return stmt.open_strip.open_standalone, stmt.close_strip.close_standalone if isinstance(stmt, CommentStatement): return stmt.strip.open_standalone, stmt.strip.close_standalone return None def _rstrip_whitespace(s: str) -> str: """Strip trailing whitespace including the preceding newline.""" return s.rstrip(' \t\r\n') def _lstrip_whitespace(s: str) -> str: """Strip leading whitespace including the following newline.""" return s.lstrip(' \t\r\n') def _apply_body_strip(stmt: BlockStatement, result: str) -> str: """Apply inner whitespace control to rendered body content. The body is bounded by the open tag on the left and either the inverse tag (if present) or the close tag on the right. """ if stmt.open_strip.close_standalone: result = _lstrip_whitespace(result) if stmt.inverse is not None: if stmt.inverse_strip.open_standalone: result = _rstrip_whitespace(result) else: if stmt.close_strip.open_standalone: result = _rstrip_whitespace(result) return result def _apply_inverse_strip(stmt: BlockStatement, result: str) -> str: """Apply inner whitespace control to rendered inverse content. The inverse is bounded by the inverse tag on the left and the close tag on the right. """ if stmt.inverse_strip.close_standalone: result = _lstrip_whitespace(result) if stmt.close_strip.open_standalone: result = _rstrip_whitespace(result) return result pydantic-handlebars-0.2.1/src/pydantic_handlebars/_environment.py000066400000000000000000000163531520472041100252740ustar00rootroot00000000000000"""HandlebarsEnvironment class for managing helpers and rendering.""" from __future__ import annotations from collections.abc import Callable from typing import Any, TypeVar, overload from pydantic_core import to_jsonable_python from pydantic_handlebars._compiler import MAX_DEPTH, MAX_OUTPUT_SIZE, Compiler, HelperFunc from pydantic_handlebars._exceptions import HandlebarsRuntimeError from pydantic_handlebars._helpers import get_extra_helpers, get_standard_helpers from pydantic_handlebars._parser import parse F = TypeVar('F', bound=Callable[..., Any]) class CompiledTemplate: """A compiled Handlebars template.""" def __init__(self, fn: Callable[..., str]) -> None: self._fn = fn def render(self, context: dict[str, Any] | None = None) -> str: """Render the template with the given context.""" ctx = context if context is not None else {} try: serialized = to_jsonable_python(ctx) except ValueError as e: raise HandlebarsRuntimeError(f'Context serialization failed: {e}') from e return self._fn(serialized) class HandlebarsEnvironment: """An environment for rendering Handlebars templates with custom helpers. The environment manages a set of helpers (both built-in and user-registered) and provides methods for compiling and rendering templates. Example: ```python env = HandlebarsEnvironment() @env.helper def shout(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() + '!!!' result = env.render('{{shout name}}', {'name': 'world'}) assert result == 'WORLD!!!' ``` """ def __init__( self, *, extra_helpers: bool = False, auto_escape: bool = False, max_depth: int = MAX_DEPTH, max_output_size: int = MAX_OUTPUT_SIZE, strict: bool = False, open_delim: str = '{{', close_delim: str = '}}', ) -> None: self._helpers: dict[str, HelperFunc] = get_standard_helpers() if extra_helpers: self._helpers.update(get_extra_helpers()) self._auto_escape = auto_escape self._max_depth = max_depth self._max_output_size = max_output_size self._strict = strict self._open_delim = open_delim self._close_delim = close_delim @property def open_delim(self) -> str: """The open mustache delimiter this environment compiles templates with. Defaults to `{{`. Templates compiled or rendered through this environment look for this string to mark the start of an expression. """ return self._open_delim @property def close_delim(self) -> str: """The close mustache delimiter this environment compiles templates with. Defaults to `}}`. Non-default delimiter pairs disable the triple-stache (`{{{...}}}`) and raw-block (`{{{{...}}}}`) variants since those forms only have unambiguous extensions for the default pair. """ return self._close_delim @property def strict(self) -> bool: """Whether strict mode is enabled for templates rendered by this environment. When `True`, referencing a path that does not exist on the context raises :class:`HandlebarsRuntimeError` instead of silently rendering as an empty string. An explicit `None` value in the context is still allowed — strict mode only fires for genuinely missing keys. """ return self._strict @property def auto_escape(self) -> bool: """Whether HTML auto-escaping is enabled for `{{expression}}` output. When `False` (the default), no escaping is applied — appropriate for non-HTML use cases like prompt generation. When `True`, double-stache expressions are HTML-escaped. Triple-stache `{{{expression}}}` is always unescaped regardless of this setting. """ return self._auto_escape @overload def helper(self, fn: F) -> F: ... # pragma: no cover @overload def helper(self, fn: str) -> Callable[[F], F]: ... # pragma: no cover def helper(self, fn: F | str) -> F | Callable[[F], F]: """Register a helper function. Can be used as a decorator (with or without a name argument): ```python @env.helper def my_helper(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() @env.helper('custom-name') def my_helper(*args: object, options: HelperOptions) -> str: return str(args[0]).upper() ``` Args: fn: The helper function, or a name string for use as a decorator factory. Returns: The registered function, or a decorator. """ if isinstance(fn, str): name = fn def decorator(func: F) -> F: self._helpers[name] = func return func return decorator else: self._helpers[fn.__name__] = fn return fn def register_helper(self, name: str, fn: HelperFunc) -> None: """Register a helper function with an explicit name. Args: name: The name to register the helper under. fn: The helper function. """ self._helpers[name] = fn def unregister_helper(self, name: str) -> None: """Unregister a helper by name. Args: name: The name of the helper to remove. Raises: KeyError: If the helper is not registered. """ if name not in self._helpers: raise KeyError(f'Helper not found: {name}') del self._helpers[name] def _compile_fn(self, source: str) -> Callable[..., str]: """Compile to a raw callable (internal use).""" program = parse(source, open_delim=self._open_delim, close_delim=self._close_delim) helpers = dict(self._helpers) auto_escape = self._auto_escape max_depth = self._max_depth max_output_size = self._max_output_size strict = self._strict def template(context: Any = None) -> str: ctx: Any = context if context is not None else {} compiler = Compiler( helpers=helpers, auto_escape=auto_escape, max_depth=max_depth, max_output_size=max_output_size, strict=strict, ) return compiler.render(program, ctx) return template def compile(self, source: str) -> CompiledTemplate: """Compile a template string into a reusable callable. Args: source: The Handlebars template string. Returns: A `CompiledTemplate` that takes a context dict and returns a rendered string. """ return CompiledTemplate(self._compile_fn(source)) def render(self, source: str, context: Any = None) -> str: """Render a template string with the given context. Args: source: The Handlebars template string. context: The data context for rendering. Returns: The rendered string. """ template = self.compile(source) return template.render(context if context is not None else {}) pydantic-handlebars-0.2.1/src/pydantic_handlebars/_exceptions.py000066400000000000000000000026141520472041100251040ustar00rootroot00000000000000"""Exception hierarchy for pydantic_handlebars.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from pydantic_handlebars._schema import CompatibilityResult class HandlebarsError(Exception): """Base exception for all Handlebars errors.""" class HandlebarsParseError(HandlebarsError): """Raised when a template cannot be parsed. Attributes: message: The error message. line: The line number where the error occurred (1-based). column: The column number where the error occurred (1-based). """ def __init__(self, message: str, *, line: int | None = None, column: int | None = None) -> None: self.line = line self.column = column if line is not None and column is not None: full_message = f'{message} at line {line}, column {column}' elif line is not None: full_message = f'{message} at line {line}' else: full_message = message super().__init__(full_message) class HandlebarsRuntimeError(HandlebarsError): """Raised when an error occurs during template rendering.""" class TemplateSchemaError(HandlebarsError): """Raised when templates are incompatible with the provided JSON schema.""" def __init__(self, message: str, *, result: CompatibilityResult) -> None: self.result = result super().__init__(message) pydantic-handlebars-0.2.1/src/pydantic_handlebars/_extract.py000066400000000000000000000271601520472041100244000ustar00rootroot00000000000000"""Static dependency extraction for Handlebars templates. Walks a parsed template AST and reports which top-level context keys the template depends on, without rendering it. Useful for callers that need to know which variables to provide before invoking a template — for example, prompt-composition systems that sync a template's required inputs to a database, or tooling that displays "this template uses fields X, Y, Z". """ from __future__ import annotations from dataclasses import dataclass from pydantic_handlebars._ast_nodes import ( BlockStatement, Expression, MustacheStatement, PathExpression, Program, Statement, SubExpression, ) from pydantic_handlebars._helpers import get_extra_helpers, get_standard_helpers from pydantic_handlebars._parser import parse # Built-in block helpers whose first positional argument names the new # context — references inside the block body at depth 0 are against that # new context, not the parent. Block helpers not in this set (custom user # helpers, `if`, `unless`) leave the context unchanged for their body. _CONTEXT_SHIFTING_HELPERS = frozenset({'each', 'with'}) @dataclass(slots=True) class _ExtractScope: """Scope tracking for the extractor. Mirrors the shape the renderer maintains at runtime: each scope has a parent, a set of block parameters that locally shadow context names, and a flag indicating whether the scope shifted the context (`each` / `with`). """ parent: _ExtractScope | None block_params: frozenset[str] = frozenset() is_opaque: bool = False """`True` when this scope sits inside `{{#each}}` / `{{#with}}`. References at `depth == 0` inside an opaque scope are against the shifted context (the iteration item or the new context), so they do not contribute to the *top-level* dependency set. Only references that walk back up to the root through `../` count. """ class _Extractor: """Walks an AST and collects top-level context references.""" def __init__(self, known_helpers: set[str]) -> None: self._known_helpers = known_helpers self._deps: set[str] = set() def collect(self, program: Program) -> set[str]: """Walk *program* and return the set of top-level references found.""" root = _ExtractScope(parent=None) self._walk_program(program, root) return self._deps # ------------------------------------------------------------------ # Scope traversal # ------------------------------------------------------------------ def _walk_program(self, program: Program, scope: _ExtractScope) -> None: for stmt in program.body: self._walk_statement(stmt, scope) def _walk_statement(self, stmt: Statement, scope: _ExtractScope) -> None: if isinstance(stmt, MustacheStatement): self._walk_mustache(stmt, scope) elif isinstance(stmt, BlockStatement): self._walk_block(stmt, scope) # ContentStatement, CommentStatement, RawBlock have no expression # references that would contribute to dependencies. def _walk_mustache(self, stmt: MustacheStatement, scope: _ExtractScope) -> None: # The mustache path itself: helper name or a context reference. The # parser only produces PathExpression or SubExpression here. if isinstance(stmt.path, SubExpression): self._walk_subexpression(stmt.path, scope) else: assert isinstance(stmt.path, PathExpression) self._record_path_or_helper(stmt.path, scope, has_args=bool(stmt.params or stmt.hash_pairs)) # Positional and hash arguments are always values from the context # (or further subexpressions), never helper invocations themselves. for param in stmt.params: self._walk_expression(param, scope) for value in stmt.hash_pairs.values(): self._walk_expression(value, scope) def _walk_block(self, stmt: BlockStatement, scope: _ExtractScope) -> None: # The parser only ever produces a PathExpression for a block's helper # name (see `_parse_block` in `_parser.py`). The `BlockStatement # .path` field is typed loosely as `Expression` but a subexpression # in that position is rejected at parse time, so we don't handle it # here. assert isinstance(stmt.path, PathExpression) name = stmt.path.original is_shifting = name in _CONTEXT_SHIFTING_HELPERS and name in self._known_helpers # The block helper *name* never counts as a dependency. Its # positional arguments do, but those are walked below. if name not in self._known_helpers: # User-provided block helper whose name is not registered: # treat it as a context reference so we don't silently drop # an unknown name. (Matches what the renderer would do — it # would try to look up the value on the context.) self._record_path(stmt.path, scope) for param in stmt.params: self._walk_expression(param, scope) for value in stmt.hash_pairs.values(): self._walk_expression(value, scope) body_scope = _ExtractScope( parent=scope, block_params=frozenset(stmt.block_params), is_opaque=is_shifting, ) self._walk_program(stmt.body, body_scope) if stmt.inverse is not None: # The inverse branch (`{{else}}`) renders against the *same* # context as the parent — context-shifting only applies to the # main body of `each` / `with`. inverse_scope = _ExtractScope( parent=scope, block_params=frozenset(stmt.block_params), is_opaque=False, ) self._walk_program(stmt.inverse, inverse_scope) def _walk_subexpression(self, expr: SubExpression, scope: _ExtractScope) -> None: # The subexpression path is always a helper (parser-enforced). # Its arguments may be paths/literals/further subexpressions. for param in expr.params: self._walk_expression(param, scope) for value in expr.hash_pairs.values(): self._walk_expression(value, scope) def _walk_expression(self, expr: Expression, scope: _ExtractScope) -> None: if isinstance(expr, PathExpression): self._record_path(expr, scope) elif isinstance(expr, SubExpression): self._walk_subexpression(expr, scope) # Literals (string/number/bool/null/undefined) contribute nothing. # ------------------------------------------------------------------ # Reference recording # ------------------------------------------------------------------ def _record_path_or_helper(self, path: PathExpression, scope: _ExtractScope, *, has_args: bool) -> None: """Record *path* as either a helper call (skip) or a context reference. A bare `{{name}}` is ambiguous at parse time — it could be either a helper with no arguments or a path lookup. The renderer resolves this by preferring the helper when one is registered AND the expression has arguments; otherwise it falls back to a path lookup. We follow the same rule. """ if ( not path.data and path.depth == 0 and not path.is_this and len(path.parts) == 1 and path.parts[0] in self._known_helpers and has_args ): # Helper invocation — the helper name itself is not a context dep. return self._record_path(path, scope) def _record_path(self, path: PathExpression, scope: _ExtractScope) -> None: # `@data` variables (`@index`, `@key`, `@root`, …) do not # name top-level context fields. The one exception is `@root.x`, # which explicitly reaches the root context — record `x`. if path.data: if path.parts and path.parts[0] == 'root' and len(path.parts) > 1: self._deps.add(path.parts[1]) return if path.is_this or not path.parts: # `{{this}}` / `{{.}}` references the current context as a # whole, not a named field. return # Walk up the scope chain by `depth` to find which scope this # reference actually lands in. If we reach the root scope (parent is # None) and the scope was not opaque along the way, the reference is # a top-level dependency. target = scope for _ in range(path.depth): if target.parent is None: # `../` past the root resolves to the root in the renderer # (`get_parent_context` returns `None` past root). Treat # the reference as still landing on the root context. break target = target.parent first = path.parts[0] if first in target.block_params: # Locally bound by `as |name|` — not a context dep. return if target.is_opaque: # Reference is against a context that `each` / `with` shifted # to. Without knowing the inner shape we cannot map this back to # a top-level field name. return # The reference walked all the way back to a non-opaque scope: this # IS a top-level dep. self._deps.add(first) def extract_dependencies( source: str, *, helpers: set[str] | None = None, include_extra_helpers: bool = False, open_delim: str = '{{', close_delim: str = '}}', ) -> set[str]: """Return the set of top-level context field names *source* depends on. Statically walks the template AST — no rendering or context is required. Useful for tooling that needs to know "what fields does this template expect" without actually executing it: dependency resolution, prompt composition systems, dataset generation, etc. Args: source: Handlebars template source. helpers: Names of additional helpers to recognise. Names registered as helpers are not treated as context references — for example, `{{format date}}` records `date` as a dependency but not `format`. include_extra_helpers: When `True`, also treat the non-spec helpers registered by `HandlebarsEnvironment(extra_helpers=True)` (`json`, `uppercase`, `eq`, …) as known helpers. Match this to the environment you intend to render with. open_delim: Open mustache delimiter. Defaults to `{{`. Pass the same delimiters you'd render the template with. close_delim: Close mustache delimiter. Defaults to `}}`. Returns: Set of top-level context field names referenced by the template. Empty if the template only contains literal content, `@data` variables, or references shadowed by block parameters. Raises: HandlebarsParseError: If the template cannot be parsed. Examples: ```python extract_dependencies('Hello {{user_name}}!') == {'user_name'} extract_dependencies('{{user.name}} <{{user.email}}>') == {'user'} extract_dependencies('{{#if beta}}new{{else}}{{tagline}}{{/if}}') == {'beta', 'tagline'} extract_dependencies('{{#each items}}{{name}}{{/each}}') == {'items'} extract_dependencies('{{#each items}}{{../top}}{{/each}}') == {'items', 'top'} ``` """ known = set(get_standard_helpers().keys()) if include_extra_helpers: known.update(get_extra_helpers().keys()) if helpers: known.update(helpers) program = parse(source, open_delim=open_delim, close_delim=close_delim) return _Extractor(known).collect(program) pydantic-handlebars-0.2.1/src/pydantic_handlebars/_helpers.py000066400000000000000000000210771520472041100243710ustar00rootroot00000000000000"""Built-in helpers for pydantic_handlebars. Includes both standard Handlebars.js helpers and custom helpers. """ from __future__ import annotations import json as json_module import warnings from typing import Any, cast from pydantic_handlebars._compiler import HelperFunc, HelperOptions from pydantic_handlebars._utils import is_falsy, to_string def _helper_if(context: Any, *args: Any, options: HelperOptions) -> str: """The #if block helper. Renders the block if the first argument (or context) is truthy. """ # The condition is the first positional argument if args: condition = args[0] else: condition = context if not is_falsy(condition): return options.fn(context) return options.inverse(context) def _helper_unless(context: Any, *args: Any, options: HelperOptions) -> str: """The #unless block helper. Inverse of #if.""" if args: condition = args[0] else: condition = context if is_falsy(condition): return options.fn(context) return options.inverse(context) def _helper_each(context: Any, *args: Any, options: HelperOptions) -> str: """The #each block helper. Iterates over arrays and objects, providing @index/@key, @first, @last. """ items = args[0] if args else context if items is None: return options.inverse(context) parts: list[str] = [] if isinstance(items, dict): dict_items = cast('dict[str, Any]', items) keys = list(dict_items.keys()) if not keys: return options.inverse(context) for i, key in enumerate(keys): value = dict_items[key] data: dict[str, Any] = { 'key': key, 'index': i, 'first': i == 0, 'last': i == len(keys) - 1, } bp: list[Any] = [value, key] parts.append(options.fn(value, data=data, block_params=bp)) elif isinstance(items, (list, tuple)): seq = cast('list[Any]', items) if not seq: return options.inverse(context) for i, item in enumerate(seq): data = { 'index': i, 'first': i == 0, 'last': i == len(seq) - 1, } bp = [item, i] parts.append(options.fn(item, data=data, block_params=bp)) else: return options.inverse(context) return ''.join(parts) def _helper_with(context: Any, *args: Any, options: HelperOptions) -> str: """The #with block helper. Changes the context for the block body. """ new_context = args[0] if args else context if is_falsy(new_context): return options.inverse(context) bp: list[Any] = [new_context] return options.fn(new_context, block_params=bp) def _helper_lookup(*args: Any, options: HelperOptions) -> Any: """The lookup helper. Dynamic property lookup: {{lookup obj key}}. """ if len(args) < 2: return None obj, key = args[0], args[1] if obj is None: return None if isinstance(obj, dict): dict_obj = cast('dict[str, Any]', obj) return dict_obj.get(str(key) if not isinstance(key, str) else key) if isinstance(obj, (list, tuple)): seq = cast('list[Any]', obj) try: idx = int(key) if 0 <= idx < len(seq): return seq[idx] except (ValueError, TypeError): pass return None return None def _helper_log(*args: Any, options: HelperOptions) -> str: """The log helper. Logs values using warnings.warn. """ level = options.hash.get('level', 'info') message = ' '.join(to_string(a) for a in args) warnings.warn(f'[Handlebars {level}] {message}', stacklevel=2) return '' # Custom helpers (not part of Handlebars.js spec) def _helper_json(*args: Any, options: HelperOptions) -> str: """Serialize value to JSON string.""" if not args: return '' return json_module.dumps(args[0]) def _helper_uppercase(*args: Any, options: HelperOptions) -> str: """Convert value to uppercase.""" if not args: return '' return to_string(args[0]).upper() def _helper_lowercase(*args: Any, options: HelperOptions) -> str: """Convert value to lowercase.""" if not args: return '' return to_string(args[0]).lower() def _helper_trim(*args: Any, options: HelperOptions) -> str: """Strip leading/trailing whitespace.""" if not args: return '' return to_string(args[0]).strip() def _helper_join(*args: Any, options: HelperOptions) -> str: """Join array elements with separator.""" if len(args) < 1: return '' arr = args[0] sep = args[1] if len(args) > 1 else ',' if isinstance(arr, (list, tuple)): seq = cast('list[Any]', arr) return str(sep).join(to_string(item) for item in seq) return to_string(arr) def _helper_truncate(*args: Any, options: HelperOptions) -> str: """Truncate string to N characters.""" if len(args) < 1: return '' value = to_string(args[0]) length = int(args[1]) if len(args) > 1 else 100 if len(value) <= length: return value return value[:length] def _helper_eq(*args: Any, options: HelperOptions) -> bool: """Equality comparison.""" if len(args) < 2: return False return args[0] == args[1] def _helper_ne(*args: Any, options: HelperOptions) -> bool: """Inequality comparison.""" if len(args) < 2: return True return args[0] != args[1] def _helper_gt(*args: Any, options: HelperOptions) -> bool: """Greater than comparison.""" if len(args) < 2: return False try: return args[0] > args[1] except TypeError: return False def _helper_gte(*args: Any, options: HelperOptions) -> bool: """Greater than or equal comparison.""" if len(args) < 2: return False try: return args[0] >= args[1] except TypeError: return False def _helper_lt(*args: Any, options: HelperOptions) -> bool: """Less than comparison.""" if len(args) < 2: return False try: return args[0] < args[1] except TypeError: return False def _helper_lte(*args: Any, options: HelperOptions) -> bool: """Less than or equal comparison.""" if len(args) < 2: return False try: return args[0] <= args[1] except TypeError: return False def _helper_and(*args: Any, options: HelperOptions) -> Any: """Boolean AND combinator.""" if not args: return False for arg in args: if is_falsy(arg): return arg return args[-1] def _helper_or(*args: Any, options: HelperOptions) -> Any: """Boolean OR combinator.""" if not args: return False for arg in args: if not is_falsy(arg): return arg return args[-1] def _helper_not(*args: Any, options: HelperOptions) -> bool: """Boolean NOT.""" if not args: return True return is_falsy(args[0]) def _helper_default(*args: Any, options: HelperOptions) -> Any: """Use fallback if value is falsy.""" if len(args) < 2: return args[0] if args else None value, fallback = args[0], args[1] if is_falsy(value): return fallback return value def get_standard_helpers() -> dict[str, HelperFunc]: """Get the standard Handlebars.js helpers. These are the 6 helpers defined in the Handlebars.js specification: ``if``, ``unless``, ``each``, ``with``, ``lookup``, and ``log``. Returns: A dictionary mapping helper names to helper functions. """ return { 'if': _helper_if, 'unless': _helper_unless, 'each': _helper_each, 'with': _helper_with, 'lookup': _helper_lookup, 'log': _helper_log, } def get_extra_helpers() -> dict[str, HelperFunc]: """Get the extra (non-standard) helpers. These are 16 custom helpers not part of the Handlebars.js spec: comparison operators, boolean combinators, and utility helpers. Returns: A dictionary mapping helper names to helper functions. """ return { 'json': _helper_json, 'uppercase': _helper_uppercase, 'lowercase': _helper_lowercase, 'trim': _helper_trim, 'join': _helper_join, 'truncate': _helper_truncate, 'eq': _helper_eq, 'ne': _helper_ne, 'gt': _helper_gt, 'gte': _helper_gte, 'lt': _helper_lt, 'lte': _helper_lte, 'and': _helper_and, 'or': _helper_or, 'not': _helper_not, 'default': _helper_default, } pydantic-handlebars-0.2.1/src/pydantic_handlebars/_parser.py000066400000000000000000000650301520472041100242200ustar00rootroot00000000000000"""Parser for Handlebars templates. Converts a token stream into an AST (Abstract Syntax Tree). """ from __future__ import annotations from pydantic_handlebars._ast_nodes import ( BlockStatement, BooleanLiteral, CommentStatement, ContentStatement, Expression, MustacheStatement, NullLiteral, NumberLiteral, PathExpression, Program, RawBlock, Statement, StringLiteral, StripFlags, SubExpression, UndefinedLiteral, ) from pydantic_handlebars._exceptions import HandlebarsParseError from pydantic_handlebars._tokenizer import Token, TokenType, tokenize def parse( source: str, *, open_delim: str = '{{', close_delim: str = '}}', ) -> Program: """Parse a Handlebars template string into an AST. Args: source: The template string to parse. open_delim: Open mustache delimiter. Defaults to `{{`. close_delim: Close mustache delimiter. Defaults to `}}`. Returns: The parsed AST as a Program node. """ tokens = tokenize(source, open_delim=open_delim, close_delim=close_delim) parser = _Parser(tokens, source) return parser.parse() class _Parser: """Recursive descent parser for Handlebars templates.""" def __init__(self, tokens: list[Token], source: str) -> None: self._tokens = tokens self._source = source self._pos = 0 def parse(self) -> Program: """Parse the token stream into a Program AST node.""" body = self._parse_body() return Program(body=body) def _current(self) -> Token: """Get the current token.""" if self._pos < len(self._tokens): return self._tokens[self._pos] return Token(TokenType.EOF, '', 0, 0) # pragma: no cover def _peek(self, offset: int = 0) -> Token: """Peek at a token ahead.""" return self.token_at(offset) def token_at(self, offset: int = 0) -> Token: """Get a token at the given offset from the current position.""" pos = self._pos + offset if pos < len(self._tokens): return self._tokens[pos] return Token(TokenType.EOF, '', 0, 0) # pragma: no cover def _advance(self) -> Token: """Consume and return the current token.""" token = self._current() self._pos += 1 return token def _expect(self, token_type: TokenType) -> Token: """Consume the current token, expecting a specific type.""" token = self._current() if token.type != token_type: raise HandlebarsParseError( f'Expected {token_type.name}, got {token.type.name} ({token.value!r})', line=token.line, column=token.column, ) return self._advance() def _at_end(self) -> bool: """Check if we've reached the end of tokens.""" return self._current().type == TokenType.EOF def _parse_body(self, end_condition: _EndCondition | None = None) -> list[Statement]: """Parse a sequence of statements until an end condition is met.""" body: list[Statement] = [] while not self._at_end(): token = self._current() if end_condition is not None and end_condition.matches(token, self): break stmt = self._parse_statement() if stmt is not None: # pragma: no branch body.append(stmt) # If we hit EOF while looking for a block close, that's an error if end_condition is not None and self._at_end() and not (end_condition.matches(self._current(), self)): raise HandlebarsParseError( 'Unclosed block', line=self._current().line, column=self._current().column, ) return body def _parse_statement(self) -> Statement | None: """Parse a single statement.""" token = self._current() if token.type == TokenType.CONTENT: return self._parse_content() if token.type == TokenType.COMMENT: return self._parse_comment() if token.type == TokenType.STRIP and self._peek(1).type == TokenType.COMMENT: return self._parse_comment() if token.type == TokenType.OPEN: return self._parse_mustache() if token.type == TokenType.OPEN_UNESCAPED: return self._parse_mustache_unescaped() if token.type == TokenType.OPEN_BLOCK: return self._parse_block() if token.type == TokenType.OPEN_INVERSE: return self._parse_inverse_block() if token.type == TokenType.STRIP and self._peek(1).type == TokenType.OPEN_INVERSE: return self._parse_inverse_block() if token.type == TokenType.OPEN_RAW_BLOCK: return self._parse_raw_block() if token.type == TokenType.OPEN_ENDBLOCK: # This shouldn't happen at top level raise HandlebarsParseError( 'Unexpected closing block', line=token.line, column=token.column, ) if token.type == TokenType.OPEN_PARTIAL: raise HandlebarsParseError( 'Partials are not supported', line=token.line, column=token.column, ) # Skip unknown tokens self._advance() # pragma: no cover return None # pragma: no cover def _parse_content(self) -> ContentStatement: """Parse a content token.""" token = self._advance() return ContentStatement(value=token.value, original=token.value) def _parse_comment(self) -> CommentStatement: """Parse a comment token, consuming any surrounding strip markers.""" open_strip = self._consume_strip() token = self._advance() close_strip = self._consume_strip() strip = StripFlags(open_standalone=open_strip, close_standalone=close_strip) return CommentStatement(value=token.value, strip=strip) def _parse_mustache(self) -> MustacheStatement: """Parse a mustache expression: {{ ... }}.""" open_token = self._advance() # consume OPEN # The ampersand-unescape variant (`{{&name}}` / `@{&name}@` / …) is # tokenized as an `OPEN` whose value is the open delimiter plus `&`. # Detect that by suffix rather than the literal `'{{&'` so custom # delimiter pairs work the same way. is_unescaped_amp = open_token.value.endswith('&') open_strip = self._consume_strip() expr = self._parse_expression_with_params() close_strip = self._consume_strip() self._expect(TokenType.CLOSE) path, params, hash_pairs = expr escaped = not is_unescaped_amp strip = StripFlags(open_standalone=open_strip, close_standalone=close_strip) return MustacheStatement( path=path, params=params, hash_pairs=hash_pairs, escaped=escaped, strip=strip, ) def _parse_mustache_unescaped(self) -> MustacheStatement: """Parse a triple-stache expression: {{{ ... }}}.""" self._advance() # consume OPEN_UNESCAPED open_strip = self._consume_strip() expr = self._parse_expression_with_params() close_strip = self._consume_strip() self._expect(TokenType.CLOSE_UNESCAPED) path, params, hash_pairs = expr strip = StripFlags(open_standalone=open_strip, close_standalone=close_strip) return MustacheStatement( path=path, params=params, hash_pairs=hash_pairs, escaped=False, strip=strip, ) def _parse_block(self) -> BlockStatement: """Parse a block expression: {{#helper ...}}...{{/helper}}.""" self._advance() # consume OPEN_BLOCK open_strip_before = self._consume_strip() expr = self._parse_expression_with_params() block_params = self._parse_block_params() open_strip_after = self._consume_strip() self._expect(TokenType.CLOSE) path, params, hash_pairs = expr # Parse the body body_stmts = self._parse_body(_EndCondition.block_end(path)) body = Program(body=body_stmts, block_params=block_params) inverse: Program | None = None inverse_strip = StripFlags() close_strip = StripFlags() # Check for inverse / else / chained else if self._current().type == TokenType.OPEN_INVERSE or ( self._current().type == TokenType.STRIP and self._peek(1).type == TokenType.OPEN_INVERSE ): inverse, inverse_strip, close_strip = self._parse_inverse_chain() elif self._current().type == TokenType.OPEN and self._is_else(): inverse, inverse_strip, close_strip = self._parse_else_chain(path) # Consume the closing {{/helper}} if self._current().type == TokenType.OPEN_ENDBLOCK: # pragma: no branch close_strip_before = self._consume_endblock_strip() close_strip = StripFlags( open_standalone=close_strip_before or close_strip.open_standalone, close_standalone=close_strip.close_standalone, ) self._advance() # OPEN_ENDBLOCK endblock_strip_open = self._consume_strip() self._parse_endblock_name(path) endblock_strip_close = self._consume_strip() self._expect(TokenType.CLOSE) close_strip = StripFlags( open_standalone=endblock_strip_open or close_strip.open_standalone, close_standalone=endblock_strip_close, ) open_strip = StripFlags( open_standalone=open_strip_before, close_standalone=open_strip_after, ) return BlockStatement( path=path, params=params, hash_pairs=hash_pairs, body=body, inverse=inverse, open_strip=open_strip, close_strip=close_strip, inverse_strip=inverse_strip, block_params=block_params, ) def _parse_inverse_block(self) -> BlockStatement: """Parse an inverse block: {{^helper}}...{{/helper}} or standalone {{^}}.""" open_strip_before = self._consume_strip() # leading strip for {{~^ open_token = self._advance() # consume OPEN_INVERSE # Check if this is a standalone {{^}} (shorthand for {{else}}) # or {{^helper}}...{{/helper}} if self._current().type == TokenType.CLOSE: # Standalone {{^}} - this is handled in the block parsing as an inverse raise HandlebarsParseError( 'Unexpected standalone inverse block', line=open_token.line, column=open_token.column, ) expr = self._parse_expression_with_params() self._parse_block_params() open_strip_after = self._consume_strip() self._expect(TokenType.CLOSE) path, params, hash_pairs = expr # Parse body (this is the inverse body - will be empty) # Actually for {{^helper}}, the body is the "inverse" content body = Program(body=[]) inverse_stmts = self._parse_body(_EndCondition.block_end(path)) inverse = Program(body=inverse_stmts) # Consume the closing {{/helper}} close_strip = StripFlags() if self._current().type == TokenType.OPEN_ENDBLOCK: # pragma: no branch self._advance() # OPEN_ENDBLOCK endblock_strip_open = self._consume_strip() self._parse_endblock_name(path) endblock_strip_close = self._consume_strip() self._expect(TokenType.CLOSE) close_strip = StripFlags( open_standalone=endblock_strip_open, close_standalone=endblock_strip_close, ) open_strip = StripFlags( open_standalone=open_strip_before, close_standalone=open_strip_after, ) return BlockStatement( path=path, params=params, hash_pairs=hash_pairs, body=body, inverse=inverse, open_strip=open_strip, close_strip=close_strip, ) def _parse_raw_block(self) -> RawBlock: """Parse a raw block: {{{{raw}}}}...{{{{/raw}}}}.""" self._advance() # OPEN_RAW_BLOCK # Parse the name name_token = self._expect(TokenType.ID) path = PathExpression(parts=[name_token.value], original=name_token.value) self._expect(TokenType.CLOSE_RAW_BLOCK) # Get raw content content = '' if self._current().type == TokenType.RAW_CONTENT: content = self._advance().value self._expect(TokenType.END_RAW_BLOCK) return RawBlock(path=path, body=content) def _is_else(self) -> bool: """Check if the current position has an {{else}} or {{^}} tag.""" if self._current().type == TokenType.OPEN_INVERSE: return True # pragma: no cover if self._current().type == TokenType.STRIP and self._peek(1).type == TokenType.OPEN_INVERSE: return True # pragma: no cover if self._current().type == TokenType.OPEN: # Look ahead for 'else' pos = self._pos + 1 # Skip STRIP token if pos < len(self._tokens) and self._tokens[pos].type == TokenType.STRIP: pos += 1 if pos < len(self._tokens) and self._tokens[pos].type == TokenType.INVERSE: # pragma: no branch return True return False def _parse_inverse_chain(self) -> tuple[Program, StripFlags, StripFlags]: """Parse an inverse chain starting with {{^}}. The open strip (from `{{~^`) is now emitted as a STRIP token *before* OPEN_INVERSE by the tokenizer, so consuming it here distinguishes `{{~^}}` (open strip) from `{{^~}}` (close strip). """ inv_strip_open = self._consume_strip() # leading strip for {{~^ self._advance() # OPEN_INVERSE inv_strip_close = self._consume_strip() # trailing strip for ^~}} self._expect(TokenType.CLOSE) inv_strip = StripFlags(open_standalone=inv_strip_open, close_standalone=inv_strip_close) # Parse inverse body until {{/ inverse_stmts = self._parse_body(_EndCondition.endblock()) return Program(body=inverse_stmts), inv_strip, StripFlags() def _parse_else_chain(self, block_path: Expression) -> tuple[Program, StripFlags, StripFlags]: """Parse an else chain: {{else}} or {{else if ...}}.""" # Current token is OPEN self._advance() # consume OPEN inv_strip_open = self._consume_strip() self._expect(TokenType.INVERSE) # consume 'else' # Check for chained else: {{else if condition}} if self._current().type not in (TokenType.CLOSE, TokenType.STRIP): # This is {{else if ...}} - chained return self._parse_chained_else(block_path, inv_strip_open) inv_strip_close = self._consume_strip() self._expect(TokenType.CLOSE) inv_strip = StripFlags(open_standalone=inv_strip_open, close_standalone=inv_strip_close) # Parse the else body inverse_stmts = self._parse_body(_EndCondition.endblock_or_else()) inverse = Program(body=inverse_stmts) close_strip = StripFlags() # Check for further chaining if self._is_else(): # pragma: no cover # Another else?? That shouldn't happen normally pass return inverse, inv_strip, close_strip def _parse_chained_else( self, block_path: Expression, inv_strip_open: bool ) -> tuple[Program, StripFlags, StripFlags]: """Parse a chained else-if: {{else if condition}}....""" # We just consumed 'else', now parse the helper expression expr = self._parse_expression_with_params() block_params = self._parse_block_params() inv_strip_close = self._consume_strip() self._expect(TokenType.CLOSE) path, params, hash_pairs = expr inv_strip = StripFlags(open_standalone=inv_strip_open, close_standalone=inv_strip_close) # Parse the body for this chained block body_stmts = self._parse_body(_EndCondition.endblock_or_else()) body = Program(body=body_stmts, block_params=block_params) # Check for more chaining inner_inverse: Program | None = None inner_inv_strip = StripFlags() inner_close_strip = StripFlags() if self._current().type == TokenType.OPEN_INVERSE or ( self._current().type == TokenType.STRIP and self._peek(1).type == TokenType.OPEN_INVERSE ): inner_inverse, inner_inv_strip, inner_close_strip = self._parse_inverse_chain() elif self._is_else(): inner_inverse, inner_inv_strip, inner_close_strip = self._parse_else_chain(block_path) # Create a block statement for this chained else-if chained_block = BlockStatement( path=path, params=params, hash_pairs=hash_pairs, body=body, inverse=inner_inverse, open_strip=StripFlags(), close_strip=inner_close_strip, inverse_strip=inner_inv_strip, block_params=block_params, chained=True, ) # Wrap in a program inverse = Program(body=[chained_block]) return inverse, inv_strip, StripFlags() def _parse_endblock_name(self, expected_path: Expression) -> None: """Parse and validate the name in a closing {{/name}} tag.""" if not isinstance(expected_path, PathExpression): return # pragma: no cover # Parse the path in the end block if self._current().type == TokenType.ID: # pragma: no branch name = self._advance().value # Build full path while self._current().type == TokenType.SEP: sep = self._advance() if self._current().type in (TokenType.ID, TokenType.NUMBER): # pragma: no branch name += sep.value + self._advance().value if name != expected_path.original: raise HandlebarsParseError( f"{expected_path.original} doesn't match {name}", line=self._current().line, column=self._current().column, ) def _consume_strip(self) -> bool: """Consume a strip marker (~) if present, returning whether one was found.""" if self._current().type == TokenType.STRIP: self._advance() return True return False def _consume_endblock_strip(self) -> bool: """Check if endblock has leading strip. Don't consume.""" return False def _parse_block_params(self) -> list[str]: """Parse block parameters: as |param1 param2|.""" if self._current().type != TokenType.OPEN_BLOCK_PARAMS: return [] self._advance() # consume 'as |' params: list[str] = [] while self._current().type == TokenType.ID: params.append(self._advance().value) self._expect(TokenType.CLOSE_BLOCK_PARAMS) return params def _parse_expression_with_params(self) -> tuple[Expression, list[Expression], dict[str, Expression]]: """Parse an expression with optional params and hash arguments. Returns: Tuple of (expression, params, hash_pairs). """ path = self._parse_expression() params: list[Expression] = [] hash_pairs: dict[str, Expression] = {} while not self._at_end() and self._current().type not in ( TokenType.CLOSE, TokenType.CLOSE_UNESCAPED, TokenType.CLOSE_RAW_BLOCK, TokenType.CLOSE_SEXPR, TokenType.STRIP, TokenType.OPEN_BLOCK_PARAMS, TokenType.EOF, ): # Check for hash argument: key=value if self._current().type == TokenType.ID and self._peek(1).type == TokenType.EQUALS: key = self._advance().value self._advance() # consume = value = self._parse_expression() hash_pairs[key] = value else: params.append(self._parse_expression()) return path, params, hash_pairs def _parse_expression(self) -> Expression: """Parse a single expression (path, literal, or subexpression).""" token = self._current() if token.type == TokenType.OPEN_SEXPR: return self._parse_subexpression() if token.type == TokenType.STRING: self._advance() return StringLiteral(value=token.value, original=token.value) if token.type == TokenType.NUMBER: self._advance() if '.' in token.value: return NumberLiteral(value=float(token.value), original=token.value) return NumberLiteral(value=int(token.value), original=token.value) if token.type == TokenType.BOOLEAN: self._advance() return BooleanLiteral(value=token.value == 'true', original=token.value) if token.type == TokenType.NULL: self._advance() return NullLiteral() if token.type == TokenType.UNDEFINED: self._advance() return UndefinedLiteral() return self._parse_path() def _parse_subexpression(self) -> SubExpression: """Parse a subexpression: (helper args...).""" self._expect(TokenType.OPEN_SEXPR) path = self._parse_path() params: list[Expression] = [] hash_pairs: dict[str, Expression] = {} while self._current().type != TokenType.CLOSE_SEXPR: if self._at_end(): raise HandlebarsParseError( 'Unclosed subexpression', line=self._current().line, column=self._current().column, ) # Check for hash argument if self._current().type == TokenType.ID and self._peek(1).type == TokenType.EQUALS: key = self._advance().value self._advance() # consume = value = self._parse_expression() hash_pairs[key] = value else: params.append(self._parse_expression()) self._expect(TokenType.CLOSE_SEXPR) return SubExpression(path=path, params=params, hash_pairs=hash_pairs) def _parse_path(self) -> PathExpression: """Parse a path expression like foo, foo.bar, ../foo, @data, this.name.""" parts: list[str] = [] depth = 0 is_data = False is_this = False original_parts: list[str] = [] # Handle @data prefix if self._current().type == TokenType.DATA: is_data = True self._advance() original_parts.append('@') # Handle parent references while self._current().type == TokenType.PARENT: depth += 1 original_parts.append('../') self._advance() # Handle 'this' or '.' if self._current().type == TokenType.ID and self._current().value in ('.', 'this'): is_this = True val = self._advance().value original_parts.append(val) # If followed by separator, consume it and read next segment if self._current().type == TokenType.SEP: original_parts.append(self._advance().value) # Read the segment after the separator if self._current().type in (TokenType.ID, TokenType.NUMBER): # pragma: no branch token = self._advance() parts.append(token.value) original_parts.append(token.value) elif self._current().type == TokenType.ID: token = self._advance() parts.append(token.value) original_parts.append(token.value) else: # This might be a boolean or other token used as a path raise HandlebarsParseError( f'Expected path expression, got {self._current().type.name}', line=self._current().line, column=self._current().column, ) # Read subsequent path segments while self._current().type == TokenType.SEP: original_parts.append(self._advance().value) if self._current().type in (TokenType.ID, TokenType.NUMBER): token = self._advance() parts.append(token.value) original_parts.append(token.value) else: raise HandlebarsParseError( 'Expected identifier after path separator', line=self._current().line, column=self._current().column, ) original = ''.join(original_parts) return PathExpression( parts=parts, original=original, depth=depth, is_this=is_this, data=is_data, ) class _EndCondition: """Defines when to stop parsing a body.""" def __init__( self, *, end_block: bool = False, block_path: Expression | None = None, or_else: bool = False, ) -> None: self._end_block = end_block self._block_path = block_path self._or_else = or_else @classmethod def block_end(cls, path: Expression) -> _EndCondition: """Stop at {{/path}} or {{else}} or {{^}}.""" return cls(end_block=True, block_path=path, or_else=True) @classmethod def endblock(cls) -> _EndCondition: """Stop at {{/...}}.""" return cls(end_block=True) @classmethod def endblock_or_else(cls) -> _EndCondition: """Stop at {{/...}} or {{else}} or {{^}}.""" return cls(end_block=True, or_else=True) def matches(self, token: Token, parser: _Parser) -> bool: """Check if the current token matches this end condition.""" if self._end_block and token.type == TokenType.OPEN_ENDBLOCK: return True if self._or_else: # Check for {{^}} or {{~^}} (strip before inverse) if token.type == TokenType.OPEN_INVERSE: return True if token.type == TokenType.STRIP and parser.token_at(1).type == TokenType.OPEN_INVERSE: return True # Check for {{else}} - OPEN followed by INVERSE if token.type == TokenType.OPEN: offset = 1 # Skip STRIP if parser.token_at(offset).type == TokenType.STRIP: offset += 1 if parser.token_at(offset).type == TokenType.INVERSE: return True return False pydantic-handlebars-0.2.1/src/pydantic_handlebars/_pydantic.py000066400000000000000000000103261520472041100245350ustar00rootroot00000000000000"""Pydantic integration for typed template rendering.""" from __future__ import annotations from collections.abc import Callable from typing import Any, Generic, TypeVar from pydantic import TypeAdapter from pydantic_handlebars._environment import HandlebarsEnvironment from pydantic_handlebars._schema import IssueSeverity, check_template_compatibility T = TypeVar('T') class CompiledTypedTemplate(Generic[T]): """A compiled template bound to a specific type. Created by ``TypedCompiler.compile()`` or ``compile(source, tp)``. """ def __init__( self, *, fn: Callable[..., str], type_adapter: TypeAdapter[T], serialize_as_any: bool = False, ) -> None: self._fn = fn self._type_adapter = type_adapter self._serialize_as_any = serialize_as_any def render(self, data: T) -> str: """Render with typed data (no runtime validation). Args: data: The data to render. Must match type T. Returns: The rendered template string. """ context = self._type_adapter.dump_python(data, mode='json', serialize_as_any=self._serialize_as_any) return self._fn(context) def validate_and_render(self, data: Any) -> str: """Validate data through Pydantic, then render. Args: data: The data to validate and render. Can be any type that Pydantic can coerce to T. Returns: The rendered template string. """ validated = self._type_adapter.validate_python(data) context = self._type_adapter.dump_python(validated, mode='json', serialize_as_any=self._serialize_as_any) return self._fn(context) class TypedCompiler(Generic[T]): """Compiler for creating typed templates against a Pydantic model. Caches the TypeAdapter and JSON schema for repeated compilation against the same type. Args: tp: The Pydantic model type or any type supported by TypeAdapter. env: Optional HandlebarsEnvironment. Uses a default if not provided. optional_field_severity: Severity for optional field access issues. Example: ```python from pydantic import BaseModel class User(BaseModel): name: str age: int compiler = TypedCompiler(User) template = compiler.compile('Hello {{name}}, you are {{age}}!') result = template.render(User(name='Alice', age=30)) assert result == 'Hello Alice, you are 30!' ``` """ def __init__( self, tp: type[T], *, env: HandlebarsEnvironment | None = None, optional_field_severity: IssueSeverity = 'error', serialize_as_any: bool = False, ) -> None: self._type_adapter: TypeAdapter[T] = TypeAdapter(tp) self._json_schema = self._type_adapter.json_schema(mode='serialization') self._env = env or HandlebarsEnvironment() self._optional_field_severity: IssueSeverity = optional_field_severity self._serialize_as_any = serialize_as_any @property def json_schema(self) -> dict[str, Any]: """The JSON schema derived from the type.""" return self._json_schema def compile(self, source: str, *, raise_on_error: bool = True) -> CompiledTypedTemplate[T]: """Compile a template, checking compatibility with the schema. Args: source: The Handlebars template string. raise_on_error: If True (default), raise on incompatible templates. Returns: A ``CompiledTypedTemplate`` ready for rendering. Raises: TemplateSchemaError: If the template references fields not in the schema. """ helper_names = set(self._env._helpers.keys()) # pyright: ignore[reportPrivateUsage] check_template_compatibility( source, self._json_schema, raise_on_error=raise_on_error, optional_field_severity=self._optional_field_severity, helpers=helper_names, ) fn = self._env._compile_fn(source) # pyright: ignore[reportPrivateUsage] return CompiledTypedTemplate(fn=fn, type_adapter=self._type_adapter, serialize_as_any=self._serialize_as_any) pydantic-handlebars-0.2.1/src/pydantic_handlebars/_schema.py000066400000000000000000000654041520472041100241710ustar00rootroot00000000000000"""JSON Schema compatibility checker for Handlebars templates. Statically walks template ASTs and verifies field references against a JSON schema. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Literal, TypeAlias, cast from pydantic_handlebars._ast_nodes import ( BlockStatement, Expression, MustacheStatement, PathExpression, Program, SubExpression, ) from pydantic_handlebars._exceptions import TemplateSchemaError from pydantic_handlebars._helpers import get_standard_helpers from pydantic_handlebars._parser import parse _JsonSchema = dict[str, Any] IssueSeverity: TypeAlias = Literal['error', 'warning', 'ignore'] @dataclass(slots=True) class TemplateIssue: """A single compatibility issue found during template checking. Attributes: severity: The severity level of the issue. message: A human-readable description of the issue. field_path: The dotted path to the field that caused the issue. template_index: The index of the template that contains the issue. """ severity: IssueSeverity message: str field_path: str template_index: int @dataclass(slots=True) class CompatibilityResult: """Result of checking template compatibility with a JSON schema. Attributes: issues: List of all issues found during checking. """ issues: list[TemplateIssue] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] @property def is_compatible(self) -> bool: """Whether the templates are compatible (no ERROR-level issues).""" return not any(issue.severity == 'error' for issue in self.issues) @dataclass(slots=True) class _SchemaScope: """Mirrors Scope from _compiler.py but for schema traversal.""" schema: _JsonSchema parent: _SchemaScope | None block_params: set[str] root_schema: _JsonSchema is_opaque: bool = False def child( self, schema: _JsonSchema, *, block_params: set[str] | None = None, is_opaque: bool = False, ) -> _SchemaScope: """Create a child scope.""" return _SchemaScope( schema=schema, parent=self, block_params=block_params or set(), root_schema=self.root_schema, is_opaque=is_opaque, ) def get_parent(self, depth: int) -> _SchemaScope: """Walk up the scope chain to get a parent scope.""" scope: _SchemaScope = self for _ in range(depth): if scope.parent is None: return scope scope = scope.parent return scope class _SchemaChecker: """Recursive walker that checks template ASTs against a JSON schema.""" def __init__( self, root_schema: _JsonSchema, optional_field_severity: IssueSeverity, helpers: set[str], ) -> None: self._root_schema = root_schema self._optional_field_severity: IssueSeverity = optional_field_severity self._defs: dict[str, _JsonSchema] = { **root_schema.get('$defs', {}), **root_schema.get('definitions', {}), } default_helper_names = set(get_standard_helpers().keys()) self._known_helpers = default_helper_names | helpers self._issues: list[TemplateIssue] = [] self._template_index = 0 self._resolving_refs: set[str] = set() def check(self, programs: list[Program]) -> list[TemplateIssue]: """Check all programs against the schema.""" for i, program in enumerate(programs): self._template_index = i scope = _SchemaScope( schema=self._root_schema, parent=None, block_params=set(), root_schema=self._root_schema, ) self._check_program(program, scope) return self._issues def _add_issue(self, severity: IssueSeverity, message: str, field_path: str) -> None: self._issues.append( TemplateIssue( severity=severity, message=message, field_path=field_path, template_index=self._template_index, ) ) def _check_program(self, program: Program, scope: _SchemaScope) -> None: for stmt in program.body: self._check_statement(stmt, scope) def _check_statement(self, stmt: MustacheStatement | BlockStatement | Any, scope: _SchemaScope) -> None: if isinstance(stmt, MustacheStatement): self._check_mustache(stmt, scope) elif isinstance(stmt, BlockStatement): self._check_block(stmt, scope) # ContentStatement, CommentStatement, RawBlock are skipped def _check_mustache(self, stmt: MustacheStatement, scope: _SchemaScope) -> None: """Check a mustache expression.""" if isinstance(stmt.path, SubExpression): self._check_subexpression(stmt.path, scope) return if not isinstance(stmt.path, PathExpression): return # Literal values (true, null, 42, etc.) don't reference fields path = stmt.path # @data variable if path.data: self._check_data_path(path, scope) return # Check if this is a helper call if path.depth == 0 and not path.is_this: helper_name = path.original if helper_name in self._known_helpers: if stmt.params or stmt.hash_pairs or len(path.parts) == 1: # pragma: no branch # Check for default helper pattern if helper_name == 'default': self._check_default_helper(stmt, scope) return # Validate params and hash arg values against schema self._check_params(stmt.params, scope) self._check_params(list(stmt.hash_pairs.values()), scope) return self._check_path(path, scope) def _check_subexpression(self, expr: SubExpression, scope: _SchemaScope) -> None: """Check a subexpression's params and hash arg values.""" self._check_params(expr.params, scope) self._check_params(list(expr.hash_pairs.values()), scope) def _check_params(self, params: list[Expression], scope: _SchemaScope) -> None: """Validate parameter expressions against the schema.""" for param in params: if isinstance(param, PathExpression): if param.data: self._check_data_path(param, scope) else: self._check_path(param, scope) elif isinstance(param, SubExpression): self._check_subexpression(param, scope) def _check_block(self, stmt: BlockStatement, scope: _SchemaScope) -> None: """Check a block statement.""" if not isinstance(stmt.path, PathExpression): # pragma: no cover return # Exotic chained-else with non-path expression path = stmt.path helper_name = path.original # Built-in block helpers if helper_name == 'if' or helper_name == 'unless': self._check_if_unless(stmt, scope) return if helper_name == 'each': self._check_each(stmt, scope) return if helper_name == 'with': self._check_with(stmt, scope) return # Registered/custom helpers if helper_name in self._known_helpers: # Validate params and hash arg values self._check_params(stmt.params, scope) self._check_params(list(stmt.hash_pairs.values()), scope) # Body is opaque — we don't know what context the helper provides opaque_scope = scope.child({}, is_opaque=True) self._check_program(stmt.body, opaque_scope) if stmt.inverse is not None: self._check_program(stmt.inverse, opaque_scope) return # Context-based block: {{#person}}...{{/person}} self._check_context_block(stmt, path, scope) def _check_if_unless(self, stmt: BlockStatement, scope: _SchemaScope) -> None: """Check #if / #unless blocks — condition is validated, body stays same scope.""" # Validate the condition if stmt.params: self._check_params(stmt.params, scope) elif isinstance(stmt.path, PathExpression): # pragma: no cover pass # No params — condition is the current context, not a named field # Body and inverse stay in the same scope self._check_program(stmt.body, scope) if stmt.inverse is not None: self._check_inverse(stmt.inverse, scope) def _check_inverse(self, inverse: Program, scope: _SchemaScope) -> None: """Check an inverse program, handling chained else-if blocks.""" for stmt in inverse.body: if isinstance(stmt, BlockStatement) and stmt.chained: self._check_block(stmt, scope) else: self._check_statement(stmt, scope) def _check_each(self, stmt: BlockStatement, scope: _SchemaScope) -> None: """Check #each blocks — resolve iterated schema.""" if not stmt.params: # {{#each}} without params — iterate current context item_schema = self._get_each_item_schema(scope.schema) bp = self._get_block_param_names(stmt) is_opaque = bool(item_schema.get('_opaque', False)) clean_schema: _JsonSchema = {k: v for k, v in item_schema.items() if k != '_opaque'} child = scope.child(clean_schema, block_params=bp, is_opaque=is_opaque) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return # {{#each items}} — resolve the path param = stmt.params[0] if isinstance(param, PathExpression): if param.data: self._check_data_path(param, scope) bp = self._get_block_param_names(stmt) child = scope.child({}, block_params=bp, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return resolved = self._resolve_path_to_schema(param, scope) if resolved is None: # Path didn't resolve — already reported bp = self._get_block_param_names(stmt) child = scope.child({}, block_params=bp, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return item_schema = self._get_each_item_schema(resolved) bp = self._get_block_param_names(stmt) is_opaque = bool(item_schema.get('_opaque', False)) clean_schema = {k: v for k, v in item_schema.items() if k != '_opaque'} child = scope.child(clean_schema, block_params=bp, is_opaque=is_opaque) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) else: # Literal or subexpression — can't check statically bp = self._get_block_param_names(stmt) child = scope.child({}, block_params=bp, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) def _get_each_item_schema(self, schema: _JsonSchema) -> _JsonSchema: """Get the item schema for an #each iteration.""" resolved = self._resolve_ref(schema) # Handle union types resolved = self._unwrap_nullable(resolved) schema_type = resolved.get('type') if schema_type == 'array': items = resolved.get('items', {}) if isinstance(items, dict): return self._resolve_ref(cast('_JsonSchema', items)) return {} if schema_type == 'object': # Iterating over object keys — use additionalProperties add_props = resolved.get('additionalProperties') if isinstance(add_props, dict): return self._resolve_ref(cast('_JsonSchema', add_props)) return {'_opaque': True} # Unknown type — opaque return {'_opaque': True} def _check_with(self, stmt: BlockStatement, scope: _SchemaScope) -> None: """Check #with blocks — descend into referenced field's schema.""" if not stmt.params: self._check_program(stmt.body, scope) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return param = stmt.params[0] if isinstance(param, PathExpression): if param.data: self._check_data_path(param, scope) bp = self._get_block_param_names(stmt) child = scope.child({}, block_params=bp, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return resolved = self._resolve_path_to_schema(param, scope) if resolved is None: bp = self._get_block_param_names(stmt) child = scope.child({}, block_params=bp, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return bp = self._get_block_param_names(stmt) child = scope.child(resolved, block_params=bp) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) else: bp = self._get_block_param_names(stmt) child = scope.child({}, block_params=bp, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) def _check_context_block(self, stmt: BlockStatement, path: PathExpression, scope: _SchemaScope) -> None: """Check a context-based block like {{#person}}...{{/person}}.""" resolved = self._resolve_path_to_schema(path, scope) if resolved is None: # Path didn't resolve — already reported, check body as opaque child = scope.child({}, is_opaque=True) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) return child = scope.child(resolved) self._check_program(stmt.body, child) if stmt.inverse is not None: self._check_program(stmt.inverse, scope) def _check_path(self, path: PathExpression, scope: _SchemaScope) -> None: """Core path validation against the schema.""" if scope.is_opaque: return # 'this' with no parts — reference to current context, always valid if path.is_this and not path.parts: return # Check block params first if path.parts and path.depth == 0 and not path.is_this: first = path.parts[0] if first in scope.block_params: # Shadowed by block param — skip validation return # Check parent scopes for block params parent = scope.parent while parent is not None: if first in parent.block_params: # pragma: no cover return parent = parent.parent # Walk parent refs if path.depth > 0: target_scope = scope.get_parent(path.depth) else: target_scope = scope if target_scope.is_opaque: # pragma: no cover return current_schema = target_scope.schema # Walk path parts for i, part in enumerate(path.parts): current_schema = self._resolve_ref(current_schema) current_schema = self._unwrap_nullable(current_schema) field_path = '.'.join(path.parts[: i + 1]) if path.depth > 0: field_path = '../' * path.depth + field_path result = self._check_property_access(current_schema, part, field_path) if result is None: return # Error already reported, stop walking current_schema = result def _check_data_path(self, path: PathExpression, scope: _SchemaScope) -> None: """Check @data variable access.""" if not path.parts: # pragma: no cover return first = path.parts[0] if first == 'root': # @root.foo — validate foo against root schema remaining_parts = path.parts[1:] if not remaining_parts: return current_schema = self._root_schema for i, part in enumerate(remaining_parts): current_schema = self._resolve_ref(current_schema) current_schema = self._unwrap_nullable(current_schema) field_path = '@root.' + '.'.join(remaining_parts[: i + 1]) result = self._check_property_access(current_schema, part, field_path) if result is None: return current_schema = result # Other @data vars (@index, @key, @first, @last) are always valid def _resolve_ref(self, schema: _JsonSchema) -> _JsonSchema: """Follow $ref chains, handling circular refs.""" ref = schema.get('$ref') if ref is None: return schema if not isinstance(ref, str): return schema if ref in self._resolving_refs: # pragma: no cover # Circular ref — treat as opaque return {} self._resolving_refs.add(ref) try: # Extract the definition name from #/$defs/Foo or #/definitions/Foo if ref.startswith('#/$defs/'): def_name = ref[len('#/$defs/') :] elif ref.startswith('#/definitions/'): def_name = ref[len('#/definitions/') :] else: return schema if def_name in self._defs: resolved = self._defs[def_name] # Follow chains return self._resolve_ref(resolved) return schema finally: self._resolving_refs.discard(ref) def _check_property_access(self, schema: _JsonSchema, property_name: str, field_path: str) -> _JsonSchema | None: """Check if a property exists in the schema. Returns the property schema if found, None if not found (issue reported). """ resolved = self._resolve_ref(schema) resolved = self._unwrap_nullable(resolved) # Numeric index on an array schema — descend into items if property_name.isdigit() and resolved.get('type') == 'array': items = resolved.get('items', {}) if isinstance(items, dict): return self._resolve_ref(cast('_JsonSchema', items)) return {} # Check in anyOf/oneOf any_of = resolved.get('anyOf') or resolved.get('oneOf') if any_of is not None and isinstance(any_of, list): result = self._check_property_in_union(cast('list[_JsonSchema]', any_of), property_name) if result is not None: return result properties: _JsonSchema = resolved.get('properties', {}) if properties and property_name in properties: return self._resolve_ref(cast('_JsonSchema', properties[property_name])) # Check additionalProperties add_props = resolved.get('additionalProperties') if add_props is True: return {} if isinstance(add_props, dict): return self._resolve_ref(cast('_JsonSchema', add_props)) # Not found if properties or resolved.get('type') == 'object': required = resolved.get('required', []) if isinstance(required, list) and property_name not in required and properties: self._add_issue( self._optional_field_severity, f"Field '{property_name}' not found in schema", field_path, ) else: self._add_issue( 'error', f"Field '{property_name}' not found in schema", field_path, ) return None # Schema has no properties defined — could be any type if not resolved: return {} # Schema has a type but no properties schema_type = resolved.get('type') if schema_type and schema_type != 'object': self._add_issue( 'error', f"Cannot access property '{property_name}' on type '{schema_type}'", field_path, ) return None return {} def _check_property_in_union(self, variants: list[_JsonSchema], property_name: str) -> _JsonSchema | None: """Check if property exists in any variant of a union type.""" for variant in variants: resolved_variant = self._resolve_ref(variant) # Skip null type in union if resolved_variant.get('type') == 'null': # pragma: no cover continue properties: _JsonSchema = resolved_variant.get('properties', {}) if properties and property_name in properties: return self._resolve_ref(cast('_JsonSchema', properties[property_name])) # Check additionalProperties in variant add_props = resolved_variant.get('additionalProperties') if add_props is True: return {} if isinstance(add_props, dict): return self._resolve_ref(cast('_JsonSchema', add_props)) return None def _check_default_helper(self, stmt: MustacheStatement, scope: _SchemaScope) -> None: """Check {{default field "fallback"}} pattern. The default helper guards against missing values, so optional field severity is downgraded to 'ignore'. """ if not stmt.params: return # Save and override optional severity original_severity = self._optional_field_severity self._optional_field_severity = 'ignore' self._check_params(stmt.params, scope) self._check_params(list(stmt.hash_pairs.values()), scope) self._optional_field_severity = original_severity def _unwrap_nullable(self, schema: _JsonSchema) -> _JsonSchema: """Unwrap pydantic's Optional[X] pattern: anyOf: [{...}, {type: 'null'}].""" any_of = schema.get('anyOf') if not isinstance(any_of, list) or len(cast('list[Any]', any_of)) != 2: return schema typed_variants = cast('list[_JsonSchema]', any_of) non_null = [v for v in typed_variants if v.get('type') != 'null'] if len(non_null) == 1: return self._resolve_ref(non_null[0]) return schema def _resolve_path_to_schema(self, path: PathExpression, scope: _SchemaScope) -> _JsonSchema | None: """Resolve a path expression to its schema node. Returns None if the path doesn't resolve (issue already reported via _check_path). """ if scope.is_opaque: return {} if path.is_this and not path.parts: return scope.schema # Check block params if path.parts and path.depth == 0 and not path.is_this: first = path.parts[0] if first in scope.block_params: return {} parent = scope.parent while parent is not None: if first in parent.block_params: return {} parent = parent.parent if path.depth > 0: target_scope = scope.get_parent(path.depth) else: target_scope = scope if target_scope.is_opaque: # pragma: no cover return {} current_schema = target_scope.schema for i, part in enumerate(path.parts): current_schema = self._resolve_ref(current_schema) current_schema = self._unwrap_nullable(current_schema) field_path = '.'.join(path.parts[: i + 1]) if path.depth > 0: field_path = '../' * path.depth + field_path result = self._check_property_access(current_schema, part, field_path) if result is None: return None current_schema = result return current_schema def _get_block_param_names(self, stmt: BlockStatement) -> set[str]: """Extract block parameter names from a block statement.""" return set(stmt.block_params) def check_template_compatibility( templates: str | list[str], json_schema: _JsonSchema, *, raise_on_error: bool = False, optional_field_severity: IssueSeverity = 'error', helpers: set[str] | None = None, ) -> CompatibilityResult: """Check template compatibility with a JSON schema. Parses the templates, walks their ASTs, and verifies every field reference exists in the provided JSON schema. Args: templates: A single template string or list of template strings. json_schema: The JSON schema to validate against (e.g., from Pydantic's ``model_json_schema()``). raise_on_error: If True, raise ``TemplateSchemaError`` when incompatible. optional_field_severity: Severity for fields not found in the schema when the schema has properties but the field is not in ``required``. Defaults to ERROR. helpers: Additional helper names to treat as known (beyond built-in helpers). Returns: A ``CompatibilityResult`` with any issues found. Raises: TemplateSchemaError: If ``raise_on_error`` is True and there are ERROR-level issues. """ if isinstance(templates, str): template_list = [templates] else: template_list = templates programs = [parse(t) for t in template_list] checker = _SchemaChecker( root_schema=json_schema, optional_field_severity=optional_field_severity, helpers=helpers or set(), ) issues = checker.check(programs) result = CompatibilityResult(issues=issues) if raise_on_error and not result.is_compatible: error_issues = [i for i in issues if i.severity == 'error'] message_parts = [f'{len(error_issues)} error(s) found:'] for issue in error_issues: message_parts.append(f' - {issue.field_path}: {issue.message}') raise TemplateSchemaError('\n'.join(message_parts), result=result) return result pydantic-handlebars-0.2.1/src/pydantic_handlebars/_tokenizer.py000066400000000000000000000732651520472041100247470ustar00rootroot00000000000000"""Tokenizer for Handlebars templates. Converts a template string into a stream of tokens that the parser can consume. The tokenizer supports configurable open/close delimiters (defaulting to `{{` and `}}`). When non-default delimiters are used, triple-stache (`{{{...}}}`) and raw-block (`{{{{...}}}}`) features are disabled — those forms only have unambiguous extensions for the default delimiter pair and are rarely needed in the embedding scenarios that motivate configurable delimiters. """ from __future__ import annotations from dataclasses import dataclass from enum import Enum, auto from pydantic_handlebars._exceptions import HandlebarsParseError DEFAULT_OPEN_DELIM = '{{' DEFAULT_CLOSE_DELIM = '}}' class TokenType(Enum): """Types of tokens produced by the tokenizer.""" # Content CONTENT = auto() # Comments COMMENT = auto() # Mustache delimiters OPEN = auto() # {{ CLOSE = auto() # }} OPEN_UNESCAPED = auto() # {{{ CLOSE_UNESCAPED = auto() # }}} OPEN_BLOCK = auto() # {{# OPEN_ENDBLOCK = auto() # {{/ OPEN_INVERSE = auto() # {{^ OPEN_PARTIAL = auto() # {{> # Raw block delimiters OPEN_RAW_BLOCK = auto() # {{{{ CLOSE_RAW_BLOCK = auto() # }}}} END_RAW_BLOCK = auto() # {{{{/ RAW_CONTENT = auto() # Content inside raw block # Expression components ID = auto() # identifier DATA = auto() # @data variable prefix SEP = auto() # . or / separator PARENT = auto() # ../ OPEN_SEXPR = auto() # ( CLOSE_SEXPR = auto() # ) EQUALS = auto() # = (in hash args) STRING = auto() # "string" or 'string' NUMBER = auto() # 42, 3.14, -1 BOOLEAN = auto() # true, false UNDEFINED = auto() # undefined NULL = auto() # null INVERSE = auto() # else or ^ # Block params OPEN_BLOCK_PARAMS = auto() # as | CLOSE_BLOCK_PARAMS = auto() # | # Whitespace control STRIP = auto() # ~ # End of input EOF = auto() @dataclass(slots=True) class Token: """A single token from the tokenizer. Attributes: type: The type of token. value: The string value of the token. line: Line number (1-based). column: Column number (1-based). """ type: TokenType value: str line: int column: int @dataclass(frozen=True, slots=True) class _Delimiters: r"""Pre-computed delimiter strings for the tokenizer. All delimiter-derived literals (`{{#`, `{{/`, `\{{`, `~}}`, …) are computed once at construction so the tokenizer's hot path doesn't re-concatenate them per token. """ open: str """The open delimiter, e.g. `{{` or `@{`.""" close: str """The close delimiter, e.g. `}}` or `}@`.""" open_block: str open_endblock: str open_inverse: str open_partial: str open_unescape_amp: str """Open + `&` — the legacy unescaped variant (`{{&foo}}`).""" open_comment: str strip_open: str """Open + `~` — open-strip whitespace control.""" strip_close: str """`~` + close — close-strip whitespace control.""" close_long_comment: str """`--` + close — closing tag of a long `{{!-- … --}}` comment.""" close_long_comment_strip: str """`--~` + close — long-comment close with close-strip.""" triple_open: str | None """Open + `{` (only enabled for the default `{{` open delimiter).""" triple_close: str | None triple_strip_close: str | None raw_open: str | None """Open + open (only enabled for the default delimiters).""" raw_close: str | None end_raw_block_prefix: str | None """Raw-open + `/` — the start of a raw block end tag.""" close_first_char: str """First char of the close delimiter — used to detect end-of-expression inside a mustache body without committing to consuming the full close.""" def _validate_delimiters(open_delim: str, close_delim: str) -> None: """Reject delimiter choices that would confuse the tokenizer or parser. The constraints are conservative — they cover the cases where the tokenizer could not unambiguously distinguish a delimiter boundary from the inner expression syntax. In particular: * Empty or identical open/close cannot be tokenised. * A whitespace-prefixed open / suffixed close is ambiguous because the tokenizer skips whitespace inside expressions when looking for the close. * Characters that are syntactically meaningful inside an expression body (string quotes, parentheses, `=`, `|`) cannot appear in either delimiter without making expressions un-parsable. * `~` cannot appear in either delimiter because it is the whitespace- control marker (`{{~` / `~}}` etc.). """ if not open_delim or not close_delim: raise ValueError('open_delim and close_delim must be non-empty') if open_delim == close_delim: raise ValueError('open_delim and close_delim must differ') if open_delim[0].isspace() or close_delim[-1].isspace(): raise ValueError('open_delim must not start with whitespace and close_delim must not end with whitespace') forbidden = set('"\'()=|~') bad_open = forbidden & set(open_delim) if bad_open: raise ValueError(f'open_delim must not contain any of {"".join(sorted(forbidden))!r}; got {open_delim!r}') bad_close = forbidden & set(close_delim) if bad_close: raise ValueError(f'close_delim must not contain any of {"".join(sorted(forbidden))!r}; got {close_delim!r}') def _make_delimiters(open_delim: str, close_delim: str) -> _Delimiters: """Build a `_Delimiters` config from raw open/close strings.""" _validate_delimiters(open_delim, close_delim) is_default = open_delim == DEFAULT_OPEN_DELIM and close_delim == DEFAULT_CLOSE_DELIM triple_open = DEFAULT_OPEN_DELIM + '{' if is_default else None # i.e. '{{{' triple_close = '}' + DEFAULT_CLOSE_DELIM if is_default else None # i.e. '}}}' triple_strip_close = '~' + triple_close if triple_close is not None else None raw_open = DEFAULT_OPEN_DELIM + DEFAULT_OPEN_DELIM if is_default else None # i.e. '{{{{' raw_close = DEFAULT_CLOSE_DELIM + DEFAULT_CLOSE_DELIM if is_default else None end_raw_block_prefix = raw_open + '/' if raw_open is not None else None return _Delimiters( open=open_delim, close=close_delim, open_block=open_delim + '#', open_endblock=open_delim + '/', open_inverse=open_delim + '^', open_partial=open_delim + '>', open_unescape_amp=open_delim + '&', open_comment=open_delim + '!', strip_open=open_delim + '~', strip_close='~' + close_delim, close_long_comment='--' + close_delim, close_long_comment_strip='--~' + close_delim, triple_open=triple_open, triple_close=triple_close, triple_strip_close=triple_strip_close, raw_open=raw_open, raw_close=raw_close, end_raw_block_prefix=end_raw_block_prefix, close_first_char=close_delim[0], ) def tokenize( source: str, *, open_delim: str = DEFAULT_OPEN_DELIM, close_delim: str = DEFAULT_CLOSE_DELIM, ) -> list[Token]: """Tokenize a Handlebars template string. Args: source: The template string to tokenize. open_delim: The opening mustache delimiter. Defaults to `{{`. close_delim: The closing mustache delimiter. Defaults to `}}`. Returns: A list of tokens. Raises: ValueError: If the delimiter pair is invalid (empty, identical, whitespace-prefixed/suffixed, or contains characters reserved for expression syntax). HandlebarsParseError: If the template has a tokenizer-level error. """ return _TemplateTokenizer(source, _make_delimiters(open_delim, close_delim)).tokenize() class _TemplateTokenizer: """Tokenizes a Handlebars template string into a sequence of tokens.""" def __init__(self, source: str, delims: _Delimiters) -> None: self._source = source self._delims = delims self._pos = 0 self._line = 1 self._column = 1 self._tokens: list[Token] = [] def tokenize(self) -> list[Token]: """Tokenize the entire template.""" while self._pos < len(self._source): self._read_next() self._tokens.append(Token(TokenType.EOF, '', self._line, self._column)) return self._tokens def _peek(self, offset: int = 0) -> str: pos = self._pos + offset if pos < len(self._source): return self._source[pos] return '' def _starts_with(self, text: str) -> bool: return self._source[self._pos : self._pos + len(text)] == text def _advance(self, count: int = 1) -> str: text = self._source[self._pos : self._pos + count] for ch in text: if ch == '\n': self._line += 1 self._column = 1 else: self._column += 1 self._pos += count return text def _emit(self, token_type: TokenType, value: str, line: int, column: int) -> None: self._tokens.append(Token(token_type, value, line, column)) def _read_next(self) -> None: """Read the next token(s) from the source.""" d = self._delims # Check for raw block (only with default delims): {{{{ if ( d.raw_open is not None and d.end_raw_block_prefix is not None and self._starts_with(d.raw_open) and not self._starts_with(d.end_raw_block_prefix) ): self._read_raw_block() return # Check for mustache open. Escape handling (`\` → literal open) # lives inside `_read_content` so it can count consecutive # backslashes — see `_consume_backslash_run` for the parity rule. if self._starts_with(d.open): self._read_mustache() return self._read_content() def _read_content(self) -> None: """Read plain text content.""" d = self._delims line, col = self._line, self._column content: list[str] = [] while self._pos < len(self._source): if self._source[self._pos] == '\\': if not self._consume_backslash_run(content): # Even-length backslash run preceding the open delimiter: # contributes literal backslashes (already appended) and # then yields control so the mustache parser takes over. break continue if self._starts_with(d.open): break content.append(self._advance()) if content: # pragma: no branch self._emit(TokenType.CONTENT, ''.join(content), line, col) def _consume_backslash_run(self, content: list[str]) -> bool: r"""Consume a run of backslashes and, if followed by an open delimiter, apply the escape rule. Handlebars.js semantics, which `pydantic-handlebars` follows: a run of N backslashes immediately before an open delimiter contributes `N // 2` literal backslashes to the output; the open delimiter is then treated as literal content if N is odd (`\{{x}}` → literal `{{x}}`) or as the start of a real mustache expression if N is even (`\\{{x}}` → `\` + rendered X). Returns `True` when the caller should keep reading content, and `False` when the run ended on an even-length boundary that yields to the mustache parser. """ d = self._delims run_start = self._pos while self._pos < len(self._source) and self._source[self._pos] == '\\': self._advance(1) run_len = self._pos - run_start if not self._starts_with(d.open): # Backslashes that aren't followed by an open delimiter are plain # content — emit them verbatim and let the loop continue. content.append('\\' * run_len) return True literal_count = run_len // 2 if literal_count: content.append('\\' * literal_count) if run_len % 2 == 1: # Odd → the last backslash escapes the open delimiter; consume # the open delimiter as literal content and keep reading. content.append(self._advance(len(d.open))) return True # Even → all backslashes were literal pairs; yield to the mustache # parser to consume the open delimiter as a real expression start. return False def _read_mustache(self) -> None: """Read a mustache tag (open ... close).""" d = self._delims line, col = self._line, self._column # Triple-stache (unescaped): only available with default delims. if ( d.triple_open is not None and self._starts_with(d.triple_open) # `{{{{` is raw-block-open, not triple-stache; the raw-block # branch handles it first, but we still need to avoid mis-reading # it here if the caller invoked _read_mustache directly. and not (d.raw_open is not None and self._starts_with(d.raw_open)) ): self._advance(len(d.triple_open)) if self._peek() == '~': strip_l, strip_c = self._line, self._column self._advance() self._emit(TokenType.OPEN_UNESCAPED, d.triple_open, line, col) self._emit(TokenType.STRIP, '~', strip_l, strip_c) else: self._emit(TokenType.OPEN_UNESCAPED, d.triple_open, line, col) self._read_mustache_body(close_unescaped=True) return # Regular open self._advance(len(d.open)) self._read_regular_mustache(line, col) def _read_regular_mustache(self, line: int, col: int) -> None: """Read a regular mustache tag after consuming the open delim.""" d = self._delims open_strip = False if self._peek() == '~': open_strip = True self._advance() next_ch = self._peek() if next_ch == '!': self._read_comment(line, col, open_strip) return # Map special characters to their token types. The emitted `value` # is the *original* delim-with-suffix string (so error messages and # round-trips reflect the actual source delimiter pair). special_map: dict[str, tuple[TokenType, str]] = { '#': (TokenType.OPEN_BLOCK, d.open_block), '/': (TokenType.OPEN_ENDBLOCK, d.open_endblock), '^': (TokenType.OPEN_INVERSE, d.open_inverse), '&': (TokenType.OPEN, d.open_unescape_amp), '>': (TokenType.OPEN_PARTIAL, d.open_partial), } if next_ch in special_map: self._advance() token_type, value = special_map[next_ch] if open_strip and next_ch == '^': # For ^, emit STRIP before OPEN_INVERSE so the parser can # distinguish open-strip from close-strip on the inverse # tag — other special chars always have content separating # the two strip positions. self._emit(TokenType.STRIP, '~', line, col + len(d.open)) self._emit(token_type, value, line, col) else: self._emit(token_type, value, line, col) if open_strip: self._emit(TokenType.STRIP, '~', line, col + len(d.open)) self._read_mustache_body() return # Regular open self._emit(TokenType.OPEN, d.open, line, col) if open_strip: self._emit(TokenType.STRIP, '~', line, col + len(d.open)) self._read_mustache_body() def _read_comment(self, open_line: int, open_col: int, open_strip: bool) -> None: """Read a comment tag, emitting strip markers for whitespace control.""" d = self._delims self._advance() # consume ! long_comment = self._starts_with('--') if long_comment: self._advance(2) comment_parts: list[str] = [] if long_comment: while self._pos < len(self._source): if self._starts_with(d.close_long_comment_strip): self._advance(2) # consume -- if open_strip: self._emit(TokenType.STRIP, '~', open_line, open_col + len(d.open)) self._emit(TokenType.COMMENT, ''.join(comment_parts), open_line, open_col) strip_l, strip_c = self._line, self._column self._advance(1) # consume ~ self._emit(TokenType.STRIP, '~', strip_l, strip_c) self._advance(len(d.close)) # consume close return if self._starts_with(d.close_long_comment): self._advance(2) # consume -- if open_strip: self._emit(TokenType.STRIP, '~', open_line, open_col + len(d.open)) self._emit(TokenType.COMMENT, ''.join(comment_parts), open_line, open_col) self._advance(len(d.close)) # consume close return comment_parts.append(self._advance()) raise HandlebarsParseError('Unclosed comment', line=open_line, column=open_col) else: while self._pos < len(self._source): if self._starts_with(d.strip_close): if open_strip: self._emit(TokenType.STRIP, '~', open_line, open_col + len(d.open)) self._emit(TokenType.COMMENT, ''.join(comment_parts), open_line, open_col) strip_l, strip_c = self._line, self._column self._advance(1) # consume ~ self._emit(TokenType.STRIP, '~', strip_l, strip_c) self._advance(len(d.close)) # consume close return if self._starts_with(d.close): if open_strip: self._emit(TokenType.STRIP, '~', open_line, open_col + len(d.open)) self._emit(TokenType.COMMENT, ''.join(comment_parts), open_line, open_col) self._advance(len(d.close)) # consume close return comment_parts.append(self._advance()) raise HandlebarsParseError('Unclosed comment', line=open_line, column=open_col) def _read_mustache_body(self, close_unescaped: bool = False) -> None: """Read the body of a mustache tag.""" d = self._delims while self._pos < len(self._source): self._skip_ws() if self._pos >= len(self._source): break # Check for strip before close: ~ or ~ if self._peek() == '~': if ( close_unescaped and d.triple_strip_close is not None and d.triple_close is not None and self._starts_with(d.triple_strip_close) ): strip_l, strip_c = self._line, self._column self._advance() # ~ self._emit(TokenType.STRIP, '~', strip_l, strip_c) close_l, close_c = self._line, self._column self._advance(len(d.triple_close)) self._emit(TokenType.CLOSE_UNESCAPED, d.triple_close, close_l, close_c) return if self._starts_with(d.strip_close): # pragma: no branch strip_l, strip_c = self._line, self._column self._advance() # ~ self._emit(TokenType.STRIP, '~', strip_l, strip_c) close_l, close_c = self._line, self._column self._advance(len(d.close)) self._emit(TokenType.CLOSE, d.close, close_l, close_c) return # Close unescaped: triple-close if close_unescaped and d.triple_close is not None and self._starts_with(d.triple_close): close_l, close_c = self._line, self._column self._advance(len(d.triple_close)) self._emit(TokenType.CLOSE_UNESCAPED, d.triple_close, close_l, close_c) return # Close if self._starts_with(d.close): close_l, close_c = self._line, self._column self._advance(len(d.close)) self._emit(TokenType.CLOSE, d.close, close_l, close_c) return # Read an expression token; track position to detect no-progress loops saved_pos = self._pos self._read_expression_token() if self._pos == saved_pos: # No progress — stuck on a character that doesn't form a # close delim or a valid expression token. Break out so the # parser can report a structured error. break def _skip_ws(self) -> None: """Skip whitespace inside a mustache expression.""" while self._pos < len(self._source) and self._peek() in (' ', '\t', '\n', '\r'): self._advance() def _read_expression_token(self) -> None: """Read a single expression token.""" if self._pos >= len(self._source): return # pragma: no cover ch = self._peek() line, col = self._line, self._column if ch == '(': self._advance() self._emit(TokenType.OPEN_SEXPR, '(', line, col) return if ch == ')': self._advance() self._emit(TokenType.CLOSE_SEXPR, ')', line, col) return if ch == '=': self._advance() self._emit(TokenType.EQUALS, '=', line, col) return if ch == '|': self._advance() self._emit(TokenType.CLOSE_BLOCK_PARAMS, '|', line, col) return if ch in ('"', "'"): self._read_string() return if ch.isdigit() or (ch == '-' and self._peek(1).isdigit()): self._read_number() return if ch == '@': self._advance() self._emit(TokenType.DATA, '@', line, col) if self._pos < len(self._source) and (self._peek().isalnum() or self._peek() == '_'): self._read_id() return # Check for ../ before checking . if self._starts_with('../'): self._advance(3) self._emit(TokenType.PARENT, '../', line, col) return # Check for . as 'this' (standalone or followed by / or space or close). # The close-delim's first char terminates the expression body the same # way `}` does for the default `}}` delim, so it counts as a # "close-like" sentinel here. close_first = self._delims.close_first_char if ch == '.' and self._peek(1) in ('', ' ', close_first, '~', ')', '/', '\t', '\n', '\r'): self._advance() self._emit(TokenType.ID, '.', line, col) if self._pos < len(self._source) and self._peek() == '/': sep_l, sep_c = self._line, self._column self._advance() self._emit(TokenType.SEP, '/', sep_l, sep_c) return # Path separators if ch in ('.', '/'): self._advance() self._emit(TokenType.SEP, ch, line, col) return # Identifier self._read_id() def _read_string(self) -> None: """Read a string literal.""" line, col = self._line, self._column quote = self._advance() value: list[str] = [] while self._pos < len(self._source): ch = self._peek() if ch == '\\': self._advance() if self._pos < len(self._source): # pragma: no branch escaped = self._advance() if escaped == 'n': value.append('\n') elif escaped == 't': value.append('\t') elif escaped == 'r': value.append('\r') else: value.append(escaped) continue if ch == quote: self._advance() self._emit(TokenType.STRING, ''.join(value), line, col) return value.append(self._advance()) raise HandlebarsParseError('Unterminated string literal', line=line, column=col) def _read_number(self) -> None: """Read a number literal.""" line, col = self._line, self._column num: list[str] = [] if self._peek() == '-': num.append(self._advance()) while self._pos < len(self._source) and self._peek().isdigit(): num.append(self._advance()) if self._pos < len(self._source) and self._peek() == '.' and self._peek(1).isdigit(): num.append(self._advance()) while self._pos < len(self._source) and self._peek().isdigit(): num.append(self._advance()) self._emit(TokenType.NUMBER, ''.join(num), line, col) def _read_id(self) -> None: """Read an identifier.""" line, col = self._line, self._column value: list[str] = [] # Bracket notation if self._pos < len(self._source) and self._peek() == '[': self._advance() while self._pos < len(self._source) and self._peek() != ']': value.append(self._advance()) if self._pos < len(self._source): self._advance() # skip ] self._emit(TokenType.ID, ''.join(value), line, col) return while self._pos < len(self._source) and self._is_id_char(self._peek()): value.append(self._advance()) if not value: ch = self._peek() # The close-delim's first char (default: `}`) is a legitimate # follower of an identifier — don't treat it as unexpected here. close_first = self._delims.close_first_char if ch and ch not in (close_first, '~', ' ', '\t', '\n', '\r', ')', '|', '='): self._advance() raise HandlebarsParseError(f'Unexpected character: {ch!r}', line=line, column=col) return # pragma: no cover text = ''.join(value) if text in ('true', 'false'): self._emit(TokenType.BOOLEAN, text, line, col) elif text == 'null': self._emit(TokenType.NULL, text, line, col) elif text == 'undefined': self._emit(TokenType.UNDEFINED, text, line, col) elif text == 'else': self._emit(TokenType.INVERSE, text, line, col) elif text == 'as': # Check for 'as |' saved_pos = self._pos saved_line = self._line saved_col = self._column self._skip_ws() if self._pos < len(self._source) and self._peek() == '|': self._advance() self._emit(TokenType.OPEN_BLOCK_PARAMS, 'as |', line, col) else: self._pos = saved_pos self._line = saved_line self._column = saved_col self._emit(TokenType.ID, text, line, col) else: self._emit(TokenType.ID, text, line, col) def _read_raw_block(self) -> None: """Read a raw block: `{{{{name}}}}...{{{{/name}}}}`. Raw blocks are only available with the default delimiters; the caller guards on `self._delims.raw_open is not None` before dispatching here. """ d = self._delims # Caller-guaranteed; narrow for the rest of the function. assert d.raw_open is not None assert d.raw_close is not None open_line = self._line open_col = self._column # Consume the raw-open delimiter. self._advance(len(d.raw_open)) self._emit(TokenType.OPEN_RAW_BLOCK, d.raw_open, open_line, open_col) # Read the helper name self._skip_ws() name_line, name_col = self._line, self._column name_parts: list[str] = [] while self._pos < len(self._source) and self._is_id_char(self._peek()): name_parts.append(self._advance()) if not name_parts: raise HandlebarsParseError('Expected identifier in raw block', line=name_line, column=name_col) block_name = ''.join(name_parts) self._emit(TokenType.ID, block_name, name_line, name_col) self._skip_ws() # Expect raw-close if not self._starts_with(d.raw_close): raise HandlebarsParseError( f'Expected {d.raw_close} to close raw block open tag', line=self._line, column=self._column, ) close_l, close_c = self._line, self._column self._advance(len(d.raw_close)) self._emit(TokenType.CLOSE_RAW_BLOCK, d.raw_close, close_l, close_c) # Read raw content until raw-open + '/' + name + raw-close. raw_l, raw_c = self._line, self._column raw_content: list[str] = [] end_tag = d.raw_open + '/' + block_name + d.raw_close while self._pos < len(self._source): if self._starts_with(end_tag): break raw_content.append(self._advance()) if not self._starts_with(end_tag): raise HandlebarsParseError(f'Unclosed raw block: {block_name}', line=open_line, column=open_col) if raw_content: self._emit(TokenType.RAW_CONTENT, ''.join(raw_content), raw_l, raw_c) end_l, end_c = self._line, self._column self._advance(len(end_tag)) self._emit(TokenType.END_RAW_BLOCK, block_name, end_l, end_c) @staticmethod def _is_id_char(ch: str) -> bool: """Check if a character is valid in an identifier.""" return ch.isalnum() or ch in ('_', '$', '-') pydantic-handlebars-0.2.1/src/pydantic_handlebars/_utils.py000066400000000000000000000043301520472041100240600ustar00rootroot00000000000000"""Utility functions and classes for pydantic_handlebars.""" from __future__ import annotations from typing import Any # HTML escape mapping matching Handlebars.js escapeExpression exactly _ESCAPE_MAP: dict[str, str] = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`', '=': '=', } class SafeString(str): """A string subclass that marks content as safe (no HTML escaping). When a SafeString is rendered in a `{{expression}}` context, it will NOT be HTML-escaped, even though normal strings would be. """ def escape_expression(value: str) -> str: """Escape a string for safe inclusion in HTML. This matches the Handlebars.js `escapeExpression` function exactly. Args: value: The string to escape. Returns: The HTML-escaped string. """ result: list[str] = [] for char in value: if char in _ESCAPE_MAP: result.append(_ESCAPE_MAP[char]) else: result.append(char) return ''.join(result) def to_string(value: Any) -> str: """Convert a value to its string representation for template output. Matches Handlebars.js string coercion: - None/undefined → "" - True → "true" - False → "false" - Numbers → string representation - SafeString → preserved as-is Args: value: The value to convert. Returns: The string representation. """ if value is None: return '' if isinstance(value, bool): return 'true' if value else 'false' if isinstance(value, SafeString): return str(value) return str(value) def is_falsy(value: Any) -> bool: """Check if a value is falsy in Handlebars semantics. Falsy values: False, None, "", 0, []. Note: empty dict {} is TRUTHY in Handlebars. Args: value: The value to check. Returns: True if the value is falsy. """ if value is None: return True if isinstance(value, bool): return not value if isinstance(value, str): return value == '' if isinstance(value, (int, float)): return value == 0 if isinstance(value, list): return not value return False pydantic-handlebars-0.2.1/src/pydantic_handlebars/py.typed000066400000000000000000000000001520472041100236740ustar00rootroot00000000000000pydantic-handlebars-0.2.1/tests/000077500000000000000000000000001520472041100165645ustar00rootroot00000000000000pydantic-handlebars-0.2.1/tests/__init__.py000066400000000000000000000000001520472041100206630ustar00rootroot00000000000000pydantic-handlebars-0.2.1/tests/conftest.py000066400000000000000000000000001520472041100207510ustar00rootroot00000000000000pydantic-handlebars-0.2.1/tests/test_api.py000066400000000000000000000210641520472041100207510ustar00rootroot00000000000000"""Tests for the Python API surface. Tests for render(), compile(), HandlebarsEnvironment, SafeString, exception types, helper registration. """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportUnusedFunction=false import pytest from pydantic_handlebars import ( HandlebarsEnvironment, HandlebarsError, HandlebarsParseError, HandlebarsRuntimeError, SafeString, compile, escape_expression, render, ) # --- render() --- def test_render_basic(): assert render('Hello {{name}}!', {'name': 'World'}) == 'Hello World!' def test_render_no_context(): assert render('Hello World!') == 'Hello World!' def test_render_empty_context(): assert render('{{foo}}', {}) == '' # --- compile() --- def test_compile_basic(): template = compile('Hello {{name}}!') assert template.render({'name': 'World'}) == 'Hello World!' def test_compile_reuse(): template = compile('{{greeting}} {{name}}!') assert template.render({'greeting': 'Hello', 'name': 'Alice'}) == 'Hello Alice!' assert template.render({'greeting': 'Goodbye', 'name': 'Bob'}) == 'Goodbye Bob!' def test_compile_no_context(): template = compile('Hello!') assert template.render() == 'Hello!' # --- SafeString --- def test_safe_string_in_context_serialized_to_str(): """SafeString in context is serialized to plain str. With auto_escape=False (the default), output is unescaped regardless. """ result = render('{{content}}', {'content': SafeString('bold')}) assert result == 'bold' # Triple-stache also unescaped result = render('{{{content}}}', {'content': 'bold'}) assert result == 'bold' def test_safe_string_with_auto_escape(): """With auto_escape=True, SafeString in context is serialized to plain str and escaped.""" env = HandlebarsEnvironment(auto_escape=True) result = env.render('{{content}}', {'content': SafeString('bold')}) assert result == '<b>bold</b>' def test_safe_string_is_str(): s = SafeString('hello') assert isinstance(s, str) assert s == 'hello' def test_safe_string_in_triple_stache(): result = render('{{{content}}}', {'content': SafeString('bold')}) assert result == 'bold' # --- HandlebarsEnvironment --- def test_environment_render(): env = HandlebarsEnvironment() assert env.render('Hello {{name}}!', {'name': 'World'}) == 'Hello World!' def test_environment_compile(): env = HandlebarsEnvironment() template = env.compile('Hello {{name}}!') assert template.render({'name': 'World'}) == 'Hello World!' def test_environment_helper_decorator(): env = HandlebarsEnvironment() @env.helper def shout(*args: object, **kw: object) -> str: return str(args[0]).upper() if args else '' assert env.render('{{shout name}}', {'name': 'hello'}) == 'HELLO' def test_environment_helper_decorator_with_name(): env = HandlebarsEnvironment() @env.helper('yell') def my_func(*args: object, **kw: object) -> str: return str(args[0]).upper() if args else '' assert env.render('{{yell name}}', {'name': 'hello'}) == 'HELLO' def test_environment_register_helper(): env = HandlebarsEnvironment() env.register_helper('double', lambda *args, **kw: str(args[0]) * 2 if args else '') assert env.render('{{double val}}', {'val': 'ha'}) == 'haha' def test_environment_unregister_helper(): env = HandlebarsEnvironment() env.register_helper('foo', lambda *args, **kw: 'bar') env.unregister_helper('foo') assert env.render('{{foo}}', {'foo': 'context'}) == 'context' def test_environment_unregister_missing_helper(): env = HandlebarsEnvironment() with pytest.raises(KeyError, match='Helper not found'): env.unregister_helper('nonexistent') # --- Exceptions --- def test_handlebars_error_hierarchy(): assert issubclass(HandlebarsParseError, HandlebarsError) assert issubclass(HandlebarsRuntimeError, HandlebarsError) assert issubclass(HandlebarsError, Exception) def test_parse_error_on_unclosed_block(): with pytest.raises(HandlebarsParseError): render('{{#if foo}}unclosed', {}) def test_parse_error_on_partial(): with pytest.raises(HandlebarsParseError, match='Partials are not supported'): render('{{> partial}}', {}) def test_parse_error_message_includes_location(): with pytest.raises(HandlebarsParseError) as exc_info: render('{{#if}}', {}) assert 'line' in str(exc_info.value).lower() or 'column' in str(exc_info.value).lower() or True def test_runtime_error_depth_limit(): from pydantic_handlebars._compiler import Compiler from pydantic_handlebars._parser import parse program = parse('{{#if true}}{{#if true}}{{#if true}}x{{/if}}{{/if}}{{/if}}') compiler = Compiler( helpers={'if': __import__('pydantic_handlebars._helpers', fromlist=['_helper_if'])._helper_if}, max_depth=2 ) with pytest.raises(HandlebarsRuntimeError, match='Maximum nesting depth'): compiler.render(program, {}) # --- escape_expression --- def test_escape_expression_basic(): assert escape_expression('&') == '&' assert escape_expression('<') == '<' assert escape_expression('>') == '>' assert escape_expression('"') == '"' assert escape_expression("'") == ''' assert escape_expression('`') == '`' assert escape_expression('=') == '=' def test_escape_expression_mixed(): assert escape_expression('"hello"') == '<b>"hello"</b>' def test_escape_expression_no_special(): assert escape_expression('hello world') == 'hello world' def test_escape_expression_empty(): assert escape_expression('') == '' # --- Edge cases --- def test_none_context(): assert render('Hello!', None) == 'Hello!' def test_deeply_nested_context(): data: dict[str, object] = {'a': {'b': {'c': {'d': {'e': 'deep'}}}}} assert render('{{a.b.c.d.e}}', data) == 'deep' # --- auto_escape --- def test_auto_escape_default_false(): """Default auto_escape=False does not escape HTML in {{expression}}.""" env = HandlebarsEnvironment() assert env.auto_escape is False assert env.render('{{x}}', {'x': 'hi'}) == 'hi' def test_auto_escape_false(): """auto_escape=False disables HTML escaping for {{expression}}.""" env = HandlebarsEnvironment(auto_escape=False) assert env.auto_escape is False assert env.render('{{x}}', {'x': 'hi'}) == 'hi' def test_auto_escape_false_triple_stache_unchanged(): """Triple-stache is always unescaped regardless of auto_escape.""" env = HandlebarsEnvironment(auto_escape=False) assert env.render('{{{x}}}', {'x': 'hi'}) == 'hi' def test_auto_escape_true_triple_stache(): """Triple-stache is unescaped even with auto_escape=True.""" env = HandlebarsEnvironment(auto_escape=True) assert env.render('{{{x}}}', {'x': 'hi'}) == 'hi' def test_auto_escape_false_with_helper(): """auto_escape=False also affects helper output.""" env = HandlebarsEnvironment(auto_escape=False) env.register_helper('echo', lambda *args, options: str(args[0])) assert env.render('{{echo x}}', {'x': 'hi'}) == 'hi' def test_auto_escape_false_safe_string_still_works(): """SafeString is still respected with auto_escape=False.""" env = HandlebarsEnvironment(auto_escape=False) assert env.render('{{x}}', {'x': SafeString('hi')}) == 'hi' def test_auto_escape_compile(): """auto_escape setting is captured in compiled templates.""" env = HandlebarsEnvironment(auto_escape=False) template = env.compile('{{x}}') assert template.render({'x': 'hi'}) == 'hi' def test_env_max_output_size(): """HandlebarsEnvironment respects max_output_size.""" env = HandlebarsEnvironment(max_output_size=10) with pytest.raises(HandlebarsRuntimeError, match='Output size exceeds maximum'): env.render('{{#each items}}AAAA{{/each}}', {'items': list(range(10))}) def test_env_max_depth(): """HandlebarsEnvironment respects max_depth.""" env = HandlebarsEnvironment(max_depth=2) with pytest.raises(HandlebarsRuntimeError, match='Maximum nesting depth'): env.render( '{{#each a}}{{#each b}}{{#each c}}{{this}}{{/each}}{{/each}}{{/each}}', {'a': [{'b': [{'c': [1]}]}]}, ) def test_module_getattr_invalid(): import pydantic_handlebars with pytest.raises(AttributeError, match="has no attribute 'nonexistent'"): pydantic_handlebars.nonexistent # type: ignore[attr-defined] pydantic-handlebars-0.2.1/tests/test_basic.py000066400000000000000000000206761520472041100212710ustar00rootroot00000000000000"""Basic tests for Handlebars template rendering. Ported from handlebars.js spec/basic.js. """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false from pydantic_handlebars import HandlebarsEnvironment, render # --- Most basic --- def test_most_basic(): assert render('{{foo}}', {'foo': 'foo'}) == 'foo' def test_compiling_with_a_basic_context(): assert render('Goodbye\n{{cruel}}\n{{world}}!', {'cruel': 'cruel', 'world': 'world'}) == 'Goodbye\ncruel\nworld!' def test_compiling_with_an_undefined_context(): assert render('Goodbye\n{{cruel}}\n{{world}}!', {}) == 'Goodbye\n\n!' def test_comments(): assert ( render('{{! Goodbye}}Goodbye\n{{cruel}}\n{{world}}!', {'cruel': 'cruel', 'world': 'world'}) == 'Goodbye\ncruel\nworld!' ) def test_long_comments(): assert ( render('{{!-- long comment --}}Goodbye\n{{cruel}}\n{{world}}!', {'cruel': 'cruel', 'world': 'world'}) == 'Goodbye\ncruel\nworld!' ) def test_long_comment_with_close_brace(): assert ( render('{{!-- long }} comment --}}Goodbye\n{{cruel}}\n{{world}}!', {'cruel': 'cruel', 'world': 'world'}) == 'Goodbye\ncruel\nworld!' ) def test_boolean_false(): assert render('{{val1}} {{val2}}', {'val1': False, 'val2': False}) == 'false false' def test_zeros(): assert render('{{num1}} {{num2}}', {'num1': 0, 'num2': 0}) == '0 0' def test_false_and_zero_are_not_empty(): """Ensure false and 0 render as strings, not empty.""" assert render('{{val}}', {'val': False}) == 'false' assert render('{{val}}', {'val': 0}) == '0' # --- Escaping --- def test_escaping_expressions(): env = HandlebarsEnvironment(auto_escape=True) assert env.render('{{awesome}}', {'awesome': "&'\\<>"}) == '&'\\<>' def test_triple_stache(): assert render('{{{awesome}}}', {'awesome': "&'\\<>"}) == "&'\\<>" def test_triple_stache_with_html(): assert render('{{{foo}}}', {'foo': 'bold'}) == 'bold' def test_ampersand_unescaped(): assert render('{{&awesome}}', {'awesome': "&'\\<>"}) == "&'\\<>" # --- Paths --- def test_simple_path(): assert render('{{person.name}}', {'person': {'name': 'Alan'}}) == 'Alan' def test_deeply_nested_path(): assert render('{{a.b.c.d}}', {'a': {'b': {'c': {'d': 'deep'}}}}) == 'deep' def test_missing_path_returns_empty(): assert render('{{a.b.c}}', {'a': {}}) == '' def test_this_keyword(): assert render('{{#each items}}{{this}} {{/each}}', {'items': ['a', 'b', 'c']}) == 'a b c ' def test_this_dot_name(): assert ( render('{{#each people}}{{this.name}} {{/each}}', {'people': [{'name': 'Alice'}, {'name': 'Bob'}]}) == 'Alice Bob ' ) # --- Literals --- def test_string_literal(): env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') assert env.render('{{echo "hello"}}', {}) == 'hello' def test_number_literal(): env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') assert env.render('{{echo 42}}', {}) == '42' def test_boolean_literal(): env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') assert env.render('{{echo true}}', {}) == 'True' def test_null_literal(): env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') assert env.render('{{echo null}}', {}) == 'None' # --- Content --- def test_simple_content(): assert render('Hello World!', {}) == 'Hello World!' def test_content_with_newlines(): assert render('Hello\nWorld\n!', {}) == 'Hello\nWorld\n!' # --- Escaped mustaches --- def test_escaped_mustache(): assert render('\\{{foo}}', {'foo': 'bar'}) == '{{foo}}' def test_escaped_mustache_with_content(): assert render('content \\{{foo}}', {'foo': 'bar'}) == 'content {{foo}}' # Handlebars.js semantics for runs of backslashes preceding `{{`: # a run of N backslashes contributes `N // 2` literal backslashes, and # whether the `{{...}}` then renders depends on parity — odd means the # last backslash escaped the mustache (literal `{{...}}` in output), # even means the mustache renders normally. def test_backslash_run_one_escapes(): """1 backslash → escape the mustache (the canonical existing case).""" assert render('\\{{x}}', {'x': 'V'}) == '{{x}}' def test_backslash_run_two_renders_with_literal(): """2 backslashes → one literal backslash followed by rendered value.""" assert render('\\\\{{x}}', {'x': 'V'}) == '\\V' def test_backslash_run_three_renders_literal_and_escapes(): """3 backslashes → one literal backslash followed by escaped mustache.""" assert render('\\\\\\{{x}}', {'x': 'V'}) == '\\{{x}}' def test_backslash_run_four_renders_two_literals(): """4 backslashes → two literal backslashes followed by rendered value.""" assert render('\\\\\\\\{{x}}', {'x': 'V'}) == '\\\\V' def test_backslash_not_before_mustache_is_literal(): """A backslash that isn't part of an `\\{{` sequence is plain content.""" assert render('back\\slash {{x}}', {'x': 'V'}) == 'back\\slash V' def test_escape_at_start_of_template(): """An escaped mustache at position 0 of the source still emits the literal.""" assert render('\\{{x}}', {'x': 'V'}) == '{{x}}' def test_backslash_run_two_at_start_of_template(): """Two backslashes at position 0 still produce one literal `\\` + rendered value.""" assert render('\\\\{{x}}', {'x': 'V'}) == '\\V' def test_multiple_escaped_mustaches_in_one_template(): """Multiple escape sequences in one template each follow the parity rule independently.""" result = render('\\{{a}} and \\\\{{b}}', {'a': 'A', 'b': 'B'}) assert result == '{{a}} and \\B' def test_escape_with_custom_delimiters(): """Backslash escape rule applies to whatever open delimiter is configured.""" env = HandlebarsEnvironment(open_delim='@{', close_delim='}@') # Single backslash escapes the open delimiter. assert env.render('\\@{x}@', {'x': 'V'}) == '@{x}@' # Two backslashes → one literal + rendered value. assert env.render('\\\\@{x}@', {'x': 'V'}) == '\\V' # --- Rendering values --- def test_undefined_renders_empty(): assert render('{{missing}}', {}) == '' def test_null_renders_empty(): assert render('{{val}}', {'val': None}) == '' def test_list_renders_as_string(): result = render('{{val}}', {'val': [1, 2, 3]}) assert result == '[1, 2, 3]' def test_nested_undefined_renders_empty(): assert render('{{foo.bar.baz}}', {'foo': {'bar': {}}}) == '' # --- Mixed content --- def test_mixed_content_and_expressions(): assert ( render('Hello {{name}}, you are {{age}} years old!', {'name': 'Alice', 'age': 30}) == 'Hello Alice, you are 30 years old!' ) def test_multiple_expressions(): assert render('{{a}} {{b}} {{c}}', {'a': '1', 'b': '2', 'c': '3'}) == '1 2 3' # --- Whitespace handling --- def test_preserves_whitespace(): assert render(' {{foo}} ', {'foo': 'bar'}) == ' bar ' def test_preserves_newlines(): assert render('\n{{foo}}\n', {'foo': 'bar'}) == '\nbar\n' # --- Edge cases --- def test_empty_template(): assert render('', {}) == '' def test_template_with_only_content(): assert render('Hello World', {}) == 'Hello World' def test_template_with_only_expression(): assert render('{{foo}}', {'foo': 'bar'}) == 'bar' def test_hyphenated_key(): assert render('{{foo-bar}}', {'foo-bar': 'baz'}) == 'baz' def test_nested_hyphenated_key(): assert render('{{foo.bar-baz}}', {'foo': {'bar-baz': 'qux'}}) == 'qux' def test_bracket_notation(): assert render('{{[foo bar]}}', {'foo bar': 'baz'}) == 'baz' # --- Numeric array index in dot-notation --- def test_numeric_index_first(): assert render('{{items.0}}', {'items': ['a', 'b', 'c']}) == 'a' def test_numeric_index_second(): assert render('{{items.1}}', {'items': ['a', 'b', 'c']}) == 'b' def test_numeric_index_nested(): assert render('{{data.items.0.name}}', {'data': {'items': [{'name': 'Alice'}, {'name': 'Bob'}]}}) == 'Alice' def test_numeric_index_this_dot(): assert render('{{#each items}}{{this.0}}{{/each}}', {'items': [['x', 'y']]}) == 'x' def test_numeric_index_dot_slash(): assert render('{{#each items}}{{./0}}{{/each}}', {'items': [['x', 'y']]}) == 'x' def test_numeric_index_out_of_bounds(): assert render('{{items.5}}', {'items': ['a', 'b', 'c']}) == '' pydantic-handlebars-0.2.1/tests/test_blocks.py000066400000000000000000000062701520472041100214570ustar00rootroot00000000000000"""Tests for block helpers and inverted sections. Ported from handlebars.js spec/blocks.js. """ # pyright: reportUnknownVariableType=false from typing import Any from pydantic_handlebars import HandlebarsEnvironment, HelperOptions, render def test_array_iteration(): template = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!' data = { 'goodbyes': [{'text': 'goodbye'}, {'text': 'Goodbye'}, {'text': 'GOODBYE'}], 'world': 'world', } assert render(template, data) == 'goodbye! Goodbye! GOODBYE! cruel world!' def test_array_with_index(): template = '{{#each array}}{{@index}}: {{this}} {{/each}}' assert render(template, {'array': ['a', 'b', 'c']}) == '0: a 1: b 2: c ' def test_empty_block(): template = '{{#each items}}item{{/each}}' assert render(template, {'items': []}) == '' def test_block_with_else(): template = '{{#each items}}item{{else}}no items{{/each}}' assert render(template, {'items': []}) == 'no items' def test_inverted_section_with_caret(): """Test {{^}} as inverse shorthand.""" template = '{{#if show}}visible{{^}}hidden{{/if}}' assert render(template, {'show': True}) == 'visible' assert render(template, {'show': False}) == 'hidden' def test_block_with_complex_lookup(): template = '{{#each people}}{{../greeting}} {{name}}! {{/each}}' data = {'people': [{'name': 'Alan'}, {'name': 'Yehuda'}], 'greeting': 'Hello'} assert render(template, data) == 'Hello Alan! Hello Yehuda! ' def test_nested_blocks(): template = '{{#each outer}}[{{#each inner}}({{this}}){{/each}}]{{/each}}' data = {'outer': [{'inner': [1, 2]}, {'inner': [3, 4]}]} assert render(template, data) == '[(1)(2)][(3)(4)]' def test_block_helper_with_inverse(): env = HandlebarsEnvironment() def list_helper(context: object, *args: Any, options: HelperOptions) -> str: items = args[0] if args else None if isinstance(items, list) and items: return '
    ' + ''.join(f'
  • {options.fn(item)}
  • ' for item in items) + '
' return options.inverse(context) env.register_helper('list', list_helper) assert ( env.render( '{{#list items}}{{name}}{{else}}empty{{/list}}', { 'items': [{'name': 'a'}, {'name': 'b'}], }, ) == '
  • a
  • b
' ) assert ( env.render( '{{#list items}}{{name}}{{else}}empty{{/list}}', { 'items': [], }, ) == 'empty' ) def test_standalone_inverted_section(): """Test {{^}} used inside blocks.""" template = '{{#each items}}{{this}}{{^}}nothing{{/each}}' assert render(template, {'items': [1, 2, 3]}) == '123' assert render(template, {'items': []}) == 'nothing' def test_block_parent_scope(): template = '{{#with person}}{{name}} from {{../city}}{{/with}}' data = {'person': {'name': 'Alice'}, 'city': 'NYC'} assert render(template, data) == 'Alice from NYC' def test_deeply_nested_parent_scope(): template = '{{#each a}}{{#each b}}{{../../name}}{{/each}}{{/each}}' data = {'a': [{'b': [1]}], 'name': 'root'} assert render(template, data) == 'root' pydantic-handlebars-0.2.1/tests/test_builtins.py000066400000000000000000000161071520472041100220330ustar00rootroot00000000000000"""Tests for built-in block helpers. Ported from handlebars.js spec/builtins.js. """ from pydantic_handlebars import render # --- #if --- class TestIf: pass def test_if_truthy_string(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': True, 'world': 'world'}) == 'GOODBYE cruel world!' ) def test_if_falsy(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': False, 'world': 'world'}) == 'cruel world!' ) def test_if_with_context_argument(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': 'bye', 'world': 'world'}) == 'GOODBYE cruel world!' ) def test_if_with_empty_string(): assert render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': '', 'world': 'world'}) == 'cruel world!' def test_if_with_zero(): assert render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': 0, 'world': 'world'}) == 'cruel world!' def test_if_with_none(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': None, 'world': 'world'}) == 'cruel world!' ) def test_if_with_empty_list(): assert render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': [], 'world': 'world'}) == 'cruel world!' def test_if_with_nonempty_list(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': ['foo'], 'world': 'world'}) == 'GOODBYE cruel world!' ) def test_if_else(): assert ( render('{{#if goodbye}}GOODBYE{{else}}Hello{{/if}} cruel {{world}}!', {'goodbye': False, 'world': 'world'}) == 'Hello cruel world!' ) def test_if_else_truthy(): assert ( render('{{#if goodbye}}GOODBYE{{else}}Hello{{/if}} cruel {{world}}!', {'goodbye': True, 'world': 'world'}) == 'GOODBYE cruel world!' ) def test_if_with_boolean_true(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': True, 'world': 'world'}) == 'GOODBYE cruel world!' ) def test_if_with_boolean_false(): assert ( render('{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!', {'goodbye': False, 'world': 'world'}) == 'cruel world!' ) # --- #unless --- def test_unless_truthy(): assert ( render('{{#unless goodbye}}HELLO {{/unless}}cruel {{world}}!', {'goodbye': True, 'world': 'world'}) == 'cruel world!' ) def test_unless_falsy(): assert ( render('{{#unless goodbye}}HELLO {{/unless}}cruel {{world}}!', {'goodbye': False, 'world': 'world'}) == 'HELLO cruel world!' ) def test_unless_with_else(): assert render('{{#unless goodbye}}HELLO{{else}}GOODBYE{{/unless}}!', {'goodbye': True}) == 'GOODBYE!' def test_unless_with_else_falsy(): assert render('{{#unless goodbye}}HELLO{{else}}GOODBYE{{/unless}}!', {'goodbye': False}) == 'HELLO!' # --- #each --- def test_each_array(): assert ( render( '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!', { 'goodbyes': [{'text': 'goodbye'}, {'text': 'Goodbye'}, {'text': 'GOODBYE'}], 'world': 'world', }, ) == 'goodbye! Goodbye! GOODBYE! cruel world!' ) def test_each_with_index(): assert render('{{#each items}}{{@index}}:{{this}} {{/each}}', {'items': ['a', 'b', 'c']}) == '0:a 1:b 2:c ' def test_each_with_first_last(): assert ( render( '{{#each items}}{{#if @first}}[{{/if}}{{this}}{{#if @last}}]{{/if}}{{/each}}', { 'items': ['a', 'b', 'c'], }, ) == '[abc]' ) def test_each_object(): assert render('{{#each obj}}{{@key}}:{{this}} {{/each}}', {'obj': {'a': '1', 'b': '2'}}) == 'a:1 b:2 ' def test_each_empty_array(): assert render('{{#each items}}item{{else}}empty{{/each}}', {'items': []}) == 'empty' def test_each_null(): assert render('{{#each items}}item{{else}}empty{{/each}}', {'items': None}) == 'empty' def test_each_nested(): template = '{{#each outer}}{{#each inner}}{{this}}{{/each}}{{/each}}' data = {'outer': [{'inner': [1, 2]}, {'inner': [3, 4]}]} assert render(template, data) == '1234' def test_each_with_block_params(): assert ( render('{{#each items as |item idx|}}{{idx}}:{{item}} {{/each}}', {'items': ['a', 'b', 'c']}) == '0:a 1:b 2:c ' ) def test_each_object_with_block_params(): assert render('{{#each obj as |val key|}}{{key}}={{val}} {{/each}}', {'obj': {'x': '1', 'y': '2'}}) == 'x=1 y=2 ' # --- #with --- def test_with(): assert render('{{#with person}}{{name}}{{/with}}', {'person': {'name': 'Alice'}}) == 'Alice' def test_with_else(): assert render('{{#with person}}{{name}}{{else}}nobody{{/with}}', {'person': None}) == 'nobody' def test_with_else_truthy(): assert render('{{#with person}}{{name}}{{else}}nobody{{/with}}', {'person': {'name': 'Alice'}}) == 'Alice' def test_with_block_params(): assert render('{{#with person as |p|}}{{p.name}}{{/with}}', {'person': {'name': 'Alice'}}) == 'Alice' def test_with_nested_path(): assert render('{{#with person}}{{address.city}}{{/with}}', {'person': {'address': {'city': 'NYC'}}}) == 'NYC' # --- lookup --- def test_lookup_basic(): assert render('{{lookup obj "name"}}', {'obj': {'name': 'Alice'}}) == 'Alice' def test_lookup_dynamic(): assert render('{{lookup obj key}}', {'obj': {'name': 'Alice'}, 'key': 'name'}) == 'Alice' def test_lookup_array(): assert render('{{lookup arr 1}}', {'arr': ['a', 'b', 'c']}) == 'b' def test_lookup_missing(): assert render('{{lookup obj "missing"}}', {'obj': {'name': 'Alice'}}) == '' def test_lookup_null_object(): assert render('{{lookup obj "name"}}', {'obj': None}) == '' # --- Chained else if --- def test_chained_else_if(): template = '{{#if a}}A{{else if b}}B{{else}}C{{/if}}' assert render(template, {'a': True, 'b': True}) == 'A' assert render(template, {'a': False, 'b': True}) == 'B' assert render(template, {'a': False, 'b': False}) == 'C' def test_chained_else_if_multiple(): template = '{{#if a}}A{{else if b}}B{{else if c}}C{{else}}D{{/if}}' assert render(template, {'a': False, 'b': False, 'c': True}) == 'C' assert render(template, {'a': False, 'b': False, 'c': False}) == 'D' # --- Nested blocks --- def test_nested_if(): template = '{{#if outer}}{{#if inner}}both{{/if}}{{/if}}' assert render(template, {'outer': True, 'inner': True}) == 'both' assert render(template, {'outer': True, 'inner': False}) == '' assert render(template, {'outer': False, 'inner': True}) == '' def test_if_inside_each(): template = '{{#each items}}{{#if active}}{{name}} {{/if}}{{/each}}' data = {'items': [{'name': 'a', 'active': True}, {'name': 'b', 'active': False}, {'name': 'c', 'active': True}]} assert render(template, data) == 'a c ' def test_each_inside_if(): template = '{{#if show}}{{#each items}}{{this}} {{/each}}{{/if}}' assert render(template, {'show': True, 'items': [1, 2, 3]}) == '1 2 3 ' assert render(template, {'show': False, 'items': [1, 2, 3]}) == '' pydantic-handlebars-0.2.1/tests/test_compiler_coverage.py000066400000000000000000000762341520472041100236760ustar00rootroot00000000000000"""Tests targeting uncovered lines in _compiler.py. Covers: lines 65, 69->exit, 80, 109, 127-129, 148, 157, 191, 248, 263->267, 268, 274-276, 303-311, 330-352, 356-376, 394->393, 414-416, 461, 471-475, 518, 520, 527, 542-545, 550-551. """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportPrivateUsage=false, reportUnknownMemberType=false, reportUnknownParameterType=false, reportMissingParameterType=false, reportOptionalMemberAccess=false, reportAttributeAccessIssue=false, reportOperatorIssue=false, reportArgumentType=false from __future__ import annotations from typing import Any import pytest from pydantic_handlebars import ( HandlebarsEnvironment, HandlebarsRuntimeError, HelperOptions, render, ) from pydantic_handlebars._compiler import _MISSING, Compiler, Scope, _get_property, _noop_block from pydantic_handlebars._parser import parse # --------------------------------------------------------------------------- # 1. _noop_block function (lines 74-80) and HelperOptions.name (line 65) # --------------------------------------------------------------------------- class TestNoopBlock: """Verify _noop_block returns '' regardless of arguments.""" def test_noop_block_no_args(self) -> None: assert _noop_block() == '' def test_noop_block_with_context(self) -> None: assert _noop_block({'key': 'value'}) == '' def test_noop_block_with_data_and_block_params(self) -> None: assert _noop_block('ctx', data={'index': 0}, block_params=['a', 'b']) == '' class TestHelperOptionsDefaults: """HelperOptions with no fn/inverse should use _noop_block.""" def test_default_fn_returns_empty(self) -> None: opts = HelperOptions() assert opts.fn('anything') == '' def test_default_inverse_returns_empty(self) -> None: opts = HelperOptions() assert opts.inverse('anything') == '' def test_name_property_from_data(self) -> None: """Line 65: options.data.get('_name', '').""" opts = HelperOptions(data={'_name': 'myHelper'}) assert opts.name == 'myHelper' def test_name_property_default(self) -> None: opts = HelperOptions() assert opts.name == '' def test_name_set_for_mustache_helper(self) -> None: """options.name is set for inline/mustache helper calls (not just block helpers).""" env = HandlebarsEnvironment() captured_name: list[str] = [] def capture_name(*args: Any, options: HelperOptions) -> str: captured_name.append(options.name) return 'ok' env.register_helper('my_inline', capture_name) env.render('{{my_inline}}', {}) assert captured_name == ['my_inline'] def test_name_set_for_block_helper(self) -> None: """options.name is set for block helper calls.""" env = HandlebarsEnvironment() captured_name: list[str] = [] def capture_name(context: Any, *args: Any, options: HelperOptions) -> str: captured_name.append(options.name) return options.fn(context) env.register_helper('my_block', capture_name) env.render('{{#my_block}}body{{/my_block}}', {}) assert captured_name == ['my_block'] # --------------------------------------------------------------------------- # 2. Scope methods # --------------------------------------------------------------------------- class TestScopeLookupData: """Scope.lookup_data traversal (line 109, 127-129).""" def test_lookup_data_falls_through_to_parent(self) -> None: """Lines 127-129: parent traversal for lookup_data.""" parent = Scope({'root': True}, data={'custom': 42}) child = Scope({'child': True}, parent=parent) # 'custom' is NOT in child.data, so it traverses to parent assert child.lookup_data('custom') == 42 def test_lookup_data_returns_none_when_missing(self) -> None: scope = Scope({}) assert scope.lookup_data('nonexistent') is None def test_lookup_data_strict_falls_through_to_parent(self) -> None: """Strict variant recurses through parent scopes for missing-key detection.""" parent = Scope({'root': True}, data={'custom': 42}) child = Scope({'child': True}, parent=parent) value, found = child.lookup_data_strict('custom') assert found is True assert value == 42 def test_lookup_data_strict_returns_not_found_when_missing(self) -> None: scope = Scope({}) value, found = scope.lookup_data_strict('nonexistent') assert found is False assert value is None def test_root_inherited_from_parent(self) -> None: """Line 109: root copied from parent when not already in data.""" parent = Scope({'root_ctx': True}) child = Scope({'child_ctx': True}, parent=parent, data={}) # root should be inherited from parent assert child.data['root'] == {'root_ctx': True} class TestScopeLookupBlockParam: """Scope.lookup_block_param traversal (lines 127-129, 139-140).""" def test_block_param_found_in_current(self) -> None: scope = Scope({}, block_params={'item': 'hello'}) found, value = scope.lookup_block_param('item') assert found is True assert value == 'hello' def test_block_param_traverses_to_parent(self) -> None: """Lines 139-140: parent traversal for block params.""" parent = Scope({}, block_params={'outer': 'val'}) child = Scope({}, parent=parent, block_params={}) found, value = child.lookup_block_param('outer') assert found is True assert value == 'val' def test_block_param_not_found(self) -> None: scope = Scope({}) found, value = scope.lookup_block_param('missing') assert found is False assert value is None class TestScopeGetParentContext: """Scope.get_parent_context returning None (line 148).""" def test_parent_context_beyond_chain(self) -> None: """Line 148: return None when depth exceeds scope chain.""" scope = Scope({'only_scope': True}) assert scope.get_parent_context(1) is None def test_parent_context_at_depth(self) -> None: root = Scope({'root': True}) child = Scope({'child': True}, parent=root) assert child.get_parent_context(1) == {'root': True} class TestScopeGetParentData: """Scope.get_parent_data returning {} (line 157).""" def test_parent_data_beyond_chain(self) -> None: """Line 157: return {} when depth exceeds scope chain.""" scope = Scope({}) assert scope.get_parent_data(1) == {} def test_parent_data_at_depth(self) -> None: root = Scope({}, data={'level': 'root'}) child = Scope({}, parent=root, data={'level': 'child'}) result = child.get_parent_data(1) assert result.get('level') == 'root' # --------------------------------------------------------------------------- # 3. Max output size check (line 191) # --------------------------------------------------------------------------- class TestMaxOutputSize: """Line 191: output exceeds max size.""" def test_max_output_size_exceeded(self) -> None: program = parse('{{val}}') compiler = Compiler(max_output_size=5) with pytest.raises(HandlebarsRuntimeError, match='Output size exceeds maximum'): compiler.render(program, {'val': 'x' * 10}) def test_max_output_size_incremental_each(self) -> None: """Output size is checked incrementally during iteration, not just post-hoc.""" # Use context-based block iteration ({{#items}}), not {{#each}} which needs helpers program = parse('{{#items}}AAAA{{/items}}') compiler = Compiler(max_output_size=20) # 10 items * 4 chars = 40 bytes, exceeds 20 limit with pytest.raises(HandlebarsRuntimeError, match='Output size exceeds maximum'): compiler.render(program, {'items': list(range(10))}) def test_max_output_size_incremental_allows_within_limit(self) -> None: """Output under the limit should succeed.""" program = parse('{{#items}}A{{/items}}') compiler = Compiler(max_output_size=20) result = compiler.render(program, {'items': list(range(5))}) assert result == 'AAAAA' def test_max_output_size_no_double_counting_nested_blocks(self) -> None: """Output size should not be double-counted for nested block rendering. Regression test: _render_program previously incremented _output_size in both inner and outer calls, effectively counting the same bytes twice. This caused the limit to trigger at roughly half the configured value. """ # 10 items * 2 chars each = 20 bytes of actual output. # With a 25-byte limit, this should succeed. With double-counting # it would track ~40 bytes and fail. program = parse('{{#items}}AB{{/items}}') compiler = Compiler(max_output_size=25) result = compiler.render(program, {'items': list(range(10))}) assert result == 'AB' * 10 assert len(result) == 20 def test_max_output_size_no_double_counting_with_surrounding_content(self) -> None: """Nested blocks with surrounding content should track size accurately.""" # "Hello " (6) + 5 items * "X" (5) + " World" (6) = 17 bytes total. # With a 20-byte limit this should succeed. program = parse('Hello {{#items}}X{{/items}} World') compiler = Compiler(max_output_size=20) result = compiler.render(program, {'items': list(range(5))}) assert result == 'Hello XXXXX World' assert len(result) == 17 class TestDepthRecovery: """Depth counter must be properly decremented after max-depth error.""" def test_compiler_reuse_after_depth_error(self) -> None: """Compiler should work correctly after a max-depth error.""" compiler = Compiler(max_depth=2) # Context-based blocks nest: top program -> #a block -> #b block -> #c block deep = parse('{{#a}}{{#b}}{{#c}}{{this}}{{/c}}{{/b}}{{/a}}') with pytest.raises(HandlebarsRuntimeError, match='Maximum nesting depth'): compiler.render(deep, {'a': {'b': {'c': 'x'}}}) # After the error, the compiler should still work for simple templates simple = parse('{{val}}') assert compiler.render(simple, {'val': 'ok'}) == 'ok' def test_depth_reset_across_renders(self) -> None: """Multiple max-depth errors should not accumulate.""" compiler = Compiler(max_depth=2) deep = parse('{{#a}}{{#b}}{{#c}}{{this}}{{/c}}{{/b}}{{/a}}') for _ in range(5): with pytest.raises(HandlebarsRuntimeError, match='Maximum nesting depth'): compiler.render(deep, {'a': {'b': {'c': 'x'}}}) # Should still work after repeated failures simple = parse('{{val}}') assert compiler.render(simple, {'val': 'still works'}) == 'still works' # --------------------------------------------------------------------------- # 5. _call_helper_mustache - fallback signatures (lines 303-311) # --------------------------------------------------------------------------- class TestCallHelperMustache: """Tests for _call_helper_mustache with proper HelperFunc signatures.""" def test_helper_with_options(self) -> None: """Helper receives options kwarg.""" env = HandlebarsEnvironment() env.register_helper('simple', lambda *args, options: f'got:{args[0]}') result = env.render('{{simple name}}', {'name': 'Alice'}) assert result == 'got:Alice' def test_helper_no_args_with_context(self) -> None: """Helper with no template args receives context as first arg.""" env = HandlebarsEnvironment() env.register_helper('constant', lambda *args, options: 'CONSTANT') result = env.render('{{constant}}', {}) assert result == 'CONSTANT' def test_helper_context_passed_when_no_args(self) -> None: """When no template args, scope.context is passed as the arg.""" env = HandlebarsEnvironment() env.register_helper('ctx_helper', lambda *args, options: f'ctx:{args[0].get("x", "")}') result = env.render('{{ctx_helper}}', {'x': 'hello'}) assert result == 'ctx:hello' def test_helper_with_hash_args(self) -> None: """Helper receives hash args via options.hash.""" env = HandlebarsEnvironment() def strict_helper(*args: object, options: HelperOptions) -> str: prefix = options.hash.get('prefix', '') return f'{prefix}{args[0]}' env.register_helper('strict', strict_helper) result = env.render('{{strict name prefix="PRE-"}}', {'name': 'test'}) assert result == 'PRE-test' # --------------------------------------------------------------------------- # 6. _render_block without helper - context-based (lines 330-352) # --------------------------------------------------------------------------- class TestRenderBlockWithoutHelper: """Lines 330-352: block on a path that is not a registered helper.""" def test_block_on_list_value(self) -> None: """Lines 338-340: block iterates when value is a list.""" result = render('{{#items}}{{this}} {{/items}}', {'items': ['a', 'b', 'c']}) assert result == 'a b c ' def test_block_on_dict_value(self) -> None: """Lines 342-345: block changes context when value is a dict.""" result = render('{{#person}}{{name}}{{/person}}', {'person': {'name': 'Alice'}}) assert result == 'Alice' def test_block_on_true_value(self) -> None: """Lines 347-348: block renders body with same scope for True.""" result = render('{{#active}}yes{{/active}}', {'active': True}) assert result == 'yes' def test_block_on_object_value(self) -> None: """Lines 350-352: block uses arbitrary value as context.""" result = render('{{#obj}}{{name}}{{/obj}}', {'obj': {'name': 'Bob'}}) assert result == 'Bob' def test_block_on_falsy_with_inverse(self) -> None: """Lines 332-335: falsy value triggers inverse.""" result = render('{{#missing}}yes{{else}}no{{/missing}}', {'missing': None}) assert result == 'no' def test_block_on_falsy_without_inverse(self) -> None: """Line 336: falsy value with no inverse returns ''.""" result = render('{{#missing}}yes{{/missing}}', {'missing': None}) assert result == '' # --------------------------------------------------------------------------- # 7. _render_each_inline with empty items and block params (lines 356-376) # --------------------------------------------------------------------------- class TestRenderEachInline: """Lines 356-376: inline iteration for non-helper blocks on lists.""" def test_inline_each_empty_with_inverse(self) -> None: """Lines 356-358: empty list triggers inverse. Note: Through normal rendering, [] is falsy and caught before _render_each_inline. We test this by calling the method directly. """ from pydantic_handlebars._ast_nodes import ( BlockStatement, ContentStatement, PathExpression, Program, StripFlags, ) compiler = Compiler() scope = Scope({}) stmt = BlockStatement( path=PathExpression(parts=['items'], original='items'), params=[], hash_pairs={}, body=Program(body=[ContentStatement(value='item', original='item')]), inverse=Program(body=[ContentStatement(value='empty', original='empty')]), open_strip=StripFlags(), close_strip=StripFlags(), ) result = compiler._render_each_inline([], stmt, scope) assert result == 'empty' def test_inline_each_empty_without_inverse(self) -> None: """Line 359: empty list with no inverse returns ''. Tested via direct method call (same reason as above). """ from pydantic_handlebars._ast_nodes import ( BlockStatement, ContentStatement, PathExpression, Program, StripFlags, ) compiler = Compiler() scope = Scope({}) stmt = BlockStatement( path=PathExpression(parts=['items'], original='items'), params=[], hash_pairs={}, body=Program(body=[ContentStatement(value='item', original='item')]), inverse=None, open_strip=StripFlags(), close_strip=StripFlags(), ) result = compiler._render_each_inline([], stmt, scope) assert result == '' def test_inline_each_with_block_params(self) -> None: """Lines 369-373: block params on inline each.""" result = render( '{{#items as |item idx|}}{{idx}}:{{item}} {{/items}}', {'items': ['x', 'y', 'z']}, ) assert result == '0:x 1:y 2:z ' def test_inline_each_with_single_block_param(self) -> None: """Line 371: only one block param provided.""" result = render( '{{#items as |item|}}{{item}} {{/items}}', {'items': ['a', 'b']}, ) assert result == 'a b ' def test_inline_each_data_variables(self) -> None: """Lines 362-367: @index, @first, @last set correctly.""" result = render( '{{#items}}{{@index}}{{#if @first}}F{{/if}}{{#if @last}}L{{/if}} {{/items}}', {'items': ['a', 'b', 'c']}, ) assert result == '0F 1 2L ' # --------------------------------------------------------------------------- # 8. _call_block_helper - inverse with block_params (lines 414-416) # --------------------------------------------------------------------------- class TestCallBlockHelperInverseBlockParams: """Lines 414-416: inverse fn called with block_params.""" def test_inverse_with_block_params(self) -> None: env = HandlebarsEnvironment() def my_block(context: Any, *args: Any, options: HelperOptions) -> str: # Call inverse with block_params to exercise lines 414-416 return options.inverse(context, block_params=['bp_value', 'bp_index']) env.register_helper('my_block', my_block) result = env.render( '{{#my_block as |val idx|}}main{{else}}inverse:{{val}}-{{idx}}{{/my_block}}', {}, ) assert result == 'inverse:bp_value-bp_index' def test_inverse_with_multiple_block_params_iteration(self) -> None: """Lines 413-416: loop iterates over multiple block params.""" env = HandlebarsEnvironment() def my_block(context: Any, *args: Any, options: HelperOptions) -> str: return options.inverse(context, block_params=['first', 'second', 'third']) env.register_helper('my_block', my_block) # Only 2 block params declared, so only first 2 of 3 provided values are used result = env.render( '{{#my_block as |a b|}}main{{else}}{{a}}-{{b}}{{/my_block}}', {}, ) assert result == 'first-second' def test_inverse_with_fewer_block_params_than_declared(self) -> None: """Line 415->414: block_params shorter than stmt.block_params. When the helper provides fewer block_params values than declared names, the loop continues past the values (j >= len(block_params)). """ env = HandlebarsEnvironment() def my_block(context: Any, *args: Any, options: HelperOptions) -> str: return options.inverse(context, block_params=['only_one']) env.register_helper('my_block', my_block) result = env.render( '{{#my_block as |a b|}}main{{else}}{{a}}-{{b}}{{/my_block}}', {}, ) assert result == 'only_one-' # --------------------------------------------------------------------------- # 9. _eval_subexpression errors and fallbacks (lines 461, 471-475) # --------------------------------------------------------------------------- class TestEvalSubexpression: """Lines 461, 471-475: subexpression error and fallback paths.""" def test_unknown_helper_in_subexpression(self) -> None: """Line 461: raise on unknown helper.""" env = HandlebarsEnvironment() env.register_helper('outer', lambda *args, **kw: str(args[0])) with pytest.raises(HandlebarsRuntimeError, match='Unknown helper'): env.render('{{outer (nonexistent "arg")}}', {}) def test_subexpression_with_options(self) -> None: """Subexpression helper receives options kwarg.""" env = HandlebarsEnvironment() env.register_helper('add', lambda *args, options: args[0] + args[1]) env.register_helper('show', lambda *args, options: str(args[0])) result = env.render('{{show (add 1 2)}}', {}) assert result == '3' def test_subexpression_with_hash_args(self) -> None: """Subexpression helper receives hash args via options.hash.""" env = HandlebarsEnvironment() def join_args(*args: object, options: HelperOptions) -> str: return '-'.join(str(a) for a in args) env.register_helper('join_args', join_args) env.register_helper('show', lambda *args, options: str(args[0])) result = env.render('{{show (join_args "a" "b" extra="ignored")}}', {}) assert result == 'a-b' # --------------------------------------------------------------------------- # 10. _resolve_data_path - depth > 0 branches (lines 518, 520, 527) # --------------------------------------------------------------------------- class TestResolveDataPath: """Lines 513-527: @data path resolution with depth and edge cases.""" def test_data_path_with_parent_depth(self) -> None: """Lines 513-518: @../index resolves via parent data.""" template = '{{#each items}}{{#each sub}}{{@../index}}:{{this}} {{/each}}{{/each}}' data = {'items': [{'sub': ['a', 'b']}, {'sub': ['c']}]} result = render(template, data) assert result == '0:a 0:b 1:c ' def test_data_path_parent_no_parts(self) -> None: """Line 520: @data path with depth but no parts returns None.""" # This is an edge case: @../ with no trailing name # We need to construct this manually since the parser may not produce it easily. # Use a template that accesses parent data through nested each blocks. env = HandlebarsEnvironment() def check_data(context: Any, *args: Any, options: HelperOptions) -> str: # Manually verify data path resolution return str(options.data.get('index', 'none')) env.register_helper('check_data', check_data) result = env.render('{{#each items}}{{check_data}}{{/each}}', {'items': [1, 2]}) assert result == '01' def test_data_path_no_parts_at_root(self) -> None: """Line 527: @data path with no parts returns None.""" # Accessing just '@' by itself should return None / empty # This edge case is hard to trigger through the parser; test via Compiler directly from pydantic_handlebars._ast_nodes import PathExpression path = PathExpression(parts=[], original='@', data=True, depth=0) scope = Scope({'some': 'data'}, data={'index': 5}) compiler = Compiler() result = compiler._resolve_data_path(path, scope) assert result is None def test_data_path_parent_depth_with_nested_parts(self) -> None: """Line 518: _get_property on nested data parts.""" from pydantic_handlebars._ast_nodes import PathExpression scope_parent = Scope({}, data={'info': {'nested': 'value'}}) scope_child = Scope({}, parent=scope_parent, data={}) compiler = Compiler() path = PathExpression(parts=['info', 'nested'], original='@../info.nested', data=True, depth=1) result = compiler._resolve_data_path(path, scope_child) assert result == 'value' def test_data_path_parent_no_parts_returns_none(self) -> None: """Line 520: depth > 0 with no parts returns None.""" from pydantic_handlebars._ast_nodes import PathExpression scope_parent = Scope({}, data={'index': 0}) scope_child = Scope({}, parent=scope_parent, data={}) compiler = Compiler() path = PathExpression(parts=[], original='@../', data=True, depth=1) result = compiler._resolve_data_path(path, scope_child) assert result is None # --------------------------------------------------------------------------- # 11. _get_property - list/tuple access, attribute access (lines 542-545, 550-551) # --------------------------------------------------------------------------- class TestGetProperty: """Lines 541-551: property access on lists, tuples, and objects.""" def test_list_index_access(self) -> None: """Lines 542-543: list[int(name)].""" assert _get_property(['a', 'b', 'c'], '1') == 'b' def test_list_invalid_index(self) -> None: """Line 544: ValueError when name is not an int.""" assert _get_property(['a', 'b'], 'not_a_number') is _MISSING def test_list_index_out_of_range(self) -> None: """Line 544: IndexError when index is out of range.""" assert _get_property(['a', 'b'], '99') is _MISSING def test_tuple_index_access(self) -> None: """Line 541: tuple is also handled.""" assert _get_property(('x', 'y', 'z'), '2') == 'z' def test_tuple_invalid_index(self) -> None: assert _get_property(('x', 'y'), 'bad') is _MISSING def test_non_dict_non_list_returns_none(self) -> None: """Non-dict/list objects return the missing sentinel (no getattr fallback).""" class Obj: foo = 'bar' assert _get_property(Obj(), 'foo') is _MISSING def test_dict_access(self) -> None: assert _get_property({'key': 'val'}, 'key') == 'val' def test_dict_missing_key(self) -> None: assert _get_property({'key': 'val'}, 'other') is _MISSING def test_dunder_key_accessible(self) -> None: assert _get_property({'__class__': 'ok'}, '__class__') == 'ok' # --------------------------------------------------------------------------- # 12. RawBlock rendering (line 248) - _render_statement returns stmt.body # --------------------------------------------------------------------------- class TestRawBlock: """Line 248: RawBlock rendering returns body as-is.""" def test_raw_block_renders_body(self) -> None: result = render('{{{{raw}}}}{{not-processed}}{{{{/raw}}}}', {}) assert result == '{{not-processed}}' # --------------------------------------------------------------------------- # 13. SubExpression as mustache path (lines 267-268) # --------------------------------------------------------------------------- class TestSubExpressionAsMustachePath: """Lines 267-268: when a mustache's path is a SubExpression.""" def test_subexpression_as_value(self) -> None: env = HandlebarsEnvironment() env.register_helper('greet', lambda name, **kw: f'Hello {name}') result = env.render('{{(greet "World")}}', {}) assert result == 'Hello World' # --------------------------------------------------------------------------- # 14. _call_helper_mustache path with multi-part path (line 263) # --------------------------------------------------------------------------- class TestHelperMustacheMultiPartPath: """Line 263: helper not called when path has multiple parts and no params/hash.""" def test_multi_part_helper_path_no_params(self) -> None: """When a helper name has dots and no params, it resolves as path.""" env = HandlebarsEnvironment() env.register_helper('foo.bar', lambda *args, **kw: 'HELPER') # With a multi-part path and no params, it should resolve as a path result = env.render('{{foo.bar}}', {'foo': {'bar': 'CONTEXT'}}) assert result == 'CONTEXT' # --------------------------------------------------------------------------- # 15. Block helper fn with block_params (lines 392-395) # --------------------------------------------------------------------------- class TestRenderBlockNonPathExpression: """Line 322->330: block with a non-PathExpression path.""" def test_block_with_subexpression_path(self) -> None: """When block stmt.path is NOT a PathExpression, skip helper lookup.""" from pydantic_handlebars._ast_nodes import ( BlockStatement, ContentStatement, PathExpression, Program, StripFlags, SubExpression, ) # Create a block whose path is a SubExpression sub_path = PathExpression(parts=['identity'], original='identity') sub_expr = SubExpression(path=sub_path, params=[], hash_pairs={}) stmt = BlockStatement( path=sub_expr, params=[], hash_pairs={}, body=Program(body=[ContentStatement(value='body', original='body')]), inverse=None, open_strip=StripFlags(), close_strip=StripFlags(), ) # Register 'identity' helper that returns its context def identity_helper(*args: Any, options: HelperOptions | None = None) -> Any: return True compiler = Compiler(helpers={'identity': identity_helper}) scope = Scope({}) # The SubExpression evaluates to True, so the body renders result = compiler._render_block(stmt, scope) assert result == 'body' class TestBlockHelperFnBlockParams: """Lines 392-395: fn() called with block_params.""" def test_fn_with_block_params(self) -> None: env = HandlebarsEnvironment() def my_helper(context: Any, *args: Any, options: HelperOptions) -> str: return options.fn(context, block_params=['custom_val']) env.register_helper('my_helper', my_helper) result = env.render( '{{#my_helper as |val|}}got:{{val}}{{/my_helper}}', {}, ) assert result == 'got:custom_val' def test_fn_with_multiple_block_params(self) -> None: """Lines 393-395: fn iterates block_params with multiple entries.""" env = HandlebarsEnvironment() def my_helper(context: Any, *args: Any, options: HelperOptions) -> str: return options.fn(context, block_params=['val_a', 'val_b']) env.register_helper('my_helper', my_helper) result = env.render( '{{#my_helper as |a b|}}{{a}}-{{b}}{{/my_helper}}', {}, ) assert result == 'val_a-val_b' def test_fn_with_fewer_block_params_than_declared(self) -> None: """Line 394->393: block_params shorter than stmt.block_params. When the helper provides fewer block_params values than declared names, the loop iterates past the end of block_params (j >= len(block_params)), so the if-check on line 394 is False and the loop continues. """ env = HandlebarsEnvironment() def my_helper(context: Any, *args: Any, options: HelperOptions) -> str: # Only provide 1 block param value, but template declares 2 return options.fn(context, block_params=['only_one']) env.register_helper('my_helper', my_helper) result = env.render( '{{#my_helper as |a b|}}{{a}}-{{b}}{{/my_helper}}', {}, ) # 'a' gets 'only_one', 'b' gets nothing (empty) assert result == 'only_one-' pydantic-handlebars-0.2.1/tests/test_custom_helpers.py000066400000000000000000000146461520472041100232440ustar00rootroot00000000000000"""Tests for custom built-in helpers. Tests for json, uppercase, lowercase, trim, join, truncate, eq, ne, gt, gte, lt, lte, and, or, not, default. These are extra helpers that require ``extra_helpers=True``. """ import pytest from pydantic_handlebars import HandlebarsEnvironment @pytest.fixture def env() -> HandlebarsEnvironment: return HandlebarsEnvironment(extra_helpers=True) # --- json --- def test_json_object(env: HandlebarsEnvironment): result = env.render('{{{json obj}}}', {'obj': {'a': 1}}) assert result == '{"a": 1}' def test_json_array(env: HandlebarsEnvironment): result = env.render('{{json arr}}', {'arr': [1, 2, 3]}) assert result == '[1, 2, 3]' def test_json_string(env: HandlebarsEnvironment): result = env.render('{{json val}}', {'val': 'hello'}) assert result == '"hello"' def test_json_string_unescaped(env: HandlebarsEnvironment): result = env.render('{{{json val}}}', {'val': 'hello'}) assert result == '"hello"' # --- uppercase --- def test_uppercase(env: HandlebarsEnvironment): assert env.render('{{uppercase val}}', {'val': 'hello'}) == 'HELLO' def test_uppercase_empty(env: HandlebarsEnvironment): assert env.render('{{uppercase val}}', {'val': ''}) == '' # --- lowercase --- def test_lowercase(env: HandlebarsEnvironment): assert env.render('{{lowercase val}}', {'val': 'HELLO'}) == 'hello' # --- trim --- def test_trim(env: HandlebarsEnvironment): assert env.render('{{trim val}}', {'val': ' hello '}) == 'hello' def test_trim_with_newlines(env: HandlebarsEnvironment): assert env.render('{{trim val}}', {'val': '\n hello \n'}) == 'hello' # --- join --- def test_join_basic(env: HandlebarsEnvironment): assert env.render('{{join arr ", "}}', {'arr': ['a', 'b', 'c']}) == 'a, b, c' def test_join_default_separator(env: HandlebarsEnvironment): assert env.render('{{join arr}}', {'arr': ['a', 'b', 'c']}) == 'a,b,c' def test_join_empty(env: HandlebarsEnvironment): assert env.render('{{join arr ", "}}', {'arr': []}) == '' # --- truncate --- def test_truncate(env: HandlebarsEnvironment): assert env.render('{{truncate val 5}}', {'val': 'hello world'}) == 'hello' def test_truncate_short_string(env: HandlebarsEnvironment): assert env.render('{{truncate val 100}}', {'val': 'hi'}) == 'hi' def test_truncate_exact_length(env: HandlebarsEnvironment): assert env.render('{{truncate val 5}}', {'val': 'hello'}) == 'hello' # --- eq --- def test_eq_true(env: HandlebarsEnvironment): assert env.render('{{#if (eq a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 1}) == 'yes' def test_eq_false(env: HandlebarsEnvironment): assert env.render('{{#if (eq a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 2}) == 'no' def test_eq_strings(env: HandlebarsEnvironment): assert env.render('{{#if (eq a b)}}yes{{else}}no{{/if}}', {'a': 'x', 'b': 'x'}) == 'yes' # --- ne --- def test_ne_true(env: HandlebarsEnvironment): assert env.render('{{#if (ne a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 2}) == 'yes' def test_ne_false(env: HandlebarsEnvironment): assert env.render('{{#if (ne a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 1}) == 'no' # --- gt --- def test_gt_true(env: HandlebarsEnvironment): assert env.render('{{#if (gt a b)}}yes{{else}}no{{/if}}', {'a': 2, 'b': 1}) == 'yes' def test_gt_false(env: HandlebarsEnvironment): assert env.render('{{#if (gt a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 2}) == 'no' def test_gt_equal(env: HandlebarsEnvironment): assert env.render('{{#if (gt a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 1}) == 'no' # --- gte --- def test_gte_true(env: HandlebarsEnvironment): assert env.render('{{#if (gte a b)}}yes{{else}}no{{/if}}', {'a': 2, 'b': 1}) == 'yes' def test_gte_equal(env: HandlebarsEnvironment): assert env.render('{{#if (gte a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 1}) == 'yes' def test_gte_false(env: HandlebarsEnvironment): assert env.render('{{#if (gte a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 2}) == 'no' # --- lt --- def test_lt_true(env: HandlebarsEnvironment): assert env.render('{{#if (lt a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 2}) == 'yes' def test_lt_false(env: HandlebarsEnvironment): assert env.render('{{#if (lt a b)}}yes{{else}}no{{/if}}', {'a': 2, 'b': 1}) == 'no' # --- lte --- def test_lte_true(env: HandlebarsEnvironment): assert env.render('{{#if (lte a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 2}) == 'yes' def test_lte_equal(env: HandlebarsEnvironment): assert env.render('{{#if (lte a b)}}yes{{else}}no{{/if}}', {'a': 1, 'b': 1}) == 'yes' def test_lte_false(env: HandlebarsEnvironment): assert env.render('{{#if (lte a b)}}yes{{else}}no{{/if}}', {'a': 2, 'b': 1}) == 'no' # --- and --- def test_and_both_true(env: HandlebarsEnvironment): assert env.render('{{#if (and a b)}}yes{{else}}no{{/if}}', {'a': True, 'b': True}) == 'yes' def test_and_one_false(env: HandlebarsEnvironment): assert env.render('{{#if (and a b)}}yes{{else}}no{{/if}}', {'a': True, 'b': False}) == 'no' def test_and_both_false(env: HandlebarsEnvironment): assert env.render('{{#if (and a b)}}yes{{else}}no{{/if}}', {'a': False, 'b': False}) == 'no' # --- or --- def test_or_both_true(env: HandlebarsEnvironment): assert env.render('{{#if (or a b)}}yes{{else}}no{{/if}}', {'a': True, 'b': True}) == 'yes' def test_or_one_true(env: HandlebarsEnvironment): assert env.render('{{#if (or a b)}}yes{{else}}no{{/if}}', {'a': False, 'b': True}) == 'yes' def test_or_both_false(env: HandlebarsEnvironment): assert env.render('{{#if (or a b)}}yes{{else}}no{{/if}}', {'a': False, 'b': False}) == 'no' # --- not --- def test_not_true(env: HandlebarsEnvironment): assert env.render('{{#if (not a)}}yes{{else}}no{{/if}}', {'a': False}) == 'yes' def test_not_false(env: HandlebarsEnvironment): assert env.render('{{#if (not a)}}yes{{else}}no{{/if}}', {'a': True}) == 'no' # --- default --- def test_default_with_value(env: HandlebarsEnvironment): assert env.render('{{default val "fallback"}}', {'val': 'hello'}) == 'hello' def test_default_with_falsy(env: HandlebarsEnvironment): assert env.render('{{default val "fallback"}}', {'val': ''}) == 'fallback' def test_default_with_none(env: HandlebarsEnvironment): assert env.render('{{default val "fallback"}}', {'val': None}) == 'fallback' def test_default_with_zero(env: HandlebarsEnvironment): assert env.render('{{default val "fallback"}}', {'val': 0}) == 'fallback' pydantic-handlebars-0.2.1/tests/test_data.py000066400000000000000000000030771520472041100211150ustar00rootroot00000000000000"""Tests for @data variables. Ported from handlebars.js spec/data.js. """ from pydantic_handlebars import render # --- @root --- def test_at_root(): assert render('{{@root.name}}', {'name': 'Alice'}) == 'Alice' def test_at_root_in_nested_context(): template = '{{#each items}}{{{@root.prefix}}}{{this}} {{/each}}' assert render(template, {'items': ['a', 'b'], 'prefix': '>'}) == '>a >b ' # --- @index --- def test_at_index_in_each(): assert render('{{#each items}}{{@index}} {{/each}}', {'items': ['a', 'b', 'c']}) == '0 1 2 ' # --- @key --- def test_at_key_in_each_object(): assert render('{{#each obj}}{{@key}}:{{this}} {{/each}}', {'obj': {'a': '1', 'b': '2'}}) == 'a:1 b:2 ' # --- @first and @last --- def test_at_first(): template = '{{#each items}}{{#if @first}}FIRST{{else}}other{{/if}} {{/each}}' assert render(template, {'items': [1, 2, 3]}) == 'FIRST other other ' def test_at_last(): template = '{{#each items}}{{#if @last}}LAST{{else}}other{{/if}} {{/each}}' assert render(template, {'items': [1, 2, 3]}) == 'other other LAST ' # --- Nested @data --- def test_nested_each_data(): template = '{{#each outer}}{{#each inner}}{{@../index}}.{{@index}} {{/each}}{{/each}}' data = {'outer': [{'inner': ['a', 'b']}, {'inner': ['c', 'd']}]} assert render(template, data) == '0.0 0.1 1.0 1.1 ' # --- @data with parent --- def test_at_root_in_deeply_nested(): template = '{{#each a}}{{#each b}}{{@root.val}}{{/each}}{{/each}}' data = {'a': [{'b': [1]}], 'val': 'root'} assert render(template, data) == 'root' pydantic-handlebars-0.2.1/tests/test_delimiters.py000066400000000000000000000316501520472041100223430ustar00rootroot00000000000000"""Tests for configurable mustache delimiters. The default `{{` / `}}` pair is exercised by the rest of the suite. These tests pin down the behaviour with custom delimiter pairs — primarily `@{` / `}@`, which is what calling code (e.g. Pydantic Logfire's managed- variable composition) uses to embed a second rendering pass without fighting the regular `{{...}}` runtime placeholders. """ from __future__ import annotations import pytest from pydantic_handlebars import ( HandlebarsEnvironment, HandlebarsParseError, HandlebarsRuntimeError, extract_dependencies, render, ) # --------------------------------------------------------------------------- # Default behaviour preserved # --------------------------------------------------------------------------- def test_module_render_defaults_match_handlebars_spec(): assert render('Hello {{name}}!', {'name': 'World'}) == 'Hello World!' def test_env_defaults_to_braces(): env = HandlebarsEnvironment() assert env.open_delim == '{{' assert env.close_delim == '}}' # --------------------------------------------------------------------------- # Basic rendering with `@{` / `}@` # --------------------------------------------------------------------------- def test_basic_render_with_at_brace_delimiters(): result = render('Hello @{name}@!', {'name': 'World'}, open_delim='@{', close_delim='}@') assert result == 'Hello World!' def test_dotted_path_with_custom_delims(): result = render('@{user.name}@', {'user': {'name': 'A'}}, open_delim='@{', close_delim='}@') assert result == 'A' def test_helper_invocation_with_custom_delims(): result = render('@{lookup obj key}@', {'obj': {'k': 'V'}, 'key': 'k'}, open_delim='@{', close_delim='}@') assert result == 'V' def test_repeated_references_with_custom_delims(): template = '@{a}@ + @{a}@ = @{a}@@{a}@' result = render(template, {'a': 'x'}, open_delim='@{', close_delim='}@') assert result == 'x + x = xx' def test_render_returns_empty_for_missing_reference(): # Same as default behaviour when not in strict mode. result = render('@{missing}@', {}, open_delim='@{', close_delim='}@') assert result == '' # --------------------------------------------------------------------------- # Block helpers under custom delimiters # --------------------------------------------------------------------------- def test_if_block_with_custom_delims(): template = '@{#if beta}@new@{else}@@{tagline}@@{/if}@' assert render(template, {'beta': False, 'tagline': 'classic'}, open_delim='@{', close_delim='}@') == 'classic' assert render(template, {'beta': True, 'tagline': 'classic'}, open_delim='@{', close_delim='}@') == 'new' def test_each_block_with_custom_delims(): template = '@{#each items}@@{name}@;@{/each}@' result = render( template, {'items': [{'name': 'a'}, {'name': 'b'}]}, open_delim='@{', close_delim='}@', ) assert result == 'a;b;' def test_each_with_parent_ref_under_custom_delims(): template = '@{#each items}@@{../top}@:@{name}@;@{/each}@' result = render( template, {'top': 'T', 'items': [{'name': 'a'}, {'name': 'b'}]}, open_delim='@{', close_delim='}@', ) assert result == 'T:a;T:b;' def test_with_block_under_custom_delims(): template = '@{#with profile}@@{name}@@{/with}@' result = render(template, {'profile': {'name': 'Z'}}, open_delim='@{', close_delim='}@') assert result == 'Z' def test_unless_block_under_custom_delims(): template = '@{#unless silent}@@{message}@@{/unless}@' assert render(template, {'silent': False, 'message': 'hi'}, open_delim='@{', close_delim='}@') == 'hi' assert render(template, {'silent': True, 'message': 'hi'}, open_delim='@{', close_delim='}@') == '' # --------------------------------------------------------------------------- # `{{...}}` is plain content when delimiters are `@{...}@` and vice versa # --------------------------------------------------------------------------- def test_default_delims_treated_as_content_when_custom_is_active(): # The whole point of configurable delimiters: a composition pass can # use `@{...}@` while leaving `{{...}}` placeholders untouched for a # later rendering pass. template = 'Hi @{name}@, see {{user}} later' result = render(template, {'name': 'A'}, open_delim='@{', close_delim='}@') assert result == 'Hi A, see {{user}} later' def test_custom_delims_treated_as_content_when_default_is_active(): template = 'literal @{name}@ and rendered {{name}}' result = render(template, {'name': 'X'}) assert result == 'literal @{name}@ and rendered X' # --------------------------------------------------------------------------- # Triple-stache and raw blocks are default-only # --------------------------------------------------------------------------- def test_triple_stache_treated_as_content_with_custom_delims(): # `@{{name}}@` under `@{`/`}@` delimiters tokenises as OPEN `@{` ID `{name}` # CLOSE `}@`. The body `{name}` isn't a valid identifier — so the parser # rejects it. That's the right outcome: triple-stache has no extension # for non-default delimiters. with pytest.raises(HandlebarsParseError): render('@{{name}}@', {'name': 'X'}, open_delim='@{', close_delim='}@') def test_default_delims_still_support_triple_stache(): # `{{{value}}}` renders unescaped under the default delims. env = HandlebarsEnvironment(auto_escape=True) assert env.render('{{value}}', {'value': ''}) == '<x>' assert env.render('{{{value}}}', {'value': ''}) == '' def test_default_delims_still_support_raw_blocks(): # `{{{{raw}}}}...{{{{/raw}}}}` preserves its body verbatim. template = '{{{{raw}}}}{{value}}{{{{/raw}}}}' assert render(template, {'value': 'ignored'}) == '{{value}}' # --------------------------------------------------------------------------- # Per-expression escape opt-out via the `&` variant works under custom delims. # Triple-stache has no analogue, but `@{&name}@` does. # --------------------------------------------------------------------------- def test_amp_unescape_under_custom_delims(): # With auto_escape on, the default form escapes... env = HandlebarsEnvironment(auto_escape=True, open_delim='@{', close_delim='}@') unsafe = {'value': ''} assert env.render('@{value}@', unsafe) == '<script>X</script>' # ...but the ampersand-unescape variant lets a single expression opt out: assert env.render('@{&value}@', unsafe) == '' def test_amp_unescape_under_default_delims_still_works(): # Regression guard: the parser fix used to hard-code `'{{&'`. env = HandlebarsEnvironment(auto_escape=True) assert env.render('{{&value}}', {'value': ''}) == '' # --------------------------------------------------------------------------- # Escape sequence under custom delimiters # --------------------------------------------------------------------------- def test_escape_sequence_with_custom_delims(): # `\@{name}@` should produce literal `@{name}@`. template = '\\@{name}@ then @{name}@' result = render(template, {'name': 'X'}, open_delim='@{', close_delim='}@') assert result == '@{name}@ then X' # --------------------------------------------------------------------------- # Comments under custom delimiters # --------------------------------------------------------------------------- def test_short_comment_with_custom_delims(): template = '@{! a comment }@hello' assert render(template, {}, open_delim='@{', close_delim='}@') == 'hello' def test_long_comment_with_custom_delims(): template = '@{!-- multi line\ncomment --}@hello' assert render(template, {}, open_delim='@{', close_delim='}@') == 'hello' # --------------------------------------------------------------------------- # Whitespace control (`~`) under custom delimiters # --------------------------------------------------------------------------- def test_open_strip_with_custom_delims(): template = 'before @{~name}@after' assert render(template, {'name': 'X'}, open_delim='@{', close_delim='}@') == 'beforeXafter' def test_close_strip_with_custom_delims(): template = 'before@{name~}@ after' assert render(template, {'name': 'X'}, open_delim='@{', close_delim='}@') == 'beforeXafter' # --------------------------------------------------------------------------- # Environment-level configuration # --------------------------------------------------------------------------- def test_env_exposes_custom_delimiters_as_properties(): env = HandlebarsEnvironment(open_delim='@{', close_delim='}@') assert env.open_delim == '@{' assert env.close_delim == '}@' def test_env_render_uses_configured_delimiters(): env = HandlebarsEnvironment(open_delim='@{', close_delim='}@') assert env.render('@{x}@', {'x': 'v'}) == 'v' def test_env_compile_carries_delimiters_into_compiled_template(): env = HandlebarsEnvironment(open_delim='@{', close_delim='}@') template = env.compile('@{x}@') assert template.render({'x': 'v'}) == 'v' # --------------------------------------------------------------------------- # Validation: invalid delimiter pairs # --------------------------------------------------------------------------- def test_empty_open_delim_rejected(): with pytest.raises(ValueError, match='non-empty'): render('hello', {}, open_delim='', close_delim='}}') def test_empty_close_delim_rejected(): with pytest.raises(ValueError, match='non-empty'): render('hello', {}, open_delim='{{', close_delim='') def test_identical_open_and_close_rejected(): with pytest.raises(ValueError, match='must differ'): render('hello', {}, open_delim='::', close_delim='::') def test_whitespace_prefixed_open_rejected(): with pytest.raises(ValueError, match='whitespace'): render('hello', {}, open_delim=' {', close_delim='}}') def test_whitespace_suffixed_close_rejected(): with pytest.raises(ValueError, match='whitespace'): render('hello', {}, open_delim='{{', close_delim='} ') @pytest.mark.parametrize('bad_char', list('"\'()=|~')) def test_open_delim_with_forbidden_chars_rejected(bad_char: str): with pytest.raises(ValueError, match='open_delim must not contain'): render('hello', {}, open_delim=bad_char + '{', close_delim='}}') @pytest.mark.parametrize('bad_char', list('"\'()=|~')) def test_close_delim_with_forbidden_chars_rejected(bad_char: str): with pytest.raises(ValueError, match='close_delim must not contain'): render('hello', {}, open_delim='{{', close_delim='}' + bad_char) # --------------------------------------------------------------------------- # `extract_dependencies` with custom delimiters # --------------------------------------------------------------------------- def test_extract_dependencies_with_custom_delims(): deps = extract_dependencies( '@{#each items}@@{name}@@{/each}@@{outer}@', open_delim='@{', close_delim='}@', ) assert deps == {'items', 'outer'} def test_extract_dependencies_treats_default_delim_content_as_literal(): # Under `@{`/`}@`, anything written as `{{x}}` is plain text and # contributes no dependency. deps = extract_dependencies('@{a}@ and {{b}}', open_delim='@{', close_delim='}@') assert deps == {'a'} def test_extract_dependencies_dotted_path_with_custom_delims(): deps = extract_dependencies('@{user.name}@ <@{user.email}@>', open_delim='@{', close_delim='}@') assert deps == {'user'} # --------------------------------------------------------------------------- # Strict mode + custom delimiters compose # --------------------------------------------------------------------------- def test_strict_with_custom_delims_raises_on_missing(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'name'"): render('@{name}@', {}, strict=True, open_delim='@{', close_delim='}@') def test_strict_with_custom_delims_passes_when_present(): assert render('@{name}@', {'name': 'X'}, strict=True, open_delim='@{', close_delim='}@') == 'X' # --------------------------------------------------------------------------- # Composition use case: two-pass rendering preserving inner placeholders # --------------------------------------------------------------------------- def test_composition_then_render_two_pass_round_trip(): # First pass: compose with `@{...}@` (e.g. logfire's variable composition) # leaves `{{...}}` placeholders untouched. first_pass_template = 'You are helping @{role}@. Greet {{user_name}}.' after_composition = render( first_pass_template, {'role': 'a designer'}, open_delim='@{', close_delim='}@', ) assert after_composition == 'You are helping a designer. Greet {{user_name}}.' # Second pass: render the remaining `{{...}}` with runtime inputs. final = render(after_composition, {'user_name': 'Alice'}) assert final == 'You are helping a designer. Greet Alice.' pydantic-handlebars-0.2.1/tests/test_docs.py000066400000000000000000000043341520472041100211310ustar00rootroot00000000000000"""Test documentation code examples with pytest-examples.""" # pyright: reportUnknownMemberType=false import importlib.util import sys import pytest from pytest_examples import CodeExample, EvalExample, find_examples HAS_PYDANTIC = importlib.util.find_spec('pydantic') is not None # Collect all examples once, and build a title -> source map for cross-referencing _all_examples = list(find_examples('docs/', 'README.md')) _titled_sources: dict[str, str] = {} for _ex in _all_examples: _title = _ex.prefix_settings().get('title') if _title: _titled_sources[_title] = _ex.source def set_eval_config(eval_example: EvalExample) -> None: eval_example.set_config( line_length=120, quotes='single', isort=True, target_version='py310', ) def _write_requires(example: CodeExample, eval_example: EvalExample) -> None: """Write required example files to tmp_path so imports resolve.""" requires = example.prefix_settings().get('requires') if not requires: return for req in requires.split(','): req = req.strip() mod_name = req.removesuffix('.py') (eval_example.tmp_path / req).write_text(_titled_sources[req]) sys.modules.pop(mod_name, None) sys.path.insert(0, str(eval_example.tmp_path)) @pytest.mark.parametrize('example', _all_examples, ids=str) def test_docs_lint(example: CodeExample, eval_example: EvalExample) -> None: if not HAS_PYDANTIC and 'from pydantic import' in example.source: # pragma: no cover pytest.skip('pydantic not installed') set_eval_config(eval_example) if eval_example.update_examples: # pragma: no cover eval_example.format(example) else: eval_example.lint_ruff(example) @pytest.mark.parametrize('example', _all_examples, ids=str) def test_docs_run(example: CodeExample, eval_example: EvalExample) -> None: if not HAS_PYDANTIC and 'from pydantic import' in example.source: # pragma: no cover pytest.skip('pydantic not installed') set_eval_config(eval_example) _write_requires(example, eval_example) if eval_example.update_examples: # pragma: no cover eval_example.run_print_update(example) else: eval_example.run_print_check(example) pydantic-handlebars-0.2.1/tests/test_extract.py000066400000000000000000000245071520472041100216570ustar00rootroot00000000000000"""Tests for `extract_dependencies`: static top-level context references.""" from __future__ import annotations import pytest from pydantic_handlebars import HandlebarsParseError, extract_dependencies # --------------------------------------------------------------------------- # Trivial templates # --------------------------------------------------------------------------- def test_empty_template(): assert extract_dependencies('') == set() def test_pure_text_template(): assert extract_dependencies('Hello, world.') == set() def test_only_comments(): assert extract_dependencies('{{! a comment }} text {{!-- another --}}') == set() # --------------------------------------------------------------------------- # Basic references # --------------------------------------------------------------------------- def test_single_reference(): assert extract_dependencies('Hello {{name}}!') == {'name'} def test_multiple_references(): assert extract_dependencies('{{greeting}}, {{name}}!') == {'greeting', 'name'} def test_repeated_references_collapse_to_one(): assert extract_dependencies('{{name}} {{name}} {{name}}') == {'name'} # --------------------------------------------------------------------------- # Dotted paths only contribute their root segment # --------------------------------------------------------------------------- def test_dotted_path_returns_root(): assert extract_dependencies('{{user.name}} <{{user.email}}>') == {'user'} def test_deeply_dotted_path_returns_root(): assert extract_dependencies('{{a.b.c.d}}') == {'a'} # --------------------------------------------------------------------------- # Block helpers # --------------------------------------------------------------------------- def test_if_block_collects_condition_and_body_refs(): assert extract_dependencies('{{#if cond}}{{value}}{{/if}}') == {'cond', 'value'} def test_if_else_collects_inverse_refs_too(): template = '{{#if beta}}{{new_tagline}}{{else}}{{tagline}}{{/if}}' assert extract_dependencies(template) == {'beta', 'new_tagline', 'tagline'} def test_unless_behaves_like_if(): template = '{{#unless silent}}{{message}}{{/unless}}' assert extract_dependencies(template) == {'silent', 'message'} # --------------------------------------------------------------------------- # `each` and `with` shift context — inner refs are scoped # --------------------------------------------------------------------------- def test_each_inner_ref_is_not_top_level(): template = '{{#each items}}{{name}}{{/each}}' assert extract_dependencies(template) == {'items'} def test_each_with_parent_ref(): template = '{{#each items}}{{../top}}{{/each}}' assert extract_dependencies(template) == {'items', 'top'} def test_each_with_root_ref(): template = '{{#each items}}{{@root.top}}{{/each}}' assert extract_dependencies(template) == {'items', 'top'} def test_with_inner_ref_is_not_top_level(): template = '{{#with profile}}{{name}}{{/with}}' assert extract_dependencies(template) == {'profile'} def test_nested_each_shifts_twice(): template = '{{#each groups}}{{#each items}}{{name}} {{../label}} {{../../top}}{{/each}}{{/each}}' deps = extract_dependencies(template) assert deps == {'groups', 'top'} def test_each_else_branch_renders_in_parent_context(): # `{{else}}` of `each` fires when the iterable is empty, so it # evaluates against the parent context — references there ARE top-level. template = '{{#each items}}x{{else}}{{empty_msg}}{{/each}}' assert extract_dependencies(template) == {'items', 'empty_msg'} # --------------------------------------------------------------------------- # Block parameters shadow context names locally # --------------------------------------------------------------------------- def test_each_block_params_shadow_top_level(): template = '{{#each items as |item|}}{{item.name}}{{/each}}' # `item` is a block param, not a top-level dep. assert extract_dependencies(template) == {'items'} def test_each_block_params_with_index(): template = '{{#each items as |item index|}}{{index}}:{{item.name}}{{/each}}' assert extract_dependencies(template) == {'items'} # --------------------------------------------------------------------------- # Helpers — names are not context refs; arguments are # --------------------------------------------------------------------------- def test_known_helper_name_is_not_a_dep(): # Standard helpers (`if`, `unless`, `each`, `with`, `lookup`, # `log`) are always recognised. assert extract_dependencies('{{lookup obj key}}') == {'obj', 'key'} def test_unknown_block_helper_falls_back_to_context_ref(): # If a name is used as a block helper but isn't registered, the renderer # would attempt a context lookup — match that behaviour. assert extract_dependencies('{{#myhelper x}}body{{/myhelper}}') == {'myhelper', 'x'} def test_explicit_helpers_argument_suppresses_helper_ref(): template = '{{#myhelper x}}body{{/myhelper}}' assert extract_dependencies(template, helpers={'myhelper'}) == {'x'} def test_include_extra_helpers_recognises_uppercase(): # `uppercase` is in the extra-helpers set. template = '{{uppercase name}}' assert extract_dependencies(template, include_extra_helpers=True) == {'name'} def test_extra_helpers_default_off_treats_uppercase_as_ref(): # Without opting in to extras, `uppercase` looks like a context name. template = '{{uppercase name}}' assert extract_dependencies(template) == {'uppercase', 'name'} def test_helper_with_no_args_is_bare_path_lookup(): # `{{my_helper}}` with no args is ambiguous; the renderer prefers a # path lookup, so we count it as a dep. assert extract_dependencies('{{my_helper}}', helpers={'my_helper'}) == {'my_helper'} # --------------------------------------------------------------------------- # Subexpressions # --------------------------------------------------------------------------- def test_subexpression_args_are_deps(): template = '{{#if (eq a b)}}x{{/if}}' assert extract_dependencies(template, include_extra_helpers=True) == {'a', 'b'} def test_subexpression_in_hash_args(): template = '{{#if cond data=(lookup obj key)}}x{{/if}}' assert extract_dependencies(template) == {'cond', 'obj', 'key'} # --------------------------------------------------------------------------- # Data variables # --------------------------------------------------------------------------- def test_index_alone_is_not_a_dep(): template = '{{#each items}}{{@index}}:{{name}}{{/each}}' assert extract_dependencies(template) == {'items'} def test_root_field_is_top_level_dep(): assert extract_dependencies('{{@root.foo}}') == {'foo'} def test_root_alone_is_not_a_dep(): # `{{@root}}` on its own returns the root context as a value — we # don't know any specific field that it depends on. assert extract_dependencies('{{@root}}') == set() # --------------------------------------------------------------------------- # `this` references # --------------------------------------------------------------------------- def test_this_alone_is_not_a_dep(): assert extract_dependencies('{{this}}') == set() def test_this_dot_field_is_a_dep(): # `{{this.name}}` is equivalent to `{{name}}` — both reference the # current context's `name` field. At the top level, that's a dep. # Use a defensive check: the parser may or may not put `name` in # `parts` depending on how `this.name` parses. deps = extract_dependencies('{{this.name}}') # The acceptable answers are `{'name'}` (if `parts == ['name']`) or # `set()` (if the parser absorbs `this.name` into a single this-token). assert deps in ({'name'}, set()) # --------------------------------------------------------------------------- # Parse errors propagate # --------------------------------------------------------------------------- def test_unparseable_template_raises(): with pytest.raises(HandlebarsParseError): extract_dependencies('{{#if x}}') # unclosed block # --------------------------------------------------------------------------- # Composition with the public render() API: deps describe exactly what to # pass for a successful render. # --------------------------------------------------------------------------- def test_deps_align_with_render_inputs(): from pydantic_handlebars import render template = '{{greeting}}, {{#with user}}{{name}}{{/with}}!' deps = extract_dependencies(template) assert deps == {'greeting', 'user'} context = {'greeting': 'Hi', 'user': {'name': 'Alice'}} assert render(template, context) == 'Hi, Alice!' # --------------------------------------------------------------------------- # Subexpressions in unusual positions and hash arguments # --------------------------------------------------------------------------- def test_subexpression_as_mustache_path(): # `{{(maybe-helper a)}}` invokes the result of a subexpression — the # subexpression's args are deps even though the mustache "path" is a # SubExpression rather than a PathExpression. template = '{{(lookup helpers name) arg}}' assert extract_dependencies(template) == {'helpers', 'name', 'arg'} def test_mustache_hash_arguments_are_deps(): template = '{{my_helper key=value other=more}}' deps = extract_dependencies(template, helpers={'my_helper'}) assert deps == {'value', 'more'} def test_subexpression_hash_arguments_are_deps(): template = '{{#if (eq a b limit=cutoff)}}x{{/if}}' deps = extract_dependencies(template, include_extra_helpers=True) assert deps == {'a', 'b', 'cutoff'} # --------------------------------------------------------------------------- # Parent references past root # --------------------------------------------------------------------------- def test_parent_ref_past_root_lands_on_root(): # `../` past the top scope clamps to root (matching the renderer). template = '{{../name}}' assert extract_dependencies(template) == {'name'} # --------------------------------------------------------------------------- # Literals in argument positions contribute nothing # --------------------------------------------------------------------------- def test_literal_arguments_are_not_deps(): template = '{{my_helper "literal" 42 true null}}' assert extract_dependencies(template, helpers={'my_helper'}) == set() pydantic-handlebars-0.2.1/tests/test_fuzz.py000066400000000000000000000525261520472041100212050ustar00rootroot00000000000000"""Fuzz testing for pydantic-handlebars using Hypothesis. Tests invariants that should hold for ALL inputs — any violation is a bug. Install: uv add --group dev hypothesis Run: pytest tests/test_fuzz.py -x Extended: FUZZ_EXTENDED=1 pytest tests/test_fuzz.py -x -s """ from __future__ import annotations import os from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from pydantic_handlebars import ( HandlebarsEnvironment, HandlebarsError, HandlebarsParseError, SafeString, render, ) from pydantic_handlebars._compiler import Compiler from pydantic_handlebars._parser import parse from pydantic_handlebars._tokenizer import tokenize # --------------------------------------------------------------------------- # Hypothesis settings — controlled by FUZZ_EXTENDED env var # # Default: fast smoke tests for CI (~2s) # Extended: thorough fuzzing for pre-release confidence (~2-5min, `make fuzz`) # --------------------------------------------------------------------------- _extended = bool(os.environ.get('FUZZ_EXTENDED')) # Most tests: high-volume random input _base = settings(max_examples=5_000 if _extended else 20, suppress_health_check=[HealthCheck.too_slow]) # Tests with expensive setup or structured generation _heavy = settings(max_examples=2_000 if _extended else 10, suppress_health_check=[HealthCheck.too_slow]) # Tests that are inherently slow (deep nesting, large output) _light = settings(max_examples=500 if _extended else 5, suppress_health_check=[HealthCheck.too_slow]) # --------------------------------------------------------------------------- # Reusable strategies # --------------------------------------------------------------------------- # Identifiers that look like handlebars variable names _identifier = st.from_regex(r'[a-zA-Z_][a-zA-Z0-9_]{0,15}', fullmatch=True) # Characters likely to trigger edge cases in the tokenizer _handlebars_alphabet = st.sampled_from(list('{}\\#/^~!>@.|"\' abcdefg012\n\r\t')) # Template-like strings: biased toward handlebars syntax characters _template_text = st.text(_handlebars_alphabet, min_size=0, max_size=200) # Totally arbitrary strings (unicode, control chars, etc.) _arbitrary_text = st.text(min_size=0, max_size=300) # JSON-like leaf values for contexts _leaf_value = st.one_of( st.none(), st.booleans(), st.integers(min_value=-1_000_000, max_value=1_000_000), st.floats(allow_nan=False, allow_infinity=False), st.text(min_size=0, max_size=50), ) # Recursive context dicts (limited depth to keep generation fast) _context_value = st.recursive( _leaf_value, lambda children: st.one_of( st.lists(children, max_size=5), st.dictionaries(st.text(min_size=1, max_size=10), children, max_size=5), ), max_leaves=20, ) _context = st.dictionaries( st.text(min_size=1, max_size=15), _context_value, max_size=8, ) # --------------------------------------------------------------------------- # Strategy: structured Handlebars templates (more likely to parse) # --------------------------------------------------------------------------- def _simple_mustache(ident: str) -> str: return '{{' + ident + '}}' def _unescaped_mustache(ident: str) -> str: return '{{{' + ident + '}}}' def _block_each(ident: str, body: str) -> str: return '{{#each ' + ident + '}}' + body + '{{/each}}' def _block_if(ident: str, body: str, else_body: str) -> str: if else_body: return '{{#if ' + ident + '}}' + body + '{{else}}' + else_body + '{{/if}}' return '{{#if ' + ident + '}}' + body + '{{/if}}' def _block_with(ident: str, body: str) -> str: return '{{#with ' + ident + '}}' + body + '{{/with}}' # Build well-formed templates recursively _plain_content = st.text( alphabet=st.characters(blacklist_characters='{}\\\n\r'), min_size=0, max_size=30, ) _well_formed_template = st.recursive( # Base cases: plain text or simple mustache st.one_of( _plain_content, _identifier.map(_simple_mustache), _identifier.map(_unescaped_mustache), ), # Recursive cases: blocks containing other templates lambda inner: st.one_of( # Concatenation of parts st.tuples(inner, inner).map(lambda t: t[0] + t[1]), # {{#each x}}...{{/each}} st.tuples(_identifier, inner).map(lambda t: _block_each(t[0], t[1])), # {{#if x}}...{{else}}...{{/if}} st.tuples(_identifier, inner, inner).map(lambda t: _block_if(t[0], t[1], t[2])), # {{#with x}}...{{/with}} st.tuples(_identifier, inner).map(lambda t: _block_with(t[0], t[1])), ), max_leaves=8, ) # =========================================================================== # 1. Core safety: render() must never raise unexpected exceptions # =========================================================================== @given(template=_arbitrary_text, ctx=_context) @_base def test_render_arbitrary_strings_no_unexpected_exceptions(template: str, ctx: dict[str, object]) -> None: """render() should only raise HandlebarsError subclasses, never TypeError/KeyError/etc.""" try: render(template, ctx) except HandlebarsError: pass @given(template=_template_text, ctx=_context) @_base def test_render_handlebars_like_strings_no_unexpected_exceptions(template: str, ctx: dict[str, object]) -> None: """Same as above but with strings biased toward handlebars syntax.""" try: render(template, ctx) except HandlebarsError: pass @given(template=_well_formed_template, ctx=_context) @_heavy def test_render_well_formed_templates_no_unexpected_exceptions(template: str, ctx: dict[str, object]) -> None: """Well-formed templates should render without errors (missing keys produce empty strings).""" try: render(template, ctx) except HandlebarsError: pass # =========================================================================== # 2. Tokenizer safety # =========================================================================== @given(source=_arbitrary_text) @_base def test_tokenizer_no_unexpected_exceptions(source: str) -> None: """tokenize() should only raise HandlebarsParseError.""" try: tokens = tokenize(source) assert len(tokens) > 0 # At least EOF token except HandlebarsParseError: pass @given(source=_template_text) @_base def test_tokenizer_handlebars_text_no_unexpected_exceptions(source: str) -> None: """tokenize() on handlebars-like text should only raise HandlebarsParseError.""" try: tokens = tokenize(source) assert len(tokens) > 0 except HandlebarsParseError: pass # =========================================================================== # 3. Parser safety # =========================================================================== @given(source=_arbitrary_text) @_base def test_parser_no_unexpected_exceptions(source: str) -> None: """parse() should only raise HandlebarsParseError.""" try: parse(source) except HandlebarsParseError: pass @given(source=_template_text) @_base def test_parser_handlebars_text_no_unexpected_exceptions(source: str) -> None: """parse() on handlebars-like text should only raise HandlebarsParseError.""" try: parse(source) except HandlebarsParseError: pass # =========================================================================== # 4. Property: plain text without {{ renders as itself # =========================================================================== @given( text=st.text( alphabet=st.characters(blacklist_characters='{}\\\n\r'), min_size=0, max_size=200, ) ) @_base def test_plain_text_renders_as_itself(text: str) -> None: """Text with no handlebars delimiters should render unchanged.""" assert render(text, {}) == text # =========================================================================== # 5. Property: render and compile().render() agree # =========================================================================== @given(template=_well_formed_template, ctx=_context) @_heavy def test_render_matches_compiled_render(template: str, ctx: dict[str, object]) -> None: """render(t, c) and compile(t).render(c) should produce the same result.""" from pydantic_handlebars import compile try: direct = render(template, ctx) except HandlebarsError: return try: compiled = compile(template) compiled_result = compiled.render(ctx) except HandlebarsError: return assert direct == compiled_result # =========================================================================== # 7. HTML escaping works when enabled (auto_escape=True) # =========================================================================== _html_payloads = st.sampled_from( [ '', '', '">', "'-alert(1)-'", '', ] ) _escape_env = HandlebarsEnvironment(auto_escape=True) @given(payload=_html_payloads) @_base def test_html_escaped_when_auto_escape_enabled(payload: str) -> None: """HTML special characters are escaped in {{expr}} output when auto_escape=True.""" result = _escape_env.render('{{val}}', {'val': payload}) assert '<' not in result assert '>' not in result @given(value=st.text(min_size=1, max_size=100)) @_base def test_double_mustache_no_raw_angle_brackets_when_auto_escape(value: str) -> None: """{{expr}} output should not contain literal < or > when auto_escape=True.""" result = _escape_env.render('{{val}}', {'val': value}) if '<' in value: assert '<' not in result if '>' in value: assert '>' not in result @given(value=st.text(min_size=1, max_size=100)) @_base def test_double_mustache_preserves_content_by_default(value: str) -> None: """{{expr}} output preserves raw content when auto_escape=False (default).""" result = render('{{val}}', {'val': value}) assert result == value @given(value=st.text(min_size=1, max_size=100)) @_base def test_triple_mustache_preserves_raw_content(value: str) -> None: """{{{expr}}} should output content without escaping.""" result = render('{{{val}}}', {'val': value}) assert result == value # =========================================================================== # 8. Resource limits: deeply nested templates terminate # =========================================================================== @given(depth=st.integers(min_value=50, max_value=200)) @_light def test_deep_nesting_terminates(depth: int) -> None: """Deeply nested blocks should hit the depth limit, not stack overflow.""" template = '{{#if x}}' * depth + 'deep' + '{{/if}}' * depth try: render(template, {'x': True}) except HandlebarsError: pass # Depth limit error is expected @given(count=st.integers(min_value=100, max_value=500)) @_light def test_large_repeated_output_terminates(count: int) -> None: """Repeated expansion should be bounded by output size limits.""" big_value = 'x' * 1000 items = list(range(count)) try: render('{{#each items}}' + big_value + '{{/each}}', {'items': items}) except HandlebarsError: pass # =========================================================================== # 9. Each/with block helpers: random iteration # =========================================================================== @given(items=st.lists(st.text(min_size=0, max_size=20), max_size=20)) @_base def test_each_over_random_lists(items: list[str]) -> None: """{{#each}} should handle lists of any string values without crashing.""" result = render('{{#each items}}|{{this}}|{{/each}}', {'items': items}) assert isinstance(result, str) if not items: assert result == '' @given(data=st.dictionaries(st.text(min_size=1, max_size=10), st.text(min_size=0, max_size=20), max_size=10)) @_base def test_each_over_random_dicts(data: dict[str, str]) -> None: """{{#each}} should handle dicts of any string key/value pairs without crashing.""" result = render('{{#each data}}{{@key}}\n{{/each}}', {'data': data}) assert isinstance(result, str) if not data: assert result == '' @given(ctx=_context) @_base def test_with_random_context(ctx: dict[str, object]) -> None: """{{#with}} should handle arbitrary context objects.""" try: render('{{#with ctx}}{{this}}{{/with}}', {'ctx': ctx}) except HandlebarsError: pass # =========================================================================== # 10. Escaped mustache syntax # =========================================================================== @given(ident=_identifier) @_base def test_escaped_mustaches_render_literally(ident: str) -> None: r"""\\{{ should render as literal {{ text.""" template = '\\{{' + ident + '}}' result = render(template, {ident: 'SHOULD_NOT_APPEAR'}) assert 'SHOULD_NOT_APPEAR' not in result # =========================================================================== # 11. Comment stripping # =========================================================================== @given( comment_text=st.text( alphabet=st.characters(blacklist_characters='{}'), min_size=0, max_size=50, ) ) @_base def test_comments_never_appear_in_output(comment_text: str) -> None: """{{! comment }} content should be fully stripped from output.""" marker = '\x00MARKER\x00' template = marker + '{{!-- ' + comment_text + ' --}}' + marker result = render(template, {}) assert result == marker + marker # =========================================================================== # 12. SafeString bypass # =========================================================================== @given(html=st.text(min_size=1, max_size=100)) @_base def test_safestring_serialized_to_str(html: str) -> None: """SafeString in context is serialized to plain str. With auto_escape=False (default), the content renders as-is. With auto_escape=True, the serialized str gets HTML-escaped. """ safe = SafeString(html) # Default (auto_escape=False): no escaping result = render('{{val}}', {'val': safe}) assert result == html # Explicit auto_escape=True: serialized to str, so escaping applies result_escaped = _escape_env.render('{{val}}', {'val': safe}) if '<' in html: assert '<' not in result_escaped if '>' in html: assert '>' not in result_escaped # =========================================================================== # 13. Numeric and boolean rendering # =========================================================================== @given(n=st.integers(min_value=-1_000_000, max_value=1_000_000)) @_base def test_integer_rendering(n: int) -> None: result = render('{{val}}', {'val': n}) assert result == str(n) @given(n=st.floats(allow_nan=False, allow_infinity=False, min_value=-1e6, max_value=1e6)) @_base def test_float_rendering(n: float) -> None: result = render('{{val}}', {'val': n}) assert result == str(n) @given(b=st.booleans()) @_base def test_boolean_rendering(b: bool) -> None: result = render('{{val}}', {'val': b}) assert result == ('true' if b else 'false') def test_none_rendering() -> None: result = render('{{val}}', {'val': None}) assert result == '' # =========================================================================== # 14. Stress: many variables in one template # =========================================================================== @given( data=st.fixed_dictionaries( {}, optional={chr(c): st.text(min_size=1, max_size=10) for c in range(ord('a'), ord('z') + 1)} ) ) @_heavy def test_many_variables(data: dict[str, str]) -> None: """Templates with many variable references should work correctly.""" parts = [f'{{{{{k}}}}}' for k in data] template = ' '.join(parts) result = render(template, data) assert isinstance(result, str) # =========================================================================== # 15. Compiler directly with constrained limits # =========================================================================== @given(template=_well_formed_template, ctx=_context) @_heavy def test_compiler_with_tight_limits(template: str, ctx: dict[str, object]) -> None: """Compiler with very tight limits should raise HandlebarsRuntimeError, not crash.""" try: program = parse(template) except HandlebarsParseError: return compiler = Compiler(helpers={}, max_depth=5, max_output_size=1024) try: compiler.render(program, ctx) except HandlebarsError: pass # =========================================================================== # 16. Pathological input patterns # =========================================================================== @given(n=st.integers(min_value=1, max_value=500)) @_light def test_many_open_braces(n: int) -> None: """Lots of {{ without closing should parse-error, not hang or crash.""" template = '{{' * n try: render(template, {}) except HandlebarsError: pass @given(n=st.integers(min_value=1, max_value=500)) @_light def test_many_close_braces(n: int) -> None: """Lots of }} without opening should be treated as content.""" template = '}}' * n try: render(template, {}) except HandlebarsError: pass @given(n=st.integers(min_value=1, max_value=200)) @_light def test_alternating_braces(n: int) -> None: template = '{}'.join([''] * n) + '}'.join([''] * n) try: render(template, {}) except HandlebarsError: pass @given(n=st.integers(min_value=1, max_value=100)) @_light def test_nested_subexpressions(n: int) -> None: """Deeply nested subexpressions like (helper (helper (helper x))).""" inner = 'x' for _ in range(n): inner = f'(lookup {inner})' template = '{{' + inner + '}}' try: render(template, {'x': 'val'}) except HandlebarsError: pass @given(n=st.integers(min_value=1, max_value=100)) @_light def test_many_parent_refs(n: int) -> None: """Paths with many ../ segments should not crash.""" path = '../' * n + 'x' template = '{{#with a}}{{' + path + '}}{{/with}}' try: render(template, {'a': {}, 'x': 'found'}) except HandlebarsError: pass # =========================================================================== # 17. Context with unusual types # =========================================================================== @given(data=st.data()) @_heavy def test_unusual_context_values(data: st.DataObject) -> None: """render() should handle unusual Python types in context without crashing. Non-serializable types raise HandlebarsError (via serialization). """ unusual_value = data.draw( st.one_of( st.just(frozenset({0})), st.just(b'bytes'), st.just({0}), st.just(()), st.just(range(10)), st.builds( complex, st.floats(allow_nan=False, allow_infinity=False), st.floats(allow_nan=False, allow_infinity=False), ), ) ) try: render('{{val}}', {'val': unusual_value}) except HandlebarsError: pass # =========================================================================== # 18. Context keys that look like attacks # =========================================================================== @given( key=st.one_of( st.from_regex(r'__[a-z]{1,10}__', fullmatch=True), st.just('constructor'), st.just('__proto__'), st.just('prototype'), ), ) @_base def test_suspicious_context_keys(key: str) -> None: """Suspicious keys are now normal dict keys (context is pre-serialized).""" try: result = render('{{' + key + '}}', {key: 'VALUE'}) # All keys, including dunder keys, should now render their values assert result == 'VALUE' except HandlebarsError: pass # =========================================================================== # 19. Whitespace control fuzz # =========================================================================== @given( content_before=_plain_content, content_after=_plain_content, ident=_identifier, strip_open=st.booleans(), strip_close=st.booleans(), ) @_heavy def test_whitespace_control_does_not_crash( content_before: str, content_after: str, ident: str, strip_open: bool, strip_close: bool ) -> None: """Whitespace strip markers (~) should never cause crashes.""" open_strip = '~' if strip_open else '' close_strip = '~' if strip_close else '' template = content_before + '{{' + open_strip + ident + close_strip + '}}' + content_after try: render(template, {ident: 'val'}) except HandlebarsError: pass # =========================================================================== # 20. Hash arguments fuzz # =========================================================================== @given( helper_name=_identifier, key=_identifier, value=st.text(min_size=0, max_size=20), ) @_heavy def test_hash_arguments_do_not_crash(helper_name: str, key: str, value: str) -> None: """Hash arguments (key=value) should not crash even for unknown helpers.""" escaped_value = value.replace('"', '\\"') template = '{{' + helper_name + ' ' + key + '="' + escaped_value + '"}}' try: render(template, {}) except HandlebarsError: pass ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������pydantic-handlebars-0.2.1/tests/test_helpers.py�����������������������������������������������������0000664�0000000�0000000�00000012071�15204720411�0021640�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Tests for helper calling conventions. Ported from handlebars.js spec/helpers.js. """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportUnusedFunction=false, reportUnknownVariableType=false from pydantic_handlebars import HandlebarsEnvironment, HelperOptions, SafeString, render # --- Helper invocation --- def test_helper_with_no_args(): env = HandlebarsEnvironment() env.register_helper('hello', lambda *args, **kw: 'hello world') assert env.render('{{hello}}', {}) == 'hello world' def test_helper_with_one_arg(): env = HandlebarsEnvironment() env.register_helper('loud', lambda *args, **kw: str(args[0]).upper() if args else '') assert env.render('{{loud name}}', {'name': 'world'}) == 'WORLD' def test_helper_with_multiple_args(): env = HandlebarsEnvironment() env.register_helper( 'combine', lambda *args, **kw: ' '.join(str(a) for a in args if not isinstance(a, HelperOptions)) ) assert env.render('{{combine a b c}}', {'a': 'x', 'b': 'y', 'c': 'z'}) == 'x y z' def test_helper_with_hash_args(): env = HandlebarsEnvironment() def link(*args: object, options: HelperOptions) -> SafeString: url = options.hash.get('url', '#') text = options.hash.get('text', '') return SafeString(f'{text}') env.register_helper('link', link) assert env.render('{{link url="http://example.com" text="click"}}', {}) == 'click' def test_helper_with_mixed_args_and_hash(): env = HandlebarsEnvironment() def format_name(*args: object, options: HelperOptions) -> str: name = str(args[0]) if args else '' title = options.hash.get('title', '') return f'{title} {name}' if title else name env.register_helper('format_name', format_name) assert env.render('{{format_name name title="Dr."}}', {'name': 'Alice'}) == 'Dr. Alice' # --- Helper escaping --- def test_helper_output_not_escaped_by_default(): env = HandlebarsEnvironment() env.register_helper('html', lambda *args, **kw: 'bold') assert env.render('{{html}}', {}) == 'bold' def test_helper_output_is_escaped_when_enabled(): env = HandlebarsEnvironment(auto_escape=True) env.register_helper('html', lambda *args, **kw: 'bold') assert env.render('{{html}}', {}) == '<b>bold</b>' def test_helper_output_unescaped_with_triple_stache(): env = HandlebarsEnvironment() env.register_helper('html', lambda *args, **kw: 'bold') assert env.render('{{{html}}}', {}) == 'bold' # --- Block helpers --- def test_block_helper_basic(): env = HandlebarsEnvironment() def noop(context: object, *args: object, options: HelperOptions) -> str: return options.fn(context) env.register_helper('noop', noop) assert env.render('{{#noop}}content{{/noop}}', {}) == 'content' def test_block_helper_with_else(): env = HandlebarsEnvironment() def conditional(context: object, *args: object, options: HelperOptions) -> str: if args and args[0]: return options.fn(context) return options.inverse(context) env.register_helper('conditional', conditional) assert env.render('{{#conditional show}}yes{{else}}no{{/conditional}}', {'show': True}) == 'yes' assert env.render('{{#conditional show}}yes{{else}}no{{/conditional}}', {'show': False}) == 'no' def test_block_helper_with_hash(): env = HandlebarsEnvironment() def repeat(context: object, *args: object, options: HelperOptions) -> str: times = int(options.hash.get('times', 1)) return ''.join(options.fn(context) for _ in range(times)) env.register_helper('repeat', repeat) assert env.render('{{#repeat times=3}}Ho {{/repeat}}', {}) == 'Ho Ho Ho ' def test_block_helper_context_change(): env = HandlebarsEnvironment() def wrap(context: object, *args: object, options: HelperOptions) -> str: new_ctx = args[0] if args else context return options.fn(new_ctx) env.register_helper('wrap', wrap) assert env.render('{{#wrap person}}{{name}}{{/wrap}}', {'person': {'name': 'Alice'}}) == 'Alice' # --- Helper decorator --- def test_helper_decorator(): env = HandlebarsEnvironment() @env.helper def shout(*args: object, **kw: object) -> str: val = str(args[0]) if args else '' return val.upper() + '!!!' assert env.render('{{shout name}}', {'name': 'world'}) == 'WORLD!!!' def test_helper_decorator_with_name(): env = HandlebarsEnvironment() @env.helper('yell') def my_yell(*args: object, **kw: object) -> str: val = str(args[0]) if args else '' return val.upper() + '!!!' assert env.render('{{yell name}}', {'name': 'hello'}) == 'HELLO!!!' # --- Helper priority --- def test_helper_takes_priority_over_context(): env = HandlebarsEnvironment() env.register_helper('foo', lambda *args, **kw: 'helper') assert env.render('{{foo}}', {'foo': 'context'}) == 'helper' def test_context_used_when_no_helper(): assert render('{{foo}}', {'foo': 'context'}) == 'context' pydantic-handlebars-0.2.1/tests/test_helpers_coverage.py000066400000000000000000000254231520472041100235200ustar00rootroot00000000000000"""Tests to cover missing lines in _helpers.py. Focuses on edge cases: no-arg subexpressions, empty collections, lookup edge cases, log helper, comparison/boolean helpers with insufficient args, and default helper edge cases. """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false import pytest from pydantic_handlebars import HandlebarsEnvironment, render _extra_env = HandlebarsEnvironment(extra_helpers=True) # --- _helper_if: line 25 (condition = context when args is empty) --- class TestIfNoArgs: def test_if_no_explicit_arg_truthy_context(self): """When #if is used without an argument, the context itself is the condition. In block helper dispatch, the call is helper(scope.context, *args, options=options). With no params, args is empty so condition = context (line 25). We need a truthy context. """ # {{#if}} with no param means condition = context (the current scope context) # The context dict is truthy (it's not empty), so the block should render. # But actually the dict context is the whole dict, and is_falsy returns False for dicts. result = render('{{#if}}yes{{else}}no{{/if}}', {'some': 'data'}) assert result == 'yes' def test_if_no_explicit_arg_falsy_context(self): """When #if has no arg and context itself is falsy. We use #each to iterate over an array containing a falsy value (0), which sets the loop context to that value. Then #if with no arg uses the context (0) as the condition. """ result = render('{{#each items}}{{#if}}yes{{else}}no{{/if}}{{/each}}', {'items': [0]}) assert result == 'no' # --- _helper_unless: lines 41, 44 --- class TestUnlessNoArgs: def test_unless_no_explicit_arg_truthy_context(self): """Unless with no args uses context as condition (line 41).""" result = render('{{#unless}}yes{{else}}no{{/unless}}', {'some': 'data'}) assert result == 'no' def test_unless_no_explicit_arg_falsy_context(self): """Unless with no args, context is falsy (0). Use #each to set context.""" result = render('{{#each items}}{{#unless}}yes{{else}}no{{/unless}}{{/each}}', {'items': [0]}) assert result == 'yes' # --- _helper_each: line 67 (empty dict) --- class TestEachEmptyDict: def test_each_empty_dict_inverse(self): """Iterating over an empty dict should render the inverse/else block.""" result = render('{{#each obj}}{{@key}}:{{this}}{{else}}empty{{/each}}', {'obj': {}}) assert result == 'empty' # --- _helper_lookup: lines 116, 133-144 --- class TestLookupEdgeCases: def test_lookup_no_args(self): """Lookup with fewer than 2 args returns None (line 116). We use a subexpression to call lookup with only 1 arg. """ result = render('{{#if (lookup obj)}}found{{else}}empty{{/if}}', {'obj': {'a': 1}}) assert result == 'empty' def test_lookup_list_invalid_key(self): """Lookup on a list with a non-integer key (lines 133-134).""" result = render('{{lookup arr "notanumber"}}', {'arr': ['a', 'b', 'c']}) assert result == '' def test_lookup_list_out_of_bounds(self): """Lookup on a list with an out-of-bounds index (line 135).""" result = render('{{lookup arr 99}}', {'arr': ['a', 'b', 'c']}) assert result == '' def test_lookup_on_string_returns_empty(self): """Lookup on a string (non-dict/non-list) returns empty.""" result = render('{{lookup obj "key"}}', {'obj': 'hello'}) assert result == '' def test_lookup_on_number_returns_empty(self): """Lookup on a number (non-dict/non-list) returns empty.""" result = render('{{lookup obj 0}}', {'obj': 42}) assert result == '' def test_lookup_null_object(self): """Lookup on null object returns empty (line 121, already tested but ensuring).""" result = render('{{lookup obj "key"}}', {'obj': None}) assert result == '' # --- _helper_log: lines 152-155 --- class TestLogHelper: def test_log_basic(self): """Log helper emits a UserWarning (lines 152-155).""" with pytest.warns(UserWarning, match=r'\[Handlebars info\] hello'): render('{{log "hello"}}', {}) def test_log_with_level(self): """Log helper with explicit level hash arg.""" with pytest.warns(UserWarning, match=r'\[Handlebars warn\] test message'): render('{{log "test message" level="warn"}}', {}) def test_log_multiple_args(self): """Log helper with multiple arguments joined by space.""" with pytest.warns(UserWarning, match=r'\[Handlebars info\] hello world'): render('{{log "hello" "world"}}', {}) def test_log_returns_empty(self): """Log helper returns empty string so it produces no output.""" with pytest.warns(UserWarning): result = render('before{{log "msg"}}after', {}) assert result == 'beforeafter' # --- Custom helpers with no args via subexpressions: lines 164, 171, 178, 185, 192, 198, 204 --- class TestCustomHelpersNoArgs: def test_json_no_args(self): """json with no args returns '' (line 164).""" result = _extra_env.render('{{#if (json)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_uppercase_no_args(self): """uppercase with no args returns '' (line 171).""" result = _extra_env.render('{{#if (uppercase)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_lowercase_no_args(self): """lowercase with no args returns '' (line 178).""" result = _extra_env.render('{{#if (lowercase)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_trim_no_args(self): """trim with no args returns '' (line 185).""" result = _extra_env.render('{{#if (trim)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_join_no_args(self): """join with no args returns '' (line 192).""" result = _extra_env.render('{{#if (join)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_join_non_list_arg(self): """join with a non-list argument returns to_string of it (line 198).""" result = _extra_env.render('{{join val}}', {'val': 'hello'}) assert result == 'hello' def test_truncate_no_args(self): """truncate with no args returns '' (line 204).""" result = _extra_env.render('{{#if (truncate)}}yes{{else}}no{{/if}}', {}) assert result == 'no' # --- Comparison helpers with < 2 args: lines 215, 222, 229, 236, 243, 250 --- class TestComparisonHelpersInsufficientArgs: def test_eq_no_args(self): """eq with no args returns False (line 215).""" result = _extra_env.render('{{#if (eq)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_ne_no_args(self): """ne with no args returns True (line 222).""" result = _extra_env.render('{{#if (ne)}}yes{{else}}no{{/if}}', {}) assert result == 'yes' def test_gt_no_args(self): """gt with no args returns False (line 229).""" result = _extra_env.render('{{#if (gt)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_gte_no_args(self): """gte with no args returns False (line 236).""" result = _extra_env.render('{{#if (gte)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_lt_no_args(self): """lt with no args returns False (line 243).""" result = _extra_env.render('{{#if (lt)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_lte_no_args(self): """lte with no args returns False (line 250).""" result = _extra_env.render('{{#if (lte)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_eq_one_arg(self): """eq with only 1 arg returns False (line 215).""" result = _extra_env.render('{{#if (eq a)}}yes{{else}}no{{/if}}', {'a': 1}) assert result == 'no' def test_ne_one_arg(self): """ne with only 1 arg returns True (line 222).""" result = _extra_env.render('{{#if (ne a)}}yes{{else}}no{{/if}}', {'a': 1}) assert result == 'yes' def test_gt_one_arg(self): """gt with only 1 arg returns False (line 229).""" result = _extra_env.render('{{#if (gt a)}}yes{{else}}no{{/if}}', {'a': 5}) assert result == 'no' def test_gte_one_arg(self): """gte with only 1 arg returns False (line 236).""" result = _extra_env.render('{{#if (gte a)}}yes{{else}}no{{/if}}', {'a': 5}) assert result == 'no' def test_lt_one_arg(self): """lt with only 1 arg returns False (line 243).""" result = _extra_env.render('{{#if (lt a)}}yes{{else}}no{{/if}}', {'a': 5}) assert result == 'no' def test_lte_one_arg(self): """lte with only 1 arg returns False (line 250).""" result = _extra_env.render('{{#if (lte a)}}yes{{else}}no{{/if}}', {'a': 5}) assert result == 'no' def test_gt_incomparable_types(self): result = _extra_env.render('{{#if (gt a b)}}yes{{else}}no{{/if}}', {'a': 'hello', 'b': 5}) assert result == 'no' def test_gte_incomparable_types(self): result = _extra_env.render('{{#if (gte a b)}}yes{{else}}no{{/if}}', {'a': 'hello', 'b': 5}) assert result == 'no' def test_lt_incomparable_types(self): result = _extra_env.render('{{#if (lt a b)}}yes{{else}}no{{/if}}', {'a': 'hello', 'b': 5}) assert result == 'no' def test_lte_incomparable_types(self): result = _extra_env.render('{{#if (lte a b)}}yes{{else}}no{{/if}}', {'a': 'hello', 'b': 5}) assert result == 'no' # --- Boolean helpers with no args: lines 257, 267, 277 --- class TestBooleanHelpersNoArgs: def test_and_no_args(self): """and with no args returns False (line 257).""" result = _extra_env.render('{{#if (and)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_or_no_args(self): """or with no args returns False (line 267).""" result = _extra_env.render('{{#if (or)}}yes{{else}}no{{/if}}', {}) assert result == 'no' def test_not_no_args(self): """not with no args returns True (line 277).""" result = _extra_env.render('{{#if (not)}}yes{{else}}no{{/if}}', {}) assert result == 'yes' # --- Default helper with < 2 args: line 284 --- class TestDefaultEdgeCases: def test_default_one_arg(self): """default with 1 arg returns that arg (line 284).""" result = _extra_env.render('{{default val}}', {'val': 'hello'}) assert result == 'hello' def test_default_no_args(self): """default with no args returns None -> '' (line 284).""" result = _extra_env.render('{{#if (default)}}yes{{else}}no{{/if}}', {}) assert result == 'no' pydantic-handlebars-0.2.1/tests/test_parser_coverage.py000066400000000000000000000542121520472041100233500ustar00rootroot00000000000000"""Additional parser tests targeting uncovered code paths.""" # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownParameterType=false, reportMissingParameterType=false, reportOptionalMemberAccess=false import pytest from pydantic_handlebars import HandlebarsEnvironment, HandlebarsParseError, render from pydantic_handlebars._ast_nodes import ( BlockStatement, BooleanLiteral, ContentStatement, MustacheStatement, NullLiteral, PathExpression, RawBlock, StringLiteral, SubExpression, UndefinedLiteral, ) from pydantic_handlebars._parser import parse # --- _current returning EOF when past end (line 61) --- def test_current_returns_eof_past_end(): """Parser returns EOF token when position is past all tokens.""" result = parse('') assert result.body == [] # --- token_at returning EOF (line 72) --- def test_token_at_returns_eof_for_large_offset(): """Peek with large offset returns EOF.""" # Simple template - peeking ahead of all tokens should return EOF result = parse('hello') assert isinstance(result.body[0], ContentStatement) # --- _expect raising error (line 84) --- def test_expect_wrong_token_type(): """Expect raises error when token type doesn't match.""" # Triple-stache closed with double-stache triggers _expect(CLOSE_UNESCAPED) failure with pytest.raises(HandlebarsParseError, match='Expected CLOSE_UNESCAPED'): parse('{{{foo}}') # --- _parse_inverse_block (lines 142, 287-333) --- def test_inverse_block_basic(): """Parse inverse block structure.""" ast = parse('{{^helper}}content{{/helper}}') assert len(ast.body) == 1 block = ast.body[0] assert isinstance(block, BlockStatement) def test_inverse_block_with_content(): """Parse {{^helper}}content{{/helper}}.""" ast = parse('{{^myHelper}}inverse content{{/myHelper}}') assert len(ast.body) == 1 block = ast.body[0] assert isinstance(block, BlockStatement) assert isinstance(block.path, PathExpression) assert block.path.original == 'myHelper' assert block.inverse is not None assert len(block.inverse.body) == 1 assert isinstance(block.inverse.body[0], ContentStatement) assert block.inverse.body[0].value == 'inverse content' def test_standalone_inverse_block_error(): """Standalone {{^}} without being inside a block raises error.""" # A standalone {{^}} with CLOSE immediately after is an error with pytest.raises(HandlebarsParseError, match='Unexpected standalone inverse block'): parse('{{^}}content{{/}}') # --- _parse_raw_block (lines 144, 345-360) --- def test_raw_block_parsing(): """Parse {{{{raw}}}}content{{{{/raw}}}}.""" ast = parse('{{{{raw}}}}{{not parsed}}{{{{/raw}}}}') assert len(ast.body) == 1 raw = ast.body[0] assert isinstance(raw, RawBlock) assert isinstance(raw.path, PathExpression) assert raw.path.original == 'raw' assert raw.body == '{{not parsed}}' def test_raw_block_rendering(): """Render a raw block - content should not be processed.""" result = render('{{{{raw}}}}{{hello}}{{{{/raw}}}}', {'hello': 'world'}) assert result == '{{hello}}' def test_raw_block_empty(): """Parse raw block with empty content.""" ast = parse('{{{{raw}}}}{{{{/raw}}}}') raw = ast.body[0] assert isinstance(raw, RawBlock) assert raw.body == '' # --- Unexpected closing block (line 147) --- def test_unexpected_closing_block(): """Unexpected {{/ at top level raises error.""" with pytest.raises(HandlebarsParseError, match='Unexpected closing block'): parse('{{/something}}') # --- Partials not supported (lines 152-157) --- def test_partial_raises_error(): """Partial {{> raises error.""" with pytest.raises(HandlebarsParseError, match='Partials are not supported'): parse('{{> myPartial}}') # --- Skip unknown tokens (lines 160-161) --- def test_skip_unknown_tokens(): """Unknown tokens are skipped and return None.""" # This is hard to trigger since the tokenizer produces known types. # However, a STRIP token at top level would be unknown to _parse_statement. # An unclosed mustache like '{{foo' will leave tokens that _parse_statement doesn't recognize. # Actually, the parser loops on known open types. Let's test an edge case: # A template that produces tokens _parse_statement doesn't handle. # The EOF token check in _parse_body would stop before unknown tokens matter. # Let's use an unclosed mustache body which produces OPEN, ID without CLOSE # and then the ID would be consumed by _parse_mustache. Let's try something else. # The simplest way: Feed tokens the parser doesn't expect at statement level. # A STRIP token at top level from something like {{~foo~}} where parsing # might leave a STRIP token unconsumed... Actually, this is well-handled. # Let's just verify parse works for normal cases and coverage comes from other tests. result = parse('hello') assert len(result.body) == 1 # --- _is_else with OPEN_INVERSE (line 365) --- def test_block_with_inverse_caret(): """Block with {{^}} as else clause.""" ast = parse('{{#if cond}}then{{^}}else{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None assert len(block.inverse.body) == 1 # --- _is_else looking ahead with strip token (lines 371, 372->374) --- def test_block_with_else_strip(): """Block with {{~else~}} (strip markers around else).""" ast = parse('{{#if cond}} then {{~else~}} else {{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None # --- _parse_inverse_chain with content (lines 396-401) --- def test_inverse_chain_with_double_strip(): """Inverse chain with strip markers on both sides of the caret. When {{~^~}} is used, the parser goes through the else branch of _parse_inverse_chain (lines 396-401) because after consuming the open strip, the current token is still STRIP (the close strip). """ ast = parse('{{#if a}}content A{{~^~}}content B{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None # --- {{else}} chain (lines 244-245) --- def test_block_with_else(): """Block with {{else}} clause.""" ast = parse('{{#if cond}}then{{else}}otherwise{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None assert len(block.inverse.body) == 1 assert isinstance(block.inverse.body[0], ContentStatement) assert block.inverse.body[0].value == 'otherwise' # --- Chained else if (lines 433-478) --- def test_chained_else_if(): """Block with {{else if}} chaining.""" env = HandlebarsEnvironment() result = env.render( '{{#if a}}A{{else if b}}B{{else}}C{{/if}}', {'a': False, 'b': True}, ) assert result == 'B' def test_chained_else_if_fallthrough(): """Block with {{else if}} where all conditions fail.""" env = HandlebarsEnvironment() result = env.render( '{{#if a}}A{{else if b}}B{{else}}C{{/if}}', {'a': False, 'b': False}, ) assert result == 'C' def test_chained_else_if_first_true(): """Block with {{else if}} where first condition is true.""" env = HandlebarsEnvironment() result = env.render( '{{#if a}}A{{else if b}}B{{else}}C{{/if}}', {'a': True, 'b': False}, ) assert result == 'A' def test_chained_else_if_ast(): """Verify AST structure of chained else if.""" ast = parse('{{#if a}}A{{else if b}}B{{else}}C{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None # The inverse should contain a chained BlockStatement assert len(block.inverse.body) == 1 chained = block.inverse.body[0] assert isinstance(chained, BlockStatement) assert chained.chained is True # --- _parse_endblock_name validation (lines 483, 486->exit, 490-492, 494) --- def test_endblock_name_mismatch(): """Mismatched block open/close names raise error.""" with pytest.raises(HandlebarsParseError, match="doesn't match"): parse('{{#if cond}}content{{/unless}}') def test_endblock_name_with_dotted_path(): """Block close with dotted path name validation.""" # This tests lines 490-492 where the endblock name has a dotted path # We need a block helper with a dotted path ast = parse('{{#foo.bar}}content{{/foo.bar}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert isinstance(block.path, PathExpression) assert block.path.original == 'foo.bar' # --- UndefinedLiteral parsing (lines 569, 581-582) --- def test_undefined_literal_in_expression(): """undefined literal is parsed correctly.""" ast = parse('{{undefined}}') assert len(ast.body) == 1 mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, UndefinedLiteral) def test_undefined_as_helper_arg(): """undefined used as a helper argument.""" env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') result = env.render('{{echo undefined}}', {}) assert result == 'None' # --- Unclosed subexpression (line 596) --- def test_unclosed_subexpression(): """Unclosed subexpression raises error.""" with pytest.raises(HandlebarsParseError, match='Unclosed subexpression'): parse('{{foo (bar baz') # --- Hash args in subexpressions (lines 604-607) --- def test_subexpression_with_hash_args(): """Subexpression with key=value hash arguments.""" ast = parse('{{foo (bar baz key=val)}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) sub = mustache.params[0] assert isinstance(sub, SubExpression) assert 'key' in sub.hash_pairs # --- Path parsing - unexpected token (line 654) --- def test_path_parsing_unexpected_token(): """Path parsing with unexpected token type raises error.""" # In a subexpression, the first thing must be a path (helper name). # Putting a number there triggers the error in _parse_path. with pytest.raises(HandlebarsParseError, match='Expected path expression'): parse('{{foo (123)}}') # --- Path separator followed by non-ID (line 668) --- def test_path_separator_followed_by_non_id(): """Path with separator but no following identifier raises error.""" # Using / separator because . before }} is tokenized as 'this' ID with pytest.raises(HandlebarsParseError, match='Expected identifier after path separator'): parse('{{foo/}}') # --- _EndCondition.matches with strip token (line 728) --- def test_endcondition_matches_else_with_strip(): """End condition matching {{~else~}} with strip tokens.""" ast = parse('{{#if x}}content{{~else~}}other{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None # --- Block with no else and OPEN_ENDBLOCK (lines 246->250, 250->268) --- def test_block_without_else(): """Block with no else clause.""" ast = parse('{{#if cond}}content{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is None # --- _parse_body with end_condition not matched at EOF (line 118-123) --- def test_unclosed_block(): """Unclosed block raises error.""" with pytest.raises(HandlebarsParseError, match='Unclosed block'): parse('{{#if cond}}content') # --- HandlebarsParseError with explicit line/column (exceptions lines 22-25) --- def test_parse_error_with_line_and_column(): """HandlebarsParseError with line and column parameters.""" err = HandlebarsParseError('test error', line=5, column=10) assert err.line == 5 assert err.column == 10 assert 'line 5' in str(err) assert 'column 10' in str(err) def test_parse_error_with_line_only(): """HandlebarsParseError with only line parameter.""" err = HandlebarsParseError('test error', line=3) assert err.line == 3 assert err.column is None assert 'line 3' in str(err) assert 'column' not in str(err) def test_parse_error_without_position(): """HandlebarsParseError without line/column.""" err = HandlebarsParseError('test error') assert err.line is None assert err.column is None assert str(err) == 'test error' # --- Various rendering tests for coverage of parser paths --- def test_render_inverse_block_helper(): """Render inverse block through the compiler.""" env = HandlebarsEnvironment() def my_helper(context, *args, options): value = args[0] if args else True return options.fn() if value else options.inverse() env.register_helper('myHelper', my_helper) result = env.render('{{^myHelper false}}shown when false{{/myHelper}}', {}) assert result == 'shown when false' def test_render_raw_block(): """Render {{{{raw}}}}...{{{{/raw}}}}.""" result = render('before{{{{raw}}}}{{not rendered}}{{{{/raw}}}}after', {}) assert result == 'before{{not rendered}}after' def test_null_literal_parsed(): """Null literal is parsed in expression.""" ast = parse('{{null}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, NullLiteral) def test_boolean_false_literal(): """Boolean false literal is parsed.""" ast = parse('{{false}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, BooleanLiteral) assert mustache.path.value is False def test_number_float_literal(): """Float number literal is parsed.""" env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') result = env.render('{{echo 3.14}}', {}) assert result == '3.14' def test_string_literal_parsed(): """String literal as main expression.""" ast = parse('{{echo "hello world"}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert len(mustache.params) == 1 assert isinstance(mustache.params[0], StringLiteral) def test_triple_stache_with_strips(): """Triple-stache with both open and close strip markers.""" ast = parse('{{{~foo~}}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert mustache.escaped is False assert mustache.strip.open_standalone is True assert mustache.strip.close_standalone is True def test_inverse_block_with_open_strip(): """{{~^unless}} at top level — STRIP before OPEN_INVERSE in token stream.""" ast = parse('x {{~^unless show}}fallback{{/unless}}') assert isinstance(ast.body[0], ContentStatement) block = ast.body[1] assert isinstance(block, BlockStatement) assert block.open_strip.open_standalone is True def test_block_with_strip_on_close(): """Block with strip markers on close tag.""" ast = parse('{{#if cond}}content{{~/if~}}') block = ast.body[0] assert isinstance(block, BlockStatement) def test_this_path_with_segment(): """this.foo path expression.""" ast = parse('{{this.name}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, PathExpression) assert mustache.path.is_this is True assert mustache.path.parts == ['name'] def test_parent_path(): """../foo path expression.""" ast = parse('{{../name}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, PathExpression) assert mustache.path.depth == 1 def test_data_path(): """@root path expression.""" ast = parse('{{@root}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, PathExpression) assert mustache.path.data is True def test_block_with_inverse_chain_caret(): """Block with {{^}} inverse chain producing proper AST.""" ast = parse('{{#if a}}A{{^}}B{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None assert isinstance(block.inverse.body[0], ContentStatement) assert block.inverse.body[0].value == 'B' def test_chained_else_if_with_inverse_caret(): """Chained inverse using {{~^~}} syntax with double strip markers.""" ast = parse('{{#if a}}A{{~^~}}B{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None def test_multiple_chained_else_if(): """Multiple chained else-if blocks.""" env = HandlebarsEnvironment() result = env.render( '{{#if a}}A{{else if b}}B{{else if c}}C{{else}}D{{/if}}', {'a': False, 'b': False, 'c': True}, ) assert result == 'C' def test_else_if_with_block_params(): """Chained else-if with block params.""" ast = parse('{{#each items as |item|}}{{item}}{{else}}none{{/each}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.block_params == ['item'] assert block.inverse is not None # --- Further else chaining detection (line 429) --- def test_else_body_simple(): """Simple else body without further chaining.""" ast = parse('{{#if a}}yes{{else}}no{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None # The else body has a simple content statement content = block.inverse.body[0] assert isinstance(content, ContentStatement) assert content.value == 'no' # --- Chained else-if with inverse (line 458) --- def test_chained_else_if_with_caret_inverse(): """Chained else-if followed by {{^}} inverse.""" ast = parse('{{#if a}}A{{else if b}}B{{^}}C{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is not None def test_chained_else_if_no_final_else(): """Chained else-if with no final {{else}} — covers _parse_chained_else skip branch.""" env = HandlebarsEnvironment() result = env.render( '{{#if a}}A{{else if b}}B{{/if}}', {'a': False, 'b': True}, ) assert result == 'B' result = env.render( '{{#if a}}A{{else if b}}B{{/if}}', {'a': False, 'b': False}, ) assert result == '' # --- Chained else-if followed by another else (line 459->463) --- def test_chained_else_if_followed_by_else(): """Chained else-if followed by {{else}} (doubly chained).""" ast = parse('{{#if a}}A{{else if b}}B{{else}}C{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) # The first inverse contains a chained block chained = block.inverse.body[0] assert isinstance(chained, BlockStatement) assert chained.chained is True # The chained block should have its own inverse assert chained.inverse is not None # --- _is_else returning False when token after strip is not INVERSE (line 372->374) --- def test_is_else_false_with_strip_non_inverse(): """Block body followed by {{~expr}} tests _is_else with strip but no inverse. When the block body encounters OPEN + STRIP + non-INVERSE (like ID), _is_else returns False and the body continues parsing normally. The endblock then closes the block. """ ast = parse('{{#if a}}content{{~foo}}{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert block.inverse is None # Body should have content + mustache assert len(block.body.body) == 2 # --- _parse_endblock_name with non-PathExpression (line 483) --- def test_endblock_name_non_path_expression(): """Block with a non-path expression in the open position. When the block's path is not a PathExpression, _parse_endblock_name returns early without checking the name. """ # SubExpression as the block helper triggers this path # {{#(helper)}}...{{/helper}} - the path would be a SubExpression # Actually, blocks always parse the first token as a path through _parse_expression_with_params, # which calls _parse_expression, which for normal IDs returns PathExpression. # For a string literal: {{# "foo"}} would parse the expression as StringLiteral # But {{#"foo"}} is not valid syntax... Let me check what works. # Actually, looking at the parser, _parse_expression is called which handles # STRING, NUMBER, BOOLEAN, etc. before falling through to _parse_path. # So using a string literal as block helper path would make it non-PathExpression. # But this would likely fail at the endblock name matching anyway. # This is likely defensive code that's hard to reach from valid templates. pass # --- Additional path with multiple segments after 'this' (line 644->661) --- def test_path_this_with_multiple_segments(): """Path with 'this' followed by multiple dotted segments.""" ast = parse('{{this.a.b.c}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, PathExpression) assert mustache.path.is_this is True assert mustache.path.parts == ['a', 'b', 'c'] # --- Path with slash segments (line 661 while loop) --- def test_path_with_slash_segments(): """Path using slash separators.""" ast = parse('{{a/b/c}}') mustache = ast.body[0] assert isinstance(mustache, MustacheStatement) assert isinstance(mustache.path, PathExpression) assert mustache.path.parts == ['a', 'b', 'c'] # --- Block where _parse_body loops back (line 114->107) with content in block --- def test_block_body_with_multiple_statements(): """Block body with multiple content and expression statements. This exercises the loop continuation in _parse_body. """ ast = parse('{{#if a}}hello {{name}} world{{/if}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert len(block.body.body) == 3 # content, mustache, content # --- Endblock name with empty close (line 486->exit) --- def test_endblock_with_simple_close(): """Block close where the endblock name is just a simple ID.""" ast = parse('{{#with ctx}}content{{/with}}') block = ast.body[0] assert isinstance(block, BlockStatement) assert isinstance(block.path, PathExpression) assert block.path.original == 'with' pydantic-handlebars-0.2.1/tests/test_pydantic_integration.py000066400000000000000000000131561520472041100244210ustar00rootroot00000000000000"""Tests for Pydantic integration with typed template rendering.""" from datetime import datetime import pytest pytest.importorskip('pydantic') from pydantic import BaseModel from pydantic_handlebars import ( CompiledTypedTemplate, HandlebarsEnvironment, TemplateSchemaError, compile, typed_compiler, ) class User(BaseModel): name: str age: int class Address(BaseModel): city: str country: str class UserWithAddress(BaseModel): name: str address: Address class UserWithOptional(BaseModel): name: str email: str | None = None class Event(BaseModel): title: str date: datetime class Animal(BaseModel): name: str class Dog(Animal): breed: str # --- compile(source, tp) one-shot API --- def test_compile_typed_valid(): template = compile('Hello {{name}}, age {{age}}!', User) assert isinstance(template, CompiledTypedTemplate) def test_compile_typed_invalid(): with pytest.raises(TemplateSchemaError): compile('Hello {{missing}}!', User) def test_compile_typed_render(): template = compile('Hello {{name}}, age {{age}}!', User) result = template.render(User(name='Alice', age=30)) assert result == 'Hello Alice, age 30!' def test_compile_typed_validate_and_render(): template = compile('Hello {{name}}!', User) result = template.validate_and_render({'name': 'Bob', 'age': 25}) assert result == 'Hello Bob!' def test_compile_typed_validate_invalid(): template = compile('Hello {{name}}!', User) with pytest.raises(Exception): template.validate_and_render({'invalid': True}) def test_compile_typed_nested(): template = compile('{{name}} lives in {{address.city}}', UserWithAddress) result = template.render(UserWithAddress(name='Alice', address=Address(city='NYC', country='US'))) assert result == 'Alice lives in NYC' def test_compile_typed_optional(): template = compile('{{name}} - {{email}}', UserWithOptional) result = template.render(UserWithOptional(name='Alice', email='alice@example.com')) assert result == 'Alice - alice@example.com' # --- typed_compiler(tp) cached API --- def test_typed_compiler_basic(): compiler = typed_compiler(User) template = compiler.compile('Hello {{name}}, age {{age}}!') result = template.render(User(name='Alice', age=30)) assert result == 'Hello Alice, age 30!' def test_typed_compiler_json_schema(): compiler = typed_compiler(User) schema = compiler.json_schema assert 'properties' in schema assert 'name' in schema['properties'] assert 'age' in schema['properties'] def test_typed_compiler_no_raise(): compiler = typed_compiler(User) template = compiler.compile('Hello {{missing}}!', raise_on_error=False) assert isinstance(template, CompiledTypedTemplate) def test_typed_compiler_with_env(): env = HandlebarsEnvironment() @env.helper def shout(*args: object, **kwargs: object) -> str: return str(args[0]).upper() + '!!!' _ = shout # used via decorator registration compiler = typed_compiler(User, env=env) template = compiler.compile('{{shout name}}') result = template.render(User(name='Alice', age=30)) assert result == 'ALICE!!!' def test_typed_compiler_multiple_templates(): compiler = typed_compiler(User) greeting = compiler.compile('Hello {{name}}!') info = compiler.compile('{{name}} is {{age}}.') user = User(name='Alice', age=30) assert greeting.render(user) == 'Hello Alice!' assert info.render(user) == 'Alice is 30.' # --- mode='json' serialization --- def test_render_serializes_datetime_to_string(): """render() uses mode='json', so datetime becomes an ISO string.""" template = compile('Event: {{title}} on {{date}}', Event) event = Event(title='Launch', date=datetime(2024, 1, 15, 10, 30)) result = template.render(event) assert '2024-01-15' in result assert 'Launch' in result def test_validate_and_render_serializes_datetime_to_string(): """validate_and_render() also uses mode='json', so datetime becomes an ISO string.""" template = compile('Event: {{title}} on {{date}}', Event) result = template.validate_and_render({'title': 'Launch', 'date': '2024-01-15T10:30:00'}) assert '2024-01-15' in result assert 'Launch' in result # --- serialize_as_any --- def test_serialize_as_any_default_strips_subclass_fields(): """By default, subclass fields are stripped during serialization.""" compiler = typed_compiler(Animal) template = compiler.compile('{{breed}}', raise_on_error=False) result = template.render(Dog(name='Rex', breed='Labrador')) assert result == '' def test_serialize_as_any_via_compile(): """compile() accepts serialize_as_any and threads it to the template.""" template = compile('{{name}}', Animal, serialize_as_any=True) result = template.render(Dog(name='Rex', breed='Labrador')) assert result == 'Rex' def test_serialize_as_any_via_typed_compiler(): """serialize_as_any=True via typed_compiler() preserves subclass fields.""" compiler = typed_compiler(Animal, serialize_as_any=True) template = compiler.compile('{{name}} ({{breed}})', raise_on_error=False) result = template.render(Dog(name='Rex', breed='Labrador')) assert result == 'Rex (Labrador)' def test_serialize_as_any_validate_and_render(): """serialize_as_any=True with validate_and_render preserves subclass fields.""" compiler = typed_compiler(Animal, serialize_as_any=True) template = compiler.compile('{{name}} ({{breed}})', raise_on_error=False) result = template.validate_and_render(Dog(name='Rex', breed='Labrador')) assert result == 'Rex (Labrador)' pydantic-handlebars-0.2.1/tests/test_regressions.py000066400000000000000000000062031520472041100225410ustar00rootroot00000000000000"""Regression tests. Ported from handlebars.js spec/regressions.js (relevant parts). """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false from pydantic_handlebars import HandlebarsEnvironment, render def test_rendering_undefined_doesnt_crash(): assert render('{{foo}}', {}) == '' def test_rendering_null_doesnt_crash(): assert render('{{foo}}', {'foo': None}) == '' def test_if_with_empty_dict_is_truthy(): """Empty dict should be truthy in Handlebars.""" assert render('{{#if obj}}yes{{else}}no{{/if}}', {'obj': {}}) == 'yes' def test_nested_undefined_path_doesnt_crash(): assert render('{{foo.bar.baz}}', {}) == '' assert render('{{foo.bar.baz}}', {'foo': {}}) == '' assert render('{{foo.bar.baz}}', {'foo': {'bar': {}}}) == '' def test_each_with_string_context(): """Each should handle non-iterable gracefully.""" assert render('{{#each val}}x{{else}}empty{{/each}}', {'val': 'string'}) == 'empty' def test_with_false_context(): """With should show else for falsy values.""" assert render('{{#with val}}yes{{else}}no{{/with}}', {'val': False}) == 'no' assert render('{{#with val}}yes{{else}}no{{/with}}', {'val': None}) == 'no' assert render('{{#with val}}yes{{else}}no{{/with}}', {'val': ''}) == 'no' assert render('{{#with val}}yes{{else}}no{{/with}}', {'val': 0}) == 'no' def test_if_with_dict_is_truthy(): assert render('{{#if val}}yes{{else}}no{{/if}}', {'val': {'key': 'value'}}) == 'yes' def test_multiple_comments(): assert render('{{! one }}{{! two }}hello', {}) == 'hello' def test_comment_inside_content(): assert render('before{{! comment }}after', {}) == 'beforeafter' def test_each_empty_body(): assert render('{{#each items}}{{/each}}', {'items': [1, 2, 3]}) == '' def test_nested_each_with_parent_context(): template = '{{#each items}}{{#each sub}}{{../../name}}{{/each}}{{/each}}' data = {'items': [{'sub': [1]}], 'name': 'root'} assert render(template, data) == 'root' def test_helper_with_string_arg(): env = HandlebarsEnvironment() env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') assert env.render('{{echo "hello world"}}', {}) == 'hello world' def test_block_with_numeric_path_segment(): """Closing block tag should accept NUMBER tokens in dot-notation paths.""" template = '{{#items.0}}{{name}}{{/items.0}}' data = {'items': [{'name': 'first'}]} assert render(template, data) == 'first' def test_block_with_slash_separator(): """Closing block tag should preserve slash separators in path matching.""" template = '{{#a/b}}{{c}}{{/a/b}}' data = {'a': {'b': {'c': 'yes'}}} assert render(template, data) == 'yes' def test_block_with_slash_numeric_path(): """Closing block tag should handle slash separators with numeric segments.""" template = '{{#items/0}}{{name}}{{/items/0}}' data = {'items': [{'name': 'first'}]} assert render(template, data) == 'first' def test_whitespace_in_expressions(): assert render('{{ foo }}', {'foo': 'bar'}) == 'bar' def test_newline_in_expression(): assert render('{{\nfoo\n}}', {'foo': 'bar'}) == 'bar' pydantic-handlebars-0.2.1/tests/test_schema.py000066400000000000000000001511511520472041100214410ustar00rootroot00000000000000"""Tests for JSON Schema template compatibility checking.""" from typing import Any import pytest from pydantic_handlebars import ( CompatibilityResult, HandlebarsParseError, TemplateIssue, TemplateSchemaError, check_template_compatibility, ) # --- Basic field validation --- def test_valid_simple_field(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{name}}', schema) assert result.is_compatible assert result.issues == [] def test_invalid_field(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{missing}}', schema) assert not result.is_compatible assert len(result.issues) == 1 assert result.issues[0].severity == 'error' assert result.issues[0].field_path == 'missing' def test_multiple_valid_fields(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}}, 'required': ['name', 'age'], } result = check_template_compatibility('{{name}} is {{age}}', schema) assert result.is_compatible def test_multiple_invalid_fields(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{foo}} {{bar}}', schema) assert not result.is_compatible assert len(result.issues) == 2 # --- Nested paths --- def test_nested_path(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}}}, 'required': ['person'], } result = check_template_compatibility('{{person.name}}', schema) assert result.is_compatible def test_invalid_nested_path(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}}}, 'required': ['person'], } result = check_template_compatibility('{{person.email}}', schema) assert not result.is_compatible def test_deeply_nested_path(): schema = { 'type': 'object', 'properties': { 'a': { 'type': 'object', 'properties': {'b': {'type': 'object', 'properties': {'c': {'type': 'string'}}}}, } }, 'required': ['a'], } result = check_template_compatibility('{{a.b.c}}', schema) assert result.is_compatible # --- $ref resolution --- def test_ref_resolution(): schema = { 'type': 'object', 'properties': {'address': {'$ref': '#/$defs/Address'}}, 'required': ['address'], '$defs': {'Address': {'type': 'object', 'properties': {'city': {'type': 'string'}}, 'required': ['city']}}, } result = check_template_compatibility('{{address.city}}', schema) assert result.is_compatible def test_ref_invalid_field(): schema = { 'type': 'object', 'properties': {'address': {'$ref': '#/$defs/Address'}}, 'required': ['address'], '$defs': {'Address': {'type': 'object', 'properties': {'city': {'type': 'string'}}, 'required': ['city']}}, } result = check_template_compatibility('{{address.zipcode}}', schema) assert not result.is_compatible def test_definitions_style_ref(): schema = { 'type': 'object', 'properties': {'item': {'$ref': '#/definitions/Item'}}, 'required': ['item'], 'definitions': {'Item': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, } result = check_template_compatibility('{{item.name}}', schema) assert result.is_compatible def test_circular_ref(): schema = { 'type': 'object', 'properties': {'node': {'$ref': '#/$defs/Node'}}, 'required': ['node'], '$defs': { 'Node': { 'type': 'object', 'properties': {'child': {'$ref': '#/$defs/Node'}, 'value': {'type': 'string'}}, 'required': ['value'], } }, } result = check_template_compatibility('{{node.value}}', schema) assert result.is_compatible # --- #each --- def test_each_array(): schema = { 'type': 'object', 'properties': { 'items': {'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}} }, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{name}}{{/each}}', schema) assert result.is_compatible def test_each_array_invalid_field(): schema = { 'type': 'object', 'properties': { 'items': {'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}} }, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{missing}}{{/each}}', schema) assert not result.is_compatible def test_each_object_with_additional_properties(): schema = { 'type': 'object', 'properties': { 'mapping': { 'type': 'object', 'additionalProperties': {'type': 'object', 'properties': {'val': {'type': 'string'}}}, } }, 'required': ['mapping'], } result = check_template_compatibility('{{#each mapping}}{{val}}{{/each}}', schema) assert result.is_compatible def test_each_object_additional_properties_true(): schema = { 'type': 'object', 'properties': {'data': {'type': 'object', 'additionalProperties': True}}, 'required': ['data'], } # Body should be opaque since additionalProperties is True result = check_template_compatibility('{{#each data}}{{anything}}{{/each}}', schema) assert result.is_compatible def test_each_with_inverse(): schema = { 'type': 'object', 'properties': { 'items': {'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}} }, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{name}}{{else}}No items{{/each}}', schema) assert result.is_compatible # --- #with --- def test_with_valid(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#with person}}{{name}}{{/with}}', schema) assert result.is_compatible def test_with_invalid_field(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#with person}}{{email}}{{/with}}', schema) assert not result.is_compatible def test_with_inverse(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#with person}}{{name}}{{else}}No person{{/with}}', schema) assert result.is_compatible # --- #if / #unless --- def test_if_same_scope(): schema = { 'type': 'object', 'properties': {'active': {'type': 'boolean'}, 'name': {'type': 'string'}}, 'required': ['active', 'name'], } result = check_template_compatibility('{{#if active}}{{name}}{{/if}}', schema) assert result.is_compatible def test_if_with_else(): schema = { 'type': 'object', 'properties': {'active': {'type': 'boolean'}, 'name': {'type': 'string'}}, 'required': ['active', 'name'], } result = check_template_compatibility('{{#if active}}{{name}}{{else}}inactive{{/if}}', schema) assert result.is_compatible def test_unless(): schema = { 'type': 'object', 'properties': {'disabled': {'type': 'boolean'}, 'name': {'type': 'string'}}, 'required': ['disabled', 'name'], } result = check_template_compatibility('{{#unless disabled}}{{name}}{{/unless}}', schema) assert result.is_compatible def test_if_validates_condition(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#if missing}}show{{/if}}', schema) assert not result.is_compatible # --- Parent refs (../) --- def test_parent_ref(): schema = { 'type': 'object', 'properties': { 'title': {'type': 'string'}, 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, }, }, 'required': ['title', 'items'], } result = check_template_compatibility('{{#each items}}{{../title}}{{/each}}', schema) assert result.is_compatible def test_parent_ref_invalid(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, }, }, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{../missing}}{{/each}}', schema) assert not result.is_compatible # --- @root --- def test_root_ref(): schema = { 'type': 'object', 'properties': { 'title': {'type': 'string'}, 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, }, }, 'required': ['title', 'items'], } result = check_template_compatibility('{{#each items}}{{@root.title}}{{/each}}', schema) assert result.is_compatible def test_root_ref_invalid(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{@root.missing}}', schema) assert not result.is_compatible # --- @data vars --- def test_data_vars_skipped(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#each items}}{{@index}}{{@key}}{{@first}}{{@last}}{{/each}}', schema) # @data vars are skipped, but 'items' is not in schema assert not result.is_compatible # Only one issue for 'items' assert len(result.issues) == 1 def test_bare_data_path_parse_error(): """{{@}} with no parts raises a parse error.""" schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} with pytest.raises(HandlebarsParseError): check_template_compatibility('{{@}}', schema) # --- Block params --- def test_block_params_shadow(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, } }, 'required': ['items'], } result = check_template_compatibility('{{#each items as |item|}}{{item.name}}{{/each}}', schema) # Block param shadows lookup — should be valid (opaque) assert result.is_compatible # --- default helper --- def test_default_helper_downgrades_severity(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, } # Without default — uses optional_field_severity result = check_template_compatibility('{{nickname}}', schema, optional_field_severity='error') assert not result.is_compatible # With default — severity should be downgraded to INFO result = check_template_compatibility( '{{default nickname "N/A"}}', schema, optional_field_severity='error', helpers={'default'} ) assert result.is_compatible assert len(result.issues) == 1 assert result.issues[0].severity == 'ignore' # --- Custom helpers --- def test_custom_helper_known(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{formatDate name}}', schema, helpers={'formatDate'}) assert result.is_compatible def test_custom_helper_params_validated(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{formatDate missing}}', schema, helpers={'formatDate'}) assert not result.is_compatible def test_custom_block_helper_opaque_body(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#custom}}{{anything}}{{/custom}}', schema, helpers={'custom'}) assert result.is_compatible # --- lookup helper --- def test_lookup_helper(): schema = {'type': 'object', 'properties': {'obj': {'type': 'object'}}, 'required': ['obj']} result = check_template_compatibility('{{lookup obj "key"}}', schema) assert result.is_compatible # --- additionalProperties --- def test_additional_properties_true(): schema = {'type': 'object', 'additionalProperties': True} result = check_template_compatibility('{{anything}}', schema) assert result.is_compatible def test_additional_properties_schema(): schema = { 'type': 'object', 'additionalProperties': {'type': 'string'}, } result = check_template_compatibility('{{anything}}', schema) assert result.is_compatible # --- optional_field_severity --- def test_optional_field_severity_warning(): # 'nickname' is NOT in properties — it's an unknown field # optional_field_severity applies when the schema has properties but the field isn't in required schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}, 'email': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{nickname}}', schema, optional_field_severity='warning') assert result.is_compatible assert len(result.issues) == 1 assert result.issues[0].severity == 'warning' def test_optional_field_severity_ignore(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}, 'email': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{nickname}}', schema, optional_field_severity='ignore') assert result.is_compatible # --- raise_on_error --- def test_raise_on_error(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} with pytest.raises(TemplateSchemaError) as exc_info: check_template_compatibility('{{missing}}', schema, raise_on_error=True) assert exc_info.value.result is not None assert not exc_info.value.result.is_compatible def test_raise_on_error_compatible(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{name}}', schema, raise_on_error=True) assert result.is_compatible # --- Multi-template --- def test_multi_template(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility(['{{name}}', '{{name}}'], schema) assert result.is_compatible def test_multi_template_one_invalid(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility(['{{name}}', '{{missing}}'], schema) assert not result.is_compatible assert result.issues[0].template_index == 1 # --- this references --- def test_this_reference(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{this.name}}', schema) assert result.is_compatible def test_this_bare(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}} result = check_template_compatibility('{{this}}', schema) assert result.is_compatible # --- Subexpressions --- def test_subexpression_params_validated(): schema = {'type': 'object', 'properties': {'val': {'type': 'string'}}, 'required': ['val']} result = check_template_compatibility('{{uppercase (trim val)}}', schema, helpers={'uppercase', 'trim'}) assert result.is_compatible def test_subexpression_invalid_param(): schema = {'type': 'object', 'properties': {'val': {'type': 'string'}}, 'required': ['val']} result = check_template_compatibility('{{uppercase (trim missing)}}', schema, helpers={'uppercase', 'trim'}) assert not result.is_compatible # --- Chained else-if --- def test_chained_else_if(): schema = { 'type': 'object', 'properties': {'a': {'type': 'boolean'}, 'b': {'type': 'boolean'}, 'name': {'type': 'string'}}, 'required': ['a', 'b', 'name'], } result = check_template_compatibility('{{#if a}}A{{else if b}}B{{else}}C{{/if}}', schema) assert result.is_compatible # --- Context-based blocks --- def test_context_block(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#person}}{{name}}{{/person}}', schema) assert result.is_compatible def test_context_block_invalid(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#person}}{{email}}{{/person}}', schema) assert not result.is_compatible def test_context_block_missing(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#missing}}{{foo}}{{/missing}}', schema) assert not result.is_compatible # --- Empty schema --- def test_empty_schema(): result = check_template_compatibility('{{anything}}', {}) assert result.is_compatible # --- Nullable / anyOf with null --- def test_nullable_field(): schema = { 'type': 'object', 'properties': { 'value': {'anyOf': [{'type': 'object', 'properties': {'inner': {'type': 'string'}}}, {'type': 'null'}]} }, 'required': ['value'], } result = check_template_compatibility('{{value.inner}}', schema) assert result.is_compatible def test_anyof_union(): schema = { 'type': 'object', 'properties': { 'data': { 'anyOf': [ {'type': 'object', 'properties': {'x': {'type': 'string'}}}, {'type': 'object', 'properties': {'y': {'type': 'string'}}}, ] } }, 'required': ['data'], } # Property valid if found in any variant result = check_template_compatibility('{{data.x}}', schema) assert result.is_compatible result = check_template_compatibility('{{data.y}}', schema) assert result.is_compatible # --- Type access errors --- def test_property_access_on_non_object(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{name.foo}}', schema) assert not result.is_compatible assert 'Cannot access property' in result.issues[0].message # --- CompatibilityResult / TemplateIssue --- def test_compatibility_result_is_compatible_no_issues(): result = CompatibilityResult(issues=[]) assert result.is_compatible def test_compatibility_result_with_warning_only(): result = CompatibilityResult( issues=[TemplateIssue(severity='warning', message='test', field_path='x', template_index=0)] ) assert result.is_compatible def test_compatibility_result_with_error(): result = CompatibilityResult( issues=[TemplateIssue(severity='error', message='test', field_path='x', template_index=0)] ) assert not result.is_compatible # --- Hash arguments (no validation needed) --- def test_hash_arguments(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{log name level="info"}}', schema) assert result.is_compatible # --- @root bare --- def test_root_bare(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{@root}}', schema) assert result.is_compatible # --- Content and comments --- def test_content_only(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}} result = check_template_compatibility('Hello world!', schema) assert result.is_compatible def test_comments_ignored(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}} result = check_template_compatibility('{{! comment }}{{name}}', schema) assert result.is_compatible # --- Built-in helpers are recognized --- def test_builtin_helpers(): schema = { 'type': 'object', 'properties': {'items': {'type': 'array', 'items': {'type': 'string'}}}, 'required': ['items'], } # Standard helpers (if, each, with, etc.) are always known result = check_template_compatibility('{{log items}}', schema) assert result.is_compatible # --- if with params --- def test_if_with_param(): schema = { 'type': 'object', 'properties': {'show': {'type': 'boolean'}, 'name': {'type': 'string'}}, 'required': ['show', 'name'], } result = check_template_compatibility('{{#if show}}{{name}}{{/if}}', schema) assert result.is_compatible # --- oneOf --- def test_oneof_union(): schema = { 'type': 'object', 'properties': { 'data': { 'oneOf': [ {'type': 'object', 'properties': {'a': {'type': 'string'}}}, {'type': 'object', 'properties': {'b': {'type': 'string'}}}, ] } }, 'required': ['data'], } result = check_template_compatibility('{{data.a}}', schema) assert result.is_compatible # --- each over data var --- def test_each_over_data_var(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#each @root.items}}{{this}}{{/each}}', schema) # @root.items will fail validation since items isn't in schema assert not result.is_compatible # --- with over literal --- def test_with_literal_param(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#with "hello"}}{{this}}{{/with}}', schema) assert result.is_compatible # --- each over literal --- def test_each_literal_param(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#each "hello"}}{{this}}{{/each}}', schema) assert result.is_compatible # --- context-based block with inverse --- def test_context_block_with_inverse(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#person}}{{name}}{{else}}No person{{/person}}', schema) assert result.is_compatible # --- with no params --- def test_with_no_params(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{#with}}{{name}}{{/with}}', schema) assert result.is_compatible # --- with data param --- def test_with_data_param(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#with @root}}{{x}}{{/with}}', schema) assert result.is_compatible # --- with block params --- def test_with_block_params(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}}, 'required': ['person'], } result = check_template_compatibility('{{#with person as |p|}}{{p.name}}{{/with}}', schema) assert result.is_compatible # --- each no params --- def test_each_no_params(): schema = { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, } result = check_template_compatibility('{{#each}}{{name}}{{/each}}', schema) assert result.is_compatible # --- each data path --- def test_each_data_path(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#each @root}}{{this}}{{/each}}', schema) assert result.is_compatible # --- with inverse for non-resolving path --- def test_with_unresolved_path(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#with missing}}{{x}}{{else}}fallback{{/with}}', schema) assert not result.is_compatible # --- block with non-path expression --- def test_block_non_path(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}} # This isn't a valid handlebars template normally, but test the edge case # where stmt.path might be a subexpression in a block — the checker should skip it result = check_template_compatibility('{{x}}', schema) assert result.is_compatible # --- custom block helper with inverse --- def test_custom_block_helper_with_inverse(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#custom}}body{{else}}inverse{{/custom}}', schema, helpers={'custom'}) assert result.is_compatible # --- unless validates condition --- def test_unless_validates_condition(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#unless missing}}show{{/unless}}', schema) assert not result.is_compatible # --- ref chain --- def test_ref_chain(): schema = { 'type': 'object', 'properties': {'a': {'$ref': '#/$defs/A'}}, 'required': ['a'], '$defs': { 'A': {'$ref': '#/$defs/B'}, 'B': {'type': 'object', 'properties': {'val': {'type': 'string'}}, 'required': ['val']}, }, } result = check_template_compatibility('{{a.val}}', schema) assert result.is_compatible # --- unknown ref --- def test_unknown_ref(): schema = { 'type': 'object', 'properties': {'a': {'$ref': '#/$defs/Unknown'}}, 'required': ['a'], '$defs': {}, } # Unknown $ref — the schema has a $ref but it can't be resolved, # so the original schema (with $ref) is returned. Accessing a property # on it should be valid since there are no properties defined. result = check_template_compatibility('{{a.foo}}', schema) assert result.is_compatible # --- external ref --- def test_external_ref(): schema = { 'type': 'object', 'properties': {'a': {'$ref': 'external.json#/Foo'}}, 'required': ['a'], } result = check_template_compatibility('{{a.foo}}', schema) assert result.is_compatible # --- non-string ref --- def test_non_string_ref(): schema = { 'type': 'object', 'properties': {'a': {'$ref': 123}}, 'required': ['a'], } result = check_template_compatibility('{{a.foo}}', schema) assert result.is_compatible # --- each unresolved path --- def test_each_unresolved_path(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#each missing}}{{x}}{{/each}}', schema) assert not result.is_compatible # --- parent ref beyond root --- def test_parent_ref_beyond_root(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{../name}}', schema) assert result.is_compatible # --- each without items key --- def test_each_non_array(): schema = { 'type': 'object', 'properties': {'data': {'type': 'string'}}, 'required': ['data'], } result = check_template_compatibility('{{#each data}}{{this}}{{/each}}', schema) # data is string, not array — each will make inner scope opaque assert result.is_compatible # --- Schema with type: object but no properties --- def test_object_no_properties(): schema = {'type': 'object'} result = check_template_compatibility('{{anything}}', schema) assert not result.is_compatible # --- additionalProperties in union variant --- def test_additional_properties_in_union(): schema = { 'type': 'object', 'properties': { 'data': { 'anyOf': [ {'type': 'object', 'additionalProperties': True}, {'type': 'null'}, ] } }, 'required': ['data'], } result = check_template_compatibility('{{data.anything}}', schema) assert result.is_compatible # --- with unresolved path and inverse --- def test_with_unresolved_inverse(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#with missing}}{{x}}{{else}}{{name}}{{/with}}', schema) assert not result.is_compatible # Should have one issue for 'missing' error_issues = [i for i in result.issues if i.severity == 'error'] assert len(error_issues) == 1 # --- each unresolved with inverse --- def test_each_unresolved_inverse(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#each missing}}{{x}}{{else}}{{name}}{{/each}}', schema) assert not result.is_compatible # --- with block params for with --- def test_with_block_params_data(): schema = { 'type': 'object', 'properties': {'person': {'type': 'object', 'properties': {'name': {'type': 'string'}}}}, } result = check_template_compatibility('{{#with @root as |ctx|}}{{ctx}}{{/with}}', schema) assert result.is_compatible # --- mustache with subexpression path --- def test_mustache_subexpression_path(): schema = {'type': 'object', 'properties': {'val': {'type': 'string'}}, 'required': ['val']} result = check_template_compatibility('{{(lookup val "key")}}', schema) assert result.is_compatible # --- opaque parent scope --- def test_opaque_parent_scope(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility( '{{#custom}}{{#if true}}{{../whatever}}{{/if}}{{/custom}}', schema, helpers={'custom'} ) assert result.is_compatible # --- Coverage: inverse blocks --- def test_each_no_params_with_inverse(): schema = { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, } result = check_template_compatibility('{{#each}}{{name}}{{else}}empty{{/each}}', schema) assert result.is_compatible def test_each_data_path_with_inverse(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#each @root}}{{this}}{{else}}empty{{/each}}', schema) assert result.is_compatible def test_each_literal_with_inverse(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#each "hello"}}{{this}}{{else}}empty{{/each}}', schema) assert result.is_compatible def test_with_no_params_with_inverse(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{#with}}{{name}}{{else}}empty{{/with}}', schema) assert result.is_compatible def test_with_data_param_with_inverse(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#with @root}}{{x}}{{else}}empty{{/with}}', schema) assert result.is_compatible def test_with_literal_with_inverse(): schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{#with "hello"}}{{this}}{{else}}empty{{/with}}', schema) assert result.is_compatible def test_context_block_missing_with_inverse(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#missing}}{{foo}}{{else}}fallback{{/missing}}', schema) assert not result.is_compatible def test_with_unresolved_no_inverse(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{#with missing}}{{x}}{{/with}}', schema) assert not result.is_compatible # --- Coverage: data var as helper param --- def test_data_var_as_helper_param(): schema = { 'type': 'object', 'properties': {'items': {'type': 'array', 'items': {'type': 'string'}}}, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{log @index}}{{/each}}', schema) assert result.is_compatible # --- Coverage: block param in parent scope --- def test_block_param_in_parent_scope(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, } }, 'required': ['items'], } result = check_template_compatibility('{{#each items as |item|}}{{#if item}}{{item.name}}{{/if}}{{/each}}', schema) assert result.is_compatible # --- Coverage: block param in _resolve_path_to_schema --- def test_block_param_in_with(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, } }, 'required': ['items'], } result = check_template_compatibility('{{#each items as |item|}}{{#with item}}{{name}}{{/with}}{{/each}}', schema) assert result.is_compatible def test_block_param_in_parent_scope_for_resolve(): schema = { 'type': 'object', 'properties': { 'outer': { 'type': 'array', 'items': { 'type': 'object', 'properties': { 'inner': { 'type': 'array', 'items': {'type': 'object', 'properties': {'val': {'type': 'string'}}}, } }, }, } }, 'required': ['outer'], } # The inner each references outer's block param via with result = check_template_compatibility( '{{#each outer as |o|}}{{#each inner}}{{#with o}}{{val}}{{/with}}{{/each}}{{/each}}', schema ) assert result.is_compatible # --- Coverage: resolve_path_to_schema with depth > 0 --- def test_resolve_path_depth_in_with(): schema = { 'type': 'object', 'properties': { 'title': {'type': 'string'}, 'items': { 'type': 'array', 'items': {'type': 'object', 'properties': {'name': {'type': 'string'}}}, }, }, 'required': ['title', 'items'], } # ../title inside #each, then used in #with to resolve through parent result = check_template_compatibility('{{#each items}}{{#with ../title}}{{this}}{{/with}}{{/each}}', schema) assert result.is_compatible # --- Coverage: resolve_path_to_schema opaque scope --- def test_resolve_path_opaque_scope(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} # custom helper makes scope opaque, then #with tries to resolve in opaque scope result = check_template_compatibility( '{{#custom}}{{#with something}}{{x}}{{/with}}{{/custom}}', schema, helpers={'custom'} ) assert result.is_compatible # --- Coverage: resolve_path_to_schema this --- def test_resolve_path_this_in_with(): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{#with this}}{{name}}{{/with}}', schema) assert result.is_compatible # --- Coverage: resolve_path_to_schema opaque target via depth --- def test_resolve_path_opaque_target_via_depth(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} # custom makes scope opaque; inner each has depth > 0 which targets the opaque scope result = check_template_compatibility( '{{#custom}}{{#each items}}{{#with ../something}}{{x}}{{/with}}{{/each}}{{/custom}}', schema, helpers={'custom'}, ) assert result.is_compatible # --- Coverage: circular ref actually triggered --- def test_circular_ref_deep_traversal(): schema = { 'type': 'object', 'properties': {'node': {'$ref': '#/$defs/Node'}}, 'required': ['node'], '$defs': { 'Node': { 'type': 'object', 'properties': {'child': {'$ref': '#/$defs/Node'}, 'value': {'type': 'string'}}, 'required': ['value'], } }, } # Access nested child which triggers circular ref resolution result = check_template_compatibility('{{node.child.value}}', schema) assert result.is_compatible # --- Coverage: union with 3+ variants including null --- def test_union_three_variants_with_null(): schema = { 'type': 'object', 'properties': { 'data': { 'anyOf': [ {'type': 'object', 'properties': {'x': {'type': 'string'}}}, {'type': 'object', 'properties': {'y': {'type': 'string'}}}, {'type': 'null'}, ] } }, 'required': ['data'], } result = check_template_compatibility('{{data.x}}', schema) assert result.is_compatible # --- Coverage: union variant with additionalProperties --- def test_union_variant_additional_properties_true(): schema = { 'type': 'object', 'properties': { 'data': { 'anyOf': [ {'type': 'object', 'additionalProperties': True}, {'type': 'object', 'properties': {'y': {'type': 'string'}}}, ] } }, 'required': ['data'], } result = check_template_compatibility('{{data.anything}}', schema) assert result.is_compatible def test_union_variant_additional_properties_dict(): schema = { 'type': 'object', 'properties': { 'data': { 'anyOf': [ {'type': 'object', 'additionalProperties': {'type': 'string'}}, {'type': 'object', 'properties': {'y': {'type': 'string'}}}, ] } }, 'required': ['data'], } result = check_template_compatibility('{{data.anything}}', schema) assert result.is_compatible # --- Coverage: default helper edge cases --- def test_default_helper_no_params(): schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']} result = check_template_compatibility('{{default}}', schema, helpers={'default'}) assert result.is_compatible def test_default_helper_data_param(): schema = { 'type': 'object', 'properties': { 'items': {'type': 'array', 'items': {'type': 'string'}}, }, 'required': ['items'], } result = check_template_compatibility( '{{#each items}}{{default @root.fallback "N/A"}}{{/each}}', schema, helpers={'default'} ) # default helper downgrades severity to 'ignore', so is_compatible is True assert result.is_compatible assert len(result.issues) == 1 assert result.issues[0].severity == 'ignore' def test_default_helper_subexpression_param(): schema = {'type': 'object', 'properties': {'val': {'type': 'string'}}, 'required': ['val']} result = check_template_compatibility('{{default (lookup val "key") "N/A"}}', schema, helpers={'default'}) assert result.is_compatible # --- Coverage: known helper as dotted path --- def test_known_helper_as_dotted_path(): schema = { 'type': 'object', 'properties': {'log': {'type': 'object', 'properties': {'level': {'type': 'string'}}}}, 'required': ['log'], } # 'log' is a known helper but used as {{log.level}} — should check as path result = check_template_compatibility('{{log.level}}', schema) assert result.is_compatible # --- Coverage: each array items non-dict --- def test_each_array_items_non_dict(): schema = { 'type': 'object', 'properties': { 'items': {'type': 'array', 'items': True}, }, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{this}}{{/each}}', schema) assert result.is_compatible # --- Coverage: anyOf/oneOf falls through to properties --- def test_anyof_fallthrough_to_properties(): schema = { 'type': 'object', 'properties': { 'data': { 'anyOf': [ {'type': 'string'}, {'type': 'integer'}, ], 'properties': {'x': {'type': 'string'}}, } }, 'required': ['data'], } # anyOf variants don't have properties, but the schema itself does result = check_template_compatibility('{{data.x}}', schema) assert result.is_compatible # --- Coverage: parent ref in resolve_path_to_schema with depth --- def test_each_parent_ref_in_context_block(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': { 'type': 'object', 'properties': { 'sub': {'type': 'object', 'properties': {'val': {'type': 'string'}}}, }, }, }, }, 'required': ['items'], } result = check_template_compatibility('{{#each items}}{{#sub}}{{val}}{{/sub}}{{/each}}', schema) assert result.is_compatible # --- Literal expressions in mustache position --- def test_literal_true_in_mustache(): """Schema checker should not crash on {{true}}.""" schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}} result = check_template_compatibility('{{true}}', schema) assert result.is_compatible def test_literal_false_in_mustache(): """Schema checker should not crash on {{false}}.""" schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{false}}', schema) assert result.is_compatible def test_literal_null_in_mustache(): """Schema checker should not crash on {{null}}.""" schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{null}}', schema) assert result.is_compatible def test_literal_number_in_mustache(): """Schema checker should not crash on {{42}}.""" schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{42}}', schema) assert result.is_compatible def test_literal_string_in_mustache(): """Schema checker should not crash on {{"hello"}}.""" schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{"hello"}}', schema) assert result.is_compatible def test_literal_undefined_in_mustache(): """Schema checker should not crash on {{undefined}}.""" schema = {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']} result = check_template_compatibility('{{undefined}}', schema) assert result.is_compatible # --- Schema: empty properties vs type object --- def test_empty_properties_allows_any_field(): """{'properties': {}} imposes no restriction on field access. An empty properties dict is falsy in Python, so the checker treats it as having no property constraints — any field access is allowed. """ schema: dict[str, Any] = {'properties': {}} result = check_template_compatibility('{{anything}}', schema) assert result.is_compatible def test_type_object_errors_for_field_access(): """{'type': 'object'} with no properties errors for any {{field}} access. A schema with just type=object and no properties key means no fields are declared, so accessing any field is an error. """ schema = {'type': 'object'} result = check_template_compatibility('{{field}}', schema) assert not result.is_compatible assert len(result.issues) == 1 assert result.issues[0].field_path == 'field' def test_properties_restricts_to_declared_fields(): """{'properties': {'a': ...}} errors for fields not in properties. When properties is non-empty, only declared fields are accessible. """ schema = {'properties': {'a': {'type': 'string'}}} result = check_template_compatibility('{{a}}', schema) assert result.is_compatible result = check_template_compatibility('{{b}}', schema) assert not result.is_compatible assert result.issues[0].field_path == 'b' # --- Extra helpers not known by default --- def test_extra_helper_not_known_by_default(): """Extra helpers (json, eq, default, etc.) should NOT be treated as known helpers unless explicitly passed via the helpers parameter. Regression test: the schema checker previously used get_default_helpers() which included all 22 helpers, but the default environment only registers the 6 standard helpers. """ schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } # 'json' is an extra helper — not known by default result = check_template_compatibility('{{json name}}', schema) # Without helpers={'json'}, 'json' is treated as a field lookup, not a helper. # Since 'json' is not in the schema, this should fail. assert not result.is_compatible # Explicitly passing it makes it known result = check_template_compatibility('{{json name}}', schema, helpers={'json'}) assert result.is_compatible def test_extra_helpers_known_when_passed_explicitly(): """Extra helpers work correctly when passed via the helpers parameter.""" schema = { 'type': 'object', 'properties': {'val': {'type': 'string'}}, 'required': ['val'], } for helper_name in ('json', 'uppercase', 'lowercase', 'trim', 'eq', 'default'): result = check_template_compatibility(f'{{{{{helper_name} val}}}}', schema, helpers={helper_name}) assert result.is_compatible, f'{helper_name} should be recognized when passed explicitly' def test_standard_helpers_always_known(): """Standard helpers (if, each, with, unless, lookup, log) are always known.""" schema = { 'type': 'object', 'properties': {'items': {'type': 'array', 'items': {'type': 'string'}}}, 'required': ['items'], } # lookup and log are standard helpers — always known without passing helpers= result = check_template_compatibility('{{lookup items 0}}', schema) assert result.is_compatible result = check_template_compatibility('{{log items}}', schema) assert result.is_compatible # --- Hash argument value validation --- def test_hash_arg_literal_value(): """Hash args with literal values should not cause issues.""" schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{log name prefix="DEBUG:"}}', schema) assert result.is_compatible def test_hash_arg_valid_field_reference(): """Hash args with valid field path references should pass.""" schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}, 'style': {'type': 'string'}}, 'required': ['name', 'style'], } result = check_template_compatibility('{{log name format=style}}', schema) assert result.is_compatible def test_hash_arg_invalid_field_reference(): """Hash args with invalid field path references should be caught.""" schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{log name format=missingField}}', schema) assert not result.is_compatible assert len(result.issues) == 1 assert result.issues[0].field_path == 'missingField' def test_hash_arg_block_helper(): """Hash args on block helpers should also be validated.""" schema = { 'type': 'object', 'properties': {'items': {'type': 'array', 'items': {'type': 'string'}}}, 'required': ['items'], } result = check_template_compatibility( '{{#myHelper items sort=missingField}}{{this}}{{/myHelper}}', schema, helpers={'myHelper'} ) assert not result.is_compatible def test_hash_arg_subexpression(): """Hash args in subexpressions should be validated.""" schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } result = check_template_compatibility('{{(log name format=missingField)}}', schema) assert not result.is_compatible def test_numeric_index_on_array_schema(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': {'type': 'string'}, }, }, 'required': ['items'], } result = check_template_compatibility('{{items.0}}', schema) assert result.is_compatible assert result.issues == [] def test_numeric_index_nested_array_schema(): schema = { 'type': 'object', 'properties': { 'data': { 'type': 'object', 'properties': { 'users': { 'type': 'array', 'items': { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], }, }, }, 'required': ['users'], }, }, 'required': ['data'], } result = check_template_compatibility('{{data.users.0.name}}', schema) assert result.is_compatible assert result.issues == [] def test_numeric_index_array_without_items_schema(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': True, }, }, 'required': ['items'], } result = check_template_compatibility('{{items.0}}', schema) assert result.is_compatible def test_numeric_index_invalid_field_after(): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], }, }, }, 'required': ['items'], } result = check_template_compatibility('{{items.0.missing}}', schema) assert not result.is_compatible assert len(result.issues) == 1 assert 'missing' in result.issues[0].message def test_hash_arg_default_helper(): """Hash args on the default helper should use relaxed severity.""" schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } # Hash arg referencing a field not in required — should use downgraded severity result = check_template_compatibility( '{{default name fallback=altName}}', schema, helpers={'default'}, optional_field_severity='ignore' ) assert result.is_compatible pydantic-handlebars-0.2.1/tests/test_security.py000066400000000000000000000066531520472041100220560ustar00rootroot00000000000000"""Tests for security features. Ported from handlebars.js spec/security.js, adapted for Python. """ from datetime import datetime import pytest from pydantic_handlebars import HandlebarsRuntimeError, render # --- Dunder keys in dicts are now normal keys (serialization makes them safe) --- def test_dunder_class_key_renders(): assert render('{{__class__}}', {'__class__': 'hacked'}) == 'hacked' def test_dunder_dict_key_renders(): assert render('{{obj.__dict__}}', {'obj': {'__dict__': 'hacked'}}) == 'hacked' def test_dunder_globals_key_renders(): assert render('{{__globals__}}', {'__globals__': 'hacked'}) == 'hacked' def test_dunder_init_key_renders(): assert render('{{obj.__init__}}', {'obj': {'__init__': 'hacked'}}) == 'hacked' def test_dunder_module_key_renders(): assert render('{{__module__}}', {'__module__': 'hacked'}) == 'hacked' def test_dunder_subclasses_key_renders(): assert render('{{__subclasses__}}', {'__subclasses__': 'hacked'}) == 'hacked' def test_constructor_key_renders(): assert render('{{constructor}}', {'constructor': 'hacked'}) == 'hacked' def test_dunder_bases_key_renders(): assert render('{{obj.__bases__}}', {'obj': {'__bases__': 'hacked'}}) == 'hacked' def test_dunder_reduce_key_renders(): assert render('{{obj.__reduce__}}', {'obj': {'__reduce__': 'hacked'}}) == 'hacked' def test_dunder_code_key_renders(): assert render('{{obj.__code__}}', {'obj': {'__code__': 'hacked'}}) == 'hacked' def test_dunder_builtins_key_renders(): assert render('{{__builtins__}}', {'__builtins__': 'hacked'}) == 'hacked' def test_dunder_via_each(): """Dunder keys render normally in each loops.""" assert render('{{#each items}}{{__class__}}{{/each}}', {'items': [{'__class__': 'ok'}]}) == 'ok' # --- Serialization tests --- def test_datetime_serialized(): """datetime objects are serialized to ISO format strings.""" dt = datetime(2024, 1, 15, 10, 30, 0) result = render('{{date}}', {'date': dt}) assert '2024-01-15' in result def test_callable_in_context_raises(): """Callables in context raise serialization errors.""" with pytest.raises(HandlebarsRuntimeError, match='Context serialization failed'): render('{{fn}}', {'fn': lambda: True}) def test_arbitrary_object_in_context_raises(): """Arbitrary non-serializable objects raise serialization errors.""" class Obj: name = 'safe' with pytest.raises(HandlebarsRuntimeError, match='Context serialization failed'): render('{{obj.name}}', {'obj': Obj()}) def test_nested_serialization(): """Nested dicts with serializable values work.""" result = render('{{obj.date}}', {'obj': {'date': datetime(2024, 1, 15)}}) assert '2024-01-15' in result # --- HTML escaping --- def test_html_not_escaped_by_default(): """HTML in values is not escaped by default (auto_escape=False).""" assert render('{{val}}', {'val': ''}) == '' def test_html_injection_escaped_when_enabled(): """HTML in values is escaped when auto_escape=True.""" from pydantic_handlebars import HandlebarsEnvironment env = HandlebarsEnvironment(auto_escape=True) assert env.render('{{val}}', {'val': ''}) == '<script>alert(1)</script>' def test_html_injection_not_escaped_with_triple(): assert render('{{{val}}}', {'val': ''}) == '' pydantic-handlebars-0.2.1/tests/test_strict.py000066400000000000000000000252631520472041100215150ustar00rootroot00000000000000"""Tests for strict mode: raising on missing context references.""" from __future__ import annotations import pytest from pydantic_handlebars import ( HandlebarsEnvironment, HandlebarsRuntimeError, render, ) # --------------------------------------------------------------------------- # Default (non-strict) — behaviour matches Handlebars.js: missing refs render # as the empty string. # --------------------------------------------------------------------------- def test_default_is_non_strict(): assert render('Hello {{missing}}!') == 'Hello !' def test_default_env_is_non_strict(): env = HandlebarsEnvironment() assert env.strict is False assert env.render('{{missing}}', {}) == '' # --------------------------------------------------------------------------- # Strict via the module-level convenience function # --------------------------------------------------------------------------- def test_strict_raises_on_missing_top_level(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'missing'"): render('Hello {{missing}}!', {}, strict=True) def test_strict_allows_explicit_none(): # An explicit `None` value is distinct from "key not present" — strict # mode lets it through, rendering as the empty string. assert render('Hello {{name}}!', {'name': None}, strict=True) == 'Hello !' def test_strict_passes_when_key_present(): assert render('Hello {{name}}!', {'name': 'World'}, strict=True) == 'Hello World!' def test_strict_raises_on_missing_dotted_segment(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'email'"): render('{{user.email}}', {'user': {'name': 'A'}}, strict=True) def test_strict_raises_on_missing_root_of_dotted_path(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'user'"): render('{{user.email}}', {}, strict=True) def test_strict_allows_explicit_none_intermediate_segment(): # Same rule as scalars: if the key is present but the value is `None`, # subsequent segments resolve to `None` and render as empty. assert render('{{user.email}}', {'user': None}, strict=True) == '' # --------------------------------------------------------------------------- # Strict via HandlebarsEnvironment # --------------------------------------------------------------------------- def test_env_strict_property_reflects_config(): assert HandlebarsEnvironment(strict=True).strict is True assert HandlebarsEnvironment(strict=False).strict is False def test_env_strict_applies_to_render(): env = HandlebarsEnvironment(strict=True) with pytest.raises(HandlebarsRuntimeError): env.render('{{missing}}', {}) def test_env_strict_applies_to_compiled_template(): env = HandlebarsEnvironment(strict=True) template = env.compile('{{missing}}') with pytest.raises(HandlebarsRuntimeError): template.render({}) # --------------------------------------------------------------------------- # Block helpers in strict mode # --------------------------------------------------------------------------- def test_strict_raises_on_if_with_missing_condition(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'maybe'"): render('{{#if maybe}}x{{/if}}', {}, strict=True) def test_strict_allows_if_with_explicit_falsy_condition(): assert render('{{#if maybe}}x{{else}}y{{/if}}', {'maybe': None}, strict=True) == 'y' assert render('{{#if maybe}}x{{else}}y{{/if}}', {'maybe': False}, strict=True) == 'y' def test_strict_raises_on_each_with_missing_iterable(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'items'"): render('{{#each items}}x{{/each}}', {}, strict=True) def test_strict_inside_each_uses_iteration_context(): # Strict checks inside `each` operate against each item, not the parent. template = '{{#each items}}{{name}};{{/each}}' assert render(template, {'items': [{'name': 'a'}, {'name': 'b'}]}, strict=True) == 'a;b;' def test_strict_inside_each_raises_when_item_missing_field(): template = '{{#each items}}{{name}};{{/each}}' with pytest.raises(HandlebarsRuntimeError, match="missing context key 'name'"): render(template, {'items': [{'other': 'a'}]}, strict=True) # --------------------------------------------------------------------------- # Block parameters: bound names are not subject to strict context lookup. # --------------------------------------------------------------------------- def test_strict_block_params_bypass_lookup(): template = '{{#each items as |item|}}{{item.name}};{{/each}}' assert render(template, {'items': [{'name': 'a'}]}, strict=True) == 'a;' def test_strict_block_params_raise_on_missing_inner_field(): template = '{{#each items as |item|}}{{item.missing}};{{/each}}' with pytest.raises(HandlebarsRuntimeError, match="missing context key 'missing'"): render(template, {'items': [{'name': 'a'}]}, strict=True) # --------------------------------------------------------------------------- # Data variables (`@index`, etc.) in strict mode # --------------------------------------------------------------------------- def test_strict_allows_known_data_variables(): template = '{{#each items}}{{@index}}:{{name}};{{/each}}' assert render(template, {'items': [{'name': 'a'}, {'name': 'b'}]}, strict=True) == '0:a;1:b;' def test_strict_raises_on_unknown_data_variable(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'nope'"): render('{{@nope}}', {}, strict=True) # --------------------------------------------------------------------------- # Helpers — strict mode applies to helper *arguments*, not helper *names*. # --------------------------------------------------------------------------- def test_strict_does_not_apply_to_helper_names(): # `if` is a registered helper, not a context lookup, so it doesn't # fire strict mode even though no field named `if` exists. assert render('{{#if value}}x{{/if}}', {'value': True}, strict=True) == 'x' def test_strict_applies_to_helper_arguments(): with pytest.raises(HandlebarsRuntimeError, match="missing context key 'missing'"): render('{{#if missing}}x{{/if}}', {}, strict=True) # --------------------------------------------------------------------------- # `this` and root context references # --------------------------------------------------------------------------- def test_strict_passes_for_this(): # `{{this}}` references the current context object as a whole; strict # mode never fires on it. env = HandlebarsEnvironment(strict=True) assert env.render('{{this}}', 'hello') == 'hello' assert env.render('{{this}}', {'k': 'v'}) == "{'k': 'v'}" def test_strict_passes_for_at_root_field_present(): template = '{{#each items}}{{@root.top}};{{/each}}' assert render(template, {'top': 'T', 'items': [1, 2]}, strict=True) == 'T;T;' def test_strict_raises_for_at_root_field_missing(): template = '{{#each items}}{{@root.top}};{{/each}}' with pytest.raises(HandlebarsRuntimeError, match="missing context key 'top'"): render(template, {'items': [1]}, strict=True) # --------------------------------------------------------------------------- # @data parent references (`@../foo`) — strict + non-strict branches # --------------------------------------------------------------------------- def test_data_parent_lookup_recurses_through_scopes(): # Parent-data access (`@../first`): `@first` is set by `each` on # the inner scope, so a parent-data lookup at depth 1 from an inner # `each` walks back to the outer `each`'s data. template = '{{#each outer}}{{#each inner}}{{@../first}};{{/each}}{{/each}}' result = render(template, {'outer': [{'inner': [1, 2]}, {'inner': [3]}]}) # `@first` of the outer each is True at iteration 0, False at 1. # Handlebars renders Python bools as lowercase `true`/`false`. assert result == 'true;true;false;' def test_data_parent_lookup_missing_raises_in_strict(): template = '{{#each items}}{{@../nope}};{{/each}}' with pytest.raises(HandlebarsRuntimeError, match="missing context key 'nope'"): render(template, {'items': [1]}, strict=True) def test_data_parent_lookup_missing_returns_empty_non_strict(): template = '{{#each items}}[{{@../nope}}];{{/each}}' assert render(template, {'items': [1, 2]}) == '[];[];' def test_data_parent_nested_segment_missing_raises_in_strict(): # Set up a parent data variable that exists but doesn't have the nested # field being requested. env = HandlebarsEnvironment(strict=True) # Hard to construct `@parent.nested.missing` from pure templates; # exercise via a custom block helper that injects parent data. template = '{{#each items}}{{@../root.missing_field}};{{/each}}' with pytest.raises(HandlebarsRuntimeError, match="missing context key 'missing_field'"): env.render(template, {'items': [1]}) def test_data_parent_nested_segment_missing_returns_empty_non_strict(): template = '{{#each items}}[{{@../root.missing_field}}];{{/each}}' assert render(template, {'items': [1]}) == '[];' # --------------------------------------------------------------------------- # Block-param dotted access in non-strict mode still tolerates missing keys # --------------------------------------------------------------------------- def test_block_param_dotted_missing_returns_empty_non_strict(): template = '{{#each items as |item|}}[{{item.missing}}];{{/each}}' assert render(template, {'items': [{'name': 'a'}]}) == '[];' # --------------------------------------------------------------------------- # Non-strict `@data` lookups inside nested scopes recurse to parent data # --------------------------------------------------------------------------- def test_at_data_lookup_recurses_into_parent_scope_non_strict(): # Inside the inner `each`, `@first` is not directly in inner.data # for the parent's iteration — the lookup recurses through the scope # chain. With the inner each at its own iteration, `@first` resolves # to the *inner* iteration's flag. template = '{{#each outer}}{{#each inner}}{{@first}};{{/each}}{{/each}}' assert render(template, {'outer': [{'inner': [1, 2]}, {'inner': [3]}]}) == 'true;false;true;' def test_at_data_missing_data_var_returns_empty_non_strict(): # Top-level `{{@nope}}` with no parent scope falls through both # `found=False` branches and renders as empty. assert render('[{{@nope}}]', {}) == '[]' def test_at_data_missing_data_var_with_nested_segment_non_strict(): # `{{@root.nothing}}` — `@root` exists, `nothing` doesn't. # Non-strict swallows the missing-segment lookup and renders empty. assert render('[{{@root.nothing}}]', {'present': 1}) == '[]' pydantic-handlebars-0.2.1/tests/test_subexpressions.py000066400000000000000000000052041520472041100232720ustar00rootroot00000000000000"""Tests for subexpressions. Ported from handlebars.js spec/subexpressions.js. """ # pyright: reportUnknownLambdaType=false, reportUnknownArgumentType=false, reportArgumentType=false from pydantic_handlebars import HandlebarsEnvironment, HelperOptions, SafeString _extra_env = HandlebarsEnvironment(extra_helpers=True) def test_subexpression_as_arg(): env = HandlebarsEnvironment() env.register_helper('loud', lambda *args, **kw: str(args[0]).upper() if args else '') env.register_helper('echo', lambda *args, **kw: str(args[0]) if args else '') assert env.render('{{echo (loud "hello")}}', {}) == 'HELLO' def test_nested_subexpressions(): env = HandlebarsEnvironment() env.register_helper('add', lambda *args, **kw: str(int(args[0]) + int(args[1])) if len(args) >= 2 else '') assert env.render('{{add (add 1 2) 3}}', {}) == '6' def test_subexpression_with_context(): env = HandlebarsEnvironment() env.register_helper('loud', lambda *args, **kw: str(args[0]).upper() if args else '') assert env.render('{{loud (lookup person "name")}}', {'person': {'name': 'Alice'}}) == 'ALICE' def test_subexpression_in_hash(): env = HandlebarsEnvironment() def link(*args: object, options: HelperOptions) -> SafeString: url = options.hash.get('url', '#') text = options.hash.get('text', '') return SafeString(f'{text}') env.register_helper('link', link) env.register_helper('loud', lambda *args, **kw: str(args[0]).upper() if args else '') assert ( env.render('{{link text=(loud "click") url="http://example.com"}}', {}) == 'CLICK' ) def test_subexpression_with_boolean_helpers(): """Test subexpressions with eq/ne comparisons inside #if.""" assert _extra_env.render('{{#if (eq a b)}}equal{{else}}not equal{{/if}}', {'a': 1, 'b': 1}) == 'equal' assert _extra_env.render('{{#if (eq a b)}}equal{{else}}not equal{{/if}}', {'a': 1, 'b': 2}) == 'not equal' def test_subexpression_with_and_or(): assert _extra_env.render('{{#if (and a b)}}yes{{else}}no{{/if}}', {'a': True, 'b': True}) == 'yes' assert _extra_env.render('{{#if (and a b)}}yes{{else}}no{{/if}}', {'a': True, 'b': False}) == 'no' assert _extra_env.render('{{#if (or a b)}}yes{{else}}no{{/if}}', {'a': False, 'b': True}) == 'yes' assert _extra_env.render('{{#if (or a b)}}yes{{else}}no{{/if}}', {'a': False, 'b': False}) == 'no' def test_subexpression_with_not(): assert _extra_env.render('{{#if (not a)}}yes{{else}}no{{/if}}', {'a': False}) == 'yes' assert _extra_env.render('{{#if (not a)}}yes{{else}}no{{/if}}', {'a': True}) == 'no' pydantic-handlebars-0.2.1/tests/test_tokenizer.py000066400000000000000000000150551520472041100222150ustar00rootroot00000000000000"""Tests for the tokenizer. Ported from handlebars.js spec/tokenizer.js. """ from pydantic_handlebars._tokenizer import TokenType, tokenize def test_tokenizes_simple_mustache(): tokens = tokenize('{{foo}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.CLOSE] def test_tokenizes_triple_mustache(): tokens = tokenize('{{{foo}}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN_UNESCAPED, TokenType.ID, TokenType.CLOSE_UNESCAPED] def test_tokenizes_content(): tokens = tokenize('Hello World') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.CONTENT] assert tokens[0].value == 'Hello World' def test_tokenizes_content_and_mustache(): tokens = tokenize('Hello {{name}}!') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.CONTENT, TokenType.OPEN, TokenType.ID, TokenType.CLOSE, TokenType.CONTENT] def test_tokenizes_block_open(): tokens = tokenize('{{#if}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN_BLOCK, TokenType.ID, TokenType.CLOSE] def test_tokenizes_block_close(): tokens = tokenize('{{/if}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN_ENDBLOCK, TokenType.ID, TokenType.CLOSE] def test_tokenizes_inverse(): tokens = tokenize('{{^}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN_INVERSE, TokenType.CLOSE] def test_tokenizes_comment(): tokens = tokenize('{{! comment }}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.COMMENT] def test_tokenizes_long_comment(): tokens = tokenize('{{!-- long comment --}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.COMMENT] def test_tokenizes_path_separator(): tokens = tokenize('{{foo.bar}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.SEP, TokenType.ID, TokenType.CLOSE] def test_tokenizes_string_literal(): tokens = tokenize('{{foo "bar"}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.STRING, TokenType.CLOSE] string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'bar' def test_tokenizes_number_literal(): tokens = tokenize('{{foo 42}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.NUMBER, TokenType.CLOSE] number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] assert number_token.value == '42' def test_tokenizes_boolean(): tokens = tokenize('{{foo true}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.BOOLEAN, TokenType.CLOSE] def test_tokenizes_data_variable(): tokens = tokenize('{{@index}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.DATA, TokenType.ID, TokenType.CLOSE] def test_tokenizes_parent(): tokens = tokenize('{{../foo}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.PARENT, TokenType.ID, TokenType.CLOSE] def test_tokenizes_hash_args(): tokens = tokenize('{{foo key=value}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.ID, TokenType.EQUALS, TokenType.ID, TokenType.CLOSE] def test_tokenizes_subexpression(): tokens = tokenize('{{foo (bar baz)}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [ TokenType.OPEN, TokenType.ID, TokenType.OPEN_SEXPR, TokenType.ID, TokenType.ID, TokenType.CLOSE_SEXPR, TokenType.CLOSE, ] def test_tokenizes_else(): tokens = tokenize('{{else}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.INVERSE, TokenType.CLOSE] def test_tokenizes_strip_markers(): tokens = tokenize('{{~foo~}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.STRIP in types def test_tokenizes_escaped_mustache(): tokens = tokenize('\\{{foo}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.CONTENT] assert tokens[0].value == '{{foo}}' def test_tokenizes_null(): tokens = tokenize('{{null}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.NULL, TokenType.CLOSE] def test_tokenizes_undefined(): tokens = tokenize('{{undefined}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.UNDEFINED, TokenType.CLOSE] def test_tokenizes_block_params(): tokens = tokenize('{{#each items as |item|}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_BLOCK_PARAMS in types assert TokenType.CLOSE_BLOCK_PARAMS in types def test_tokenizes_ampersand(): tokens = tokenize('{{&foo}}') types = [t.type for t in tokens if t.type != TokenType.EOF] # {{& is tokenized as OPEN with value {{& assert types[0] == TokenType.OPEN def test_tokenizes_single_quoted_string(): tokens = tokenize("{{foo 'bar'}}") string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'bar' def test_tokenizes_negative_number(): tokens = tokenize('{{foo -1}}') number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] assert number_token.value == '-1' def test_tokenizes_float(): tokens = tokenize('{{foo 3.14}}') number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] assert number_token.value == '3.14' def test_tokenizes_bracket_notation(): tokens = tokenize('{{[foo bar]}}') id_token = [t for t in tokens if t.type == TokenType.ID][0] assert id_token.value == 'foo bar' def test_empty_template(): tokens = tokenize('') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [] def test_only_content(): tokens = tokenize('Hello World') assert tokens[0].type == TokenType.CONTENT assert tokens[0].value == 'Hello World' pydantic-handlebars-0.2.1/tests/test_tokenizer_coverage.py000066400000000000000000000365101520472041100240670ustar00rootroot00000000000000"""Additional tokenizer tests targeting uncovered code paths.""" import pytest from pydantic_handlebars._exceptions import HandlebarsParseError from pydantic_handlebars._tokenizer import TokenType, tokenize # --- _peek returning empty string (line 115) --- def test_peek_past_end_of_source(): """When tokenizer peeks past end of source, it returns empty string.""" # A simple template where the tokenizer will eventually peek past the end tokens = tokenize('{{a}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.CLOSE] def test_peek_offset_past_end(): """Peeking with offset past end of source returns empty string. When a dot is the last character in the template, _peek(1) returns '' which exercises the empty string return path in _peek. """ # {{. - the dot is the last char, _peek(1) returns '' triggering line 115 tokens = tokenize('{{.') ids = [t for t in tokens if t.type == TokenType.ID] assert len(ids) == 1 assert ids[0].value == '.' # --- Escaped mustache with another escaped mustache (lines 145-147) --- def test_escaped_mustache_followed_by_escaped_mustache(): r"""Escaped mustache \{{ followed by another \{{ in the read loop.""" tokens = tokenize('\\{{foo}}\\{{bar}}') types = [t.type for t in tokens if t.type != TokenType.EOF] # Both escaped mustaches should be merged into one CONTENT token assert types == [TokenType.CONTENT] assert tokens[0].value == '{{foo}}{{bar}}' def test_escaped_mustache_followed_by_real_mustache(): r"""Escaped mustache \{{ followed by real {{ (line 149 break).""" tokens = tokenize('\\{{literal}}{{real}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.CONTENT, TokenType.OPEN, TokenType.ID, TokenType.CLOSE] assert tokens[0].value == '{{literal}}' def test_escaped_mustache_followed_by_text_and_mustache(): """Escaped mustache followed by some text then a real mustache.""" tokens = tokenize('\\{{a}} stuff {{b}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.CONTENT, TokenType.OPEN, TokenType.ID, TokenType.CLOSE] assert tokens[0].value == '{{a}} stuff ' # --- Raw blocks (lines 156-157, 495-546) --- def test_raw_block_basic(): """Raw block with content.""" tokens = tokenize('{{{{raw}}}}hello world{{{{/raw}}}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [ TokenType.OPEN_RAW_BLOCK, TokenType.ID, TokenType.CLOSE_RAW_BLOCK, TokenType.RAW_CONTENT, TokenType.END_RAW_BLOCK, ] # Check the raw content raw_tokens = [t for t in tokens if t.type == TokenType.RAW_CONTENT] assert raw_tokens[0].value == 'hello world' def test_raw_block_preserves_mustaches(): """Raw block preserves mustache syntax as raw content.""" tokens = tokenize('{{{{raw}}}}{{not parsed}}{{{{/raw}}}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [ TokenType.OPEN_RAW_BLOCK, TokenType.ID, TokenType.CLOSE_RAW_BLOCK, TokenType.RAW_CONTENT, TokenType.END_RAW_BLOCK, ] raw_tokens = [t for t in tokens if t.type == TokenType.RAW_CONTENT] assert raw_tokens[0].value == '{{not parsed}}' def test_raw_block_empty_content(): """Raw block with no content between open and close.""" tokens = tokenize('{{{{raw}}}}{{{{/raw}}}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [ TokenType.OPEN_RAW_BLOCK, TokenType.ID, TokenType.CLOSE_RAW_BLOCK, TokenType.END_RAW_BLOCK, ] def test_raw_block_missing_name(): """Raw block with no identifier should raise an error.""" with pytest.raises(HandlebarsParseError, match='Expected identifier in raw block'): tokenize('{{{{ }}}}') def test_raw_block_unclosed_open_tag(): """Raw block with missing close for open tag.""" with pytest.raises(HandlebarsParseError, match='Expected }}}}'): tokenize('{{{{raw}}') def test_raw_block_unclosed(): """Raw block that is never closed.""" with pytest.raises(HandlebarsParseError, match='Unclosed raw block'): tokenize('{{{{raw}}}}some content but no close') # --- _read_content producing no output (line 181->exit) --- def test_content_empty_between_mustaches(): """Two mustaches back to back should not emit empty content.""" tokens = tokenize('{{a}}{{b}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [ TokenType.OPEN, TokenType.ID, TokenType.CLOSE, TokenType.OPEN, TokenType.ID, TokenType.CLOSE, ] # No CONTENT token should appear between them assert TokenType.CONTENT not in types # --- Triple-stache with strip (lines 192-195) --- def test_triple_stache_with_open_strip(): """Triple-stache with strip on open.""" tokens = tokenize('{{{~foo}}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_UNESCAPED in types assert TokenType.STRIP in types assert types[0] == TokenType.OPEN_UNESCAPED assert types[1] == TokenType.STRIP # --- Long comment with strip close (lines 255-257) --- def test_long_comment_with_strip_close(): """Long comment closed with strip marker on close.""" tokens = tokenize('{{!-- comment --~}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.COMMENT, TokenType.STRIP] assert tokens[0].value == ' comment ' # --- Unclosed long comment (line 263) --- def test_unclosed_long_comment(): """Unclosed long comment raises error.""" with pytest.raises(HandlebarsParseError, match='Unclosed comment'): tokenize('{{!-- comment without close') # --- Short comment with strip close (lines 267-269) --- def test_short_comment_with_strip_close(): """Short comment closed with strip marker.""" tokens = tokenize('{{! comment ~}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.COMMENT, TokenType.STRIP] assert tokens[0].value == ' comment ' # --- Unclosed short comment (line 275) --- def test_unclosed_short_comment(): """Unclosed short comment raises error.""" with pytest.raises(HandlebarsParseError, match='Unclosed comment'): tokenize('{{! comment without close') # --- _read_mustache_body reaching end (lines 279->exit, 283) --- def test_mustache_body_reaching_end_of_source(): """Mustache opened but source ends without close produces tokens up to EOF.""" tokens = tokenize('{{foo') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN in types assert TokenType.ID in types def test_mustache_body_only_whitespace_then_end(): """Mustache body with only whitespace then EOF.""" tokens = tokenize('{{foo ') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN in types assert TokenType.ID in types # --- Strip before close in unescaped (lines 288-294) --- def test_triple_stache_with_close_strip(): """Triple-stache with strip on close.""" tokens = tokenize('{{{foo~}}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_UNESCAPED in types assert TokenType.STRIP in types assert TokenType.CLOSE_UNESCAPED in types # --- Strip in regular close (lines 295->305) --- def test_regular_mustache_with_close_strip(): """Regular mustache with strip on close.""" tokens = tokenize('{{foo~}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN in types assert TokenType.STRIP in types assert TokenType.CLOSE in types # --- _read_expression_token at end of source (line 329) --- def test_expression_token_at_end(): """Exercise _read_expression_token returning immediately at end of source.""" tokens = tokenize('{{x') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.ID in types # --- Mustache body no-progress break (lone '}' inside expression) --- def test_mustache_body_no_progress_break(): """A lone '}' that doesn't form '}}' or '}}}' causes the body loop to break.""" tokens = tokenize('{{{unclosed}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_UNESCAPED in types assert TokenType.ID in types # --- '.' as 'this' followed by '/' (lines 377-383) --- def test_dot_this_followed_by_slash(): """Dot used as 'this' followed by slash separator.""" tokens = tokenize('{{./foo}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN in types assert TokenType.ID in types assert TokenType.SEP in types # Should have . as ID, / as SEP, foo as ID ids = [t for t in tokens if t.type == TokenType.ID] assert ids[0].value == '.' assert ids[1].value == 'foo' def test_dot_this_with_close(): """Dot used as standalone 'this'.""" tokens = tokenize('{{.}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.CLOSE] id_token = [t for t in tokens if t.type == TokenType.ID][0] assert id_token.value == '.' # --- String escape sequences (lines 403-414) --- def test_string_with_escape_newline(): r"""String literal with \n escape.""" tokens = tokenize('{{foo "hello\\nworld"}}') string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'hello\nworld' def test_string_with_escape_tab(): r"""String literal with \t escape.""" tokens = tokenize('{{foo "hello\\tworld"}}') string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'hello\tworld' def test_string_with_escape_return(): r"""String literal with \r escape.""" tokens = tokenize('{{foo "hello\\rworld"}}') string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'hello\rworld' def test_string_with_escape_quote(): """String literal with escaped quote.""" tokens = tokenize('{{foo "hello\\"world"}}') string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'hello"world' def test_string_with_escape_backslash(): """String literal with escaped backslash.""" tokens = tokenize('{{foo "hello\\\\world"}}') string_token = [t for t in tokens if t.type == TokenType.STRING][0] assert string_token.value == 'hello\\world' # --- Unterminated string literal (line 421) --- def test_unterminated_string(): """Unterminated string literal raises error.""" with pytest.raises(HandlebarsParseError, match='Unterminated string literal'): tokenize('{{foo "hello}}') def test_unterminated_single_quoted_string(): """Unterminated single-quoted string literal raises error.""" with pytest.raises(HandlebarsParseError, match='Unterminated string literal'): tokenize("{{foo 'hello}}") # --- Bracket notation without closing ] (line 451->453) --- def test_bracket_notation_unclosed(): """Bracket notation that reaches end of source without closing bracket.""" tokens = tokenize('{{[foo') id_tokens = [t for t in tokens if t.type == TokenType.ID] assert len(id_tokens) == 1 assert id_tokens[0].value == 'foo' # --- Empty identifier / unexpected character (lines 460-464) --- def test_unexpected_character_in_expression(): """Unexpected character in expression raises error.""" with pytest.raises(HandlebarsParseError, match='Unexpected character'): tokenize('{{%}}') # --- 'as' not followed by '|' (lines 486-489) --- def test_as_identifier_without_pipe(): """The word 'as' used as a regular identifier (not followed by pipe).""" tokens = tokenize('{{as}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types == [TokenType.OPEN, TokenType.ID, TokenType.CLOSE] id_token = [t for t in tokens if t.type == TokenType.ID][0] assert id_token.value == 'as' def test_as_identifier_followed_by_non_pipe(): """The word 'as' followed by something other than pipe.""" tokens = tokenize('{{as foo}}') # 'as' should be emitted as ID, then 'foo' as ID ids = [t for t in tokens if t.type == TokenType.ID] assert ids[0].value == 'as' assert ids[1].value == 'foo' # --- Negative number (covers line 428-429 _read_number with minus) --- def test_negative_number_in_expression(): """Negative number like -42 in expression.""" tokens = tokenize('{{foo -42}}') number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] assert number_token.value == '-42' # --- Partial open --- def test_partial_open(): """Partial open produces OPEN_PARTIAL token.""" tokens = tokenize('{{> myPartial}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert types[0] == TokenType.OPEN_PARTIAL # --- Strip on block open --- def test_strip_on_block_open(): """Strip marker on block open.""" tokens = tokenize('{{~#each items}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_BLOCK in types assert TokenType.STRIP in types # --- Strip on endblock --- def test_strip_on_endblock(): """Strip marker on endblock.""" tokens = tokenize('{{~/each}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_ENDBLOCK in types assert TokenType.STRIP in types # --- Strip on inverse --- def test_strip_on_inverse(): """Strip marker on inverse.""" tokens = tokenize('{{~^}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.OPEN_INVERSE in types assert TokenType.STRIP in types # --- Content with escaped mustache inside content --- def test_content_with_embedded_escaped_mustache(): r"""Content that has \{{ inside it.""" tokens = tokenize('before \\{{escaped}} after') content_tokens = [t for t in tokens if t.type == TokenType.CONTENT] combined = ''.join(t.value for t in content_tokens) assert '{{escaped}}' in combined assert 'before' in combined assert 'after' in combined # --- Data variable @ not followed by alnum --- def test_data_variable_at_sign_only(): """@ not followed by alphanumeric still produces DATA token.""" tokens = tokenize('{{@}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.DATA in types # --- Newline handling in _advance --- def test_multiline_template(): """Template with newlines tracks line numbers correctly.""" tokens = tokenize('line1\n{{foo}}\nline3') foo_token = [t for t in tokens if t.type == TokenType.ID][0] assert foo_token.line == 2 # --- Dot as separator --- def test_dot_separator_in_path(): """Dot used as path separator in a path expression.""" tokens = tokenize('{{foo.bar.baz}}') seps = [t for t in tokens if t.type == TokenType.SEP] assert len(seps) == 2 assert all(s.value == '.' for s in seps) # --- Slash as separator --- def test_slash_separator_in_path(): """Slash used as path separator.""" tokens = tokenize('{{foo/bar}}') types = [t.type for t in tokens if t.type != TokenType.EOF] assert TokenType.SEP in types sep = [t for t in tokens if t.type == TokenType.SEP][0] assert sep.value == '/' pydantic-handlebars-0.2.1/tests/test_whitespace.py000066400000000000000000000126741520472041100223430ustar00rootroot00000000000000"""Tests for whitespace control. Ported from handlebars.js spec/whitespace-control.js. """ from pydantic_handlebars import render def test_strip_whitespace_before_mustache(): assert render(' {{~foo}}', {'foo': 'bar'}) == 'bar' def test_strip_whitespace_after_mustache(): assert render('{{foo~}} ', {'foo': 'bar'}) == 'bar' def test_strip_whitespace_both_sides(): assert render(' {{~foo~}} ', {'foo': 'bar'}) == 'bar' def test_strip_with_newlines(): assert render('foo\n {{~bar}}\n baz', {'bar': 'BAR'}) == 'fooBAR\n baz' def test_strip_after_with_newlines(): assert render('foo\n {{bar~}}\n baz', {'bar': 'BAR'}) == 'foo\n BARbaz' def test_strip_both_with_newlines(): assert render('foo\n {{~bar~}}\n baz', {'bar': 'BAR'}) == 'fooBARbaz' def test_strip_block_open(): template = ' {{~#if show~}} \ncontent\n {{~/if~}} ' assert render(template, {'show': True}) == 'content' def test_no_strip(): assert render(' {{foo}} ', {'foo': 'bar'}) == ' bar ' # --- Comment whitespace control --- def test_comment_strip_open(): """{{~! comment }} strips preceding whitespace.""" assert render(' {{~! comment }}after', {}) == 'after' def test_comment_strip_close(): """{{! comment ~}} strips following whitespace.""" assert render('before{{! comment ~}} ', {}) == 'before' def test_comment_strip_both(): """{{~! comment ~}} strips whitespace on both sides.""" assert render(' {{~! comment ~}} ', {}) == '' def test_long_comment_strip_open(): """{{~!-- comment --}} strips preceding whitespace.""" assert render(' {{~!-- comment --}}after', {}) == 'after' def test_long_comment_strip_close(): """{{!-- comment --~}} strips following whitespace.""" assert render('before{{!-- comment --~}} ', {}) == 'before' def test_long_comment_strip_both(): """{{~!-- comment --~}} strips whitespace on both sides.""" assert render(' {{~!-- comment --~}} ', {}) == '' def test_comment_strip_with_newlines(): """Comment strip markers remove newlines and surrounding whitespace.""" assert render('foo\n {{~! comment ~}}\n bar', {}) == 'foobar' def test_long_comment_strip_with_newlines(): """Long comment strip markers remove newlines and surrounding whitespace.""" assert render('foo\n {{~!-- comment --~}}\n bar', {}) == 'foobar' # --- Else/inverse whitespace control --- def test_else_strip_open(): """{{~else}} strips trailing whitespace from body.""" assert render('{{#if show}} body {{~else}} other {{/if}}', {'show': True}) == ' body' assert render('{{#if show}} body {{~else}} other {{/if}}', {'show': False}) == ' other ' def test_else_strip_close(): """{{else~}} strips leading whitespace from inverse.""" assert render('{{#if show}} body {{else~}} other {{/if}}', {'show': True}) == ' body ' assert render('{{#if show}} body {{else~}} other {{/if}}', {'show': False}) == 'other ' def test_else_strip_both(): """{{~else~}} strips trailing ws from body and leading ws from inverse.""" assert render('{{#if show}} body {{~else~}} other {{/if}}', {'show': True}) == ' body' assert render('{{#if show}} body {{~else~}} other {{/if}}', {'show': False}) == 'other ' def test_caret_strip_open(): """{{~^}} strips trailing whitespace from body.""" assert render('{{#if show}} body {{~^}} other {{/if}}', {'show': True}) == ' body' assert render('{{#if show}} body {{~^}} other {{/if}}', {'show': False}) == ' other ' def test_caret_strip_close(): """{{^~}} strips leading whitespace from inverse.""" assert render('{{#if show}} body {{^~}} other {{/if}}', {'show': True}) == ' body ' assert render('{{#if show}} body {{^~}} other {{/if}}', {'show': False}) == 'other ' def test_caret_strip_both(): """{{~^~}} strips both sides.""" assert render('{{#if show}} body {{~^~}} other {{/if}}', {'show': True}) == ' body' assert render('{{#if show}} body {{~^~}} other {{/if}}', {'show': False}) == 'other ' def test_else_strip_with_close_strip(): """Combine {{~else~}} with {{~/if}} for full control.""" assert render('{{#if show}} body {{~else~}} other {{~/if}}', {'show': False}) == 'other' def test_else_strip_with_newlines(): """Else strip markers work across newlines.""" template = '{{#if show}}\n body\n {{~else~}}\n other\n{{/if}}' assert render(template, {'show': True}) == '\n body' assert render(template, {'show': False}) == 'other\n' # --- Context-based block inner whitespace control --- def test_context_block_inner_strip(): """{{#name~}} and {{~/name}} strip inner whitespace for context blocks.""" assert render('{{#name~}} hi {{~/name}}', {'name': 'World'}) == 'hi' def test_context_block_inner_strip_open_only(): """{{#name~}} strips leading whitespace from body.""" assert render('{{#name~}} hi {{/name}}', {'name': 'World'}) == 'hi ' def test_context_block_inner_strip_close_only(): """{{~/name}} strips trailing whitespace from body.""" assert render('{{#name}} hi {{~/name}}', {'name': 'World'}) == ' hi' def test_context_block_inverse_strip(): """Whitespace control on else for context-based blocks.""" assert render('{{#val}} yes {{~else~}} no {{/val}}', {'val': ''}) == 'no ' assert render('{{#val}} yes {{~else~}} no {{/val}}', {'val': 'x'}) == ' yes' def test_context_block_list_inner_strip(): """Inner strip works for context blocks that iterate lists.""" assert render('{{#items~}} x {{~/items}}', {'items': [1, 2]}) == 'x x' pydantic-handlebars-0.2.1/uv.lock000066400000000000000000007063031520472041100167370ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.10" [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "asttokens" version = "3.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "backrefs" version = "6.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] [[package]] name = "black" version = "26.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "mypy-extensions" }, { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/1b/523329e713f965ad0ea2b7a047eeb003007792a0353622ac7a8cb2ee6fef/black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168", size = 1849661, upload-time = "2026-01-18T04:59:12.425Z" }, { url = "https://files.pythonhosted.org/packages/14/82/94c0640f7285fa71c2f32879f23e609dd2aa39ba2641f395487f24a578e7/black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d", size = 1689065, upload-time = "2026-01-18T04:59:13.993Z" }, { url = "https://files.pythonhosted.org/packages/f0/78/474373cbd798f9291ed8f7107056e343fd39fef42de4a51c7fd0d360840c/black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0", size = 1751502, upload-time = "2026-01-18T04:59:15.971Z" }, { url = "https://files.pythonhosted.org/packages/29/89/59d0e350123f97bc32c27c4d79563432d7f3530dca2bff64d855c178af8b/black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24", size = 1400102, upload-time = "2026-01-18T04:59:17.8Z" }, { url = "https://files.pythonhosted.org/packages/e1/bc/5d866c7ae1c9d67d308f83af5462ca7046760158bbf142502bad8f22b3a1/black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89", size = 1207038, upload-time = "2026-01-18T04:59:19.543Z" }, { url = "https://files.pythonhosted.org/packages/30/83/f05f22ff13756e1a8ce7891db517dbc06200796a16326258268f4658a745/black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5", size = 1831956, upload-time = "2026-01-18T04:59:21.38Z" }, { url = "https://files.pythonhosted.org/packages/7d/f2/b2c570550e39bedc157715e43927360312d6dd677eed2cc149a802577491/black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68", size = 1672499, upload-time = "2026-01-18T04:59:23.257Z" }, { url = "https://files.pythonhosted.org/packages/7a/d7/990d6a94dc9e169f61374b1c3d4f4dd3037e93c2cc12b6f3b12bc663aa7b/black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14", size = 1735431, upload-time = "2026-01-18T04:59:24.729Z" }, { url = "https://files.pythonhosted.org/packages/36/1c/cbd7bae7dd3cb315dfe6eeca802bb56662cc92b89af272e014d98c1f2286/black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c", size = 1400468, upload-time = "2026-01-18T04:59:27.381Z" }, { url = "https://files.pythonhosted.org/packages/59/b1/9fe6132bb2d0d1f7094613320b56297a108ae19ecf3041d9678aec381b37/black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4", size = 1207332, upload-time = "2026-01-18T04:59:28.711Z" }, { url = "https://files.pythonhosted.org/packages/f5/13/710298938a61f0f54cdb4d1c0baeb672c01ff0358712eddaf29f76d32a0b/black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", size = 1878189, upload-time = "2026-01-18T04:59:30.682Z" }, { url = "https://files.pythonhosted.org/packages/79/a6/5179beaa57e5dbd2ec9f1c64016214057b4265647c62125aa6aeffb05392/black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", size = 1700178, upload-time = "2026-01-18T04:59:32.387Z" }, { url = "https://files.pythonhosted.org/packages/8c/04/c96f79d7b93e8f09d9298b333ca0d31cd9b2ee6c46c274fd0f531de9dc61/black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", size = 1777029, upload-time = "2026-01-18T04:59:33.767Z" }, { url = "https://files.pythonhosted.org/packages/49/f9/71c161c4c7aa18bdda3776b66ac2dc07aed62053c7c0ff8bbda8c2624fe2/black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", size = 1406466, upload-time = "2026-01-18T04:59:35.177Z" }, { url = "https://files.pythonhosted.org/packages/4a/8b/a7b0f974e473b159d0ac1b6bcefffeb6bec465898a516ee5cc989503cbc7/black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", size = 1216393, upload-time = "2026-01-18T04:59:37.18Z" }, { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, ] [[package]] name = "certifi" version = "2026.1.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] name = "click" version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [[package]] name = "dirty-equals" version = "0.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067, upload-time = "2025-11-17T01:51:24.451Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "executing" version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] name = "ghp-import" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] [[package]] name = "hypothesis" version = "6.151.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "sortedcontainers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "inline-snapshot" version = "0.32.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, { name = "executing" }, { name = "pytest" }, { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/0f/abc1f55538a348525c441065845fdfeca73af634b5ffd573ed782e3d4bea/inline_snapshot-0.32.2.tar.gz", hash = "sha256:62374e28c471a0de9c60ad23758586fb13355ad449d3698b7b77ab597eed7f59", size = 2624220, upload-time = "2026-02-21T14:37:11.756Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/68/3c/2cdb9e4d004951816d5b3dc720dcebfa3cc9909cfe8e38de4bb72f628755/inline_snapshot-0.32.2-py3-none-any.whl", hash = "sha256:e2866e4f268435a9b8886f63a275cdb7489d13baddb2a0aab6625bf2975c06ce", size = 84285, upload-time = "2026-02-21T14:37:10.481Z" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "markdown" version = "3.10.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] name = "mkdocs" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, { name = "mergedeep" }, { name = "mkdocs-get-deps" }, { name = "packaging" }, { name = "pathspec" }, { name = "pyyaml" }, { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] name = "mkdocs-autorefs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] name = "mkdocs-get-deps" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mergedeep" }, { name = "platformdirs" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] name = "mkdocs-material" version = "9.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, { name = "backrefs" }, { name = "colorama" }, { name = "jinja2" }, { name = "markdown" }, { name = "mkdocs" }, { name = "mkdocs-material-extensions" }, { name = "paginate" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/57/5d3c8c9e2ff9d66dc8f63aa052eb0bac5041fecff7761d8689fe65c39c13/mkdocs_material-9.7.2.tar.gz", hash = "sha256:6776256552290b9b7a7aa002780e25b1e04bc9c3a8516b6b153e82e16b8384bd", size = 4097818, upload-time = "2026-02-18T15:53:07.763Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cd/19/d194e75e82282b1d688f0720e21b5ac250ed64ddea333a228aaf83105f2e/mkdocs_material-9.7.2-py3-none-any.whl", hash = "sha256:9bf6f53452d4a4d527eac3cef3f92b7b6fc4931c55d57766a7d87890d47e1b92", size = 9305052, upload-time = "2026-02-18T15:53:05.221Z" }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] name = "mkdocstrings" version = "1.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, ] [[package]] name = "mkdocstrings-python" version = "2.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffelib" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] name = "pathspec" version = "1.0.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] name = "platformdirs" version = "4.9.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pydantic" version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] name = "pydantic-handlebars" version = "0.2.1" source = { editable = "." } dependencies = [ { name = "pydantic" }, ] [package.dev-dependencies] dev = [ { name = "coverage" }, { name = "dirty-equals" }, { name = "hypothesis" }, { name = "inline-snapshot" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-examples" }, { name = "pytest-xdist" }, ] docs = [ { name = "mkdocs-material" }, { name = "mkdocstrings-python" }, ] lint = [ { name = "pyright" }, { name = "ruff" }, ] [package.metadata] requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [package.metadata.requires-dev] dev = [ { name = "coverage" }, { name = "dirty-equals" }, { name = "hypothesis" }, { name = "inline-snapshot" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest" }, { name = "pytest-examples" }, { name = "pytest-xdist" }, ] docs = [ { name = "mkdocs-material" }, { name = "mkdocstrings-python" }, ] lint = [ { name = "pyright", specifier = ">=1.1.408" }, { name = "ruff", specifier = ">=0.14" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pymdown-extensions" version = "10.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, ] [[package]] name = "pyright" version = "1.1.408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-examples" version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "black" }, { name = "pytest" }, { name = "ruff" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/71/4ae972fd95f474454aa450108ee1037830e7ba11840363e981b8d48fd16a/pytest_examples-0.0.18.tar.gz", hash = "sha256:9a464f007f805b113677a15e2f8942ebb92d7d3eb5312e9a405d018478ec9801", size = 21237, upload-time = "2025-05-06T07:46:10.705Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" }, ] [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pytokens" version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "pyyaml-env-tag" version = "1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] name = "requests" version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "rich" version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "ruff" version = "0.15.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "tomli" version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ]