diff --git a/.devcontainer/Dockerfile.dev b/.devcontainer/Dockerfile.dev deleted file mode 100644 index 5bce3e0..0000000 --- a/.devcontainer/Dockerfile.dev +++ /dev/null @@ -1,27 +0,0 @@ -ARG UV_VERSION=0.8.13 - -FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv - -FROM mcr.microsoft.com/devcontainers/base:bookworm - -COPY --from=uv /uv /uvx /bin/ - -# Python environment variables for development -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONIOENCODING=utf-8 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - UV_NO_CACHE=1 \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 - -USER vscode - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -WORKDIR /app - -HEALTHCHECK --interval=60s --timeout=5s --start-period=60s --retries=3 \ - CMD test -d /app && whoami || exit 1 diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 0fb4953..0000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# Development Container - -This project uses a [Development Container](https://containers.dev/) to provide a consistent development environment. - -## Getting Started - -1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop) -2. Install [VS Code](https://code.visualstudio.com/) and the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) -3. Open this folder in VS Code -4. When prompted, click "Reopen in Container" (or run command: `Dev Containers: Reopen in Container`) - -## Local Customization - -To customize your local development environment without modifying the committed configuration, create a `.devcontainer/devcontainer.local.json` file (this file is gitignored). - -### Example: Add Additional VS Code Extensions - -```json -{ - "customizations": { - "vscode": { - "extensions": [ - "esbenp.prettier-vscode", - "usernamehw.errorlens" - ] - } - } -} -``` - -### Example: Mount Additional Volumes - -```json -{ - "mounts": [ - "source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached" - ] -} -``` - -### Example: Override or Add Environment Variables - -The container sets several Python-related environment variables by default (see `Dockerfile.dev`). You can override them or add new ones using `remoteEnv`: - -```json -{ - "remoteEnv": { - "PYTHONUNBUFFERED": "0", - "DEBUG": "1", - "LOG_LEVEL": "DEBUG" - } -} -``` - -See `devcontainer.local.json.example` for a complete list of default environment variables that can be overridden. - -Alternatively, create a `.devcontainer/.env` file (also gitignored): -```bash -DEBUG=1 -LOG_LEVEL=DEBUG -``` - -And reference it in `devcontainer.local.json`: -```json -{ - "runArgs": ["--env-file", ".devcontainer/.env"] -} -``` - -### Example: Forward Additional Ports - -```json -{ - "forwardPorts": [8080, 3000] -} -``` - -## How It Works - -The Dev Container will: -- Build from `Dockerfile.dev` -- Mount your workspace to `/app` -- Install Git and GitHub CLI features -- Run `uv sync --all-groups` to create a virtual environment at `/app/.venv` -- Configure VS Code to use the virtual environment's Python interpreter -- Set up bash as the default terminal - -### Default Environment Variables - -The following Python-related environment variables are set in the container: - -- **`PYTHONUNBUFFERED=1`** - Ensures real-time output in logs -- **`PYTHONDONTWRITEBYTECODE=1`** - Prevents `.pyc` file creation -- **`PYTHONIOENCODING=utf-8`** - Ensures UTF-8 encoding for I/O -- **`PIP_NO_CACHE_DIR=1`** - Disables pip caching to reduce container size -- **`PIP_DISABLE_PIP_VERSION_CHECK=1`** - Suppresses pip update warnings -- **`UV_NO_CACHE=1`** - Disables uv caching -- **`UV_PROJECT_ENVIRONMENT=/app/.venv`** - Sets the virtual environment location for uv -- **`LANG=C.UTF-8`** - Sets locale for proper Unicode handling -- **`LC_ALL=C.UTF-8`** - Sets all locale categories to UTF-8 - -These can be overridden in `devcontainer.local.json` using the `remoteEnv` property. - -### Virtual Environment - -The project uses `uv` to manage dependencies. When the container is created: -1. `uv sync --all-groups` is automatically run to create a virtual environment at `/app/.venv` -2. VS Code is configured to use `/app/.venv/bin/python` as the Python interpreter -3. All dependencies from `pyproject.toml` (including dev groups) are installed - -To manually update dependencies, run: -```bash -uv sync --all-groups -``` - -## Rebuilding the Container - -If you modify the Dockerfile or devcontainer configuration: -1. Run command: `Dev Containers: Rebuild Container` -2. Or run: `Dev Containers: Rebuild Container Without Cache` for a clean build diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a686774..78b3d8a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,29 +1,25 @@ { - // To customize this for your local environment without committing changes, - // create a .devcontainer/devcontainer.local.json file (it's gitignored). - // See .devcontainer/README.md for examples. - "name": "toon-python (devcontainer)", - "build": { - "dockerfile": "Dockerfile.dev", - "context": ".." - }, - "workspaceFolder": "/app", - "mounts": [ - "source=${localWorkspaceFolder},target=/app,type=bind,consistency=cached" - ], + "name": "toon-python", + "image": "mcr.microsoft.com/devcontainers/python:3.14-trixie", "features": { - "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, - "remoteEnv": { - // Python environment variables (can be overridden in devcontainer.local.json) - // These supplement the ENV vars set in Dockerfile.dev + "containerEnv": { + // Keep the container's virtualenv out of the mounted repository, so it + // never replaces the host's .venv. + "UV_PROJECT_ENVIRONMENT": "/home/vscode/.venv", + "UV_LINK_MODE": "copy" }, - "postCreateCommand": "uv sync --all-groups", + "postCreateCommand": ".devcontainer/post-create.sh", "customizations": { "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.mypy-type-checker", + "charliermarsh.ruff" + ], "settings": { - "python.defaultInterpreterPath": "/app/.venv/bin/python" + "python.defaultInterpreterPath": "/home/vscode/.venv/bin/python" } } } diff --git a/.devcontainer/devcontainer.local.json.example b/.devcontainer/devcontainer.local.json.example deleted file mode 100644 index 65e0d3f..0000000 --- a/.devcontainer/devcontainer.local.json.example +++ /dev/null @@ -1,32 +0,0 @@ -{ - // Example local devcontainer configuration - // Copy this to devcontainer.local.json and customize as needed - // This file is gitignored and won't be committed - - "remoteEnv": { - // Override Python environment variables - // Uncomment and modify any of these to override defaults from Dockerfile.dev: - - // "PYTHONUNBUFFERED": "0", - // "PYTHONDONTWRITEBYTECODE": "0", - // "PYTHONIOENCODING": "utf-8", - // "PIP_NO_CACHE_DIR": "0", - // "PIP_DISABLE_PIP_VERSION_CHECK": "0", - // "UV_NO_CACHE": "0", - // "UV_PROJECT_ENVIRONMENT": "/custom/path/.venv", - // "LANG": "en_US.UTF-8", - // "LC_ALL": "en_US.UTF-8", - - // Add custom environment variables for local development: - // "DEBUG": "1", - // "LOG_LEVEL": "DEBUG" - }, - - // You can also override other devcontainer settings: - // "features": {}, - // "customizations": { - // "vscode": { - // "extensions": [] - // } - // } -} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..ed76210 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Kept in a file so postCreateCommand is a single word: Zed joins the +# arguments of lifecycle commands without quoting them. +set -eu +pipx install uv +uv sync --all-groups +uv run prek install diff --git a/.editorconfig b/.editorconfig index b470af3..1805a4b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,18 +5,9 @@ charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true - -[*.{py,pyi}] -indent_style = space -indent_size = 4 - -[*.{yml,yaml}] indent_style = space indent_size = 2 -[*.{json,toml}] -indent_style = space -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false +[*.{py,pyi}] +indent_size = 4 +max_line_length = 88 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a62dab7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] + - package-ecosystem: uv + directory: / + schedule: + interval: monthly + groups: + dev-dependencies: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..68eb359 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint and type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + + docs: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv run --locked --group docs mkdocs build --strict + + test: + name: Test (Python ${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + include: + - os: macos-latest + python-version: "3.14" + - os: windows-latest + python-version: "3.14" + env: + HYPOTHESIS_PROFILE: ci + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --locked + - run: uv run pytest --cov --cov-report=term --cov-report=xml --cov-fail-under=95 + - uses: actions/upload-artifact@v5 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14' + with: + name: coverage + path: coverage.xml + + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + - run: uv build + - name: Smoke test the wheel + run: | + uv run --isolated --no-project --with dist/toon_format-*.whl python -c \ + "import toon_format as t; assert t.loads(t.dumps({'a': [1, 2]})) == {'a': [1, 2]}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 4a73fd2..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Lint - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - name: Lint and Type Check - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - uv sync - uv pip install ruff mypy pytest - - - name: Run ruff check - run: uv run ruff check src/ tests/ - - - name: Run ruff format check - run: uv run ruff format --check src/ tests/ - - - name: Run mypy - run: uv run mypy src/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a6d9b5..e35d49a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,13 @@ -name: Publish to PyPI +# Publishes "toon-format" to PyPI with trusted publishing (no API token). +# +# PyPI's trusted publisher is bound to this file name (publish.yml) and to the +# "pypi" and "testpypi" environments: keep them. +# +# - Publishing a GitHub release uploads to PyPI. The release tag must be +# "v" followed by the version in pyproject.toml, for example v1.0.0. +# - Running the workflow by hand uploads to TestPyPI. + +name: Publish on: release: @@ -10,74 +19,59 @@ permissions: jobs: build: - name: Build distribution + name: Build distributions runs-on: ubuntu-latest - steps: - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" - - - name: Install build dependencies + - uses: astral-sh/setup-uv@v7 + - name: Check that the tag matches the version + if: github.event_name == 'release' run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build package - run: python -m build - - - name: Check package - run: twine check dist/* - - - name: Store distribution packages - uses: actions/upload-artifact@v4 + version="$(uv version --short)" + if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match version ${version} in pyproject.toml" + exit 1 + fi + - run: uv sync --locked + - run: uv run pytest + - run: uv build + - uses: actions/upload-artifact@v5 with: - name: python-package-distributions + name: dist path: dist/ - publish-to-pypi: + pypi: name: Publish to PyPI - if: github.event_name == 'release' && github.event.action == 'published' + if: github.event_name == 'release' needs: build runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/toon_format + url: https://pypi.org/p/toon-format permissions: id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: - name: python-package-distributions + name: dist path: dist/ + - uses: astral-sh/setup-uv@v7 + - run: uv publish --trusted-publishing always - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - - publish-to-testpypi: + testpypi: name: Publish to TestPyPI if: github.event_name == 'workflow_dispatch' needs: build runs-on: ubuntu-latest environment: name: testpypi - url: https://test.pypi.org/p/toon_format + url: https://test.pypi.org/p/toon-format permissions: id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: - name: python-package-distributions + name: dist path: dist/ - - - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ + - uses: astral-sh/setup-uv@v7 + - run: uv publish --trusted-publishing always --publish-url https://test.pypi.org/legacy/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index c5405af..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Tests - -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] - -jobs: - test: - name: Test Python ${{ matrix.python-version }} - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v5 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: uv sync - - - name: Run tests with coverage - run: uv run pytest --cov=toon_format --cov-report=xml --cov-report=term --cov-report=html --cov-fail-under=85 - - - name: Upload coverage reports as artifact - uses: actions/upload-artifact@v4 - if: matrix.python-version == '3.12' - with: - name: coverage-reports - path: | - coverage.xml - htmlcov/ - retention-days: 30 - - - name: Coverage comment on PR - uses: py-cov-action/python-coverage-comment-action@v3 - if: matrix.python-version == '3.12' && github.event_name == 'pull_request' - with: - GITHUB_TOKEN: ${{ github.token }} - MINIMUM_GREEN: 90 - MINIMUM_ORANGE: 85 diff --git a/.gitignore b/.gitignore index 0575f02..75819cf 100644 --- a/.gitignore +++ b/.gitignore @@ -73,11 +73,6 @@ venv.bak/ *.swo *~ .claude/ -CLAUDE.md - -# DevContainer local overrides -.devcontainer/devcontainer.local.json -.devcontainer/.env # macOS .DS_Store @@ -103,8 +98,8 @@ Temporary Items # uv .uv/ -uv.lock PR_DESCRIPTION.md -AGENTS.md -.augment/ \ No newline at end of file +.augment/ +# MkDocs build output +site/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0d9e33d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# Git hooks run by prek (`uv run prek install`). The hooks call the tools +# through uv so that they use the versions locked in uv.lock. + +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run ruff check --fix + language: system + types_or: [python, pyi] + - id: ruff-format + name: ruff format + entry: uv run ruff format + language: system + types_or: [python, pyi] + - id: mypy + name: mypy + entry: uv run mypy + language: system + types_or: [python, pyi] + pass_filenames: false + - id: mkdocs + name: mkdocs build + entry: uv run --group docs mkdocs build --strict + language: system + # The API reference is generated from the docstrings in src/. + files: ^(docs/|src/|mkdocs\.yml$) + pass_filenames: false + # The remaining CI checks are slow: run them with + # `uv run prek run --all-files --hook-stage manual`. + - id: pytest + name: pytest + entry: uv run pytest -q --cov --cov-fail-under=95 + language: system + pass_filenames: false + stages: [manual] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..ffd3b14 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,19 @@ +# Read the Docs configuration: https://docs.readthedocs.com/platform/stable/config-file/v2.html +version: 2 + +build: + os: ubuntu-lts-latest + tools: + python: "3.14" + +mkdocs: + configuration: mkdocs.yml + fail_on_warning: true + +python: + install: + # Same pins as local development: uv reads pyproject.toml and uv.lock. + - method: uv + command: sync + groups: + - docs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..574405c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# AGENTS.md + +Rules for anyone changing this repository, human or coding agent. Read this +before editing; keep it current when a rule changes. + +## What this project is + +`toon-format` (import name `toon_format`) is the official Python +implementation of [TOON](https://github.com/toon-format/spec), the +Token-Oriented Object Notation. It is pure Python with no runtime dependencies +and mirrors the `json` module: `dumps`, `dump`, `loads`, `load`, and +`ToonDecodeError`, with `encode` and `decode` as aliases of `dumps` and +`loads`, the names the other TOON implementations use. + +**Target specification: TOON 4.1**, exposed as `toon_format.__toon_spec__`. +The conformance fixtures in `tests/fixtures/` are copied from the spec +repository and never edited by hand. The specification is the authority: when +this code, its tests, or another implementation disagree with +[SPEC.md](https://github.com/toon-format/spec/blob/main/SPEC.md), the +specification wins. + +## Environment + +The project is managed with [uv](https://docs.astral.sh/uv/). + +```bash +uv sync # create .venv with the dev dependencies +uv run pytest # full test suite +uv run pytest -k keyed # a subset +uv run prek install # install the Git hooks +``` + +[prek](https://github.com/j178/prek) reads `.pre-commit-config.yaml`: before +each commit it runs `ruff check --fix`, `ruff format`, `mypy`, and +`mkdocs build --strict`. `uv run prek run --all-files` runs these hooks on the +whole repository, and `uv run prek run --all-files --hook-stage manual` runs +every check below, tests included. + +## Checks before proposing a change + +All of these must pass; CI runs the same commands on Python 3.10 to 3.14. + +```bash +uv run ruff check . +uv run ruff format --check . +uv run mypy +uv run pytest --cov --cov-fail-under=95 +uv run --group docs mkdocs build --strict +``` + +Set `HYPOTHESIS_PROFILE=ci` to run the property tests with more examples, as CI does. + +## Code rules + +- **Language.** Code, comments, docstrings, commit messages, and documentation + are written in American English. +- **Python 3.10+.** Use modern syntax (`X | Y`, built-in generics, `from + __future__ import annotations`). Every module starts with a docstring. +- **Typing.** `mypy --strict` must pass. Avoid `Any` in new signatures unless + the value is genuinely arbitrary (user data, hook results). +- **No runtime dependencies.** Integrations import their library lazily or live + in an optional module (`toon_format.pydantic`), declared as an extra. +- **Documentation.** User-facing behavior is documented in `docs/`; the API + reference is generated from docstrings, which use the Google style. Preview + with `uv run --group docs mkdocs serve`. `README.md` stays short and links to + the documentation. +- **Public API.** Everything public is re-exported from `toon_format/__init__.py` and + listed in `__all__`. Everything else is private (`_module.py`, `_name`). Options + are keyword-only. Changing the public API requires updating `docs/`, + `CHANGELOG.md`, and its tests. +- **Cite the specification.** Comments and docstrings reference the section + they implement, for example `(§9.5)`. Keep that habit: it is how reviewers + check behavior. +- **Errors.** Encoding raises `TypeError` for values or keys that cannot be + represented and `ValueError` for invalid options, cycles, unpaired + surrogates, or excessive nesting, like `json.dumps`. Decoding raises only + `ToonDecodeError` for bad input, with `.line` and `.source` set. Never + replace an unrepresentable value with `null` silently. +- **Style.** Lines are at most 88 characters (the reason is in + `CONTRIBUTING.md`); `ruff format` decides the layout. Small functions, + early returns, no dead code, no commented-out code, no speculative options. + Match the surrounding code. + +### Encoder invariants + +- The form of a value follows from its shape and position, never from a + preference (§1.4): inline for primitive arrays, tabular for uniform objects, + keyed tabular for an object with at least two uniform entries in field or + root position, list form otherwise. +- A keyless array that is itself a list item never uses a field list (§9.4). +- A list-item object is rendered as fields one level deeper whose first line + moves onto the hyphen line; this single rule produces the §10 layout for + every first field, tabular or not. +- Every header declares the document delimiter (§11.1). +- Output has no trailing spaces, no trailing newline, and no comment lines. +- Normalization runs before rendering and yields only `dict`, `list`, `str`, + `int`, `float` (finite), `Decimal` (finite), `bool`, and `None`. + +### Decoder invariants + +- Comment lines and blank lines are handled while splitting lines, before any + structural decision (§5.1, §12). Line numbers in errors always refer to the + original document. +- Line classification follows §5.2. In strict mode a malformed header is an + error; in non-strict mode it falls back to a key-value line with a literal + key (§6). +- `strict=False` relaxes only what §14 lists as strict-only: counts and + widths, blank lines in header spans, indentation, over-indented lines, + duplicate keys (last write wins, first position kept), malformed headers, + and trailing content after a root array. Errors marked "any mode" in §14 stay + errors. +- A declared `[N]` never ends or truncates a scope; it is only compared with + what the scope contains. +- Numbers follow the §4 grammar, never Python's `int()`/`float()` grammar, + which accepts `1_000`, `+5`, and `inf`. + +### Documented choices + +The specification requires implementations to document some choices. They +are listed under "Specification choices" in `docs/data-types.md`; change that +section together with the code. + +## Tests + +- pytest only; parametrize instead of looping; assert on complete output, not + substrings. +- Every official fixture runs through `dumps`/`dump` and `loads`/`load`. A + fixture failure is a bug in this package. +- Other tests cover what the fixtures cannot express: host types, hooks, + errors, non-strict leniency, and regressions from the issue tracker (cite + the issue number). Hypothesis checks `loads(dumps(x)) == x`. +- Every bug fix comes with a test that fails without it. + +## Specification updates + +1. Run `uv run scripts/update_fixtures.py vX.Y.Z` and read the spec changelog. +2. Implement the changes, citing the new sections. +3. Update `__toon_spec__`, the README badge, `docs/data-types.md`, this file, + and `CHANGELOG.md`. + +## Commits + +- Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `refactor:`, + `ci:`, `chore:`); add `!` for breaking changes. +- Every user-visible change gets a line under *Unreleased* in `CHANGELOG.md`. +- Releases follow `CONTRIBUTING.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0e440b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [1.0.0] + +A complete rewrite. See [docs/migration.md](docs/migration.md) for upgrading +from 0.9. + +### Added + +- Full support for [TOON specification 4.1](https://github.com/toon-format/spec/blob/v4.1.1/SPEC.md): + keyed tabular objects, nested field groups, comment lines, canonical empty + arrays, and every strict-mode check. All official fixtures of spec `v4.1.1` pass. +- A `json`-style interface: `dumps`, `dump`, `loads`, `load`, with keyword-only + options. `encode` and `decode` are aliases of `dumps` and `loads`. +- `default` and `sort_keys` options for encoding; `parse_float`, `parse_int`, + `object_hook`, and `object_pairs_hook` for decoding. +- Encoding of dataclasses, attrs classes, enums, UUIDs, sets, `Decimal` (lossless), and Pydantic models. +- Decoding from `bytes` and binary files. +- `ToonDecodeError.line`, `.source`, and `.msg`. +- `toon_format.Delimiter` and `toon_format.__toon_spec__`. +- Documentation site built with MkDocs for Read the Docs. +- Property-based round-trip tests. + +### Changed + +- `encode` and `decode` take options as keyword arguments instead of a + dictionary. +- Unsupported values raise `TypeError` instead of becoming `null`. +- Python 3.10 or later is required. + +### Removed + +- The options types `EncodeOptions`, `DecodeOptions`, and `DelimiterKey`. +- The token helpers `count_tokens`, `estimate_savings`, and `compare_formats`. +- The `toon` command and `python -m toon_format`; use + [`@toon-format/cli`](https://www.npmjs.com/package/@toon-format/cli). +- Length markers (`[#N]`), removed from the specification. + +### Fixed + +- Nested objects inside list items (#33), nested length mismatches (#47), the + empty root array (#61), arrays of arrays (#62), empty objects as the first + field of a list item (#63), empty-string keys (#64), quoting of non-ASCII + keys (#65), negative array lengths, and CRLF input. + +## [0.9.0-beta.1] - 2025-11-08 + +First public beta, implementing TOON specification 1.3. + +[Unreleased]: https://github.com/toon-format/toon-python/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/toon-format/toon-python/releases/tag/v1.0.0 +[0.9.0-beta.1]: https://github.com/toon-format/toon-python/releases/tag/v0.9.0-beta.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5845aeb..4006e4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,106 +1,52 @@ # Contributing to toon-python -Thank you for your interest in contributing to the official Python implementation of TOON! - -## Project Setup - -This project uses [`uv`](https://github.com/astral-sh/uv) for dependency management. +Thank you for helping with the official Python implementation of TOON. + +1. Install [uv](https://docs.astral.sh/uv/) and run `uv sync`, then + `uv run prek install` to lint, format, type-check, and build the + documentation before each commit. Alternatively, open the repository in + the [dev container](https://containers.dev/) + (`.devcontainer/devcontainer.json`, also used by GitHub Codespaces), which + comes with uv, every dependency, and the Git hooks installed. +2. Read [AGENTS.md](AGENTS.md): it describes the code rules and the checks + every change must pass. +3. For behavior changes, start from the [specification](https://github.com/toon-format/spec/blob/main/SPEC.md) + and cite the section you implement. Questions about the format itself + belong in the [spec repository](https://github.com/toon-format/spec). +4. Add tests, update `CHANGELOG.md`, and open a pull request with a + Conventional Commits title (`fix: ...`, `feat: ...`). ```bash -# Clone the repository -git clone https://github.com/toon-format/toon-python.git -cd toon-python - -# Install dependencies (uv will create a virtual environment automatically) -uv sync - -# Run tests -uv run pytest - -# Run tests with coverage -uv run pytest --cov=src/toon_format --cov-report=term-missing +uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest ``` -## Development Workflow - -1. **Fork the repository** and create a feature branch -2. **Make your changes** following the coding standards below -3. **Add tests** for any new functionality -4. **Ensure all tests pass** and coverage remains high -5. **Submit a pull request** with a clear description - -## Coding Standards - -### Python Version Support - -We support Python 3.8 and above (including Python 3.13 and 3.14). - -### Type Safety - -- All code must include type hints -- Run `mypy` before committing: - ```bash - uv run mypy src/ - ``` - -### Code Style - -- We use `ruff` for linting and formatting -- Run before committing: - ```bash - uv run ruff check src/ tests/ - uv run ruff format src/ tests/ - ``` - -### Testing - -- All new features must include tests -- Maintain test coverage at **85%+ line coverage** -- Tests should cover edge cases and spec compliance -- Run the full test suite: - ```bash - uv run pytest tests/ - - # Run with coverage report - uv run pytest --cov=toon_format --cov-report=term --cov-fail-under=85 - ``` - -## SPEC Compliance - -All implementations must comply with the [TOON specification](https://github.com/toon-format/spec/blob/main/SPEC.md). - -Before submitting changes that affect encoding/decoding behavior: -1. Verify against the official SPEC.md -2. Add tests for the specific spec sections you're implementing -3. Document any spec version requirements - -## Pull Request Guidelines - -- **Title**: Use a clear, descriptive title -- **Description**: Explain what changes you made and why -- **Tests**: Include tests for your changes -- **Documentation**: Update README or documentation if needed -- **Commits**: Use clear commit messages ([Conventional Commits](https://www.conventionalcommits.org/) preferred) - -Your pull request will use our standard template which guides you through the required information. - -## Communication - -- **GitHub Issues**: For bug reports and feature requests -- **GitHub Discussions**: For questions and general discussion -- **Pull Requests**: For code reviews and implementation discussion +## Code style -## Maintainers +`ruff format` formats the code and `ruff check` lints it; CI runs both. -This is a collaborative project. Current maintainers: +Lines are limited to **88 characters**, the default of ruff and Black. It is +the de facto standard of the Python ecosystem: FastAPI, Flask, Django, HTTPX, +Requests, pytest, and pandas all use it. PEP 8's 79 characters wrap ordinary +code too often, and a longer limit would diverge from the projects most +contributors already know. The value is set explicitly in `pyproject.toml` so +that nobody has to know the default. -- [@xaviviro](https://github.com/xaviviro) -- [@davidpirogov](https://github.com/davidpirogov) -- [@bpradana](https://github.com/bpradana) -- [@Justar96](https://github.com/Justar96) +## Releases -All maintainers have equal and consensual decision-making power. For major architectural decisions, please open a discussion issue first. +Publishing happens only through the `Publish` workflow +(`.github/workflows/publish.yml`), with PyPI trusted publishing. PyPI binds +the publisher to that file name and to the `pypi` and `testpypi` +environments: do not rename them. -## License +1. `uv version --bump minor` (or `patch`, `major`, or an explicit version), + and move the *Unreleased* entries of `CHANGELOG.md` under the new version. +2. Merge that change through a pull request. +3. Optional: run the `Publish` workflow by hand to upload to TestPyPI. +4. Create a GitHub release tagged `vX.Y.Z`. The workflow checks that the tag + matches the version, runs the tests, builds, and uploads to PyPI. -By contributing, you agree that your contributions will be licensed under the MIT License. +The documentation is meant to live at , +built by Read the Docs from `.readthedocs.yaml`. That project does not exist +yet: import the repository on readthedocs.org with the slug `toon-python`, then +point the "Documentation" section of `README.md` and the `Documentation` URL in +`pyproject.toml` to it, and add a Read the Docs badge next to the others. diff --git a/LICENSE b/LICENSE index 8354b19..6793327 100644 --- a/LICENSE +++ b/LICENSE @@ -4,6 +4,7 @@ Copyright (c) 2025-PRESENT Xavi Vinaixa Copyright (c) 2025-PRESENT David Pirogov Copyright (c) 2025-PRESENT Justar Copyright (c) 2025-PRESENT Johann Schopplich +Copyright (c) 2026-PRESENT Alessio Sanfratello Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index fa2c639..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,212 +0,0 @@ -# Publishing toon_format to PyPI - -This guide explains how to publish the toon_format package to PyPI (Python Package Index). - -## Prerequisites - -### 1. PyPI Accounts - -Create accounts on both platforms: - -- **TestPyPI** (for testing): https://test.pypi.org/account/register/ -- **PyPI** (production): https://pypi.org/account/register/ - -### 2. GitHub Environments - -The publish workflow uses GitHub environments with trusted publishing (no API tokens needed!). - -#### Set up environments in GitHub: - -1. Go to your repository Settings > Environments -2. Create two environments: - - `testpypi` - for TestPyPI releases - - `pypi` - for production PyPI releases -3. For each environment, configure: - - **Deployment protection rules** (optional but recommended): - - Required reviewers (for production releases) - - Wait timer (optional delay before deployment) - -#### Configure Trusted Publishers: - -**For TestPyPI:** -1. Log in to https://test.pypi.org -2. Go to Account Settings > Publishing -3. Add a new pending publisher: - - PyPI Project Name: `toon_format` - - Owner: `toon-format` - - Repository: `toon-python` - - Workflow: `publish.yml` - - Environment: `testpypi` - -**For PyPI:** -1. Log in to https://pypi.org -2. Go to Account Settings > Publishing -3. Add a new pending publisher: - - PyPI Project Name: `toon_format` - - Owner: `toon-format` - - Repository: `toon-python` - - Workflow: `publish.yml` - - Environment: `pypi` - -> Note: After the first successful publish, the project will be registered and future publishes will use the same trusted publisher configuration. - -## Release Process - -### Step 1: Prepare the Release - -1. **Update version number** in two places: - - `pyproject.toml` (line 3): `version = "X.Y.Z"` - - `src/toon_format/__init__.py` (line 28): `__version__ = "X.Y.Z"` - -2. **Update changelog** (if exists) or create release notes - -3. **Run tests locally**: - ```bash - uv run pytest - uv run ruff check . - uv run mypy src/toon_format - ``` - -4. **Build and test locally**: - ```bash - # Clean previous builds - rm -rf dist/ build/ *.egg-info - - # Build the package - python -m build - - # Verify the package contents - python -m zipfile -l dist/toon_format-X.Y.Z-py3-none-any.whl - - # Test installation in a clean environment - python -m venv test_env - test_env/bin/pip install dist/toon_format-X.Y.Z-py3-none-any.whl - test_env/bin/python -c "import toon_format; print(toon_format.__version__)" - rm -rf test_env - ``` - -### Step 2: Commit and Tag - -1. **Commit version changes**: - ```bash - git add pyproject.toml src/toon_format/__init__.py - git commit -m "Bump version to X.Y.Z" - ``` - -2. **Create and push tag**: - ```bash - git tag -a vX.Y.Z -m "Release version X.Y.Z" - git push origin main - git push origin vX.Y.Z - ``` - -### Step 3: Test on TestPyPI (Recommended) - -Before publishing to production PyPI, test on TestPyPI: - -1. Go to GitHub Actions: https://github.com/toon-format/toon-python/actions -2. Select the "Publish to PyPI" workflow -3. Click "Run workflow" -4. Select branch: `main` (or the tag `vX.Y.Z`) -5. Click "Run workflow" - - Note: Manual workflow dispatch automatically publishes to TestPyPI - -6. **Verify the TestPyPI upload**: - - Check the package: https://test.pypi.org/project/toon_format/ - - Test installation: - ```bash - pip install --index-url https://test.pypi.org/simple/ toon_format - ``` - -### Step 4: Publish to PyPI - -**Automatic (via GitHub Release)** - -1. Go to https://github.com/toon-format/toon-python/releases/new -2. Select the tag you created: `vX.Y.Z` -3. Release title: `Version X.Y.Z` -4. Description: Add release notes and changelog -5. Click "Publish release" -6. The GitHub Action will automatically build and publish to PyPI - -### Step 5: Verify the Release - -1. **Check PyPI**: https://pypi.org/project/toon_format/ -2. **Test installation**: - ```bash - pip install toon_format - python -c "import toon_format; print(toon_format.__version__)" - ``` -3. **Update README badge** (optional): - ```markdown - [![PyPI version](https://badge.fury.io/py/toon_format.svg)](https://pypi.org/project/toon_format/) - ``` - -## Troubleshooting - -### Build fails with "metadata missing" - -This is usually a configuration issue in `pyproject.toml`. Verify: -- All required fields are present (name, version, description, etc.) -- Project URLs are properly formatted -- Author email is valid - -### Trusted publishing fails - -If the trusted publisher configuration fails: -1. Verify the environment name matches exactly -2. Check that the repository owner and name are correct -3. Ensure the workflow file path is correct (`publish.yml`) -4. Make sure the PyPI project name is available or already claimed by you - -### Package already exists on PyPI - -PyPI doesn't allow overwriting published versions. You must: -1. Increment the version number -2. Create a new tag -3. Publish the new version - -## Version Numbering - -Follow [Semantic Versioning](https://semver.org/): - -- **MAJOR version** (X.0.0): Incompatible API changes -- **MINOR version** (0.X.0): New functionality, backward compatible -- **PATCH version** (0.0.X): Bug fixes, backward compatible - -### Agreed Roadmap (from Discussion #18): - -- **0.8.x** - Initial code set, tests, documentation, migration from toon-llm -- **0.9.x** - Serializer, spec compliance, publishing to PyPI (test and prod) -- **1.0.0-rc.x** - Production readiness candidates -- **1.0.0** - Official stable release 🎉 - -Examples: -- `0.9.0-beta.1` - First beta release for testing -- `0.9.0-beta.2` - Second beta with fixes -- `0.9.0` - First minor release with new features -- `1.0.0-rc.1` - Release candidate -- `1.0.0` - First stable release - -## Checklist - -Before each release, verify: - -- [ ] All tests pass (`uv run pytest`) -- [ ] Linting passes (`uv run ruff check .`) -- [ ] Type checking passes (`uv run mypy src/toon_format`) -- [ ] Version updated in `pyproject.toml` and `src/toon_format/__init__.py` -- [ ] Changes committed and pushed to `main` -- [ ] Git tag created and pushed -- [ ] Package tested on TestPyPI (optional but recommended) -- [ ] GitHub Release created -- [ ] Package verified on PyPI -- [ ] Installation tested from PyPI - -## References - -- [Python Packaging Guide](https://packaging.python.org/) -- [PyPI Help](https://pypi.org/help/) -- [Trusted Publishing Guide](https://docs.pypi.org/trusted-publishers/) -- [GitHub Actions Publishing](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/) -- [TOON Format Specification](https://github.com/toon-format/spec) diff --git a/README.md b/README.md index 708a814..addfe70 100644 --- a/README.md +++ b/README.md @@ -1,213 +1,132 @@ -# TOON Format for Python +# TOON for Python -[![Tests](https://github.com/toon-format/toon-python/actions/workflows/test.yml/badge.svg)](https://github.com/toon-format/toon-python/actions) -[![Python Versions](https://img.shields.io/pypi/pyversions/toon_format.svg)](https://pypi.org/project/toon_format/) +[![CI](https://github.com/toon-format/toon-python/actions/workflows/ci.yml/badge.svg)](https://github.com/toon-format/toon-python/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/toon-format.svg)](https://pypi.org/project/toon-format/) +[![Python versions](https://img.shields.io/pypi/pyversions/toon-format.svg)](https://pypi.org/project/toon-format/) +[![TOON spec](https://img.shields.io/badge/TOON%20spec-4.1-blue)](https://github.com/toon-format/spec/blob/main/SPEC.md) -> **⚠️ Beta Status (v0.9.x):** This library is in active development and working towards spec compliance. Beta published to PyPI. API may change before 1.0.0 release. +The official Python implementation of **TOON** (Token-Oriented Object Notation): +a compact, human-readable encoding of the JSON data model, designed to save +tokens in LLM prompts. -Compact, human-readable serialization format for LLM contexts with **30-60% token reduction** vs JSON. Combines YAML-like indentation with CSV-like tabular arrays. Working towards full compatibility with the [official TOON specification](https://github.com/toon-format/spec). +- **Complete**: implements [TOON specification 4.1](https://github.com/toon-format/spec/blob/main/SPEC.md) + and passes every official conformance fixture. +- **Familiar**: `dumps`, `dump`, `loads`, and `load`, like the `json` module, + with `encode` and `decode` as aliases. +- **Lossless**: `loads(dumps(data)) == data` for every JSON-compatible value. +- **Pure Python**, no dependencies, fully typed, Python 3.10+. -**Key Features:** Minimal syntax • Tabular arrays for uniform data • Array length validation • Python 3.8+ • Comprehensive test coverage. - -```bash -# Beta published to PyPI - install from source: -git clone https://github.com/toon-format/toon-python.git -cd toon-python -uv sync - -# Or install directly from GitHub: -pip install git+https://github.com/toon-format/toon-python.git -``` - -## Quick Start +## Example ```python -from toon_format import encode, decode - -# Simple object -encode({"name": "Alice", "age": 30}) -# name: Alice -# age: 30 - -# Tabular array (uniform objects) -encode([{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]) -# [2,]{id,name}: -# 1,Alice -# 2,Bob - -# Decode back to Python -decode("items[2]: apple,banana") -# {'items': ['apple', 'banana']} +import toon_format + +data = { + "context": {"task": "Our favorite hikes together", "location": "Boulder"}, + "friends": ["ana", "luis", "sam"], + "hikes": [ + {"id": 1, "name": "Blue Lake Trail", "distanceKm": 7.5, "wasSunny": True}, + {"id": 2, "name": "Ridge Overlook", "distanceKm": 9.2, "wasSunny": False}, + ], +} + +text = toon_format.dumps(data) +print(text) ``` -## CLI Usage - -```bash -# Auto-detect format by extension -toon input.json -o output.toon # Encode -toon data.toon -o output.json # Decode -echo '{"x": 1}' | toon - # Stdin/stdout - -# Options -toon data.json --encode --delimiter "\t" --length-marker -toon data.toon --decode --no-strict --indent 4 ``` - -**Options:** `-e/--encode` `-d/--decode` `-o/--output` `--delimiter` `--indent` `--length-marker` `--no-strict` - -## API Reference - -### `encode(value, options=None)` → `str` - -```python -encode({"id": 123}, {"delimiter": "\t", "indent": 4, "lengthMarker": "#"}) +context: + task: Our favorite hikes together + location: Boulder +friends[3]: ana,luis,sam +hikes[2]{id,name,distanceKm,wasSunny}: + 1,Blue Lake Trail,7.5,true + 2,Ridge Overlook,9.2,false ``` -**Options:** -- `delimiter`: `","` (default), `"\t"`, `"|"` -- `indent`: Spaces per level (default: `2`) -- `lengthMarker`: `""` (default) or `"#"` to prefix array lengths - -### `decode(input_str, options=None)` → `Any` - ```python -decode("id: 123", {"indent": 2, "strict": True}) +assert toon_format.loads(text) == data ``` -**Options:** -- `indent`: Expected indent size (default: `2`) -- `strict`: Validate syntax, lengths, delimiters (default: `True`) - -### Token Counting & Comparison +Keys of uniform arrays are written once, as a header, and each object becomes +a row. With OpenAI's `o200k_base` tokenizer, this document takes 65 tokens, +against 76 for compact JSON and 130 for indented JSON. The saving grows with +the number of rows: 100 uniform records take 38% fewer tokens than compact +JSON. Deeply nested or irregular data gains less. -Measure token efficiency and compare formats: +## Installation -```python -from toon_format import estimate_savings, compare_formats, count_tokens - -# Measure savings -data = {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} -result = estimate_savings(data) -print(f"Saves {result['savings_percent']:.1f}% tokens") # Saves 42.3% tokens - -# Visual comparison -print(compare_formats(data)) -# Format Comparison -# ──────────────────────────────────────────────── -# Format Tokens Size (chars) -# JSON 45 123 -# TOON 28 85 -# ──────────────────────────────────────────────── -# Savings: 17 tokens (37.8%) - -# Count tokens directly -toon_str = encode(data) -tokens = count_tokens(toon_str) # Uses tiktoken (gpt5/gpt5-mini) +```bash +pip install toon-format +# or +uv add toon-format ``` -**Requires tiktoken:** `uv add tiktoken` (benchmark features are optional) +The module is `toon_format`. Install `toon-format[pydantic]` for the Pydantic +integration. -## Format Specification +## Usage -| Type | Example Input | TOON Output | -|------|---------------|-------------| -| **Object** | `{"name": "Alice", "age": 30}` | `name: Alice`
`age: 30` | -| **Primitive Array** | `[1, 2, 3]` | `[3]: 1,2,3` | -| **Tabular Array** | `[{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]` | `[2,]{id,name}:`
  `1,A`
  `2,B` | -| **Mixed Array** | `[{"x": 1}, 42, "hi"]` | `[3]:`
  `- x: 1`
  `- 42`
  `- hi` | +Encoding and decoding follow the `json` module: -**Quoting:** Only when necessary (empty, keywords, numeric strings, whitespace, structural chars, delimiters) - -**Type Normalization:** `Infinity/NaN/Functions` → `null` • `Decimal` → `float` • `datetime` → ISO 8601 • `-0` → `0` - -## Pydantic Integration – (Structured TOON for LLM Outputs) - -Adds a **completely optional** Pydantic integration via the `[pydantic]` extra. - -```bash -pip install "toon-python[pydantic]" +```python +toon_format.dumps(data, delimiter="\t") # tabs often tokenize better than commas +toon_format.dumps(data, default=str) # fallback for unsupported types +toon_format.loads(text, parse_float=Decimal) # same hooks as json.loads +toon_format.loads(llm_output, strict=False) # tolerate wrong counts and indentation ``` -### Features +Invalid documents raise `toon_format.ToonDecodeError`, a `ValueError` that +carries the 1-based `line` and the `source` line of the error. -- Schema: 50–60 % smaller than model_json_schema() -- Zero JSON parsing errors -- Works with `Instructor`, `Outlines`, `Marvin`, `LangChain agents`, etc. -- Full Pydantic validation preserved - -## Usage After Release +With Pydantic, `toon_format.dumps` accepts any model, and `ToonPydanticModel` +adds TOON counterparts of the JSON helpers: ```python from toon_format.pydantic import ToonPydanticModel + class User(ToonPydanticModel): name: str age: int - email: str | None = None -# Convert schema to TOON for LLM system prompts -schema_toon = User.schema_to_toon() -# name:str,age:int,email:str|None -# Parse LLM TOON output into validated Pydantic model -toon_output = "name:Ansar,age:25,email:ansar@example.com" -user = User.model_validate_toon(toon_output) - -# user.name → "Ansar" -# user.age → 25 -# user.email → "ansar@example.com" - -# Serialize a model instance back to TOON -toon_str = user.model_dump_toon() +user = User.model_validate_toon("name: Ada\nage: 36") ``` -## Development +## Command line + +This package has no command; use the official +[`@toon-format/cli`](https://www.npmjs.com/package/@toon-format/cli), which +runs with Node.js and needs no installation: ```bash -# Setup (requires uv: https://docs.astral.sh/uv/) -git clone https://github.com/toon-format/toon-python.git -cd toon-python -uv sync - -# Run tests (792 tests, 91% coverage, 85% enforced) -uv run pytest --cov=toon_format --cov-report=term - -# Code quality -uv run ruff check src/ tests/ # Lint -uv run ruff format src/ tests/ # Format -uv run mypy src/ # Type check +npx @toon-format/cli data.json -o data.toon # JSON to TOON +npx @toon-format/cli data.toon -o data.json # TOON to JSON +npx @toon-format/cli data.json --stats # token savings against JSON ``` -**CI/CD:** GitHub Actions • Python 3.8-3.14 • Coverage enforcement • PR coverage comments - -## Project Status & Roadmap - -Following semantic versioning towards 1.0.0: - -- **v0.8.x** - Initial code set, tests, documentation ✅ -- **v0.9.x** - Serializer improvements, spec compliance testing, publishing setup (current) -- **v1.0.0-rc.x** - Release candidates for production readiness -- **v1.0.0** - First stable release with full spec compliance +## Documentation -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. +The full documentation is in +**[`docs/`](https://github.com/toon-format/toon-python/tree/main/docs)**: usage, +the Python type mapping, the choices the specification leaves to +implementations, and Pydantic. -## Documentation +## Migrating from `toon_format` 0.9 -- [📘 Full Documentation](docs/) - Complete guides and references -- [🔧 API Reference](docs/api.md) - Detailed function documentation -- [📋 Format Specification](docs/format.md) - TOON syntax and rules -- [🤖 LLM Integration](docs/llm-integration.md) - Best practices for LLM usage -- [📜 TOON Spec](https://github.com/toon-format/spec) - Official specification -- [🐛 Issues](https://github.com/toon-format/toon-python/issues) - Bug reports and features -- [🤝 Contributing](CONTRIBUTING.md) - Contribution guidelines +The distribution and the module keep their names, and `encode(value)` and +`decode(text)` keep working. Options are now keyword arguments instead of a +dictionary, the token helpers and the `toon` command are gone, and the output +follows specification 4.1. See the +[migration guide](https://github.com/toon-format/toon-python/blob/main/docs/migration.md). -## Contributors +## Contributing -- [Xavi Vinaixa](https://github.com/xaviviro) -- [David Pirogov](https://github.com/davidpirogov) -- [Justar](https://github.com/Justar96) -- [Johann Schopplich](https://github.com/johannschopplich) +See [CONTRIBUTING.md](https://github.com/toon-format/toon-python/blob/main/CONTRIBUTING.md). +Project conventions for humans and coding agents are in +[AGENTS.md](https://github.com/toon-format/toon-python/blob/main/AGENTS.md). ## License -MIT License – see [LICENSE](LICENSE) for details +[MIT](https://github.com/toon-format/toon-python/blob/main/LICENSE) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index d39e328..0000000 --- a/docs/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# Documentation - -Comprehensive documentation for toon_format Python package. - -## Quick Links - -- [API Reference](api.md) - Complete function and class documentation -- [Format Specification](format.md) - Detailed TOON syntax and rules -- [LLM Integration](llm-integration.md) - Best practices for using TOON with LLMs - -## Getting Started - -New to TOON? Start here: - -1. Read the [main README](../README.md) for quick start examples -2. Review the [Format Specification](format.md) to understand TOON syntax -3. Check the [API Reference](api.md) for detailed function usage -4. See [LLM Integration](llm-integration.md) for advanced use cases - -## Documentation Structure - -### [API Reference](api.md) - -Complete reference for all public functions and classes: -- `encode()` - Convert Python to TOON -- `decode()` - Convert TOON to Python -- `count_tokens()` - Count tokens in text using tiktoken -- `estimate_savings()` - Compare JSON vs TOON token counts -- `compare_formats()` - Generate formatted comparison table -- `EncodeOptions` - Encoding configuration -- `DecodeOptions` - Decoding configuration -- `ToonDecodeError` - Error handling -- Type normalization rules -- Advanced usage patterns - -### [Format Specification](format.md) - -Detailed explanation of TOON format rules: -- Objects (key-value pairs, nesting) -- Arrays (primitive, tabular, list, nested) -- Delimiters (comma, tab, pipe) -- String quoting rules -- Primitives (numbers, booleans, null) -- Indentation rules -- Complete format examples - -### [LLM Integration](llm-integration.md) - -Best practices for LLM usage: -- Why TOON for LLMs -- Prompting strategies -- Token efficiency techniques -- Real-world use cases -- Error handling -- Integration examples (OpenAI, Anthropic) -- Performance metrics -- Debugging tips - -## Roadmap - -The following features are planned for future releases: - -- **Comprehensive Benchmarks**: Detailed token efficiency comparisons across various data structures and LLM models (gpt5, gpt5-mini, Claude) -- **Official Documentation Site**: Dedicated documentation website with interactive examples and tutorials - -Stay tuned for updates! - -## External Resources - -- [Official TOON Specification](https://github.com/toon-format/spec) - Normative spec -- [TypeScript Reference](https://github.com/toon-format/toon) - Original implementation -- [Test Fixtures](../tests/README.md) - Spec compliance test suite -- [Contributing Guide](../CONTRIBUTING.md) - How to contribute - -## Examples - -### Basic Encoding - -```python -from toon_format import encode - -data = {"name": "Alice", "age": 30} -print(encode(data)) -# name: Alice -# age: 30 -``` - -### Basic Decoding - -```python -from toon_format import decode - -toon = "items[2]: apple,banana" -data = decode(toon) -# {'items': ['apple', 'banana']} -``` - -### With Options - -```python -# Custom delimiter -encode([1, 2, 3], {"delimiter": "\t"}) -# [3 ]: 1 2 3 - -# Lenient decoding -decode("items[5]: a,b,c", {"strict": False}) -# {'items': ['a', 'b', 'c']} # Accepts length mismatch -``` - -### Token Efficiency - -```python -from toon_format import estimate_savings, compare_formats - -data = {"employees": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} - -# Get savings metrics -result = estimate_savings(data) -print(f"Saves {result['savings_percent']:.1f}% tokens") - -# Get formatted comparison -print(compare_formats(data)) -# Format Comparison -# ──────────────────────────────────────────────── -# Format Tokens Size (chars) -# JSON 45 123 -# TOON 28 85 -# ──────────────────────────────────────────────── -# Savings: 17 tokens (37.8%) -``` - -## Support - -- **Bug Reports:** [GitHub Issues](https://github.com/toon-format/toon-python/issues) -- **Questions:** [GitHub Discussions](https://github.com/toon-format/toon-python/discussions) -- **Contributing:** See [CONTRIBUTING.md](../CONTRIBUTING.md) - -## License - -MIT License - see [LICENSE](../LICENSE) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..347db6f --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,15 @@ +# API reference + +::: toon_format + options: + members: + - dumps + - dump + - loads + - load + - encode + - decode + - ToonDecodeError + - Delimiter + +::: toon_format.pydantic.ToonPydanticModel diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 3db61da..0000000 --- a/docs/api.md +++ /dev/null @@ -1,447 +0,0 @@ -# API Reference - -Complete API documentation for toon_format Python package. - -## Core Functions - -### `encode(value, options=None)` - -Converts a Python value to TOON format string. - -**Parameters:** -- `value` (Any): JSON-serializable Python value (dict, list, primitives, or nested structures) -- `options` (dict | EncodeOptions, optional): Encoding configuration - -**Returns:** `str` - TOON-formatted string - -**Raises:** -- `ValueError`: If value contains non-normalizable types - -**Examples:** - -```python -from toon_format import encode - -# Simple encoding -encode({"name": "Alice", "age": 30}) -# name: Alice -# age: 30 - -# With options (dict) -encode([1, 2, 3], {"delimiter": "\t"}) -# [3 ]: 1 2 3 - -# With typed options (TypedDict) -from toon_format.types import EncodeOptions -options: EncodeOptions = {"delimiter": "|", "indent": 4, "lengthMarker": "#"} -encode([1, 2, 3], options) -# [#3|]: 1|2|3 -``` - ---- - -### `decode(input_str, options=None)` - -Converts a TOON-formatted string back to Python values. - -**Parameters:** -- `input_str` (str): TOON-formatted string -- `options` (dict | DecodeOptions, optional): Decoding configuration - -**Returns:** `Any` - Python value (dict, list, or primitive) - -**Raises:** -- `ToonDecodeError`: On syntax errors, validation failures, or malformed input - -**Examples:** - -```python -from toon_format import decode - -# Simple decoding -decode("name: Alice\nage: 30") -# {'name': 'Alice', 'age': 30} - -# Tabular arrays -decode("users[2,]{id,name}:\n 1,Alice\n 2,Bob") -# {'users': [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]} - -# With options (class) -from toon_format.types import DecodeOptions -decode(" item: value", DecodeOptions(indent=4, strict=False)) - -# Or use dict -decode(" item: value", {"indent": 4, "strict": False}) -``` - ---- - -## Options Classes - -### `EncodeOptions` - -TypedDict for encoding configuration. Use dict syntax to create options. - -**Fields:** -- `delimiter` (str, optional): Array value separator - - `","` - Comma (default) - - `"\t"` - Tab - - `"|"` - Pipe -- `indent` (int, optional): Spaces per indentation level (default: `2`) -- `lengthMarker` (Literal["#"] | Literal[False], optional): Prefix for array lengths - - `False` - No marker (default) - - `"#"` - Add `#` prefix (e.g., `[#5]`) - -**Example:** - -```python -from toon_format import encode -from toon_format.types import EncodeOptions - -# EncodeOptions is a TypedDict, use dict syntax -options: EncodeOptions = { - "delimiter": "\t", - "indent": 4, - "lengthMarker": "#" -} - -data = [{"id": 1}, {"id": 2}] -print(encode(data, options)) -# [#2 ]{id}: -# 1 -# 2 -``` - ---- - -### `DecodeOptions` - -Configuration class for decoding behavior. - -**Constructor:** -```python -DecodeOptions(indent=2, strict=True) -``` - -**Parameters:** -- `indent` (int): Expected spaces per indentation level (default: `2`) -- `strict` (bool): Enable strict validation (default: `True`) - -**Note:** Unlike `EncodeOptions` (which is a TypedDict), `DecodeOptions` is a class. You can also pass a plain dict with the same keys to `decode()`. - -**Strict Mode Validation:** - -When `strict=True`, the decoder enforces: -- **Indentation**: Must be consistent multiples of `indent` value -- **No tabs**: Tabs in indentation cause errors -- **Array lengths**: Declared length must match actual element count -- **Delimiter consistency**: All rows must use same delimiter as header -- **No blank lines**: Blank lines within arrays are rejected -- **Valid syntax**: Missing colons, unterminated strings, invalid escapes fail - -When `strict=False`: -- Lenient indentation (accepts tabs, inconsistent spacing) -- Array length mismatches allowed -- Blank lines tolerated - -**Example:** - -```python -from toon_format import decode -from toon_format.types import DecodeOptions - -# Strict validation (default) -try: - decode("items[5]: a,b,c", DecodeOptions(strict=True)) -except ToonDecodeError as e: - print(f"Error: {e}") # Length mismatch: expected 5, got 3 - -# Lenient parsing -result = decode("items[5]: a,b,c", DecodeOptions(strict=False)) -# {'items': ['a', 'b', 'c']} # Accepts mismatch -``` - ---- - -## Error Handling - -### `ToonDecodeError` - -Exception raised when decoding fails. - -**Attributes:** -- `message` (str): Human-readable error description -- `line` (int | None): Line number where error occurred (if applicable) - -**Common Error Scenarios:** - -```python -from toon_format import decode, ToonDecodeError - -# Unterminated string -try: - decode('text: "unterminated') -except ToonDecodeError as e: - print(e) # Unterminated quoted string - -# Array length mismatch -try: - decode("items[3]: a,b") # Declared 3, provided 2 -except ToonDecodeError as e: - print(e) # Expected 3 items, but got 2 - -# Invalid indentation -try: - decode("outer:\n inner: value") # 3 spaces, not multiple of 2 -except ToonDecodeError as e: - print(e) # Invalid indentation: expected multiple of 2 -``` - ---- - -## Type Normalization - -Non-JSON types are automatically normalized during encoding: - -| Python Type | Normalized To | Example | -|-------------|---------------|---------| -| `datetime.datetime` | ISO 8601 string | `"2024-01-15T10:30:00"` | -| `datetime.date` | ISO 8601 date | `"2024-01-15"` | -| `decimal.Decimal` | `float` | `3.14` | -| `tuple` | `list` | `[1, 2, 3]` | -| `set` / `frozenset` | Sorted `list` | `[1, 2, 3]` | -| `float('inf')` | `null` | `null` | -| `float('-inf')` | `null` | `null` | -| `float('nan')` | `null` | `null` | -| Functions / Callables | `null` | `null` | -| `-0.0` | `0` | `0` | - -**Example:** - -```python -from datetime import datetime, date -from decimal import Decimal - -data = { - "timestamp": datetime(2024, 1, 15, 10, 30), - "date": date(2024, 1, 15), - "price": Decimal("19.99"), - "tags": {"alpha", "beta"}, # set - "coords": (10, 20), # tuple - "infinity": float("inf"), - "func": lambda x: x -} - -toon = encode(data) -# timestamp: "2024-01-15T10:30:00" -# date: "2024-01-15" -# price: 19.99 -# tags[2]: alpha,beta -# coords[2]: 10,20 -# infinity: null -# func: null -``` - ---- - -## Utility Functions - -### `count_tokens(text, encoding="o200k_base")` - -Count tokens in a text string using tiktoken. - -**Parameters:** -- `text` (str): The string to tokenize -- `encoding` (str, optional): Tokenizer encoding name (default: `"o200k_base"` for gpt5/gpt5-mini) - - Other options: `"cl100k_base"` (GPT-3.5), `"p50k_base"` (older models) - -**Returns:** `int` - The number of tokens in the text - -**Raises:** -- `RuntimeError`: If tiktoken is not installed - -**Requirements:** -- Install tiktoken: `uv add tiktoken` or `uv add toon_format[benchmark]` - -**Example:** - -```python -from toon_format import count_tokens - -text = "Hello, world!" -tokens = count_tokens(text) -print(f"Token count: {tokens}") -# Token count: 4 -``` - ---- - -### `estimate_savings(data, encoding="o200k_base")` - -Compare token counts between JSON and TOON formats. - -**Parameters:** -- `data` (Any): Python dict or list to compare -- `encoding` (str, optional): Tokenizer encoding name (default: `"o200k_base"`) - -**Returns:** `dict` containing: -- `json_tokens` (int): Token count for JSON format -- `toon_tokens` (int): Token count for TOON format -- `savings` (int): Absolute token savings (json_tokens - toon_tokens) -- `savings_percent` (float): Percentage savings - -**Example:** - -```python -from toon_format import estimate_savings - -data = { - "employees": [ - {"id": 1, "name": "Alice"}, - {"id": 2, "name": "Bob"} - ] -} - -result = estimate_savings(data) -print(f"JSON tokens: {result['json_tokens']}") -print(f"TOON tokens: {result['toon_tokens']}") -print(f"Savings: {result['savings_percent']:.1f}%") -# JSON tokens: 45 -# TOON tokens: 28 -# Savings: 37.8% -``` - -**Note:** Significant savings are typically achieved with structured data, especially arrays of uniform objects (tabular data). - ---- - -### `compare_formats(data, encoding="o200k_base")` - -Generate a formatted comparison table showing JSON vs TOON metrics. - -**Parameters:** -- `data` (Any): Python dict or list to compare -- `encoding` (str, optional): Tokenizer encoding name (default: `"o200k_base"`) - -**Returns:** `str` - Formatted table as multi-line string showing token counts, character sizes, and savings percentage - -**Example:** - -```python -from toon_format import compare_formats - -data = { - "users": [ - {"id": 1, "name": "Alice", "age": 30}, - {"id": 2, "name": "Bob", "age": 25} - ] -} - -print(compare_formats(data)) -# Format Comparison -# ──────────────────────────────────────────────── -# Format Tokens Size (chars) -# JSON 45 123 -# TOON 28 85 -# ──────────────────────────────────────────────── -# Savings: 17 tokens (37.8%) -``` - -**Note:** Useful for quick visual comparison during development and optimization. - ---- - -## Advanced Usage - -### Working with Large Integers - -Integers larger than 2^53-1 are converted to strings for JavaScript compatibility: - -```python -encode({"bigInt": 9007199254740992}) -# bigInt: "9007199254740992" -``` - -### Custom Delimiters - -Use different delimiters based on your data: - -```python -# Comma (best for general use) -encode([1, 2, 3]) -# [3]: 1,2,3 - -# Tab (for data with commas) -encode(["a,b", "c,d"], {"delimiter": "\t"}) -# [2 ]: a,b c,d - -# Pipe (alternative) -encode([1, 2, 3], {"delimiter": "|"}) -# [3|]: 1|2|3 -``` - -### Length Markers - -Add `#` prefix for explicit length indication: - -```python -users = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] - -# Without marker -encode(users) -# [2,]{id,name}: -# 1,Alice -# 2,Bob - -# With marker -encode(users, {"lengthMarker": "#"}) -# [#2,]{id,name}: -# 1,Alice -# 2,Bob -``` - -### Zero Indentation - -Use `indent=0` for minimal whitespace (not recommended for readability): - -```python -encode({"outer": {"inner": 1}}, {"indent": 0}) -# outer: -# inner: 1 -``` - ---- - -## Type Hints - -The package includes comprehensive type hints for static analysis: - -```python -from typing import Any, Dict, List, Union -from toon_format import encode, decode -from toon_format.types import EncodeOptions, DecodeOptions, JsonValue - -# Type-safe usage -data: Dict[str, Any] = {"key": "value"} -options: EncodeOptions = EncodeOptions(delimiter=",") -result: str = encode(data, options) - -decoded: JsonValue = decode(result) -``` - ---- - -## Performance Considerations - -- **Caching**: The encoder caches indent strings for performance -- **Large arrays**: Tabular format is most efficient for uniform object arrays -- **Validation**: Disable strict mode (`strict=False`) for lenient parsing of untrusted input -- **Memory**: Decode operations are memory-efficient, processing line-by-line - ---- - -## See Also - -- [Format Specification](format.md) - Detailed format rules and examples -- [LLM Integration](llm-integration.md) - Best practices for using TOON with LLMs -- [TOON Specification](https://github.com/toon-format/spec) - Official specification diff --git a/docs/data-types.md b/docs/data-types.md new file mode 100644 index 0000000..82593a5 --- /dev/null +++ b/docs/data-types.md @@ -0,0 +1,61 @@ +# Data types + +## Encoding + +| Python | TOON | +| --- | --- | +| `dict` and other mappings | object; keys may be `str`, `int`, `float`, `bool`, or `None`, converted like `json.dumps` does | +| `list`, `tuple` | array | +| `set`, `frozenset` | array, sorted | +| `str` | string, quoted only when needed | +| `int` | number, exact at any size | +| `float` | number; `nan` and infinities become `null` | +| `decimal.Decimal` | number, with every digit preserved (see [Numbers](#specification-choices)) | +| `bool`, `None` | `true`, `false`, `null` | +| `datetime`, `date`, `time` | ISO 8601 string | +| `uuid.UUID`, `pathlib.PurePath` | string | +| `enum.Enum` | its value | +| dataclass instance | object of its fields | +| `attrs` class instance | object of its fields (attrs is not imported) | +| `pydantic.BaseModel` | `model_dump(mode="json")` | + +Anything else goes to `default`, or raises `TypeError`. + +## Decoding + +Decoding produces `dict`, `list`, `str`, `int`, `float`, `bool`, and `None`. +A number without a fraction or exponent is an `int`; any other number is a +`float`, unless `parse_float` or `parse_int` says otherwise. Strings are never +converted back to dates or other types. + +## Specification choices + +This package targets **TOON 4.1** (`toon_format.__toon_spec__`). The specification +asks implementations to document the following choices. + +**Numbers.** Integers encode and decode exactly at any size. Floats use the +shortest representation that round-trips, in plain decimal between 1e-6 and +1e21 and in exponent form (`1e+21`, `1e-7`) outside it. Integral floats from +2⁵³ up are written with their exact digits, so that they decode to an equal +`int`. A decoded number beyond the double range is an error in strict mode and +`±inf` otherwise. `Decimal` input keeps every digit, and `parse_float=Decimal` +keeps them when decoding. A `Decimal` beyond the double range, such as +`Decimal("1e400")`, therefore encodes but does not decode back with the +default strict decoder: pass `parse_float=Decimal` to read it. + +**Strings.** A string holding an unpaired surrogate cannot be encoded and +raises `ValueError`. A string starting with U+FEFF is quoted, because a +leading U+FEFF in a document is read as a byte-order mark. In strict mode, quoted strings may not contain literal +control characters other than tab. + +**Key order** is preserved, except that tabular rows follow the header's field +order. With `strict=False`, a duplicate key keeps its first position and its +last value, as in `json.loads`. When encoding, keys that become equal once +converted to strings (`1` and `"1"`, `True` and `"true"`) raise `ValueError` +instead of dropping a value. No key is special: `__proto__` is an ordinary key. + +**Tabs in indentation** are an error in strict mode; otherwise each leading tab +counts as one level. + +**Nesting** deeper than the interpreter's recursion limit raises `ValueError` +when encoding and `ToonDecodeError` when decoding. diff --git a/docs/format.md b/docs/format.md deleted file mode 100644 index 34b99d5..0000000 --- a/docs/format.md +++ /dev/null @@ -1,672 +0,0 @@ -# TOON Format Specification - -Detailed format rules, syntax, and examples for TOON (Token-Oriented Object Notation). - -## Overview - -TOON uses indentation-based structure like YAML for nested objects and tabular format like CSV for uniform arrays. This document explains the complete syntax and formatting rules. - ---- - -## Objects - -Objects use `key: value` pairs with indentation for nesting. - -### Simple Objects - -```python -{"name": "Alice", "age": 30, "active": True} -``` - -```toon -name: Alice -age: 30 -active: true -``` - -### Nested Objects - -```python -{ - "user": { - "name": "Alice", - "settings": { - "theme": "dark" - } - } -} -``` - -```toon -user: - name: Alice - settings: - theme: dark -``` - -### Object Keys - -Keys follow identifier rules or must be quoted: - -```python -{ - "simple_key": 1, - "with-dash": 2, - "123": 3, # Numeric key - "with space": 4, # Spaces require quotes - "": 5 # Empty key requires quotes -} -``` - -```toon -simple_key: 1 -with-dash: 2 -"123": 3 -"with space": 4 -"": 5 -``` - ---- - -## Arrays - -All arrays include length indicator `[N]` for validation. - -### Primitive Arrays - -Arrays of primitives use inline format with comma separation: - -```python -[1, 2, 3, 4, 5] -``` - -```toon -[5]: 1,2,3,4,5 -``` - -```python -["alpha", "beta", "gamma"] -``` - -```toon -[3]: alpha,beta,gamma -``` - -**Note:** Comma delimiter is hidden in primitive arrays: `[5]:` not `[5,]:` - -### Tabular Arrays - -Uniform objects with primitive-only fields use CSV-like format: - -```python -[ - {"id": 1, "name": "Alice", "age": 30}, - {"id": 2, "name": "Bob", "age": 25}, - {"id": 3, "name": "Charlie", "age": 35} -] -``` - -```toon -[3,]{id,name,age}: - 1,Alice,30 - 2,Bob,25 - 3,Charlie,35 -``` - -**Tabular Format Rules:** -- All objects must have identical keys -- All values must be primitives (no nested objects/arrays) -- Field order in header determines column order -- Delimiter appears in header: `[N,]` or `[N|]` or `[N\t]` - -### List Arrays - -Non-uniform or nested arrays use list format with `-` markers: - -```python -[ - {"name": "Alice"}, - 42, - "hello" -] -``` - -```toon -[3]: - - name: Alice - - 42 - - hello -``` - -### Nested Arrays - -```python -{ - "matrix": [ - [1, 2, 3], - [4, 5, 6] - ] -} -``` - -```toon -matrix[2]: - - [3]: 1,2,3 - - [3]: 4,5,6 -``` - -### Empty Arrays - -```python -{"items": []} -``` - -```toon -items[0]: -``` - ---- - -## Delimiters - -Three delimiter options for array values: - -### Comma (Default) - -```python -encode([1, 2, 3]) # Default delimiter -``` - -```toon -[3]: 1,2,3 -``` - -For tabular arrays, delimiter shown in header: -```toon -users[2,]{id,name}: - 1,Alice - 2,Bob -``` - -### Tab - -```python -encode([1, 2, 3], {"delimiter": "\t"}) -``` - -```toon -[3 ]: 1 2 3 -``` - -Tabular with tab: -```toon -users[2 ]{id,name}: - 1 Alice - 2 Bob -``` - -### Pipe - -```python -encode([1, 2, 3], {"delimiter": "|"}) -``` - -```toon -[3|]: 1|2|3 -``` - -Tabular with pipe: -```toon -users[2|]{id,name}: - 1|Alice - 2|Bob -``` - ---- - -## String Quoting Rules - -Strings are quoted **only when necessary** to avoid ambiguity. - -### Unquoted Strings (Safe) - -```python -"hello" # Simple identifier -"hello world" # Internal spaces OK -"user_name" # Underscores OK -"hello-world" # Hyphens OK -``` - -```toon -hello -hello world -user_name -hello-world -``` - -### Quoted Strings (Required) - -**Empty strings:** -```python -"" -``` -```toon -"" -``` - -**Reserved keywords:** -```python -"null" -"true" -"false" -``` -```toon -"null" -"true" -"false" -``` - -**Numeric-looking strings:** -```python -"42" -"-3.14" -"1e5" -"0123" # Leading zero -``` -```toon -"42" -"-3.14" -"1e5" -"0123" -``` - -**Leading/trailing whitespace:** -```python -" hello" -"hello " -" hello " -``` -```toon -" hello" -"hello " -" hello " -``` - -**Structural characters:** -```python -"key: value" # Colon -"[array]" # Brackets -"{object}" # Braces -"- item" # Leading hyphen -``` -```toon -"key: value" -"[array]" -"{object}" -"- item" -``` - -**Delimiter characters:** -```python -# When using comma delimiter -"a,b" -``` -```toon -"a,b" -``` - -**Control characters:** -```python -"line1\nline2" -"tab\there" -``` -```toon -"line1\nline2" -"tab\there" -``` - -### Escape Sequences - -Inside quoted strings: - -| Sequence | Meaning | -|----------|---------| -| `\"` | Double quote | -| `\\` | Backslash | -| `\n` | Newline | -| `\r` | Carriage return | -| `\t` | Tab | -| `\uXXXX` | Unicode character (4 hex digits) | - -**Example:** - -```python -{ - "text": "Hello \"world\"\nNew line", - "path": "C:\\Users\\Alice" -} -``` - -```toon -text: "Hello \"world\"\nNew line" -path: "C:\\Users\\Alice" -``` - ---- - -## Primitives - -### Numbers - -**Integers:** -```python -42 --17 -0 -``` - -```toon -42 --17 -0 -``` - -**Floats:** -```python -3.14 --0.5 -0.0 -``` - -```toon -3.14 --0.5 -0 -``` - -**Special Numbers:** -- **Scientific notation accepted in decoding:** `1e5`, `-3.14E-2` -- **Encoders must NOT use scientific notation** - always decimal form -- **Negative zero normalized:** `-0.0` → `0` -- **Non-finite values → null:** `Infinity`, `-Infinity`, `NaN` → `null` - -**Large integers (>2^53-1):** -```python -9007199254740993 # Exceeds JS safe integer -``` - -```toon -"9007199254740993" # Quoted for JS compatibility -``` - -### Booleans - -```python -True # true in TOON (lowercase) -False # false in TOON (lowercase) -``` - -```toon -true -false -``` - -### Null - -```python -None # null in TOON (lowercase) -``` - -```toon -null -``` - ---- - -## Indentation - -Default: 2 spaces per level (configurable) - -```python -{ - "level1": { - "level2": { - "level3": "value" - } - } -} -``` - -```toon -level1: - level2: - level3: value -``` - -**With 4-space indent:** -```python -encode(data, {"indent": 4}) -``` - -```toon -level1: - level2: - level3: value -``` - -**Strict mode rules:** -- Indentation must be consistent multiples of `indent` value -- Tabs not allowed in indentation -- Mixing spaces and tabs causes errors - ---- - -## Array Length Indicators - -All arrays include `[N]` to indicate element count for validation. - -### Without Length Marker (Default) - -```toon -items[3]: a,b,c -users[2,]{id,name}: - 1,Alice - 2,Bob -``` - -### With Length Marker (`#`) - -```python -encode(data, {"lengthMarker": "#"}) -``` - -```toon -items[#3]: a,b,c -users[#2,]{id,name}: - 1,Alice - 2,Bob -``` - -The `#` prefix makes length indicators more explicit for validation-focused use cases. - ---- - -## Blank Lines - -**Within arrays:** Blank lines are **not allowed** in strict mode - -```toon -# ❌ Invalid (blank line in array) -items[3]: - - a - - - b - - c -``` - -```toon -# ✅ Valid (no blank lines) -items[3]: - - a - - b - - c -``` - -**Between top-level keys:** Blank lines are allowed and ignored - -```toon -# ✅ Valid (blank lines between objects) -name: Alice - -age: 30 -``` - ---- - -## Comments - -**TOON does not support comments.** The format prioritizes minimal syntax for token efficiency. - -If you need to document TOON data, use surrounding markdown or separate documentation files. - ---- - -## Whitespace - -### Trailing Whitespace - -Trailing whitespace on lines is **allowed** and **ignored**. - -### Leading Whitespace in Values - -Leading/trailing whitespace in string values requires quoting: - -```python -{"text": " value "} -``` - -```toon -text: " value " -``` - ---- - -## Order Preservation - -**Object key order** and **array element order** are **always preserved** during encoding and decoding. - -```python -from collections import OrderedDict - -data = OrderedDict([("z", 1), ("a", 2), ("m", 3)]) -toon = encode(data) -``` - -```toon -z: 1 -a: 2 -m: 3 -``` - -Decoding preserves order: -```python -decoded = decode(toon) -list(decoded.keys()) # ['z', 'a', 'm'] -``` - ---- - -## Complete Examples - -### Simple Configuration - -```python -{ - "app": "myapp", - "version": "1.0.0", - "debug": False, - "port": 8080 -} -``` - -```toon -app: myapp -version: "1.0.0" -debug: false -port: 8080 -``` - -### Nested Structure with Arrays - -```python -{ - "metadata": { - "version": 2, - "author": "Alice" - }, - "items": [ - {"id": 1, "name": "Item1", "qty": 10}, - {"id": 2, "name": "Item2", "qty": 5} - ], - "tags": ["alpha", "beta", "gamma"] -} -``` - -```toon -metadata: - version: 2 - author: Alice -items[2,]{id,name,qty}: - 1,Item1,10 - 2,Item2,5 -tags[3]: alpha,beta,gamma -``` - -### Mixed Array Types - -```python -{ - "data": [ - {"type": "user", "id": 1}, - {"type": "user", "id": 2, "extra": "field"}, # Non-uniform - 42, - "hello" - ] -} -``` - -```toon -data[4]: - - type: user - id: 1 - - type: user - id: 2 - extra: field - - 42 - - hello -``` - ---- - -## Token Efficiency Comparison - -**JSON (177 chars):** -```json -{"users":[{"id":1,"name":"Alice","age":30,"active":true},{"id":2,"name":"Bob","age":25,"active":true},{"id":3,"name":"Charlie","age":35,"active":false}]} -``` - -**TOON (85 chars, 52% reduction):** -```toon -users[3,]{id,name,age,active}: - 1,Alice,30,true - 2,Bob,25,true - 3,Charlie,35,false -``` - ---- - -## See Also - -- [API Reference](api.md) - Complete function documentation -- [LLM Integration](llm-integration.md) - Best practices for LLM usage -- [Official Specification](https://github.com/toon-format/spec/blob/main/SPEC.md) - Normative spec diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..7c9f85d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,60 @@ +# TOON for Python + +The official Python implementation of **TOON** (Token-Oriented Object Notation): +a compact, human-readable encoding of the JSON data model, designed to save +tokens in LLM prompts. + +- **Complete**: implements [TOON specification 4.1](https://github.com/toon-format/spec/blob/main/SPEC.md) + and passes every official conformance fixture. +- **Familiar**: `dumps`, `dump`, `loads`, and `load`, like the `json` module, + with `encode` and `decode` as aliases. +- **Pure Python**, no dependencies, fully typed, Python 3.10+. + +## Installation + +```bash +pip install toon-format +# or +uv add toon-format +``` + +The module is `toon_format`. Install `toon-format[pydantic]` for the +[Pydantic integration](usage.md#pydantic). + +## Quick start + +```python +import toon_format + +data = { + "context": {"task": "Our favorite hikes together", "location": "Boulder"}, + "friends": ["ana", "luis", "sam"], + "hikes": [ + {"id": 1, "name": "Blue Lake Trail", "distanceKm": 7.5, "wasSunny": True}, + {"id": 2, "name": "Ridge Overlook", "distanceKm": 9.2, "wasSunny": False}, + ], +} + +text = toon_format.dumps(data) +print(text) +``` + +``` +context: + task: Our favorite hikes together + location: Boulder +friends[3]: ana,luis,sam +hikes[2]{id,name,distanceKm,wasSunny}: + 1,Blue Lake Trail,7.5,true + 2,Ridge Overlook,9.2,false +``` + +```python +assert toon_format.loads(text) == data +``` + +## Next steps + +- [Usage](usage.md): encoding, decoding, errors, and Pydantic. +- [Data types](data-types.md): how Python values map to TOON. +- [Migrating from 0.9](migration.md): what changed since `toon-format` 0.9. diff --git a/docs/llm-integration.md b/docs/llm-integration.md deleted file mode 100644 index 21b5c5f..0000000 --- a/docs/llm-integration.md +++ /dev/null @@ -1,623 +0,0 @@ -# LLM Integration Guide - -Best practices for using TOON with Large Language Models to maximize token efficiency and response quality. - -## Why TOON for LLMs? - -Traditional JSON wastes tokens on structural characters: -- **Braces & brackets:** `{}`, `[]` -- **Repeated quotes:** Every key quoted in JSON -- **Commas everywhere:** Between all elements - -TOON eliminates this redundancy, achieving **30-60% token reduction** while maintaining readability. - ---- - -## Quick Example - -**JSON (45 tokens with GPT-5):** -```json -{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} -``` - -**TOON (20 tokens with GPT-5, 56% reduction):** -```toon -users[2,]{id,name}: - 1,Alice - 2,Bob -``` - ---- - -## Basic Integration Patterns - -### 1. Prompting the Model - -**Explicit format instruction:** - -``` -Respond using TOON format (Token-Oriented Object Notation): -- Use `key: value` for objects -- Use indentation for nesting -- Use `[N]` to indicate array lengths -- Use tabular format `[N,]{fields}:` for uniform arrays - -Example: -users[2,]{id,name}: - 1,Alice - 2,Bob -``` - -### 2. Code Block Wrapping - -Always wrap TOON in code blocks for clarity: - -````markdown -```toon -users[3,]{id,name,age}: - 1,Alice,30 - 2,Bob,25 - 3,Charlie,35 -``` -```` - -This helps the model distinguish TOON from natural language. - -### 3. Validation with Length Markers - -Use `lengthMarker="#"` for explicit validation hints: - -```python -from toon_format import encode - -data = {"items": ["a", "b", "c"]} -toon = encode(data, {"lengthMarker": "#"}) -# items[#3]: a,b,c -``` - -Tell the model: -> "Array lengths are prefixed with `#`. Ensure your response matches these counts exactly." - ---- - -## Measuring Token Savings - -Before integrating TOON with your LLM application, measure actual savings for your data: - -### Basic Measurement - -```python -from toon_format import estimate_savings - -# Your actual data structure -user_data = { - "users": [ - {"id": 1, "name": "Alice", "email": "alice@example.com", "active": True}, - {"id": 2, "name": "Bob", "email": "bob@example.com", "active": True}, - {"id": 3, "name": "Charlie", "email": "charlie@example.com", "active": False} - ] -} - -# Compare formats -result = estimate_savings(user_data) -print(f"JSON: {result['json_tokens']} tokens") -print(f"TOON: {result['toon_tokens']} tokens") -print(f"Savings: {result['savings_percent']:.1f}%") -# JSON: 112 tokens -# TOON: 68 tokens -# Savings: 39.3% -``` - -### Cost Estimation - -Calculate actual dollar savings based on your API usage: - -```python -from toon_format import estimate_savings - -# Your typical prompt data -prompt_data = { - "context": [ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "Analyze this data"} - ], - "data": [ - {"id": i, "value": f"Item {i}", "score": i * 10} - for i in range(1, 101) # 100 items - ] -} - -result = estimate_savings(prompt_data["data"]) - -# GPT-5 pricing (example: $0.01 per 1K tokens) -cost_per_1k = 0.01 -json_cost = (result['json_tokens'] / 1000) * cost_per_1k -toon_cost = (result['toon_tokens'] / 1000) * cost_per_1k - -print(f"JSON cost per request: ${json_cost:.4f}") -print(f"TOON cost per request: ${toon_cost:.4f}") -print(f"Savings per request: ${json_cost - toon_cost:.4f}") -print(f"Savings per 10,000 requests: ${(json_cost - toon_cost) * 10000:.2f}") -``` - -### Detailed Comparison - -Get a formatted report for documentation or analysis: - -```python -from toon_format import compare_formats - -api_response = { - "status": "success", - "results": [ - {"id": 1, "score": 0.95, "category": "A"}, - {"id": 2, "score": 0.87, "category": "B"}, - {"id": 3, "score": 0.92, "category": "A"} - ], - "total": 3 -} - -print(compare_formats(api_response)) -# Format Comparison -# ──────────────────────────────────────────────── -# Format Tokens Size (chars) -# JSON 78 189 -# TOON 48 112 -# ──────────────────────────────────────────────── -# Savings: 30 tokens (38.5%) -``` - -### Integration Pattern - -Use token counting in production to monitor savings: - -```python -import json -from toon_format import encode, count_tokens - -def send_to_llm(data, use_toon=True): - """Send data to LLM with optional TOON encoding.""" - if use_toon: - formatted = encode(data) - format_type = "TOON" - else: - formatted = json.dumps(data, indent=2) - format_type = "JSON" - - tokens = count_tokens(formatted) - print(f"[{format_type}] Sending {tokens} tokens") - - # Your LLM API call here - # response = openai.ChatCompletion.create(...) - - return formatted, tokens - -# Example usage -data = {"items": [{"id": 1}, {"id": 2}]} -formatted, token_count = send_to_llm(data, use_toon=True) -``` - ---- - -## Real-World Use Cases - -### Use Case 1: Structured Data Extraction - -**Prompt:** -``` -Extract user information from the text below. Respond in TOON format. - -Text: "Alice (age 30) works at ACME. Bob (age 25) works at XYZ." - -Format: -users[N,]{name,age,company}: - ... -``` - -**Model Response:** -```toon -users[2,]{name,age,company}: - Alice,30,ACME - Bob,25,XYZ -``` - -**Processing:** -```python -from toon_format import decode - -response = """users[2,]{name,age,company}: - Alice,30,ACME - Bob,25,XYZ""" - -data = decode(response) -# {'users': [ -# {'name': 'Alice', 'age': 30, 'company': 'ACME'}, -# {'name': 'Bob', 'age': 25, 'company': 'XYZ'} -# ]} -``` - ---- - -### Use Case 2: Configuration Generation - -**Prompt:** -``` -Generate a server configuration in TOON format with: -- app: "myapp" -- port: 8080 -- database settings (host, port, name) -- enabled features: ["auth", "logging", "cache"] -``` - -**Model Response:** -```toon -app: myapp -port: 8080 -database: - host: localhost - port: 5432 - name: myapp_db -features[3]: auth,logging,cache -``` - -**Processing:** -```python -config = decode(response) -# Use config dict directly in your application -``` - ---- - -### Use Case 3: API Response Formatting - -**Prompt:** -``` -Convert this data to TOON format for efficient transmission: - -Products: -1. Widget A ($9.99, stock: 50) -2. Widget B ($14.50, stock: 30) -3. Widget C ($19.99, stock: 0) -``` - -**Model Response:** -```toon -products[3,]{id,name,price,stock}: - 1,"Widget A",9.99,50 - 2,"Widget B",14.50,30 - 3,"Widget C",19.99,0 -``` - ---- - -## Advanced Techniques - -### 1. Few-Shot Learning - -Provide examples in your prompt: - -``` -Convert the following to TOON format. Examples: - -Input: {"name": "Alice", "age": 30} -Output: -name: Alice -age: 30 - -Input: [{"id": 1, "item": "A"}, {"id": 2, "item": "B"}] -Output: -[2,]{id,item}: - 1,A - 2,B - -Now convert this: -``` - -### 2. Validation Instructions - -Add explicit validation rules: - -``` -Respond in TOON format. Rules: -1. Array lengths MUST match actual count: [3] means exactly 3 items -2. Tabular arrays require uniform keys across all objects -3. Use quotes for: empty strings, keywords (null/true/false), numeric strings -4. Indentation: 2 spaces per level - -If you cannot provide valid TOON, respond with an error message. -``` - -### 3. Delimiter Selection - -Choose delimiters based on your data: - -```python -# For data with commas (addresses, descriptions) -encode(data, {"delimiter": "\t"}) # Use tab - -# For data with tabs (code snippets) -encode(data, {"delimiter": "|"}) # Use pipe - -# For general use -encode(data, {"delimiter": ","}) # Use comma (default) -``` - -Tell the model which delimiter to use: -> "Use tab-separated values in tabular arrays due to commas in descriptions." - ---- - -## Error Handling - -### Graceful Degradation - -Always wrap TOON decoding in error handling: - -```python -from toon_format import decode, ToonDecodeError - -def safe_decode(toon_str): - try: - return decode(toon_str) - except ToonDecodeError as e: - print(f"TOON decode error: {e}") - # Fall back to asking model to regenerate - return None -``` - -### Model Error Prompting - -If decoding fails, ask the model to fix it: - -``` -The TOON you provided has an error: "Expected 3 items, but got 2" - -Please regenerate with correct array lengths. Original: -items[3]: a,b - -Should be either: -items[2]: a,b (fix length) -OR -items[3]: a,b,c (add missing item) -``` - ---- - -## Token Efficiency Best Practices - -### 1. Prefer Tabular Format - -**Less efficient (list format):** -```toon -users[3]: - - id: 1 - name: Alice - - id: 2 - name: Bob - - id: 3 - name: Charlie -``` - -**More efficient (tabular format):** -```toon -users[3,]{id,name}: - 1,Alice - 2,Bob - 3,Charlie -``` - -### 2. Minimize Nesting - -**Less efficient:** -```toon -data: - metadata: - items: - list[2]: a,b -``` - -**More efficient:** -```toon -items[2]: a,b -``` - -### 3. Use Compact Keys - -**Less efficient:** -```toon -user_identification_number: 123 -user_full_name: Alice -``` - -**More efficient:** -```toon -id: 123 -name: Alice -``` - ---- - -## Common Pitfalls - -### ❌ Don't: Trust Model Without Validation - -```python -# BAD: No validation -response = llm.generate(prompt) -data = decode(response) # May raise error -``` - -```python -# GOOD: Validate and handle errors -response = llm.generate(prompt) -try: - data = decode(response, {"strict": True}) -except ToonDecodeError: - # Retry or fall back -``` - -### ❌ Don't: Mix Formats Mid-Conversation - -``` -First response: JSON -Second response: TOON -``` - -**Be consistent** - stick to TOON throughout the conversation. - -### ❌ Don't: Forget Quoting Rules - -Model might produce: -```toon -code: 123 # Wrong! Numeric string needs quotes -``` - -Should be: -```toon -code: "123" # Correct -``` - -**Solution:** Explicitly mention quoting in prompts. - ---- - -## Integration Examples - -### With OpenAI API - -```python -import openai -from toon_format import decode - -def ask_for_toon_data(prompt): - response = openai.ChatCompletion.create( - model="gpt-5", - messages=[ - {"role": "system", "content": "Respond using TOON format"}, - {"role": "user", "content": prompt} - ] - ) - - toon_str = response.choices[0].message.content - - # Extract TOON from code blocks if wrapped - if "```toon" in toon_str: - toon_str = toon_str.split("```toon")[1].split("```")[0].strip() - elif "```" in toon_str: - toon_str = toon_str.split("```")[1].split("```")[0].strip() - - return decode(toon_str) -``` - -### With Anthropic Claude API - -```python -import anthropic -from toon_format import decode - -def claude_toon(prompt): - client = anthropic.Anthropic() - - message = client.messages.create( - model="claude-3-5-sonnet-20241022", - messages=[{ - "role": "user", - "content": f"{prompt}\n\nRespond in TOON format (Token-Oriented Object Notation)." - }] - ) - - toon_str = message.content[0].text - - # Remove code blocks if present - if "```" in toon_str: - toon_str = toon_str.split("```")[1].strip() - if toon_str.startswith("toon\n"): - toon_str = toon_str[5:] - - return decode(toon_str) -``` - ---- - -## Performance Metrics - -Based on testing with gpt5 and Claude: - -| Data Type | JSON Tokens | TOON Tokens | Reduction | -|-----------|-------------|-------------|-----------| -| Simple config (10 keys) | 45 | 28 | 38% | -| User list (50 users) | 892 | 312 | 65% | -| Nested structure | 234 | 142 | 39% | -| Mixed arrays | 178 | 95 | 47% | - -**Average reduction: 30-60%** depending on data structure and tokenizer. - -**Note:** Comprehensive benchmarks across gpt5, gpt5-mini, and other models are coming soon. See the [roadmap](README.md#roadmap) for details. - ---- - -## Debugging Tips - -### 1. Log Raw TOON - -Always log the raw TOON before decoding: - -```python -print("Raw TOON from model:") -print(repr(toon_str)) - -try: - data = decode(toon_str) -except ToonDecodeError as e: - print(f"Decode error: {e}") -``` - -### 2. Test with Strict Mode - -Enable strict validation during development: - -```python -decode(toon_str, {"strict": True}) # Strict validation -``` - -Disable for production if lenient parsing is acceptable: - -```python -decode(toon_str, {"strict": False}) # Lenient -``` - -### 3. Validate Against Schema - -After decoding, validate the Python structure: - -```python -data = decode(toon_str) - -# Validate structure -assert "users" in data -assert isinstance(data["users"], list) -assert all("id" in user for user in data["users"]) -``` - ---- - -## Resources - -- [Format Specification](format.md) - Complete TOON syntax reference -- [API Reference](api.md) - Function documentation -- [Official Spec](https://github.com/toon-format/spec) - Normative specification -- [Benchmarks](https://github.com/toon-format/toon#benchmarks) - Token efficiency analysis - ---- - -## Summary - -**Key Takeaways:** -1. **Explicit prompting** - Tell the model to use TOON format clearly -2. **Validation** - Always validate model output with error handling -3. **Examples** - Provide few-shot examples in prompts -4. **Consistency** - Use TOON throughout the conversation -5. **Tabular format** - Prefer tabular arrays for maximum efficiency -6. **Error recovery** - Handle decode errors gracefully - -TOON can reduce LLM costs by 30-60% while maintaining readability and structure. Start with simple use cases and expand as you become familiar with the format. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..fa68d42 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,58 @@ +# Migrating from `toon_format` 0.9 + +Version 1.0 is a rewrite that implements TOON specification 4.1. The +distribution is still `toon-format` and the module still `toon_format`, so +`encode(value)` and `decode(text)` keep working. The module now follows the +interface of the standard `json` module: `dumps`, `dump`, `loads`, and `load` +are the primary names, and `encode` and `decode` are their aliases. + +## Options are keyword arguments + +The options dictionary of 0.9 and its types (`EncodeOptions`, `DecodeOptions`, +`DelimiterKey`) are gone. Pass options as keyword arguments; a positional +options argument raises `TypeError`. + +| 0.9 | 1.0 | +| --- | --- | +| `encode(value)` | `dumps(value)` or `encode(value)` | +| `encode(value, {"indent": 4, "delimiter": "\t"})` | `dumps(value, indent_size=4, delimiter="\t")` | +| `encode(value, {"delimiter": "pipe"})` | `dumps(value, delimiter="\|")` | +| `decode(text)` | `loads(text)` or `decode(text)` | +| `decode(text, DecodeOptions(indent=4, strict=False))` | `loads(text, indent_size=4, strict=False)` | + +## Removed + +- **Token helpers.** `count_tokens`, `estimate_savings`, and + `compare_formats` are gone. Count tokens with your model provider's + tokenizer, for example `tiktoken` for OpenAI models. +- **The `toon` command** and `python -m toon_format`. The official command line + is [`@toon-format/cli`](https://www.npmjs.com/package/@toon-format/cli): + `npx @toon-format/cli data.json`. +- **Length markers** (`[#3]`), removed from the specification, and the + `lengthMarker` option. + +## Behavior changes + +The 0.9 encoder and decoder implemented specification 1.3. Moving to 4.1 +changes some output and fixes many bugs, among them +[#33](https://github.com/toon-format/toon-python/issues/33), +[#47](https://github.com/toon-format/toon-python/issues/47), +[#61](https://github.com/toon-format/toon-python/issues/61), +[#62](https://github.com/toon-format/toon-python/issues/62), +[#63](https://github.com/toon-format/toon-python/issues/63), +[#64](https://github.com/toon-format/toon-python/issues/64), and +[#65](https://github.com/toon-format/toon-python/issues/65). + +- **Empty arrays** encode as `key: []` and `[]` instead of `key[0]:`. Both forms decode. +- **Objects of uniform objects** use the keyed tabular form `key[N:]{fields}:`. +- **Uniform nested objects** in tabular arrays become nested field groups: + `orders[2]{id,customer{name,country}}:`. +- **Comment lines** starting with `#` are ignored by the decoder. +- **Unsupported values** raise `TypeError`, like `json.dumps`, instead of + silently becoming `null`. Pass `default=` to convert them. +- **Non-string keys** are converted like `json.dumps` does (`1` becomes `"1"`, + `True` becomes `"true"`); other key types raise `TypeError`. +- **`Decimal`** values keep every digit instead of going through `float`. +- **`ToonDecodeError`** now subclasses `ValueError` and has `line`, `source`, + and `msg` attributes. +- **Python 3.8 and 3.9** are no longer supported. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..0cc4790 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,137 @@ +# Usage + +`toon_format` follows the interface of the standard `json` module. Every +option is described in the [API reference](api-reference.md). `encode` and +`decode`, the names used by the other TOON implementations, are aliases of +`dumps` and `loads` and take the same options. + +## Encoding + +```python +toon_format.dumps(obj, *, indent_size=2, delimiter=",", default=None, sort_keys=False) -> str +toon_format.dump(obj, fp, **options) -> None +``` + +The encoder picks the most compact form the specification allows for each +value; the choice follows from the value's shape. Arrays of primitives stay on +one line and arrays of uniform objects become tables: + +```python +>>> print(toon_format.dumps({"tags": ["a", "b"], "users": [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bob"}]})) +tags[2]: a,b +users[2]{id,name}: + 1,Ada + 2,Bob +``` + +Objects whose values share one shape use the keyed tabular form, and uniform +nested objects collapse into nested field groups: + +```python +>>> print(toon_format.dumps({"servers": {"alpha": {"host": "a.example.com", "port": 8080}, +... "beta": {"host": "b.example.com", "port": 9090}}})) +servers[2:]{host,port}: + alpha: a.example.com,8080 + beta: b.example.com,9090 + +>>> print(toon_format.dumps({"orders": [{"id": 1, "customer": {"name": "Ada", "country": "DK"}}, +... {"id": 2, "customer": {"name": "Bob", "country": "UK"}}]})) +orders[2]{id,customer{name,country}}: + 1,Ada,DK + 2,Bob,UK +``` + +Everything else uses the list form: + +```python +>>> print(toon_format.dumps({"items": [1, {"a": 1}, [2, 3]]})) +items[3]: + - 1 + - a: 1 + - [2]: 2,3 +``` + +`delimiter="\t"` or `"|"` often tokenizes better than commas. Types without a +TOON representation go to `default`, as with `json.dumps`: + +```python +>>> toon_format.dumps({"z": 1 + 2j}, default=lambda value: [value.real, value.imag]) +'z[2]: 1,2' +``` + +### NumPy and pandas + +`toon_format` does not depend on either library. A pandas `DataFrame` becomes a table +through its records; missing values (`NaN`) become `null` and timestamps ISO +8601 strings: + +```python +>>> df = pd.DataFrame({"id": [1, 2], "name": ["Ada", "Bob"], "score": [9.5, None]}) +>>> print(toon_format.dumps({"users": df.to_dict("records")})) +users[2]{id,name,score}: + 1,Ada,9.5 + 2,Bob,null +``` + +NumPy arrays and scalars such as `np.int64` convert with `tolist()`: + +```python +>>> toon_format.dumps({"matrix": np.arange(6).reshape(2, 3), "n": np.int64(6)}, +... default=lambda value: value.tolist()) +'matrix[2]:\n - [3]: 0,1,2\n - [3]: 3,4,5\nn: 6' +``` + +## Decoding + +```python +toon_format.loads(s, *, strict=True, indent_size=2, parse_float=None, parse_int=None, + object_hook=None, object_pairs_hook=None) -> Any +toon_format.load(fp, **options) -> Any +``` + +`s` may also be `bytes`, and `fp` a binary file. By default the decoder +enforces every check of the specification: declared lengths, row widths, +indentation, blank lines inside arrays, and duplicate keys. `strict=False` +tolerates them, which helps with hand-written or LLM-generated documents. +The hooks work as in `json.loads`: + +```python +>>> from decimal import Decimal +>>> toon_format.loads("price: 0.1", parse_float=Decimal) +{'price': Decimal('0.1')} +``` + +Invalid documents raise `toon_format.ToonDecodeError`, a `ValueError` that carries the +1-based `line`, the offending `source` line, and the bare `msg`: + +```python +>>> try: +... toon_format.loads("a: 1\ntags[3]: a,b") +... except toon_format.ToonDecodeError as exc: +... print(exc.line, repr(exc.source), exc.msg) +2 'tags[3]: a,b' Declared length 3 but found 2 +``` + +## Pydantic + +With the `toon-format[pydantic]` extra, `toon_format.dumps` accepts any +`pydantic.BaseModel`, and `ToonPydanticModel` adds TOON counterparts of +pydantic's JSON helpers: + +```python +from toon_format.pydantic import ToonPydanticModel + + +class User(ToonPydanticModel): + name: str + age: int + + +prompt = User.schema_to_toon() # the JSON schema, as TOON +user = User.model_validate_toon("name: Ada\nage: 36") # parse an LLM answer +text = user.model_dump_toon() +``` + +`model_validate_toon(text, strict=...)` passes `strict` to both the TOON +decoder and pydantic. The default, `None`, decodes in strict mode and follows +the model's own configuration for validation. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..a6442b9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,68 @@ +site_name: TOON for Python +site_description: The official Python implementation of TOON (Token-Oriented Object Notation) +# Read the Docs sets READTHEDOCS_CANONICAL_URL; the fallback is for local builds. +site_url: !ENV [READTHEDOCS_CANONICAL_URL, "http://127.0.0.1:8000/"] +repo_name: toon-format/toon-python +repo_url: https://github.com/toon-format/toon-python +edit_uri: edit/main/docs/ + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.top + - navigation.footer + - search.suggest + - search.highlight + - content.code.copy + +nav: + - Home: index.md + - Usage: usage.md + - Data types: data-types.md + - Migrating from 0.9: migration.md + - API reference: api-reference.md + - Changelog: https://github.com/toon-format/toon-python/blob/main/CHANGELOG.md + +markdown_extensions: + - admonition + - attr_list + - tables + - toc: + permalink: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + show_source: false + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + separate_signature: true + merge_init_into_class: true + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/toon-format/toon-python + - icon: fontawesome/brands/python + link: https://pypi.org/project/toon-format/ diff --git a/pyproject.toml b/pyproject.toml index 7453dc4..e5200fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,101 +1,108 @@ [project] -name = "toon_format" -version = "0.9.0-beta.1" -description = "A compact, human-readable serialization format designed for passing structured data to Large Language Models with significantly reduced token usage" +name = "toon-format" +version = "1.0.0" +description = "Official Python implementation of TOON (Token-Oriented Object Notation), a compact, human-readable encoding of the JSON data model for LLM prompts." readme = "README.md" authors = [ - { name = "Johann Schopplich", email = "hello@johannschopplich.com" } + { name = "Johann Schopplich", email = "hello@johannschopplich.com" }, + { name = "Alessio Sanfratello", email = "sanfra90@gmail.com" }, ] -requires-python = ">=3.8" -dependencies = [ - "typing-extensions>=4.0.0; python_version < '3.10'", -] -license = { text = "MIT" } -keywords = ["toon", "serialization", "llm", "data-format", "token-efficient"] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +dependencies = [] +keywords = ["toon", "serialization", "llm", "json", "tokens", "data-format"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Text Processing :: Markup", + "Typing :: Typed", ] [project.urls] -Homepage = "https://github.com/toon-format/toon-python" +Homepage = "https://toonformat.dev" +Documentation = "https://github.com/toon-format/toon-python/tree/main/docs" Repository = "https://github.com/toon-format/toon-python" -Documentation = "https://github.com/toon-format/spec" -"Bug Tracker" = "https://github.com/toon-format/toon-python/issues" - -[project.scripts] -toon = "toon_format.cli:main" +Specification = "https://github.com/toon-format/spec/blob/main/SPEC.md" +Changelog = "https://github.com/toon-format/toon-python/blob/main/CHANGELOG.md" +Issues = "https://github.com/toon-format/toon-python/issues" [project.optional-dependencies] -pydantic = ["pydantic>=2.0.0"] +pydantic = ["pydantic>=2", "typing-extensions>=4.0; python_version < '3.11'"] [dependency-groups] -benchmark = ["tiktoken>=0.4.0"] dev = [ - "pytest>=8.0.0", - "pytest-cov>=4.1.0", - "ruff>=0.8.0", - "mypy>=1.8.0", - "pydantic>=2.0.0", + "attrs>=23", + "hypothesis>=6.100", + "mypy>=1.13", + "prek>=0.5.4", + "pydantic>=2", + "pytest>=8", + "pytest-cov>=5", + "ruff>=0.8", +] +docs = [ + "mkdocs>=1.6.1,<2", + "mkdocs-material>=9.7.7", + "mkdocstrings[python]>=1.0.6", ] +[build-system] +requires = ["uv_build>=0.9,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "toon_format" + [tool.pytest.ini_options] testpaths = ["tests"] -python_files = ["test_*.py"] -python_functions = ["test_*"] -addopts = [ - "--strict-markers", - "--strict-config", - "-ra", -] +addopts = ["--strict-markers", "--strict-config", "-ra"] +filterwarnings = ["error"] [tool.coverage.run] -relative_files = true -source = ["src"] +branch = true +source = ["toon_format"] [tool.coverage.report] -precision = 2 show_missing = true -skip_covered = false +skip_covered = true +exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError", "@overload"] [tool.ruff] -target-version = "py38" -line-length = 100 +line-length = 88 # see CONTRIBUTING.md +target-version = "py310" [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "UP", # pyupgrade + "E", # pycodestyle + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "RUF", # ruff-specific rules + "PT", # flake8-pytest-style + "C4", # flake8-comprehensions ] -ignore = ["N"] -[tool.ruff.format] -quote-style = "double" -indent-style = "space" +[tool.ruff.lint.per-file-ignores] +# Expected TOON documents are literal strings that cannot be wrapped. +"tests/**" = ["E501", "RUF001"] -[tool.mypy] -python_version = "3.9" -warn_return_any = false -warn_unused_configs = true -disallow_untyped_defs = false -check_untyped_defs = false +[tool.ruff.lint.isort] +known-first-party = ["toon_format"] -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/toon_format"] \ No newline at end of file +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src"] diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..aa663ad --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,142 @@ +"""Time encoding and decoding, optionally against another git revision. + +Usage: + uv run scripts/benchmark.py # the working tree only + uv run scripts/benchmark.py main # the working tree against main + uv run scripts/benchmark.py v0.9.0-beta.1 + +The revision is checked out in a temporary git worktree and imported from its +``src`` directory through ``toon_format.encode`` and ``toon_format.decode``, +which every version provides. Each figure is the best of five runs. The +datasets avoid forms that 0.9 cannot write, such as keyed tabular objects, so +that both sides encode the same text. +""" + +from __future__ import annotations + +import importlib +import json +import os +import random +import subprocess +import sys +import tempfile +import timeit +from collections.abc import Callable +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +WORDS = ["alpha", "beta", "gamma", "hello world", "x:y", "42", "true", "é ü"] + + +def _datasets() -> dict[str, tuple[Any, int]]: + """Return each dataset with the number of calls per timing run.""" + rng = random.Random(42) + + def user(i: int) -> dict[str, Any]: + return { + "id": i, + "name": f"user{i}", + "email": f"u{i}@example.com", + "active": i % 2 == 0, + "score": round(rng.random() * 100, 3), + "role": rng.choice(WORDS), + } + + def node(depth: int) -> dict[str, Any]: + if depth == 0: + return {"k": rng.choice(WORDS), "v": rng.randint(0, 999), "f": rng.random()} + children: dict[str, Any] = {f"child{j}": node(depth - 1) for j in range(3)} + return children | {"tags": ["a", "b", "c"]} + + def mixed(i: int) -> dict[str, Any]: + item: dict[str, Any] = {"id": i, "kind": rng.choice(["a", "b"])} + if i % 3 == 0: + item["meta"] = {"x": i, "y": [1, 2, 3]} + elif i % 3 == 1: + item["items"] = [{"n": j, "s": "s"} for j in range(3)] + return item + + numbers = {f"a{i}": [rng.randint(0, 10**6) for _ in range(50)] for i in range(200)} + small = {"name": "Ada", "age": 36, "tags": ["math", "code"], "ok": True} + return { + "tabular, 1000 rows": ({"users": [user(i) for i in range(1000)]}, 20), + "nested, depth 6": (node(6), 20), + "mixed list, 1000 items": ({"items": [mixed(i) for i in range(1000)]}, 20), + "200 number arrays": (numbers, 20), + "small object": (small, 10000), + } + + +def _best(call: Callable[[], object], number: int) -> float: + return min(timeit.repeat(call, number=number, repeat=5)) / number + + +def _measure(src: Path) -> dict[str, dict[str, float]]: + """Time the implementation in ``src`` inside this process.""" + module = importlib.import_module("toon_format") + dumps, loads = module.encode, module.decode + # The installed package must not shadow the revision under test. + assert Path(module.__file__ or "").is_relative_to(src), module.__file__ + results = {} + for name, (data, number) in _datasets().items(): + text = dumps(data) + assert loads(text) == data, name + results[name] = { + "encode": _best(lambda: dumps(data), number), # noqa: B023 + "decode": _best(lambda: loads(text), number), # noqa: B023 + } + return results + + +def _run(src: Path) -> dict[str, dict[str, float]]: + """Time the implementation in ``src`` in a fresh interpreter.""" + env = {**os.environ, "PYTHONPATH": str(src)} + command = [sys.executable, __file__, "--measure", str(src)] + output = subprocess.run(command, env=env, check=True, capture_output=True) + result: dict[str, dict[str, float]] = json.loads(output.stdout) + return result + + +def _milliseconds(seconds: float) -> str: + return f"{seconds * 1000:.3f} ms" + + +def main(revision: str | None) -> None: + current = _run(ROOT / "src") + if revision is None: + print(f"{'':24} {'encode':>12} {'decode':>12}") + for name, times in current.items(): + encode, decode = ( + _milliseconds(times["encode"]), + _milliseconds(times["decode"]), + ) + print(f"{name:24} {encode:>12} {decode:>12}") + return + with tempfile.TemporaryDirectory() as directory: + tree = Path(directory) / "tree" + git = ["git", "-C", str(ROOT), "worktree"] + add = [*git, "add", "--quiet", "--detach", str(tree), revision] + subprocess.run(add, check=True) + try: + other = _run(tree / "src") + finally: + subprocess.run([*git, "remove", "--force", str(tree)], check=True) + print(f"{'':31} {revision:>12} {'working tree':>12} {'speedup':>8}") + for name, times in current.items(): + for operation in ("encode", "decode"): + before, after = other[name][operation], times[operation] + print( + f"{name:24} {operation:6} {_milliseconds(before):>12}" + f" {_milliseconds(after):>12} {before / after:7.2f}x" + ) + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--measure": + print(json.dumps(_measure(Path(sys.argv[2])))) + elif len(sys.argv) <= 2: + main(sys.argv[1] if len(sys.argv) == 2 else None) + else: + sys.exit(__doc__) diff --git a/scripts/update_fixtures.py b/scripts/update_fixtures.py new file mode 100644 index 0000000..18a64a6 --- /dev/null +++ b/scripts/update_fixtures.py @@ -0,0 +1,53 @@ +# /// script +# requires-python = ">=3.10" +# /// +"""Replace tests/fixtures with the conformance fixtures of a TOON specification release. + +Usage: uv run scripts/update_fixtures.py v4.1.1 +""" + +from __future__ import annotations + +import io +import shutil +import sys +import tarfile +import urllib.request +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures" +ARCHIVE = "https://github.com/toon-format/spec/archive/refs/tags/{tag}.tar.gz" + + +def main(tag: str) -> None: + with urllib.request.urlopen(ARCHIVE.format(tag=tag)) as response: + data = response.read() + for category in ("encode", "decode"): + shutil.rmtree(FIXTURES / category, ignore_errors=True) + count = 0 + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + for member in archive.getmembers(): + parts = Path(member.name).parts + if member.isfile() and parts[1:4] in ( + ("tests", "fixtures", "encode"), + ("tests", "fixtures", "decode"), + ): + source = archive.extractfile(member) + assert source is not None + target = FIXTURES.joinpath(*parts[3:]) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read()) + count += 1 + readme = FIXTURES / "README.md" + text = readme.read_text(encoding="utf-8") + start = text.index("at tag `") + len("at tag `") + readme.write_text( + text[:start] + tag + text[text.index("`", start) :], encoding="utf-8" + ) + print(f"Copied {count} fixture files from {tag}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/src/toon_format/__init__.py b/src/toon_format/__init__.py index f664ec0..e993388 100644 --- a/src/toon_format/__init__.py +++ b/src/toon_format/__init__.py @@ -1,40 +1,46 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""TOON Format for Python. - -Token-Oriented Object Notation (TOON) is a compact, human-readable serialization -format optimized for LLM contexts. Achieves 30-60% token reduction vs JSON while -maintaining readability and structure. - -This package provides encoding and decoding functionality with 100% compatibility -with the official TOON specification (v1.3). - -Example: - >>> from toon_format import encode, decode - >>> data = {"name": "Alice", "age": 30} - >>> toon = encode(data) - >>> print(toon) - name: Alice - age: 30 - >>> decode(toon) - {'name': 'Alice', 'age': 30} +"""TOON (Token-Oriented Object Notation) for Python. + +TOON encodes the JSON data model in a compact, line-oriented form that saves +tokens in LLM prompts. This package follows the interface of the standard +:mod:`json` module:: + + >>> import toon_format + >>> users = [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bob"}] + >>> print(toon_format.dumps({"users": users})) + users[2]{id,name}: + 1,Ada + 2,Bob + >>> toon_format.loads("tags[2]: a,b") + {'tags': ['a', 'b']} + +:func:`encode` and :func:`decode`, the names used by the other TOON +implementations, are aliases of :func:`dumps` and :func:`loads`. """ -from .decoder import ToonDecodeError, decode -from .encoder import encode -from .types import DecodeOptions, Delimiter, DelimiterKey, EncodeOptions -from .utils import compare_formats, count_tokens, estimate_savings +from importlib.metadata import PackageNotFoundError, version + +from ._api import Delimiter, decode, dump, dumps, encode, load, loads +from ._errors import ToonDecodeError -__version__ = "0.9.0-beta.1" __all__ = [ - "encode", - "decode", - "ToonDecodeError", "Delimiter", - "DelimiterKey", - "EncodeOptions", - "DecodeOptions", - "count_tokens", - "estimate_savings", - "compare_formats", + "ToonDecodeError", + "__toon_spec__", + "__version__", + "decode", + "dump", + "dumps", + "encode", + "load", + "loads", ] + +ToonDecodeError.__module__ = __name__ # shown as toon_format.ToonDecodeError + +__toon_spec__ = "4.1" +"""Version of the TOON specification this package implements.""" + +try: + __version__ = version("toon-format") +except PackageNotFoundError: # pragma: no cover - running from a source tree + __version__ = "0.0.0" diff --git a/src/toon_format/__main__.py b/src/toon_format/__main__.py deleted file mode 100644 index 85c2759..0000000 --- a/src/toon_format/__main__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""CLI entry point for TOON format. - -Allows running the package as a module: python -m toon_format -""" - -import sys - -from .cli import main - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/toon_format/_api.py b/src/toon_format/_api.py new file mode 100644 index 0000000..298ba4f --- /dev/null +++ b/src/toon_format/_api.py @@ -0,0 +1,189 @@ +"""The public ``json``-style functions: ``dumps``, ``dump``, ``loads``, ``load``. + +``encode`` and ``decode`` are aliases of ``dumps`` and ``loads``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import IO, Any + +from . import _decoder, _encoder +from ._errors import ToonDecodeError +from ._text import DELIMITERS + +__all__ = ["Delimiter", "decode", "dump", "dumps", "encode", "load", "loads"] + + +class Delimiter(str, Enum): + """The delimiters TOON supports (§11). Members compare equal to their character.""" + + COMMA = "," + TAB = "\t" + PIPE = "|" + + def __str__(self) -> str: + return self.value + + +def _check_indent_size(indent_size: int) -> int: + if isinstance(indent_size, bool) or not isinstance(indent_size, int): + raise TypeError(f"indent_size must be an int, not {type(indent_size).__name__}") + if indent_size < 1: + raise ValueError(f"indent_size must be at least 1, got {indent_size}") + return indent_size + + +def dumps( + obj: Any, + *, + indent_size: int = 2, + delimiter: str = ",", + default: Callable[[Any], Any] | None = None, + sort_keys: bool = False, +) -> str: + """Serialize ``obj`` to a TOON document. + + Args: + obj: The value to encode. ``dict``, ``list``, ``tuple``, ``str``, + ``int``, ``float``, ``bool``, and ``None`` map directly onto the + JSON data model; see the documentation for other supported types. + indent_size: Spaces per indentation level. + delimiter: Document delimiter: ``","``, ``"\\t"``, or ``"|"`` + (or a :class:`Delimiter` member). + default: Called with any value that has no TOON representation; it + should return an encodable replacement or raise :class:`TypeError`. + sort_keys: Sort the keys of every object. + + Returns: + The TOON document, without a trailing newline. + + Raises: + TypeError: A value, or a mapping key, cannot be encoded. + ValueError: An option is invalid, the object contains a reference + cycle, a string contains an unpaired surrogate, or the object is + nested too deeply. + """ + if delimiter not in DELIMITERS: + raise ValueError(f"delimiter must be ',', '\\t', or '|', got {delimiter!r}") + return _encoder.encode( + obj, + indent_size=_check_indent_size(indent_size), + delimiter=str.__str__(delimiter), + default=default, + sort_keys=sort_keys, + ) + + +def dump( + obj: Any, + fp: IO[str], + *, + indent_size: int = 2, + delimiter: str = ",", + default: Callable[[Any], Any] | None = None, + sort_keys: bool = False, +) -> None: + """Serialize ``obj`` as a TOON document to the text file ``fp``. + + Accepts the same options as :func:`dumps`. + """ + fp.write( + dumps( + obj, + indent_size=indent_size, + delimiter=delimiter, + default=default, + sort_keys=sort_keys, + ) + ) + + +def loads( + s: str | bytes | bytearray, + *, + strict: bool = True, + indent_size: int = 2, + parse_float: Callable[[str], Any] | None = None, + parse_int: Callable[[str], Any] | None = None, + object_hook: Callable[[dict[str, Any]], Any] | None = None, + object_pairs_hook: Callable[[list[tuple[str, Any]]], Any] | None = None, +) -> Any: + """Deserialize a TOON document to a Python object. + + Args: + s: The document. Bytes are decoded as UTF-8; ill-formed UTF-8 is an error. + strict: Enforce every check of §14. With ``strict=False``, length and + width mismatches, blank lines inside arrays, irregular + indentation, and duplicate keys (last one wins) are tolerated. + indent_size: Spaces per indentation level. + parse_float: Called with the text of every number that has a fraction + or an exponent, as in :func:`json.loads`. Use + :class:`decimal.Decimal` for lossless decoding. + parse_int: Called with the text of every integer. + object_hook: Called with every decoded object; its return value is + used instead of the ``dict``. + object_pairs_hook: Called with every decoded object as a list of + ``(key, value)`` pairs; takes priority over ``object_hook``. + + Returns: + The decoded value. + + Raises: + ToonDecodeError: The document is not valid TOON. + ValueError: An option is invalid. + """ + if isinstance(s, (bytes, bytearray)): + try: + s = s.decode("utf-8") + except UnicodeDecodeError as exc: + raise ToonDecodeError( + f"Invalid UTF-8: {exc.reason} at byte {exc.start}" + ) from None + elif not isinstance(s, str): + raise TypeError( + f"the TOON document must be str, bytes or bytearray, not {type(s).__name__}" + ) + return _decoder.decode( + s, + indent_size=_check_indent_size(indent_size), + strict=strict, + parse_int=parse_int, + parse_float=parse_float, + object_hook=object_hook, + object_pairs_hook=object_pairs_hook, + ) + + +def load( + fp: IO[str] | IO[bytes], + *, + strict: bool = True, + indent_size: int = 2, + parse_float: Callable[[str], Any] | None = None, + parse_int: Callable[[str], Any] | None = None, + object_hook: Callable[[dict[str, Any]], Any] | None = None, + object_pairs_hook: Callable[[list[tuple[str, Any]]], Any] | None = None, +) -> Any: + """Deserialize a TOON document read from the file object ``fp``. + + Accepts the same options as :func:`loads`; ``fp`` may be opened in text + or binary mode. + """ + return loads( + fp.read(), + strict=strict, + indent_size=indent_size, + parse_float=parse_float, + parse_int=parse_int, + object_hook=object_hook, + object_pairs_hook=object_pairs_hook, + ) + + +encode = dumps +"""Alias of :func:`dumps`, the name used by the other TOON implementations.""" + +decode = loads +"""Alias of :func:`loads`, the name used by the other TOON implementations.""" diff --git a/src/toon_format/_decoder.py b/src/toon_format/_decoder.py new file mode 100644 index 0000000..3a890e7 --- /dev/null +++ b/src/toon_format/_decoder.py @@ -0,0 +1,676 @@ +"""Decoder: TOON text to Python objects. + +The document is first split into lines, with comment lines removed, blank +lines folded into a flag on the following line, and indentation converted to +depths (§5.1, §12). A recursive-descent parser then walks those lines, +dispatching on the line classes of §5.2. +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from typing import Any, TypeAlias + +from ._errors import ToonDecodeError +from ._text import DELIMITERS, is_number_token + +__all__ = ["decode"] + +ObjectHook: TypeAlias = Callable[[dict[str, Any]], Any] +PairsHook: TypeAlias = Callable[[list[tuple[str, Any]]], Any] + +# A parsed field list: each field is a leaf (``None``) or a nested field group. +Fields: TypeAlias = "tuple[tuple[str, Fields | None], ...]" + +_BRACKET = re.compile(r"\[(0|[1-9][0-9]*)(:?)([\t|]?)\]") +_QUOTED = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL) +_HEX4 = re.compile(r"[0-9A-Fa-f]{4}") +_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f]") +_SIMPLE_ESCAPES = {"\\": "\\", '"': '"', "n": "\n", "r": "\r", "t": "\t"} +_MISSING = object() + + +def decode( + text: str, + *, + indent_size: int, + strict: bool, + parse_int: Callable[[str], Any] | None, + parse_float: Callable[[str], Any] | None, + object_hook: ObjectHook | None, + object_pairs_hook: PairsHook | None, +) -> Any: + """Decode a TOON document. Options are already validated.""" + lines = _split_lines(text, indent_size, strict) + parser = _Parser( + lines, strict, parse_int, parse_float, object_hook, object_pairs_hook + ) + try: + return parser.document() + except RecursionError: + raise ToonDecodeError("Document is nested too deeply to decode") from None + + +# -------------------------------------------------------------------------- +# Lines (§5.1, §12) +# -------------------------------------------------------------------------- + + +@dataclass(slots=True) +class _Line: + number: int + """1-based line number in the original document.""" + depth: int + content: str + """The line without indentation and trailing spaces.""" + source: str + """The line as written, for error messages.""" + blank_before: int | None + """Number of the first blank line directly above this one, if any.""" + + +def _split_lines(text: str, indent_size: int, strict: bool) -> list[_Line]: + if text.startswith("\ufeff"): + text = text[1:] # byte-order mark (§12) + lines: list[_Line] = [] + blank: int | None = None + for number, source in enumerate(text.split("\n"), 1): + if source.endswith("\r"): + source = source[:-1] # CRLF input (§12) + stripped = source.lstrip(" ") + if stripped.startswith("#"): + continue # comment line (§5.1); lines are adjacent across it + content = stripped.rstrip(" ") + if not content.strip("\t"): + blank = blank or number + continue + spaces = len(source) - len(stripped) + if content[0] == "\t": + if strict: + raise ToonDecodeError( + "Tabs are not allowed in indentation", number, source + ) + # Non-strict: a tab counts as one level, spaces as usual (§12). + run = len(content) - len(content.lstrip(" \t")) + spaces += content.count(" ", 0, run) + depth = content.count("\t", 0, run) + spaces // indent_size + content = content[run:] + else: + if strict and spaces % indent_size: + raise ToonDecodeError( + f"Indentation of {spaces} spaces is not a multiple of " + f"{indent_size}", + number, + source, + ) + depth = spaces // indent_size + lines.append(_Line(number, depth, content, source, blank)) + blank = None + return lines + + +# -------------------------------------------------------------------------- +# Line classes (§5.2) +# -------------------------------------------------------------------------- + + +@dataclass(slots=True) +class _Header: + key: str | None + length: int + keyed: bool + delimiter: str + fields: Fields | None + rest: str + """Inline content after the colon, trimmed.""" + + +@dataclass(slots=True) +class _KeyValue: + key: str + rest: str + + +@dataclass(slots=True) +class _Scalar: + token: str + + +class _Malformed(Exception): + """A header that fails the grammar of §6; may fall through in non-strict mode.""" + + +def _is_list_item(content: str) -> bool: + return content == "-" or content.startswith("- ") + + +def _quoted_end(text: str, start: int) -> int: + """Index just past the quoted token at ``start``, or -1 if it is unterminated.""" + match = _QUOTED.match(text, start) + return match.end() if match else -1 + + +def _find_unquoted(text: str, char: str, start: int = 0) -> int: + """Index of the first ``char`` outside double quotes, or -1.""" + if '"' not in text: + return text.find(char, start) + in_quotes = False + i, n = start, len(text) + while i < n: + c = text[i] + if in_quotes: + if c == "\\": + i += 1 + elif c == '"': + in_quotes = False + elif c == '"': + in_quotes = True + elif c == char: + return i + i += 1 + return -1 + + +def _split(text: str, delimiter: str) -> list[str]: + """Split on unquoted delimiters and trim each token (§11.2, Appendix B.3).""" + if '"' not in text: + return [token.strip(" ") for token in text.split(delimiter)] + tokens: list[str] = [] + in_quotes = False + start = i = 0 + n = len(text) + while i < n: + c = text[i] + if in_quotes: + if c == "\\": + i += 1 + elif c == '"': + in_quotes = False + elif c == '"': + in_quotes = True + elif c == delimiter: + tokens.append(text[start:i].strip(" ")) + start = i + 1 + i += 1 + tokens.append(text[start:].strip(" ")) + return tokens + + +def _leaf_count(fields: Fields) -> int: + return sum(1 if nested is None else _leaf_count(nested) for _, nested in fields) + + +# -------------------------------------------------------------------------- +# Parser +# -------------------------------------------------------------------------- + + +class _Parser: + def __init__( + self, + lines: list[_Line], + strict: bool, + parse_int: Callable[[str], Any] | None, + parse_float: Callable[[str], Any] | None, + object_hook: ObjectHook | None, + object_pairs_hook: PairsHook | None, + ) -> None: + self.lines = lines + self.pos = 0 + self.strict = strict + self.parse_int = parse_int or int + self.parse_float = parse_float + self.object_hook = object_hook + self.object_pairs_hook = object_pairs_hook + # Number of enclosing header scopes that have consumed their first + # item, row, or entry: a blank line inside any of them is a header span. + self.spans = 0 + # Number of enclosing arrays in list form. Inside them a line starting + # with "- " is a list item, admissible only at item depth (§5.2). + self.lists = 0 + + # -- infrastructure ----------------------------------------------------- + + def error(self, msg: str, line: _Line) -> ToonDecodeError: + return ToonDecodeError(msg, line.number, line.source) + + def peek(self) -> _Line | None: + return self.lines[self.pos] if self.pos < len(self.lines) else None + + def take(self) -> _Line: + line = self.lines[self.pos] + if line.blank_before is not None and self.spans and self.strict: + raise ToonDecodeError( + "Blank line inside an array or keyed object", line.blank_before, "" + ) + self.pos += 1 + return line + + def finish(self, obj: dict[str, Any]) -> Any: + """Apply the object hooks to a fully decoded object.""" + if self.object_pairs_hook is not None: + return self.object_pairs_hook(list(obj.items())) + if self.object_hook is not None: + return self.object_hook(obj) + return obj + + def assign(self, obj: dict[str, Any], key: str, value: Any, line: _Line) -> None: + if self.strict and key in obj: + raise self.error(f"Duplicate key {key!r}", line) + # Non-strict: last write wins (§14.3); like json.loads, the key keeps + # the position of its first occurrence. + obj[key] = value + + def scope_depth(self, expected: int) -> int: + """Content depth of a scope; non-strict mode tolerates a deeper first line.""" + line = self.peek() + if line is not None and not self.strict and line.depth > expected: + return line.depth + return expected + + # -- tokens (§4, §7) ---------------------------------------------------- + + def unescape(self, body: str, line: _Line) -> str: + if self.strict and _CONTROL.search(body): + raise self.error("Unescaped control character in quoted string", line) + if "\\" not in body: + return body + parts: list[str] = [] + i = 0 + while (j := body.find("\\", i)) >= 0: + parts.append(body[i:j]) + c = body[j + 1] if j + 1 < len(body) else "" + if c in _SIMPLE_ESCAPES: + parts.append(_SIMPLE_ESCAPES[c]) + i = j + 2 + elif c == "u" and _HEX4.fullmatch(body, j + 2, j + 6): + code = int(body[j + 2 : j + 6], 16) + if 0xD800 <= code <= 0xDFFF: + raise self.error(f"Surrogate escape \\u{body[j + 2 : j + 6]}", line) + parts.append(chr(code)) + i = j + 6 + else: + raise self.error(f"Invalid escape sequence {body[j : j + 6]!r}", line) + parts.append(body[i:]) + return "".join(parts) + + def quoted(self, token: str, line: _Line) -> str: + """Decode a token that starts with a quote (§7.4 quoted-token boundary).""" + end = _quoted_end(token, 0) + if end < 0: + raise self.error("Unterminated string", line) + if end != len(token): + raise self.error("Unexpected characters after closing quote", line) + return self.unescape(token[1:-1], line) + + def key(self, token: str, line: _Line) -> str: + token = token.strip(" ") + return self.quoted(token, line) if token.startswith('"') else token + + def primitive(self, token: str, line: _Line) -> Any: + if not token: + return "" + if token[0] == '"': + return self.quoted(token, line) + if token == "true": + return True + if token == "false": + return False + if token == "null": + return None + if is_number_token(token): + return self.number(token, line) + return token + + def number(self, token: str, line: _Line) -> Any: + if "." not in token and "e" not in token and "E" not in token: + try: + return self.parse_int(token) + except ValueError as exc: + raise self.error(str(exc), line) from None + if self.parse_float is not None: + return self.parse_float(token) + value = float(token) + if math.isinf(value) and self.strict: + raise self.error(f"Number {token} is out of range", line) + return value or 0.0 # -0 decodes to 0 (§4) + + def values(self, text: str, delimiter: str, line: _Line) -> list[Any]: + """Decode a delimited cell sequence; empty text is zero cells (§11.2).""" + if not text: + return [] + return [self.primitive(token, line) for token in _split(text, delimiter)] + + # -- headers (§6) ------------------------------------------------------- + + def classify(self, content: str, line: _Line) -> _Header | _KeyValue | _Scalar: + """Classify a line body as a header, a key-value line, or a scalar (§5.2).""" + colon = _find_unquoted(content, ":") + if content.startswith('"'): + end = _quoted_end(content, 0) + if end < 0: + raise self.error("Unterminated string", line) + if content.startswith("[", end): + return self.header(content, end, colon, line) + after = content[end:].lstrip(" ") + if not after: + return _Scalar(content) + if after[0] != ":": + raise self.error("Unexpected characters after closing quote", line) + return _KeyValue(self.quoted(content[:end], line), after[1:].strip(" ")) + bracket = _find_unquoted(content, "[") + if bracket >= 0 and (colon < 0 or bracket < colon): + return self.header(content, bracket, colon, line) + if colon >= 0: + return _KeyValue( + content[:colon].strip(" "), content[colon + 1 :].strip(" ") + ) + return _Scalar(content) + + def header( + self, content: str, bracket: int, colon: int, line: _Line + ) -> _Header | _KeyValue | _Scalar: + """Parse a header candidate whose bracket segment starts at ``bracket``.""" + match = _BRACKET.match(content, bracket) + try: + if match is None: + raise _Malformed("Malformed bracket segment") + if content[bracket - 1 : bracket] in (" ", "\t"): + raise _Malformed("Whitespace between a key and its bracket segment") + delimiter = match.group(3) or "," + pos = match.end() + fields = None + if content.startswith("{", pos): + fields, pos = self.field_list(content, pos, delimiter, line) + if not content.startswith(":", pos): + raise _Malformed("Expected ':' after the array header") + rest = content[pos + 1 :].strip(" ") + keyed = bool(match.group(2)) + if keyed and fields is None: + raise _Malformed("A keyed header requires a field list") + if fields is not None and rest: + raise _Malformed("Unexpected content after a header with a field list") + except _Malformed as exc: + if colon < 0: + return _Scalar(content) # without a colon it is a scalar line (§5.2) + if self.strict: + raise self.error(str(exc), line) from None + return self.literal_key_value(content, line) + key_text = content[:bracket] + key = self.key(key_text, line) if key_text else None + return _Header(key, int(match.group(1)), keyed, delimiter, fields, rest) + + def literal_key_value(self, content: str, line: _Line) -> _KeyValue: + """Read a malformed header as a key-value line (non-strict fall-through, §6).""" + colon = _find_unquoted(content, ":") + return _KeyValue( + self.key(content[:colon], line), content[colon + 1 :].strip(" ") + ) + + def field_list( + self, text: str, pos: int, delimiter: str, line: _Line + ) -> tuple[Fields, int]: + """Parse the ``{...}`` at ``pos``; return the fields and the index past it.""" + fields: list[tuple[str, Fields | None]] = [] + seen: set[str] = set() + n = len(text) + pos += 1 + while True: + while pos < n and text[pos] == " ": + pos += 1 + if text.startswith('"', pos): + end = _quoted_end(text, pos) + if end < 0: + raise _Malformed("Unterminated field name") + name = self.unescape(text[pos + 1 : end - 1], line) + pos = end + else: + start = pos + while pos < n and text[pos] not in "{},|\t": + pos += 1 + name = text[start:pos].strip(" ") + if not name: + raise _Malformed("Empty field name in field list") + while pos < n and text[pos] == " ": + pos += 1 + nested = None + if text.startswith("{", pos): + nested, pos = self.field_list(text, pos, delimiter, line) + if name in seen and self.strict: + raise self.error(f"Duplicate field name {name!r}", line) + seen.add(name) + fields.append((name, nested)) + if pos >= n: + raise _Malformed("Unmatched '{' in field list") + c = text[pos] + pos += 1 + if c == "}": + return tuple(fields), pos + if c != delimiter: + if c in DELIMITERS: + raise _Malformed( + "Field list delimiter does not match the bracket segment" + ) + raise _Malformed("Malformed field list") + + # -- document (§5) ------------------------------------------------------ + + def document(self) -> Any: + first = self.peek() + if first is None: + return self.finish({}) # empty document (§5) + if first.content == "[]": + self.take() + self.end_of_root() + return [] + kind = self.classify(first.content, first) + if isinstance(kind, _Header) and kind.key is None and first.depth == 0: + self.take() + value = self.header_value(kind, first, 1) + self.end_of_root() + return value + if isinstance(kind, _Scalar) and len(self.lines) == 1: + self.take() + return self.primitive(first.content, first) + return self.finish(self.object_body(0, {})) + + def end_of_root(self) -> None: + """A root array or keyed object spans the whole document (§5).""" + line = self.peek() + if line is not None: + if self.strict: + raise self.error("Unexpected content after the root value", line) + self.pos = len(self.lines) + + # -- objects (§8) ------------------------------------------------------- + + def object_body(self, depth: int, obj: dict[str, Any]) -> dict[str, Any]: + """Parse the fields of an object whose content sits at ``depth``.""" + while (line := self.peek()) is not None and line.depth >= depth: + if self.lists and _is_list_item(line.content): + raise self.error("List item outside the item depth of its array", line) + if line.depth > depth: + self.orphan(line) + continue + self.take() + self.field(line, obj, depth) + return obj + + def orphan(self, line: _Line) -> None: + """Handle a line deeper than its scope that no line opened (§8, §14.2).""" + if self.strict: + raise self.error("Unexpected indentation", line) + if isinstance(self.classify(line.content, line), _Scalar): + raise self.error("Unexpected bare value", line) + self.take() + + def field(self, line: _Line, obj: dict[str, Any], depth: int) -> None: + kind = self.classify(line.content, line) + if isinstance(kind, _Header): + if kind.key is None: + if self.strict: + raise self.error( + "A header without a key is only valid at the root", line + ) + kind = self.literal_key_value(line.content, line) + else: + self.assign( + obj, kind.key, self.header_value(kind, line, depth + 1), line + ) + return + if isinstance(kind, _Scalar): + raise self.error("Expected 'key: value'", line) + self.assign(obj, kind.key, self.field_value(kind.rest, line, depth), line) + + def field_value(self, rest: str, line: _Line, depth: int) -> Any: + """Decode what follows ``key:`` for a field standing at ``depth``.""" + if rest: + return [] if rest == "[]" else self.primitive(rest, line) + child = self.peek() + if child is None or child.depth <= depth: + return self.finish({}) + if self.strict and child.depth != depth + 1: + raise self.error("Indentation jumps more than one level", child) + return self.finish(self.object_body(child.depth, {})) + + # -- arrays and keyed objects (§9, §10) --------------------------------- + + def header_value(self, header: _Header, line: _Line, depth: int) -> Any: + """Decode the value a header opens; ``depth`` is its content depth.""" + if header.keyed: + return self.entries(header, line, self.scope_depth(depth)) + if header.fields is not None: + return self.rows(header, line, self.scope_depth(depth)) + if header.rest: + values = self.values(header.rest, header.delimiter, line) + self.check_count(len(values), header, line) + return values + return self.items(header, line, self.scope_depth(depth)) + + def check_count(self, count: int, header: _Header, line: _Line) -> None: + if self.strict and count != header.length: + raise self.error(f"Declared length {header.length} but found {count}", line) + + def check_width(self, cells: list[Any], fields: Fields, line: _Line) -> None: + width = _leaf_count(fields) + if self.strict and len(cells) != width: + raise self.error(f"Expected {width} values but found {len(cells)}", line) + + def items(self, header: _Header, line: _Line, depth: int) -> list[Any]: + """Parse the list items of an array in list form (§9.2, §9.4).""" + items: list[Any] = [] + started = False + self.lists += 1 + try: + while ( + (item := self.peek()) + and item.depth == depth + and _is_list_item(item.content) + ): + self.take() + if not started: + self.spans += 1 + started = True + items.append(self.item(item, depth)) + finally: + self.lists -= 1 + self.spans -= started + self.check_count(len(items), header, line) + return items + + def item(self, line: _Line, depth: int) -> Any: + """Decode one list item at ``depth`` (§9.4, §10).""" + rest = line.content[2:].strip(" ") + if not rest: + return self.finish({}) + if rest == "[]": + return [] + kind = self.classify(rest, line) + if isinstance(kind, _Scalar): + return self.primitive(rest, line) + if isinstance(kind, _Header) and kind.key is None: + if kind.fields is None: + return self.header_value(kind, line, depth + 1) + if self.strict: + raise self.error( + "A header with a field list cannot be a list item", line + ) + kind = self.literal_key_value(rest, line) + # An object whose first field sits on the hyphen line at depth + 1. + obj: dict[str, Any] = {} + if isinstance(kind, _Header): + assert kind.key is not None + obj[kind.key] = self.header_value(kind, line, depth + 2) + else: + obj[kind.key] = self.field_value(kind.rest, line, depth + 1) + return self.finish(self.object_body(depth + 1, obj)) + + def rows(self, header: _Header, line: _Line, depth: int) -> list[Any]: + """Parse the rows of a tabular array (§9.3).""" + assert header.fields is not None + delimiter = header.delimiter + rows: list[Any] = [] + started = False + try: + while (row := self.peek()) and row.depth == depth: + colon = _find_unquoted(row.content, ":") + if colon >= 0: + split = _find_unquoted(row.content, delimiter) + if split < 0 or colon < split: + break # a key-value line ends the rows + self.take() + if not started: + self.spans += 1 + started = True + cells = self.values(row.content, delimiter, row) + self.check_width(cells, header.fields, row) + rows.append(self.materialize(header.fields, iter(cells))) + finally: + self.spans -= started + self.check_count(len(rows), header, line) + return rows + + def entries(self, header: _Header, line: _Line, depth: int) -> Any: + """Parse the entry rows of a keyed tabular object (§9.5).""" + assert header.fields is not None + obj: dict[str, Any] = {} + count = 0 + try: + while (entry := self.peek()) and entry.depth == depth: + self.take() + if not count: + self.spans += 1 + count += 1 + colon = _find_unquoted(entry.content, ":") + if colon < 0: + if self.strict: + raise self.error("Expected 'key: values' entry row", entry) + continue + key = self.key(entry.content[:colon], entry) + cells = self.values( + entry.content[colon + 1 :].strip(" "), header.delimiter, entry + ) + self.check_width(cells, header.fields, entry) + self.assign( + obj, key, self.materialize(header.fields, iter(cells)), entry + ) + finally: + self.spans -= bool(count) + self.check_count(count, header, line) + return self.finish(obj) + + def materialize(self, fields: Fields, cells: Iterator[Any]) -> Any: + """Build one object from row cells, walking the field list depth-first.""" + obj: dict[str, Any] = {} + for name, nested in fields: + value = ( + next(cells, _MISSING) + if nested is None + else self.materialize(nested, cells) + ) + if value is not _MISSING: + obj[name] = value + return self.finish(obj) diff --git a/src/toon_format/_encoder.py b/src/toon_format/_encoder.py new file mode 100644 index 0000000..7e50da0 --- /dev/null +++ b/src/toon_format/_encoder.py @@ -0,0 +1,371 @@ +"""Encoder: Python objects to TOON text. + +Encoding runs in two passes. :func:`normalize` maps host values onto the JSON +data model (§3), and :class:`_Encoder` renders that model, choosing each +value's form from its shape and position (§1.4, §8-§10). +""" + +from __future__ import annotations + +import dataclasses +import math +import re +import sys +from collections.abc import Callable, Iterator, Mapping +from datetime import date, datetime, time +from decimal import Decimal +from enum import Enum +from pathlib import PurePath +from typing import Any, TypeAlias +from uuid import UUID + +from ._text import encode_key, encode_string, format_decimal, format_float + +__all__ = ["encode"] + +Json: TypeAlias = ( + "bool | int | float | Decimal | str | list[Json] | dict[str, Json] | None" +) + +# An ordered field list: each field is a leaf (``None``) or a nested field group. +Shape: TypeAlias = "tuple[tuple[str, Shape | None], ...]" + +_SURROGATE = re.compile("[\ud800-\udfff]") + + +def encode( + obj: Any, + *, + indent_size: int, + delimiter: str, + default: Callable[[Any], Any] | None, + sort_keys: bool, +) -> str: + """Encode ``obj`` as a TOON document. Options are already validated.""" + try: + value = _Normalizer(default, sort_keys).normalize(obj) + encoder = _Encoder(indent_size, delimiter) + encoder.root(value) + except RecursionError: + raise ValueError("Object is nested too deeply to encode") from None + return "\n".join(encoder.lines) + + +# -------------------------------------------------------------------------- +# Normalization (§3) +# -------------------------------------------------------------------------- + + +def _check_string(value: str) -> str: + if _SURROGATE.search(value): + # §3: an unpaired surrogate has no TOON representation. + raise ValueError(f"String contains an unpaired surrogate: {value!r}") + return value + + +class _Normalizer: + def __init__(self, default: Callable[[Any], Any] | None, sort_keys: bool) -> None: + self.default = default + self.sort_keys = sort_keys + self.active: set[int] = set() + + def normalize(self, value: Any) -> Json: + cls = type(value) + if cls is str: + return _check_string(value) + if value is None or cls is bool or cls is int: + return value # type: ignore[no-any-return] + if cls is float: + return value if math.isfinite(value) else None + if cls is dict or cls is list or cls is tuple: + return self._container(value) + return self._other(value) + + def _container(self, value: Any) -> Json: + marker = id(value) + if marker in self.active: + raise ValueError("Circular reference detected") + self.active.add(marker) + try: + if isinstance(value, Mapping): + return self._mapping(value) + items = value + if isinstance(value, (set, frozenset)): + items = _sorted_members(value) + return [self.normalize(item) for item in items] + finally: + self.active.discard(marker) + + def _mapping(self, value: Mapping[Any, Any]) -> dict[str, Json]: + pairs = [(_convert_key(key), item) for key, item in value.items()] + if self.sort_keys: + pairs.sort(key=lambda pair: pair[0]) + result: dict[str, Json] = {} + for key, item in pairs: + # Keys such as 1 and "1" collide once converted; keeping one value + # would lose data, and a duplicate key is invalid TOON (§14). + if key in result: + raise ValueError(f"Duplicate key after conversion: {key!r}") + result[key] = self.normalize(item) + return result + + def _other(self, value: Any) -> Json: + # Order matters: bool before int, str/int subclasses (StrEnum, IntEnum) + # before Enum, datetime before date. + if isinstance(value, str): + return _check_string(str.__str__(value)) + if isinstance(value, bool): + return bool(value) + if isinstance(value, int): + return int(value) + if isinstance(value, float): + number = float(value) + return number if math.isfinite(number) else None + if isinstance(value, Decimal): + return value if value.is_finite() else None + if isinstance(value, (Mapping, list, tuple, set, frozenset)): + return self._container(value) + if isinstance(value, (datetime, date, time)): + return value.isoformat() + if isinstance(value, (UUID, PurePath)): + return str(value) + if isinstance(value, Enum): + return self._converted(value, value.value) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + fields = { + field.name: getattr(value, field.name) + for field in dataclasses.fields(value) + } + return self._converted(value, fields) + # attrs classes are recognized without importing attrs (§3). + attributes = getattr(type(value), "__attrs_attrs__", None) + if attributes is not None: + fields = {field.name: getattr(value, field.name) for field in attributes} + return self._converted(value, fields) + pydantic = sys.modules.get("pydantic") + if pydantic is not None and isinstance(value, pydantic.BaseModel): + return self._converted(value, value.model_dump(mode="json")) + if self.default is not None: + return self._converted(value, self.default(value)) + raise TypeError( + f"Object of type {type(value).__name__} is not TOON serializable" + ) + + def _converted(self, original: Any, replacement: Any) -> Json: + """Normalize ``replacement``, which must not lead back to ``original``.""" + marker = id(original) + if marker in self.active: + raise ValueError("Circular reference detected") + self.active.add(marker) + try: + return self.normalize(replacement) + finally: + self.active.discard(marker) + + +def _sorted_members(value: set[Any] | frozenset[Any]) -> list[Any]: + """Order set members deterministically (Appendix E.6).""" + try: + return sorted(value) + except TypeError: + return sorted(value, key=repr) + + +def _convert_key(key: Any) -> str: + """Coerce a mapping key to a string, following :func:`json.dumps`.""" + if isinstance(key, str): + return _check_string(str.__str__(key)) + if key is None or isinstance(key, bool): + return "null" if key is None else ("true" if key else "false") + if isinstance(key, int): + return str(int(key)) + if isinstance(key, float): + if math.isfinite(key): + return format_float(key) + return "NaN" if math.isnan(key) else ("Infinity" if key > 0 else "-Infinity") + raise TypeError( + f"Keys must be str, int, float, bool or None, not {type(key).__name__}" + ) + + +# -------------------------------------------------------------------------- +# Form detection (§9.3, §9.5) +# -------------------------------------------------------------------------- + + +def _is_primitive(value: Json) -> bool: + return not isinstance(value, (dict, list)) + + +def _shape(objects: list[Json]) -> Shape | None: + """Return the shared field list of ``objects``, or ``None`` if they are not uniform. + + Objects are uniform when all are non-empty objects with the same set of + keys and every column is uniform-primitive or nested-uniform (§9.3). + """ + first = objects[0] + if not isinstance(first, dict) or not first: + return None + keys = first.keys() + for obj in objects: + if not isinstance(obj, dict) or len(obj) != len(keys) or obj.keys() != keys: + return None + fields: list[tuple[str, Shape | None]] = [] + for key in keys: + column = [obj[key] for obj in objects if isinstance(obj, dict)] + if all(map(_is_primitive, column)): + fields.append((key, None)) + continue + nested = _shape(column) + if nested is None: + return None + fields.append((key, nested)) + return tuple(fields) + + +def _keyed_shape(obj: dict[str, Json]) -> Shape | None: + """Field list for the keyed tabular form of an object (§9.5).""" + if len(obj) < 2: + return None + return _shape(list(obj.values())) + + +def _leaves(obj: Json, shape: Shape) -> Iterator[Json]: + """Yield the leaf values of ``obj`` in depth-first header order.""" + assert isinstance(obj, dict) + for key, nested in shape: + if nested is None: + yield obj[key] + else: + yield from _leaves(obj[key], nested) + + +# -------------------------------------------------------------------------- +# Rendering +# -------------------------------------------------------------------------- + + +class _Encoder: + def __init__(self, indent_size: int, delimiter: str) -> None: + self.unit = " " * indent_size + self.delimiter = delimiter + # §11.1: every header declares the document delimiter; comma is implicit. + self.symbol = "" if delimiter == "," else delimiter + self.lines: list[str] = [] + + def emit(self, depth: int, text: str) -> None: + self.lines.append(self.unit * depth + text) + + def root(self, value: Json) -> None: + if isinstance(value, dict): + shape = _keyed_shape(value) + if shape is not None: + self.keyed("", value, shape, 0) + else: + self.fields(value, 0) # an empty object is an empty document (§8) + elif isinstance(value, list): + if value: + self.array("", value, 0, tabular=True) + else: + self.emit(0, "[]") + else: + self.emit(0, self.primitive(value)) + + def primitive(self, value: Json) -> str: + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return format_float(value) + if isinstance(value, Decimal): + return format_decimal(value) + assert isinstance(value, str) + return encode_string(value, self.delimiter) + + def join(self, values: Iterator[Json] | list[Json]) -> str: + return self.delimiter.join(map(self.primitive, values)) + + def field_list(self, shape: Shape) -> str: + entries = ( + encode_key(key) + ("" if nested is None else self.field_list(nested)) + for key, nested in shape + ) + return "{" + self.delimiter.join(entries) + "}" + + def fields(self, obj: dict[str, Json], depth: int) -> None: + for key, value in obj.items(): + self.field(encode_key(key), value, depth) + + def field(self, key: str, value: Json, depth: int) -> None: + """Emit one object field; ``key`` is already encoded (§8).""" + if isinstance(value, dict): + shape = _keyed_shape(value) + if shape is not None: + self.keyed(key, value, shape, depth) + else: + self.emit(depth, key + ":") + self.fields(value, depth + 1) + elif isinstance(value, list): + if value: + self.array(key, value, depth, tabular=True) + else: + self.emit(depth, key + ": []") + else: + self.emit(depth, f"{key}: {self.primitive(value)}") + + def array( + self, prefix: str, array: list[Json], depth: int, *, tabular: bool + ) -> None: + """Emit a non-empty array whose header starts with ``prefix``. + + ``tabular`` is false for a keyless array that is itself a list item, + where a field list is not allowed (§9.4). + """ + head = f"{prefix}[{len(array)}{self.symbol}]" + if all(map(_is_primitive, array)): + self.emit(depth, f"{head}: {self.join(array)}") # §9.1 + return + shape = _shape(array) if tabular else None + if shape is not None: + self.emit(depth, f"{head}{self.field_list(shape)}:") # §9.3 + for element in array: + self.emit(depth + 1, self.join(_leaves(element, shape))) + return + self.emit(depth, head + ":") # §9.2, §9.4 + for element in array: + self.item(element, depth + 1) + + def keyed(self, key: str, obj: dict[str, Json], shape: Shape, depth: int) -> None: + """Emit an object of uniform objects in keyed tabular form (§9.5).""" + self.emit(depth, f"{key}[{len(obj)}:{self.symbol}]{self.field_list(shape)}:") + for entry, value in obj.items(): + self.emit( + depth + 1, f"{encode_key(entry)}: {self.join(_leaves(value, shape))}" + ) + + def item(self, value: Json, depth: int) -> None: + """Emit one list item at ``depth`` (§9.4, §10).""" + if isinstance(value, dict): + if not value: + self.emit(depth, "-") + return + # The object's fields stand at depth + 1; the first one moves onto + # the hyphen line, so whatever scope it opens lands at depth + 2. + first = len(self.lines) + self.fields(value, depth + 1) + indent = self.unit * depth + self.lines[first] = ( + indent + "- " + self.lines[first][len(indent + self.unit) :] + ) + elif isinstance(value, list): + if value: + self.array("- ", value, depth, tabular=False) + else: + self.emit(depth, f"- [0{self.symbol}]:") # never "- []" (§9.2) + else: + self.emit(depth, "- " + self.primitive(value)) diff --git a/src/toon_format/_errors.py b/src/toon_format/_errors.py new file mode 100644 index 0000000..53bab1d --- /dev/null +++ b/src/toon_format/_errors.py @@ -0,0 +1,36 @@ +"""Exceptions raised by the decoder.""" + +from __future__ import annotations + +__all__ = ["ToonDecodeError"] + + +class ToonDecodeError(ValueError): + """Raised when a TOON document cannot be decoded. + + Subclasses :class:`ValueError`, like :class:`json.JSONDecodeError`, so + existing ``except ValueError`` handlers keep working. + + Attributes: + msg: The unformatted error message. + line: 1-based line number of the offending line, or ``None`` when the + error is not tied to a line (for example, invalid UTF-8 input). + source: The offending line as it appears in the document, or ``None``. + """ + + msg: str + line: int | None + source: str | None + + def __init__( + self, msg: str, line: int | None = None, source: str | None = None + ) -> None: + self.msg = msg + self.line = line + self.source = source + super().__init__(f"line {line}: {msg}" if line is not None else msg) + + def __reduce__( + self, + ) -> tuple[type[ToonDecodeError], tuple[str, int | None, str | None]]: + return type(self), (self.msg, self.line, self.source) diff --git a/src/toon_format/_literal_utils.py b/src/toon_format/_literal_utils.py deleted file mode 100644 index 701dc3a..0000000 --- a/src/toon_format/_literal_utils.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Utilities for detecting literal token types. - -This module provides functions to identify different types of literal -values in TOON syntax, such as booleans, null, and numeric literals. -Used during decoding to distinguish between literal values and strings. -""" - -from .constants import FALSE_LITERAL, NULL_LITERAL, TRUE_LITERAL - - -def is_boolean_or_null_literal(token: str) -> bool: - """Check if a token is a boolean or null literal (`true`, `false`, `null`). - - Args: - token: The token to check - - Returns: - True if the token is a boolean or null literal - - Examples: - >>> is_boolean_or_null_literal("true") - True - >>> is_boolean_or_null_literal("null") - True - >>> is_boolean_or_null_literal("hello") - False - """ - return token == TRUE_LITERAL or token == FALSE_LITERAL or token == NULL_LITERAL - - -def is_numeric_literal(token: str) -> bool: - """Check if a token represents a valid numeric literal. - - Rejects numbers with leading zeros (except `"0"` itself or decimals like `"0.5"`). - Per Section 7.3 of the TOON specification. - - Args: - token: The token to check - - Returns: - True if the token is a valid numeric literal - - Examples: - >>> is_numeric_literal("42") - True - >>> is_numeric_literal("3.14") - True - >>> is_numeric_literal("0.5") - True - >>> is_numeric_literal("0123") # Leading zero - not valid - False - >>> is_numeric_literal("-01") # Negative with leading zero - not valid - False - >>> is_numeric_literal("hello") - False - """ - if not token: - return False - - # Handle negative numbers - start_idx = 1 if token.startswith("-") else 0 - if start_idx >= len(token): - return False - - # Must not have leading zeros (except for `"0"` itself or decimals like `"0.5"`) - # Check the first digit after optional minus sign - if len(token) > start_idx + 1 and token[start_idx] == "0" and token[start_idx + 1] != ".": - return False - - # Check if it's a valid number - try: - num = float(token) - # Reject NaN and infinity - return not (num != num or not (-float("inf") < num < float("inf"))) - except ValueError: - return False diff --git a/src/toon_format/_parsing_utils.py b/src/toon_format/_parsing_utils.py deleted file mode 100644 index 747afaa..0000000 --- a/src/toon_format/_parsing_utils.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Parsing utilities for quote-aware string processing. - -This module provides utilities for parsing TOON strings while respecting -quoted sections and escape sequences. Used extensively in decoder for -finding delimiters and structural characters outside of quoted strings. -""" - -from typing import Iterator, List, Tuple - -from .constants import BACKSLASH, DOUBLE_QUOTE - - -def iter_unquoted(line: str, start: int = 0) -> Iterator[Tuple[int, str, bool]]: - """Iterate over characters in a line, tracking quote state. - - This is the core utility for quote-aware string processing. It handles: - - Tracking quote boundaries - - Skipping escaped characters within quotes - - Yielding (index, character, is_quoted) tuples - - Args: - line: The line to iterate over - start: Starting position (default: 0) - - Yields: - Tuple of (index, char, is_quoted) for each character - - Examples: - >>> list(iter_unquoted('a"b:c"d')) - [(0, 'a', False), (1, '"', False), (2, 'b', True), (3, ':', True), - (4, 'c', True), (5, '"', True), (6, 'd', False)] - """ - in_quotes = False - i = start - - while i < len(line): - char = line[i] - - if char == DOUBLE_QUOTE: - # Yield quote with current state, THEN toggle for next char - yield (i, char, in_quotes) - in_quotes = not in_quotes - elif char == BACKSLASH and i + 1 < len(line) and in_quotes: - # Escaped character - yield backslash, then skip and yield next char - yield (i, char, True) - i += 1 - if i < len(line): - yield (i, line[i], True) - else: - yield (i, char, in_quotes) - - i += 1 - - -def find_unquoted_char(line: str, target_char: str, start: int = 0) -> int: - """Find first occurrence of target character outside of quoted strings. - - Args: - line: Line to search - target_char: Character to find - start: Starting position (default: 0) - - Returns: - Index of character, or -1 if not found - - Examples: - >>> find_unquoted_char('a:b"c:d"e', ':') - 1 - >>> find_unquoted_char('a"b:c"d:e', ':', 0) - 7 - >>> find_unquoted_char('"a:b":c', ':', 0) - 5 - """ - for i, char, is_quoted in iter_unquoted(line, start): - if char == target_char and not is_quoted: - return i - return -1 - - -def parse_delimited_values(line: str, delimiter: str) -> List[str]: - """Parse delimiter-separated values, respecting quotes and escapes. - - This function splits a line on the delimiter, but only at unquoted positions. - Quotes and escape sequences within quoted sections are preserved. - - Args: - line: Line content - delimiter: Active delimiter (e.g., ',', '\\t', '|') - - Returns: - List of token strings (with quotes and escapes preserved) - - Examples: - >>> parse_delimited_values('a,b,c', ',') - ['a', 'b', 'c'] - >>> parse_delimited_values('a,"b,c",d', ',') - ['a', '"b,c"', 'd'] - >>> parse_delimited_values('"a,b",c', ',') - ['"a,b"', 'c'] - """ - tokens: List[str] = [] - current: List[str] = [] - - for i, char, is_quoted in iter_unquoted(line): - if char == delimiter and not is_quoted: - # Split on unquoted delimiter - tokens.append("".join(current)) - current = [] - else: - current.append(char) - - # Add final token (always add, even if empty, to handle trailing delimiters) - if current or tokens: - tokens.append("".join(current)) - - return tokens - - -def split_at_unquoted_char(line: str, target_char: str) -> Tuple[str, str]: - """Split a line at the first unquoted occurrence of target character. - - Args: - line: Line content - target_char: Character to split on - - Returns: - Tuple of (before, after) strings - - Raises: - ValueError: If target character not found outside quotes - - Examples: - >>> split_at_unquoted_char('key: value', ':') - ('key', ' value') - >>> split_at_unquoted_char('"key:1": value', ':') - ('"key:1"', ' value') - """ - idx = find_unquoted_char(line, target_char) - if idx == -1: - raise ValueError(f"Character '{target_char}' not found outside quotes") - return (line[:idx], line[idx + 1 :]) - - -def find_first_unquoted(line: str, chars: List[str], start: int = 0) -> Tuple[int, str]: - """Find the first occurrence of any character in chars, outside quotes. - - Args: - line: Line to search - chars: List of characters to search for - start: Starting position (default: 0) - - Returns: - Tuple of (index, character) for first match, or (-1, '') if none found - - Examples: - >>> find_first_unquoted('a:b,c', [':', ',']) - (1, ':') - >>> find_first_unquoted('a"b:c",d', [':', ',']) - (7, ',') - """ - char_set = set(chars) - for i, char, is_quoted in iter_unquoted(line, start): - if char in char_set and not is_quoted: - return (i, char) - return (-1, "") diff --git a/src/toon_format/_scanner.py b/src/toon_format/_scanner.py deleted file mode 100644 index cb927a2..0000000 --- a/src/toon_format/_scanner.py +++ /dev/null @@ -1,289 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Scanner for parsing TOON input into lines with depth information. - -This module implements the first stage of the TOON decoding pipeline: -scanning the input text and converting it into structured line objects -with depth and indentation metadata. Handles strict and lenient parsing modes. -""" - -from dataclasses import dataclass -from typing import List, Optional, Tuple - -from .constants import SPACE, TAB - - -@dataclass -class ParsedLine: - """A parsed line with metadata. - - Attributes: - raw: The original raw line content - depth: The indentation depth (number of indent levels) - indent: The number of leading spaces - content: The line content after removing indentation - line_num: The 1-based line number in the source - """ - - raw: str - depth: int - indent: int - content: str - line_num: int - - @property - def is_blank(self) -> bool: - """Check if this line is blank (only whitespace). - - Returns: - True if the line contains only whitespace - """ - return not self.content.strip() - - -@dataclass -class BlankLineInfo: - """Information about a blank line. - - Attributes: - line_num: The 1-based line number - indent: The number of leading spaces - depth: The computed indentation depth - """ - - line_num: int - indent: int - depth: int - - -class LineCursor: - """Iterator-like class for traversing parsed lines. - - Provides methods to peek at the current line, advance to the next line, - and check for lines at specific depths. This abstraction makes the decoder - logic cleaner and easier to test. - """ - - def __init__( - self, - lines: List[ParsedLine], - blank_lines: Optional[List[BlankLineInfo]] = None, - ) -> None: - """Initialize a line cursor. - - Args: - lines: The parsed lines to traverse - blank_lines: Optional list of blank line information - """ - self._lines = lines - self._index = 0 - self._blank_lines = blank_lines or [] - - def get_blank_lines(self) -> List[BlankLineInfo]: - """Get the list of blank lines.""" - return self._blank_lines - - def peek(self) -> Optional[ParsedLine]: - """Peek at the current line without advancing. - - Returns: - The current line, or None if at end - """ - if self._index >= len(self._lines): - return None - return self._lines[self._index] - - def next(self) -> Optional[ParsedLine]: - """Get the current line and advance. - - Returns: - The current line, or None if at end - """ - if self._index >= len(self._lines): - return None - line = self._lines[self._index] - self._index += 1 - return line - - def current(self) -> Optional[ParsedLine]: - """Get the most recently consumed line. - - Returns: - The previous line, or None if no line has been consumed - """ - if self._index > 0: - return self._lines[self._index - 1] - return None - - def advance(self) -> None: - """Advance to the next line.""" - self._index += 1 - - def at_end(self) -> bool: - """Check if cursor is at the end of lines. - - Returns: - True if at end - """ - return self._index >= len(self._lines) - - @property - def length(self) -> int: - """Get the total number of lines.""" - return len(self._lines) - - def peek_at_depth(self, target_depth: int) -> Optional[ParsedLine]: - """Peek at the next line at a specific depth. - - Args: - target_depth: The target depth - - Returns: - The line if it matches the depth, None otherwise - """ - line = self.peek() - if not line or line.depth < target_depth: - return None - if line.depth == target_depth: - return line - return None - - def has_more_at_depth(self, target_depth: int) -> bool: - """Check if there are more lines at a specific depth. - - Args: - target_depth: The target depth - - Returns: - True if there are more lines at the target depth - """ - return self.peek_at_depth(target_depth) is not None - - def skip_deeper_than(self, depth: int) -> None: - """Skip all lines that are deeper than the given depth. - - This is useful for skipping over nested structures after processing them. - - Args: - depth: The reference depth. All lines with depth > this will be skipped. - - Example: - >>> cursor.skip_deeper_than(1) # Skip all lines at depth 2, 3, 4, etc. - """ - line = self.peek() - while line and line.depth > depth: - self.advance() - line = self.peek() - - -def to_parsed_lines( - source: str, - indent_size: int, - strict: bool, -) -> Tuple[List[ParsedLine], List[BlankLineInfo]]: - """Convert source string to parsed lines with depth information. - - Per Section 12 of the TOON specification for indentation handling. - This is the entry point for the scanning stage of the decoder pipeline. - - Args: - source: The source string to parse - indent_size: The number of spaces per indentation level - strict: Whether to enforce strict indentation validation - - Returns: - A tuple of (parsed_lines, blank_lines) - - Raises: - SyntaxError: If strict mode validation fails (tabs in indentation, invalid spacing) - - Examples: - >>> lines, blanks = to_parsed_lines("name: Alice\\n age: 30", 2, True) - >>> lines[0].content - 'name: Alice' - >>> lines[1].depth - 1 - """ - if not source.strip(): - return [], [] - - lines = source.split("\n") - parsed: List[ParsedLine] = [] - blank_lines: List[BlankLineInfo] = [] - - for i, raw in enumerate(lines): - line_num = i + 1 - indent = 0 - while indent < len(raw) and raw[indent] == SPACE: - indent += 1 - - content = raw[indent:] - - # Compute depth for both blank and non-blank lines - depth = _compute_depth_from_indent(indent, indent_size) - - # Track blank lines (but still include them in parsed list for validation) - is_blank = not content.strip() - if is_blank: - blank_lines.append( - BlankLineInfo( - line_num=line_num, - indent=indent, - depth=depth, - ) - ) - # Blank lines are not validated for indentation - # But we still add them to parsed list for array blank line detection - - # Strict mode validation (skip for blank lines) - if strict and not is_blank: - # Find the full leading whitespace region (spaces and tabs) - ws_end = 0 - while ws_end < len(raw) and (raw[ws_end] == SPACE or raw[ws_end] == TAB): - ws_end += 1 - - # Check for tabs in leading whitespace (before actual content) - if TAB in raw[:ws_end]: - raise SyntaxError( - f"Line {line_num}: Tabs not allowed in indentation in strict mode" - ) - - # Check for exact multiples of indent_size - if indent > 0 and indent % indent_size != 0: - raise SyntaxError( - f"Line {line_num}: Indent must be exact multiple of {indent_size}, " - f"but found {indent} spaces" - ) - - parsed.append( - ParsedLine( - raw=raw, - indent=indent, - content=content, - depth=depth, - line_num=line_num, - ) - ) - - return parsed, blank_lines - - -def _compute_depth_from_indent(indent_spaces: int, indent_size: int) -> int: - """Compute depth from indentation spaces. - - Args: - indent_spaces: Number of leading spaces - indent_size: Number of spaces per indentation level - - Returns: - The computed depth - - Examples: - >>> _compute_depth_from_indent(0, 2) - 0 - >>> _compute_depth_from_indent(4, 2) - 2 - >>> _compute_depth_from_indent(3, 2) # Lenient mode - 1 - """ - return indent_spaces // indent_size diff --git a/src/toon_format/_string_utils.py b/src/toon_format/_string_utils.py deleted file mode 100644 index 6f58753..0000000 --- a/src/toon_format/_string_utils.py +++ /dev/null @@ -1,169 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""String utilities for TOON encoding and decoding. - -This module provides shared string processing functions used by both -the encoder and decoder, following the TOON specification Section 7.1 -for escape sequences and quoted string handling. -""" - -from .constants import ( - BACKSLASH, - CARRIAGE_RETURN, - DOUBLE_QUOTE, - NEWLINE, - TAB, -) - - -def escape_string(value: str) -> str: - """Escape special characters in a string for encoding. - - Handles backslashes, quotes, newlines, carriage returns, and tabs. - Per Section 7.1 of the TOON specification. - - Args: - value: The string to escape - - Returns: - The escaped string - - Examples: - >>> escape_string('hello\\nworld') - 'hello\\\\nworld' - >>> escape_string('say "hello"') - 'say \\\\"hello\\\\"' - """ - return ( - value.replace(BACKSLASH, BACKSLASH + BACKSLASH) - .replace(DOUBLE_QUOTE, BACKSLASH + DOUBLE_QUOTE) - .replace(NEWLINE, BACKSLASH + "n") - .replace(CARRIAGE_RETURN, BACKSLASH + "r") - .replace(TAB, BACKSLASH + "t") - ) - - -def unescape_string(value: str) -> str: - """Unescape a string by processing escape sequences. - - Handles `\\n`, `\\t`, `\\r`, `\\\\`, and `\\"` escape sequences. - Per Section 7.1 of the TOON specification. - - Args: - value: The string to unescape (without surrounding quotes) - - Returns: - The unescaped string - - Raises: - ValueError: If an invalid escape sequence is encountered - - Examples: - >>> unescape_string('hello\\\\nworld') - 'hello\\nworld' - >>> unescape_string('say \\\\"hello\\\\"') - 'say "hello"' - """ - result = "" - i = 0 - - while i < len(value): - if value[i] == BACKSLASH: - if i + 1 >= len(value): - raise ValueError("Invalid escape sequence: backslash at end of string") - - next_char = value[i + 1] - if next_char == "n": - result += NEWLINE - i += 2 - continue - if next_char == "t": - result += TAB - i += 2 - continue - if next_char == "r": - result += CARRIAGE_RETURN - i += 2 - continue - if next_char == BACKSLASH: - result += BACKSLASH - i += 2 - continue - if next_char == DOUBLE_QUOTE: - result += DOUBLE_QUOTE - i += 2 - continue - - raise ValueError(f"Invalid escape sequence: \\{next_char}") - - result += value[i] - i += 1 - - return result - - -def find_closing_quote(content: str, start: int) -> int: - """Find the index of the closing double quote, accounting for escape sequences. - - Args: - content: The string to search in - start: The index of the opening quote - - Returns: - The index of the closing quote, or -1 if not found - - Examples: - >>> find_closing_quote('"hello"', 0) - 6 - >>> find_closing_quote('"hello \\\\"world\\\\""', 0) - 17 - """ - i = start + 1 - while i < len(content): - if content[i] == BACKSLASH and i + 1 < len(content): - # Skip escaped character - i += 2 - continue - if content[i] == DOUBLE_QUOTE: - return i - i += 1 - return -1 # Not found - - -def find_unquoted_char(content: str, char: str, start: int = 0) -> int: - """Find the index of a specific character outside of quoted sections. - - Args: - content: The string to search in - char: The character to look for - start: Optional starting index (defaults to 0) - - Returns: - The index of the character, or -1 if not found outside quotes - - Examples: - >>> find_unquoted_char('key: "value: nested"', ':', 0) - 3 - >>> find_unquoted_char('"key: nested": value', ':', 0) - 13 - """ - in_quotes = False - i = start - - while i < len(content): - if content[i] == BACKSLASH and i + 1 < len(content) and in_quotes: - # Skip escaped character - i += 2 - continue - - if content[i] == DOUBLE_QUOTE: - in_quotes = not in_quotes - i += 1 - continue - - if content[i] == char and not in_quotes: - return i - - i += 1 - - return -1 diff --git a/src/toon_format/_text.py b/src/toon_format/_text.py new file mode 100644 index 0000000..f7c34d1 --- /dev/null +++ b/src/toon_format/_text.py @@ -0,0 +1,127 @@ +"""Lexical rules shared by the encoder and the decoder. + +Quoting (§7.2), key encoding (§7.3), escaping (§7.1), and the number grammars +of §2 and §4 live here so that both directions agree on them. +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +__all__ = [ + "COMMA", + "DELIMITERS", + "PIPE", + "TAB", + "encode_key", + "encode_string", + "format_decimal", + "format_float", + "is_number_token", + "quote", +] + +COMMA = "," +TAB = "\t" +PIPE = "|" +DELIMITERS = (COMMA, TAB, PIPE) + +# §7.3: keys and field names that may be written without quotes. ASCII only: +# Python's `\w` would also accept letters such as "é" (issue #65). +_UNQUOTED_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*") + +# §7.2: strings that look numeric must be quoted. This is wider than the +# decoder grammar on purpose: "05" and "+1" are quoted too. +_NUMERIC_LIKE = re.compile(r"[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?") + +# §4: the only unquoted tokens that decode as numbers. Leading zeros in the +# integer part ("05", "-007") are excluded, so those tokens stay strings. +_NUMBER = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?") + +# Characters that force quoting wherever they appear in a string (§7.2). +_STRUCTURAL = re.compile(r'[:"\\\[\]{}\x00-\x1f]') + +_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"} +_NEEDS_ESCAPE = re.compile(r'[\\"\x00-\x1f]') + +_CANONICAL_MIN = Decimal("1e-6") +_CANONICAL_MAX = Decimal("1e21") + + +def is_number_token(token: str) -> bool: + """Return whether an unquoted token decodes as a number (§4).""" + return _NUMBER.fullmatch(token) is not None + + +def _escape_char(match: re.Match[str]) -> str: + char = match.group() + return _ESCAPES.get(char) or f"\\u{ord(char):04x}" + + +def quote(value: str) -> str: + """Return ``value`` as a quoted token, escaped per §7.1.""" + return '"' + _NEEDS_ESCAPE.sub(_escape_char, value) + '"' + + +def _needs_quotes(value: str, delimiter: str) -> bool: + return ( + not value + or value[0] in " \t-#\ufeff" # a leading U+FEFF reads as a BOM (§12) + or value[-1] in " \t" + or value in ("true", "false", "null") + or delimiter in value + or _STRUCTURAL.search(value) is not None + or _NUMERIC_LIKE.fullmatch(value) is not None + ) + + +def encode_string(value: str, delimiter: str) -> str: + """Encode a string value, quoting it only when §7.2 requires it.""" + return quote(value) if _needs_quotes(value, delimiter) else value + + +def encode_key(key: str) -> str: + """Encode an object key, entry key, or field name (§7.3).""" + return key if _UNQUOTED_KEY.fullmatch(key) else quote(key) + + +def format_decimal(value: Decimal) -> str: + """Format a finite decimal in the canonical number form of §2. + + Magnitudes in ``[1e-6, 1e21)`` use plain decimal notation without + trailing zeros. Other magnitudes use JSON exponent notation with a + lowercase ``e`` and an explicit exponent sign. + """ + if not value: + return "0" + magnitude = abs(value) + if _CANONICAL_MIN <= magnitude < _CANONICAL_MAX: + text = f"{value:f}" + if "." in text: + text = text.rstrip("0").rstrip(".") + return text + # Decimal.normalize() would round to the context precision, so strip + # trailing zeros by hand to keep every significant digit. + sign, digits, exponent = value.as_tuple() + assert isinstance(exponent, int) + mantissa = "".join(map(str, digits)).rstrip("0") + exponent += len(digits) - 1 + if len(mantissa) > 1: + mantissa = f"{mantissa[0]}.{mantissa[1:]}" + exponent_sign = "+" if exponent >= 0 else "-" + return f"{'-' if sign else ''}{mantissa}e{exponent_sign}{abs(exponent)}" + + +def format_float(value: float) -> str: + """Format a finite float in the canonical number form of §2. + + ``repr`` yields the shortest digit string that round-trips, so the output + decodes back to the same float. Integral floats of 2**53 and beyond are + written with their exact digits instead: they decode as ``int``, and the + shortest digits would not equal the float (``float(2**60) == 2**60`` but + ``1152921504606847000 != 2**60``). + """ + if value.is_integer() and abs(value) < 1e21: + return str(int(value)) + return format_decimal(Decimal(repr(value))) diff --git a/src/toon_format/_validation.py b/src/toon_format/_validation.py deleted file mode 100644 index 6735ae1..0000000 --- a/src/toon_format/_validation.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Validation utilities for TOON encoding. - -This module provides validation functions to determine whether strings, -keys, and values can be safely encoded without quotes or need quoting -according to TOON specification rules. -""" - -import re - -from ._literal_utils import is_boolean_or_null_literal -from .constants import ( - COMMA, - LIST_ITEM_MARKER, - NUMERIC_REGEX, - OCTAL_REGEX, - VALID_KEY_REGEX, -) - - -def is_valid_unquoted_key(key: str) -> bool: - """Check if a key can be used without quotes. - - Valid unquoted keys must start with a letter or underscore, - followed by letters, digits, underscores, or dots. - Per Section 8.2 of the TOON specification. - - Args: - key: The key to validate - - Returns: - True if the key can be used without quotes - - Examples: - >>> is_valid_unquoted_key("name") - True - >>> is_valid_unquoted_key("user_id") - True - >>> is_valid_unquoted_key("config.value") - True - >>> is_valid_unquoted_key("123") # Starts with digit - False - >>> is_valid_unquoted_key("my-key") # Contains hyphen - False - """ - if not key: - return False - return bool(re.match(VALID_KEY_REGEX, key, re.IGNORECASE)) - - -def is_safe_unquoted(value: str, delimiter: str = COMMA) -> bool: - """Determine if a string value can be safely encoded without quotes. - - A string needs quoting if it: - - Is empty - - Has leading or trailing whitespace - - Could be confused with a literal (boolean, null, number) - - Contains structural characters (colons, brackets, braces) - - Contains quotes or backslashes (need escaping) - - Contains control characters (newlines, tabs, etc.) - - Contains the active delimiter - - Starts with a list marker (hyphen) - - Per Section 7.2 of the TOON specification. - - Args: - value: The string value to check - delimiter: The active delimiter (default: comma) - - Returns: - True if the string can be safely encoded without quotes - - Examples: - >>> is_safe_unquoted("hello") - True - >>> is_safe_unquoted("") # Empty - False - >>> is_safe_unquoted("true") # Reserved literal - False - >>> is_safe_unquoted("123") # Looks like number - False - >>> is_safe_unquoted("hello world") # Has whitespace (but not leading/trailing) - True - """ - if not value: - return False - - if value != value.strip(): - return False - - # Check if it looks like any literal value (boolean, null, or numeric) - if is_boolean_or_null_literal(value) or is_numeric_like(value): - return False - - # Check for colon (always structural) - if ":" in value: - return False - - # Check for quotes and backslash (always need escaping) - if '"' in value or "\\" in value: - return False - - # Check for brackets and braces (always structural) - if re.search(r"[\[\]{}]", value): - return False - - # Check for control characters (newline, carriage return, tab) - if re.search(r"[\n\r\t]", value): - return False - - # Check for the active delimiter - if delimiter in value: - return False - - # Check for hyphen at start (list marker) - if value.startswith(LIST_ITEM_MARKER): - return False - - return True - - -def is_numeric_like(value: str) -> bool: - """Check if a string looks like a number. - - Match numbers like `42`, `-3.14`, `1e-6`, `05`, etc. - Includes octal-like numbers (leading zero) which must be quoted. - - Args: - value: The string to check - - Returns: - True if the string looks like a number - - Examples: - >>> is_numeric_like("42") - True - >>> is_numeric_like("-3.14") - True - >>> is_numeric_like("1e-6") - True - >>> is_numeric_like("0123") # Octal-like - True - >>> is_numeric_like("hello") - False - """ - return bool( - re.match(NUMERIC_REGEX, value, re.IGNORECASE) - or re.match(OCTAL_REGEX, value) # Octal pattern - ) diff --git a/src/toon_format/cli.py b/src/toon_format/cli.py deleted file mode 100644 index f861f33..0000000 --- a/src/toon_format/cli.py +++ /dev/null @@ -1,217 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Command-line interface for TOON encoding/decoding. - -Provides the `toon` command-line tool for converting between JSON and TOON formats. -Supports auto-detection based on file extensions and content, with options for -delimiters, indentation, and validation modes. -""" - -import argparse -import json -import sys -from pathlib import Path - -from . import decode, encode -from .types import DecodeOptions, EncodeOptions - - -def main() -> int: - """Main CLI entry point.""" - parser = argparse.ArgumentParser( - prog="toon", - description="Convert between JSON and TOON formats", - ) - - parser.add_argument( - "input", - type=str, - help="Input file path (or - for stdin)", - ) - - parser.add_argument( - "-o", - "--output", - type=str, - help="Output file path (prints to stdout if omitted)", - ) - - parser.add_argument( - "-e", - "--encode", - action="store_true", - help="Force encode mode (overrides auto-detection)", - ) - - parser.add_argument( - "-d", - "--decode", - action="store_true", - help="Force decode mode (overrides auto-detection)", - ) - - parser.add_argument( - "--delimiter", - type=str, - choices=[",", "\t", "|"], - default=",", - help='Array delimiter: , (comma), \\t (tab), | (pipe) (default: ",")', - ) - - parser.add_argument( - "--indent", - type=int, - default=2, - help="Indentation size (default: 2)", - ) - - parser.add_argument( - "--length-marker", - action="store_true", - help="Add # prefix to array lengths (e.g., items[#3])", - ) - - parser.add_argument( - "--no-strict", - action="store_true", - help="Disable strict validation when decoding", - ) - - args = parser.parse_args() - - # Read input - try: - if args.input == "-": - input_text = sys.stdin.read() - input_path = None - else: - input_path = Path(args.input) - if not input_path.exists(): - print(f"Error: Input file not found: {args.input}", file=sys.stderr) - return 1 - input_text = input_path.read_text(encoding="utf-8") - except Exception as e: - print(f"Error reading input: {e}", file=sys.stderr) - return 1 - - # Determine operation mode - if args.encode and args.decode: - print("Error: Cannot specify both --encode and --decode", file=sys.stderr) - return 1 - - if args.encode: - mode = "encode" - elif args.decode: - mode = "decode" - else: - # Auto-detect based on file extension - if input_path: - if input_path.suffix.lower() == ".json": - mode = "encode" - elif input_path.suffix.lower() == ".toon": - mode = "decode" - else: - # Try to detect by content - try: - json.loads(input_text) - mode = "encode" - except json.JSONDecodeError: - mode = "decode" - else: - # No file path, try to detect by content - try: - json.loads(input_text) - mode = "encode" - except json.JSONDecodeError: - mode = "decode" - - # Process - try: - if mode == "encode": - output_text = encode_json_to_toon( - input_text, - delimiter=args.delimiter, - indent=args.indent, - length_marker=args.length_marker, - ) - else: - output_text = decode_toon_to_json( - input_text, - indent=args.indent, - strict=not args.no_strict, - ) - except Exception as e: - print(f"Error during {mode}: {e}", file=sys.stderr) - return 1 - - # Write output - try: - if args.output: - output_path = Path(args.output) - output_path.write_text(output_text, encoding="utf-8") - else: - print(output_text) - except Exception as e: - print(f"Error writing output: {e}", file=sys.stderr) - return 1 - - return 0 - - -def encode_json_to_toon( - json_text: str, - delimiter: str = ",", - indent: int = 2, - length_marker: bool = False, -) -> str: - """Encode JSON text to TOON format. - - Args: - json_text: JSON input string - delimiter: Delimiter character - indent: Indentation size - length_marker: Whether to add # prefix - - Returns: - TOON-formatted string - - Raises: - json.JSONDecodeError: If JSON is invalid - """ - data = json.loads(json_text) - - options: EncodeOptions = { - "indent": indent, - "delimiter": delimiter, - "lengthMarker": "#" if length_marker else False, - } - - return encode(data, options) - - -def decode_toon_to_json( - toon_text: str, - indent: int = 2, - strict: bool = True, -) -> str: - """Decode TOON text to JSON format. - - Args: - toon_text: TOON input string - indent: Indentation size - strict: Whether to use strict validation - - Returns: - JSON-formatted string - - Raises: - ToonDecodeError: If TOON is invalid - """ - options = DecodeOptions(indent=indent, strict=strict) - data = decode(toon_text, options) - - return json.dumps(data, indent=indent, ensure_ascii=False) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/toon_format/constants.py b/src/toon_format/constants.py deleted file mode 100644 index 49ceb79..0000000 --- a/src/toon_format/constants.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Constants for TOON format encoding and decoding. - -Defines all string literals, characters, and configuration values used throughout -the TOON implementation. Centralizes magic values for maintainability. -""" - -from typing import TYPE_CHECKING, Dict - -if TYPE_CHECKING: - from .types import Delimiter - -# region List markers -LIST_ITEM_MARKER = "-" -LIST_ITEM_PREFIX = "- " -# endregion - -# region Structural characters -COMMA: "Delimiter" = "," -COLON = ":" -SPACE = " " -PIPE: "Delimiter" = "|" -# endregion - -# region Brackets and braces -OPEN_BRACKET = "[" -CLOSE_BRACKET = "]" -OPEN_BRACE = "{" -CLOSE_BRACE = "}" -# endregion - -# region Literals -NULL_LITERAL = "null" -TRUE_LITERAL = "true" -FALSE_LITERAL = "false" -# endregion - -# region Escape characters -BACKSLASH = "\\" -DOUBLE_QUOTE = '"' -NEWLINE = "\n" -CARRIAGE_RETURN = "\r" -TAB: "Delimiter" = "\t" -# endregion - -# region Delimiters -DELIMITERS: Dict[str, "Delimiter"] = { - "comma": COMMA, - "tab": TAB, - "pipe": PIPE, -} - -DEFAULT_DELIMITER: "Delimiter" = DELIMITERS["comma"] -# endregion - -# region Regex patterns -# Pattern strings are compiled in modules that use them -STRUCTURAL_CHARS_REGEX = r"[\[\]{}]" -CONTROL_CHARS_REGEX = r"[\n\r\t]" -NUMERIC_REGEX = r"^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$" -OCTAL_REGEX = r"^0\d+$" -VALID_KEY_REGEX = r"^[A-Z_][\w.]*$" -HEADER_LENGTH_REGEX = r"^#?(\d+)([\|\t])?$" -INTEGER_REGEX = r"^-?\d+$" -# endregion - -# region Escape sequence maps -ESCAPE_SEQUENCES = { - BACKSLASH: "\\\\", - DOUBLE_QUOTE: '\\"', - NEWLINE: "\\n", - CARRIAGE_RETURN: "\\r", - TAB: "\\t", -} - -UNESCAPE_SEQUENCES = { - "n": NEWLINE, - "r": CARRIAGE_RETURN, - "t": TAB, - "\\": BACKSLASH, - '"': DOUBLE_QUOTE, -} -# endregion diff --git a/src/toon_format/decoder.py b/src/toon_format/decoder.py deleted file mode 100644 index 90f0849..0000000 --- a/src/toon_format/decoder.py +++ /dev/null @@ -1,788 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""TOON decoder implementation following v1.3 spec. - -This module provides the main `decode()` function and ToonDecodeError exception -for converting TOON format strings back to Python values. Supports strict and -lenient parsing modes, handles all TOON syntax forms (objects, arrays, primitives), -and validates array lengths and delimiters. -""" - -from typing import Any, Dict, List, Optional, Tuple - -from ._literal_utils import is_boolean_or_null_literal, is_numeric_literal -from ._parsing_utils import ( - find_first_unquoted, - find_unquoted_char, - parse_delimited_values, -) -from ._scanner import ParsedLine, to_parsed_lines -from ._string_utils import unescape_string as _unescape_string -from .constants import ( - CLOSE_BRACE, - CLOSE_BRACKET, - COLON, - COMMA, - DOUBLE_QUOTE, - FALSE_LITERAL, - LIST_ITEM_MARKER, - OPEN_BRACE, - OPEN_BRACKET, - PIPE, - TAB, - TRUE_LITERAL, -) -from .types import DecodeOptions, JsonValue - - -class ToonDecodeError(Exception): - """TOON decoding error.""" - - pass - - -def unescape_string(value: str) -> str: - """Unescape a quoted string. - - Args: - value: Escaped string (without surrounding quotes) - - Returns: - Unescaped string - - Raises: - ToonDecodeError: If escape sequence is invalid - """ - try: - return _unescape_string(value) - except ValueError as e: - raise ToonDecodeError(str(e)) from e - - -def parse_primitive(token: str) -> JsonValue: - """Parse a primitive token. - - Args: - token: Token string - - Returns: - Parsed value - - Raises: - ToonDecodeError: If quoted string is malformed - """ - token = token.strip() - - # Quoted string - if token.startswith(DOUBLE_QUOTE): - if not token.endswith(DOUBLE_QUOTE) or len(token) < 2: - raise ToonDecodeError("Unterminated string: missing closing quote") - return unescape_string(token[1:-1]) - - # Boolean and null literals - if is_boolean_or_null_literal(token): - if token == TRUE_LITERAL: - return True - if token == FALSE_LITERAL: - return False - return None # NULL_LITERAL - - # Try to parse as number using utility function - if token and is_numeric_literal(token): - try: - # Try int first - if "." not in token and "e" not in token.lower(): - return int(token) - # Then float - return float(token) - except ValueError: - pass - - # Otherwise it's an unquoted string (including octal-like "0123") - return token - - -def parse_header( - line: str, -) -> Optional[Tuple[Optional[str], int, str, Optional[List[str]]]]: - """Parse an array header. - - Args: - line: Line content - - Returns: - Tuple of (key, length, delimiter, fields) or None if not a header - - Raises: - ToonDecodeError: If header is malformed - """ - line = line.strip() - - # Find the bracket segment (respecting quoted strings) - bracket_start = find_unquoted_char(line, OPEN_BRACKET) - if bracket_start == -1: - return None - - # Extract key (if any) - key = None - if bracket_start > 0: - key_part = line[:bracket_start].strip() - key = parse_key(key_part) if key_part else None - - # Find closing bracket - bracket_end = find_unquoted_char(line, CLOSE_BRACKET, bracket_start) - if bracket_end == -1: - return None - - # Parse bracket content: [#?N] - bracket_content = line[bracket_start + 1 : bracket_end] - - # Remove optional # marker - if bracket_content.startswith("#"): - bracket_content = bracket_content[1:] - - # Determine delimiter from bracket content - delimiter = COMMA # default - length_str = bracket_content - - if bracket_content.endswith(TAB): - delimiter = TAB - length_str = bracket_content[:-1] - elif bracket_content.endswith(PIPE): - delimiter = PIPE - length_str = bracket_content[:-1] - elif bracket_content.endswith(COMMA): - # Explicit comma delimiter (for tabular arrays) - delimiter = COMMA - length_str = bracket_content[:-1] - - # Parse length - try: - length = int(length_str) - except ValueError: - return None - - # Check for fields segment - fields = None - after_bracket = line[bracket_end + 1 :].strip() - - if after_bracket.startswith(OPEN_BRACE): - brace_end = find_unquoted_char(after_bracket, CLOSE_BRACE) - if brace_end == -1: - raise ToonDecodeError("Unterminated fields segment") - - fields_content = after_bracket[1:brace_end] - # Parse fields using the delimiter - field_tokens = parse_delimited_values(fields_content, delimiter) - fields = [parse_key(f.strip()) for f in field_tokens] - - after_bracket = after_bracket[brace_end + 1 :].strip() - - # Must end with colon - if not after_bracket.startswith(COLON): - return None - - return (key, length, delimiter, fields) - - -def parse_key(key_str: str) -> str: - """Parse a key (quoted or unquoted). - - Args: - key_str: Key string - - Returns: - Parsed key - - Raises: - ToonDecodeError: If quoted key is malformed - """ - key_str = key_str.strip() - - if key_str.startswith(DOUBLE_QUOTE): - if not key_str.endswith(DOUBLE_QUOTE) or len(key_str) < 2: - raise ToonDecodeError("Unterminated quoted key") - return unescape_string(key_str[1:-1]) - - return key_str - - -def split_key_value(line: str) -> Tuple[str, str]: - """Split a line into key and value at first unquoted colon. - - Args: - line: Line content - - Returns: - Tuple of (key, value) - - Raises: - ToonDecodeError: If no colon found - """ - colon_idx = find_unquoted_char(line, COLON) - if colon_idx == -1: - raise ToonDecodeError("Missing colon after key") - - key = line[:colon_idx].strip() - value = line[colon_idx + 1 :].strip() - return (key, value) - - -def decode(input_str: str, options: Optional[DecodeOptions] = None) -> JsonValue: - """Decode a TOON-formatted string to a Python value. - - Args: - input_str: TOON-formatted string - options: Optional decoding options - - Returns: - Decoded Python value - - Raises: - ToonDecodeError: If input is malformed - """ - if options is None: - options = DecodeOptions() - - indent_size = options.indent - strict = options.strict - - # Parse lines using scanner module - try: - parsed_lines, blank_lines_info = to_parsed_lines(input_str, indent_size, strict) - except SyntaxError as e: - # Convert scanner's SyntaxError to ToonDecodeError - raise ToonDecodeError(str(e)) from e - - # Convert ParsedLine to have stripped content (decoder expects stripped) - # Note: ParsedLine.content keeps whitespace after indent removal, but decoder needs stripped - lines: List[ParsedLine] = [ - ParsedLine( - raw=line.raw, - depth=line.depth, - indent=line.indent, - content=line.content.strip(), - line_num=line.line_num, - ) - for line in parsed_lines - ] - - # Remove blank lines outside arrays (Section 12) - # For simplicity, we'll handle this during parsing - - # Check for empty input (per spec Section 8: empty/whitespace-only → empty object) - non_blank_lines = [ln for ln in lines if not ln.is_blank] - if not non_blank_lines: - return {} - - # Determine root form (Section 5) - first_line = non_blank_lines[0] - - # Check if it's a root array header - header_info = parse_header(first_line.content) - if header_info is not None and header_info[0] is None: # No key = root array - # Root array - return decode_array(lines, 0, 0, header_info, strict) - - # Check if it's a single primitive - if len(non_blank_lines) == 1: - line_content = first_line.content - # Check if it's not a key-value line - try: - split_key_value(line_content) - # It's a key-value, so root object - except ToonDecodeError: - # Not a key-value, check if it's a header - if header_info is None: - # Single primitive - return parse_primitive(line_content) - - # Otherwise, root object - return decode_object(lines, 0, 0, strict) - - -def decode_object( - lines: List[ParsedLine], start_idx: int, parent_depth: int, strict: bool -) -> Dict[str, Any]: - """Decode an object starting at given line index. - - Args: - lines: List of lines - start_idx: Starting line index - parent_depth: Parent indentation depth - strict: Strict mode flag - - Returns: - Decoded object - """ - result: Dict[str, Any] = {} - i = start_idx - expected_depth = parent_depth if start_idx == 0 else parent_depth + 1 - - while i < len(lines): - line = lines[i] - - # Skip blank lines outside arrays (allowed) - if line.is_blank: - i += 1 - continue - - # Stop if we've dedented below expected depth - if line.depth < expected_depth: - break - - # Skip lines that are too deeply indented (they belong to nested structures) - if line.depth > expected_depth: - i += 1 - continue - - content = line.content - - # Check for array header - header_info = parse_header(content) - if header_info is not None: - key, length, delimiter, fields = header_info - if key is not None: - # Array field - array_val, next_i = decode_array_from_header( - lines, i, line.depth, header_info, strict - ) - result[key] = array_val - i = next_i - continue - - # Must be a key-value line - try: - key_str, value_str = split_key_value(content) - except ToonDecodeError: - # Invalid line, skip in non-strict mode - if strict: - raise - i += 1 - continue - - key = parse_key(key_str) - - # Check if value is empty (nested object) - if not value_str: - # Nested object - result[key] = decode_object(lines, i + 1, line.depth, strict) - # Skip past nested object - i += 1 - while i < len(lines) and lines[i].depth > line.depth: - i += 1 - else: - # Primitive value - result[key] = parse_primitive(value_str) - i += 1 - - return result - - -def decode_array_from_header( - lines: List[ParsedLine], - header_idx: int, - header_depth: int, - header_info: Tuple[Optional[str], int, str, Optional[List[str]]], - strict: bool, -) -> Tuple[List[Any], int]: - """Decode array starting from a header line. - - Args: - lines: List of lines - header_idx: Index of header line - header_depth: Depth of header line - header_info: Parsed header info - strict: Strict mode flag - - Returns: - Tuple of (decoded array, next line index) - """ - key, length, delimiter, fields = header_info - header_line = lines[header_idx].content - - # Check if there's inline content after the colon - # Use split_key_value to find the colon position (respects quoted strings) - try: - _, inline_content = split_key_value(header_line) - except ToonDecodeError: - # No colon found (shouldn't happen with valid headers) - inline_content = "" - - # Inline primitive array (can be empty if length is 0) - if inline_content or (not fields and length == 0): - # Inline primitive array (handles empty arrays like [0]:) - return ( - decode_inline_array(inline_content, delimiter, length, strict), - header_idx + 1, - ) - - # Non-inline array - if fields is not None: - # Tabular array - return decode_tabular_array( - lines, header_idx + 1, header_depth, fields, delimiter, length, strict - ) - else: - # List format (mixed/non-uniform) - return decode_list_array(lines, header_idx + 1, header_depth, delimiter, length, strict) - - -def decode_array( - lines: List[ParsedLine], - start_idx: int, - parent_depth: int, - header_info: Tuple[Optional[str], int, str, Optional[List[str]]], - strict: bool, -) -> List[Any]: - """Decode array (convenience wrapper). - - Args: - lines: List of lines - start_idx: Starting line index - parent_depth: Parent depth - header_info: Header info - strict: Strict mode - - Returns: - Decoded array - """ - arr, _ = decode_array_from_header(lines, start_idx, parent_depth, header_info, strict) - return arr - - -def decode_inline_array( - content: str, delimiter: str, expected_length: int, strict: bool -) -> List[Any]: - """Decode an inline primitive array. - - Args: - content: Inline content after colon - delimiter: Active delimiter - expected_length: Expected array length - strict: Strict mode flag - - Returns: - Decoded array - - Raises: - ToonDecodeError: If length mismatch in strict mode - """ - if not content and expected_length == 0: - return [] - - tokens = parse_delimited_values(content, delimiter) - values = [parse_primitive(token) for token in tokens] - - if strict and len(values) != expected_length: - raise ToonDecodeError(f"Expected {expected_length} values, but got {len(values)}") - - return values - - -def decode_tabular_array( - lines: List[ParsedLine], - start_idx: int, - header_depth: int, - fields: List[str], - delimiter: str, - expected_length: int, - strict: bool, -) -> Tuple[List[Dict[str, Any]], int]: - """Decode a tabular array. - - Args: - lines: List of lines - start_idx: Starting line index (after header) - header_depth: Depth of header - fields: Field names - delimiter: Active delimiter - expected_length: Expected number of rows - strict: Strict mode flag - - Returns: - Tuple of (decoded array, next line index) - - Raises: - ToonDecodeError: If row width or count mismatch in strict mode - """ - result = [] - i = start_idx - row_depth = header_depth + 1 - - while i < len(lines): - line = lines[i] - - # Handle blank lines - if line.is_blank: - if strict: - # In strict mode: blank lines at or above row depth are errors - # Blank lines dedented below row depth mean array has ended - if line.depth >= row_depth: - raise ToonDecodeError("Blank lines not allowed inside arrays") - else: - break - else: - # In non-strict mode: ignore all blank lines and continue - i += 1 - continue - - # Stop if dedented or different depth - if line.depth < row_depth: - break - if line.depth > row_depth: - # End of tabular rows (might be next key-value) - break - - content = line.content - - # Disambiguation: check if this is a row or a key-value line - # A row has no unquoted colon, or delimiter before colon - if is_row_line(content, delimiter): - # Parse as row - tokens = parse_delimited_values(content, delimiter) - values = [parse_primitive(token) for token in tokens] - - if strict and len(values) != len(fields): - raise ToonDecodeError( - f"Expected {len(fields)} values in row, but got {len(values)}" - ) - - obj = {fields[j]: values[j] for j in range(min(len(fields), len(values)))} - result.append(obj) - i += 1 - else: - # Not a row, end of tabular data - break - - if strict and len(result) != expected_length: - raise ToonDecodeError(f"Expected {expected_length} rows, but got {len(result)}") - - return result, i - - -def is_row_line(line: str, delimiter: str) -> bool: - """Check if a line is a tabular row (not a key-value line). - - A line is a tabular row if: - - It has no unquoted colon, OR - - The first unquoted delimiter appears before the first unquoted colon - - Args: - line: Line content - delimiter: Active delimiter - - Returns: - True if it's a row line - """ - # Find first occurrence of delimiter or colon (single pass optimization) - pos, char = find_first_unquoted(line, [delimiter, COLON]) - - # No special chars found -> row - if pos == -1: - return True - - # First special char is delimiter -> row - # First special char is colon -> key-value - return char == delimiter - - -def decode_list_array( - lines: List[ParsedLine], - start_idx: int, - header_depth: int, - delimiter: str, - expected_length: int, - strict: bool, -) -> Tuple[List[Any], int]: - """Decode a list-format array (mixed/non-uniform). - - Args: - lines: List of lines - start_idx: Starting line index - header_depth: Header depth - delimiter: Active delimiter - expected_length: Expected number of items - strict: Strict mode flag - - Returns: - Tuple of (decoded array, next line index) - - Raises: - ToonDecodeError: If item count mismatch in strict mode - """ - result: List[Any] = [] - i = start_idx - item_depth = header_depth + 1 - - while i < len(lines): - line = lines[i] - - # Handle blank lines - if line.is_blank: - if strict: - # In strict mode: blank lines at or above item depth are errors - # Blank lines dedented below item depth mean array has ended - if line.depth >= item_depth: - raise ToonDecodeError("Blank lines not allowed inside arrays") - else: - break - else: - # In non-strict mode: ignore all blank lines and continue - i += 1 - continue - - # Stop if dedented - if line.depth < item_depth: - break - - # Must start with "- " - content = line.content - if not content.startswith(LIST_ITEM_MARKER): - # Not a list item, end of array - break - - # Remove "- " prefix - item_content = content[len(LIST_ITEM_MARKER) :].strip() - - # Check what kind of item this is - item_header = parse_header(item_content) - if item_header is not None: - # It's an array header: - [N]: ... or - key[N]: ... - key, length, item_delim, fields = item_header - - if key is None: - # - [N]: inline array - colon_idx = item_content.find(COLON) - if colon_idx != -1: - inline_part = item_content[colon_idx + 1 :].strip() - # Inline primitive array (handles empty arrays like [0]:) - if inline_part or length == 0: - item_val = decode_inline_array(inline_part, item_delim, length, strict) - result.append(item_val) - i += 1 - continue - else: - # - key[N]: array field in object - # This is an object with an array as its first field - item_obj: Dict[str, Any] = {} - array_val, next_i = decode_array_from_header( - lines, i, line.depth, item_header, strict - ) - item_obj[key] = array_val - - # Continue reading remaining fields at depth +1 - i = next_i - while i < len(lines) and lines[i].depth == line.depth + 1: - field_line = lines[i] - if field_line.is_blank: - i += 1 - continue - - field_content = field_line.content - - # Check for array header - field_header = parse_header(field_content) - if field_header is not None and field_header[0] is not None: - field_key, field_length, field_delim, field_fields = field_header - assert field_key is not None # Already checked above - field_val, next_i = decode_array_from_header( - lines, i, field_line.depth, field_header, strict - ) - item_obj[field_key] = field_val - i = next_i - continue - - try: - field_key_str, field_value_str = split_key_value(field_content) - field_key = parse_key(field_key_str) - - if not field_value_str: - # Nested object - item_obj[field_key] = decode_object( - lines, i + 1, field_line.depth, strict - ) - i += 1 - while i < len(lines) and lines[i].depth > field_line.depth: - i += 1 - else: - item_obj[field_key] = parse_primitive(field_value_str) - i += 1 - except ToonDecodeError: - break - - result.append(item_obj) - continue - - # Check if it's an object (has colon) - try: - key_str, value_str = split_key_value(item_content) - # It's an object item - obj_item: Dict[str, Any] = {} - - # First field - key = parse_key(key_str) - if not value_str: - # First field is nested object: fields at depth +2 - nested = decode_object(lines, i + 1, line.depth + 1, strict) - obj_item[key] = nested - # Skip nested content - i += 1 - while i < len(lines) and lines[i].depth > line.depth + 1: - i += 1 - else: - # First field is primitive - obj_item[key] = parse_primitive(value_str) - i += 1 - - # Remaining fields at depth +1 - while i < len(lines) and lines[i].depth == line.depth + 1: - field_line = lines[i] - if field_line.is_blank: - i += 1 - continue - - field_content = field_line.content - - # Check for array header - field_header = parse_header(field_content) - if field_header is not None and field_header[0] is not None: - field_key, field_length, field_delim, field_fields = field_header - assert field_key is not None # Already checked above - field_val, next_i = decode_array_from_header( - lines, i, field_line.depth, field_header, strict - ) - obj_item[field_key] = field_val - i = next_i - continue - - try: - field_key_str, field_value_str = split_key_value(field_content) - field_key = parse_key(field_key_str) - - if not field_value_str: - # Nested object - obj_item[field_key] = decode_object(lines, i + 1, field_line.depth, strict) - i += 1 - while i < len(lines) and lines[i].depth > field_line.depth: - i += 1 - else: - obj_item[field_key] = parse_primitive(field_value_str) - i += 1 - except ToonDecodeError: - break - - result.append(obj_item) - except ToonDecodeError: - # Not an object, must be a primitive - # Special case: empty content after "- " is an empty object - if not item_content: - result.append({}) - else: - result.append(parse_primitive(item_content)) - i += 1 - - if strict and len(result) != expected_length: - raise ToonDecodeError(f"Expected {expected_length} items, but got {len(result)}") - - return result, i diff --git a/src/toon_format/encoder.py b/src/toon_format/encoder.py deleted file mode 100644 index 665dc70..0000000 --- a/src/toon_format/encoder.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Core TOON encoding functionality. - -This module provides the main `encode()` function for converting Python values -to TOON format strings. Handles option resolution and coordinates the encoding -pipeline: normalization → encoding → writing. -""" - -from typing import Any, Optional - -from .constants import DEFAULT_DELIMITER, DELIMITERS -from .encoders import encode_value -from .normalize import normalize_value -from .types import EncodeOptions, ResolvedEncodeOptions -from .writer import LineWriter - - -def encode(value: Any, options: Optional[EncodeOptions] = None) -> str: - """Encode a value into TOON format. - - Args: - value: The value to encode (must be JSON-serializable) - options: Optional encoding options - - Returns: - TOON-formatted string - """ - normalized = normalize_value(value) - resolved_options = resolve_options(options) - writer = LineWriter(resolved_options.indent) - encode_value(normalized, resolved_options, writer, 0) - return writer.to_string() - - -def resolve_options(options: Optional[EncodeOptions]) -> ResolvedEncodeOptions: - """Resolve encoding options with defaults. - - Args: - options: Optional user-provided options - - Returns: - Resolved options with defaults applied - """ - if options is None: - return ResolvedEncodeOptions() - - indent = options.get("indent", 2) - delimiter = options.get("delimiter", DEFAULT_DELIMITER) - length_marker = options.get("lengthMarker", False) - - # Resolve delimiter if it's a key - if delimiter in DELIMITERS: - delimiter = DELIMITERS[delimiter] - - return ResolvedEncodeOptions(indent=indent, delimiter=delimiter, length_marker=length_marker) diff --git a/src/toon_format/encoders.py b/src/toon_format/encoders.py deleted file mode 100644 index 5d1022e..0000000 --- a/src/toon_format/encoders.py +++ /dev/null @@ -1,456 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Type-specific encoders for TOON format. - -Provides encoding functions for different value types: objects, arrays (primitive, -tabular, and list formats), and primitives. Includes format detection logic to -determine the most efficient TOON representation for arrays. -""" - -from typing import List, Optional, cast - -from .constants import LIST_ITEM_PREFIX -from .normalize import ( - is_array_of_arrays, - is_array_of_objects, - is_array_of_primitives, - is_json_array, - is_json_object, - is_json_primitive, -) -from .primitives import encode_key, encode_primitive, format_header, join_encoded_values -from .types import ( - Depth, - JsonArray, - JsonObject, - JsonPrimitive, - JsonValue, - ResolvedEncodeOptions, -) -from .writer import LineWriter - - -def encode_value( - value: JsonValue, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth = 0, -) -> None: - """Encode a value to TOON format. - - Args: - value: Normalized JSON value - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - """ - if is_json_primitive(value): - writer.push(depth, encode_primitive(cast(JsonPrimitive, value), options.delimiter)) - elif is_json_array(value): - encode_array(cast(JsonArray, value), options, writer, depth, None) - elif is_json_object(value): - encode_object(cast(JsonObject, value), options, writer, depth, None) - - -def encode_object( - obj: JsonObject, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, - key: Optional[str], -) -> None: - """Encode an object to TOON format. - - Args: - obj: Dictionary object - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - key: Optional key name - """ - if key: - writer.push(depth, f"{encode_key(key)}:") - - for obj_key, obj_value in obj.items(): - encode_key_value_pair(obj_key, obj_value, options, writer, depth if not key else depth + 1) - - -def encode_key_value_pair( - key: str, - value: JsonValue, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, -) -> None: - """Encode a key-value pair. - - Args: - key: Key name - value: Value to encode - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - """ - if is_json_primitive(value): - primitive_str = encode_primitive(cast(JsonPrimitive, value), options.delimiter) - writer.push(depth, f"{encode_key(key)}: {primitive_str}") - elif is_json_array(value): - encode_array(cast(JsonArray, value), options, writer, depth, key) - elif is_json_object(value): - encode_object(cast(JsonObject, value), options, writer, depth, key) - - -def encode_array( - arr: JsonArray, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, - key: Optional[str], -) -> None: - """Encode an array to TOON format. - - Args: - arr: List array - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - key: Optional key name - """ - # Handle empty array - if not arr: - header = format_header(key, 0, None, options.delimiter, options.lengthMarker) - writer.push(depth, header) - return - - # Check array type and encode accordingly - if is_array_of_primitives(arr): - encode_inline_primitive_array(arr, options, writer, depth, key) - elif is_array_of_arrays(arr): - encode_array_of_arrays(arr, options, writer, depth, key) - elif is_array_of_objects(arr): - tabular_header = detect_tabular_header(arr, options.delimiter) - if tabular_header: - encode_array_of_objects_as_tabular(arr, tabular_header, options, writer, depth, key) - else: - encode_mixed_array_as_list_items(arr, options, writer, depth, key) - else: - encode_mixed_array_as_list_items(arr, options, writer, depth, key) - - -def encode_array_content( - arr: JsonArray, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, -) -> None: - """Encode array content without header (header already written). - - Args: - arr: Array to encode - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth for array items - """ - # Handle empty array - if not arr: - return - - # Check array type and encode accordingly - if is_array_of_primitives(arr): - # Inline primitive array - write values on same line as header - # But header was already written, so we need to append to last line - # Actually, we can't modify the last line, so this won't work for inline arrays - # For now, encode inline arrays separately - encoded_values = [encode_primitive(item, options.delimiter) for item in arr] - joined = join_encoded_values(encoded_values, options.delimiter) - # Get the last line and append to it - # This is tricky - we need to modify the writer to support this - # For now, let's just write at current depth - # Actually, looking at the expected output, inline arrays should have their content - # on the same line as the header. But we already wrote the header. - # The solution is to NOT use this function for inline primitive arrays - # Instead, we should write them completely inline - pass # Handled differently - elif is_array_of_arrays(arr): - for item in arr: - if is_array_of_primitives(item): - encoded_values = [encode_primitive(v, options.delimiter) for v in item] - joined = join_encoded_values(encoded_values, options.delimiter) - item_header = format_header( - None, len(item), None, options.delimiter, options.lengthMarker - ) - line = f"{LIST_ITEM_PREFIX}{item_header}" - if joined: - line += f" {joined}" - writer.push(depth, line) - else: - encode_array(item, options, writer, depth, None) - elif is_array_of_objects(arr): - tabular_header = detect_tabular_header(arr, options.delimiter) - if tabular_header: - # Tabular format - for obj in arr: - row_values = [ - encode_primitive(obj[field], options.delimiter) for field in tabular_header - ] - row = join_encoded_values(row_values, options.delimiter) - writer.push(depth, row) - else: - # List format - for item in arr: - encode_object_as_list_item(item, options, writer, depth) - else: - # Mixed array - for item in arr: - if is_json_primitive(item): - writer.push( - depth, - f"{LIST_ITEM_PREFIX}{encode_primitive(item, options.delimiter)}", - ) - elif is_json_object(item): - encode_object_as_list_item(item, options, writer, depth) - elif is_json_array(item): - encode_array(item, options, writer, depth, None) - - -def encode_inline_primitive_array( - arr: JsonArray, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, - key: Optional[str], -) -> None: - """Encode an array of primitives inline. - - Args: - arr: Array of primitives - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - key: Optional key name - """ - encoded_values = [encode_primitive(item, options.delimiter) for item in arr] - joined = join_encoded_values(encoded_values, options.delimiter) - header = format_header(key, len(arr), None, options.delimiter, options.lengthMarker) - writer.push(depth, f"{header} {joined}") - - -def encode_array_of_arrays( - arr: JsonArray, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, - key: Optional[str], -) -> None: - """Encode an array of arrays. - - Args: - arr: Array of arrays - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - key: Optional key name - """ - header = format_header(key, len(arr), None, options.delimiter, options.lengthMarker) - writer.push(depth, header) - - for item in arr: - if is_array_of_primitives(item): - encoded_values = [encode_primitive(v, options.delimiter) for v in item] - joined = join_encoded_values(encoded_values, options.delimiter) - # Use format_header for correct delimiter handling - item_header = format_header( - None, len(item), None, options.delimiter, options.lengthMarker - ) - # Only add space and content if array is not empty - line = f"{LIST_ITEM_PREFIX}{item_header}" - if joined: - line += f" {joined}" - writer.push(depth + 1, line) - else: - encode_array(item, options, writer, depth + 1, None) - - -def detect_tabular_header(arr: List[JsonObject], delimiter: str) -> Optional[List[str]]: - """Detect if array can use tabular format and return header keys. - - Args: - arr: Array of objects - delimiter: Delimiter character - - Returns: - List of keys if tabular, None otherwise - """ - if not arr: - return None - - # Get keys from first object - first_keys = list(arr[0].keys()) - first_keys_set = set(first_keys) - - # Check all objects have same keys (regardless of order) and all values are primitives - for obj in arr: - if set(obj.keys()) != first_keys_set: - return None - if not all(is_json_primitive(value) for value in obj.values()): - return None - - return first_keys - - -def is_tabular_array(arr: List[JsonObject], delimiter: str) -> bool: - """Check if array qualifies for tabular format. - - Args: - arr: Array to check - delimiter: Delimiter character - - Returns: - True if tabular format can be used - """ - return detect_tabular_header(arr, delimiter) is not None - - -def encode_array_of_objects_as_tabular( - arr: List[JsonObject], - fields: List[str], - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, - key: Optional[str], -) -> None: - """Encode array of uniform objects in tabular format. - - Args: - arr: Array of uniform objects - fields: Field names for header - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - key: Optional key name - """ - header = format_header(key, len(arr), fields, options.delimiter, options.lengthMarker) - writer.push(depth, header) - - for obj in arr: - row_values = [encode_primitive(obj[field], options.delimiter) for field in fields] - row = join_encoded_values(row_values, options.delimiter) - writer.push(depth + 1, row) - - -def encode_mixed_array_as_list_items( - arr: JsonArray, - options: ResolvedEncodeOptions, - writer: LineWriter, - depth: Depth, - key: Optional[str], -) -> None: - """Encode mixed array as list items. - - Args: - arr: Mixed array - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - key: Optional key name - """ - header = format_header(key, len(arr), None, options.delimiter, options.lengthMarker) - writer.push(depth, header) - - for item in arr: - if is_json_primitive(item): - writer.push( - depth + 1, - f"{LIST_ITEM_PREFIX}{encode_primitive(item, options.delimiter)}", - ) - elif is_json_object(item): - encode_object_as_list_item(item, options, writer, depth + 1) - elif is_json_array(item): - # Arrays as list items need the "- " prefix with their header - item_arr = cast(JsonArray, item) - if is_array_of_primitives(item_arr): - # Inline primitive array: "- [N]: values" - encoded_values = [encode_primitive(v, options.delimiter) for v in item_arr] - joined = join_encoded_values(encoded_values, options.delimiter) - header = format_header( - None, len(item_arr), None, options.delimiter, options.lengthMarker - ) - line = f"{LIST_ITEM_PREFIX}{header}" - if joined: - line += f" {joined}" - writer.push(depth + 1, line) - else: - # Non-inline array: "- [N]:" header, then content at depth + 2 - tabular_fields = None - if is_array_of_objects(item_arr): - tabular_fields = detect_tabular_header(item_arr, options.delimiter) - header = format_header( - None, - len(item_arr), - tabular_fields, - options.delimiter, - options.lengthMarker, - ) - writer.push(depth + 1, f"{LIST_ITEM_PREFIX}{header}") - encode_array_content(item_arr, options, writer, depth + 2) - - -def encode_object_as_list_item( - obj: JsonObject, options: ResolvedEncodeOptions, writer: LineWriter, depth: Depth -) -> None: - """Encode object as a list item. - - Args: - obj: Object to encode - options: Resolved encoding options - writer: Line writer for output - depth: Current indentation depth - """ - # Get all keys - keys = list(obj.items()) - if not keys: - writer.push(depth, LIST_ITEM_PREFIX.rstrip()) - return - - # First key-value pair goes on same line as the "-" - first_key, first_value = keys[0] - if is_json_primitive(first_value): - encoded_val = encode_primitive(first_value, options.delimiter) - writer.push(depth, f"{LIST_ITEM_PREFIX}{encode_key(first_key)}: {encoded_val}") - elif is_json_array(first_value): - # Arrays go on the same line as "-" with their header - first_arr = cast(JsonArray, first_value) - if is_array_of_primitives(first_arr): - # Inline primitive array: write header and content on same line - encoded_values = [encode_primitive(item, options.delimiter) for item in first_arr] - joined = join_encoded_values(encoded_values, options.delimiter) - header = format_header( - first_key, len(first_arr), None, options.delimiter, options.lengthMarker - ) - line = f"{LIST_ITEM_PREFIX}{header}" - if joined: - line += f" {joined}" - writer.push(depth, line) - else: - # Non-inline array: write header on hyphen line, content below - tabular_fields = None - if is_array_of_objects(first_arr): - tabular_fields = detect_tabular_header(first_arr, options.delimiter) - header = format_header( - first_key, - len(first_arr), - tabular_fields, - options.delimiter, - options.lengthMarker, - ) - writer.push(depth, f"{LIST_ITEM_PREFIX}{header}") - # Now encode the array content at depth + 1 - encode_array_content(first_arr, options, writer, depth + 1) - else: - # If first value is an object, put "-" alone then encode normally - writer.push(depth, LIST_ITEM_PREFIX.rstrip()) - encode_key_value_pair(first_key, first_value, options, writer, depth + 1) - - # Rest of the keys go normally indented - for key, value in keys[1:]: - encode_key_value_pair(key, value, options, writer, depth + 1) diff --git a/src/toon_format/logging_config.py b/src/toon_format/logging_config.py deleted file mode 100644 index af8ae87..0000000 --- a/src/toon_format/logging_config.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Centralized logging configuration for toon_format. - -This module provides consistent logging infrastructure across all toon_format -modules with support for the TOON_FORMAT_DEBUG environment variable for -enabling debug-level logging. -""" - -import logging -import os -from functools import lru_cache -from typing import Optional - -# Constants -TOON_FORMAT_DEBUG_ENV_VAR = "TOON_FORMAT_DEBUG" -DEFAULT_LOG_LEVEL = logging.WARNING -DEBUG_LOG_LEVEL = logging.DEBUG - - -@lru_cache(maxsize=1) -def is_debug_enabled() -> bool: - """Check if TOON_FORMAT_DEBUG environment variable is set to truthy value. - - Accepts: "1", "true", "True", "TRUE", "yes", "Yes", "YES" - - Returns: - bool: True if debug mode is enabled, False otherwise. - - Note: - Result is cached for performance. - """ - value = os.environ.get(TOON_FORMAT_DEBUG_ENV_VAR, "").lower() - return value in ("1", "true", "yes") - - -def get_logger(name: str) -> logging.Logger: - """Create or retrieve logger for given module name. - - Configures logger with appropriate level based on environment variable - and adds a StreamHandler with consistent formatting. - - Args: - name: Module name (typically __name__). - - Returns: - logging.Logger: Configured logger instance. - - Examples: - >>> logger = get_logger(__name__) - >>> logger.debug("Debug message") # Only shown if TOON_FORMAT_DEBUG=1 - """ - logger = logging.getLogger(name) - - # Set log level based on debug mode - level = DEBUG_LOG_LEVEL if is_debug_enabled() else DEFAULT_LOG_LEVEL - logger.setLevel(level) - - # Add StreamHandler if not already present - if not logger.handlers: - handler = logging.StreamHandler() - handler.setLevel(level) - formatter = logging.Formatter("[%(name)s] %(levelname)s: %(message)s") - handler.setFormatter(formatter) - logger.addHandler(handler) - - return logger - - -def configure_logging(level: Optional[int] = None) -> None: - """Configure log level programmatically for all toon_format loggers. - - Useful for testing and programmatic control of logging. - - Args: - level: Log level (e.g., logging.DEBUG, logging.INFO). - If None, uses environment variable or default. - - Examples: - >>> configure_logging(logging.DEBUG) # Enable debug logging - >>> configure_logging(logging.WARNING) # Reset to default - """ - if level is None: - level = DEBUG_LOG_LEVEL if is_debug_enabled() else DEFAULT_LOG_LEVEL - - # Update all existing toon_format loggers - for name in list(logging.Logger.manager.loggerDict.keys()): - if name.startswith("toon_format"): - logger = logging.getLogger(name) - logger.setLevel(level) - for handler in logger.handlers: - handler.setLevel(level) diff --git a/src/toon_format/normalize.py b/src/toon_format/normalize.py deleted file mode 100644 index dda9bfd..0000000 --- a/src/toon_format/normalize.py +++ /dev/null @@ -1,250 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Value normalization for TOON encoding. - -Converts Python-specific types to JSON-compatible values before encoding: -- datetime/date → ISO 8601 strings -- Decimal → float -- tuple/set/frozenset → sorted lists -- pathlib.Path → string representation -- Infinity/NaN → null -- Functions/callables → null -- Negative zero → zero -""" - -import math -import sys -from collections.abc import Mapping -from datetime import date, datetime -from decimal import Decimal -from pathlib import PurePath -from typing import Any - -# TypeGuard was added in Python 3.10, use typing_extensions for older versions -if sys.version_info >= (3, 10): - from typing import TypeGuard -else: - from typing_extensions import TypeGuard - -from .logging_config import get_logger -from .types import JsonArray, JsonObject, JsonPrimitive, JsonValue - -# Module logger -logger = get_logger(__name__) - -_MAX_SAFE_INTEGER = 2**53 - 1 - - -def normalize_value(value: Any) -> JsonValue: - """Normalize Python value to JSON-compatible type. - - Converts Python-specific types to JSON-compatible equivalents: - - datetime objects → ISO 8601 strings - - sets → sorted lists - - pathlib.Path → string representation - - Large integers (>2^53-1) → strings (for JS compatibility) - - Non-finite floats (inf, -inf, NaN) → null - - Negative zero → positive zero - - Mapping types → dicts with string keys - - Unsupported types → null - - Args: - value: Python value to normalize. - - Returns: - JsonValue: Normalized value (None, bool, int, float, str, list, or dict). - - Examples: - >>> normalize_value(datetime(2024, 1, 1)) - '2024-01-01T00:00:00' - - >>> normalize_value({1, 2, 3}) - [1, 2, 3] - - >>> normalize_value(float('inf')) - None - - >>> normalize_value(2**60) # Large integer - '1152921504606846976' - - >>> from pathlib import Path - >>> normalize_value(Path('/tmp/file.txt')) - '/tmp/file.txt' - - Note: - - Recursive: normalizes nested structures - - Sets are sorted for deterministic output - - Heterogeneous sets sorted by repr() if natural sorting fails - - Path objects are converted to their string representation - """ - if value is None: - return None - - if isinstance(value, bool): - return value - if isinstance(value, str): - return value - - if isinstance(value, int): - # Python integers have arbitrary precision and are encoded directly - # Note: JavaScript BigInt types are converted to strings during normalization - # (per spec Section 3), but Python ints don't need this conversion - return value - - if isinstance(value, float): - # Handle non-finite first - if not math.isfinite(value) or value != value: # includes inf, -inf, NaN - logger.debug(f"Converting non-finite float to null: {value}") - return None - if value == 0.0 and math.copysign(1.0, value) == -1.0: - logger.debug("Converting negative zero to positive zero") - return 0 - return value - - # Handle Decimal - if isinstance(value, Decimal): - if not value.is_finite(): - logger.debug(f"Converting non-finite Decimal to null: {value}") - return None - return float(value) - - # Handle pathlib.Path objects -> string representation - if isinstance(value, PurePath): - logger.debug(f"Converting {type(value).__name__} to string: {value}") - return str(value) - - if isinstance(value, datetime): - try: - result = value.isoformat() - logger.debug(f"Converting datetime to ISO string: {value}") - return result - except Exception as e: - raise ValueError(f"Failed to convert datetime to ISO format: {e}") from e - - if isinstance(value, date): - try: - result = value.isoformat() - logger.debug(f"Converting date to ISO string: {value}") - return result - except Exception as e: - raise ValueError(f"Failed to convert date to ISO format: {e}") from e - - if isinstance(value, list): - if not value: - return [] - return [normalize_value(item) for item in value] - - if isinstance(value, tuple): - logger.debug(f"Converting tuple to list: {len(value)} items") - return [normalize_value(item) for item in value] - - if isinstance(value, (set, frozenset)): - logger.debug(f"Converting {type(value).__name__} to sorted list: {len(value)} items") - try: - return [normalize_value(item) for item in sorted(value)] - except TypeError: - # Fall back to stable conversion for heterogeneous sets/frozensets - logger.debug( - f"{type(value).__name__} contains heterogeneous types, using repr() for sorting" - ) - return [normalize_value(item) for item in sorted(value, key=lambda x: repr(x))] - - # Handle generic mapping types (Map-like) and dicts - if isinstance(value, Mapping): - logger.debug(f"Converting {type(value).__name__} to dict: {len(value)} items") - try: - return {str(k): normalize_value(v) for k, v in value.items()} - except Exception as e: - raise ValueError( - f"Failed to convert mapping to dict: {e}. " - "Check that all keys can be converted to strings." - ) from e - - # Handle callables -> null - if callable(value): - logger.debug(f"Converting callable {type(value).__name__} to null") - return None - - # Fallback for other types - logger.warning( - f"Unsupported type {type(value).__name__}, converting to null. Value: {str(value)[:50]}" - ) - return None - - -def is_json_primitive(value: Any) -> TypeGuard[JsonPrimitive]: - """Check if value is a JSON primitive type. - - Args: - value: Value to check. - - Returns: - TypeGuard[JsonPrimitive]: True if value is None, str, int, float, or bool. - """ - return value is None or isinstance(value, (str, int, float, bool)) - - -def is_json_array(value: Any) -> TypeGuard[JsonArray]: - """Check if value is a JSON array (Python list). - - Args: - value: Value to check. - - Returns: - TypeGuard[JsonArray]: True if value is a list. - """ - return isinstance(value, list) - - -def is_json_object(value: Any) -> TypeGuard[JsonObject]: - """Check if value is a JSON object (Python dict). - - Args: - value: Value to check. - - Returns: - TypeGuard[JsonObject]: True if value is a dict. - """ - return isinstance(value, dict) - - -def is_array_of_primitives(value: JsonArray) -> bool: - """Check if array contains only primitive values. - - Args: - value: List to check. - - Returns: - bool: True if all items are primitives. Empty arrays return True. - """ - if not value: - return True - return all(is_json_primitive(item) for item in value) - - -def is_array_of_arrays(value: JsonArray) -> bool: - """Check if array contains only arrays. - - Args: - value: List to check. - - Returns: - bool: True if all items are lists. Empty arrays return True. - """ - if not value: - return True - return all(is_json_array(item) for item in value) - - -def is_array_of_objects(value: JsonArray) -> bool: - """Check if array contains only objects. - - Args: - value: List to check. - - Returns: - bool: True if all items are dicts. Empty arrays return True. - """ - if not value: - return True - return all(is_json_object(item) for item in value) diff --git a/src/toon_format/primitives.py b/src/toon_format/primitives.py deleted file mode 100644 index 266d20d..0000000 --- a/src/toon_format/primitives.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Primitive value encoding utilities. - -Handles encoding of primitive values (strings, numbers, booleans, null) and -array headers. Implements quoting rules, escape sequences, and header formatting -for inline and tabular array formats. -""" - -import re -from typing import List, Literal, Optional, Union - -from ._string_utils import escape_string -from ._validation import is_safe_unquoted, is_valid_unquoted_key -from .constants import ( - CLOSE_BRACE, - CLOSE_BRACKET, - COLON, - COMMA, - CONTROL_CHARS_REGEX, - DOUBLE_QUOTE, - FALSE_LITERAL, - NULL_LITERAL, - NUMERIC_REGEX, - OCTAL_REGEX, - OPEN_BRACE, - OPEN_BRACKET, - STRUCTURAL_CHARS_REGEX, - TRUE_LITERAL, - VALID_KEY_REGEX, -) -from .logging_config import get_logger -from .types import Delimiter, JsonPrimitive - -# Precompiled patterns for performance -_STRUCTURAL_CHARS_PATTERN = re.compile(STRUCTURAL_CHARS_REGEX) -_CONTROL_CHARS_PATTERN = re.compile(CONTROL_CHARS_REGEX) -_NUMERIC_PATTERN = re.compile(NUMERIC_REGEX, re.IGNORECASE) -_OCTAL_PATTERN = re.compile(OCTAL_REGEX) -_VALID_KEY_PATTERN = re.compile(VALID_KEY_REGEX, re.IGNORECASE) - - -logger = get_logger(__name__) - - -def encode_primitive(value: JsonPrimitive, delimiter: str = COMMA) -> str: - """Encode a primitive value. - - Args: - value: Primitive value - delimiter: Current delimiter being used - - Returns: - Encoded string - """ - if value is None: - return NULL_LITERAL - if isinstance(value, bool): - return TRUE_LITERAL if value else FALSE_LITERAL - if isinstance(value, (int, float)): - # Format numbers in decimal form without scientific notation - # Per spec Section 2: numbers must be rendered without exponent notation - if isinstance(value, int): - return str(value) - # For floats, use Python's default conversion first - formatted = str(value) - # Check if Python used scientific notation - if "e" in formatted or "E" in formatted: - # Convert to fixed-point decimal notation - # Use format with enough precision, then strip trailing zeros - from decimal import Decimal - - # Convert through Decimal to get exact decimal representation - dec = Decimal(str(value)) - formatted = format(dec, "f") - return formatted - if isinstance(value, str): - return encode_string_literal(value, delimiter) - return str(value) - - -# Note: escape_string and is_safe_unquoted are now imported from _string_utils and _validation - - -def encode_string_literal(value: str, delimiter: str = COMMA) -> str: - """Encode a string, quoting only if necessary. - - Args: - value: String value - delimiter: Current delimiter being used - - Returns: - Encoded string - """ - if is_safe_unquoted(value, delimiter): - return value - return f"{DOUBLE_QUOTE}{escape_string(value)}{DOUBLE_QUOTE}" - - -def encode_key(key: str) -> str: - """Encode an object key. - - Args: - key: Key string - - Returns: - Encoded key - """ - # Keys matching /^[A-Z_][\w.]*$/i don't require quotes - if is_valid_unquoted_key(key): - return key - return f"{DOUBLE_QUOTE}{escape_string(key)}{DOUBLE_QUOTE}" - - -def join_encoded_values(values: List[str], delimiter: Delimiter) -> str: - """Join encoded primitive values with a delimiter. - - Args: - values: List of encoded values - delimiter: Delimiter to use - - Returns: - Joined string - """ - return delimiter.join(values) - - -def format_header( - key: Optional[str], - length: int, - fields: Optional[List[str]], - delimiter: Delimiter, - length_marker: Union[str, Literal[False], None], -) -> str: - """Format array/table header. - - Args: - key: Optional key name - length: Array length - fields: Optional field names for tabular format - delimiter: Delimiter character - length_marker: Optional length marker prefix - - Returns: - Formatted header string - """ - # Build length marker - marker_prefix = length_marker if length_marker else "" - - # Build fields if provided - fields_str = "" - if fields: - # Encode each field name as a key (may need quoting per Section 7.3) - encoded_fields = [encode_key(field) for field in fields] - fields_str = f"{OPEN_BRACE}{delimiter.join(encoded_fields)}{CLOSE_BRACE}" - - # Build length string with delimiter when needed - # Rules per TOON spec: delimiter is optional in bracket [N] - # - Only include delimiter if it's NOT comma (comma is the default) - # - This applies to both tabular and primitive arrays - if delimiter != COMMA: - # Non-comma delimiter: show delimiter in bracket - length_str = f"{OPEN_BRACKET}{marker_prefix}{length}{delimiter}{CLOSE_BRACKET}" - else: - # Comma delimiter (default): just [length] - length_str = f"{OPEN_BRACKET}{marker_prefix}{length}{CLOSE_BRACKET}" - - # Combine parts - if key: - return f"{encode_key(key)}{length_str}{fields_str}{COLON}" - return f"{length_str}{fields_str}{COLON}" diff --git a/src/toon_format/pydantic.py b/src/toon_format/pydantic.py new file mode 100644 index 0000000..88be709 --- /dev/null +++ b/src/toon_format/pydantic.py @@ -0,0 +1,74 @@ +"""Pydantic integration. + +Requires the ``pydantic`` extra: ``pip install "toon-format[pydantic]"``. + +Plain :class:`pydantic.BaseModel` instances can be passed to +:func:`toon_format.dumps` directly. :class:`ToonPydanticModel` adds TOON +counterparts of pydantic's JSON helpers:: + + class User(ToonPydanticModel): + name: str + age: int + + text = User(name="Ada", age=36).model_dump_toon() + user = User.model_validate_toon(text) +""" + +from __future__ import annotations + +import sys +from typing import Any + +from pydantic import BaseModel + +from ._api import dumps, loads + +if sys.version_info >= (3, 11): + from typing import Self +else: # declared in the pydantic extra for Python 3.10 + from typing_extensions import Self + +__all__ = ["ToonPydanticModel"] + + +class ToonPydanticModel(BaseModel): + """A :class:`pydantic.BaseModel` that reads and writes TOON.""" + + @classmethod + def schema_to_toon(cls, *, indent_size: int = 2, delimiter: str = ",") -> str: + """Return the model's JSON schema encoded as TOON, for use in prompts.""" + return dumps( + cls.model_json_schema(), indent_size=indent_size, delimiter=delimiter + ) + + def model_dump_toon( + self, *, indent_size: int = 2, delimiter: str = ",", **kwargs: Any + ) -> str: + """Serialize the model to TOON, like ``BaseModel.model_dump_json``. + + Extra keyword arguments go to :meth:`pydantic.BaseModel.model_dump` + (for example ``exclude_none=True``). + """ + data = self.model_dump(mode="json", **kwargs) + return dumps(data, indent_size=indent_size, delimiter=delimiter) + + @classmethod + def model_validate_toon( + cls, text: str | bytes, *, strict: bool | None = None, **kwargs: Any + ) -> Self: + """Decode and validate TOON, like ``BaseModel.model_validate_json``. + + Args: + text: The TOON document. + strict: Passed to both :func:`toon_format.loads` and + :meth:`pydantic.BaseModel.model_validate`. ``None`` decodes + in strict mode and leaves validation to the model's + configuration. + **kwargs: Passed to :meth:`pydantic.BaseModel.model_validate`. + + Raises: + toon_format.ToonDecodeError: The text is not valid TOON. + pydantic.ValidationError: The data does not match the model. + """ + data = loads(text, strict=strict is not False) + return cls.model_validate(data, strict=strict, **kwargs) diff --git a/src/toon_format/pydantic/__init__.py b/src/toon_format/pydantic/__init__.py deleted file mode 100644 index fe1af2a..0000000 --- a/src/toon_format/pydantic/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .serializer import ToonPydanticModel - -__all__ = ["ToonPydanticModel"] diff --git a/src/toon_format/pydantic/serializer.py b/src/toon_format/pydantic/serializer.py deleted file mode 100644 index c83d7ac..0000000 --- a/src/toon_format/pydantic/serializer.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from typing import TypeVar - -from pydantic import BaseModel, ValidationError - -from toon_format import decode, encode - -T = TypeVar("T", bound="ToonPydanticModel") - - -class ToonPydanticModel(BaseModel): - """ - Pydantic mixin that adds TOON superpowers. - - • schema_to_toon() → TOON schema string (for LLM few-shot / system prompts) - • model_dump_toon() → Serialize this model instance to a TOON string - • model_validate_toon() → Parse TOON output directly into a validated model - """ - - @classmethod - def schema_to_toon(cls) -> str: - """ - Convert the model's JSON schema into compact TOON format. - Use this in your LLM prompt to save 40–60% tokens vs JSON schema. - """ - schema = cls.model_json_schema() - # Pydantic gives us full JSON schema - return encode(schema) - - def model_dump_toon(self, **kwargs) -> str: - """ - Serialize this model instance into a compact TOON string. - - Mirrors pydantic's ``model_dump_json()``. Extra keyword arguments are - forwarded to ``model_dump()`` (e.g. ``exclude_none=True``). - """ - data = self.model_dump(mode="json", **kwargs) - return encode(data) - - @classmethod - def model_validate_toon(cls: type[T], text: str) -> T: - """ - Parse a raw TOON string (from an LLM) into a fully validated model. - - Mirrors pydantic's ``model_validate_json()``. - - Raises: - ValueError – If TOON parsing fails or the input is empty - ValidationError – If data doesn't match the model - """ - if not text.strip(): - raise ValueError("Empty string cannot be parsed as TOON") - - try: - data = decode(text.strip()) - return cls.model_validate(data) - except ValidationError as e: - raise e # Let Pydantic's rich error surface (best UX) - except Exception as e: - raise ValueError(f"Failed to parse TOON into {cls.__name__}: {e}") from e diff --git a/src/toon_format/types.py b/src/toon_format/types.py deleted file mode 100644 index a000d5a..0000000 --- a/src/toon_format/types.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Type definitions for TOON format. - -Defines type aliases and TypedDict classes for JSON values, encoding/decoding -options, and internal types used throughout the package. -""" - -from typing import Any, Dict, List, Literal, TypedDict, Union - -# JSON-compatible types -JsonPrimitive = Union[str, int, float, bool, None] -JsonObject = Dict[str, Any] -JsonArray = List[Any] -JsonValue = Union[JsonPrimitive, JsonArray, JsonObject] - -# Delimiter type -Delimiter = str -DelimiterKey = Literal["comma", "tab", "pipe"] - - -class EncodeOptions(TypedDict, total=False): - """Options for TOON encoding. - - Attributes: - indent: Number of spaces per indentation level (default: 2) - delimiter: Delimiter character for arrays (default: comma) - lengthMarker: Optional marker to prefix array lengths (default: False) - """ - - indent: int - delimiter: Delimiter - lengthMarker: Union[Literal["#"], Literal[False]] - - -class ResolvedEncodeOptions: - """Resolved encoding options with defaults applied.""" - - def __init__( - self, - indent: int = 2, - delimiter: str = ",", - length_marker: Union[Literal["#"], Literal[False]] = False, - ) -> None: - self.indent = indent - self.delimiter = delimiter - self.lengthMarker: Union[str, Literal[False]] = length_marker - - -class DecodeOptions: - """Options for TOON decoding. - - Attributes: - indent: Number of spaces per indentation level (default: 2) - strict: Enable strict validation (default: True) - """ - - def __init__(self, indent: int = 2, strict: bool = True) -> None: - self.indent = indent - self.strict = strict - - -# Depth type for tracking indentation level -Depth = int diff --git a/src/toon_format/utils.py b/src/toon_format/utils.py deleted file mode 100644 index 935a074..0000000 --- a/src/toon_format/utils.py +++ /dev/null @@ -1,187 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Token analysis utilities for TOON format. - -This module provides utilities for counting tokens and comparing -token efficiency between JSON and TOON formats. Useful for: -- Estimating API costs (tokens are the primary cost driver) -- Optimizing prompt sizes for LLM context windows -- Benchmarking TOON's token efficiency - -Functions: - count_tokens: Count tokens in a text string - estimate_savings: Compare JSON vs TOON token counts - compare_formats: Generate formatted comparison table - -Requirements: - tiktoken: Install with `uv add tiktoken` or `uv add toon_format[benchmark]` - -Example: - >>> import toon_format - >>> data = {"name": "Alice", "age": 30} - >>> result = toon_format.estimate_savings(data) - >>> print(f"TOON saves {result['savings_percent']:.1f}% tokens") -""" - -import functools -import json -from typing import Any, Dict - -# Import encode from parent package (defined in __init__.py before this module is imported) -# __init__.py defines encode() before importing utils, so this is safe -from . import encode - -__all__ = ["count_tokens", "estimate_savings", "compare_formats"] - - -_TIKTOKEN_MISSING_MSG = ( - "tiktoken is required for token counting. " - "Install with: uv add tiktoken or uv add toon_format[benchmark]" -) - - -def _require_tiktoken(): - try: - import tiktoken # type: ignore[import-not-found] - except ImportError as exc: # pragma: no cover - exercised via count_tokens - raise RuntimeError(_TIKTOKEN_MISSING_MSG) from exc - return tiktoken - - -@functools.lru_cache(maxsize=1) -def _get_tokenizer(): - """Get cached tiktoken tokenizer for o200k_base encoding. - - Returns: - tiktoken.Encoding: The o200k_base tokenizer (gpt5/gpt5-mini). - - Raises: - RuntimeError: If tiktoken is not installed. - """ - tiktoken = _require_tiktoken() - return tiktoken.get_encoding("o200k_base") - - -def count_tokens(text: str, encoding: str = "o200k_base") -> int: - """Count tokens in a text string using tiktoken. - - Args: - text: The string to tokenize. - encoding: Tokenizer encoding name (default: 'o200k_base' for gpt5/gpt5-mini). - Other options include 'cl100k_base' (GPT-3.5), 'p50k_base' (older models). - - Returns: - int: The number of tokens in the text. - - Example: - >>> import toon_format - >>> text = "Hello, world!" - >>> toon_format.count_tokens(text) - 4 - - Note: - Requires tiktoken to be installed: uv add tiktoken or uv add toon_format[benchmark] - """ - if encoding == "o200k_base": - enc = _get_tokenizer() - else: - tiktoken = _require_tiktoken() - enc = tiktoken.get_encoding(encoding) - - return len(enc.encode(text)) - - -def estimate_savings(data: Any, encoding: str = "o200k_base") -> Dict[str, Any]: - """Compare token counts between JSON and TOON formats. - - Args: - data: Python dict or list to compare. - encoding: Tokenizer encoding name (default: 'o200k_base'). - - Returns: - dict: Dictionary containing: - - json_tokens (int): Token count for JSON format - - toon_tokens (int): Token count for TOON format - - savings (int): Absolute token savings (json_tokens - toon_tokens) - - savings_percent (float): Percentage savings - - Example: - >>> import toon_format - >>> data = {"employees": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} - >>> result = toon_format.estimate_savings(data) - >>> print(f"Savings: {result['savings_percent']:.1f}%") - Savings: 42.3% - - Note: - Significant savings are typically achieved with structured data, - especially arrays of uniform objects (tabular data). - """ - # Encode as JSON - json_str = json.dumps(data, indent=2, ensure_ascii=False) - json_tokens = count_tokens(json_str, encoding) - - # Encode as TOON - toon_str = encode(data) - toon_tokens = count_tokens(toon_str, encoding) - - # Calculate savings - savings = max(0, json_tokens - toon_tokens) - savings_percent = (savings / json_tokens * 100.0) if json_tokens > 0 else 0.0 - - return { - "json_tokens": json_tokens, - "toon_tokens": toon_tokens, - "savings": savings, - "savings_percent": savings_percent, - } - - -def compare_formats(data: Any, encoding: str = "o200k_base") -> str: - """Generate a formatted comparison table showing JSON vs TOON metrics. - - Args: - data: Python dict or list to compare. - encoding: Tokenizer encoding name (default: 'o200k_base'). - - Returns: - str: Formatted table as multi-line string showing token counts, - character sizes, and savings percentage. - - Example: - >>> import toon_format - >>> data = {"users": [{"id": 1, "name": "Alice"}]} - >>> print(toon_format.compare_formats(data)) - Format Comparison - ──────────────────────────────────────────────── - Format Tokens Size (chars) - JSON 1,234 5,678 - TOON 789 3,456 - ──────────────────────────────────────────────── - Savings: 445 tokens (36.1%) - - Note: - This is useful for quick visual comparison during development. - """ - # Get token metrics - metrics = estimate_savings(data, encoding) - - # Encode both formats to get character counts - json_str = json.dumps(data, indent=2, ensure_ascii=False) - toon_str = encode(data) - - json_chars = len(json_str) - toon_chars = len(toon_str) - - # Build formatted table - separator = "─" * 48 - lines = [ - "Format Comparison", - separator, - "Format Tokens Size (chars)", - f"JSON {metrics['json_tokens']:>7,} {json_chars:>11,}", - f"TOON {metrics['toon_tokens']:>7,} {toon_chars:>11,}", - separator, - f"Savings: {metrics['savings']:,} tokens ({metrics['savings_percent']:.1f}%)", - ] - - return "\n".join(lines) diff --git a/src/toon_format/writer.py b/src/toon_format/writer.py deleted file mode 100644 index 57c5aaf..0000000 --- a/src/toon_format/writer.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) 2025 TOON Format Organization -# SPDX-License-Identifier: MIT -"""Line writer for managing indented TOON output. - -Provides LineWriter class that manages indented text generation with optimized -indent string caching for performance. -""" - -from typing import Dict, List - -from .types import Depth - - -class LineWriter: - """Manages indented text output with optimized indent caching.""" - - def __init__(self, indent_size: int) -> None: - """Initialize the line writer. - - Args: - indent_size: Number of spaces per indentation level - """ - self._lines: List[str] = [] - # Ensure nested structures remain distinguishable even for indent=0 - normalized_indent = indent_size if indent_size > 0 else 1 - self._indentation_string = " " * normalized_indent - self._indent_cache: Dict[int, str] = {0: ""} - self._indent_size = indent_size - - def push(self, depth: Depth, content: str) -> None: - """Add a line with appropriate indentation. - - Args: - depth: Indentation depth level - content: Content to add - """ - # Use cached indent string for performance - if depth not in self._indent_cache: - if self._indent_size == 0: - # indent=0 uses minimal spacing to preserve structure - self._indent_cache[depth] = " " * depth - else: - self._indent_cache[depth] = self._indentation_string * depth - indent = self._indent_cache[depth] - self._lines.append(indent + content) - - def to_string(self) -> str: - """Return all lines joined with newlines. - - Returns: - Complete output string - """ - return "\n".join(self._lines) diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 9cdf29d..0000000 --- a/tests/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# TOON Test Fixtures - -This directory contains **comprehensive language-agnostic JSON test fixtures** for validating TOON implementations against the specification. These fixtures cover all specification requirements and provide a standardized conformance test suite. - -## Purpose - -The test fixtures serve multiple purposes: - -- **Conformance validation:** Verify implementations follow the specification -- **Regression testing:** Catch behavioral changes across versions -- **Implementation guide:** Demonstrate expected encoding/decoding behavior -- **Cross-language consistency:** Ensure all implementations produce identical output - -## Directory Structure - -``` -tests/ -├── fixtures.schema.json # JSON Schema for fixture validation -├── fixtures/ -│ ├── encode/ # Encoding tests (JSON → TOON) -│ │ ├── primitives.json -│ │ ├── objects.json -│ │ ├── arrays-primitive.json -│ │ ├── arrays-tabular.json -│ │ ├── arrays-nested.json -│ │ ├── arrays-objects.json -│ │ ├── delimiters.json -│ │ ├── normalization.json -│ │ ├── whitespace.json -│ │ └── options.json -│ └── decode/ # Decoding tests (TOON → JSON) -│ ├── primitives.json -│ ├── objects.json -│ ├── arrays-primitive.json -│ ├── arrays-tabular.json -│ ├── arrays-nested.json -│ ├── delimiters.json -│ ├── validation-errors.json -│ ├── indentation-errors.json -│ └── blank-lines.json -└── README.md # This file -``` - -## Fixture Format - -All test fixtures follow a standard JSON structure defined in [`fixtures.schema.json`](./fixtures.schema.json): - -```json -{ - "version": "1.3", - "category": "encode", - "description": "Brief description of test category", - "tests": [ - { - "name": "descriptive test name", - "input": "JSON value or TOON string", - "expected": "TOON string or JSON value", - "options": {}, - "specSection": "7.2", - "note": "Optional explanation" - } - ] -} -``` - -### Field Descriptions - -| Field | Required | Description | -|-------|----------|-------------| -| `version` | Yes | TOON specification version (e.g., `"1.3"`) | -| `category` | Yes | Test category: `"encode"` or `"decode"` | -| `description` | Yes | Brief description of what this fixture tests | -| `tests` | Yes | Array of test cases | -| `tests[].name` | Yes | Descriptive name explaining what is validated | -| `tests[].input` | Yes | Input value (JSON for encode, TOON string for decode) | -| `tests[].expected` | Yes | Expected output (TOON string for encode, JSON for decode) | -| `tests[].shouldError` | No | If `true`, expects an error (default: `false`) | -| `tests[].options` | No | Encoder/decoder options (see below) | -| `tests[].specSection` | No | Reference to specification section (e.g., `"7.2"`, `"§6"`) | -| `tests[].note` | No | Optional explanation for special cases | -| `tests[].minSpecVersion` | No | Minimum spec version required (e.g., `"1.3"`) | - -### Options - -#### Encoding Options - -```json -{ - "delimiter": ",", - "indent": 2, - "lengthMarker": "" -} -``` - -- `delimiter`: `","` (comma, default), `"\t"` (tab), or `"|"` (pipe) -- `indent`: Number of spaces per indentation level (default: `2`) -- `lengthMarker`: `"#"` to prefix array lengths, or `""` for no marker (default: `""`) - -#### Decoding Options - -```json -{ - "indent": 2, - "strict": true -} -``` - -- `indent`: Expected number of spaces per level (default: `2`) -- `strict`: Enable strict validation (default: `true`) - -### Error Tests - -Error tests use `shouldError: true` to indicate that the test expects an error to be thrown: - -```json -{ - "name": "throws on array length mismatch", - "input": "tags[3]: a,b", - "expected": null, - "shouldError": true, - "options": { "strict": true } -} -``` - -**Note:** Error tests do not specify expected error messages, as these are implementation-specific and vary across languages. - -## Using These Tests - -To validate your TOON implementation against these fixtures: - -1. **Load a fixture file** from `fixtures/encode/` or `fixtures/decode/`. -2. **Iterate through the `tests` array** in the fixture. -3. **For each test case:** - - If `shouldError` is `true`: verify your implementation throws an error. - - Otherwise: assert that your encoder/decoder produces the `expected` output when given the `input`. -4. **Pass options** from `test.options` to your encoder/decoder (if present). - -The fixture format is language-agnostic JSON, so you can load and iterate it using your language's standard JSON parser and test framework. - -## Test Coverage - -### Encoding Tests (`fixtures/encode/`) - -| File | Description | Spec Sections | -|------|-------------|---------------| -| `primitives.json` | String, number, boolean, null encoding and escaping | §5 | -| `objects.json` | Simple objects, nested objects, key encoding | §6 | -| `arrays-primitive.json` | Inline primitive arrays, empty arrays | §7.1 | -| `arrays-tabular.json` | Tabular format with header and rows | §7.2 | -| `arrays-nested.json` | Arrays of arrays, mixed arrays | §7.3 | -| `arrays-objects.json` | Objects as list items, complex nesting | §7 | -| `delimiters.json` | Tab and pipe delimiter options | §8 | -| `normalization.json` | BigInt, Date, undefined, NaN, Infinity handling | §5 | -| `whitespace.json` | Formatting invariants and indentation | §4 | -| `options.json` | Length marker and delimiter option combinations | §3 | - -### Decoding Tests (`fixtures/decode/`) - -| File | Description | Spec Sections | -|------|-------------|---------------| -| `primitives.json` | Parsing primitives, unescaping, ambiguity | §5 | -| `objects.json` | Parsing objects, keys, nesting | §6 | -| `arrays-primitive.json` | Inline array parsing | §7.1 | -| `arrays-tabular.json` | Tabular format parsing | §7.2 | -| `arrays-nested.json` | Nested and mixed array parsing | §7.3 | -| `delimiters.json` | Delimiter detection and parsing | §8 | -| `validation-errors.json` | Syntax errors, length mismatches, malformed input | §9 | -| `indentation-errors.json` | Strict mode indentation validation | §9 | -| `blank-lines.json` | Blank line handling in arrays | §9 | - -## Validating Fixtures - -All fixture files should validate against [`fixtures.schema.json`](./fixtures.schema.json). You can use standard JSON Schema validators: - -```bash -# Using ajv-cli -npx ajv-cli validate -s fixtures.schema.json -d "fixtures/**/*.json" - -# Using check-jsonschema (Python) -pip install check-jsonschema -check-jsonschema --schemafile fixtures.schema.json fixtures/**/*.json -``` - -## Contributing Test Cases - -To contribute new test cases: - -1. **Identify the category:** Which fixture file should contain the test? -2. **Follow the format:** Use the structure defined in `fixtures.schema.json` -3. **Add spec references:** Link to relevant specification sections -4. **Validate:** Ensure your fixture validates against the schema -5. **Test with reference implementation:** Verify expected output is correct -6. **Submit PR:** Include clear description of what the test validates - -See [CONTRIBUTING.md](../CONTRIBUTING.md) for detailed guidelines. - -## Reference Implementation - -The reference implementation in TypeScript/JavaScript is maintained at: [github.com/toon-format/toon](https://github.com/toon-format/toon) - -## Questions or Issues? - -If you find: - -- Test cases that contradict the specification -- Missing coverage for edge cases -- Ambiguous expected outputs -- Schema validation issues - -Please [open an issue](https://github.com/toon-format/spec/issues) with: - -- Fixture file and test case name -- Description of the issue -- Proposed fix (if applicable) - -## License - -These test fixtures are released under the MIT License, the same as the specification. diff --git a/tests/__init__.py b/tests/__init__.py index 4f21667..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -"""Tests for the TOON package.""" diff --git a/tests/conftest.py b/tests/conftest.py index 04a8ae4..03ead87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,122 +1,14 @@ -"""Shared pytest fixtures for TOON format tests. - -This module provides reusable test data and fixtures following pytest best practices. -""" - -from typing import Any, Dict, List - -import pytest - - -# Simple test data fixtures -@pytest.fixture -def simple_object() -> Dict[str, Any]: - """A simple object for basic encoding/decoding tests.""" - return {"id": 123, "name": "Alice", "active": True} - - -@pytest.fixture -def nested_object() -> Dict[str, Any]: - """A nested object structure for testing deep nesting.""" - return { - "user": { - "id": 123, - "profile": {"name": "Alice", "city": "NYC"}, - } - } - - -@pytest.fixture -def tabular_array() -> List[Dict[str, Any]]: - """Array of uniform objects suitable for tabular format.""" - return [ - {"id": 1, "name": "Alice", "age": 30}, - {"id": 2, "name": "Bob", "age": 25}, - {"id": 3, "name": "Charlie", "age": 35}, - ] - - -@pytest.fixture -def primitive_array() -> List[Any]: - """Array of primitive values for inline format.""" - return [1, 2, 3, 4, 5] - - -@pytest.fixture -def mixed_array() -> List[Any]: - """Array with mixed types requiring list format.""" - return [ - {"name": "Alice"}, - 42, - "hello", - True, - ] - - -# Parametrized delimiter fixture -@pytest.fixture(params=[",", "\t", "|"]) -def delimiter(request) -> str: - """Parametrized fixture providing all three supported delimiters. - - Returns comma, tab, or pipe delimiter. - """ - return request.param - - -# Edge case values -@pytest.fixture -def edge_case_values() -> Dict[str, Any]: - """Collection of edge case values for testing normalization.""" - return { - "infinity": float("inf"), - "negative_infinity": float("-inf"), - "nan": float("nan"), - "negative_zero": -0.0, - "large_int": 9007199254740992, # 2^53 - "none": None, - } - - -# Python-specific types -@pytest.fixture -def python_types() -> Dict[str, Any]: - """Python-specific types that need normalization.""" - from decimal import Decimal - - return { - "tuple": (1, 2, 3), - "set": {3, 1, 2}, - "frozenset": frozenset([3, 1, 2]), - "decimal": Decimal("3.14"), - } - - -# Options fixtures -@pytest.fixture -def encode_options_comma() -> Dict[str, Any]: - """Encode options with comma delimiter.""" - return {"delimiter": ",", "indent": 2} - - -@pytest.fixture -def encode_options_tab() -> Dict[str, Any]: - """Encode options with tab delimiter.""" - return {"delimiter": "\t", "indent": 2} - - -@pytest.fixture -def encode_options_pipe() -> Dict[str, Any]: - """Encode options with pipe delimiter.""" - return {"delimiter": "|", "indent": 2} - - -@pytest.fixture -def decode_options_strict() -> Dict[str, bool]: - """Decode options with strict mode enabled.""" - return {"strict": True} - - -@pytest.fixture -def decode_options_lenient() -> Dict[str, bool]: - """Decode options with strict mode disabled.""" - return {"strict": False} +import os + +from hypothesis import HealthCheck, settings + +settings.register_profile( + "default", + max_examples=300, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +settings.register_profile( + "ci", max_examples=2000, deadline=None, suppress_health_check=[HealthCheck.too_slow] +) +settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "default")) diff --git a/tests/fixtures.schema.json b/tests/fixtures.schema.json deleted file mode 100644 index 5ed7ca8..0000000 --- a/tests/fixtures.schema.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://toon-format.org/schemas/test-fixture.json", - "title": "TOON Test Fixture", - "description": "Schema for language-agnostic TOON test fixtures", - "type": "object", - "required": ["version", "category", "description", "tests"], - "properties": { - "version": { - "type": "string", - "description": "TOON specification version these tests target", - "pattern": "^\\d+\\.\\d+$", - "examples": ["1.0", "1.3"] - }, - "category": { - "type": "string", - "enum": ["encode", "decode"], - "description": "Test category: encode (JSON → TOON) or decode (TOON → JSON)" - }, - "description": { - "type": "string", - "description": "Brief description of what this fixture file tests", - "minLength": 1, - "examples": ["Primitives - String Encoding", "Tabular Arrays - Decoding"] - }, - "tests": { - "type": "array", - "description": "Array of test cases", - "minItems": 1, - "items": { - "type": "object", - "required": ["name", "input", "expected"], - "properties": { - "name": { - "type": "string", - "description": "Descriptive test name explaining what is being validated", - "minLength": 1, - "examples": [ - "encodes safe strings without quotes", - "throws on array length mismatch" - ] - }, - "input": { - "description": "Input value - JSON value for encode tests, TOON string for decode tests" - }, - "expected": { - "description": "Expected output - TOON string for encode tests, JSON value for decode tests" - }, - "shouldError": { - "type": "boolean", - "description": "If true, this test expects an error to be thrown", - "default": false - }, - "options": { - "type": "object", - "description": "Encoding or decoding options", - "properties": { - "delimiter": { - "type": "string", - "enum": [",", "\t", "|"], - "description": "Array delimiter (encode only)", - "default": "," - }, - "indent": { - "type": "integer", - "description": "Number of spaces per indentation level", - "minimum": 1, - "default": 2 - }, - "lengthMarker": { - "type": "string", - "enum": ["#", ""], - "description": "Optional marker to prefix array lengths (encode only)", - "default": "" - }, - "strict": { - "type": "boolean", - "description": "Enable strict validation (decode only)", - "default": true - } - }, - "additionalProperties": false - }, - "specSection": { - "type": "string", - "description": "Reference to relevant specification section", - "pattern": "^§?\\d+(\\.\\d+)*$", - "examples": ["6", "7.2", "§7.2", "9"] - }, - "note": { - "type": "string", - "description": "Optional note explaining special cases or edge case behavior" - }, - "minSpecVersion": { - "type": "string", - "description": "Minimum specification version required for this test", - "pattern": "^\\d+\\.\\d+$", - "examples": ["1.0", "1.3"] - } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false -} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..b4b99de --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,6 @@ +# Specification fixtures + +The `encode/` and `decode/` directories are copied verbatim from the +[TOON specification repository](https://github.com/toon-format/spec/tree/main/tests/fixtures) +at tag `v4.1.1`. Do not edit them by hand: run `uv run scripts/update_fixtures.py ` +to replace them with another release. diff --git a/tests/fixtures/decode/arrays-nested.json b/tests/fixtures/decode/arrays-nested.json index dbb9b20..57ffe90 100644 --- a/tests/fixtures/decode/arrays-nested.json +++ b/tests/fixtures/decode/arrays-nested.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Nested and mixed array decoding - list format, arrays of arrays, root arrays, mixed types", + "description": "Nested and mixed array decoding – list form, arrays of arrays, root arrays, mixed types", "tests": [ { "name": "parses list arrays for non-uniform objects", @@ -12,7 +12,7 @@ { "id": 2, "name": "Second", "extra": true } ] }, - "specSection": "7" + "specSection": "9.4" }, { "name": "parses list arrays with empty items", @@ -20,7 +20,7 @@ "expected": { "items": ["first", "second", {}] }, - "specSection": "7.3" + "specSection": "9.4" }, { "name": "parses list arrays with deeply nested objects", @@ -49,11 +49,11 @@ { "id": 1, "nested": { "x": 1 } } ] }, - "specSection": "7" + "specSection": "9.4" }, { - "name": "parses nested tabular arrays as first field on hyphen line", - "input": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob\n status: active", + "name": "parses list items whose first field is a tabular array", + "input": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob\n status: active", "expected": { "items": [ { @@ -65,27 +65,43 @@ } ] }, - "specSection": "7" + "specSection": "10", + "note": "Canonical encoding: tabular header on hyphen line, rows at depth +2, sibling fields at depth +1" }, { - "name": "parses objects containing arrays (including empty arrays) in list format", - "input": "items[1]:\n - name: test\n data[0]:", + "name": "parses single-field list-item object with tabular array", + "input": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob", "expected": { "items": [ - { "name": "test", "data": [] } + { + "users": [ + { "id": 1, "name": "Ada" }, + { "id": 2, "name": "Bob" } + ] + } ] }, - "specSection": "7" + "specSection": "10" + }, + { + "name": "parses objects containing arrays (including empty arrays) in list form", + "input": "items[1]:\n - name: Ada\n data[0]:", + "expected": { + "items": [ + { "name": "Ada", "data": [] } + ] + }, + "specSection": "9.4" }, { "name": "parses arrays of arrays within objects", - "input": "items[1]:\n - matrix[2]:\n - [2]: 1,2\n - [2]: 3,4\n name: grid", + "input": "items[1]:\n - matrix[2]:\n - [2]: 1,2\n - [2]: 3,4\n name: grid", "expected": { "items": [ { "matrix": [[1, 2], [3, 4]], "name": "grid" } ] }, - "specSection": "7" + "specSection": "9.2" }, { "name": "parses nested arrays of primitives", @@ -93,7 +109,7 @@ "expected": { "pairs": [["a", "b"], ["c", "d"]] }, - "specSection": "7.3" + "specSection": "9.2" }, { "name": "parses quoted strings and mixed lengths in nested arrays", @@ -101,7 +117,7 @@ "expected": { "pairs": [["a", "b"], ["c,d", "e:f", "true"]] }, - "specSection": "7.3" + "specSection": "9.2" }, { "name": "parses empty inner arrays", @@ -109,7 +125,7 @@ "expected": { "pairs": [[], []] }, - "specSection": "7.3" + "specSection": "9.2" }, { "name": "parses mixed-length inner arrays", @@ -117,37 +133,43 @@ "expected": { "pairs": [[1], [2, 3]] }, - "specSection": "7.3" + "specSection": "9.2" }, { - "name": "parses root arrays of primitives (inline)", + "name": "parses root-level primitive array inline", "input": "[5]: x,y,\"true\",true,10", "expected": ["x", "y", "true", true, 10], - "specSection": "7" + "specSection": "9.1" }, { - "name": "parses root arrays of uniform objects in tabular format", + "name": "parses root-level array of uniform objects in tabular form", "input": "[2]{id}:\n 1\n 2", "expected": [{ "id": 1 }, { "id": 2 }], - "specSection": "7.2" + "specSection": "9.3" }, { - "name": "parses root arrays of non-uniform objects in list format", + "name": "parses root-level array of non-uniform objects in list form", "input": "[2]:\n - id: 1\n - id: 2\n name: Ada", "expected": [{ "id": 1 }, { "id": 2, "name": "Ada" }], - "specSection": "7" + "specSection": "9.4" }, { - "name": "parses empty root arrays", - "input": "[0]:", - "expected": [], - "specSection": "7" + "name": "parses root-level array mixing primitive, object, and array of objects in list form", + "input": "[3]:\n - summary\n - id: 1\n name: Ada\n - [2]:\n - id: 2\n - status: draft", + "expected": ["summary", { "id": 1, "name": "Ada" }, [{ "id": 2 }, { "status": "draft" }]], + "specSection": "9.4" }, { - "name": "parses root arrays of arrays", + "name": "parses root-level array of arrays", "input": "[2]:\n - [2]: 1,2\n - [0]:", "expected": [[1, 2], []], - "specSection": "7.3" + "specSection": "9.2" + }, + { + "name": "parses empty root-level array", + "input": "[0]:", + "expected": [], + "specSection": "9.1" }, { "name": "parses complex mixed object with arrays and nested objects", @@ -161,15 +183,15 @@ "prefs": [] } }, - "specSection": "6" + "specSection": "8" }, { - "name": "parses arrays mixing primitives, objects and strings (list format)", + "name": "parses arrays mixing primitives, objects, and strings in list form", "input": "items[3]:\n - 1\n - a: 1\n - text", "expected": { "items": [1, { "a": 1 }, "text"] }, - "specSection": "7.3" + "specSection": "9.4" }, { "name": "parses arrays mixing objects and arrays", @@ -177,10 +199,10 @@ "expected": { "items": [{ "a": 1 }, [1, 2]] }, - "specSection": "7.3" + "specSection": "9.4" }, { - "name": "parses quoted key with list array format", + "name": "parses quoted key with list-form array", "input": "\"x-items\"[2]:\n - id: 1\n - id: 2", "expected": { "x-items": [ @@ -188,7 +210,40 @@ { "id": 2 } ] }, - "specSection": "7" + "specSection": "9.4" + }, + { + "name": "accepts bare bracket pair as empty inner array list item", + "input": "items[2]:\n - []\n - [1]: x", + "expected": { + "items": [[], ["x"]] + }, + "specSection": "9.2", + "note": "Decode-side acceptance mirrors §9.1's key: [] form; encoders still emit - [0]:" + }, + { + "name": "keeps every list item when the count mismatches in non-strict mode", + "input": "a[1]:\n - 1\n - 2", + "expected": { + "a": [ + 1, + 2 + ] + }, + "options": { + "strict": false + }, + "specSection": "14.1", + "note": "A declared [N] never truncates a scope", + "minSpecVersion": "4.1" + }, + { + "name": "throws on a non-list-item line at item depth", + "input": "items[2]:\n - a\n x: 1", + "expected": null, + "shouldError": true, + "specSection": "9.4", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/decode/arrays-primitive.json b/tests/fixtures/decode/arrays-primitive.json index acd7fcb..e09b87f 100644 --- a/tests/fixtures/decode/arrays-primitive.json +++ b/tests/fixtures/decode/arrays-primitive.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Primitive array decoding - inline arrays of strings, numbers, booleans, quoted strings", + "description": "Primitive array decoding – inline arrays of strings, numbers, booleans, quoted strings", "tests": [ { "name": "parses string arrays inline", @@ -9,7 +9,7 @@ "expected": { "tags": ["reading", "gaming", "coding"] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses number arrays inline", @@ -17,7 +17,7 @@ "expected": { "nums": [1, 2, 3] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses mixed primitive arrays inline", @@ -25,7 +25,7 @@ "expected": { "data": ["x", "y", true, 10] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses empty arrays", @@ -33,7 +33,7 @@ "expected": { "items": [] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses single-item array with empty string", @@ -41,7 +41,7 @@ "expected": { "items": [""] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses multi-item array with empty string", @@ -49,7 +49,7 @@ "expected": { "items": ["a", "", "b"] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses whitespace-only strings in arrays", @@ -57,7 +57,7 @@ "expected": { "items": [" ", " "] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses strings with delimiters in arrays", @@ -65,7 +65,7 @@ "expected": { "items": ["a", "b,c", "d:e"] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses strings that look like primitives when quoted", @@ -73,7 +73,7 @@ "expected": { "items": ["x", "true", "42", "-3.14"] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses strings with structural tokens in arrays", @@ -81,7 +81,7 @@ "expected": { "items": ["[5]", "- item", "{key}"] }, - "specSection": "7.1" + "specSection": "9.1" }, { "name": "parses quoted key with inline array", @@ -89,7 +89,15 @@ "expected": { "my-key": [1, 2, 3] }, - "specSection": "7.1" + "specSection": "9.1" + }, + { + "name": "parses quoted empty string key with inline array", + "input": "\"\"[3]: 1,2,3", + "expected": { + "": [1, 2, 3] + }, + "specSection": "9.1" }, { "name": "parses quoted key containing brackets with inline array", @@ -97,7 +105,15 @@ "expected": { "key[test]": [1, 2, 3] }, - "specSection": "7.1" + "specSection": "9.1" + }, + { + "name": "parses quoted key containing a colon with inline array", + "input": "\"a:b\"[2]: 1,2", + "expected": { + "a:b": [1, 2] + }, + "specSection": "9.1" }, { "name": "parses quoted key with empty array", @@ -105,7 +121,39 @@ "expected": { "x-custom": [] }, - "specSection": "7.1" + "specSection": "9.1" + }, + { + "name": "parses quoted empty string key with empty array", + "input": "\"\"[0]:", + "expected": { + "": [] + }, + "specSection": "9.1" + }, + { + "name": "decodes canonical empty array key: []", + "input": "items: []", + "expected": { + "items": [] + }, + "specSection": "9.1" + }, + { + "name": "decodes canonical empty array with quoted key", + "input": "\"x-custom\": []", + "expected": { + "x-custom": [] + }, + "specSection": "9.1" + }, + { + "name": "decodes canonical empty array with empty-string key", + "input": "\"\": []", + "expected": { + "": [] + }, + "specSection": "9.1" } ] } diff --git a/tests/fixtures/decode/arrays-tabular.json b/tests/fixtures/decode/arrays-tabular.json index 0919486..9c18ed9 100644 --- a/tests/fixtures/decode/arrays-tabular.json +++ b/tests/fixtures/decode/arrays-tabular.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Tabular array decoding - parsing arrays of uniform objects with headers", + "description": "Tabular array decoding – parsing arrays of uniform objects with headers", "tests": [ { "name": "parses tabular arrays of uniform objects", @@ -12,7 +12,7 @@ { "sku": "B2", "qty": 1, "price": 14.5 } ] }, - "specSection": "7.2" + "specSection": "9.3" }, { "name": "parses nulls and quoted values in tabular rows", @@ -23,7 +23,18 @@ { "id": 2, "value": "test" } ] }, - "specSection": "7.2" + "specSection": "9.3" + }, + { + "name": "parses quoted colon in tabular row as data", + "input": "items[2]{id,note}:\n 1,\"a:b\"\n 2,\"c:d\"", + "expected": { + "items": [ + { "id": 1, "note": "a:b" }, + { "id": 2, "note": "c:d" } + ] + }, + "specSection": "9.3" }, { "name": "parses quoted header keys in tabular arrays", @@ -34,10 +45,10 @@ { "order:id": 2, "full name": "Bob" } ] }, - "specSection": "7.2" + "specSection": "9.3" }, { - "name": "parses quoted key with tabular array format", + "name": "parses quoted key with tabular array", "input": "\"x-items\"[2]{id,name}:\n 1,Ada\n 2,Bob", "expected": { "x-items": [ @@ -45,7 +56,167 @@ { "id": 2, "name": "Bob" } ] }, - "specSection": "7.2" + "specSection": "9.3" + }, + { + "name": "parses quoted empty string key with tabular array", + "input": "\"\"[2]{id,name}:\n 1,Ada\n 2,Bob", + "expected": { + "": [ + { "id": 1, "name": "Ada" }, + { "id": 2, "name": "Bob" } + ] + }, + "specSection": "9.3" + }, + { + "name": "terminates a tabular scope at a dedented key-value line", + "input": "items[2]{id,name}:\n 1,Ada\n 2,Bob\ncount: 2", + "expected": { + "items": [ + { "id": 1, "name": "Ada" }, + { "id": 2, "name": "Bob" } + ], + "count": 2 + }, + "specSection": "9.3" + }, + { + "name": "treats a key-value line at header depth whose value contains the active delimiter as end of rows, not a row", + "input": "t[2]{a,b}:\n 1,2\n 3,4\nx: 3,4", + "expected": { + "t": [ + { "a": 1, "b": 2 }, + { "a": 3, "b": 4 } + ], + "x": "3,4" + }, + "specSection": "9.3", + "note": "The dedent to the header's depth closes the rows (§8); the line is a key-value pair, and the whole post-colon token is the value, non-numeric so a string (§11.2)" + }, + { + "name": "parses nested field groups into nested objects", + "input": "orders[2]{id,customer{name,country},total}:\n 1,Ada,DK,99\n 2,Bob,UK,149", + "expected": { + "orders": [ + { "id": 1, "customer": { "name": "Ada", "country": "DK" }, "total": 99 }, + { "id": 2, "customer": { "name": "Bob", "country": "UK" }, "total": 149 } + ] + }, + "specSection": "9.3" + }, + { + "name": "parses sibling nested field groups by depth-first cell assignment", + "input": "shipments[1]{id,sender{name,city},receiver{name,city}}:\n s1,ACME,Berlin,Globex,Oslo", + "expected": { + "shipments": [ + { "id": "s1", "sender": { "name": "ACME", "city": "Berlin" }, "receiver": { "name": "Globex", "city": "Oslo" } } + ] + }, + "specSection": "9.3" + }, + { + "name": "parses nested field groups recursively without a depth cap", + "input": "items[2]{id,geo{point{lat,lon}}}:\n 1,1.5,2.5\n 2,3,4", + "expected": { + "items": [ + { "id": 1, "geo": { "point": { "lat": 1.5, "lon": 2.5 } } }, + { "id": 2, "geo": { "point": { "lat": 3, "lon": 4 } } } + ] + }, + "specSection": "9.3" + }, + { + "name": "parses nested field groups with the pipe delimiter", + "input": "orders[2|]{id|customer{name|country}|total}:\n 1|Ada|DK|99\n 2|Bob|UK|149", + "expected": { + "orders": [ + { "id": 1, "customer": { "name": "Ada", "country": "DK" }, "total": 99 }, + { "id": 2, "customer": { "name": "Bob", "country": "UK" }, "total": 149 } + ] + }, + "specSection": "9.3" + }, + { + "name": "matches braces outside quoted names only when parsing field entries", + "input": "items[1]{\"a{b}\",c}:\n 1,2", + "expected": { + "items": [ + { "a{b}": 1, "c": 2 } + ] + }, + "specSection": "6", + "note": "A quoted field name may contain braces; brace matching is quote-aware" + }, + { + "name": "parses quoted subfield names inside nested field groups", + "input": "items[1]{id,customer{\"full name\",country}}:\n 1,Ada,UK", + "expected": { + "items": [ + { "id": 1, "customer": { "full name": "Ada", "country": "UK" } } + ] + }, + "specSection": "9.3" + }, + { + "name": "applies LWW for duplicate field names in non-strict mode", + "input": "items[1]{a,a}:\n 1,2", + "expected": { + "items": [ + { "a": 2 } + ] + }, + "options": { + "strict": false + }, + "specSection": "14.3", + "note": "Duplicate field names yield duplicate sibling keys per row; non-strict resolves last-write-wins" + }, + { + "name": "applies LWW when a bare field and a nested group share a name in non-strict mode", + "input": "items[1]{a,a{x}}:\n 1,2", + "expected": { + "items": [ + { "a": { "x": 2 } } + ] + }, + "options": { + "strict": false + }, + "specSection": "14.3" + }, + { + "name": "accepts a field name outside the encoder unquoted-key pattern", + "input": "items[1]{2key}:\n 1", + "expected": { + "items": [ + { + "2key": 1 + } + ] + }, + "specSection": "7.4", + "minSpecVersion": "4.1" + }, + { + "name": "keeps every row when the count mismatches in non-strict mode", + "input": "a[1]{x}:\n 1\n 2", + "expected": { + "a": [ + { + "x": 1 + }, + { + "x": 2 + } + ] + }, + "options": { + "strict": false + }, + "specSection": "14.1", + "note": "A declared [N] never truncates a scope", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/decode/blank-lines.json b/tests/fixtures/decode/blank-lines.json index 7abef22..b8eb4f9 100644 --- a/tests/fixtures/decode/blank-lines.json +++ b/tests/fixtures/decode/blank-lines.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Blank line handling - strict mode errors on blank lines inside arrays, accepts blank lines outside arrays", + "description": "Blank line handling – strict mode errors on blank lines inside arrays, accepts blank lines outside arrays", "tests": [ { "name": "throws on blank line inside list array", @@ -11,7 +11,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { "name": "throws on blank line inside tabular array", @@ -21,7 +21,17 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" + }, + { + "name": "throws on blank line between keyed entry rows", + "input": "m[2:]{v}:\n a: 1\n\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.2" }, { "name": "throws on multiple blank lines inside array", @@ -31,7 +41,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { "name": "throws on blank line with spaces inside array", @@ -41,7 +51,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { "name": "throws on blank line in nested list array", @@ -51,7 +61,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { "name": "accepts blank line between root-level fields", @@ -63,7 +73,19 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" + }, + { + "name": "accepts whitespace-only line at non-multiple indent as blank in strict mode", + "input": "a: 1\n \nb: 2", + "expected": { + "a": 1, + "b": 2 + }, + "options": { + "strict": true + }, + "specSection": "12" }, { "name": "accepts trailing newline at end of file", @@ -74,7 +96,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "accepts multiple trailing newlines", @@ -85,7 +107,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "accepts blank line after array ends", @@ -97,7 +119,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "accepts blank line between nested object fields", @@ -111,7 +133,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "ignores blank lines inside list array when strict=false", @@ -122,21 +144,21 @@ "options": { "strict": false }, - "specSection": "9" + "specSection": "12" }, { "name": "ignores blank lines inside tabular array when strict=false", - "input": "items[2]{id,name}:\n 1,Alice\n\n 2,Bob", + "input": "items[2]{id,name}:\n 1,Ada\n\n 2,Bob", "expected": { "items": [ - { "id": 1, "name": "Alice" }, + { "id": 1, "name": "Ada" }, { "id": 2, "name": "Bob" } ] }, "options": { "strict": false }, - "specSection": "9" + "specSection": "12" }, { "name": "ignores multiple blank lines in arrays when strict=false", @@ -147,7 +169,48 @@ "options": { "strict": false }, - "specSection": "9" + "specSection": "12" + }, + { + "name": "throws on blank line between list items after nested tabular rows", + "input": "outer[2]:\n - inner[2]{a}:\n 1\n 2\n\n - x", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "The blank is after the inner scope's last row but inside the outer array's span (\u00a712)" + }, + { + "name": "throws on blank line between a list item's fields", + "input": "items[2]:\n - a: 1\n\n b: 2\n - x", + "expected": null, + "shouldError": true, + "specSection": "14.2" + }, + { + "name": "throws on blank line inside the last list item's fields", + "input": "items[1]:\n - a: 1\n\n b: 2", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "The header span ends at the last line of the scope's content, not at the last item line (\u00a712)" + }, + { + "name": "accepts blank line between header and first list item", + "input": "items[2]:\n\n - a\n - b", + "expected": { "items": ["a", "b"] }, + "specSection": "12" + }, + { + "name": "accepts blank line between header and first tabular row", + "input": "items[2]{id}:\n\n 1\n 2", + "expected": { "items": [{ "id": 1 }, { "id": 2 }] }, + "specSection": "12" + }, + { + "name": "accepts blank line between header and first entry row", + "input": "m[2:]{v}:\n\n a: 1\n b: 2", + "expected": { "m": { "a": { "v": 1 }, "b": { "v": 2 } } }, + "specSection": "12" } ] } diff --git a/tests/fixtures/decode/comments.json b/tests/fixtures/decode/comments.json new file mode 100644 index 0000000..e351082 --- /dev/null +++ b/tests/fixtures/decode/comments.json @@ -0,0 +1,217 @@ +{ + "version": "4.0", + "category": "decode", + "description": "Comment lines – full-line decode-side comments stripped before structural interpretation", + "tests": [ + { + "name": "strips comment line between tabular rows without terminating rows", + "input": "items[2]{id,name}:\n 1,Ada\n # note\n 2,Bob", + "expected": { + "items": [ + { + "id": 1, + "name": "Ada" + }, + { + "id": 2, + "name": "Bob" + } + ] + }, + "specSection": "5.1", + "note": "The comment is not counted as a row and does not end the tabular scope" + }, + { + "name": "strips comment line between header and first row", + "input": "items[2]{id}:\n # leading\n 1\n 2", + "expected": { + "items": [ + { + "id": 1 + }, + { + "id": 2 + } + ] + }, + "specSection": "5.1" + }, + { + "name": "strips comment line whose indentation is not a multiple of indentSize", + "input": "a: 1\n # three leading spaces\nb: 2", + "expected": { + "a": 1, + "b": 2 + }, + "specSection": "5.1", + "note": "Comment lines are exempt from the §12 strict indentation check" + }, + { + "name": "strips outdented comment inside nested object scope without closing it", + "input": "user:\n id: 1\n# outdented note\n name: Ada", + "expected": { + "user": { + "id": 1, + "name": "Ada" + } + }, + "specSection": "5.1", + "note": "A depth-0 comment between depth-1 fields does not close the object" + }, + { + "name": "strips comment inside list array without counting as item", + "input": "items[2]:\n - a\n # note\n - b", + "expected": { + "items": [ + "a", + "b" + ] + }, + "specSection": "5.1" + }, + { + "name": "strips comment before root array header", + "input": "# heading\n[2]: a,b", + "expected": [ + "a", + "b" + ], + "specSection": "5.1", + "note": "Root-form discovery runs on the comment-stripped sequence" + }, + { + "name": "strips comment containing an unterminated quote without unescaping", + "input": "# \"unterminated\na: 1", + "expected": { + "a": 1 + }, + "specSection": "5.1", + "note": "Comment text is discarded without interpretation; the quote never reaches the tokenizer" + }, + { + "name": "decodes document of only comments to empty object", + "input": "# only\n# comments", + "expected": {}, + "specSection": "5.1" + }, + { + "name": "decodes comments and blank lines only to empty object", + "input": "\n# c\n\n# d\n", + "expected": {}, + "specSection": "5.1" + }, + { + "name": "decodes a hash-leading root line as a comment, yielding an empty object", + "input": "#hello", + "expected": {}, + "specSection": "5.1", + "note": "The only line is a comment, so the document is empty after the pre-pass" + }, + { + "name": "throws when a stripped hash-leading row breaks the declared count", + "input": "items[2]{id}:\n #1\n 2", + "expected": null, + "shouldError": true, + "specSection": "14.1", + "note": "The first cell makes the whole line a comment; only one row remains for [2]" + }, + { + "name": "drops hash-leading row silently in non-strict mode", + "input": "items[2]{id}:\n #1\n 2", + "expected": { + "items": [ + { + "id": 2 + } + ] + }, + "options": { + "strict": false + }, + "specSection": "5.1", + "note": "A hash-leading first cell makes the whole line a comment; non-strict mode drops the row silently" + }, + { + "name": "throws on tab-indented hash line, which is not a comment", + "input": "a: 1\n\t# not a comment", + "expected": null, + "shouldError": true, + "specSection": "5.1", + "note": "Only spaces (U+0020) may precede the #; the tab makes this a regular line, and tabs in indentation error in strict mode (§12)" + }, + { + "name": "decodes tab-indented hash row as data in non-strict mode", + "input": "items[3]{id}:\n 1\n\t#x\n 2", + "expected": { + "items": [ + { + "id": 1 + }, + { + "id": "#x" + }, + { + "id": 2 + } + ] + }, + "options": { + "strict": false + }, + "specSection": "5.1", + "note": "Comment removal precedes the §12 tab leniency, so the tab keeps the line out of the pre-pass" + }, + { + "name": "parses quoted hash-leading first cell as data, not comment", + "input": "items[1]{tag}:\n \"#a\"", + "expected": { + "items": [ + { + "tag": "#a" + } + ] + }, + "specSection": "5.1" + }, + { + "name": "parses hyphen list item with hash-leading token as string", + "input": "items[1]:\n - #x", + "expected": { + "items": [ + "#x" + ] + }, + "specSection": "5.1", + "note": "\"#\" is not the line's first non-space character; comments are full-line only" + }, + { + "name": "parses hash after key-value colon as string value", + "input": "note: #x", + "expected": { + "note": "#x" + }, + "specSection": "5.1", + "note": "No inline comments; the unquoted token decodes as the string #x" + }, + { + "name": "parses hash mid-value as data, not a trailing comment", + "input": "msg: hello # world", + "expected": { + "msg": "hello # world" + }, + "specSection": "5.1" + }, + { + "name": "round-trips quoted hash-leading values in field and inline array positions", + "input": "tag: \"#x\"\nlist[2]: \"#a\",b", + "expected": { + "tag": "#x", + "list": [ + "#a", + "b" + ] + }, + "specSection": "7.2" + } + ] +} diff --git a/tests/fixtures/decode/delimiters.json b/tests/fixtures/decode/delimiters.json index b512234..d12802a 100644 --- a/tests/fixtures/decode/delimiters.json +++ b/tests/fixtures/decode/delimiters.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Delimiter decoding - tab and pipe delimiter parsing, delimiter-aware value splitting", + "description": "Delimiter decoding – tab and pipe delimiter parsing, delimiter-aware value splitting", "tests": [ { "name": "parses primitive arrays with tab delimiter", @@ -9,7 +9,7 @@ "expected": { "tags": ["reading", "gaming", "coding"] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses primitive arrays with pipe delimiter", @@ -17,15 +17,7 @@ "expected": { "tags": ["reading", "gaming", "coding"] }, - "specSection": "8" - }, - { - "name": "parses primitive arrays with comma delimiter", - "input": "tags[3]: reading,gaming,coding", - "expected": { - "tags": ["reading", "gaming", "coding"] - }, - "specSection": "8" + "specSection": "11" }, { "name": "parses tabular arrays with tab delimiter", @@ -36,7 +28,7 @@ { "sku": "B2", "qty": 1, "price": 14.5 } ] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses tabular arrays with pipe delimiter", @@ -47,7 +39,7 @@ { "sku": "B2", "qty": 1, "price": 14.5 } ] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses nested arrays with tab delimiter", @@ -55,7 +47,7 @@ "expected": { "pairs": [["a", "b"], ["c", "d"]] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses nested arrays with pipe delimiter", @@ -63,48 +55,47 @@ "expected": { "pairs": [["a", "b"], ["c", "d"]] }, - "specSection": "8" + "specSection": "11" }, { - "name": "nested arrays inside list items default to comma delimiter", + "name": "parses nested arrays inside list items with default comma delimiter", "input": "items[1\t]:\n - tags[3]: a,b,c", "expected": { "items": [{ "tags": ["a", "b", "c"] }] }, - "specSection": "8", - "note": "Parent uses tab, nested defaults to comma" + "specSection": "11" }, { - "name": "nested arrays inside list items default to comma with pipe parent", + "name": "parses nested arrays inside list items with default comma delimiter when parent uses pipe", "input": "items[1|]:\n - tags[3]: a,b,c", "expected": { "items": [{ "tags": ["a", "b", "c"] }] }, - "specSection": "8" + "specSection": "11" }, { - "name": "parses root arrays with tab delimiter", + "name": "parses root-level array with tab delimiter", "input": "[3\t]: x\ty\tz", "expected": ["x", "y", "z"], - "specSection": "8" + "specSection": "11" }, { - "name": "parses root arrays with pipe delimiter", + "name": "parses root-level array with pipe delimiter", "input": "[3|]: x|y|z", "expected": ["x", "y", "z"], - "specSection": "8" + "specSection": "11" }, { - "name": "parses root arrays of objects with tab delimiter", + "name": "parses root-level array of objects with tab delimiter", "input": "[2\t]{id}:\n 1\n 2", "expected": [{ "id": 1 }, { "id": 2 }], - "specSection": "8" + "specSection": "11" }, { - "name": "parses root arrays of objects with pipe delimiter", + "name": "parses root-level array of objects with pipe delimiter", "input": "[2|]{id}:\n 1\n 2", "expected": [{ "id": 1 }, { "id": 2 }], - "specSection": "8" + "specSection": "11" }, { "name": "parses values containing tab delimiter when quoted", @@ -112,7 +103,7 @@ "expected": { "items": ["a", "b\tc", "d"] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses values containing pipe delimiter when quoted", @@ -120,7 +111,7 @@ "expected": { "items": ["a", "b|c", "d"] }, - "specSection": "8" + "specSection": "11" }, { "name": "does not split on commas when using tab delimiter", @@ -128,7 +119,7 @@ "expected": { "items": ["a,b", "c,d"] }, - "specSection": "8" + "specSection": "11" }, { "name": "does not split on commas when using pipe delimiter", @@ -136,7 +127,7 @@ "expected": { "items": ["a,b", "c,d"] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses tabular values containing comma with comma delimiter", @@ -147,7 +138,7 @@ { "id": 2, "note": "c,d" } ] }, - "specSection": "8" + "specSection": "11" }, { "name": "does not require quoting commas with tab delimiter", @@ -158,7 +149,7 @@ { "id": 2, "note": "c,d" } ] }, - "specSection": "8" + "specSection": "11" }, { "name": "does not require quoting commas in object values", @@ -166,8 +157,25 @@ "expected": { "note": "a,b" }, - "specSection": "8", - "note": "Object values don't require comma quoting regardless of delimiter" + "specSection": "11", + "note": "Object field values are parsed as the whole post-colon token, never split on a delimiter" + }, + { + "name": "does not split object values on commas inside a tab-delimited scope", + "input": "items[2\t]:\n - status: a,b\n - status: c,d", + "expected": { + "items": [{ "status": "a,b" }, { "status": "c,d" }] + }, + "specSection": "11.2", + "note": "The active delimiter splits row and inline cells, never object field values" + }, + { + "name": "parses quoted comma in object values", + "input": "items[2]:\n - status: \"a,b\"\n - status: \"c,d\"", + "expected": { + "items": [{ "status": "a,b" }, { "status": "c,d" }] + }, + "specSection": "11" }, { "name": "parses nested array values containing pipe delimiter", @@ -175,7 +183,7 @@ "expected": { "pairs": [["a", "b|c"]] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses nested array values containing tab delimiter", @@ -183,7 +191,7 @@ "expected": { "pairs": [["a", "b\tc"]] }, - "specSection": "8" + "specSection": "11" }, { "name": "preserves quoted ambiguity with pipe delimiter", @@ -191,7 +199,7 @@ "expected": { "items": ["true", "42", "-3.14"] }, - "specSection": "8" + "specSection": "11" }, { "name": "preserves quoted ambiguity with tab delimiter", @@ -199,7 +207,7 @@ "expected": { "items": ["true", "42", "-3.14"] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses structural-looking strings when quoted with pipe delimiter", @@ -207,7 +215,7 @@ "expected": { "items": ["[5]", "{key}", "- item"] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses structural-looking strings when quoted with tab delimiter", @@ -215,7 +223,7 @@ "expected": { "items": ["[5]", "{key}", "- item"] }, - "specSection": "8" + "specSection": "11" }, { "name": "parses tabular headers with keys containing the active delimiter", @@ -223,15 +231,19 @@ "expected": { "items": [{ "a|b": 1 }, { "a|b": 2 }] }, - "specSection": "8" + "specSection": "11" }, { - "name": "accepts length marker with pipe delimiter", - "input": "tags[#3|]: reading|gaming|coding", + "name": "falls through to a key-value line on a header delimiter mismatch", + "input": "a[2|]{x,y}: 1|2", "expected": { - "tags": ["reading", "gaming", "coding"] + "a[2|]{x,y}": "1|2" + }, + "options": { + "strict": false }, - "specSection": "8" + "specSection": "6", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/decode/indentation-errors.json b/tests/fixtures/decode/indentation-errors.json index 0c47eb7..96097d0 100644 --- a/tests/fixtures/decode/indentation-errors.json +++ b/tests/fixtures/decode/indentation-errors.json @@ -1,43 +1,43 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Strict mode indentation validation - non-multiple indentation, tab characters, custom indent sizes", + "description": "Strict mode indentation validation – non-multiple indentation, tab characters, custom indentSizes", "tests": [ { - "name": "throws when object field has non-multiple indentation (3 spaces with indent=2)", + "name": "throws on object field with non-multiple indentation (3 spaces with indentSize 2)", "input": "a:\n b: 1", "expected": null, "shouldError": true, "options": { - "indent": 2, + "indentSize": 2, "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { - "name": "throws when list item has non-multiple indentation (3 spaces with indent=2)", + "name": "throws on list item with non-multiple indentation (3 spaces with indentSize 2)", "input": "items[2]:\n - id: 1\n - id: 2", "expected": null, "shouldError": true, "options": { - "indent": 2, + "indentSize": 2, "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { - "name": "throws with custom indent size when non-multiple (3 spaces with indent=4)", + "name": "throws on non-multiple indentation with custom indentSize 4 (3 spaces)", "input": "a:\n b: 1", "expected": null, "shouldError": true, "options": { - "indent": 4, + "indentSize": 4, "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { - "name": "accepts correct indentation with custom indent size (4 spaces with indent=4)", + "name": "accepts correct indentation with custom indentSize (4 spaces with indentSize 4)", "input": "a:\n b: 1", "expected": { "a": { @@ -45,40 +45,40 @@ } }, "options": { - "indent": 4, + "indentSize": 4, "strict": true }, - "specSection": "9" + "specSection": "12" }, { - "name": "throws when tab character used in indentation", + "name": "throws on tab character used in indentation", "input": "a:\n\tb: 1", "expected": null, "shouldError": true, "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { - "name": "throws when mixed tabs and spaces in indentation", + "name": "throws on mixed tabs and spaces in indentation", "input": "a:\n \tb: 1", "expected": null, "shouldError": true, "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { - "name": "throws when tab at start of line", + "name": "throws on tab at start of line", "input": "\ta: 1", "expected": null, "shouldError": true, "options": { "strict": true }, - "specSection": "9" + "specSection": "14.2" }, { "name": "accepts tabs in quoted string values", @@ -89,7 +89,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "accepts tabs in quoted keys", @@ -100,7 +100,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "accepts tabs in quoted array elements", @@ -111,7 +111,7 @@ "options": { "strict": true }, - "specSection": "9" + "specSection": "12" }, { "name": "accepts non-multiple indentation when strict=false", @@ -122,23 +122,10 @@ } }, "options": { - "indent": 2, + "indentSize": 2, "strict": false }, - "specSection": "9" - }, - { - "name": "accepts tab indentation when strict=false (tabs ignored, depth=0)", - "input": "a:\n\tb: 1", - "expected": { - "a": {}, - "b": 1 - }, - "options": { - "strict": false - }, - "specSection": "9", - "note": "Tabs are ignored in indentation counting, so b appears at root level" + "specSection": "12" }, { "name": "accepts deeply nested non-multiples when strict=false", @@ -151,47 +138,64 @@ } }, "options": { - "indent": 2, + "indentSize": 2, "strict": false }, - "specSection": "9" + "specSection": "12" }, { - "name": "empty lines do not trigger validation errors", - "input": "a: 1\n\nb: 2", - "expected": { - "a": 1, - "b": 2 - }, - "options": { - "strict": true - }, - "specSection": "9" + "name": "throws on depth jump of more than one level", + "input": "a:\n b: 1", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "Four leading spaces are a multiple of indentSize 2, but depth 2 directly under depth 0 skips a level" }, { - "name": "root-level content (0 indentation) is always valid", - "input": "a: 1\nb: 2\nc: 3", - "expected": { - "a": 1, - "b": 2, - "c": 3 - }, - "options": { - "strict": true - }, - "specSection": "9" + "name": "throws on depth jump inside a nested object", + "input": "a:\n b:\n c: 1", + "expected": null, + "shouldError": true, + "specSection": "14.2" }, { - "name": "lines with only spaces are not validated if empty", - "input": "a: 1\n \nb: 2", - "expected": { - "a": 1, - "b": 2 - }, - "options": { - "strict": true - }, - "specSection": "9" + "name": "throws on over-indented line after a primitive field", + "input": "a: 1\n b: 2", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "A primitive field opens no scope, so the depth-2 line belongs to none and must not be silently discarded" + }, + { + "name": "throws on a line indented one level under a primitive field", + "input": "a: 1\n b: 2", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "Even a single extra level is over-indentation when the preceding line did not open a scope" + }, + { + "name": "throws on over-indented line inside a nested object", + "input": "a:\n b: 1\n c: 2", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "The depth-3 line follows a primitive field at depth 1; it belongs to no scope" + }, + { + "name": "throws on over-indented line after tabular rows", + "input": "u[1]{a}:\n 1\n junk: 9", + "expected": null, + "shouldError": true, + "specSection": "14.2" + }, + { + "name": "throws on orphan scalar line under a primitive field", + "input": "a: 1\n hello", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "A scalar line is valid only at root primitive position" } ] } diff --git a/tests/fixtures/decode/numbers.json b/tests/fixtures/decode/numbers.json new file mode 100644 index 0000000..22ac3f1 --- /dev/null +++ b/tests/fixtures/decode/numbers.json @@ -0,0 +1,225 @@ +{ + "version": "4.0", + "category": "decode", + "description": "Number decoding edge cases – trailing zeros, exponent forms, negative zero", + "tests": [ + { + "name": "parses number with trailing zeros in fractional part", + "input": "value: 1.5000", + "expected": { + "value": 1.5 + }, + "specSection": "4" + }, + { + "name": "parses negative number with positive exponent", + "input": "value: -1E+03", + "expected": { + "value": -1000 + }, + "specSection": "4" + }, + { + "name": "parses lowercase exponent", + "input": "value: 2.5e2", + "expected": { + "value": 250 + }, + "specSection": "4" + }, + { + "name": "parses uppercase exponent with negative sign", + "input": "value: 3E-02", + "expected": { + "value": 0.03 + }, + "specSection": "4" + }, + { + "name": "parses negative zero as zero", + "input": "value: -0", + "expected": { + "value": 0 + }, + "specSection": "4", + "note": "Negative zero decodes to 0; most host environments do not distinguish -0 from 0" + }, + { + "name": "parses negative zero with fractional part", + "input": "value: -0.0", + "expected": { + "value": 0 + }, + "specSection": "4" + }, + { + "name": "parses array with mixed numeric forms", + "input": "nums[5]: 42,-1E+03,1.5000,-0,2.5e2", + "expected": { + "nums": [42, -1000, 1.5, 0, 250] + }, + "specSection": "4", + "note": "Decoders normalize all numeric forms to host numeric values" + }, + { + "name": "treats leading zero as string not number", + "input": "value: 05", + "expected": { + "value": "05" + }, + "specSection": "4" + }, + { + "name": "parses very small exponent", + "input": "value: 1e-10", + "expected": { + "value": 0.0000000001 + }, + "specSection": "4" + }, + { + "name": "parses integer with positive exponent", + "input": "value: 5E+00", + "expected": { + "value": 5 + }, + "specSection": "4" + }, + { + "name": "parses zero with exponent as number", + "input": "value: 0e1", + "expected": { + "value": 0 + }, + "specSection": "4" + }, + { + "name": "parses negative zero with exponent as number", + "input": "value: -0e1", + "expected": { + "value": 0 + }, + "specSection": "4" + }, + { + "name": "parses exponent notation", + "input": "1e6", + "expected": 1000000, + "specSection": "4" + }, + { + "name": "parses exponent notation with uppercase E", + "input": "1E+6", + "expected": 1000000, + "specSection": "4" + }, + { + "name": "parses negative exponent notation", + "input": "-1e-3", + "expected": -0.001, + "specSection": "4" + }, + { + "name": "treats unquoted leading-zero number as string", + "input": "05", + "expected": "05", + "specSection": "4" + }, + { + "name": "treats unquoted multi-leading-zero as string", + "input": "007", + "expected": "007", + "specSection": "4" + }, + { + "name": "treats unquoted octal-like as string", + "input": "0123", + "expected": "0123", + "specSection": "4" + }, + { + "name": "treats leading-zeros in array as strings", + "input": "nums[3]: 05,007,0123", + "expected": { "nums": ["05", "007", "0123"] }, + "specSection": "4" + }, + { + "name": "treats unquoted negative leading-zero number as string", + "input": "-05", + "expected": "-05", + "specSection": "4" + }, + { + "name": "treats negative leading-zeros in array as strings", + "input": "nums[2]: -05,-007", + "expected": { + "nums": ["-05", "-007"] + }, + "specSection": "4" + }, + { + "name": "treats bare fraction without integer part as string", + "input": "value: .5", + "expected": { + "value": ".5" + }, + "specSection": "4", + "note": "Outside the number grammar, so a host parser would read 0.5 and corrupt the round-trip" + }, + { + "name": "treats integer with trailing dot as string", + "input": "value: 1.", + "expected": { + "value": "1." + }, + "specSection": "4" + }, + { + "name": "treats leading-plus integer as string", + "input": "value: +1", + "expected": { + "value": "+1" + }, + "specSection": "4", + "note": "The grammar admits only an optional leading minus, so +1 stays a string" + }, + { + "name": "treats leading-plus tokens in array as strings", + "input": "values[3]: +1,+1.5,+1e2", + "expected": { + "values": ["+1", "+1.5", "+1e2"] + }, + "specSection": "4", + "note": "The grammar admits only an optional leading minus, so leading-plus cells stay strings" + }, + { + "name": "parses positive exponent sign as number", + "input": "value: 1e+2", + "expected": { + "value": 100 + }, + "specSection": "4", + "note": "The plus sign is inside the exponent, which the grammar allows" + }, + { + "name": "treats Infinity and NaN tokens as strings", + "input": "a: Infinity\nb: -Infinity\nc: NaN", + "expected": { + "a": "Infinity", + "b": "-Infinity", + "c": "NaN" + }, + "specSection": "4" + }, + { + "name": "treats hex and underscore-separated tokens as strings", + "input": "a: 0x10\nb: 1_000", + "expected": { + "a": "0x10", + "b": "1_000" + }, + "specSection": "4", + "note": "Host parsers that accept these forms have a wider grammar than the spec permits" + } + ] +} diff --git a/tests/fixtures/decode/objects-keyed.json b/tests/fixtures/decode/objects-keyed.json new file mode 100644 index 0000000..3e2e05d --- /dev/null +++ b/tests/fixtures/decode/objects-keyed.json @@ -0,0 +1,230 @@ +{ + "version": "4.0", + "category": "decode", + "description": "Keyed tabular decoding – parsing keyed headers and entry rows into objects", + "tests": [ + { + "name": "parses keyed tabular objects", + "input": "servers[2:]{host,port}:\n alpha: a.example.com,8080\n beta: b.example.com,9090", + "expected": { + "servers": { + "alpha": { "host": "a.example.com", "port": 8080 }, + "beta": { "host": "b.example.com", "port": 9090 } + } + }, + "specSection": "9.5" + }, + { + "name": "parses a keyless keyed header as a root object", + "input": "[2:]{age,city}:\n alice: 30,Berlin\n bob: 25,Oslo", + "expected": { + "alice": { "age": 30, "city": "Berlin" }, + "bob": { "age": 25, "city": "Oslo" } + }, + "specSection": "9.5", + "note": "Root-form discovery (§5) routes the keyless keyed header to a root object, not a root array" + }, + { + "name": "parses nested field groups in keyed headers", + "input": "regions[2:]{name,geo{lat,lon}}:\n eu: Europe,50,10\n us: America,40,-100", + "expected": { + "regions": { + "eu": { "name": "Europe", "geo": { "lat": 50, "lon": 10 } }, + "us": { "name": "America", "geo": { "lat": 40, "lon": -100 } } + } + }, + "specSection": "9.5" + }, + { + "name": "parses keyed headers with the pipe delimiter", + "input": "servers[2:|]{host|port}:\n alpha: a|8080\n beta: b|9090", + "expected": { + "servers": { + "alpha": { "host": "a", "port": 8080 }, + "beta": { "host": "b", "port": 9090 } + } + }, + "specSection": "9.5" + }, + { + "name": "parses keyed headers with the tab delimiter", + "input": "servers[2:\t]{host\tport}:\n alpha: a\t8080\n beta: b\t9090", + "expected": { + "servers": { + "alpha": { "host": "a", "port": 8080 }, + "beta": { "host": "b", "port": 9090 } + } + }, + "specSection": "9.5" + }, + { + "name": "parses quoted entry keys", + "input": "ids[2:]{v}:\n \"42\": 1\n \"my key\": 2", + "expected": { + "ids": { + "42": { "v": 1 }, + "my key": { "v": 2 } + } + }, + "specSection": "9.5" + }, + { + "name": "parses a quoted entry key containing a colon", + "input": "m[1:]{v}:\n \"a:b\": 1", + "expected": { + "m": { + "a:b": { "v": 1 } + } + }, + "specSection": "9.5", + "note": "The entry-row split is at the first unquoted colon; the quoted colon is key data" + }, + { + "name": "treats a key-value line at the header's depth as a sibling after entries end", + "input": "data[2:]{x}:\n a: 1\n b: 2\ncount: 3", + "expected": { + "data": { + "a": { "x": 1 }, + "b": { "x": 2 } + }, + "count": 3 + }, + "specSection": "9.5", + "note": "A keyed scope ends only by dedent or end of input" + }, + { + "name": "treats key-value-shaped lines at entry depth as entry rows", + "input": "m[2:]{x}:\n total: 5\n other: 6", + "expected": { + "m": { + "total": { "x": 5 }, + "other": { "x": 6 } + } + }, + "specSection": "9.5", + "note": "Opposite polarity to §9.3: in a tabular array, a colon-before-delimiter line at row depth ends the rows; in a keyed scope it is an entry" + }, + { + "name": "splits an entry row at its first unquoted colon before delimiter splitting", + "input": "m[1:]{v}:\n k[2]: 5", + "expected": { + "m": { + "k[2]": { "v": 5 } + } + }, + "specSection": "9.5", + "note": "The token before the first unquoted colon is the entry key, accepted as a literal key per §7.4 even though an encoder would quote it" + }, + { + "name": "decodes the cell token [] as a string, not an empty array", + "input": "m[2:]{v}:\n a: []\n b: x", + "expected": { + "m": { + "a": { "v": "[]" }, + "b": { "v": "x" } + } + }, + "specSection": "9.5", + "note": "The §9.1 empty-array form does not apply inside entry rows" + }, + { + "name": "accepts a declared entry count of zero", + "input": "m[0:]{v}:", + "expected": { + "m": {} + }, + "specSection": "9.5", + "note": "Encoders never emit keyed headers for fewer than two entries; decoders accept any N with count checks" + }, + { + "name": "accepts a single entry row", + "input": "m[1:]{v}:\n a: 1", + "expected": { + "m": { + "a": { "v": 1 } + } + }, + "specSection": "9.5" + }, + { + "name": "parses quoted cells containing the active delimiter", + "input": "m[2:]{t}:\n a: \"x,y\"\n b: z", + "expected": { + "m": { + "a": { "t": "x,y" }, + "b": { "t": "z" } + } + }, + "specSection": "9.5" + }, + { + "name": "parses a keyed header on a hyphen line", + "input": "items[2]:\n - config[2:]{x}:\n a: 1\n b: 2\n status: ok\n - status: down", + "expected": { + "items": [ + { "config": { "a": { "x": 1 }, "b": { "x": 2 } }, "status": "ok" }, + { "status": "down" } + ] + }, + "specSection": "10" + }, + { + "name": "ignores comment lines between entry rows", + "input": "m[2:]{v}:\n a: 1\n# note\n b: 2", + "expected": { + "m": { + "a": { "v": 1 }, + "b": { "v": 2 } + } + }, + "specSection": "5.1", + "note": "Comment removal never ends a keyed scope, and a comment line never counts as an entry" + }, + { + "name": "applies LWW for duplicate entry keys in non-strict mode", + "input": "m[2:]{v}:\n a: 1\n a: 2", + "expected": { + "m": { + "a": { "v": 2 } + } + }, + "options": { + "strict": false + }, + "specSection": "14.3", + "note": "Entry keys are sibling keys of the decoded object and resolve last-write-wins" + }, + { + "name": "accepts entry keys outside the encoder unquoted-key pattern", + "input": "u[2:]{a}:\n 2key: 1\n foo-bar: 2", + "expected": { + "u": { + "2key": { + "a": 1 + }, + "foo-bar": { + "a": 2 + } + } + }, + "specSection": "7.4", + "minSpecVersion": "4.1" + }, + { + "name": "skips an entry-depth line without a colon in non-strict mode", + "input": "u[2:]{x}:\n a: 1\n boom", + "expected": { + "u": { + "a": { + "x": 1 + } + } + }, + "options": { + "strict": false + }, + "specSection": "9.5", + "minSpecVersion": "4.1" + } + ] +} diff --git a/tests/fixtures/decode/objects.json b/tests/fixtures/decode/objects.json index 693da81..1e42dbf 100644 --- a/tests/fixtures/decode/objects.json +++ b/tests/fixtures/decode/objects.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Object decoding - simple objects, nested objects, key parsing, quoted values", + "description": "Object decoding – fields, nested objects, key parsing, §6 fall-through (non-strict), and §14.3 duplicate-key LWW", "tests": [ { "name": "parses objects with primitive values", @@ -11,7 +11,7 @@ "name": "Ada", "active": true }, - "specSection": "6" + "specSection": "8" }, { "name": "parses null values in objects", @@ -20,7 +20,7 @@ "id": 123, "value": null }, - "specSection": "6" + "specSection": "8" }, { "name": "parses empty nested object header", @@ -28,7 +28,26 @@ "expected": { "user": {} }, - "specSection": "6" + "specSection": "8" + }, + { + "name": "bare key: with no children decodes as empty object, not array", + "input": "matches:", + "expected": { + "matches": {} + }, + "specSection": "8" + }, + { + "name": "applies last-write-wins for duplicate sibling keys in non-strict mode", + "input": "name: Ada\nname: Bob", + "expected": { + "name": "Bob" + }, + "options": { + "strict": false + }, + "specSection": "14.3" }, { "name": "parses quoted object value with colon", @@ -36,7 +55,7 @@ "expected": { "note": "a:b" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted object value with comma", @@ -44,7 +63,7 @@ "expected": { "note": "a,b" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted object value with newline escape", @@ -52,7 +71,7 @@ "expected": { "text": "line1\nline2" }, - "specSection": "6" + "specSection": "7.1" }, { "name": "parses quoted object value with escaped quotes", @@ -60,7 +79,7 @@ "expected": { "text": "say \"hello\"" }, - "specSection": "6" + "specSection": "7.1" }, { "name": "parses quoted object value with leading/trailing spaces", @@ -68,7 +87,7 @@ "expected": { "text": " padded " }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted object value with only spaces", @@ -76,7 +95,7 @@ "expected": { "text": " " }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted string value that looks like true", @@ -84,7 +103,7 @@ "expected": { "v": "true" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted string value that looks like integer", @@ -92,7 +111,7 @@ "expected": { "v": "42" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted string value that looks like negative decimal", @@ -100,7 +119,51 @@ "expected": { "v": "-7.5" }, - "specSection": "6" + "specSection": "8" + }, + { + "name": "parses quoted object value shaped like an inline array header", + "input": "a: \"[2]: x\"", + "expected": { + "a": "[2]: x" + }, + "specSection": "8", + "note": "Quoted value is opaque (§4); the bracket-and-colon shape inside the quotes is data, not an array header (§6)" + }, + { + "name": "parses quoted object value shaped like a count-matching inline array header", + "input": "a: \"[1]: x\"", + "expected": { + "a": "[1]: x" + }, + "specSection": "8", + "note": "Quoting makes the value opaque (§4), so it is never parsed as a header – the matching count is irrelevant because a quoted scalar is never a header candidate" + }, + { + "name": "parses unquoted value shaped like an inline array header after the key", + "input": "key: foo [2]: bar", + "expected": { + "key": "foo [2]: bar" + }, + "specSection": "6", + "note": "An unquoted key cannot contain a colon, so the first colon ends the key; the bracket-and-colon shape in the remainder is value text, not an array header" + }, + { + "name": "parses unquoted value shaped like a tabular array header after the key", + "input": "a: b [2]{x,y}: 1,2", + "expected": { + "a": "b [2]{x,y}: 1,2" + }, + "specSection": "6", + "note": "An unquoted key cannot contain a colon, so the first colon ends the key; the tabular-header shape in the remainder is value text, not an array header" + }, + { + "name": "parses quoted object value with bracket text and a later colon", + "input": "content: \"accept (process.argv[2]) and greet: name\"", + "expected": { + "content": "accept (process.argv[2]) and greet: name" + }, + "specSection": "8" }, { "name": "parses quoted key with colon", @@ -108,7 +171,23 @@ "expected": { "order:id": 7 }, - "specSection": "6" + "specSection": "8" + }, + { + "name": "decodes \\uXXXX in quoted key (U+0004 control character)", + "input": "\"a\\u0004b\": 1", + "expected": { + "a\u0004b": 1 + }, + "specSection": "7.1" + }, + { + "name": "decodes \\uXXXX in quoted key (case-insensitive hex)", + "input": "\"x\\u00E9y\": 2", + "expected": { + "xéy": 2 + }, + "specSection": "7.1" }, { "name": "parses quoted key with brackets", @@ -116,7 +195,55 @@ "expected": { "[index]": 5 }, - "specSection": "6" + "specSection": "8" + }, + { + "name": "treats extra brackets after valid array segment as literal key (non-strict)", + "input": "foo[1][bar]: 10", + "options": { + "strict": false + }, + "expected": { + "foo[1][bar]": 10 + }, + "specSection": "6", + "note": "Non-whitespace [bar] between ] and : prevents array header interpretation; non-strict fall-through produces a literal key not constrained by §7.3" + }, + { + "name": "treats bracket segment without a length as literal key (non-strict)", + "input": "key[]: 1,2", + "options": { + "strict": false + }, + "expected": { + "key[]": "1,2" + }, + "specSection": "6", + "note": "An absent length prevents header interpretation; non-strict fall-through produces a literal key" + }, + { + "name": "treats non-integer bracket content as literal key (non-strict)", + "input": "foo[bar][1]: 20", + "options": { + "strict": false + }, + "expected": { + "foo[bar][1]": 20 + }, + "specSection": "6", + "note": "[bar] fails integer parsing; non-strict fall-through produces a literal key not constrained by §7.3" + }, + { + "name": "treats text between bracket segment and colon as literal key (non-strict)", + "input": "foo[2]extra: a,b", + "options": { + "strict": false + }, + "expected": { + "foo[2]extra": "a,b" + }, + "specSection": "6", + "note": "Non-whitespace content between ] and : prevents array header interpretation; non-strict fall-through produces a literal key" }, { "name": "parses quoted key with braces", @@ -124,7 +251,7 @@ "expected": { "{key}": 5 }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted key with comma", @@ -132,7 +259,7 @@ "expected": { "a,b": 1 }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted key with spaces", @@ -140,7 +267,7 @@ "expected": { "full name": "Ada" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted key with leading hyphen", @@ -148,7 +275,7 @@ "expected": { "-lead": 1 }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted key with leading and trailing spaces", @@ -156,7 +283,7 @@ "expected": { " a ": 1 }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted numeric key", @@ -164,7 +291,7 @@ "expected": { "123": "x" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses quoted empty string key", @@ -172,7 +299,7 @@ "expected": { "": 1 }, - "specSection": "6" + "specSection": "8" }, { "name": "parses dotted keys as identifiers", @@ -180,7 +307,7 @@ "expected": { "user.name": "Ada" }, - "specSection": "6" + "specSection": "8" }, { "name": "parses underscore-prefixed keys", @@ -188,7 +315,7 @@ "expected": { "_private": 1 }, - "specSection": "6" + "specSection": "8" }, { "name": "parses underscore-containing keys", @@ -196,7 +323,7 @@ "expected": { "user_name": 1 }, - "specSection": "6" + "specSection": "8" }, { "name": "unescapes newline in key", @@ -204,7 +331,7 @@ "expected": { "line\nbreak": 1 }, - "specSection": "6" + "specSection": "7.1" }, { "name": "unescapes tab in key", @@ -212,7 +339,15 @@ "expected": { "tab\there": 2 }, - "specSection": "6" + "specSection": "7.1" + }, + { + "name": "unescapes tab in key immediately followed by a colon", + "input": "\"\\t:x\": v", + "expected": { + "\t:x": "v" + }, + "specSection": "7.1" }, { "name": "unescapes quotes in key", @@ -220,7 +355,7 @@ "expected": { "he said \"hi\"": 1 }, - "specSection": "6" + "specSection": "7.1" }, { "name": "parses deeply nested objects with indentation", @@ -232,7 +367,148 @@ } } }, - "specSection": "6" + "specSection": "8" + }, + { + "name": "applies LWW for nested duplicate sibling keys in non-strict mode", + "input": "outer:\n name: Ada\n name: Bob", + "expected": { + "outer": { + "name": "Bob" + } + }, + "options": { + "strict": false + }, + "specSection": "14.3" + }, + { + "name": "applies LWW for duplicate keys within a list-item object in non-strict mode", + "input": "items[1]:\n - id: 1\n id: 2", + "expected": { + "items": [ + { + "id": 2 + } + ] + }, + "options": { + "strict": false + }, + "specSection": "14.3" + }, + { + "name": "accepts unquoted key outside the encoder key pattern (hyphen)", + "input": "foo-bar: 1", + "expected": { + "foo-bar": 1 + }, + "specSection": "7.4", + "note": "§7.3 constrains encoder output; decoders accept any token before the first unquoted colon as a literal key" + }, + { + "name": "accepts unquoted key outside the encoder key pattern (leading digit)", + "input": "2key: x", + "expected": { + "2key": "x" + }, + "specSection": "7.4" + }, + { + "name": "accepts unquoted hyphen-leading value in strict mode", + "input": "k: -x", + "expected": { + "k": "-x" + }, + "specSection": "7.4", + "note": "Encoders must quote hyphen-leading strings (§7.2); decoders accept the unquoted form as data" + }, + { + "name": "accepts unquoted colon-containing value in strict mode", + "input": "k: b:c", + "expected": { + "k": "b:c" + }, + "specSection": "7.4", + "note": "The first unquoted colon ends the key; the entire post-colon token is the value (§11.2)" + }, + { + "name": "materializes __proto__ as an ordinary own key", + "input": "__proto__: polluted", + "expected": { + "__proto__": "polluted" + }, + "specSection": "15", + "note": "Decoding MUST NOT mutate the host object model, so the decoded object carries __proto__ as an own entry" + }, + { + "name": "materializes constructor and prototype as ordinary own keys", + "input": "constructor: 1\nprototype: 2", + "expected": { + "constructor": 1, + "prototype": 2 + }, + "specSection": "15" + }, + { + "name": "materializes __proto__ opening a nested object as an ordinary own key", + "input": "__proto__:\n admin: true", + "expected": { + "__proto__": { + "admin": true + } + }, + "specSection": "15", + "note": "The classic pollution shape – the nested object becomes an own entry, never the prototype" + }, + { + "name": "materializes quoted __proto__ key as an ordinary own key", + "input": "\"__proto__\": x", + "expected": { + "__proto__": "x" + }, + "specSection": "15" + }, + { + "name": "materializes __proto__ tabular field name as ordinary own keys", + "input": "rows[2]{__proto__,x}:\n a,1\n b,2", + "expected": { + "rows": [ + { + "__proto__": "a", + "x": 1 + }, + { + "__proto__": "b", + "x": 2 + } + ] + }, + "specSection": "15" + }, + { + "name": "falls through to a key-value line when whitespace precedes the bracket segment", + "input": "foo [2]: bar,baz", + "expected": { + "foo [2]": "bar,baz" + }, + "options": { + "strict": false + }, + "specSection": "5.2", + "minSpecVersion": "4.1" + }, + { + "name": "accepts a header key outside the encoder unquoted-key pattern", + "input": "foo-bar[2]: 1,2", + "expected": { + "foo-bar": [ + 1, + 2 + ] + }, + "specSection": "7.4", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/decode/primitives.json b/tests/fixtures/decode/primitives.json index 67a64aa..2347930 100644 --- a/tests/fixtures/decode/primitives.json +++ b/tests/fixtures/decode/primitives.json @@ -1,189 +1,175 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Primitive value decoding - strings, numbers, booleans, null, unescaping", + "description": "Primitive value decoding – strings, numbers, booleans, null, unescaping", "tests": [ { "name": "parses safe unquoted string", "input": "hello", "expected": "hello", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses unquoted string with underscore and numbers", "input": "Ada_99", "expected": "Ada_99", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses empty quoted string", "input": "\"\"", "expected": "", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses quoted string with newline escape", "input": "\"line1\\nline2\"", "expected": "line1\nline2", - "specSection": "5" + "specSection": "7.1" }, { "name": "parses quoted string with tab escape", "input": "\"tab\\there\"", "expected": "tab\there", - "specSection": "5" + "specSection": "7.1" }, { "name": "parses quoted string with carriage return escape", "input": "\"return\\rcarriage\"", "expected": "return\rcarriage", - "specSection": "5" + "specSection": "7.1" }, { "name": "parses quoted string with backslash escape", "input": "\"C:\\\\Users\\\\path\"", "expected": "C:\\Users\\path", - "specSection": "5" + "specSection": "7.1" }, { "name": "parses quoted string with escaped quotes", "input": "\"say \\\"hello\\\"\"", "expected": "say \"hello\"", - "specSection": "5" + "specSection": "7.1" + }, + { + "name": "decodes \\uXXXX escape (U+0004)", + "input": "val: \"a\\u0004b\"", + "expected": { "val": "a\u0004b" }, + "specSection": "7.1" + }, + { + "name": "decodes \\uXXXX with mixed-case hex digits", + "input": "val: \"a\\u00Abb\"", + "expected": { "val": "a«b" }, + "specSection": "7.1" }, { "name": "parses Unicode string", "input": "café", "expected": "café", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses Chinese characters", "input": "你好", "expected": "你好", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses emoji", "input": "🚀", "expected": "🚀", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses string with emoji and spaces", "input": "hello 👋 world", "expected": "hello 👋 world", - "specSection": "5" + "specSection": "7.4" }, { "name": "parses positive integer", "input": "42", "expected": 42, - "specSection": "5" + "specSection": "4" }, { "name": "parses decimal number", "input": "3.14", "expected": 3.14, - "specSection": "5" + "specSection": "4" }, { "name": "parses negative integer", "input": "-7", "expected": -7, - "specSection": "5" + "specSection": "4" }, { "name": "parses true", "input": "true", "expected": true, - "specSection": "5" + "specSection": "4" }, { "name": "parses false", "input": "false", "expected": false, - "specSection": "5" + "specSection": "4" }, { "name": "parses null", "input": "null", "expected": null, - "specSection": "5" - }, - { - "name": "treats unquoted leading-zero number as string", - "input": "05", - "expected": "05", - "specSection": "5", - "note": "Leading zeros make it a string" - }, - { - "name": "treats unquoted multi-leading-zero as string", - "input": "007", - "expected": "007", - "specSection": "5" - }, - { - "name": "treats unquoted octal-like as string", - "input": "0123", - "expected": "0123", - "specSection": "5" - }, - { - "name": "treats leading-zero in object value as string", - "input": "a: 05", - "expected": { "a": "05" }, - "specSection": "5" - }, - { - "name": "treats leading-zeros in array as strings", - "input": "nums[3]: 05,007,0123", - "expected": { "nums": ["05", "007", "0123"] }, - "specSection": "5" + "specSection": "4" }, { "name": "respects ambiguity quoting for true", "input": "\"true\"", "expected": "true", - "specSection": "5", - "note": "Quoted primitive remains string" + "specSection": "7.4" }, { "name": "respects ambiguity quoting for false", "input": "\"false\"", "expected": "false", - "specSection": "5" + "specSection": "7.4" }, { "name": "respects ambiguity quoting for null", "input": "\"null\"", "expected": "null", - "specSection": "5" + "specSection": "7.4" }, { "name": "respects ambiguity quoting for integer", "input": "\"42\"", "expected": "42", - "specSection": "5" + "specSection": "7.4" }, { "name": "respects ambiguity quoting for negative decimal", "input": "\"-3.14\"", "expected": "-3.14", - "specSection": "5" + "specSection": "7.4" }, { "name": "respects ambiguity quoting for scientific notation", "input": "\"1e-6\"", "expected": "1e-6", - "specSection": "5" + "specSection": "7.4" }, { "name": "respects ambiguity quoting for leading-zero", "input": "\"05\"", "expected": "05", - "specSection": "5" + "specSection": "7.4" + }, + { + "name": "decodes supplementary scalar (U+1F680) in quoted string as literal UTF-8", + "input": "\"🚀 launch\"", + "expected": "🚀 launch", + "specSection": "7.1" } ] } diff --git a/tests/fixtures/decode/root-form.json b/tests/fixtures/decode/root-form.json new file mode 100644 index 0000000..32bc71f --- /dev/null +++ b/tests/fixtures/decode/root-form.json @@ -0,0 +1,83 @@ +{ + "version": "4.0", + "category": "decode", + "description": "Root form detection – empty document, single primitive, literal empty array", + "tests": [ + { + "name": "parses empty document as empty object", + "input": "", + "expected": {}, + "options": { + "strict": true + }, + "specSection": "5" + }, + { + "name": "parses single primitive string at root as primitive", + "input": "hello", + "expected": "hello", + "options": { + "strict": true + }, + "specSection": "5" + }, + { + "name": "parses single primitive number at root as primitive", + "input": "42", + "expected": 42, + "options": { + "strict": true + }, + "specSection": "5" + }, + { + "name": "parses single primitive boolean at root as primitive", + "input": "true", + "expected": true, + "options": { + "strict": true + }, + "specSection": "5" + }, + { + "name": "parses literal [] at root as empty array", + "input": "[]", + "expected": [], + "options": { + "strict": true + }, + "specSection": "5" + }, + { + "name": "throws on trailing content after a root array", + "input": "[2]: 1,2\njunk: 3", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "5", + "note": "The root form spans the whole document; trailing lines must not be silently discarded" + }, + { + "name": "throws on trailing content after a keyed tabular root", + "input": "[2:]{v}:\n a: 1\n b: 2\njunk: 3", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "5" + }, + { + "name": "throws on trailing content after a root empty array", + "input": "[]\njunk: 3", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "5" + } + ] +} diff --git a/tests/fixtures/decode/validation-errors.json b/tests/fixtures/decode/validation-errors.json index 6e3247a..e3ad253 100644 --- a/tests/fixtures/decode/validation-errors.json +++ b/tests/fixtures/decode/validation-errors.json @@ -1,63 +1,508 @@ { - "version": "1.3", + "version": "4.0", "category": "decode", - "description": "Validation errors - length mismatches, invalid escapes, syntax errors, delimiter mismatches", + "description": "Validation errors – length mismatches, invalid escapes, syntax errors, delimiter mismatches", "tests": [ { - "name": "throws on array length mismatch (inline primitives - too many)", + "name": "throws when inline values outnumber the declared length", "input": "tags[2]: a,b,c", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.1" }, { - "name": "throws on array length mismatch (list format - too many)", + "name": "throws when list items outnumber the declared length", "input": "items[1]:\n - 1\n - 2", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.1" }, { - "name": "throws when tabular row value count does not match header field count", + "name": "throws on tabular row value count mismatch with header field count", "input": "items[2]{id,name}:\n 1,Ada\n 2", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.1" }, { - "name": "throws when tabular row count does not match header length", + "name": "throws on tabular row count mismatch with header length", "input": "[1]{id}:\n 1\n 2", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.1" }, { "name": "throws on invalid escape sequence", "input": "\"a\\x\"", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.2" + }, + { + "name": "throws on truncated unicode escape \\u00b", + "input": "val: \"a\\u00b\"", + "expected": null, + "shouldError": true, + "specSection": "7.1" + }, + { + "name": "throws on lone surrogate code point \\uD800", + "input": "val: \"a\\uD800b\"", + "expected": null, + "shouldError": true, + "specSection": "7.1" }, { "name": "throws on unterminated string", "input": "\"unterminated", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.2" }, { "name": "throws on missing colon in key-value context", "input": "a:\n user", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.2" + }, + { + "name": "throws on two primitives at root depth in strict mode", + "input": "hello\nworld", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "5" }, { - "name": "throws on delimiter mismatch (header declares tab, row uses comma)", + "name": "throws on row width mismatch when rows use a different delimiter than the active delimiter", "input": "items[2\t]{a\tb}:\n 1,2\n 3,4", "expected": null, "shouldError": true, - "specSection": "9" + "specSection": "14.1", + "note": "Active delimiter is tab; rows using comma each parse as 1 value, failing the row width check" + }, + { + "name": "throws on mismatched delimiter between bracket and brace fields", + "input": "items[2\t]{a,b}:\n 1\t2\n 3\t4", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6" + }, + { + "name": "throws on extra brackets between bracket segment and colon in strict mode", + "input": "foo[1][bar]: 10", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "Non-whitespace content between ] and : must error in strict mode; the fall-through is non-strict only" + }, + { + "name": "throws on text between bracket segment and colon in strict mode", + "input": "foo[2]extra: a,b", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6" + }, + { + "name": "throws on non-integer bracket segment in strict mode", + "input": "foo[bar]: 10", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6" + }, + { + "name": "throws on duplicate sibling keys in strict mode", + "input": "name: Ada\nname: Bob", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.3" + }, + { + "name": "throws on array header missing colon", + "input": "items[2]{id,name}\n 1,Ada\n 2,Bob", + "expected": null, + "shouldError": true, + "specSection": "6" + }, + { + "name": "throws when inline values fall short of the declared length", + "input": "tags[3]: a,b", + "expected": null, + "shouldError": true, + "specSection": "14.1" + }, + { + "name": "throws when list items fall short of the declared length", + "input": "items[2]:\n - a", + "expected": null, + "shouldError": true, + "specSection": "14.1" + }, + { + "name": "throws on bracket length with leading zeros in strict mode", + "input": "items[03]: a,b,c", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "[03] is not a canonical non-negative integer length; decoders MUST NOT interpret it as a bracket segment" + }, + { + "name": "throws on negative bracket length in strict mode", + "input": "items[-1]: a,b,c", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "[-1] is not a non-negative integer length; decoders MUST NOT interpret it as a bracket segment" + }, + { + "name": "throws on decimal bracket length in strict mode", + "input": "x[3.7]: a,b,c", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "[3.7] is not an integer length – lengths are DIGIT-only – so decoders MUST NOT interpret it as a bracket segment" + }, + { + "name": "throws on bracket length with plus sign in strict mode", + "input": "x[+3]: a,b,c", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "[+3] carries a sign and is not a non-negative integer length; decoders MUST NOT interpret it as a bracket segment" + }, + { + "name": "throws on bracket length in exponent form in strict mode", + "input": "x[1e1]: 1,2,3,4,5,6,7,8,9,10", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "[1e1] is exponent notation, not a DIGIT-only integer length; decoders MUST NOT interpret it as a bracket segment" + }, + { + "name": "throws on whitespace between bracket segment and colon in strict mode", + "input": "items[2] :\n 1,2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "No whitespace is permitted between ] and the colon/field list; any content there prevents header interpretation" + }, + { + "name": "throws on whitespace between bracket segment and field list in strict mode", + "input": "items[2] {a,b}:\n 1,2\n 3,4", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "No whitespace is permitted between ] and the field list; mirrors the ]-to-colon rule" + }, + { + "name": "throws on nested duplicate sibling keys in strict mode", + "input": "outer:\n name: Ada\n name: Bob", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.3" + }, + { + "name": "throws on duplicate keys within a list-item object in strict mode", + "input": "items[1]:\n - id: 1\n id: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.3" + }, + { + "name": "throws on bracket segment without a length", + "input": "key[]: 1,2", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "An absent length is a malformed bracket segment, same class as [03] and [-1]; only key: [] (bracket after the colon) means an empty array" + }, + { + "name": "throws on row cell count not matching the leaf-field count", + "input": "orders[1]{id,customer{name,country}}:\n 1,Ada", + "expected": null, + "shouldError": true, + "specSection": "14.1", + "note": "The header declares three leaf fields (id, customer.name, customer.country); the row has two cells" + }, + { + "name": "throws on empty field list in strict mode", + "input": "items[1]{}:\n 1", + "expected": null, + "shouldError": true, + "specSection": "14.2" + }, + { + "name": "throws on empty nested field group in strict mode", + "input": "items[1]{id,meta{}}:\n 1", + "expected": null, + "shouldError": true, + "specSection": "14.2", + "note": "A field list must contain at least one field entry at every nesting level" + }, + { + "name": "throws on unmatched brace in field list in strict mode", + "input": "items[1]{id,customer{name:\n 1,Ada", + "expected": null, + "shouldError": true, + "specSection": "14.2" + }, + { + "name": "throws on duplicate field names at the same field-list level in strict mode", + "input": "items[1]{a,a{x}}:\n 1,2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.3", + "note": "A bare field and a nested field group sharing one name yield duplicate sibling keys in every decoded row" + }, + { + "name": "throws on entry row count mismatch with keyed header length", + "input": "m[2:]{v}:\n a: 1", + "expected": null, + "shouldError": true, + "specSection": "14.1" + }, + { + "name": "throws on entry row cell count not matching the leaf-field count", + "input": "m[1:]{a,b}:\n k: 1", + "expected": null, + "shouldError": true, + "specSection": "14.1" + }, + { + "name": "throws on an entry row with no cells after the entry key", + "input": "m[1:]{v}:\n a:", + "expected": null, + "shouldError": true, + "specSection": "14.1", + "note": "A bare entrykey: has zero cells, and a field list always declares at least one leaf field (§9.5)" + }, + { + "name": "throws on keyed header without a field list in strict mode", + "input": "m[2:]:\n a: 1\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.2", + "note": "A keyed header without a field list declares no cells at all" + }, + { + "name": "throws on keyed marker after the delimiter symbol in strict mode", + "input": "m[2|:]{v}:\n a: 1\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "The keyed colon must immediately follow the length and precede the delimiter symbol: [2:|]" + }, + { + "name": "throws on keyed marker with leading-zero length in strict mode", + "input": "m[03:]{v}:\n a: 1\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6" + }, + { + "name": "throws on whitespace before the keyed marker in strict mode", + "input": "m[2 :]{v}:\n a: 1\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6" + }, + { + "name": "throws on explicit comma delimiter after the keyed marker in strict mode", + "input": "m[2:,]{v}:\n a: 1\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "6", + "note": "Comma is never written as a delimiter symbol; [N:] already declares it" + }, + { + "name": "throws on inline content after a keyed header colon in strict mode", + "input": "m[1:]{v}: x\n a: 1", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.2", + "note": "A keyed header carries no inline entries" + }, + { + "name": "throws on a line without an unquoted colon at entry depth in strict mode", + "input": "m[2:]{v}:\n a: 1\n 5", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.2" + }, + { + "name": "throws on duplicate entry keys in strict mode", + "input": "m[2:]{v}:\n a: 1\n a: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.3" + }, + { + "name": "throws on a keyless keyed header as a list item in strict mode", + "input": "items[1]:\n - [2:]{v}:\n a: 1\n b: 2", + "expected": null, + "shouldError": true, + "options": { + "strict": true + }, + "specSection": "14.2", + "note": "The keyless keyed header is valid only as the document's root header (§5, §9.5)" + }, + { + "name": "throws on inner array item count not matching its declared length", + "input": "pairs[2]:\n - [3]: 1,2\n - [2]: 3,4", + "expected": null, + "shouldError": true, + "specSection": "9.2" + }, + { + "name": "throws on keyless array header in object field position", + "input": "a:\n [2]: 1,2", + "expected": null, + "shouldError": true, + "specSection": "6" + }, + { + "name": "throws on keyless array header after a depth-0 field", + "input": "a: 1\n[2]: x,y", + "expected": null, + "shouldError": true, + "specSection": "6", + "note": "Keyless headers are valid only as the document's root header or as list items" + }, + { + "name": "throws on keyless fields-bearing header as list item", + "input": "items[1]:\n - [2]{x}:\n 1\n 2", + "expected": null, + "shouldError": true, + "specSection": "6" + }, + { + "name": "throws on inline content after tabular header", + "input": "items[2]{a,b}: 1,2", + "expected": null, + "shouldError": true, + "specSection": "6", + "note": "A fields-bearing header carries no inline content; decoding the values as an inline array would silently drop the field list" + }, + { + "name": "throws on inline content after root tabular header", + "input": "[2]{a,b}: 1,2", + "expected": null, + "shouldError": true, + "specSection": "6" + }, + { + "name": "throws on whitespace between a key and its bracket segment", + "input": "foo [2]: bar,baz", + "expected": null, + "shouldError": true, + "specSection": "6", + "minSpecVersion": "4.1" + }, + { + "name": "throws on duplicate field names in a zero-row tabular header", + "input": "items[0]{a,a}:", + "expected": null, + "shouldError": true, + "specSection": "9.3", + "note": "A duplicate field name is a header defect, diagnosed from the header line alone", + "minSpecVersion": "4.1" + }, + { + "name": "throws on characters after a closing quote in non-strict mode", + "input": "k: \"abc\" def", + "expected": null, + "shouldError": true, + "options": { + "strict": false + }, + "specSection": "7.4", + "note": "The quoted-token boundary rule applies in strict and non-strict mode alike", + "minSpecVersion": "4.1" + }, + { + "name": "throws on a bare token line inside an array scope in non-strict mode", + "input": "k[1]:\n - x: 1\n bare", + "expected": null, + "shouldError": true, + "options": { + "strict": false + }, + "specSection": "5.2", + "note": "A scalar line outside root primitive position is an error in strict and non-strict mode alike", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/decode/whitespace.json b/tests/fixtures/decode/whitespace.json new file mode 100644 index 0000000..fc22ed3 --- /dev/null +++ b/tests/fixtures/decode/whitespace.json @@ -0,0 +1,129 @@ +{ + "version": "4.0", + "category": "decode", + "description": "Whitespace tolerance in decoding – surrounding spaces around delimiters and values", + "tests": [ + { + "name": "tolerates spaces around commas in inline arrays", + "input": "tags[3]: a , b , c", + "expected": { + "tags": ["a", "b", "c"] + }, + "specSection": "12", + "note": "Trimming removes U+0020 only, never other Unicode whitespace" + }, + { + "name": "tolerates spaces around pipes in inline arrays", + "input": "tags[3|]: a | b | c", + "expected": { + "tags": ["a", "b", "c"] + }, + "specSection": "12" + }, + { + "name": "tolerates spaces around tabs in inline arrays", + "input": "tags[3\t]: a \t b \t c", + "expected": { + "tags": ["a", "b", "c"] + }, + "specSection": "12" + }, + { + "name": "tolerates leading and trailing spaces in tabular row values", + "input": "items[2]{id,name}:\n 1 , Ada \n 2 , Bob ", + "expected": { + "items": [ + { "id": 1, "name": "Ada" }, + { "id": 2, "name": "Bob" } + ] + }, + "specSection": "12" + }, + { + "name": "tolerates spaces around delimiters with quoted values", + "input": "items[3]: \"a\" , \"b\" , \"c\"", + "expected": { + "items": ["a", "b", "c"] + }, + "specSection": "12" + }, + { + "name": "parses empty tokens as empty string", + "input": "items[3]: a,,c", + "expected": { + "items": ["a", "", "c"] + }, + "specSection": "9.1" + }, + { + "name": "preserves NBSP-leading unquoted value", + "input": "k: \u00a0v", + "expected": { + "k": "\u00a0v" + }, + "specSection": "12", + "note": "Token trimming removes U+0020 only; NBSP is part of the token" + }, + { + "name": "preserves NBSP around inline array tokens", + "input": "items[2]: \u00a0a\u00a0,b", + "expected": { + "items": ["\u00a0a\u00a0", "b"] + }, + "specSection": "12", + "note": "A host trim() that strips the full Unicode whitespace set corrupts this round-trip" + }, + { + "name": "decodes CRLF line terminators", + "input": "a: 1\r\nb: 2\r\n", + "expected": { "a": 1, "b": 2 }, + "specSection": "12" + }, + { + "name": "decodes tabular rows with CRLF line terminators", + "input": "items[2]{a,b}:\r\n 1,2\r\n 3,4\r\n", + "expected": { "items": [{ "a": 1, "b": 2 }, { "a": 3, "b": 4 }] }, + "specSection": "12" + }, + { + "name": "keeps an escaped carriage return inside a quoted value", + "input": "a: \"x\\ry\"\r\n", + "expected": { "a": "x\ry" }, + "specSection": "12", + "note": "Only a CR at the end of a line is part of the line terminator" + }, + { + "name": "treats a carriage-return-only line as blank", + "input": "a: 1\n\r\nb: 2", + "expected": { "a": 1, "b": 2 }, + "specSection": "12" + }, + { + "name": "strips a trailing carriage return at end of input", + "input": "a: 1\r", + "expected": { "a": 1 }, + "specSection": "12" + }, + { + "name": "strips a leading byte-order mark", + "input": "\ufeffa: 1", + "expected": { + "a": 1 + }, + "specSection": "12", + "minSpecVersion": "4.1" + }, + { + "name": "treats a hyphen followed by trailing spaces as the bare marker", + "input": "a[1]:\n - ", + "expected": { + "a": [ + {} + ] + }, + "specSection": "12", + "note": "Trailing spaces are stripped before line classification", + "minSpecVersion": "4.1" + } + ] +} diff --git a/tests/fixtures/encode/arrays-nested.json b/tests/fixtures/encode/arrays-nested.json index c7c47a4..69c570e 100644 --- a/tests/fixtures/encode/arrays-nested.json +++ b/tests/fixtures/encode/arrays-nested.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Nested and mixed array encoding - arrays of arrays, mixed type arrays, root arrays", + "description": "Nested and mixed array encoding – arrays of arrays, mixed type arrays, root arrays", "tests": [ { "name": "encodes nested arrays of primitives", @@ -9,7 +9,7 @@ "pairs": [["a", "b"], ["c", "d"]] }, "expected": "pairs[2]:\n - [2]: a,b\n - [2]: c,d", - "specSection": "7.3" + "specSection": "9.2" }, { "name": "quotes strings containing delimiters in nested arrays", @@ -17,7 +17,7 @@ "pairs": [["a", "b"], ["c,d", "e:f", "true"]] }, "expected": "pairs[2]:\n - [2]: a,b\n - [3]: \"c,d\",\"e:f\",\"true\"", - "specSection": "7.3" + "specSection": "9.2" }, { "name": "encodes empty inner arrays", @@ -25,7 +25,7 @@ "pairs": [[], []] }, "expected": "pairs[2]:\n - [0]:\n - [0]:", - "specSection": "7.3" + "specSection": "9.2" }, { "name": "encodes mixed-length inner arrays", @@ -33,37 +33,43 @@ "pairs": [[1], [2, 3]] }, "expected": "pairs[2]:\n - [1]: 1\n - [2]: 2,3", - "specSection": "7.3" + "specSection": "9.2" }, { "name": "encodes root-level primitive array", "input": ["x", "y", "true", true, 10], "expected": "[5]: x,y,\"true\",true,10", - "specSection": "7" + "specSection": "9.1" }, { - "name": "encodes root-level array of uniform objects in tabular format", + "name": "encodes root-level array of uniform objects in tabular form", "input": [{ "id": 1 }, { "id": 2 }], "expected": "[2]{id}:\n 1\n 2", - "specSection": "7.2" + "specSection": "9.3" }, { - "name": "encodes root-level array of non-uniform objects in list format", + "name": "encodes root-level array of non-uniform objects in list form", "input": [{ "id": 1 }, { "id": 2, "name": "Ada" }], "expected": "[2]:\n - id: 1\n - id: 2\n name: Ada", - "specSection": "7" + "specSection": "9.4" }, { - "name": "encodes empty root-level array", - "input": [], - "expected": "[0]:", - "specSection": "7" + "name": "encodes root-level array mixing primitive, object, and array of objects in list form", + "input": ["summary", { "id": 1, "name": "Ada" }, [{ "id": 2 }, { "status": "draft" }]], + "expected": "[3]:\n - summary\n - id: 1\n name: Ada\n - [2]:\n - id: 2\n - status: draft", + "specSection": "9.4" }, { "name": "encodes root-level arrays of arrays", "input": [[1, 2], []], "expected": "[2]:\n - [2]: 1,2\n - [0]:", - "specSection": "7.3" + "specSection": "9.2" + }, + { + "name": "encodes empty root-level array", + "input": [], + "expected": "[]", + "specSection": "9.1" }, { "name": "encodes complex nested structure", @@ -76,24 +82,51 @@ "prefs": [] } }, - "expected": "user:\n id: 123\n name: Ada\n tags[2]: reading,gaming\n active: true\n prefs[0]:", - "specSection": "6" + "expected": "user:\n id: 123\n name: Ada\n tags[2]: reading,gaming\n active: true\n prefs: []", + "specSection": "8" }, { - "name": "uses list format for arrays mixing primitives and objects", + "name": "uses list form for arrays mixing primitives and objects", "input": { "items": [1, { "a": 1 }, "text"] }, "expected": "items[3]:\n - 1\n - a: 1\n - text", - "specSection": "7.3" + "specSection": "9.4" }, { - "name": "uses list format for arrays mixing objects and arrays", + "name": "uses list form for arrays mixing objects and arrays", "input": { "items": [{ "a": 1 }, [1, 2]] }, "expected": "items[2]:\n - a: 1\n - [2]: 1,2", - "specSection": "7.3" + "specSection": "9.4" + }, + { + "name": "quotes hash-leading string as list item", + "input": { + "items": ["#x", { "a": 1 }] + }, + "expected": "items[2]:\n - \"#x\"\n - a: 1", + "specSection": "7.2" + }, + { + "name": "uses list form for a tabular-eligible array in list-item position", + "input": { + "a": [ + [ + { + "x": 1 + }, + { + "x": 2 + } + ] + ] + }, + "expected": "a[1]:\n - [2]:\n - x: 1\n - x: 2", + "specSection": "9.3", + "note": "A keyless fields-bearing header is valid only at the document root", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/encode/arrays-objects.json b/tests/fixtures/encode/arrays-objects.json index ffca4f0..ad8342b 100644 --- a/tests/fixtures/encode/arrays-objects.json +++ b/tests/fixtures/encode/arrays-objects.json @@ -1,10 +1,10 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Arrays of objects encoding - list format for non-uniform objects and complex structures", + "description": "Arrays of objects encoding – list form for non-uniform objects and complex structures", "tests": [ { - "name": "uses list format for objects with different fields", + "name": "uses list form for objects with different fields", "input": { "items": [ { "id": 1, "name": "First" }, @@ -12,105 +12,127 @@ ] }, "expected": "items[2]:\n - id: 1\n name: First\n - id: 2\n name: Second\n extra: true", - "specSection": "7" + "specSection": "9.4" }, { - "name": "uses list format for objects with nested values", + "name": "uses list form for objects with nested values", "input": { "items": [ - { "id": 1, "nested": { "x": 1 } } + { "id": 1, "nested": { "x": 1 } }, + { "id": 2, "nested": { "y": 2 } } ] }, - "expected": "items[1]:\n - id: 1\n nested:\n x: 1", - "specSection": "7" + "expected": "items[2]:\n - id: 1\n nested:\n x: 1\n - id: 2\n nested:\n y: 2", + "specSection": "9.4", + "note": "The nested column mixes key sets {x} and {y}, so it is not nested-uniform (§9.3), so the array uses list form" }, { "name": "preserves field order in list items - array first", "input": { - "items": [{ "nums": [1, 2, 3], "name": "test" }] + "items": [{ "nums": [1, 2, 3], "name": "Ada" }] }, - "expected": "items[1]:\n - nums[3]: 1,2,3\n name: test", - "specSection": "7" + "expected": "items[1]:\n - nums[3]: 1,2,3\n name: Ada", + "specSection": "10" }, { "name": "preserves field order in list items - primitive first", "input": { - "items": [{ "name": "test", "nums": [1, 2, 3] }] + "items": [{ "name": "Ada", "nums": [1, 2, 3] }] }, - "expected": "items[1]:\n - name: test\n nums[3]: 1,2,3", - "specSection": "7" + "expected": "items[1]:\n - name: Ada\n nums[3]: 1,2,3", + "specSection": "10" }, { - "name": "uses list format for objects containing arrays of arrays", + "name": "uses list form for objects containing arrays of arrays", "input": { "items": [ { "matrix": [[1, 2], [3, 4]], "name": "grid" } ] }, - "expected": "items[1]:\n - matrix[2]:\n - [2]: 1,2\n - [2]: 3,4\n name: grid", - "specSection": "7" + "expected": "items[1]:\n - matrix[2]:\n - [2]: 1,2\n - [2]: 3,4\n name: grid", + "specSection": "10" }, { - "name": "uses tabular format for nested uniform object arrays", + "name": "uses tabular form for nested uniform object arrays", "input": { "items": [ { "users": [{ "id": 1, "name": "Ada" }, { "id": 2, "name": "Bob" }], "status": "active" } ] }, - "expected": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob\n status: active", - "specSection": "7" + "expected": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob\n status: active", + "specSection": "10", + "note": "Tabular header on hyphen line with rows at depth +2 and sibling fields at depth +1" }, { - "name": "uses list format for nested object arrays with mismatched keys", + "name": "uses list form for nested object arrays with mismatched keys", "input": { "items": [ { "users": [{ "id": 1, "name": "Ada" }, { "id": 2 }], "status": "active" } ] }, - "expected": "items[1]:\n - users[2]:\n - id: 1\n name: Ada\n - id: 2\n status: active", - "specSection": "7" + "expected": "items[1]:\n - users[2]:\n - id: 1\n name: Ada\n - id: 2\n status: active", + "specSection": "10" }, { - "name": "uses list format for objects with multiple array fields", + "name": "uses list form for objects with multiple array fields", "input": { "items": [{ "nums": [1, 2], "tags": ["a", "b"], "name": "test" }] }, "expected": "items[1]:\n - nums[2]: 1,2\n tags[2]: a,b\n name: test", - "specSection": "7" + "specSection": "10" }, { - "name": "uses list format for objects with only array fields", + "name": "uses list form for objects with only array fields", "input": { "items": [{ "nums": [1, 2, 3], "tags": ["a", "b"] }] }, "expected": "items[1]:\n - nums[3]: 1,2,3\n tags[2]: a,b", - "specSection": "7" + "specSection": "10" }, { - "name": "encodes objects with empty arrays in list format", + "name": "encodes objects with empty arrays in list form", "input": { "items": [ - { "name": "test", "data": [] } + { "name": "Ada", "data": [] } ] }, - "expected": "items[1]:\n - name: test\n data[0]:", - "specSection": "7" + "expected": "items[1]:\n - name: Ada\n data: []", + "specSection": "10" }, { - "name": "places first field of nested tabular arrays on hyphen line", + "name": "uses canonical encoding for multi-field list-item objects with tabular arrays", "input": { "items": [{ "users": [{ "id": 1 }, { "id": 2 }], "note": "x" }] }, - "expected": "items[1]:\n - users[2]{id}:\n 1\n 2\n note: x", - "specSection": "7" + "expected": "items[1]:\n - users[2]{id}:\n 1\n 2\n note: x", + "specSection": "10", + "note": "Tabular header on hyphen line with rows at depth +2 and sibling fields at depth +1" + }, + { + "name": "uses canonical encoding for single-field list-item tabular arrays", + "input": { + "items": [{ "users": [{ "id": 1, "name": "Ada" }, { "id": 2, "name": "Bob" }] }] + }, + "expected": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob", + "specSection": "10", + "note": "Tabular header on hyphen line with rows at depth +2" }, { "name": "places empty arrays on hyphen line when first", "input": { "items": [{ "data": [], "name": "x" }] }, - "expected": "items[1]:\n - data[0]:\n name: x", - "specSection": "7" + "expected": "items[1]:\n - data: []\n name: x", + "specSection": "10" + }, + { + "name": "encodes empty object list items as bare hyphen", + "input": { + "items": ["first", "second", {}] + }, + "expected": "items[3]:\n - first\n - second\n -", + "specSection": "10", + "note": "Empty object list items encode as a single \"-\" line at the list-item depth" }, { "name": "uses field order from first object for tabular headers", @@ -121,10 +143,10 @@ ] }, "expected": "items[2]{a,b,c}:\n 1,2,3\n 10,20,30", - "specSection": "7.2" + "specSection": "9.3" }, { - "name": "uses list format when one object has nested column", + "name": "uses list form when one object has nested field", "input": { "items": [ { "id": 1, "data": "string" }, @@ -132,7 +154,38 @@ ] }, "expected": "items[2]:\n - id: 1\n data: string\n - id: 2\n data:\n nested: true", - "specSection": "7" + "specSection": "9.4" + }, + { + "name": "uses list form for arrays containing empty objects", + "input": { + "items": [{}, {}] + }, + "expected": "items[2]:\n -\n -", + "specSection": "9.4", + "note": "Empty objects are excluded from tabular detection (§9.3), so the array uses list form with bare hyphen markers (§10)" + }, + { + "name": "encodes a keyed-eligible object in a tabular column as a nested field group", + "input": { + "a": [ + { + "t": { + "p": { + "x": 1 + }, + "q": { + "x": 2 + } + }, + "other": 9 + } + ] + }, + "expected": "a[1]{t{p{x},q{x}},other}:\n 1,2,9", + "specSection": "9.5", + "note": "Keyed tabular form is mandated in object-field and root positions, not in a column", + "minSpecVersion": "4.1" } ] } diff --git a/tests/fixtures/encode/arrays-primitive.json b/tests/fixtures/encode/arrays-primitive.json index 2601e5a..5644091 100644 --- a/tests/fixtures/encode/arrays-primitive.json +++ b/tests/fixtures/encode/arrays-primitive.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Primitive array encoding - inline arrays of strings, numbers, booleans", + "description": "Primitive array encoding – inline arrays of strings, numbers, booleans", "tests": [ { "name": "encodes string arrays inline", @@ -9,7 +9,7 @@ "tags": ["reading", "gaming"] }, "expected": "tags[2]: reading,gaming", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "encodes number arrays inline", @@ -17,7 +17,7 @@ "nums": [1, 2, 3] }, "expected": "nums[3]: 1,2,3", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "encodes mixed primitive arrays inline", @@ -25,15 +25,31 @@ "data": ["x", "y", true, 10] }, "expected": "data[4]: x,y,true,10", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "encodes empty arrays", "input": { "items": [] }, - "expected": "items[0]:", - "specSection": "7.1" + "expected": "items: []", + "specSection": "9.1" + }, + { + "name": "encodes empty string keys for inline arrays", + "input": { + "": [1, 2, 3] + }, + "expected": "\"\"[3]: 1,2,3", + "specSection": "9.1" + }, + { + "name": "encodes empty string keys for empty arrays", + "input": { + "": [] + }, + "expected": "\"\": []", + "specSection": "9.1" }, { "name": "encodes empty string in single-item array", @@ -41,7 +57,7 @@ "items": [""] }, "expected": "items[1]: \"\"", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "encodes empty string in multi-item array", @@ -49,7 +65,7 @@ "items": ["a", "", "b"] }, "expected": "items[3]: a,\"\",b", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "encodes whitespace-only strings in arrays", @@ -57,7 +73,7 @@ "items": [" ", " "] }, "expected": "items[2]: \" \",\" \"", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "quotes array strings with comma", @@ -65,7 +81,7 @@ "items": ["a", "b,c", "d:e"] }, "expected": "items[3]: a,\"b,c\",\"d:e\"", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "quotes strings that look like booleans in arrays", @@ -73,7 +89,7 @@ "items": ["x", "true", "42", "-3.14"] }, "expected": "items[4]: x,\"true\",\"42\",\"-3.14\"", - "specSection": "7.1" + "specSection": "9.1" }, { "name": "quotes strings with structural meanings in arrays", @@ -81,7 +97,15 @@ "items": ["[5]", "- item", "{key}"] }, "expected": "items[3]: \"[5]\",\"- item\",\"{key}\"", - "specSection": "7.1" + "specSection": "9.1" + }, + { + "name": "quotes hash-leading string in inline array", + "input": { + "tags": ["#a", "b"] + }, + "expected": "tags[2]: \"#a\",b", + "specSection": "7.2" } ] } diff --git a/tests/fixtures/encode/arrays-tabular.json b/tests/fixtures/encode/arrays-tabular.json index a04116f..2365042 100644 --- a/tests/fixtures/encode/arrays-tabular.json +++ b/tests/fixtures/encode/arrays-tabular.json @@ -1,10 +1,10 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Tabular array encoding - arrays of uniform objects with primitive values", + "description": "Tabular array encoding – arrays of uniform objects with primitive values", "tests": [ { - "name": "encodes arrays of similar objects in tabular format", + "name": "encodes arrays of uniform objects in tabular form", "input": { "items": [ { "sku": "A1", "qty": 2, "price": 9.99 }, @@ -12,10 +12,10 @@ ] }, "expected": "items[2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5", - "specSection": "7.2" + "specSection": "9.3" }, { - "name": "encodes null values in tabular format", + "name": "encodes null values in tabular form", "input": { "items": [ { "id": 1, "value": null }, @@ -23,7 +23,7 @@ ] }, "expected": "items[2]{id,value}:\n 1,null\n 2,test", - "specSection": "7.2" + "specSection": "9.3" }, { "name": "quotes strings containing delimiters in tabular rows", @@ -34,7 +34,7 @@ ] }, "expected": "items[2]{sku,desc,qty}:\n \"A,1\",cool,2\n B2,\"wip: test\",1", - "specSection": "7.2" + "specSection": "9.3" }, { "name": "quotes ambiguous strings in tabular rows", @@ -45,7 +45,7 @@ ] }, "expected": "items[2]{id,status}:\n 1,\"true\"\n 2,\"false\"", - "specSection": "7.2" + "specSection": "9.3" }, { "name": "encodes tabular arrays with keys needing quotes", @@ -56,7 +56,127 @@ ] }, "expected": "items[2]{\"order:id\",\"full name\"}:\n 1,Ada\n 2,Bob", - "specSection": "7.2" + "specSection": "9.3" + }, + { + "name": "encodes tabular arrays with empty string keys", + "input": { + "": [ + { "id": 1, "name": "Ada" }, + { "id": 2, "name": "Bob" } + ] + }, + "expected": "\"\"[2]{id,name}:\n 1,Ada\n 2,Bob", + "specSection": "9.3" + }, + { + "name": "quotes hash-leading string in tabular cell", + "input": { + "items": [{ "tag": "#a" }, { "tag": "b" }] + }, + "expected": "items[2]{tag}:\n \"#a\"\n b", + "specSection": "7.2", + "note": "Unquoted, the first row would read as a comment line on decode" + }, + { + "name": "collapses a uniform nested object column into a nested field group", + "input": { + "orders": [ + { "id": 1, "customer": { "name": "Ada", "country": "DK" }, "total": 99 }, + { "id": 2, "customer": { "name": "Bob", "country": "UK" }, "total": 149 } + ] + }, + "expected": "orders[2]{id,customer{name,country},total}:\n 1,Ada,DK,99\n 2,Bob,UK,149", + "specSection": "9.3" + }, + { + "name": "collapses sibling nested field groups with depth-first row layout", + "input": { + "shipments": [ + { "id": "s1", "sender": { "name": "ACME", "city": "Berlin" }, "receiver": { "name": "Globex", "city": "Oslo" } } + ] + }, + "expected": "shipments[1]{id,sender{name,city},receiver{name,city}}:\n s1,ACME,Berlin,Globex,Oslo", + "specSection": "9.3" + }, + { + "name": "collapses nested field groups recursively without a depth cap", + "input": { + "items": [ + { "id": 1, "geo": { "point": { "lat": 1.5, "lon": 2.5 } } }, + { "id": 2, "geo": { "point": { "lat": 3, "lon": 4 } } } + ] + }, + "expected": "items[2]{id,geo{point{lat,lon}}}:\n 1,1.5,2.5\n 2,3,4", + "specSection": "9.3" + }, + { + "name": "uses the active delimiter inside nested field groups", + "input": { + "orders": [ + { "id": 1, "customer": { "name": "Ada", "country": "DK" }, "total": 99 }, + { "id": 2, "customer": { "name": "Bob", "country": "UK" }, "total": 149 } + ] + }, + "options": { "delimiter": "|" }, + "expected": "orders[2|]{id|customer{name|country}|total}:\n 1|Ada|DK|99\n 2|Bob|UK|149", + "specSection": "9.3" + }, + { + "name": "quotes subfield names inside nested field groups per key encoding", + "input": { + "items": [ + { "id": 1, "customer": { "full name": "Ada", "country": "UK" } } + ] + }, + "expected": "items[1]{id,customer{\"full name\",country}}:\n 1,Ada,UK", + "specSection": "9.3" + }, + { + "name": "falls back to list form when nested object keys differ per row", + "input": { + "entries": [ + { "id": 1, "meta": { "a": 1 } }, + { "id": 2, "meta": { "b": 2 } } + ] + }, + "expected": "entries[2]:\n - id: 1\n meta:\n a: 1\n - id: 2\n meta:\n b: 2", + "specSection": "9.3", + "note": "The meta column is not nested-uniform (differing key sets), so the whole array uses §9.4" + }, + { + "name": "falls back to list form when a column mixes null and objects", + "input": { + "orders": [ + { "id": 1, "customer": { "name": "Ada" } }, + { "id": 2, "customer": null } + ] + }, + "expected": "orders[2]:\n - id: 1\n customer:\n name: Ada\n - id: 2\n customer: null", + "specSection": "9.3", + "note": "A null cell is a primitive, so the customer column is neither uniform-primitive nor nested-uniform" + }, + { + "name": "falls back to list form when a nested object contains an array", + "input": { + "orders": [ + { "id": 1, "customer": { "name": "Ada", "tags": ["x"] } } + ] + }, + "expected": "orders[1]:\n - id: 1\n customer:\n name: Ada\n tags[1]: x", + "specSection": "9.3", + "note": "Array values disqualify a column; rows must contain only primitive cells" + }, + { + "name": "falls back to list form when a nested column contains an empty object", + "input": { + "items": [ + { "id": 1, "meta": {} } + ] + }, + "expected": "items[1]:\n - id: 1\n meta:", + "specSection": "9.3", + "note": "Nested-uniform requires non-empty objects, mirroring the empty-object exclusion for elements" } ] } diff --git a/tests/fixtures/encode/delimiters.json b/tests/fixtures/encode/delimiters.json index c7c012b..03c9271 100644 --- a/tests/fixtures/encode/delimiters.json +++ b/tests/fixtures/encode/delimiters.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Delimiter options - tab and pipe delimiters, delimiter-aware quoting", + "description": "Delimiter options – tab and pipe delimiters, delimiter-aware quoting", "tests": [ { "name": "encodes primitive arrays with tab delimiter", @@ -12,7 +12,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "encodes primitive arrays with pipe delimiter", @@ -23,7 +23,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "encodes primitive arrays with comma delimiter", @@ -34,7 +34,7 @@ "options": { "delimiter": "," }, - "specSection": "8" + "specSection": "11" }, { "name": "encodes tabular arrays with tab delimiter", @@ -48,7 +48,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "encodes tabular arrays with pipe delimiter", @@ -62,7 +62,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "encodes nested arrays with tab delimiter", @@ -73,7 +73,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "encodes nested arrays with pipe delimiter", @@ -84,43 +84,43 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { - "name": "encodes root arrays with tab delimiter", + "name": "encodes root-level array with tab delimiter", "input": ["x", "y", "z"], "expected": "[3\t]: x\ty\tz", "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { - "name": "encodes root arrays with pipe delimiter", + "name": "encodes root-level array with pipe delimiter", "input": ["x", "y", "z"], "expected": "[3|]: x|y|z", "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { - "name": "encodes root arrays of objects with tab delimiter", + "name": "encodes root-level array of objects with tab delimiter", "input": [{ "id": 1 }, { "id": 2 }], "expected": "[2\t]{id}:\n 1\n 2", "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { - "name": "encodes root arrays of objects with pipe delimiter", + "name": "encodes root-level array of objects with pipe delimiter", "input": [{ "id": 1 }, { "id": 2 }], "expected": "[2|]{id}:\n 1\n 2", "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "quotes strings containing tab delimiter", @@ -131,7 +131,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "quotes strings containing pipe delimiter", @@ -142,7 +142,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "does not quote commas with tab delimiter", @@ -153,7 +153,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "does not quote commas with pipe delimiter", @@ -164,7 +164,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "quotes tabular values containing comma delimiter", @@ -178,7 +178,7 @@ "options": { "delimiter": "," }, - "specSection": "8" + "specSection": "11" }, { "name": "does not quote commas in tabular values with tab delimiter", @@ -192,7 +192,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "does not quote commas in object values with pipe delimiter", @@ -203,7 +203,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "does not quote commas in object values with tab delimiter", @@ -214,7 +214,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "quotes nested array values containing pipe delimiter", @@ -225,7 +225,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" }, { "name": "quotes nested array values containing tab delimiter", @@ -236,7 +236,7 @@ "options": { "delimiter": "\t" }, - "specSection": "8" + "specSection": "11" }, { "name": "preserves ambiguity quoting regardless of delimiter", @@ -247,7 +247,7 @@ "options": { "delimiter": "|" }, - "specSection": "8" + "specSection": "11" } ] } diff --git a/tests/fixtures/encode/normalization.json b/tests/fixtures/encode/normalization.json deleted file mode 100644 index 43df0e9..0000000 --- a/tests/fixtures/encode/normalization.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "version": "1.3", - "category": "encode", - "description": "Non-JSON type normalization - BigInt, Date, undefined, NaN, Infinity, functions, symbols", - "tests": [ - { - "name": "converts BigInt to number", - "input": 123, - "expected": "123", - "specSection": "5", - "note": "BigInt(123) in JavaScript becomes 123" - }, - { - "name": "converts BigInt in object to number", - "input": { - "id": 456 - }, - "expected": "id: 456", - "specSection": "5", - "note": "BigInt(456) in JavaScript becomes 456" - }, - { - "name": "converts Date to ISO string", - "input": "2025-01-01T00:00:00.000Z", - "expected": "\"2025-01-01T00:00:00.000Z\"", - "specSection": "5", - "note": "new Date('2025-01-01T00:00:00.000Z') becomes quoted ISO string" - }, - { - "name": "converts Date in object to ISO string", - "input": { - "created": "2025-01-01T00:00:00.000Z" - }, - "expected": "created: \"2025-01-01T00:00:00.000Z\"", - "specSection": "5" - }, - { - "name": "converts undefined to null", - "input": null, - "expected": "null", - "specSection": "5", - "note": "undefined in JavaScript becomes null" - }, - { - "name": "converts undefined in object to null", - "input": { - "value": null - }, - "expected": "value: null", - "specSection": "5", - "note": "undefined in JavaScript becomes null" - }, - { - "name": "converts Infinity to null", - "input": null, - "expected": "null", - "specSection": "5", - "note": "Infinity becomes null" - }, - { - "name": "converts negative Infinity to null", - "input": null, - "expected": "null", - "specSection": "5", - "note": "-Infinity becomes null" - }, - { - "name": "converts NaN to null", - "input": null, - "expected": "null", - "specSection": "5", - "note": "Number.NaN becomes null" - }, - { - "name": "converts function to null", - "input": null, - "expected": "null", - "specSection": "5", - "note": "Functions become null" - }, - { - "name": "converts function in object to null", - "input": { - "fn": null - }, - "expected": "fn: null", - "specSection": "5", - "note": "Functions become null" - }, - { - "name": "converts symbol to null", - "input": null, - "expected": "null", - "specSection": "5", - "note": "Symbols become null" - }, - { - "name": "converts symbol in object to null", - "input": { - "sym": null - }, - "expected": "sym: null", - "specSection": "5", - "note": "Symbols become null" - } - ] -} diff --git a/tests/fixtures/encode/objects-keyed.json b/tests/fixtures/encode/objects-keyed.json new file mode 100644 index 0000000..7fd4e87 --- /dev/null +++ b/tests/fixtures/encode/objects-keyed.json @@ -0,0 +1,155 @@ +{ + "version": "4.0", + "category": "encode", + "description": "Keyed tabular encoding – objects whose values are uniform objects collapse into keyed headers with entry rows", + "tests": [ + { + "name": "encodes objects of uniform objects in keyed tabular form", + "input": { + "servers": { + "alpha": { "host": "a.example.com", "port": 8080 }, + "beta": { "host": "b.example.com", "port": 9090 } + } + }, + "expected": "servers[2:]{host,port}:\n alpha: a.example.com,8080\n beta: b.example.com,9090", + "specSection": "9.5" + }, + { + "name": "encodes an eligible root object with a keyless keyed header", + "input": { + "alice": { "age": 30, "city": "Berlin" }, + "bob": { "age": 25, "city": "Oslo" } + }, + "expected": "[2:]{age,city}:\n alice: 30,Berlin\n bob: 25,Oslo", + "specSection": "9.5", + "note": "The keyless keyed header is valid only at the root (§5)" + }, + { + "name": "collapses uniform nested object columns inside keyed headers", + "input": { + "regions": { + "eu": { "name": "Europe", "geo": { "lat": 50, "lon": 10 } }, + "us": { "name": "America", "geo": { "lat": 40, "lon": -100 } } + } + }, + "expected": "regions[2:]{name,geo{lat,lon}}:\n eu: Europe,50,10\n us: America,40,-100", + "specSection": "9.5" + }, + { + "name": "orders fields by the first entry value's encounter order", + "input": { + "m": { + "a": { "x": 1, "y": 2 }, + "b": { "y": 4, "x": 3 } + } + }, + "expected": "m[2:]{x,y}:\n a: 1,2\n b: 3,4", + "specSection": "9.5" + }, + { + "name": "uses the active delimiter in keyed headers and entry-row cells", + "input": { + "servers": { + "alpha": { "host": "a.example.com", "port": 8080 }, + "beta": { "host": "b.example.com", "port": 9090 } + } + }, + "options": { "delimiter": "|" }, + "expected": "servers[2:|]{host|port}:\n alpha: a.example.com|8080\n beta: b.example.com|9090", + "specSection": "9.5", + "note": "The keyed marker precedes the delimiter symbol: [2:|]" + }, + { + "name": "quotes entry keys per key encoding", + "input": { + "ids": { + "42": { "v": 1 }, + "my key": { "v": 2 } + } + }, + "expected": "ids[2:]{v}:\n \"42\": 1\n \"my key\": 2", + "specSection": "9.5", + "note": "Entry keys follow §7.3, the same rule as object keys" + }, + { + "name": "quotes entry-row cells containing the active delimiter", + "input": { + "notes": { + "n1": { "text": "a,b" }, + "n2": { "text": "c" } + } + }, + "expected": "notes[2:]{text}:\n n1: \"a,b\"\n n2: c", + "specSection": "9.5" + }, + { + "name": "keeps single-entry objects in nested form", + "input": { + "config": { + "only": { "a": 1, "b": 2 } + } + }, + "expected": "config:\n only:\n a: 1\n b: 2", + "specSection": "9.5", + "note": "Keyed tabular detection requires at least two entries" + }, + { + "name": "keeps objects in nested form when entry values have differing key sets", + "input": { + "envs": { + "dev": { "host": "x" }, + "prod": { "host": "y", "port": 1 } + } + }, + "expected": "envs:\n dev:\n host: x\n prod:\n host: y\n port: 1", + "specSection": "9.5" + }, + { + "name": "keeps objects in nested form when a value is primitive", + "input": { + "m": { + "a": { "x": 1 }, + "b": 2 + } + }, + "expected": "m:\n a:\n x: 1\n b: 2", + "specSection": "9.5" + }, + { + "name": "keeps objects in nested form when an entry value contains an array", + "input": { + "m": { + "a": { "tags": ["x"] }, + "b": { "tags": ["y"] } + } + }, + "expected": "m:\n a:\n tags[1]: x\n b:\n tags[1]: y", + "specSection": "9.5", + "note": "Entry-row cells must be primitive leaves; an array value disqualifies the column" + }, + { + "name": "emits a keyed header on the hyphen line when it is the first field of a list item", + "input": { + "items": [ + { "config": { "a": { "x": 1 }, "b": { "x": 2 } }, "status": "ok" }, + { "status": "down" } + ] + }, + "expected": "items[2]:\n - config[2:]{x}:\n a: 1\n b: 2\n status: ok\n - status: down", + "specSection": "10", + "note": "Entry rows at depth +2, sibling fields at depth +1, mirroring tabular arrays on hyphen lines" + }, + { + "name": "never encodes an anonymous array element in keyed tabular form", + "input": { + "items": [ + { "a": { "x": 1 }, "b": { "x": 2 } }, + 5 + ] + }, + "expected": "items[2]:\n - a:\n x: 1\n b:\n x: 2\n - 5", + "specSection": "9.5", + "note": "The element object is keyed-eligible but has no key; the keyless form is root-only, so it encodes per §10" + } + ] +} diff --git a/tests/fixtures/encode/objects.json b/tests/fixtures/encode/objects.json index 72e73b7..a63f92c 100644 --- a/tests/fixtures/encode/objects.json +++ b/tests/fixtures/encode/objects.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Object encoding - simple objects, nested objects, key encoding", + "description": "Object encoding – simple objects, nested objects, key encoding", "tests": [ { "name": "preserves key order in objects", @@ -11,7 +11,7 @@ "active": true }, "expected": "id: 123\nname: Ada\nactive: true", - "specSection": "6" + "specSection": "8" }, { "name": "encodes null values in objects", @@ -20,13 +20,13 @@ "value": null }, "expected": "id: 123\nvalue: null", - "specSection": "6" + "specSection": "8" }, { "name": "encodes empty objects as empty string", "input": {}, "expected": "", - "specSection": "6" + "specSection": "8" }, { "name": "quotes string value with colon", @@ -34,7 +34,7 @@ "note": "a:b" }, "expected": "note: \"a:b\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value with comma", @@ -42,7 +42,7 @@ "note": "a,b" }, "expected": "note: \"a,b\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value with newline", @@ -50,7 +50,7 @@ "text": "line1\nline2" }, "expected": "text: \"line1\\nline2\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value with embedded quotes", @@ -58,7 +58,7 @@ "text": "say \"hello\"" }, "expected": "text: \"say \\\"hello\\\"\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value with leading space", @@ -66,7 +66,7 @@ "text": " padded " }, "expected": "text: \" padded \"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value with only spaces", @@ -74,7 +74,7 @@ "text": " " }, "expected": "text: \" \"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value that looks like true", @@ -82,7 +82,7 @@ "v": "true" }, "expected": "v: \"true\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value that looks like number", @@ -90,7 +90,7 @@ "v": "42" }, "expected": "v: \"42\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes string value that looks like negative decimal", @@ -98,7 +98,7 @@ "v": "-7.5" }, "expected": "v: \"-7.5\"", - "specSection": "6" + "specSection": "7.2" }, { "name": "quotes key with colon", @@ -106,7 +106,7 @@ "order:id": 7 }, "expected": "\"order:id\": 7", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes key with brackets", @@ -114,7 +114,7 @@ "[index]": 5 }, "expected": "\"[index]\": 5", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes key with braces", @@ -122,7 +122,7 @@ "{key}": 5 }, "expected": "\"{key}\": 5", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes key with comma", @@ -130,7 +130,7 @@ "a,b": 1 }, "expected": "\"a,b\": 1", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes key with spaces", @@ -138,7 +138,7 @@ "full name": "Ada" }, "expected": "\"full name\": Ada", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes key with leading hyphen", @@ -146,7 +146,7 @@ "-lead": 1 }, "expected": "\"-lead\": 1", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes key with leading and trailing spaces", @@ -154,7 +154,7 @@ " a ": 1 }, "expected": "\" a \": 1", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes numeric key", @@ -162,7 +162,7 @@ "123": "x" }, "expected": "\"123\": x", - "specSection": "6" + "specSection": "7.3" }, { "name": "quotes empty string key", @@ -170,7 +170,24 @@ "": 1 }, "expected": "\"\": 1", - "specSection": "6" + "specSection": "7.3" + }, + { + "name": "quotes non-ASCII key", + "input": { + "café": 1 + }, + "expected": "\"café\": 1", + "specSection": "7.3", + "note": "The unquoted-key pattern is ASCII-only, so every non-ASCII key is quoted" + }, + { + "name": "quotes CJK key", + "input": { + "名前": "x" + }, + "expected": "\"名前\": x", + "specSection": "7.3" }, { "name": "escapes newline in key", @@ -178,7 +195,7 @@ "line\nbreak": 1 }, "expected": "\"line\\nbreak\": 1", - "specSection": "6" + "specSection": "7.1" }, { "name": "escapes tab in key", @@ -186,7 +203,7 @@ "tab\there": 2 }, "expected": "\"tab\\there\": 2", - "specSection": "6" + "specSection": "7.1" }, { "name": "escapes quotes in key", @@ -194,7 +211,23 @@ "he said \"hi\"": 1 }, "expected": "\"he said \\\"hi\\\"\": 1", - "specSection": "6" + "specSection": "7.1" + }, + { + "name": "escapes U+0004 control character in key via \\uXXXX", + "input": { + "a\u0004b": 1 + }, + "expected": "\"a\\u0004b\": 1", + "specSection": "7.1" + }, + { + "name": "escapes U+001F control character in key via \\uXXXX", + "input": { + "x\u001fy": 2 + }, + "expected": "\"x\\u001fy\": 2", + "specSection": "7.1" }, { "name": "encodes deeply nested objects", @@ -206,7 +239,7 @@ } }, "expected": "a:\n b:\n c: deep", - "specSection": "6" + "specSection": "8" }, { "name": "encodes empty nested object", @@ -214,7 +247,51 @@ "user": {} }, "expected": "user:", - "specSection": "6" + "specSection": "8" + }, + { + "name": "encodes __proto__ own property as an ordinary key", + "input": { + "__proto__": "polluted", + "safe": true + }, + "expected": "__proto__: polluted\nsafe: true", + "specSection": "15", + "note": "No key has special meaning, so the encoder emits prototype-named own entries like any other (§8)" + }, + { + "name": "encodes constructor and prototype own properties as ordinary keys", + "input": { + "constructor": 1, + "prototype": 2 + }, + "expected": "constructor: 1\nprototype: 2", + "specSection": "15" + }, + { + "name": "encodes __proto__ as a tabular field name", + "input": { + "rows": [ + { + "__proto__": "a", + "x": 1 + }, + { + "__proto__": "b", + "x": 2 + } + ] + }, + "expected": "rows[2]{__proto__,x}:\n a,1\n b,2", + "specSection": "15" + }, + { + "name": "quotes hash-leading string in object field value", + "input": { + "note": "#x" + }, + "expected": "note: \"#x\"", + "specSection": "7.2" } ] } diff --git a/tests/fixtures/encode/options.json b/tests/fixtures/encode/options.json deleted file mode 100644 index 24c2955..0000000 --- a/tests/fixtures/encode/options.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "version": "1.3", - "category": "encode", - "description": "Encoding options - lengthMarker option and combinations with delimiters", - "tests": [ - { - "name": "adds length marker to primitive arrays", - "input": { - "tags": ["reading", "gaming", "coding"] - }, - "expected": "tags[#3]: reading,gaming,coding", - "options": { - "lengthMarker": "#" - }, - "specSection": "3" - }, - { - "name": "adds length marker to empty arrays", - "input": { - "items": [] - }, - "expected": "items[#0]:", - "options": { - "lengthMarker": "#" - }, - "specSection": "3" - }, - { - "name": "adds length marker to tabular arrays", - "input": { - "items": [ - { "sku": "A1", "qty": 2, "price": 9.99 }, - { "sku": "B2", "qty": 1, "price": 14.5 } - ] - }, - "expected": "items[#2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5", - "options": { - "lengthMarker": "#" - }, - "specSection": "3" - }, - { - "name": "adds length marker to nested arrays", - "input": { - "pairs": [["a", "b"], ["c", "d"]] - }, - "expected": "pairs[#2]:\n - [#2]: a,b\n - [#2]: c,d", - "options": { - "lengthMarker": "#" - }, - "specSection": "3" - }, - { - "name": "combines length marker with pipe delimiter", - "input": { - "tags": ["reading", "gaming", "coding"] - }, - "expected": "tags[#3|]: reading|gaming|coding", - "options": { - "lengthMarker": "#", - "delimiter": "|" - }, - "specSection": "3" - }, - { - "name": "combines length marker with tab delimiter", - "input": { - "tags": ["reading", "gaming", "coding"] - }, - "expected": "tags[#3\t]: reading\tgaming\tcoding", - "options": { - "lengthMarker": "#", - "delimiter": "\t" - }, - "specSection": "3" - }, - { - "name": "default lengthMarker is empty (no marker)", - "input": { - "tags": ["reading", "gaming", "coding"] - }, - "expected": "tags[3]: reading,gaming,coding", - "options": {}, - "specSection": "3", - "note": "Default behavior without lengthMarker option" - } - ] -} diff --git a/tests/fixtures/encode/primitives.json b/tests/fixtures/encode/primitives.json index 60285e5..9d6041a 100644 --- a/tests/fixtures/encode/primitives.json +++ b/tests/fixtures/encode/primitives.json @@ -1,226 +1,278 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Primitive value encoding - strings, numbers, booleans, null", + "description": "Primitive value encoding – strings, numbers, booleans, null", "tests": [ { "name": "encodes safe strings without quotes", "input": "hello", "expected": "hello", - "specSection": "5" + "specSection": "7.2" }, { "name": "encodes safe string with underscore and numbers", "input": "Ada_99", "expected": "Ada_99", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes empty string", "input": "", "expected": "\"\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string that looks like true", "input": "true", "expected": "\"true\"", - "specSection": "5", - "note": "String representation of boolean must be quoted" + "specSection": "7.2" }, { "name": "quotes string that looks like false", "input": "false", "expected": "\"false\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string that looks like null", "input": "null", "expected": "\"null\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string that looks like integer", "input": "42", "expected": "\"42\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string that looks like negative decimal", "input": "-3.14", "expected": "\"-3.14\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string that looks like scientific notation", "input": "1e-6", "expected": "\"1e-6\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string with leading zero", "input": "05", "expected": "\"05\"", - "specSection": "5", - "note": "Leading zeros make it non-numeric" + "specSection": "7.2", + "note": "Matches the numeric-like pattern, so it MUST be quoted" }, { "name": "escapes newline in string", "input": "line1\nline2", "expected": "\"line1\\nline2\"", - "specSection": "5" + "specSection": "7.1" }, { "name": "escapes tab in string", "input": "tab\there", "expected": "\"tab\\there\"", - "specSection": "5" + "specSection": "7.1" }, { "name": "escapes carriage return in string", "input": "return\rcarriage", "expected": "\"return\\rcarriage\"", - "specSection": "5" + "specSection": "7.1" + }, + { + "name": "encodes string with U+0004 control character via \\uXXXX", + "input": { "val": "a\u0004b" }, + "expected": "val: \"a\\u0004b\"", + "specSection": "7.1" }, { "name": "escapes backslash in string", "input": "C:\\Users\\path", "expected": "\"C:\\\\Users\\\\path\"", - "specSection": "5" + "specSection": "7.1" }, { "name": "quotes string with array-like syntax", "input": "[3]: x,y", "expected": "\"[3]: x,y\"", - "specSection": "5", - "note": "Looks like array header" + "specSection": "7.2" }, { "name": "quotes string starting with hyphen-space", "input": "- item", "expected": "\"- item\"", - "specSection": "5", - "note": "Looks like list item marker" + "specSection": "7.2" + }, + { + "name": "quotes single hyphen as object value", + "input": { "marker": "-" }, + "expected": "marker: \"-\"", + "specSection": "7.2", + "note": "Single hyphen must be quoted to avoid list item ambiguity" + }, + { + "name": "quotes string starting with hyphen as object value", + "input": { "note": "- item" }, + "expected": "note: \"- item\"", + "specSection": "7.2" + }, + { + "name": "quotes single hyphen in array", + "input": { "items": ["-"] }, + "expected": "items[1]: \"-\"", + "specSection": "7.2" + }, + { + "name": "quotes leading-hyphen string in array", + "input": { "tags": ["a", "- item", "b"] }, + "expected": "tags[3]: a,\"- item\",b", + "specSection": "7.2" }, { "name": "quotes string with bracket notation", "input": "[test]", "expected": "\"[test]\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "quotes string with brace notation", "input": "{key}", "expected": "\"{key}\"", - "specSection": "5" + "specSection": "7.2" }, { "name": "encodes Unicode string without quotes", "input": "café", "expected": "café", - "specSection": "5" + "specSection": "7.2" }, { "name": "encodes Chinese characters without quotes", "input": "你好", "expected": "你好", - "specSection": "5" + "specSection": "7.2" }, { "name": "encodes emoji without quotes", "input": "🚀", "expected": "🚀", - "specSection": "5" + "specSection": "7.2" }, { "name": "encodes string with emoji and spaces", "input": "hello 👋 world", "expected": "hello 👋 world", - "specSection": "5" + "specSection": "7.2" + }, + { + "name": "encodes emoji inside a quoted string", + "input": "a,🚀", + "expected": "\"a,🚀\"", + "specSection": "7.1", + "note": "Quoting is triggered by the delimiter; the supplementary scalar stays literal UTF-8, never a surrogate escape" }, { "name": "encodes positive integer", "input": 42, "expected": "42", - "specSection": "5" + "specSection": "2" }, { "name": "encodes decimal number", "input": 3.14, "expected": "3.14", - "specSection": "5" + "specSection": "2" }, { "name": "encodes negative integer", "input": -7, "expected": "-7", - "specSection": "5" + "specSection": "2" }, { "name": "encodes zero", "input": 0, "expected": "0", - "specSection": "5" + "specSection": "2" }, { "name": "encodes negative zero as zero", "input": -0, "expected": "0", - "specSection": "5", - "note": "Negative zero normalizes to zero" + "specSection": "2" }, { - "name": "encodes scientific notation as decimal", + "name": "encodes large integer without exponent notation", "input": 1000000, "expected": "1000000", - "specSection": "5", - "note": "1e6 input, but represented as decimal" + "specSection": "2", + "note": "Within the canonical decimal range" }, { - "name": "encodes small decimal from scientific notation", + "name": "encodes small decimal without exponent notation", "input": 0.000001, "expected": "0.000001", - "specSection": "5", - "note": "1e-6 input" + "specSection": "2", + "note": "Lower bound of the canonical decimal range" }, { "name": "encodes large number", "input": 100000000000000000000, "expected": "100000000000000000000", - "specSection": "5", - "note": "1e20" + "specSection": "2" }, { "name": "encodes MAX_SAFE_INTEGER", "input": 9007199254740991, "expected": "9007199254740991", - "specSection": "5" + "specSection": "2" }, { "name": "encodes repeating decimal with full precision", "input": 0.3333333333333333, "expected": "0.3333333333333333", - "specSection": "5", - "note": "Result of 1/3 in JavaScript" + "specSection": "2" }, { "name": "encodes true", "input": true, "expected": "true", - "specSection": "5" + "specSection": "2" }, { "name": "encodes false", "input": false, "expected": "false", - "specSection": "5" + "specSection": "2" }, { "name": "encodes null", "input": null, "expected": "null", - "specSection": "5" + "specSection": "2" + }, + { + "name": "quotes leading-plus numeric-like string", + "input": "+1", + "expected": "\"+1\"", + "specSection": "7.2", + "note": "Unquoted, a v3 host-parser decoder would read the number 1" + }, + { + "name": "quotes string equal to hash", + "input": "#", + "expected": "\"#\"", + "specSection": "7.2", + "note": "Comment marker at position 0 must be quoted" + }, + { + "name": "quotes string starting with hash", + "input": "#hello", + "expected": "\"#hello\"", + "specSection": "7.2" } ] } diff --git a/tests/fixtures/encode/whitespace.json b/tests/fixtures/encode/whitespace.json index 270dceb..1c6eae3 100644 --- a/tests/fixtures/encode/whitespace.json +++ b/tests/fixtures/encode/whitespace.json @@ -1,7 +1,7 @@ { - "version": "1.3", + "version": "4.0", "category": "encode", - "description": "Whitespace and formatting invariants - no trailing spaces, no trailing newlines", + "description": "Whitespace and formatting invariants – no trailing spaces, no trailing newlines", "tests": [ { "name": "produces no trailing newline at end of output", @@ -9,8 +9,7 @@ "id": 123 }, "expected": "id: 123", - "specSection": "4", - "note": "Output should not end with newline character" + "specSection": "12" }, { "name": "maintains proper indentation for nested structures", @@ -22,8 +21,31 @@ "items": ["a", "b"] }, "expected": "user:\n id: 123\n name: Ada\nitems[2]: a,b", - "specSection": "4", - "note": "2-space indentation, no trailing spaces on any line" + "specSection": "12" + }, + { + "name": "respects custom indentSize option", + "input": { + "user": { + "name": "Ada", + "role": "admin" + } + }, + "expected": "user:\n name: Ada\n role: admin", + "specSection": "12", + "options": { + "indentSize": 4 + } + }, + { + "name": "leaves non-ASCII whitespace unquoted", + "input": { + "k": "\u00a0x\u00a0" + }, + "expected": "k: \u00a0x\u00a0", + "specSection": "7.2", + "note": "The whitespace trigger is U+0020 and U+0009 only", + "minSpecVersion": "4.1" } ] } diff --git a/tests/strategies.py b/tests/strategies.py new file mode 100644 index 0000000..ac91b1b --- /dev/null +++ b/tests/strategies.py @@ -0,0 +1,92 @@ +"""Hypothesis strategies for values in the JSON data model.""" + +from __future__ import annotations + +from typing import Any + +from hypothesis import strategies as st + +# Characters that exercise quoting, escaping, and delimiter handling. +_TRICKY = st.sampled_from( + [ + ":", + ",", + "|", + "\t", + "\n", + "\r", + "-", + "#", + " ", + '"', + "\\", + "[", + "]", + "{", + "}", + "\x00", + "\x1f", + "é", + "🚀", + ] +) +_WORDS = st.sampled_from( + [ + "", + "a", + "id", + "name", + "true", + "false", + "null", + "42", + "-1", + "05", + "1e6", + "[]", + "- x", + "a.b", + "x y", + ] +) + +strings = st.one_of( + _WORDS, + st.text(st.one_of(_TRICKY, st.characters(codec="utf-8")), max_size=8), +) +keys = st.one_of(st.sampled_from(["a", "b", "c", "id", "x.y", "_k"]), strings) +numbers = st.one_of( + st.integers(), + st.integers(min_value=-(10**30), max_value=10**30), + st.floats(allow_nan=False, allow_infinity=False), +) +primitives = st.one_of(st.none(), st.booleans(), numbers, strings) + + +def _uniform_objects( + children: st.SearchStrategy[Any], +) -> st.SearchStrategy[list[dict[str, Any]]]: + """Lists of objects sharing one key set, to reach the tabular forms.""" + return st.lists(keys, min_size=1, max_size=3, unique=True).flatmap( + lambda names: st.lists( + st.fixed_dictionaries(dict.fromkeys(names, children)), + min_size=1, + max_size=4, + ) + ) + + +def _extend(children: st.SearchStrategy[Any]) -> st.SearchStrategy[Any]: + return st.one_of( + st.lists(children, max_size=4), + st.dictionaries(keys, children, max_size=4), + _uniform_objects(primitives), + _uniform_objects(st.fixed_dictionaries({"p": primitives, "q": primitives})), + _uniform_objects(primitives).map( + lambda rows: {f"k{i}": row for i, row in enumerate(rows)} + ), + ) + + +json_values = st.recursive(primitives, _extend, max_leaves=24) +documents = st.one_of(json_values, st.dictionaries(keys, json_values, max_size=5)) diff --git a/tests/test_api.py b/tests/test_api.py index 8eff0b5..292de78 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,288 +1,88 @@ -"""Tests for Python-specific TOON API behavior. +"""The public interface: signatures, options, and file helpers.""" -This module tests the Python implementation's API surface, including: -- Options handling (EncodeOptions, DecodeOptions) -- Error handling and exception types -- Error message quality and clarity -- API edge cases and validation +from __future__ import annotations -Spec compliance is tested in test_spec_fixtures.py using official fixtures. -Python type normalization is tested in test_normalization.py. -""" +import io +import re +from pathlib import Path +from typing import Any import pytest -from toon_format import ToonDecodeError, decode, encode -from toon_format.types import DecodeOptions, EncodeOptions - - -class TestEncodeAPI: - """Test encode() function API and options handling.""" - - def test_encode_accepts_dict_options(self): - """encode() should accept options as a plain dict.""" - result = encode([1, 2, 3], {"delimiter": "\t"}) - assert result == "[3\t]: 1\t2\t3" - - def test_encode_accepts_encode_options_object(self): - """encode() should accept EncodeOptions object.""" - options = EncodeOptions(delimiter="|", indent=4) - result = encode([1, 2, 3], options) - assert result == "[3|]: 1|2|3" - - def test_encode_default_options(self): - """encode() should use defaults when no options provided.""" - result = encode({"a": 1, "b": 2}) - # Default: 2-space indent, comma delimiter - assert result == "a: 1\nb: 2" - - def test_encode_with_comma_delimiter(self): - """Comma delimiter should work correctly.""" - result = encode([1, 2, 3], {"delimiter": ","}) - assert result == "[3]: 1,2,3" - - def test_encode_with_tab_delimiter(self): - """Tab delimiter should work correctly.""" - result = encode([1, 2, 3], {"delimiter": "\t"}) - assert result == "[3\t]: 1\t2\t3" - - def test_encode_with_pipe_delimiter(self): - """Pipe delimiter should work correctly.""" - result = encode([1, 2, 3], {"delimiter": "|"}) - assert result == "[3|]: 1|2|3" - - def test_encode_with_custom_indent(self): - """Custom indent size should be respected.""" - result = encode({"parent": {"child": 1}}, {"indent": 4}) - lines = result.split("\n") - assert lines[1].startswith(" ") # 4-space indent - - def test_encode_with_zero_indent(self): - """Zero indent should use minimal spacing.""" - result = encode({"parent": {"child": 1}}, {"indent": 0}) - # Should still have some structure - assert "parent:" in result - assert "child: 1" in result - - def test_encode_with_length_marker(self): - """lengthMarker option should add # prefix.""" - result = encode([1, 2, 3], {"lengthMarker": "#"}) - assert "[#3]:" in result - - def test_encode_none_returns_null_string(self): - """Encoding None should return 'null' as a string.""" - result = encode(None) - assert result == "null" - assert isinstance(result, str) - - def test_encode_empty_object_returns_empty_string(self): - """Encoding empty object should return empty string.""" - result = encode({}) - assert result == "" - - def test_encode_root_array(self): - """Encoding root-level array should work.""" - result = encode([1, 2, 3]) - assert result == "[3]: 1,2,3" - - def test_encode_root_primitive(self): - """Encoding root-level primitive should work.""" - result = encode("hello") - assert result == "hello" - - -class TestDecodeAPI: - """Test decode() function API and options handling.""" - - def test_decode_with_decode_options(self): - """decode() requires DecodeOptions object, not plain dict.""" - options = DecodeOptions(strict=False) - result = decode("id: 123", options) - assert result == {"id": 123} - - def test_decode_accepts_decode_options_object(self): - """decode() should accept DecodeOptions object.""" - options = DecodeOptions(strict=True) - result = decode("id: 123", options) - assert result == {"id": 123} - - def test_decode_default_options(self): - """decode() should use defaults when no options provided.""" - result = decode("id: 123\nname: Alice") - assert result == {"id": 123, "name": "Alice"} - - def test_decode_strict_mode_enabled(self): - """Strict mode should enforce validation.""" - # Array length mismatch should error in strict mode - toon = "items[3]: a,b" # Declared 3, only 2 values - with pytest.raises(ToonDecodeError, match="Expected 3 values"): - decode(toon, DecodeOptions(strict=True)) - - def test_decode_lenient_mode_allows_mismatch(self): - """Lenient mode should allow length mismatch.""" - toon = "items[3]: a,b" # Declared 3, only 2 values - result = decode(toon, DecodeOptions(strict=False)) - assert result == {"items": ["a", "b"]} - - def test_decode_empty_string_returns_empty_object(self): - """Decoding empty string returns empty object (per spec Section 8).""" - result = decode("") - assert result == {} - - def test_decode_whitespace_only_returns_empty_object(self): - """Decoding whitespace-only returns empty object (per spec Section 8).""" - result = decode(" \n \n ") - assert result == {} - - def test_decode_root_array(self): - """Decoding root-level array should work.""" - result = decode("[3]: a,b,c") - assert result == ["a", "b", "c"] - - def test_decode_root_primitive(self): - """Decoding root-level primitive should work.""" - result = decode("hello world") - assert result == "hello world" - - -class TestErrorHandling: - """Test error handling and exception types.""" - - def test_decode_invalid_syntax_treated_as_string(self): - """Invalid TOON syntax for objects is treated as root primitive string.""" - result = decode("[[[ invalid syntax ]]]") - # This is treated as a root-level primitive string - assert result == "[[[ invalid syntax ]]]" - - def test_decode_unterminated_string_raises_error(self): - """Unterminated string should raise ToonDecodeError.""" - toon = 'text: "unterminated' - with pytest.raises(ToonDecodeError, match="Unterminated"): - decode(toon) - - def test_decode_invalid_escape_raises_error(self): - """Invalid escape sequence should raise ToonDecodeError.""" - toon = r'text: "invalid\x"' - with pytest.raises(ToonDecodeError, match="Invalid escape"): - decode(toon) - - def test_decode_missing_colon_raises_error(self): - """Missing colon in key-value pair should raise error in strict mode.""" - toon = "key: value\ninvalid line without colon" - with pytest.raises(ToonDecodeError, match="Missing colon"): - decode(toon, DecodeOptions(strict=True)) - - def test_decode_indentation_error_in_strict_mode(self): - """Non-multiple indentation should error in strict mode.""" - toon = "user:\n id: 1" # 3 spaces instead of 2 - with pytest.raises(ToonDecodeError, match="exact multiple"): - decode(toon, DecodeOptions(strict=True)) - - -class TestErrorMessages: - """Test that error messages are clear and helpful.""" - - def test_decode_error_includes_context(self): - """Decode errors should include helpful context.""" - toon = 'text: "unterminated string' - try: - decode(toon) - pytest.fail("Should have raised ToonDecodeError") - except ToonDecodeError as e: - error_msg = str(e).lower() - # Error should mention the problem - assert "unterminated" in error_msg or "string" in error_msg - - def test_decode_length_mismatch_shows_expected_vs_actual(self): - """Length mismatch errors should show expected vs actual.""" - toon = "items[5]: a,b,c" # Declared 5, only 3 values - try: - decode(toon, DecodeOptions(strict=True)) - pytest.fail("Should have raised ToonDecodeError") - except ToonDecodeError as e: - error_msg = str(e) - # Should mention both expected (5) and actual (3) - assert "5" in error_msg and "3" in error_msg - - def test_decode_indentation_error_shows_line_info(self): - """Indentation errors should indicate the problematic line.""" - toon = "user:\n id: 1" # 3 spaces, not a multiple of 2 - try: - decode(toon, DecodeOptions(strict=True)) - pytest.fail("Should have raised ToonDecodeError") - except ToonDecodeError as e: - error_msg = str(e).lower() - # Should mention indentation or spacing - assert "indent" in error_msg or "multiple" in error_msg or "space" in error_msg - - -class TestOptionsValidation: - """Test validation of options.""" - - def test_encode_invalid_delimiter_type(self): - """Invalid delimiter type should raise error.""" - with pytest.raises((TypeError, ValueError, AttributeError)): - encode([1, 2, 3], {"delimiter": 123}) # Number instead of string - - def test_encode_unsupported_delimiter_value(self): - """Unsupported delimiter should raise error or be handled.""" - # This might raise an error or just use it as-is - # depending on implementation - test what happens - try: - result = encode([1, 2, 3], {"delimiter": ";"}) - # If it doesn't error, it should at least produce output - assert result is not None - except (TypeError, ValueError): - # Also acceptable to reject unsupported delimiters - pass - - def test_encode_negative_indent_accepted(self): - """Negative indent is accepted (treated as 0 or minimal).""" - # Implementation may accept negative indent - result = encode({"a": 1}, {"indent": -1}) - assert result is not None # Should produce output - - def test_decode_invalid_strict_type(self): - """Invalid strict option type should raise error.""" - with pytest.raises((TypeError, ValueError, AttributeError)): - decode("id: 1", {"strict": "yes"}) # String instead of bool - - -class TestRoundtrip: - """Test encode/decode roundtrip with various options.""" - - def test_roundtrip_with_comma_delimiter(self): - """Roundtrip with comma delimiter should preserve data.""" - original = {"items": [1, 2, 3]} - toon = encode(original, {"delimiter": ","}) - decoded = decode(toon) - assert decoded == original - - def test_roundtrip_with_tab_delimiter(self): - """Roundtrip with tab delimiter should preserve data.""" - original = {"items": [1, 2, 3]} - toon = encode(original, {"delimiter": "\t"}) - decoded = decode(toon) - assert decoded == original - - def test_roundtrip_with_pipe_delimiter(self): - """Roundtrip with pipe delimiter should preserve data.""" - original = {"items": [1, 2, 3]} - toon = encode(original, {"delimiter": "|"}) - decoded = decode(toon) - assert decoded == original - - def test_roundtrip_with_custom_indent(self): - """Roundtrip with custom indent should preserve data.""" - original = {"parent": {"child": {"value": 42}}} - toon = encode(original, {"indent": 4}) - # Need to specify indent size for decoding as well - decoded = decode(toon, DecodeOptions(indent=4)) - assert decoded == original - - def test_roundtrip_with_length_marker(self): - """Roundtrip with length marker should preserve data.""" - original = {"items": [1, 2, 3]} - toon = encode(original, {"lengthMarker": "#"}) - decoded = decode(toon) - assert decoded == original +import toon_format + + +def test_public_names() -> None: + assert sorted(toon_format.__all__) == sorted( + [ + "Delimiter", + "ToonDecodeError", + "__toon_spec__", + "__version__", + "decode", + "dump", + "dumps", + "encode", + "load", + "loads", + ] + ) + assert toon_format.__toon_spec__ == "4.1" + assert re.fullmatch(r"\d+\.\d+\.\d+.*", toon_format.__version__) + + +def test_encode_and_decode_are_aliases() -> None: + assert toon_format.encode is toon_format.dumps + assert toon_format.decode is toon_format.loads + + +def test_delimiter_enum() -> None: + assert toon_format.Delimiter.TAB == "\t" + assert str(toon_format.Delimiter.PIPE) == "|" + assert ( + toon_format.dumps([1, 2], delimiter=toon_format.Delimiter.PIPE) == "[2|]: 1|2" + ) + + +@pytest.mark.parametrize("delimiter", [";", "", ",,", "comma", None]) +def test_invalid_delimiter(delimiter: Any) -> None: + with pytest.raises(ValueError, match="delimiter must be"): + toon_format.dumps([1], delimiter=delimiter) + + +@pytest.mark.parametrize( + ("indent_size", "error"), + [(0, ValueError), (-2, ValueError), (2.0, TypeError), (True, TypeError)], +) +def test_invalid_indent_size(indent_size: Any, error: type[Exception]) -> None: + with pytest.raises(error, match="indent_size"): + toon_format.dumps({"a": 1}, indent_size=indent_size) + with pytest.raises(error, match="indent_size"): + toon_format.loads("a: 1", indent_size=indent_size) + + +def test_options_are_keyword_only() -> None: + with pytest.raises(TypeError): + toon_format.dumps({"a": 1}, 4) # type: ignore[misc] + with pytest.raises(TypeError): + toon_format.loads("a: 1", False) # type: ignore[misc] + + +def test_dump_and_load_with_files(tmp_path: Path) -> None: + data = {"users": [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bob"}]} + path = tmp_path / "data.toon" + with path.open("w", encoding="utf-8") as fp: + toon_format.dump(data, fp, delimiter="\t") + assert ( + path.read_text(encoding="utf-8") == "users[2\t]{id\tname}:\n 1\tAda\n 2\tBob" + ) + with path.open(encoding="utf-8") as fp: + assert toon_format.load(fp) == data + with path.open("rb") as fp: + assert toon_format.load(fp) == data + + +def test_load_passes_options() -> None: + assert toon_format.load(io.StringIO("a[2]: 1"), strict=False, parse_int=str) == { + "a": ["1"] + } diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 95b3aea..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Integration tests for the CLI module.""" - -import json -from io import StringIO -from unittest.mock import MagicMock, patch - -import pytest - -from toon_format.cli import decode_toon_to_json, encode_json_to_toon, main - - -class TestEncodeJsonToToon: - """Tests for encode_json_to_toon function.""" - - def test_basic_encode(self): - """Test basic JSON to TOON encoding.""" - json_text = '{"name": "Alice", "age": 30}' - result = encode_json_to_toon(json_text) - assert "name: Alice" in result - assert "age: 30" in result - - def test_encode_with_custom_delimiter(self): - """Test encoding with custom delimiter.""" - json_text = '{"items": [1, 2, 3]}' - result = encode_json_to_toon(json_text, delimiter="|") - assert "|" in result or "[3]:" in result # Either delimiter or inline format - - def test_encode_with_custom_indent(self): - """Test encoding with custom indentation.""" - json_text = '{"outer": {"inner": 1}}' - result = encode_json_to_toon(json_text, indent=4) - # With 4-space indent, nested items should have 4 spaces - assert result is not None - - def test_encode_with_length_marker(self): - """Test encoding with length marker.""" - json_text = '{"items": [1, 2, 3]}' - result = encode_json_to_toon(json_text, length_marker=True) - assert "#" in result or "items" in result - - def test_encode_invalid_json_raises_error(self): - """Test that invalid JSON raises JSONDecodeError.""" - invalid_json = '{"broken": invalid}' - with pytest.raises(json.JSONDecodeError): - encode_json_to_toon(invalid_json) - - -class TestDecodeToonToJson: - """Tests for decode_toon_to_json function.""" - - def test_basic_decode(self): - """Test basic TOON to JSON decoding.""" - toon_text = "name: Alice\nage: 30" - result = decode_toon_to_json(toon_text) - data = json.loads(result) - assert data["name"] == "Alice" - assert data["age"] == 30 - - def test_decode_with_custom_indent(self): - """Test decoding with custom indentation.""" - toon_text = "outer:\n inner: 1" - result = decode_toon_to_json(toon_text, indent=4) - data = json.loads(result) - assert data["outer"]["inner"] == 1 - - def test_decode_strict_mode(self): - """Test decoding in strict mode.""" - toon_text = "name: Alice\nage: 30" - result = decode_toon_to_json(toon_text, strict=True) - data = json.loads(result) - assert data["name"] == "Alice" - - def test_decode_lenient_mode(self): - """Test decoding in lenient mode.""" - toon_text = "name: Alice\nage: 30" - result = decode_toon_to_json(toon_text, strict=False) - data = json.loads(result) - assert data["name"] == "Alice" - - -class TestCLIMain: - """Integration tests for the main CLI function.""" - - def test_encode_from_file_to_stdout(self, tmp_path): - """Test encoding from file to stdout.""" - # Create input file - input_file = tmp_path / "input.json" - input_file.write_text('{"name": "Alice"}') - - # Mock stdout - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file), "--encode"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "name: Alice" in output - - def test_decode_from_file_to_stdout(self, tmp_path): - """Test decoding from file to stdout.""" - # Create input file - input_file = tmp_path / "input.toon" - input_file.write_text("name: Alice\nage: 30") - - # Mock stdout - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file), "--decode"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "Alice" in output - - def test_encode_from_stdin_to_stdout(self): - """Test encoding from stdin to stdout.""" - input_data = '{"name": "Bob"}' - - with patch("sys.stdin", StringIO(input_data)): - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", "-", "--encode"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "name: Bob" in output - - def test_decode_from_stdin_to_stdout(self): - """Test decoding from stdin to stdout.""" - input_data = "name: Charlie\nage: 25" - - with patch("sys.stdin", StringIO(input_data)): - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", "-", "--decode"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "Charlie" in output - - def test_encode_to_output_file(self, tmp_path): - """Test encoding with output file.""" - input_file = tmp_path / "input.json" - output_file = tmp_path / "output.toon" - input_file.write_text('{"name": "Dave"}') - - with patch("sys.argv", ["toon", str(input_file), "-o", str(output_file), "--encode"]): - result = main() - assert result == 0 - assert output_file.exists() - content = output_file.read_text() - assert "name: Dave" in content - - def test_decode_to_output_file(self, tmp_path): - """Test decoding with output file.""" - input_file = tmp_path / "input.toon" - output_file = tmp_path / "output.json" - input_file.write_text("name: Eve\nage: 35") - - with patch("sys.argv", ["toon", str(input_file), "-o", str(output_file), "--decode"]): - result = main() - assert result == 0 - assert output_file.exists() - content = output_file.read_text() - data = json.loads(content) - assert data["name"] == "Eve" - - def test_auto_detect_json_extension(self, tmp_path): - """Test auto-detection based on .json extension.""" - input_file = tmp_path / "data.json" - input_file.write_text('{"test": true}') - - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file)]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "test: true" in output - - def test_auto_detect_toon_extension(self, tmp_path): - """Test auto-detection based on .toon extension.""" - input_file = tmp_path / "data.toon" - input_file.write_text("test: true") - - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file)]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "true" in output - - def test_auto_detect_json_content(self, tmp_path): - """Test auto-detection based on JSON content.""" - input_file = tmp_path / "data.txt" - input_file.write_text('{"format": "json"}') - - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file)]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "format: json" in output - - def test_auto_detect_toon_content(self, tmp_path): - """Test auto-detection based on TOON content.""" - input_file = tmp_path / "data.txt" - input_file.write_text("format: toon") - - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file)]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "toon" in output - - def test_auto_detect_stdin_json(self): - """Test auto-detection from stdin with JSON content.""" - input_data = '{"source": "stdin"}' - - with patch("sys.stdin", StringIO(input_data)): - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", "-"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "source: stdin" in output - - def test_auto_detect_stdin_toon(self): - """Test auto-detection from stdin with TOON content.""" - input_data = "source: stdin" - - with patch("sys.stdin", StringIO(input_data)): - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", "-"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert "stdin" in output - - def test_custom_delimiter_option(self, tmp_path): - """Test custom delimiter option.""" - input_file = tmp_path / "input.json" - input_file.write_text('{"items": [1, 2, 3]}') - - with patch("sys.stdout", new_callable=StringIO): - with patch("sys.argv", ["toon", str(input_file), "--encode", "--delimiter", "|"]): - result = main() - assert result == 0 - - def test_custom_indent_option(self, tmp_path): - """Test custom indent option.""" - input_file = tmp_path / "input.json" - input_file.write_text('{"outer": {"inner": 1}}') - - with patch("sys.stdout", new_callable=StringIO): - with patch("sys.argv", ["toon", str(input_file), "--encode", "--indent", "4"]): - result = main() - assert result == 0 - - def test_length_marker_option(self, tmp_path): - """Test length marker option.""" - input_file = tmp_path / "input.json" - input_file.write_text('{"items": [1, 2, 3]}') - - with patch("sys.stdout", new_callable=StringIO): - with patch("sys.argv", ["toon", str(input_file), "--encode", "--length-marker"]): - result = main() - assert result == 0 - - def test_no_strict_option(self, tmp_path): - """Test no-strict option.""" - input_file = tmp_path / "input.toon" - input_file.write_text("name: Test") - - with patch("sys.stdout", new_callable=StringIO): - with patch("sys.argv", ["toon", str(input_file), "--decode", "--no-strict"]): - result = main() - assert result == 0 - - def test_decode_indent_option_affects_output(self, tmp_path): - """Ensure --indent controls the JSON formatting.""" - input_file = tmp_path / "input.toon" - input_file.write_text("outer:\n inner: 1") - - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - with patch("sys.argv", ["toon", str(input_file), "--decode", "--indent", "4"]): - result = main() - assert result == 0 - output = mock_stdout.getvalue() - assert ' "inner": 1' in output - - def test_error_file_not_found(self): - """Test error when input file doesn't exist.""" - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch("sys.argv", ["toon", "nonexistent.json"]): - result = main() - assert result == 1 - assert "not found" in mock_stderr.getvalue() - - def test_error_both_encode_and_decode(self, tmp_path): - """Test error when both --encode and --decode are specified.""" - input_file = tmp_path / "input.txt" - input_file.write_text("test") - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch("sys.argv", ["toon", str(input_file), "--encode", "--decode"]): - result = main() - assert result == 1 - assert "Cannot specify both" in mock_stderr.getvalue() - - def test_error_during_encoding(self, tmp_path): - """Test error handling during encoding.""" - input_file = tmp_path / "input.json" - input_file.write_text('{"invalid": broken}') - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch("sys.argv", ["toon", str(input_file), "--encode"]): - result = main() - assert result == 1 - assert "Error during encode" in mock_stderr.getvalue() - - def test_error_reading_input(self): - """Test error when reading input fails.""" - mock_stdin = MagicMock() - mock_stdin.read.side_effect = OSError("Read failed") - - with patch("sys.stdin", mock_stdin): - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch("sys.argv", ["toon", "-", "--encode"]): - result = main() - assert result == 1 - assert "Error reading input" in mock_stderr.getvalue() - - def test_error_writing_output(self, tmp_path): - """Test error when writing output fails.""" - input_file = tmp_path / "input.json" - input_file.write_text('{"test": true}') - - # Create a read-only directory to cause write failure - output_file = tmp_path / "readonly" / "output.toon" - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch("sys.argv", ["toon", str(input_file), "-o", str(output_file), "--encode"]): - result = main() - assert result == 1 - assert "Error writing output" in mock_stderr.getvalue() diff --git a/tests/test_decode.py b/tests/test_decode.py new file mode 100644 index 0000000..05e5c15 --- /dev/null +++ b/tests/test_decode.py @@ -0,0 +1,311 @@ +"""Decoder behavior beyond the specification fixtures.""" + +from __future__ import annotations + +import pickle +import sys +from collections import OrderedDict +from decimal import Decimal +from typing import Any + +import pytest + +import toon_format + +# -- numbers (§4) --------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected", "kind"), + [ + ("42", 42, int), + ("-0", 0, int), + ("1180591620717411303424", 2**70, int), + ("1.5", 1.5, float), + ("1e3", 1000.0, float), + ("-0.0", 0.0, float), + ("1e-400", 0.0, float), + ], +) +def test_number_types(text: str, expected: Any, kind: type) -> None: + value = toon_format.loads(text) + assert value == expected + assert type(value) is kind + + +def test_negative_zero_decodes_to_positive_zero() -> None: + assert str(toon_format.loads("-0.0")) == "0.0" + + +def test_out_of_range_floats() -> None: + with pytest.raises(toon_format.ToonDecodeError, match="out of range"): + toon_format.loads("x: 1e400") + assert toon_format.loads("x: -1e400", strict=False) == {"x": float("-inf")} + + +def test_parse_float_and_parse_int() -> None: + text = "a: 0.1\nb: 1e400\nc: 7" + assert toon_format.loads(text, parse_float=Decimal) == { + "a": Decimal("0.1"), + "b": Decimal("1e400"), + "c": 7, + } + assert toon_format.loads("n[2]: 1,2.5", parse_int=str) == {"n": ["1", 2.5]} + + +# -- hooks ------------------------------------------------------------------------ + + +def test_object_hook_sees_every_object() -> None: + text = "a:\n b: 1\nrows[1]{x,g{y}}:\n 1,2\nm[2:]{v}:\n k: 1\n l: 2" + seen: list[dict[str, Any]] = [] + + def hook(obj: dict[str, Any]) -> dict[str, Any]: + seen.append(dict(obj)) + return obj + + toon_format.loads(text, object_hook=hook) + assert seen == [ + {"b": 1}, + {"y": 2}, + {"x": 1, "g": {"y": 2}}, + {"v": 1}, + {"v": 2}, + {"k": {"v": 1}, "l": {"v": 2}}, + { + "a": {"b": 1}, + "rows": [{"x": 1, "g": {"y": 2}}], + "m": {"k": {"v": 1}, "l": {"v": 2}}, + }, + ] + + +def test_object_pairs_hook_takes_priority() -> None: + result = toon_format.loads( + "b: 1\na:\n c: 2", object_pairs_hook=OrderedDict, object_hook=lambda obj: 1 / 0 + ) + assert result == OrderedDict([("b", 1), ("a", OrderedDict([("c", 2)]))]) + assert isinstance(result["a"], OrderedDict) + + +def test_empty_document_goes_through_the_hook() -> None: + assert toon_format.loads("", object_pairs_hook=list) == [] + + +# -- input handling (§4, §12) --------------------------------------------------------- + + +def test_bytes_input() -> None: + assert toon_format.loads("name: café".encode()) == {"name": "café"} + assert toon_format.loads(bytearray(b"a: 1")) == {"a": 1} + + +def test_invalid_utf8_is_an_error() -> None: + with pytest.raises(toon_format.ToonDecodeError, match="Invalid UTF-8"): + toon_format.loads(b"a: \xff") + with pytest.raises(toon_format.ToonDecodeError, match="Invalid UTF-8"): + toon_format.loads(b"a: \xed\xa0\x80") # an encoded surrogate + + +def test_wrong_input_type() -> None: + with pytest.raises(TypeError, match="must be str, bytes or bytearray"): + toon_format.loads(42) # type: ignore[arg-type] + + +def test_bom_and_line_endings() -> None: + assert toon_format.loads("\ufeffa: 1\r\nb:\r\n c: 2\r\n") == { + "a": 1, + "b": {"c": 2}, + } + assert toon_format.loads("a: x\ry") == { + "a": "x\ry" + } # a CR inside a line is content + + +# -- errors ------------------------------------------------------------------------- + + +def test_error_attributes() -> None: + with pytest.raises(toon_format.ToonDecodeError) as info: + toon_format.loads("a: 1\nitems[3]: x,y") + error = info.value + assert isinstance(error, ValueError) + assert error.line == 2 + assert error.source == "items[3]: x,y" + assert error.msg == "Declared length 3 but found 2" + assert str(error) == "line 2: Declared length 3 but found 2" + + +def test_error_is_picklable() -> None: + error = toon_format.ToonDecodeError("boom", 3, "x") + clone = pickle.loads(pickle.dumps(error)) + assert (clone.msg, clone.line, clone.source, str(clone)) == ( + "boom", + 3, + "x", + "line 3: boom", + ) + + +@pytest.mark.parametrize( + "text", + [ + 'a: "unterminated', + 'a: "bad \\x escape"', + 'a: "\\u12"', + 'a: "\\ud83d\\ude80"', # surrogate escapes are rejected even in pairs (§7.1) + 'a: "x" y', + '"k"x: 1', + "a\nb", + "a:\n b: 1\n loose", + ], +) +def test_errors_in_any_mode(text: str) -> None: + for strict in (True, False): + with pytest.raises(toon_format.ToonDecodeError): + toon_format.loads(text, strict=strict) + + +def test_literal_control_characters_in_quotes() -> None: + with pytest.raises(toon_format.ToonDecodeError, match="control character"): + toon_format.loads('a: "x\x01y"') + assert toon_format.loads('a: "x\x01y"', strict=False) == {"a": "x\x01y"} + assert toon_format.loads('a: "x\ty"') == { + "a": "x\ty" + } # a literal tab is tolerated (§7.1) + + +def test_deep_nesting_raises_decode_error() -> None: + text = "\n".join(" " * depth + "a:" for depth in range(5000)) + with pytest.raises(toon_format.ToonDecodeError, match="nested too deeply"): + toon_format.loads(text) + + +# -- non-strict leniency (§12, §14) ---------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("a:\n\tb: 1", {"a": {"b": 1}}), # a tab is one level + ("a:\n b: 1\n c: 2", {"a": {"b": 1, "c": 2}}), # depth jump + ("items[2]:\n - x\n - y", {"items": ["x", "y"]}), + ("a: 1\n b: 2\nc: 3", {"a": 1, "c": 3}), # over-indented line is skipped + ("a: 1\nb: 2\na: 3", {"a": 3, "b": 2}), # last write wins, first position kept + ("t[2]{x,y}:\n 1\n 2,3,4", {"t": [{"x": 1}, {"x": 2, "y": 3}]}), + ("[2]: a\njunk: 1", ["a"]), # trailing content after a root array is ignored + ], +) +def test_non_strict(text: str, expected: Any) -> None: + with pytest.raises(toon_format.ToonDecodeError): + toon_format.loads(text) + assert toon_format.loads(text, strict=False) == expected + + +def test_indent_size_option() -> None: + assert toon_format.loads("a:\n b: 1", indent_size=4) == {"a": {"b": 1}} + with pytest.raises(toon_format.ToonDecodeError, match="multiple of 4"): + toon_format.loads("a:\n b: 1", indent_size=4) + + +# -- regressions from the issue tracker --------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("[]", []), # issue #61 + ("items: []", {"items": []}), + ( + 'items[1]:\n - a:\n b: "123"', + {"items": [{"a": {"b": "123"}}]}, + ), # issue #33 + ( + 'text: "He said \\"hi: there\\""', + {"text": 'He said "hi: there"'}, + ), # issue #40 + ('a: "x,y"\nb[2]: "1,2","3"', {"a": "x,y", "b": ["1,2", "3"]}), + ], +) +def test_regressions(text: str, expected: Any) -> None: + assert toon_format.loads(text) == expected + + +def test_nested_length_mismatch_is_reported() -> None: + # issue #47: a count mismatch deep inside must not truncate the document silently. + text = "outer[1]:\n - inner[3]: a,b\n x: 1\ntail: 2" + with pytest.raises( + toon_format.ToonDecodeError, match="Declared length 3 but found 2" + ) as info: + toon_format.loads(text) + assert info.value.line == 2 + assert toon_format.loads(text, strict=False) == { + "outer": [{"inner": ["a", "b"], "x": 1}], + "tail": 2, + } + + +def test_row_or_key_value_at_row_depth() -> None: + # §9.3: a delimiter before the first colon keeps the line a row... + assert toon_format.loads("t[1]{x,y}:\n a,b:c") == {"t": [{"x": "a", "y": "b:c"}]} + # ...and a colon first ends the rows, leaving the line in no scope. + text = "t[1]{x}:\n 1\n n: 2" + with pytest.raises(toon_format.ToonDecodeError, match="Unexpected indentation"): + toon_format.loads(text) + assert toon_format.loads(text, strict=False) == {"t": [{"x": 1}]} + + +def test_hyphen_outside_a_list_is_part_of_the_key() -> None: + # §5.2: outside the scope of an array in list form a leading hyphen is + # ordinary text. + assert toon_format.loads("- a: 1") == {"- a": 1} + assert toon_format.loads("- a") == "- a" + assert toon_format.loads("items[1]:\n - x\n- a: 1", strict=False) == { + "items": ["x"], + "- a": 1, + } + + +@pytest.mark.parametrize( + "text", + [ + # A list item where a field of the list-item object is expected (§10). + "items[1]:\n - a: 1\n - b: 2", + "items[1]:\n - a:\n - b: 2", + # A list item too deep to be an item of the array. + "items[2]:\n - a: 1\n - b: 2", + "items[1]:\n - [1]:\n - x\n - y", + ], +) +def test_list_item_outside_item_depth(text: str) -> None: + # §5.2: inside an array in list form a leading hyphen always marks a list + # item, which is admissible only at the array's item depth. + for strict in (True, False): + with pytest.raises(toon_format.ToonDecodeError): + toon_format.loads(text, strict=strict) + + +def test_integer_beyond_the_conversion_limit() -> None: + if not hasattr(sys, "set_int_max_str_digits"): + pytest.skip("no integer string conversion limit") + with pytest.raises(toon_format.ToonDecodeError, match="line 1"): + toon_format.loads("n: " + "9" * 5000) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("a:\n [2]: x,y", {"a": {"[2]": "x,y"}}), + ("l[1]:\n - [1]{v}:", {"l": [{"[1]{v}": {}}]}), + ("t[1]{a|b}:", {"t[1]{a|b}": {}}), + ("t[1]{a{b}c}:", {"t[1]{a{b}c}": {}}), + ], +) +def test_non_strict_header_fall_through(text: str, expected: Any) -> None: + with pytest.raises(toon_format.ToonDecodeError): + toon_format.loads(text) + assert toon_format.loads(text, strict=False) == expected + + +def test_unterminated_quote_in_a_field_list_hides_the_colon() -> None: + assert toon_format.loads('t[1]{"x}:') == 't[1]{"x}:' diff --git a/tests/test_decoder.py b/tests/test_decoder.py deleted file mode 100644 index 13c7736..0000000 --- a/tests/test_decoder.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Tests for Python-specific TOON decoder behavior. - -This file contains ONLY Python-specific decoder tests that are not covered -by the official spec fixtures in test_spec_fixtures.py. - -For spec compliance testing, see test_spec_fixtures.py (306 official tests). -For Python type normalization, see test_normalization.py. -For API testing, see test_api.py. -""" - -import pytest - -from toon_format import ToonDecodeError, decode -from toon_format.types import DecodeOptions - - -class TestPythonDecoderAPI: - """Test Python-specific decoder API behavior.""" - - def test_decode_with_lenient_mode(self): - """Test that lenient mode allows spec violations (Python-specific option).""" - toon = "items[5]: a,b,c" # Declared 5, only 3 values - options = DecodeOptions(strict=False) - result = decode(toon, options) - # Lenient mode accepts the mismatch - assert result == {"items": ["a", "b", "c"]} - - def test_decode_with_custom_indent_size(self): - """Test Python API accepts custom indent size.""" - toon = """parent: - child: - value: 42""" # 4-space indent - options = DecodeOptions(indent=4) - result = decode(toon, options) - assert result == {"parent": {"child": {"value": 42}}} - - def test_decode_returns_python_dict(self): - """Ensure decode returns native Python dict, not custom type.""" - toon = "id: 123" - result = decode(toon) - assert isinstance(result, dict) - assert type(result) is dict # Not a subclass - - def test_decode_returns_python_list(self): - """Ensure decode returns native Python list for arrays.""" - toon = "[3]: 1,2,3" - result = decode(toon) - assert isinstance(result, list) - assert type(result) is list # Not a subclass - - -class TestPythonErrorHandling: - """Test Python-specific error handling behavior.""" - - def test_error_type_is_toon_decode_error(self): - """Verify errors raise ToonDecodeError, not generic exceptions.""" - toon = 'text: "unterminated' - with pytest.raises(ToonDecodeError): - decode(toon) - - def test_error_is_exception_subclass(self): - """ToonDecodeError should be catchable as Exception.""" - toon = 'text: "unterminated' - with pytest.raises(Exception): # Should also catch as base Exception - decode(toon) - - def test_strict_mode_default_is_true(self): - """Default strict mode should be True (fail on violations).""" - toon = "items[5]: a,b,c" # Length mismatch - # Without options, should use strict=True by default - with pytest.raises(ToonDecodeError): - decode(toon) - - -class TestSpecEdgeCases: - """Tests for spec edge cases that must be handled correctly.""" - - def test_leading_zero_treated_as_string(self): - """Leading zeros like '05', '0001' should decode as strings (Section 4).""" - toon = "code: 05" - result = decode(toon) - assert result == {"code": "05"} - assert isinstance(result["code"], str) - - def test_leading_zero_in_array(self): - """Leading zeros in arrays should be strings.""" - toon = "codes[3]: 01,02,03" - result = decode(toon) - assert result == {"codes": ["01", "02", "03"]} - assert all(isinstance(v, str) for v in result["codes"]) - - def test_single_zero_is_number(self): - """Single '0' is a valid number, not a leading zero case.""" - toon = "value: 0" - result = decode(toon) - assert result == {"value": 0} - assert isinstance(result["value"], int) - - def test_zero_point_zero_is_number(self): - """'0.0' is a valid number.""" - toon = "value: 0.0" - result = decode(toon) - assert result == {"value": 0.0} - assert isinstance(result["value"], (int, float)) - - def test_exponent_notation_accepted(self): - """Decoder MUST accept exponent forms like 1e-6, -1E+9 (Section 4).""" - toon = """a: 1e-6 -b: -1E+9 -c: 2.5e3 -d: -3.14E-2""" - result = decode(toon) - assert result["a"] == 1e-6 - assert result["b"] == -1e9 - assert result["c"] == 2.5e3 - assert result["d"] == -3.14e-2 - - def test_exponent_notation_in_array(self): - """Exponent notation in arrays.""" - toon = "values[3]: 1e2,2e-1,3E+4" - result = decode(toon) - assert result["values"] == [1e2, 2e-1, 3e4] - - def test_array_order_preserved(self): - """Array order MUST be preserved (Section 2).""" - toon = "items[5]: 5,1,9,2,7" - result = decode(toon) - assert result["items"] == [5, 1, 9, 2, 7] - # Verify order is exact, not sorted - assert result["items"] != [1, 2, 5, 7, 9] - - def test_object_key_order_preserved(self): - """Object key order MUST be preserved (Section 2).""" - toon = """z: 1 -a: 2 -m: 3 -b: 4""" - result = decode(toon) - keys = list(result.keys()) - assert keys == ["z", "a", "m", "b"] - # Verify order is not alphabetical - assert keys != ["a", "b", "m", "z"] diff --git a/tests/test_encode.py b/tests/test_encode.py new file mode 100644 index 0000000..4bbeea8 --- /dev/null +++ b/tests/test_encode.py @@ -0,0 +1,422 @@ +"""Encoder behavior beyond the specification fixtures: host types, numbers, forms.""" + +from __future__ import annotations + +import collections +import dataclasses +import enum +import sys +import types +import uuid +from datetime import date, datetime, time, timezone +from decimal import Decimal +from pathlib import PurePosixPath +from typing import Any + +import pytest + +import toon_format + + +class Color(enum.Enum): + RED = "red" + GREEN = 2 + + +class Size(enum.IntEnum): + LARGE = 3 + + +class Mode(str, enum.Enum): + FAST = "fast" + + +@dataclasses.dataclass +class Point: + x: int + y: int + + +@dataclasses.dataclass +class Shape: + name: str + points: list[Point] + + +# -- numbers (§2, §3) ------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (0, "0"), + (-7, "-7"), + (2**70, "1180591620717411303424"), + (1.0, "1"), + (-0.0, "0"), + (1.5, "1.5"), + (0.1 + 0.2, "0.30000000000000004"), + (1e6, "1000000"), + (1e16, "10000000000000000"), + (1.5e20, "150000000000000000000"), + (1e-6, "0.000001"), + (2.5e-4, "0.00025"), + (1e21, "1e+21"), + (1.25e300, "1.25e+300"), + (1e-7, "1e-7"), + (-5e-324, "-5e-324"), + (float("nan"), "null"), + (float("inf"), "null"), + (float("-inf"), "null"), + (Decimal("1.50"), "1.5"), + (Decimal("-0.00"), "0"), + ( + Decimal("123456789012345678901234567890.5"), + "1.234567890123456789012345678905e+29", + ), + (Decimal("0.1"), "0.1"), + (Decimal("NaN"), "null"), + (Decimal("-Infinity"), "null"), + (True, "true"), + (False, "false"), + (None, "null"), + ], +) +def test_numbers_and_literals(value: Any, expected: str) -> None: + assert toon_format.dumps(value) == expected + assert toon_format.dumps({"v": value}) == f"v: {expected}" + + +@pytest.mark.parametrize( + "value", [1e-7, 1e21, 1.7976931348623157e308, 5e-324, 0.1, 123.456] +) +def test_floats_round_trip(value: float) -> None: + assert toon_format.loads(toon_format.dumps(value)) == value + + +# -- strings (§7) ------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("hello world", "hello world"), + ("", '""'), + (" padded", '" padded"'), + ("tab\t", '"tab\\t"'), + ("true", '"true"'), + ("null", '"null"'), + ("42", '"42"'), + ("05", '"05"'), + ("+1", '"+1"'), + ("1e-6", '"1e-6"'), + (".5", ".5"), + ("a:b", '"a:b"'), + ("a,b", '"a,b"'), + ("a|b", "a|b"), + ("[x]", '"[x]"'), + ("{x}", '"{x}"'), + ("- item", '"- item"'), + ("-", '"-"'), + ("#tag", '"#tag"'), + ("a#b", "a#b"), + ('say "hi"', '"say \\"hi\\""'), + ("back\\slash", '"back\\\\slash"'), + ("line\nbreak\r", '"line\\nbreak\\r"'), + ("bell\x07", '"bell\\u0007"'), + ("\x7f", "\x7f"), + ("café 🚀", "café 🚀"), + ("\xa0nbsp", "\xa0nbsp"), + ], +) +def test_string_quoting(value: str, expected: str) -> None: + assert toon_format.dumps({"v": value}) == f"v: {expected}" + assert toon_format.loads(toon_format.dumps(value)) == value + + +def test_delimiter_aware_quoting() -> None: + data = {"note": "a|b", "tags": ["a|b", "c,d"]} + assert toon_format.dumps(data, delimiter="|") == 'note: "a|b"\ntags[2|]: "a|b"|c,d' + assert ( + toon_format.dumps(data, delimiter=toon_format.Delimiter.TAB) + == "note: a|b\ntags[2\t]: a|b\tc,d" + ) + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + ("name", "name"), + ("_private", "_private"), + ("user.name", "user.name"), + ("full name", '"full name"'), + ("my-key", '"my-key"'), + ("2x", '"2x"'), + ("", '""'), + ("é", '"é"'), + ("aé", '"aé"'), # issue #65: ASCII-only pattern, even after the first character + ("日本", '"日本"'), + ], +) +def test_key_quoting(key: str, expected: str) -> None: + assert toon_format.dumps({key: 1}) == f"{expected}: 1" + + +@pytest.mark.parametrize("value", ["\ud800", {"\udfff": 1}, ["ok", "a\udc80b"]]) +def test_unpaired_surrogates_are_rejected(value: Any) -> None: + with pytest.raises(ValueError, match="surrogate"): + toon_format.dumps(value) + + +# -- host types (§3, Appendix E.3) ------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (datetime(2025, 1, 2, 3, 4, 5), '"2025-01-02T03:04:05"'), + (datetime(2025, 1, 2, tzinfo=timezone.utc), '"2025-01-02T00:00:00+00:00"'), + (date(2025, 1, 2), "2025-01-02"), + (time(13, 45), '"13:45:00"'), + (uuid.UUID(int=1), "00000000-0000-0000-0000-000000000001"), + (PurePosixPath("/tmp/data.json"), "/tmp/data.json"), + (Color.RED, "red"), + (Color.GREEN, "2"), + (Size.LARGE, "3"), + (Mode.FAST, "fast"), + ], +) +def test_scalar_host_types(value: Any, expected: str) -> None: + assert toon_format.dumps({"v": value}) == f"v: {expected}" + + +def test_collections() -> None: + data = { + "tuple": (1, 2), + "set": {3, 1, 2}, + "frozen": frozenset({"b", "a"}), + "mixed": {1, "a"}, # not mutually comparable: ordered by repr() + "mapping": types.MappingProxyType({"k": 1}), + "ordered": collections.OrderedDict(z=1, a=2), + } + assert toon_format.dumps(data) == ( + "tuple[2]: 1,2\n" + "set[3]: 1,2,3\n" + "frozen[2]: a,b\n" + "mixed[2]: a,1\n" + "mapping:\n k: 1\n" + "ordered:\n z: 1\n a: 2" + ) + + +def test_dataclasses() -> None: + shape = Shape("tri", [Point(0, 0), Point(1, 0), Point(0, 1)]) + assert toon_format.dumps(shape) == "name: tri\npoints[3]{x,y}:\n 0,0\n 1,0\n 0,1" + with pytest.raises(TypeError): + toon_format.dumps(Point) # the class itself is not data + + +def test_attrs_classes() -> None: + attrs = pytest.importorskip("attrs") + + @attrs.define + class Item: + name: str + tags: list[str] = attrs.field(factory=list) + _secret: int = attrs.field(default=0, repr=False) + + assert toon_format.dumps({"items": [Item("a", ["x"]), Item("b")]}) == ( + "items[2]:\n - name: a\n tags[1]: x\n _secret: 0\n" + " - name: b\n tags: []\n _secret: 0" + ) + with pytest.raises(TypeError): + toon_format.dumps(Item) # the class itself is not data + + +@pytest.mark.parametrize( + ("key", "expected"), + [(1, '"1"'), (1.5, '"1.5"'), (True, "true"), (None, "null"), (Size.LARGE, '"3"')], +) +def test_non_string_keys_are_coerced_like_json(key: Any, expected: str) -> None: + assert toon_format.dumps({key: "x"}) == f"{expected}: x" + + +@pytest.mark.parametrize( + "value", [{1: "a", "1": "b"}, {True: "a", "true": "b"}, {None: 1, "null": 2}] +) +def test_keys_colliding_after_conversion(value: dict[Any, Any]) -> None: + with pytest.raises(ValueError, match="Duplicate key after conversion"): + toon_format.dumps(value) + + +def test_unsupported_keys() -> None: + with pytest.raises(TypeError, match="Keys must be"): + toon_format.dumps({(1, 2): "x"}) + + +@pytest.mark.parametrize("value", [object(), b"bytes", 1j, iter([1]), print]) +def test_unsupported_values_raise_type_error(value: Any) -> None: + with pytest.raises(TypeError, match="is not TOON serializable"): + toon_format.dumps({"v": value}) + + +def test_default_hook() -> None: + def default(value: Any) -> Any: + if isinstance(value, complex): + return {"re": value.real, "im": value.imag} + raise TypeError(f"cannot encode {type(value).__name__}") + + assert toon_format.dumps({"z": 1 + 2j}, default=default) == "z:\n re: 1\n im: 2" + with pytest.raises(TypeError, match="cannot encode bytes"): + toon_format.dumps(b"x", default=default) + + +def test_default_hook_is_not_called_for_supported_types() -> None: + calls = [] + toon_format.dumps({"a": [1, "x", None]}, default=calls.append) + assert calls == [] + + +def test_sort_keys() -> None: + data = {"b": 1, "a": {"d": 2, "c": 3}, "rows": [{"y": 1, "x": 2}]} + assert ( + toon_format.dumps(data, sort_keys=True) + == "a:\n c: 3\n d: 2\nb: 1\nrows[1]{x,y}:\n 2,1" + ) + + +# -- errors --------------------------------------------------------------------- + + +def test_circular_references() -> None: + items: list[Any] = [1] + items.append(items) + with pytest.raises(ValueError, match="Circular reference"): + toon_format.dumps(items) + obj: dict[str, Any] = {} + obj["self"] = obj + with pytest.raises(ValueError, match="Circular reference"): + toon_format.dumps(obj) + + +def test_default_returning_its_argument_is_a_cycle() -> None: + with pytest.raises(ValueError, match="Circular reference"): + toon_format.dumps(object(), default=lambda value: value) + + +def test_shared_references_are_not_cycles() -> None: + shared = {"x": 1} + assert toon_format.dumps([shared, shared]) == "[2]{x}:\n 1\n 1" + + +def test_deep_nesting_raises_value_error() -> None: + value: Any = 0 + for _ in range(sys.getrecursionlimit() + 10): + value = [value] + with pytest.raises(ValueError, match="nested too deeply"): + toon_format.dumps(value) + + +# -- forms (§9, §10) and regressions from the issue tracker ------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # issue #64: an empty key keeps its level + ({"": {"a": 1}}, '"":\n a: 1'), + ({"": [1, 2]}, '""[2]: 1,2'), + # issue #62: every nested array item carries its marker + ([[[1]]], "[1]:\n - [1]:\n - [1]: 1"), + ([[[]]], "[1]:\n - [1]:\n - [0]:"), + # issue #63: an empty object as the first field of a list item + ([{"a": {}, "b": 1}], "[1]:\n - a:\n b: 1"), + # issue #61: the empty root array + ([], "[]"), + ({}, ""), + # keyed tabular form at the root and nested (§9.5) + ({"a": {"x": 1}, "b": {"x": 2}}, "[2:]{x}:\n a: 1\n b: 2"), + ({"m": {"a": {"x": 1}}}, "m:\n a:\n x: 1"), + # nested field groups (§9.3) + ( + { + "o": [ + {"id": 1, "c": {"n": "a", "k": "x"}}, + {"id": 2, "c": {"k": "y", "n": "b"}}, + ] + }, + "o[2]{id,c{n,k}}:\n 1,a,x\n 2,b,y", + ), + # a column mixing null with objects is not uniform + ( + {"o": [{"c": None}, {"c": {"n": 1}}]}, + "o[2]:\n - c: null\n - c:\n n: 1", + ), + # a tabular first field of a list-item object (§10) + ( + [{"rows": [{"a": 1}, {"a": 2}], "ok": True}], + "[1]:\n - rows[2]{a}:\n 1\n 2\n ok: true", + ), + # arrays of arrays of objects never use a keyless field list + ([[{"a": 1}, {"a": 2}]], "[1]:\n - [2]:\n - a: 1\n - a: 2"), + ], +) +def test_forms(value: Any, expected: str) -> None: + assert toon_format.dumps(value) == expected + assert toon_format.loads(expected) == value + + +def test_indent_size() -> None: + data = {"a": {"b": [{"c": 1, "d": {"e": 2}}, 3]}} + assert toon_format.dumps(data, indent_size=4) == ( + "a:\n b[2]:\n - c: 1\n d:\n e: 2\n - 3" + ) + for size in (1, 3, 4, 8): + assert ( + toon_format.loads( + toon_format.dumps(data, indent_size=size), indent_size=size + ) + == data + ) + + +def test_subclasses_of_builtins() -> None: + class Ratio(float): + pass + + class Count(int): + pass + + class Name(str): + def __str__(self) -> str: + return "overridden" + + assert ( + toon_format.dumps([Ratio(0.5), Ratio("inf"), Count(3), Name("ada")]) + == "[4]: 0.5,null,3,ada" + ) + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + (float("nan"), "NaN: 1"), + (float("inf"), "Infinity: 1"), + (float("-inf"), '"-Infinity": 1'), + ], +) +def test_non_finite_float_keys(key: float, expected: str) -> None: + assert toon_format.dumps({key: 1}) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("\ufeffabc", '"\ufeffabc"'), + ({"a": "\ufeffx"}, 'a: "\ufeffx"'), + (["\ufeffx"], '[1]: "\ufeffx"'), + ], +) +def test_leading_byte_order_mark_is_quoted(value: Any, expected: str) -> None: + assert toon_format.dumps(value) == expected + assert toon_format.loads(expected) == value diff --git a/tests/test_encoder.py b/tests/test_encoder.py deleted file mode 100644 index a40952b..0000000 --- a/tests/test_encoder.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for Python-specific TOON encoder behavior. - -This file contains ONLY Python-specific encoder tests that are not covered -by the official spec fixtures in test_spec_fixtures.py. - -For spec compliance testing, see test_spec_fixtures.py (306 official tests). -For Python type normalization, see test_normalization.py. -For API testing, see test_api.py. -""" - -from toon_format import encode -from toon_format.types import EncodeOptions - - -class TestPythonEncoderAPI: - """Test Python-specific encoder API behavior.""" - - def test_encode_accepts_dict_options(self): - """Test that encode accepts options as plain dict (Python convenience).""" - result = encode([1, 2, 3], {"delimiter": "\t"}) - assert result == "[3\t]: 1\t2\t3" - - def test_encode_accepts_encode_options_object(self): - """Test that encode accepts EncodeOptions typed object.""" - options = EncodeOptions(delimiter="|", indent=4) - result = encode([1, 2, 3], options) - assert result == "[3|]: 1|2|3" - - def test_encode_returns_python_str(self): - """Ensure encode returns native Python str, not bytes or custom type.""" - result = encode({"id": 123}) - assert isinstance(result, str) - assert type(result) is str # Not a subclass - - def test_encode_handles_none_gracefully(self): - """Test encoding None doesn't crash (Python-specific edge case).""" - result = encode(None) - assert result == "null" - assert isinstance(result, str) - - -class TestPythonTypeHandling: - """Test encoding of Python-specific types that require normalization.""" - - def test_callable_becomes_null(self): - """Callables (functions, methods) should normalize to null.""" - - def func(): - pass - - result = encode(func) - assert result == "null" - - def test_lambda_becomes_null(self): - """Lambda functions should normalize to null.""" - result = encode(lambda x: x) - assert result == "null" - - def test_class_instance_becomes_null(self): - """Custom class instances should normalize to null.""" - - class CustomClass: - pass - - obj = CustomClass() - result = encode(obj) - assert result == "null" - - def test_builtin_function_becomes_null(self): - """Built-in functions should normalize to null.""" - result = encode(len) - assert result == "null" - - -class TestNonFiniteNumbers: - """Test encoding of non-finite float values (Python-specific).""" - - def test_positive_infinity_becomes_null(self): - """float('inf') should encode as null.""" - result = encode(float("inf")) - assert result == "null" - - def test_negative_infinity_becomes_null(self): - """float('-inf') should encode as null.""" - result = encode(float("-inf")) - assert result == "null" - - def test_nan_becomes_null(self): - """float('nan') should encode as null.""" - result = encode(float("nan")) - assert result == "null" - - def test_infinity_in_object(self): - """Infinity in object should encode field as null.""" - obj = {"value": float("inf")} - result = encode(obj) - assert "value: null" in result - - def test_nan_in_array(self): - """NaN in array should encode as null.""" - arr = [1, float("nan"), 3] - result = encode(arr) - assert "[3]: 1,null,3" in result - - -class TestPythonOptionsHandling: - """Test Python-specific options handling.""" - - def test_invalid_option_type_handling(self): - """Test that invalid options don't cause crashes.""" - # Should either accept or raise a clear error, not crash - try: - result = encode([1, 2, 3], {"delimiter": 123}) # Invalid type - # If accepted, verify output exists - assert result is not None - except (TypeError, ValueError, AttributeError): - # Also acceptable to reject invalid types - pass - - def test_options_with_none_values(self): - """Test that None option values are handled gracefully.""" - # Should use defaults for None values or raise clear error - try: - result = encode([1, 2, 3], {"delimiter": None}) - assert result is not None - except (TypeError, ValueError, AttributeError): - # Also acceptable to reject None - pass - - def test_encode_with_extra_unknown_options(self): - """Test that unknown options are ignored (forward compatibility).""" - # Unknown options should be ignored, not cause errors - result = encode([1, 2, 3], {"delimiter": ",", "unknown_option": "value"}) - assert result == "[3]: 1,2,3" - - -class TestNumberPrecisionSpec: - """Tests for number precision requirements per Section 2 of spec.""" - - def test_no_scientific_notation_in_output(self): - """Encoders MUST NOT use scientific notation (Section 2).""" - # Large numbers should be written in full decimal form - data = {"big": 1000000} - result = encode(data) - assert "1000000" in result - assert "1e6" not in result.lower() - assert "1e+6" not in result.lower() - - def test_small_decimals_no_scientific_notation(self): - """Small decimals should not use scientific notation.""" - data = {"small": 0.000001} - result = encode(data) - assert "0.000001" in result - assert "1e-6" not in result.lower() - - def test_round_trip_precision_preserved(self): - """Numbers must preserve round-trip fidelity (Section 2).""" - original = { - "float": 3.14159265358979, - "small": 0.1 + 0.2, - "large": 999999999999999, - } - toon = encode(original) - from toon_format import decode - - decoded = decode(toon) - - # Should round-trip with fidelity - assert decoded["float"] == original["float"] - assert decoded["small"] == original["small"] - assert decoded["large"] == original["large"] - - def test_negative_zero_normalized(self): - """-0 MUST be normalized to 0 (Section 2).""" - data = {"value": -0.0} - result = encode(data) - # Should not contain "-0" - assert "-0" not in result - # Should contain positive 0 - assert "value: 0" in result - - def test_negative_zero_in_array(self): - """-0 in arrays should be normalized.""" - data = [-0.0, 0.0, 1.0] - result = encode(data) - # Should not have -0 - assert "-0" not in result - - def test_key_order_preserved(self): - """Object key order MUST be preserved (Section 2).""" - from collections import OrderedDict - - # Use OrderedDict to ensure specific order - data = OrderedDict([("z", 1), ("a", 2), ("m", 3)]) - result = encode(data) - lines = result.split("\n") - # Verify order in output - assert "z:" in lines[0] - assert "a:" in lines[1] - assert "m:" in lines[2] diff --git a/tests/test_internationalization.py b/tests/test_internationalization.py deleted file mode 100644 index 225f778..0000000 --- a/tests/test_internationalization.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Internationalization tests for TOON format (Section 16 of spec). - -Tests Unicode support, emoji handling, and UTF-8 encoding per -TOON specification Section 16 (Internationalization). -""" - -from toon_format import decode, encode - - -class TestUnicodeSupport: - """Tests for full Unicode support in keys and values.""" - - def test_emoji_in_string_values(self): - """Emoji should be preserved in string values.""" - data = {"message": "Hello 👋 World 🌍"} - - result = encode(data) - assert "👋" in result - assert "🌍" in result - - decoded = decode(result) - assert decoded["message"] == "Hello 👋 World 🌍" - - def test_emoji_in_array_values(self): - """Emoji should work in array elements.""" - data = {"tags": ["🎉", "🎊", "🎈"]} - - result = encode(data) - assert "🎉" in result - - decoded = decode(result) - assert decoded["tags"] == ["🎉", "🎊", "🎈"] - - def test_emoji_in_object_keys(self): - """Emoji should work in object keys (when quoted).""" - # Emoji keys need to be quoted per spec (not matching identifier pattern) - data = {"status": "👍"} - - result = encode(data) - decoded = decode(result) - assert decoded["status"] == "👍" - - def test_chinese_characters(self): - """Chinese characters should be preserved.""" - data = {"greeting": "你好世界", "items": ["苹果", "香蕉", "橙子"]} - - result = encode(data) - assert "你好世界" in result - - decoded = decode(result) - assert decoded["greeting"] == "你好世界" - assert decoded["items"] == ["苹果", "香蕉", "橙子"] - - def test_arabic_characters(self): - """Arabic characters should be preserved.""" - data = {"greeting": "مرحبا بالعالم", "numbers": ["واحد", "اثنان", "ثلاثة"]} - - result = encode(data) - assert "مرحبا" in result - - decoded = decode(result) - assert decoded["greeting"] == "مرحبا بالعالم" - assert decoded["numbers"] == ["واحد", "اثنان", "ثلاثة"] - - def test_japanese_characters(self): - """Japanese characters (Hiragana, Katakana, Kanji) should be preserved.""" - data = {"hiragana": "こんにちは", "katakana": "カタカナ", "kanji": "漢字"} - - result = encode(data) - assert "こんにちは" in result - assert "カタカナ" in result - assert "漢字" in result - - decoded = decode(result) - assert decoded["hiragana"] == "こんにちは" - assert decoded["katakana"] == "カタカナ" - assert decoded["kanji"] == "漢字" - - def test_korean_characters(self): - """Korean characters (Hangul) should be preserved.""" - data = {"greeting": "안녕하세요"} - - result = encode(data) - assert "안녕하세요" in result - - decoded = decode(result) - assert decoded["greeting"] == "안녕하세요" - - def test_cyrillic_characters(self): - """Cyrillic characters should be preserved.""" - data = {"greeting": "Привет мир", "items": ["Москва", "Санкт-Петербург"]} - - result = encode(data) - assert "Привет" in result - - decoded = decode(result) - assert decoded["greeting"] == "Привет мир" - assert decoded["items"] == ["Москва", "Санкт-Петербург"] - - def test_mixed_scripts(self): - """Mixed scripts in the same document should work.""" - data = {"english": "Hello", "chinese": "你好", "arabic": "مرحبا", "emoji": "👋"} - - result = encode(data) - decoded = decode(result) - - assert decoded["english"] == "Hello" - assert decoded["chinese"] == "你好" - assert decoded["arabic"] == "مرحبا" - assert decoded["emoji"] == "👋" - - -class TestUTF8Encoding: - """Tests for UTF-8 encoding compliance.""" - - def test_utf8_roundtrip(self): - """UTF-8 strings should roundtrip correctly.""" - # Various Unicode characters - data = { - "ascii": "Hello", - "latin": "Café", - "symbols": "©®™", - "math": "∑∫∂", - "arrows": "←→↑↓", - "emoji": "😀😃😄", - } - - result = encode(data) - # Result should be UTF-8 encodable - utf8_bytes = result.encode("utf-8") - assert isinstance(utf8_bytes, bytes) - - # Should decode back correctly - decoded = decode(result) - assert decoded == data - - def test_bmp_characters(self): - """Basic Multilingual Plane characters should work.""" - # Characters in BMP (U+0000 to U+FFFF) - data = {"text": "Hello\u00a9World\u2603"} # © and ☃ - - result = encode(data) - decoded = decode(result) - assert decoded["text"] == "Hello©World☃" - - def test_supplementary_plane_characters(self): - """Supplementary plane characters (above U+FFFF) should work.""" - # Mathematical Alphanumeric Symbols (U+1D400-U+1D7FF) - # Emoji (U+1F300-U+1F9FF) - data = {"text": "𝕳𝖊𝖑𝖑𝖔 🌟"} # Gothic letters and star emoji - - result = encode(data) - decoded = decode(result) - assert "𝕳𝖊𝖑𝖑𝖔" in decoded["text"] - assert "🌟" in decoded["text"] - - def test_zero_width_characters(self): - """Zero-width characters should be preserved.""" - # Zero-width joiner and zero-width space - data = {"text": "Hello\u200bWorld\u200d"} - - result = encode(data) - decoded = decode(result) - assert decoded["text"] == "Hello\u200bWorld\u200d" - - def test_combining_characters(self): - """Combining diacritical marks should be preserved.""" - # e with combining acute accent - data = {"text": "e\u0301"} # é as e + combining acute - - result = encode(data) - decoded = decode(result) - assert decoded["text"] == "e\u0301" - - def test_rtl_text(self): - """Right-to-left text should be preserved.""" - data = {"hebrew": "שלום", "arabic": "مرحبا"} - - result = encode(data) - decoded = decode(result) - assert decoded["hebrew"] == "שלום" - assert decoded["arabic"] == "مرحبا" - - -class TestSpecialUnicodeScenarios: - """Tests for special Unicode scenarios.""" - - def test_emoji_with_skin_tone_modifiers(self): - """Emoji with skin tone modifiers should be preserved.""" - data = {"emoji": "👋🏻👋🏼👋🏽👋🏾👋🏿"} - - result = encode(data) - decoded = decode(result) - assert decoded["emoji"] == "👋🏻👋🏼👋🏽👋🏾👋🏿" - - def test_emoji_with_zwj_sequences(self): - """Emoji ZWJ sequences (family emojis etc) should be preserved.""" - # Family emoji composed with ZWJ - data = {"family": "👨\u200d👩\u200d👧\u200d👦"} - - result = encode(data) - decoded = decode(result) - assert decoded["family"] == "👨\u200d👩\u200d👧\u200d👦" - - def test_flag_emojis(self): - """Flag emojis (regional indicator symbols) should be preserved.""" - # US flag: 🇺🇸 (U+1F1FA U+1F1F8) - data = {"flags": "🇺🇸🇬🇧🇯🇵"} - - result = encode(data) - decoded = decode(result) - assert decoded["flags"] == "🇺🇸🇬🇧🇯🇵" - - def test_unicode_in_tabular_format(self): - """Unicode should work in tabular array format.""" - data = { - "users": [ - {"name": "Alice", "emoji": "😀"}, - {"name": "Bob", "emoji": "😃"}, - {"name": "李明", "emoji": "😄"}, - ] - } - - result = encode(data) - decoded = decode(result) - assert decoded["users"][0]["emoji"] == "😀" - assert decoded["users"][2]["name"] == "李明" - - def test_unicode_with_internal_spaces(self): - """Unicode with internal spaces should work unquoted.""" - data = {"text": "Hello 世界 Привет"} - - result = encode(data) - # Internal spaces are safe unquoted per spec - decoded = decode(result) - assert decoded["text"] == "Hello 世界 Привет" - - def test_unicode_normalization_preserved(self): - """Different Unicode normalizations should be preserved as-is.""" - # NFD vs NFC forms of é - nfc = {"text": "\u00e9"} # é as single character (NFC) - nfd = {"text": "e\u0301"} # é as e + combining accent (NFD) - - result_nfc = encode(nfc) - result_nfd = encode(nfd) - - decoded_nfc = decode(result_nfc) - decoded_nfd = decode(result_nfd) - - # Should preserve the original normalization form - assert decoded_nfc["text"] == "\u00e9" - assert decoded_nfd["text"] == "e\u0301" - # These are visually the same but different Unicode representations - assert decoded_nfc["text"] != decoded_nfd["text"] - - -class TestLocaleIndependence: - """Tests that TOON is locale-independent per Section 16.""" - - def test_numbers_not_locale_formatted(self): - """Numbers should not use locale-specific formatting.""" - data = {"value": 1000000.5} - - result = encode(data) - # Should not have thousands separators or locale-specific decimal - assert "1000000.5" in result or "1000000" in result - # Should not have comma thousand separators - assert "1,000,000" not in result - # Should not have locale-specific decimal separator - assert "1000000,5" not in result - - decoded = decode(result) - assert decoded["value"] == 1000000.5 - - def test_booleans_not_locale_formatted(self): - """Booleans should always be true/false, not locale variants.""" - data = {"flag": True} - - result = encode(data) - # Should be lowercase "true", not "True" or locale variants - assert "flag: true" in result - assert "True" not in result - assert "TRUE" not in result - - decoded = decode(result) - assert decoded["flag"] is True - - def test_null_not_locale_formatted(self): - """Null should always be "null", not locale variants.""" - data = {"value": None} - - result = encode(data) - # Should be lowercase "null" - assert "value: null" in result - assert "None" not in result - assert "NULL" not in result - - decoded = decode(result) - assert decoded["value"] is None diff --git a/tests/test_normalization.py b/tests/test_normalization.py deleted file mode 100644 index 8c16d65..0000000 --- a/tests/test_normalization.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Tests for Python-specific type normalization in TOON format. - -This module tests Python-specific behavior not covered by the official TOON spec -(which targets JavaScript/JSON). These tests ensure Python types are correctly -normalized to JSON-compatible values: - -1. Large integers (>2^53-1) → strings for JavaScript compatibility -2. Python types (set, tuple, frozenset) → sorted lists -3. Negative zero → positive zero -4. Non-finite floats (inf, -inf, NaN) → null -5. Decimal → float conversion -6. Octal-like strings → properly quoted -7. Heterogeneous type sorting → stable, deterministic order - -Note: TOON spec v1.3 compliance is tested in test_spec_fixtures.py using -official fixtures from https://github.com/toon-format/spec -""" - -from decimal import Decimal -from pathlib import Path, PurePosixPath, PureWindowsPath - -from toon_format import decode, encode - - -class TestLargeIntegers: - """Test large integer handling (>2^53-1).""" - - def test_large_positive_integer(self) -> None: - """Python integers (arbitrary precision) stay as integers.""" - max_safe_int = 2**53 - 1 - large_int = 2**60 - - # Small integers stay as integers - result = encode({"small": max_safe_int}) - assert "small: 9007199254740991" in result - - # Large integers also stay as integers (Python has arbitrary precision) - result = encode({"bignum": large_int}) - assert "bignum: 1152921504606846976" in result - - # Round-trip verification - decoded = decode(result) - assert decoded["bignum"] == 1152921504606846976 - - def test_large_negative_integer(self) -> None: - """Large negative integers stay as integers (Python arbitrary precision).""" - large_negative = -(2**60) - result = encode({"neg": large_negative}) - assert "neg: -1152921504606846976" in result - - # Round-trip verification - decoded = decode(result) - assert decoded["neg"] == -1152921504606846976 - - def test_boundary_cases(self) -> None: - """Test exact boundaries of MAX_SAFE_INTEGER (Python keeps all as integers).""" - max_safe = 2**53 - 1 - just_over = 2**53 - - result_safe = encode({"safe": max_safe}) - result_over = encode({"over": just_over}) - - # At boundary: integer - assert "safe: 9007199254740991" in result_safe - - # Just over boundary: still integer (Python has arbitrary precision) - assert "over: 9007199254740992" in result_over - - -class TestOctalStrings: - """Test octal-like string quoting.""" - - def test_octal_like_strings_are_quoted(self) -> None: - """Strings that look like octal numbers must be quoted.""" - result = encode({"code": "0123"}) - assert 'code: "0123"' in result - - result = encode({"zip": "0755"}) - assert 'zip: "0755"' in result - - def test_single_zero_not_quoted(self) -> None: - """Single '0' is not octal-like.""" - result = encode({"zero": "0"}) - # Single "0" looks like a number, so it should be quoted - assert 'zero: "0"' in result - - def test_zero_with_non_octal_digits(self) -> None: - """'0' followed by non-octal digits.""" - result = encode({"val": "0999"}) - # This looks like octal pattern (starts with 0 followed by digits) - assert 'val: "0999"' in result - - def test_octal_in_array(self) -> None: - """Octal-like strings in arrays.""" - result = encode(["0123", "0456"]) - assert '"0123"' in result - assert '"0456"' in result - - # Round-trip verification - decoded = decode(result) - assert decoded == ["0123", "0456"] - - -class TestSetOrdering: - """Test set ordering for deterministic output.""" - - def test_numeric_set_sorted(self) -> None: - """Sets of numbers should be sorted.""" - data = {"tags": {3, 1, 2}} - result1 = encode(data) - result2 = encode(data) - - # Should be deterministic - assert result1 == result2 - - # Should be sorted: 1, 2, 3 - decoded = decode(result1) - assert decoded["tags"] == [1, 2, 3] - - def test_string_set_sorted(self) -> None: - """Sets of strings should be sorted.""" - data = {"items": {"zebra", "apple", "mango"}} - result = encode(data) - - decoded = decode(result) - assert decoded["items"] == ["apple", "mango", "zebra"] - - def test_set_ordering_consistency(self) -> None: - """Multiple encodes of the same set should produce identical output.""" - data = {"nums": {5, 2, 8, 1, 9, 3}} - - results = [encode(data) for _ in range(5)] - - # All results should be identical - assert all(r == results[0] for r in results) - - # Should be sorted - decoded = decode(results[0]) - assert decoded["nums"] == [1, 2, 3, 5, 8, 9] - - -class TestNegativeZero: - """Test negative zero normalization.""" - - def test_negative_zero_becomes_zero(self) -> None: - """Negative zero should be normalized to positive zero.""" - data = {"val": -0.0} - result = encode(data) - - # Should be "val: 0", not "val: -0" - assert "val: 0" in result or "val: 0.0" in result - # Should NOT contain "-0" - assert "-0" not in result - - def test_negative_zero_in_array(self) -> None: - """Negative zero in arrays.""" - data = [-0.0, 0.0, 1.0] - result = encode(data) - - # Should not contain "-0" - assert "-0" not in result - - decoded = decode(result) - # Both should be 0 - assert decoded[0] == 0 - assert decoded[1] == 0 - - def test_regular_negative_numbers_preserved(self) -> None: - """Regular negative numbers should not be affected.""" - data = {"neg": -1.5} - result = encode(data) - - assert "neg: -1.5" in result - - -class TestNonFiniteFloats: - """Test non-finite float handling (inf, -inf, nan).""" - - def test_positive_infinity(self) -> None: - """Positive infinity should become null.""" - data = {"inf": float("inf")} - result = encode(data) - - assert "inf: null" in result - - decoded = decode(result) - assert decoded["inf"] is None - - def test_negative_infinity(self) -> None: - """Negative infinity should become null.""" - data = {"ninf": float("-inf")} - result = encode(data) - - assert "ninf: null" in result - - decoded = decode(result) - assert decoded["ninf"] is None - - def test_nan(self) -> None: - """NaN should become null.""" - data = {"nan": float("nan")} - result = encode(data) - - assert "nan: null" in result - - decoded = decode(result) - assert decoded["nan"] is None - - def test_all_non_finite_in_array(self) -> None: - """All non-finite values in an array.""" - data = [float("inf"), float("-inf"), float("nan"), 1.5, 2.0] - result = encode(data) - - decoded = decode(result) - assert decoded == [None, None, None, 1.5, 2.0] - - def test_mixed_object_with_non_finite(self) -> None: - """Object with mix of finite and non-finite values.""" - data = { - "normal": 3.14, - "inf": float("inf"), - "ninf": float("-inf"), - "nan": float("nan"), - "zero": 0.0, - } - result = encode(data) - - decoded = decode(result) - assert decoded["normal"] == 3.14 - assert decoded["inf"] is None - assert decoded["ninf"] is None - assert decoded["nan"] is None - assert decoded["zero"] == 0 - - -class TestHeterogeneousSets: - """Test heterogeneous set handling with fallback sorting.""" - - def test_mixed_types_in_set(self) -> None: - """Sets with mixed types should use stable fallback sorting.""" - # Note: In Python, you can't directly create {1, "a"} because sets require hashable items - # But normalization converts sets to lists, and we can test mixed lists - data = {"mixed": {1, 2, 3}} # Start with same-type set - result = encode(data) - - # Should not crash - decoded = decode(result) - assert isinstance(decoded["mixed"], list) - - def test_heterogeneous_set_deterministic(self) -> None: - """Heterogeneous sets should produce deterministic output.""" - # Create a set that would challenge sorting - data = {"items": {42, 7, 15}} - - results = [encode(data) for _ in range(3)] - - # Should all be the same - assert all(r == results[0] for r in results) - - def test_empty_set(self) -> None: - """Empty sets should encode properly.""" - data = {"empty": set()} - result = encode(data) - - decoded = decode(result) - assert decoded["empty"] == [] - - def test_single_element_set(self) -> None: - """Single-element sets.""" - data = {"single": {42}} - result = encode(data) - - decoded = decode(result) - assert decoded["single"] == [42] - - -class TestEdgeCaseCombinations: - """Test combinations of edge cases.""" - - def test_large_int_in_set(self) -> None: - """Large integers in sets.""" - large_int = 2**60 - data = {"big_set": {large_int, 100, 200}} - result = encode(data) - - decoded = decode(result) - # All integers stay as integers (Python has arbitrary precision) - assert 1152921504606846976 in decoded["big_set"] - assert 100 in decoded["big_set"] - assert 200 in decoded["big_set"] - - def test_octal_strings_in_object_keys(self) -> None: - """Octal-like strings as object keys are handled differently.""" - # In TOON, object keys have different quoting rules - data = {"0123": "value"} - result = encode(data) - - # Should encode successfully - assert result is not None - - # Round-trip should work - decoded = decode(result) - assert "0123" in decoded - assert decoded["0123"] == "value" - - def test_complex_nested_edge_cases(self) -> None: - """Complex nesting with multiple edge cases.""" - data = { - "sets": {1, 2, 3}, - "large": 2**60, - "octal": "0755", - "inf": float("inf"), - "neg_zero": -0.0, - "nested": {"more_sets": {"z", "a", "m"}, "nan": float("nan")}, - } - - result = encode(data) - - # Should encode without errors - assert result is not None - - # Should round-trip correctly - decoded = decode(result) - assert decoded["sets"] == [1, 2, 3] - assert decoded["large"] == 1152921504606846976 # Integer stays as integer - assert decoded["octal"] == "0755" - assert decoded["inf"] is None - assert decoded["neg_zero"] == 0 - assert decoded["nested"]["more_sets"] == ["a", "m", "z"] - assert decoded["nested"]["nan"] is None - - -class TestPythonTypeNormalization: - """Test normalization of Python-specific types to JSON-compatible values.""" - - def test_tuple_to_list(self): - """Tuples should be converted to arrays.""" - result = encode({"items": (1, 2, 3)}) - decoded = decode(result) - assert decoded == {"items": [1, 2, 3]} - - def test_tuple_preserves_order(self): - """Tuple order should be preserved in conversion.""" - result = encode({"coords": (3, 1, 4, 1, 5)}) - assert "[5]: 3,1,4,1,5" in result - decoded = decode(result) - assert decoded["coords"] == [3, 1, 4, 1, 5] - - def test_frozenset_to_sorted_list(self): - """Frozensets should be converted to sorted arrays.""" - result = encode({"items": frozenset([3, 1, 2])}) - decoded = decode(result) - assert decoded == {"items": [1, 2, 3]} - - def test_decimal_to_float(self): - """Decimal should be converted to float.""" - result = encode({"price": Decimal("19.99")}) - assert "price: 19.99" in result - decoded = decode(result) - assert decoded["price"] == 19.99 - - def test_decimal_precision_preserved(self): - """Decimal precision should be preserved during conversion.""" - result = encode({"value": Decimal("3.14159")}) - decoded = decode(result) - assert abs(decoded["value"] - 3.14159) < 0.00001 - - def test_nested_python_types(self): - """Nested Python types should all be normalized.""" - data = { - "tuple_field": (1, 2, 3), - "set_field": {3, 2, 1}, - "nested": { - "decimal": Decimal("99.99"), - }, - } - result = encode(data) - decoded = decode(result) - - assert decoded["tuple_field"] == [1, 2, 3] - assert decoded["set_field"] == [1, 2, 3] - assert decoded["nested"]["decimal"] == 99.99 - - def test_empty_python_types(self): - """Empty Python-specific types should normalize to empty arrays.""" - data = { - "empty_tuple": (), - "empty_set": set(), - } - result = encode(data) - decoded = decode(result) - - assert decoded["empty_tuple"] == [] - assert decoded["empty_set"] == [] - - -class TestNumericPrecision: - """Test numeric round-trip fidelity (TOON v1.3 spec requirement).""" - - def test_roundtrip_numeric_precision(self): - """All numbers should round-trip with fidelity.""" - original = { - "integer": 42, - "negative": -123, - "zero": 0, - "float": 3.14159265358979, - "small": 0.0001, - "very_small": 1e-10, - "large": 999999999999999, - "scientific": 1.23e15, - "negative_float": -0.00001, - "precise": 0.1 + 0.2, # Famous floating point case - } - toon = encode(original) - decoded = decode(toon) - - # All numbers should round-trip with fidelity - for key, value in original.items(): - assert decoded[key] == value, f"Mismatch for {key}: {decoded[key]} != {value}" - - -class TestPathNormalization: - """Test pathlib.Path normalization to strings.""" - - def test_path_to_string(self): - """pathlib.Path should be converted to string.""" - data = {"file": Path("/tmp/test.txt")} - result = encode(data) - decoded = decode(result) - - assert decoded["file"] == "/tmp/test.txt" - - def test_relative_path(self): - """Relative paths should be preserved.""" - data = {"rel": Path("./relative/path.txt")} - result = encode(data) - decoded = decode(result) - - # Path normalization may vary, but should be a string - assert isinstance(decoded["rel"], str) - assert "relative" in decoded["rel"] - assert "path.txt" in decoded["rel"] - - def test_pure_path(self): - """PurePath objects should also be normalized.""" - data = { - "posix": PurePosixPath("/usr/bin/python"), - "windows": PureWindowsPath("C:\\Windows\\System32"), - } - result = encode(data) - decoded = decode(result) - - assert decoded["posix"] == "/usr/bin/python" - assert decoded["windows"] == "C:\\Windows\\System32" - - def test_path_in_array(self): - """Path objects in arrays should be normalized.""" - data = {"paths": [Path("/tmp/a"), Path("/tmp/b"), Path("/tmp/c")]} - result = encode(data) - decoded = decode(result) - - assert decoded["paths"] == ["/tmp/a", "/tmp/b", "/tmp/c"] - - def test_path_in_nested_structure(self): - """Path objects in nested structures should be normalized.""" - data = { - "project": { - "root": Path("/home/user/project"), - "src": Path("/home/user/project/src"), - "files": [ - {"name": "main.py", "path": Path("/home/user/project/src/main.py")}, - {"name": "test.py", "path": Path("/home/user/project/src/test.py")}, - ], - } - } - result = encode(data) - decoded = decode(result) - - assert decoded["project"]["root"] == "/home/user/project" - assert decoded["project"]["src"] == "/home/user/project/src" - assert decoded["project"]["files"][0]["path"] == "/home/user/project/src/main.py" - assert decoded["project"]["files"][1]["path"] == "/home/user/project/src/test.py" diff --git a/tests/test_normalize_functions.py b/tests/test_normalize_functions.py deleted file mode 100644 index 7bd85ba..0000000 --- a/tests/test_normalize_functions.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Direct unit tests for normalize.py functions. - -This module tests the normalize module's functions directly to ensure -full coverage of edge cases and error paths. -""" - -from collections import OrderedDict -from datetime import date, datetime -from decimal import Decimal - -import pytest - -from toon_format.normalize import ( - is_array_of_arrays, - is_array_of_objects, - is_array_of_primitives, - is_json_array, - is_json_object, - is_json_primitive, - normalize_value, -) - - -class TestNormalizeValue: - """Tests for normalize_value function.""" - - def test_none_value(self): - """Test None is returned as-is.""" - assert normalize_value(None) is None - - def test_bool_value(self): - """Test bool values are returned as-is.""" - assert normalize_value(True) is True - assert normalize_value(False) is False - - def test_str_value(self): - """Test string values are returned as-is.""" - assert normalize_value("hello") == "hello" - assert normalize_value("") == "" - - def test_int_value(self): - """Test integers are returned as-is.""" - assert normalize_value(42) == 42 - assert normalize_value(-100) == -100 - assert normalize_value(0) == 0 - - def test_float_value(self): - """Test normal floats are returned as-is.""" - assert normalize_value(3.14) == 3.14 - assert normalize_value(-2.5) == -2.5 - - def test_non_finite_float_inf(self): - """Test infinity is converted to null.""" - assert normalize_value(float("inf")) is None - assert normalize_value(float("-inf")) is None - - def test_non_finite_float_nan(self): - """Test NaN is converted to null.""" - assert normalize_value(float("nan")) is None - - def test_negative_zero_normalized(self): - """Test negative zero is normalized to positive zero.""" - assert normalize_value(-0.0) == 0 - - def test_decimal_to_float(self): - """Test Decimal is converted to float.""" - assert normalize_value(Decimal("19.99")) == 19.99 - assert normalize_value(Decimal("3.14159")) == 3.14159 - - def test_decimal_non_finite_to_null(self): - """Test non-finite Decimal values are converted to null.""" - inf_decimal = Decimal("Infinity") - neg_inf_decimal = Decimal("-Infinity") - nan_decimal = Decimal("NaN") - - assert normalize_value(inf_decimal) is None - assert normalize_value(neg_inf_decimal) is None - assert normalize_value(nan_decimal) is None - - def test_datetime_to_iso_string(self): - """Test datetime is converted to ISO 8601 string.""" - dt = datetime(2024, 1, 15, 10, 30, 45) - result = normalize_value(dt) - assert result == "2024-01-15T10:30:45" - - def test_date_to_iso_string(self): - """Test date is converted to ISO 8601 string.""" - d = date(2024, 1, 15) - result = normalize_value(d) - assert result == "2024-01-15" - - def test_list_normalization(self): - """Test lists are recursively normalized.""" - data = [1, 2.5, "text", None] - result = normalize_value(data) - assert result == [1, 2.5, "text", None] - - def test_empty_list(self): - """Test empty list is handled correctly.""" - assert normalize_value([]) == [] - - def test_nested_list(self): - """Test nested lists are recursively normalized.""" - data = [1, [2, [3, 4]], 5] - result = normalize_value(data) - assert result == [1, [2, [3, 4]], 5] - - def test_tuple_to_list(self): - """Test tuples are converted to lists.""" - result = normalize_value((1, 2, 3)) - assert result == [1, 2, 3] - - def test_empty_tuple(self): - """Test empty tuple is converted to empty list.""" - result = normalize_value(()) - assert result == [] - - def test_set_to_sorted_list(self): - """Test sets are converted to sorted lists.""" - result = normalize_value({3, 1, 2}) - assert result == [1, 2, 3] - - def test_frozenset_to_sorted_list(self): - """Test frozensets are converted to sorted lists.""" - result = normalize_value(frozenset({3, 1, 2})) - assert result == [1, 2, 3] - - def test_heterogeneous_set_uses_repr_sorting(self): - """Test heterogeneous sets use repr() for stable sorting.""" - - # Create a set with objects that can't be naturally sorted - class CustomObj: - def __init__(self, val): - self.val = val - - def __repr__(self): - return f"CustomObj({self.val})" - - def __hash__(self): - return hash(self.val) - - def __eq__(self, other): - return self.val == other.val - - obj1 = CustomObj("a") - obj2 = CustomObj("b") - data = {obj1, obj2} - - # Should not raise TypeError - result = normalize_value(data) - assert isinstance(result, list) - assert len(result) == 2 - - def test_dict_normalization(self): - """Test dicts are recursively normalized.""" - data = {"a": 1, "b": 2.5} - result = normalize_value(data) - assert result == {"a": 1, "b": 2.5} - - def test_mapping_with_non_string_keys(self): - """Test Mapping types with non-string keys are converted.""" - data = OrderedDict([(1, "one"), (2, "two")]) - result = normalize_value(data) - assert result == {"1": "one", "2": "two"} - - def test_callable_to_null(self): - """Test callable objects are converted to null.""" - - def my_func(): - pass - - assert normalize_value(my_func) is None - assert normalize_value(lambda x: x) is None - - def test_unsupported_type_to_null(self): - """Test unsupported types are converted to null with warning.""" - - class CustomClass: - pass - - obj = CustomClass() - result = normalize_value(obj) - assert result is None - - -class TestTypeGuards: - """Tests for type guard functions.""" - - def test_is_json_primitive(self): - """Test is_json_primitive correctly identifies primitives.""" - assert is_json_primitive(None) is True - assert is_json_primitive("text") is True - assert is_json_primitive(42) is True - assert is_json_primitive(3.14) is True - assert is_json_primitive(True) is True - assert is_json_primitive(False) is True - - assert is_json_primitive([]) is False - assert is_json_primitive({}) is False - assert is_json_primitive(object()) is False - - def test_is_json_array(self): - """Test is_json_array correctly identifies lists.""" - assert is_json_array([]) is True - assert is_json_array([1, 2, 3]) is True - assert is_json_array([None, "text"]) is True - - assert is_json_array(None) is False - assert is_json_array({}) is False - assert is_json_array((1, 2)) is False - assert is_json_array("text") is False - - def test_is_json_object(self): - """Test is_json_object correctly identifies dicts.""" - assert is_json_object({}) is True - assert is_json_object({"a": 1}) is True - - assert is_json_object(None) is False - assert is_json_object([]) is False - assert is_json_object("text") is False - - def test_is_array_of_primitives(self): - """Test is_array_of_primitives identifies arrays of primitives.""" - assert is_array_of_primitives([]) is True - assert is_array_of_primitives([1, 2, 3]) is True - assert is_array_of_primitives(["a", "b", "c"]) is True - assert is_array_of_primitives([None, 1, "text", True]) is True - - assert is_array_of_primitives([1, [2, 3]]) is False - assert is_array_of_primitives([{"a": 1}]) is False - - def test_is_array_of_arrays(self): - """Test is_array_of_arrays identifies arrays of arrays.""" - assert is_array_of_arrays([]) is True - assert is_array_of_arrays([[1, 2], [3, 4]]) is True - assert is_array_of_arrays([[], []]) is True - - assert is_array_of_arrays([1, 2]) is False - assert is_array_of_arrays([[1], 2]) is False - assert is_array_of_arrays([{"a": 1}]) is False - - def test_is_array_of_objects(self): - """Test is_array_of_objects identifies arrays of objects.""" - assert is_array_of_objects([]) is True - assert is_array_of_objects([{"a": 1}, {"b": 2}]) is True - assert is_array_of_objects([{}, {}]) is True - - assert is_array_of_objects([1, 2]) is False - assert is_array_of_objects([[1, 2]]) is False - assert is_array_of_objects([{"a": 1}, 2]) is False - - -class TestErrorHandling: - """Tests for error handling paths.""" - - def test_mapping_conversion_error(self): - """Test error handling when mapping conversion fails.""" - - class BadMapping(dict): - """A mapping that raises error during items().""" - - def items(self): - raise RuntimeError("items() failed") - - bad_map = BadMapping({"a": 1}) - # Should raise ValueError wrapping the RuntimeError - with pytest.raises(ValueError, match="Failed to convert mapping"): - normalize_value(bad_map) - - -class TestEdgeCases: - """Tests for edge cases and error conditions.""" - - def test_list_with_non_finite_floats(self): - """Test lists containing non-finite floats.""" - data = [1, float("inf"), 2, float("nan"), 3] - result = normalize_value(data) - assert result == [1, None, 2, None, 3] - - def test_nested_dict_with_decimals(self): - """Test nested dicts with Decimal values.""" - data = {"outer": {"price": Decimal("19.99"), "tax": Decimal("2.00")}} - result = normalize_value(data) - assert result == {"outer": {"price": 19.99, "tax": 2.0}} - - def test_complex_nested_structure(self): - """Test complex nested structure normalization.""" - data = { - "users": [ - {"name": "Alice", "scores": (95, 87, 92)}, - {"name": "Bob", "scores": (88, 91, 85)}, - ], - "stats": {"count": 2, "average": Decimal("89.67")}, - "tags": {"python", "testing", "toon"}, - } - result = normalize_value(data) - - assert result["users"][0]["scores"] == [95, 87, 92] - assert result["users"][1]["scores"] == [88, 91, 85] - assert result["stats"]["average"] == 89.67 - assert result["tags"] == ["python", "testing", "toon"] - - def test_empty_structures(self): - """Test various empty structures.""" - assert normalize_value({}) == {} - assert normalize_value([]) == [] - assert normalize_value(set()) == [] - assert normalize_value(frozenset()) == [] - assert normalize_value(()) == [] - - def test_list_of_tuples(self): - """Test list containing tuples.""" - data = [(1, 2), (3, 4), (5, 6)] - result = normalize_value(data) - assert result == [[1, 2], [3, 4], [5, 6]] - - def test_dict_of_sets(self): - """Test dict containing sets.""" - data = {"a": {3, 1, 2}, "b": {6, 4, 5}} - result = normalize_value(data) - assert result == {"a": [1, 2, 3], "b": [4, 5, 6]} diff --git a/tests/test_numeric_detection.py b/tests/test_numeric_detection.py deleted file mode 100644 index a8367cd..0000000 --- a/tests/test_numeric_detection.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for numeric detection utilities. - -Tests the consistency and correctness of numeric literal detection -across encoding and decoding pipelines. -""" - -from toon_format._literal_utils import is_numeric_literal -from toon_format._validation import is_numeric_like - - -class TestNumericLiteral: - """Tests for is_numeric_literal (decoder utility).""" - - def test_valid_integers(self): - """Test valid integer literals are recognized.""" - assert is_numeric_literal("0") - assert is_numeric_literal("1") - assert is_numeric_literal("42") - assert is_numeric_literal("999") - assert is_numeric_literal("-1") - assert is_numeric_literal("-42") - - def test_valid_floats(self): - """Test valid float literals are recognized.""" - assert is_numeric_literal("0.0") - assert is_numeric_literal("0.5") - assert is_numeric_literal("3.14") - assert is_numeric_literal("-2.5") - assert is_numeric_literal("1.23456") - - def test_scientific_notation(self): - """Test scientific notation is recognized.""" - assert is_numeric_literal("1e10") - assert is_numeric_literal("1.5e10") - assert is_numeric_literal("1e-10") - assert is_numeric_literal("1.5e-10") - assert is_numeric_literal("-1e10") - assert is_numeric_literal("2.5E+3") - - def test_leading_zeros_rejected(self): - """Test numbers with leading zeros are rejected (except special cases).""" - assert not is_numeric_literal("01") - assert not is_numeric_literal("0123") - assert not is_numeric_literal("00") - assert not is_numeric_literal("-01") - # But these are valid: - assert is_numeric_literal("0") # Just zero - assert is_numeric_literal("0.5") # Decimal starting with zero - assert is_numeric_literal("0.0") - - def test_non_numeric_strings(self): - """Test non-numeric strings are rejected.""" - assert not is_numeric_literal("") - assert not is_numeric_literal("abc") - assert not is_numeric_literal("12abc") - assert not is_numeric_literal("12.34.56") - assert not is_numeric_literal("--5") - assert not is_numeric_literal("1.2.3") - - def test_special_float_values_rejected(self): - """Test NaN and infinity are rejected.""" - assert not is_numeric_literal("nan") - assert not is_numeric_literal("NaN") - assert not is_numeric_literal("inf") - assert not is_numeric_literal("Infinity") - assert not is_numeric_literal("-inf") - - def test_empty_string(self): - """Test empty string is rejected.""" - assert not is_numeric_literal("") - - def test_whitespace_only(self): - """Test whitespace-only strings are rejected.""" - assert not is_numeric_literal(" ") - assert not is_numeric_literal(" ") - - -class TestNumericLike: - """Tests for is_numeric_like (encoder utility).""" - - def test_valid_integers(self): - """Test valid integers are recognized as numeric-like.""" - assert is_numeric_like("0") - assert is_numeric_like("1") - assert is_numeric_like("42") - assert is_numeric_like("-1") - assert is_numeric_like("-42") - - def test_valid_floats(self): - """Test valid floats are recognized as numeric-like.""" - assert is_numeric_like("0.0") - assert is_numeric_like("0.5") - assert is_numeric_like("3.14") - assert is_numeric_like("-2.5") - - def test_scientific_notation(self): - """Test scientific notation is recognized as numeric-like.""" - assert is_numeric_like("1e10") - assert is_numeric_like("1.5e10") - assert is_numeric_like("1e-10") - assert is_numeric_like("2.5E+3") - - def test_octal_like_numbers(self): - """Test octal-like numbers (leading zeros) are recognized as numeric-like.""" - # These LOOK like numbers so they need quoting - assert is_numeric_like("01") - assert is_numeric_like("0123") - assert is_numeric_like("00") - - def test_non_numeric_strings(self): - """Test non-numeric strings are not numeric-like.""" - assert not is_numeric_like("") - assert not is_numeric_like("abc") - assert not is_numeric_like("hello") - assert not is_numeric_like("12abc") - - def test_edge_cases(self): - """Test edge cases.""" - assert not is_numeric_like("") - assert not is_numeric_like(" ") - assert not is_numeric_like("--5") - - -class TestConsistency: - """Tests to ensure consistency between is_numeric_literal and is_numeric_like.""" - - def test_valid_numbers_recognized_by_both(self): - """Test that valid numbers are recognized by both functions.""" - valid_numbers = ["0", "1", "42", "-1", "3.14", "-2.5", "1e10", "1.5e-3"] - for num in valid_numbers: - assert is_numeric_literal(num), f"{num} should be numeric literal" - assert is_numeric_like(num), f"{num} should be numeric-like" - - def test_octal_like_difference(self): - """Test the key difference: octal-like numbers. - - is_numeric_like returns True (needs quoting in encoder) - is_numeric_literal returns False (not parsed as number in decoder) - """ - octal_like = ["01", "0123", "00", "007"] - for num in octal_like: - assert is_numeric_like(num), f"{num} should be numeric-like (needs quoting)" - assert not is_numeric_literal(num), ( - f"{num} should not be numeric literal (has leading zero)" - ) - - def test_non_numbers_rejected_by_both(self): - """Test that non-numbers are rejected by both functions.""" - non_numbers = ["", "abc", "hello", "12abc", "nan", "inf"] - for val in non_numbers: - # Allow for potential differences in edge cases, but most should agree - if val: # Skip empty string edge case - assert not is_numeric_literal(val), f"{val} should not be numeric literal" - # is_numeric_like might have slightly different behavior for edge cases - - -class TestRoundTripConsistency: - """Test that encoding and decoding are consistent.""" - - def test_octal_like_numbers_preserved_as_strings(self): - """Test that octal-like numbers are preserved as strings through round-trip.""" - from toon_format import decode, encode - - # These should be treated as strings, not numbers - octal_values = ["0123", "007", "00"] - for val in octal_values: - # When we encode a dict with these as values - data = {"value": val} - encoded = encode(data) - decoded = decode(encoded) - # Assert it's a dict before trying to access - assert isinstance(decoded, dict) - # They should come back as strings - assert decoded["value"] == val - assert isinstance(decoded["value"], str) - - def test_valid_numbers_preserved_as_numbers(self): - """Test that valid numbers are preserved as numbers through round-trip.""" - from toon_format import decode, encode - - numbers = [0, 1, 42, -1, 3.14, -2.5] - for num in numbers: - data = {"value": num} - encoded = encode(data) - decoded = decode(encoded) - # Assert it's a dict before trying to access - assert isinstance(decoded, dict) - # They should come back as numbers (with potential float/int conversion) - assert decoded["value"] == num - assert isinstance(decoded["value"], (int, float)) diff --git a/tests/test_parsing_utils.py b/tests/test_parsing_utils.py deleted file mode 100644 index 7afd741..0000000 --- a/tests/test_parsing_utils.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Tests for _parsing_utils module. - -These tests verify the quote-aware parsing utilities used throughout -the TOON decoder. -""" - -import pytest - -from src.toon_format._parsing_utils import ( - find_first_unquoted, - find_unquoted_char, - iter_unquoted, - parse_delimited_values, - split_at_unquoted_char, -) - - -class TestIterUnquoted: - """Tests for iter_unquoted() generator.""" - - def test_simple_string_no_quotes(self): - """Iterate over simple string with no quotes.""" - result = list(iter_unquoted("abc")) - assert result == [(0, "a", False), (1, "b", False), (2, "c", False)] - - def test_quoted_section(self): - """Iterate over string with quoted section.""" - result = list(iter_unquoted('a"bc"d')) - assert result == [ - (0, "a", False), - (1, '"', False), # Opening quote - (2, "b", True), - (3, "c", True), - (4, '"', True), # Closing quote - (5, "d", False), - ] - - def test_escaped_char_in_quotes(self): - """Handle escaped characters within quotes.""" - result = list(iter_unquoted(r'a"b\\"c"d')) - assert result == [ - (0, "a", False), - (1, '"', False), - (2, "b", True), - (3, "\\", True), # Backslash - (4, "\\", True), # Escaped backslash - (5, '"', True), - (6, "c", False), # Outside quotes - (7, '"', False), # Opening quote again - (8, "d", True), # Inside quotes - ] - - def test_start_position(self): - """Start iteration from specific position.""" - result = list(iter_unquoted("abcde", start=2)) - assert result == [(2, "c", False), (3, "d", False), (4, "e", False)] - - def test_empty_string(self): - """Handle empty string.""" - result = list(iter_unquoted("")) - assert result == [] - - def test_only_quotes(self): - """Handle string with only quotes.""" - result = list(iter_unquoted('""')) - assert result == [(0, '"', False), (1, '"', True)] - - def test_nested_quotes_behavior(self): - """Quotes toggle state (no true nesting in TOON).""" - result = list(iter_unquoted('"a"b"c"')) - expected = [ - (0, '"', False), - (1, "a", True), - (2, '"', True), - (3, "b", False), - (4, '"', False), - (5, "c", True), - (6, '"', True), - ] - assert result == expected - - -class TestFindUnquotedChar: - """Tests for find_unquoted_char() function.""" - - def test_find_colon_simple(self): - """Find colon in simple string.""" - assert find_unquoted_char("key: value", ":") == 3 - - def test_find_colon_with_quoted_colon(self): - """Ignore colon inside quotes.""" - assert find_unquoted_char('"key:1": value', ":") == 7 - - def test_find_bracket_with_quoted_bracket(self): - """Ignore bracket inside quotes.""" - assert find_unquoted_char('"key[test]"[3]:', "[") == 11 - - def test_char_not_found(self): - """Return -1 when character not found.""" - assert find_unquoted_char("abcdef", ":") == -1 - - def test_char_only_in_quotes(self): - """Return -1 when character only in quotes.""" - assert find_unquoted_char('"a:b"', ":") == -1 - - def test_multiple_occurrences(self): - """Find first occurrence outside quotes.""" - assert find_unquoted_char("a:b:c", ":") == 1 - - def test_start_position(self): - """Start search from specific position.""" - assert find_unquoted_char("a:b:c", ":", start=2) == 3 - - def test_escaped_quote_before_target(self): - """Handle escaped quotes correctly.""" - # "a\"b":value -> colon at position 6 - assert find_unquoted_char(r'"a\"b":value', ":") == 6 - - def test_empty_string(self): - """Handle empty string.""" - assert find_unquoted_char("", ":") == -1 - - def test_delimiter_comma(self): - """Find comma delimiter.""" - assert find_unquoted_char('a,"b,c",d', ",") == 1 - - def test_delimiter_pipe(self): - """Find pipe delimiter.""" - assert find_unquoted_char('a|"b|c"|d', "|") == 1 - - -class TestParseDelimitedValues: - """Tests for parse_delimited_values() function.""" - - def test_simple_comma_separated(self): - """Parse simple comma-separated values.""" - assert parse_delimited_values("a,b,c", ",") == ["a", "b", "c"] - - def test_values_with_quotes(self): - """Parse values containing quoted sections.""" - assert parse_delimited_values('a,"b,c",d', ",") == ["a", '"b,c"', "d"] - - def test_tab_delimiter(self): - """Parse tab-separated values.""" - assert parse_delimited_values("a\tb\tc", "\t") == ["a", "b", "c"] - - def test_pipe_delimiter(self): - """Parse pipe-separated values.""" - assert parse_delimited_values("a|b|c", "|") == ["a", "b", "c"] - - def test_empty_values(self): - """Handle empty values between delimiters.""" - assert parse_delimited_values("a,,c", ",") == ["a", "", "c"] - - def test_trailing_delimiter(self): - """Handle trailing delimiter.""" - assert parse_delimited_values("a,b,", ",") == ["a", "b", ""] - - def test_leading_delimiter(self): - """Handle leading delimiter.""" - assert parse_delimited_values(",a,b", ",") == ["", "a", "b"] - - def test_only_delimiter(self): - """Handle string with only delimiter.""" - assert parse_delimited_values(",", ",") == ["", ""] - - def test_no_delimiter(self): - """Handle string with no delimiter.""" - assert parse_delimited_values("abc", ",") == ["abc"] - - def test_empty_string(self): - """Handle empty string.""" - assert parse_delimited_values("", ",") == [] - - def test_quoted_with_escaped_quote(self): - """Handle quoted value with escaped quote.""" - result = parse_delimited_values(r'"a\"b",c', ",") - assert result == [r'"a\"b"', "c"] - - def test_multiple_quoted_sections(self): - """Handle multiple quoted sections.""" - result = parse_delimited_values('"a,b","c,d","e,f"', ",") - assert result == ['"a,b"', '"c,d"', '"e,f"'] - - def test_spec_example_with_delimiters_in_strings(self): - """Test spec example: strings with delimiters.""" - result = parse_delimited_values('a,"b,c","d:e"', ",") - assert result == ["a", '"b,c"', '"d:e"'] - - def test_preserves_whitespace(self): - """Whitespace is preserved (not stripped).""" - assert parse_delimited_values(" a , b , c ", ",") == [" a ", " b ", " c "] - - -class TestSplitAtUnquotedChar: - """Tests for split_at_unquoted_char() function.""" - - def test_simple_split_on_colon(self): - """Split simple string on colon.""" - assert split_at_unquoted_char("key: value", ":") == ("key", " value") - - def test_split_with_quoted_colon(self): - """Split at unquoted colon, ignoring quoted colon.""" - assert split_at_unquoted_char('"key:1": value', ":") == ('"key:1"', " value") - - def test_split_on_equals(self): - """Split on equals sign.""" - assert split_at_unquoted_char("key=value", "=") == ("key", "value") - - def test_char_not_found_raises_error(self): - """Raise ValueError when character not found.""" - with pytest.raises(ValueError, match="not found outside quotes"): - split_at_unquoted_char("no colon here", ":") - - def test_char_only_in_quotes_raises_error(self): - """Raise ValueError when character only in quotes.""" - with pytest.raises(ValueError, match="not found outside quotes"): - split_at_unquoted_char('"a:b"', ":") - - def test_multiple_occurrences(self): - """Split at first occurrence.""" - assert split_at_unquoted_char("a:b:c", ":") == ("a", "b:c") - - def test_empty_before(self): - """Handle empty string before delimiter.""" - assert split_at_unquoted_char(":value", ":") == ("", "value") - - def test_empty_after(self): - """Handle empty string after delimiter.""" - assert split_at_unquoted_char("key:", ":") == ("key", "") - - -class TestFindFirstUnquoted: - """Tests for find_first_unquoted() function.""" - - def test_find_first_of_multiple_chars(self): - """Find first occurrence of any character.""" - assert find_first_unquoted("a:b,c", [":", ","]) == (1, ":") - - def test_comma_before_colon(self): - """Find comma when it appears before colon.""" - assert find_first_unquoted("a,b:c", [":", ","]) == (1, ",") - - def test_ignore_quoted_chars(self): - """Ignore characters inside quotes.""" - assert find_first_unquoted('a"b:c",d', [":", ","]) == (6, ",") - - def test_no_chars_found(self): - """Return (-1, '') when none found.""" - assert find_first_unquoted("abcdef", [":", ","]) == (-1, "") - - def test_all_chars_in_quotes(self): - """Return (-1, '') when all in quotes.""" - assert find_first_unquoted('"a:b,c"', [":", ","]) == (-1, "") - - def test_start_position(self): - """Start search from specific position.""" - assert find_first_unquoted("a:b,c", [":", ","], start=2) == (3, ",") - - def test_single_char_list(self): - """Work with single-character list.""" - assert find_first_unquoted("a:b", [":"]) == (1, ":") - - def test_empty_char_list(self): - """Handle empty character list.""" - assert find_first_unquoted("a:b,c", []) == (-1, "") - - def test_empty_string(self): - """Handle empty string.""" - assert find_first_unquoted("", [":", ","]) == (-1, "") - - -class TestEdgeCases: - """Edge cases and integration scenarios.""" - - def test_extremely_long_quoted_section(self): - """Handle very long quoted sections.""" - long_quoted = '"' + "a" * 1000 + '"' - result = find_unquoted_char(long_quoted + ":value", ":") - assert result == 1002 # After the 1000 a's and 2 quotes - - def test_many_escaped_chars(self): - """Handle many escaped characters.""" - escaped = r'"' + r"\\" * 50 + '"' - result = list(iter_unquoted(escaped)) - # Should have opening quote + 100 chars (50 pairs) + closing quote - assert len(result) == 102 - - def test_unicode_characters(self): - """Handle unicode characters correctly.""" - assert find_unquoted_char("café:☕", ":") == 4 - - def test_delimiter_at_boundary(self): - """Handle delimiter at string boundaries.""" - assert parse_delimited_values(",", ",") == ["", ""] - assert parse_delimited_values(",,", ",") == ["", "", ""] - - def test_mixed_delimiters_in_quotes(self): - """Multiple different delimiters in quotes.""" - result = parse_delimited_values('"a:b|c,d",e', ",") - assert result == ['"a:b|c,d"', "e"] - - def test_realistic_toon_header(self): - """Test with realistic TOON header.""" - # Example: "key[test]"[3]: 1,2,3 - header = '"key[test]"[3]: 1,2,3' - bracket_pos = find_unquoted_char(header, "[") - assert bracket_pos == 11 # First [ outside quotes - - colon_pos = find_unquoted_char(header, ":") - assert colon_pos == 14 # : outside quotes - - values = parse_delimited_values("1,2,3", ",") - assert values == ["1", "2", "3"] - - def test_realistic_tabular_row_detection(self): - """Test realistic tabular row vs key-value detection.""" - # Row: values separated by delimiter, no colon or delimiter before colon - row = "Alice,30,Engineer" - assert find_unquoted_char(row, ":") == -1 # No colon = row - - # Key-value: colon before delimiter - kv = "name: Alice,Bob" - colon = find_unquoted_char(kv, ":") - comma = find_unquoted_char(kv, ",") - assert colon < comma # Colon first = key-value - - # Row with quoted field containing colon - row_with_quote = 'Alice,"30:manager",Engineer' - first_colon = find_unquoted_char(row_with_quote, ":") - assert first_colon == -1 # Colon only in quotes = row diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py index 8309687..41e1679 100644 --- a/tests/test_pydantic.py +++ b/tests/test_pydantic.py @@ -1,52 +1,74 @@ -from typing import Optional +"""The pydantic integration.""" + +from __future__ import annotations import pytest -from pydantic import ValidationError -from toon_format.pydantic import ToonPydanticModel +pydantic = pytest.importorskip("pydantic") + +import toon_format # noqa: E402 +from toon_format.pydantic import ToonPydanticModel # noqa: E402 + + +class Address(pydantic.BaseModel): + city: str + zip: str class User(ToonPydanticModel): name: str age: int - email: Optional[str] = None + tags: list[str] = pydantic.Field(default_factory=list) + address: Address | None = None -def test_schema_to_toon(): - schema = User.schema_to_toon() - assert "name:" in schema - assert "age:" in schema - assert "email:" in schema # optional field - assert "type: object" in schema +def test_dumps_accepts_any_base_model() -> None: + assert ( + toon_format.dumps({"home": Address(city="Oslo", zip="0150")}) + == 'home:\n city: Oslo\n zip: "0150"' + ) -def test_model_validate_toon_success(): - toon = "name:Ansar\nage:25\nemail:null" - user = User.model_validate_toon(toon) - assert user.name == "Ansar" - assert user.age == 25 - assert user.email is None +def test_model_dump_toon() -> None: + user = User(name="Ada", age=36, tags=["math", "code"]) + assert ( + user.model_dump_toon() + == "name: Ada\nage: 36\ntags[2]: math,code\naddress: null" + ) + assert ( + user.model_dump_toon(exclude_none=True, delimiter="|") + == "name: Ada\nage: 36\ntags[2|]: math|code" + ) -def test_model_validate_toon_validation_error(): - toon = "name:Ansar\nage:twenty-five" # wrong type - with pytest.raises(ValidationError): - User.model_validate_toon(toon) +def test_model_validate_toon() -> None: + text = 'name: Ada\nage: 36\naddress:\n city: Oslo\n zip: "0150"' + user = User.model_validate_toon(text) + assert user == User(name="Ada", age=36, address=Address(city="Oslo", zip="0150")) + assert User.model_validate_toon(text.encode()) == user -def test_model_validate_toon_empty_string(): - with pytest.raises(ValueError, match="Empty string"): - User.model_validate_toon("") +def test_model_validate_toon_errors() -> None: + with pytest.raises(toon_format.ToonDecodeError): + User.model_validate_toon("name: Ada\nage: 36\ntags[3]: a") + with pytest.raises(pydantic.ValidationError): + User.model_validate_toon("name: Ada\nage: old") -def test_model_dump_toon(): - user = User(name="Ansar", age=25) - toon = user.model_dump_toon() - assert "name: Ansar" in toon - assert "age: 25" in toon +def test_model_validate_toon_strict() -> None: + text = 'name: Ada\nage: "36"\ntags[2]: a' + with pytest.raises(toon_format.ToonDecodeError): + User.model_validate_toon(text) + with pytest.raises(toon_format.ToonDecodeError): + User.model_validate_toon(text, strict=True) + lenient = User.model_validate_toon(text, strict=False) + assert lenient == User(name="Ada", age=36, tags=["a"]) + with pytest.raises(pydantic.ValidationError): + User.model_validate_toon('name: Ada\nage: "36"', strict=True) + assert User.model_validate_toon('name: Ada\nage: "36"').age == 36 -def test_model_dump_toon_roundtrip(): - user = User(name="Ansar", age=25, email="a@b.com") - restored = User.model_validate_toon(user.model_dump_toon()) - assert restored == user +def test_schema_to_toon() -> None: + schema = User.schema_to_toon() + assert toon_format.loads(schema) == User.model_json_schema() + assert "properties:" in schema diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100644 index 0000000..87c8abe --- /dev/null +++ b/tests/test_roundtrip.py @@ -0,0 +1,36 @@ +"""Property: decoding an encoded value gives the value back (§2).""" + +from __future__ import annotations + +from typing import Any + +from hypothesis import given +from hypothesis import strategies as st + +import toon_format + +from .strategies import documents + + +@given( + documents, + st.sampled_from(list(toon_format.Delimiter)), + st.integers(min_value=1, max_value=4), +) +def test_round_trip( + value: Any, delimiter: toon_format.Delimiter, indent_size: int +) -> None: + text = toon_format.dumps(value, delimiter=delimiter, indent_size=indent_size) + assert toon_format.loads(text, indent_size=indent_size) == value + + +@given(documents) +def test_output_invariants(value: Any) -> None: + text = toon_format.dumps(value) + assert not text.endswith("\n") + for line in text.split("\n"): + assert line == line.rstrip(" "), "no trailing spaces (§12)" + assert not line.lstrip(" ").startswith("#"), "no comment lines (§5.1)" + assert "\t" not in line[: len(line) - len(line.lstrip(" \t"))], ( + "no tab indentation" + ) diff --git a/tests/test_scanner.py b/tests/test_scanner.py deleted file mode 100644 index 3870e94..0000000 --- a/tests/test_scanner.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for the _scanner module.""" - -import pytest - -from toon_format._scanner import ( - BlankLineInfo, - LineCursor, - ParsedLine, - to_parsed_lines, -) - - -class TestParsedLine: - """Tests for ParsedLine dataclass.""" - - def test_is_blank_with_empty_content(self): - """Test is_blank returns True for empty content.""" - line = ParsedLine(raw=" ", depth=0, indent=4, content="", line_num=1) - assert line.is_blank is True - - def test_is_blank_with_whitespace_content(self): - """Test is_blank returns True for whitespace-only content.""" - line = ParsedLine(raw=" \t ", depth=0, indent=4, content="\t ", line_num=1) - assert line.is_blank is True - - def test_is_blank_with_actual_content(self): - """Test is_blank returns False for non-blank content.""" - line = ParsedLine(raw="name: Alice", depth=0, indent=0, content="name: Alice", line_num=1) - assert line.is_blank is False - - -class TestLineCursor: - """Tests for LineCursor class.""" - - def test_get_blank_lines_with_empty_list(self): - """Test get_blank_lines returns empty list when none provided.""" - cursor = LineCursor([]) - assert cursor.get_blank_lines() == [] - - def test_get_blank_lines_with_provided_blanks(self): - """Test get_blank_lines returns the provided blank lines.""" - blanks = [BlankLineInfo(line_num=2, indent=0, depth=0)] - cursor = LineCursor([], blank_lines=blanks) - assert cursor.get_blank_lines() == blanks - - def test_peek_when_at_end(self): - """Test peek returns None when cursor is at end.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - cursor.advance() - assert cursor.peek() is None - - def test_next_when_at_end(self): - """Test next returns None when cursor is at end.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - cursor.next() # Consume the only line - assert cursor.next() is None - - def test_current_when_no_line_consumed(self): - """Test current returns None when no line has been consumed yet.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.current() is None - - def test_current_after_consuming_line(self): - """Test current returns the last consumed line.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - cursor.next() - assert cursor.current() == line - - def test_advance(self): - """Test advance moves cursor forward.""" - lines = [ - ParsedLine(raw="line1", depth=0, indent=0, content="line1", line_num=1), - ParsedLine(raw="line2", depth=0, indent=0, content="line2", line_num=2), - ] - cursor = LineCursor(lines) - assert cursor.peek() == lines[0] - cursor.advance() - assert cursor.peek() == lines[1] - - def test_at_end_when_not_at_end(self): - """Test at_end returns False when not at end.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.at_end() is False - - def test_at_end_when_at_end(self): - """Test at_end returns True when at end.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - cursor.advance() - assert cursor.at_end() is True - - def test_length_property(self): - """Test length property returns total number of lines.""" - lines = [ - ParsedLine(raw="line1", depth=0, indent=0, content="line1", line_num=1), - ParsedLine(raw="line2", depth=0, indent=0, content="line2", line_num=2), - ParsedLine(raw="line3", depth=0, indent=0, content="line3", line_num=3), - ] - cursor = LineCursor(lines) - assert cursor.length == 3 - - def test_peek_at_depth_matching_depth(self): - """Test peek_at_depth returns line when depth matches.""" - line = ParsedLine(raw=" test", depth=1, indent=2, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.peek_at_depth(1) == line - - def test_peek_at_depth_when_depth_too_shallow(self): - """Test peek_at_depth returns None when line depth is too shallow.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.peek_at_depth(1) is None - - def test_peek_at_depth_when_depth_too_deep(self): - """Test peek_at_depth returns None when line depth is too deep.""" - line = ParsedLine(raw=" test", depth=2, indent=4, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.peek_at_depth(1) is None - - def test_peek_at_depth_when_no_line(self): - """Test peek_at_depth returns None when no line available.""" - cursor = LineCursor([]) - assert cursor.peek_at_depth(0) is None - - def test_has_more_at_depth_when_true(self): - """Test has_more_at_depth returns True when line exists at depth.""" - line = ParsedLine(raw=" test", depth=1, indent=2, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.has_more_at_depth(1) is True - - def test_has_more_at_depth_when_false(self): - """Test has_more_at_depth returns False when no line at depth.""" - line = ParsedLine(raw="test", depth=0, indent=0, content="test", line_num=1) - cursor = LineCursor([line]) - assert cursor.has_more_at_depth(1) is False - - def test_skip_deeper_than(self): - """Test skip_deeper_than skips all deeper lines.""" - lines = [ - ParsedLine(raw="line1", depth=1, indent=2, content="line1", line_num=1), - ParsedLine(raw="line2", depth=2, indent=4, content="line2", line_num=2), - ParsedLine(raw="line3", depth=2, indent=4, content="line3", line_num=3), - ParsedLine(raw="line4", depth=1, indent=2, content="line4", line_num=4), - ] - cursor = LineCursor(lines) - cursor.next() # Consume first line at depth 1 - cursor.skip_deeper_than(1) - # Should skip lines 2 and 3 (depth 2) and stop at line 4 (depth 1) - assert cursor.peek() == lines[3] - - def test_skip_deeper_than_when_all_deeper(self): - """Test skip_deeper_than skips all remaining lines when all are deeper.""" - lines = [ - ParsedLine(raw="line1", depth=1, indent=2, content="line1", line_num=1), - ParsedLine(raw="line2", depth=2, indent=4, content="line2", line_num=2), - ParsedLine(raw="line3", depth=3, indent=6, content="line3", line_num=3), - ] - cursor = LineCursor(lines) - cursor.next() # Consume first line - cursor.skip_deeper_than(1) - assert cursor.at_end() is True - - -class TestToParsedLines: - """Tests for to_parsed_lines function.""" - - def test_empty_source(self): - """Test empty source returns empty lists.""" - lines, blanks = to_parsed_lines("", 2, True) - assert lines == [] - assert blanks == [] - - def test_whitespace_only_source(self): - """Test whitespace-only source returns empty lists.""" - lines, blanks = to_parsed_lines(" \n \n", 2, True) - assert lines == [] - assert blanks == [] - - def test_blank_line_tracking(self): - """Test blank lines are tracked correctly.""" - source = "name: Alice\n\n age: 30" - lines, blanks = to_parsed_lines(source, 2, False) - assert len(blanks) == 1 - assert blanks[0].line_num == 2 - assert blanks[0].indent == 0 - assert blanks[0].depth == 0 - - def test_strict_mode_tabs_in_indentation(self): - """Test strict mode rejects tabs in indentation.""" - source = "\tname: Alice" - with pytest.raises(SyntaxError, match="Tabs not allowed"): - to_parsed_lines(source, 2, True) - - def test_strict_mode_invalid_indent_multiple(self): - """Test strict mode rejects invalid indent multiples.""" - source = "name: Alice\n age: 30" # 3 spaces, not multiple of 2 - with pytest.raises(SyntaxError, match="exact multiple"): - to_parsed_lines(source, 2, True) - - def test_lenient_mode_accepts_tabs(self): - """Test lenient mode accepts tabs in indentation.""" - source = "\tname: Alice" - lines, blanks = to_parsed_lines(source, 2, False) - # Should not raise error - assert len(lines) == 1 - - def test_lenient_mode_accepts_invalid_multiples(self): - """Test lenient mode accepts invalid indent multiples.""" - source = "name: Alice\n age: 30" # 3 spaces - lines, blanks = to_parsed_lines(source, 2, False) - # Should not raise error - assert len(lines) == 2 - assert lines[1].depth == 1 # 3 // 2 = 1 - - def test_depth_calculation(self): - """Test depth is calculated correctly from indentation.""" - source = "level0\n level1\n level2\n level3" - lines, blanks = to_parsed_lines(source, 2, True) - assert lines[0].depth == 0 - assert lines[1].depth == 1 - assert lines[2].depth == 2 - assert lines[3].depth == 3 - - def test_line_numbers_are_one_based(self): - """Test line numbers start at 1.""" - source = "line1\nline2\nline3" - lines, blanks = to_parsed_lines(source, 2, True) - assert lines[0].line_num == 1 - assert lines[1].line_num == 2 - assert lines[2].line_num == 3 - - def test_blank_lines_not_validated_in_strict_mode(self): - """Test blank lines are not validated for indentation in strict mode.""" - source = "name: Alice\n \n age: 30" # Blank line with 3 spaces - lines, blanks = to_parsed_lines(source, 2, True) - # Should not raise error for blank line with invalid indentation - assert len(blanks) == 1 - assert blanks[0].line_num == 2 diff --git a/tests/test_security.py b/tests/test_security.py deleted file mode 100644 index 2d05151..0000000 --- a/tests/test_security.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Security tests for TOON format (Section 15 of spec). - -Tests resource exhaustion, malicious input handling, and security considerations -from the TOON specification Section 15. -""" - -import pytest - -from toon_format import decode, encode -from toon_format.types import DecodeOptions - - -class TestResourceExhaustion: - """Tests for resource exhaustion scenarios.""" - - def test_deeply_nested_objects_handled(self): - """Test that deeply nested objects are handled without stack overflow.""" - # Create a deeply nested structure (100 levels) - data = {"level": 0} - current = data - for i in range(1, 100): - current["nested"] = {"level": i} - current = current["nested"] - - # Should encode without stack overflow - result = encode(data) - assert "level: 0" in result - - # Should decode without stack overflow - decoded = decode(result) - assert decoded["level"] == 0 - - def test_deeply_nested_mixed_structures(self): - """Test that deeply nested mixed structures don't cause stack overflow.""" - # Create a mixed nested structure with objects and arrays - data = {"items": [{"nested": [{"deep": [1, 2, 3]}]}]} - - # Nest it further - for _ in range(10): - data = {"level": data} - - # Should encode without stack overflow - result = encode(data) - assert "level:" in result - - # Should decode without stack overflow - decoded = decode(result) - assert "level" in decoded - assert isinstance(decoded, dict) - - def test_very_long_string_handled(self): - """Test that very long strings are handled efficiently.""" - # Create a 1MB string - long_string = "a" * (1024 * 1024) - data = {"text": long_string} - - # Should encode without memory issues - result = encode(data) - assert "text:" in result - - # Should decode without memory issues - decoded = decode(result) - assert len(decoded["text"]) == 1024 * 1024 - - def test_large_array_handled(self): - """Test that large arrays are handled efficiently.""" - # Create an array with 10,000 elements - data = {"items": list(range(10000))} - - # Should encode without memory issues - result = encode(data) - assert "items[10000]:" in result - - # Should decode without memory issues - decoded = decode(result) - assert len(decoded["items"]) == 10000 - - def test_large_tabular_array_handled(self): - """Test that large tabular arrays are handled efficiently.""" - # Create a tabular array with 1000 rows - data = {"users": [{"id": i, "name": f"user{i}"} for i in range(1000)]} - - # Should encode without memory issues - result = encode(data) - assert "users[1000]" in result - - # Should decode without memory issues - decoded = decode(result) - assert len(decoded["users"]) == 1000 - - def test_many_object_keys_handled(self): - """Test that objects with many keys are handled.""" - # Create object with 1000 keys - data = {f"key{i}": i for i in range(1000)} - - # Should encode without issues - result = encode(data) - assert "key0:" in result - assert "key999:" in result - - # Should decode without issues - decoded = decode(result) - assert len(decoded) == 1000 - - -class TestMaliciousInput: - """Tests for malicious or malformed input handling.""" - - def test_unterminated_string_raises_error(self): - """Test that unterminated strings are rejected.""" - malformed = 'name: "unterminated' - - with pytest.raises(Exception): # Should raise decode error - decode(malformed) - - def test_invalid_escape_sequence_raises_error(self): - """Test that invalid escape sequences are rejected.""" - malformed = 'text: "bad\\xescape"' - - with pytest.raises(Exception): # Should raise decode error - decode(malformed) - - def test_circular_reference_in_encoding(self): - """Test that circular references are handled (Python-specific).""" - # Python allows circular references - data = {"self": None} - data["self"] = data # Circular reference - - # Should detect and handle circular reference gracefully - # (normalize_value should convert to null or handle it) - try: - result = encode(data) - # If it succeeds, it should have normalized the circular ref - # This is implementation-specific behavior - assert result is not None - except (RecursionError, ValueError): - # It's acceptable to raise an error for circular refs - pass - - def test_injection_via_delimiter_in_value(self): - """Test that delimiter injection is prevented by quoting.""" - # Try to inject extra array values via unquoted delimiter - data = {"items": ["a,b", "c"]} # Comma in first value - - result = encode(data) - # The comma should be quoted to prevent injection - assert '"a,b"' in result or "a\\,b" in result or result.count(",") == 1 - - decoded = decode(result) - assert decoded["items"] == ["a,b", "c"] - assert len(decoded["items"]) == 2 # Should be 2, not 3 - - def test_injection_via_colon_in_value(self): - """Test that colon injection is prevented by quoting.""" - # Try to inject a key-value pair via unquoted colon - data = {"text": "fake: value"} - - result = encode(data) - # The colon should be quoted - assert '"fake: value"' in result - - decoded = decode(result) - assert decoded == {"text": "fake: value"} - assert "fake" not in decoded # Should not create separate key - - def test_injection_via_hyphen_in_list(self): - """Test that hyphen injection is prevented.""" - # Try to inject list items via hyphen at start - data = ["- injected"] - - result = encode(data) - # The hyphen should be quoted - assert '"- injected"' in result - - decoded = decode(result) - assert decoded == ["- injected"] - - def test_injection_via_brackets_in_value(self): - """Test that bracket injection is prevented.""" - # Try to inject array header via brackets - data = {"text": "[10]: fake,array"} - - result = encode(data) - # Brackets should be quoted - assert '"[10]: fake,array"' in result - - decoded = decode(result) - assert decoded == {"text": "[10]: fake,array"} - - def test_tab_in_indentation_rejected_strict_mode(self): - """Test that tabs in indentation are rejected in strict mode.""" - # Malicious input with tab instead of spaces - malformed = "name: Alice\n\tage: 30" # Tab used for indentation - - with pytest.raises(Exception): # Should raise error - decode(malformed, DecodeOptions(strict=True)) - - def test_invalid_indentation_rejected_strict_mode(self): - """Test that invalid indentation multiples are rejected.""" - # Indentation not a multiple of indent size - malformed = "name: Alice\n age: 30" # 3 spaces, not multiple of 2 - - with pytest.raises(Exception): - decode(malformed, DecodeOptions(strict=True, indent=2)) - - def test_count_mismatch_detected_strict_mode(self): - """Test that array count mismatches are detected (security via validation).""" - # Declare 5 items but only provide 3 (potential truncation attack) - malformed = "items[5]: 1,2,3" - - with pytest.raises(Exception): - decode(malformed, DecodeOptions(strict=True)) - - def test_tabular_width_mismatch_detected(self): - """Test that tabular width mismatches are detected.""" - # Declare 3 fields but provide 2 values (injection or truncation) - malformed = "users[2]{id,name,age}:\n 1,Alice\n 2,Bob" - - with pytest.raises(Exception): - decode(malformed, DecodeOptions(strict=True)) - - def test_blank_line_in_array_rejected_strict_mode(self): - """Test that blank lines in arrays are rejected (prevents injection).""" - malformed = "items[3]:\n - a\n\n - b\n - c" # Blank line in array - - with pytest.raises(Exception): - decode(malformed, DecodeOptions(strict=True)) - - -class TestQuotingSecurityInvariants: - """Test that quoting rules prevent ambiguity and injection.""" - - def test_reserved_literals_quoted(self): - """Test that reserved literals are quoted when used as strings.""" - data = {"values": ["true", "false", "null"]} - - result = encode(data) - # These should be quoted to avoid ambiguity - assert '"true"' in result - assert '"false"' in result - assert '"null"' in result - - decoded = decode(result) - assert decoded["values"] == ["true", "false", "null"] - assert all(isinstance(v, str) for v in decoded["values"]) - - def test_numeric_strings_quoted(self): - """Test that numeric-looking strings are quoted.""" - data = {"codes": ["123", "3.14", "1e5", "-42"]} - - result = encode(data) - # All should be quoted to preserve string type - for code in ["123", "3.14", "1e5", "-42"]: - assert f'"{code}"' in result - - decoded = decode(result) - assert decoded["codes"] == ["123", "3.14", "1e5", "-42"] - assert all(isinstance(v, str) for v in decoded["codes"]) - - def test_octal_like_strings_quoted(self): - """Test that octal-like strings are quoted (leading zeros).""" - data = {"codes": ["0123", "0755"]} - - result = encode(data) - assert '"0123"' in result - assert '"0755"' in result - - decoded = decode(result) - assert decoded["codes"] == ["0123", "0755"] - - def test_empty_string_quoted(self): - """Test that empty strings are quoted.""" - data = {"empty": ""} - - result = encode(data) - assert 'empty: ""' in result - - decoded = decode(result) - assert decoded["empty"] == "" - - def test_whitespace_strings_quoted(self): - """Test that strings with leading/trailing whitespace are quoted.""" - data = {"values": [" space", "space ", " both "]} - - result = encode(data) - assert '" space"' in result - assert '"space "' in result - assert '" both "' in result - - decoded = decode(result) - assert decoded["values"] == [" space", "space ", " both "] - - def test_control_characters_escaped(self): - """Test that control characters are properly escaped.""" - data = {"text": "line1\nline2\ttab\rreturn"} - - result = encode(data) - # Should contain escaped sequences - assert "\\n" in result - assert "\\t" in result - assert "\\r" in result - - decoded = decode(result) - assert decoded["text"] == "line1\nline2\ttab\rreturn" diff --git a/tests/test_spec_fixtures.py b/tests/test_spec_fixtures.py index 882175e..cbc88bc 100644 --- a/tests/test_spec_fixtures.py +++ b/tests/test_spec_fixtures.py @@ -1,204 +1,78 @@ -""" -Tests for TOON spec fixtures. +"""Run the official conformance fixtures of the TOON specification.""" -This test module loads and runs all official TOON specification test fixtures -from https://github.com/toon-format/spec/tree/main/tests/fixtures -""" +from __future__ import annotations +import io import json from pathlib import Path -from typing import Any, Dict, List +from typing import Any import pytest -from toon_format import ToonDecodeError, decode, encode -from toon_format.types import DecodeOptions, EncodeOptions +import toon_format -FIXTURES_DIR = Path(__file__).parent / "fixtures" -DECODE_DIR = FIXTURES_DIR / "decode" -ENCODE_DIR = FIXTURES_DIR / "encode" - - -def load_fixture_file(filepath: Path) -> Dict[str, Any]: - """Load a fixture JSON file.""" - with open(filepath, encoding="utf-8") as f: - return json.load(f) +FIXTURES = Path(__file__).parent / "fixtures" -def get_all_decode_fixtures() -> List[tuple]: - """ - Get all decode test cases from fixture files. +def _cases(category: str) -> list[Any]: + cases = [] + for path in sorted((FIXTURES / category).glob("*.json")): + for index, case in enumerate( + json.loads(path.read_text(encoding="utf-8"))["tests"] + ): + cases.append(pytest.param(case, id=f"{path.stem}[{index}] {case['name']}")) + return cases - Returns: - List of tuples (fixture_name, test_case_name, test_data) - """ - test_cases = [] - - for fixture_file in sorted(DECODE_DIR.glob("*.json")): - fixture_data = load_fixture_file(fixture_file) - fixture_name = fixture_file.stem - - for test in fixture_data.get("tests", []): - test_id = f"{fixture_name}::{test['name']}" - test_cases.append((test_id, test, fixture_name)) - - return test_cases - - -def get_all_encode_fixtures() -> List[tuple]: - """ - Get all encode test cases from fixture files. - - Returns: - List of tuples (fixture_name, test_case_name, test_data) - """ - test_cases = [] - - for fixture_file in sorted(ENCODE_DIR.glob("*.json")): - fixture_data = load_fixture_file(fixture_file) - fixture_name = fixture_file.stem - - for test in fixture_data.get("tests", []): - test_id = f"{fixture_name}::{test['name']}" - test_cases.append((test_id, test, fixture_name)) - - return test_cases - - -class TestDecodeFixtures: - """Test all decode fixtures from the TOON specification.""" - - @pytest.mark.parametrize("test_id,test_data,fixture_name", get_all_decode_fixtures()) - def test_decode(self, test_id: str, test_data: Dict[str, Any], fixture_name: str): - """Test decoding TOON input to expected output.""" - input_str = test_data["input"] - expected = test_data.get("expected") - should_error = test_data.get("shouldError", False) - options_dict = test_data.get("options", {}) - - # Build decode options - options = DecodeOptions( - strict=options_dict.get("strict", True), indent=options_dict.get("indent", 2) - ) - - if should_error: - # Test should raise an error - with pytest.raises((ToonDecodeError, ValueError, Exception)): - decode(input_str, options=options) - else: - # Test should succeed - result = decode(input_str, options=options) - assert result == expected, ( - f"Decode mismatch in {test_id}\n" - f"Input: {input_str!r}\n" - f"Expected: {expected!r}\n" - f"Got: {result!r}" - ) - - -class TestEncodeFixtures: - """Test all encode fixtures from the TOON specification.""" - - @pytest.mark.parametrize("test_id,test_data,fixture_name", get_all_encode_fixtures()) - def test_encode(self, test_id: str, test_data: Dict[str, Any], fixture_name: str): - """Test encoding input data to expected TOON string.""" - input_data = test_data["input"] - expected = test_data["expected"] - options_dict = test_data.get("options", {}) - - # Build encode options - options = EncodeOptions( - indent=options_dict.get("indent", 2), - delimiter=options_dict.get("delimiter", ","), - lengthMarker=options_dict.get("lengthMarker", ""), - ) - - # Encode and compare - result = encode(input_data, options=options) - assert result == expected, ( - f"Encode mismatch in {test_id}\n" - f"Input: {input_data!r}\n" - f"Expected: {expected!r}\n" - f"Got: {result!r}" - ) - - -class TestRoundTrip: - """Test that encode -> decode produces the original value.""" - - @pytest.mark.parametrize("test_id,test_data,fixture_name", get_all_encode_fixtures()) - def test_roundtrip(self, test_id: str, test_data: Dict[str, Any], fixture_name: str): - """Test that encoding then decoding returns the original input.""" - # Skip normalization tests since they intentionally change data types - if fixture_name == "normalization": - pytest.skip("Normalization tests don't roundtrip by design") - - input_data = test_data["input"] - options_dict = test_data.get("options", {}) - - # Build options - encode_opts = EncodeOptions( - indent=options_dict.get("indent", 2), - delimiter=options_dict.get("delimiter", ","), - lengthMarker=options_dict.get("lengthMarker", ""), - ) - decode_opts = DecodeOptions(strict=True, indent=options_dict.get("indent", 2)) - - # Encode then decode - encoded = encode(input_data, options=encode_opts) - decoded = decode(encoded, options=decode_opts) - - assert decoded == input_data, ( - f"Roundtrip mismatch in {test_id}\n" - f"Original: {input_data!r}\n" - f"Encoded: {encoded!r}\n" - f"Decoded: {decoded!r}" - ) - - -# Statistics functions for reporting -def count_tests_in_fixture(fixture_path: Path) -> int: - """Count the number of test cases in a fixture file.""" - fixture_data = load_fixture_file(fixture_path) - return len(fixture_data.get("tests", [])) - - -def get_fixture_stats() -> Dict[str, Any]: - """Get statistics about the loaded fixtures.""" - decode_files = sorted(DECODE_DIR.glob("*.json")) - encode_files = sorted(ENCODE_DIR.glob("*.json")) - - decode_stats = { - "files": len(decode_files), - "tests": sum(count_tests_in_fixture(f) for f in decode_files), - "by_file": {f.stem: count_tests_in_fixture(f) for f in decode_files}, - } - encode_stats = { - "files": len(encode_files), - "tests": sum(count_tests_in_fixture(f) for f in encode_files), - "by_file": {f.stem: count_tests_in_fixture(f) for f in encode_files}, +def _encode_options(case: dict[str, Any]) -> dict[str, Any]: + options = case.get("options", {}) + return { + "indent_size": options.get("indentSize", 2), + "delimiter": options.get("delimiter", ","), } + +def _decode_options(case: dict[str, Any]) -> dict[str, Any]: + options = case.get("options", {}) return { - "decode": decode_stats, - "encode": encode_stats, - "total_files": decode_stats["files"] + encode_stats["files"], - "total_tests": decode_stats["tests"] + encode_stats["tests"], + "indent_size": options.get("indentSize", 2), + "strict": options.get("strict", True), } -if __name__ == "__main__": - # Print fixture statistics when run directly - stats = get_fixture_stats() - print("TOON Spec Fixture Statistics") - print("=" * 50) - print(f"\nDecode Fixtures: {stats['decode']['files']} files, {stats['decode']['tests']} tests") - for name, count in stats["decode"]["by_file"].items(): - print(f" - {name}: {count} tests") +def _key_order(value: Any) -> Any: + """The nested key sequences of a value, which ``==`` on dicts ignores.""" + if isinstance(value, dict): + return [(key, _key_order(item)) for key, item in value.items()] + if isinstance(value, list): + return [_key_order(item) for item in value] + return None + + +def _dumps_via_file(value: Any, **options: Any) -> str: + buffer = io.StringIO() + toon_format.dump(value, buffer, **options) + return buffer.getvalue() + + +def _loads_via_file(text: str, **options: Any) -> Any: + return toon_format.load(io.BytesIO(text.encode()), **options) + + +@pytest.mark.parametrize("encode", [toon_format.dumps, _dumps_via_file]) +@pytest.mark.parametrize("case", _cases("encode")) +def test_encode(case: dict[str, Any], encode: Any) -> None: + assert encode(case["input"], **_encode_options(case)) == case["expected"] - print(f"\nEncode Fixtures: {stats['encode']['files']} files, {stats['encode']['tests']} tests") - for name, count in stats["encode"]["by_file"].items(): - print(f" - {name}: {count} tests") - print(f"\nTotal: {stats['total_files']} fixture files, {stats['total_tests']} test cases") +@pytest.mark.parametrize("decode", [toon_format.loads, _loads_via_file]) +@pytest.mark.parametrize("case", _cases("decode")) +def test_decode(case: dict[str, Any], decode: Any) -> None: + if case.get("shouldError"): + with pytest.raises(toon_format.ToonDecodeError): + decode(case["input"], **_decode_options(case)) + else: + result = decode(case["input"], **_decode_options(case)) + assert result == case["expected"] + # Key order is part of the data model (§2). + assert _key_order(result) == _key_order(case["expected"]) diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py deleted file mode 100644 index 934b1ed..0000000 --- a/tests/test_string_utils.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Tests for the _string_utils module.""" - -import pytest - -from toon_format._string_utils import ( - escape_string, - find_closing_quote, - find_unquoted_char, - unescape_string, -) - - -class TestEscapeString: - """Tests for escape_string function.""" - - def test_escape_backslash(self): - """Test backslashes are escaped correctly.""" - assert escape_string("path\\to\\file") == "path\\\\to\\\\file" - - def test_escape_double_quote(self): - """Test double quotes are escaped correctly.""" - assert escape_string('say "hello"') == 'say \\"hello\\"' - - def test_escape_newline(self): - """Test newlines are escaped correctly.""" - assert escape_string("line1\nline2") == "line1\\nline2" - - def test_escape_carriage_return(self): - """Test carriage returns are escaped correctly.""" - assert escape_string("line1\rline2") == "line1\\rline2" - - def test_escape_tab(self): - """Test tabs are escaped correctly.""" - assert escape_string("col1\tcol2") == "col1\\tcol2" - - def test_escape_all_special_chars(self): - """Test all special characters are escaped in one string.""" - input_str = 'test\n\r\t\\"value"' - expected = 'test\\n\\r\\t\\\\\\"value\\"' - assert escape_string(input_str) == expected - - def test_escape_empty_string(self): - """Test empty string remains empty.""" - assert escape_string("") == "" - - def test_escape_no_special_chars(self): - """Test string without special chars is unchanged.""" - assert escape_string("hello world") == "hello world" - - -class TestUnescapeString: - """Tests for unescape_string function.""" - - def test_unescape_newline(self): - """Test \\n is unescaped to newline.""" - assert unescape_string("hello\\nworld") == "hello\nworld" - - def test_unescape_tab(self): - """Test \\t is unescaped to tab.""" - assert unescape_string("col1\\tcol2") == "col1\tcol2" - - def test_unescape_carriage_return(self): - """Test \\r is unescaped to carriage return.""" - assert unescape_string("line1\\rline2") == "line1\rline2" - - def test_unescape_backslash(self): - """Test \\\\ is unescaped to single backslash.""" - assert unescape_string("path\\\\to\\\\file") == "path\\to\\file" - - def test_unescape_double_quote(self): - """Test \\" is unescaped to double quote.""" - assert unescape_string('say \\"hello\\"') == 'say "hello"' - - def test_unescape_all_sequences(self): - """Test all escape sequences are unescaped correctly.""" - input_str = 'test\\n\\r\\t\\\\\\"value\\"' - expected = 'test\n\r\t\\"value"' - assert unescape_string(input_str) == expected - - def test_unescape_empty_string(self): - """Test empty string remains empty.""" - assert unescape_string("") == "" - - def test_unescape_no_escapes(self): - """Test string without escapes is unchanged.""" - assert unescape_string("hello world") == "hello world" - - def test_unescape_backslash_at_end_raises_error(self): - """Test backslash at end of string raises ValueError.""" - with pytest.raises(ValueError, match="backslash at end of string"): - unescape_string("test\\") - - def test_unescape_invalid_escape_sequence_raises_error(self): - """Test invalid escape sequence raises ValueError.""" - with pytest.raises(ValueError, match="Invalid escape sequence"): - unescape_string("test\\x") - - def test_unescape_preserves_non_escaped_backslash_followed_by_valid_char(self): - """Test that only valid escape sequences are processed.""" - # Any backslash followed by a non-escape character should raise error - with pytest.raises(ValueError, match="Invalid escape sequence"): - unescape_string("test\\a") - - -class TestFindClosingQuote: - """Tests for find_closing_quote function.""" - - def test_find_simple_quote(self): - """Test finding closing quote in simple string.""" - assert find_closing_quote('"hello"', 0) == 6 - - def test_find_quote_with_escaped_quote_inside(self): - """Test finding closing quote when escaped quotes are inside.""" - assert find_closing_quote('"hello \\"world\\""', 0) == 16 - - def test_find_quote_with_escaped_backslash(self): - """Test finding closing quote with escaped backslash before quote.""" - assert find_closing_quote('"path\\\\to\\\\file"', 0) == 15 - - def test_find_quote_with_multiple_escapes(self): - """Test finding closing quote with multiple escape sequences.""" - assert find_closing_quote('"test\\n\\t\\r"', 0) == 11 - - def test_find_quote_not_found(self): - """Test returns -1 when closing quote is not found.""" - assert find_closing_quote('"unclosed string', 0) == -1 - - def test_find_quote_empty_string(self): - """Test finding quote in minimal quoted string.""" - assert find_closing_quote('""', 0) == 1 - - def test_find_quote_with_escaped_char_at_end(self): - """Test finding quote when escaped character is at the end.""" - assert find_closing_quote('"test\\n"', 0) == 7 - - def test_find_quote_starts_after_opening(self): - """Test search starts after the opening quote.""" - # The function starts at position+1 internally - result = find_closing_quote('"hello"extra', 0) - assert result == 6 - - -class TestFindUnquotedChar: - """Tests for find_unquoted_char function.""" - - def test_find_char_outside_quotes(self): - """Test finding character that is outside quotes.""" - assert find_unquoted_char('key: "value"', ":", 0) == 3 - - def test_find_char_ignores_char_inside_quotes(self): - """Test character inside quotes is ignored.""" - assert find_unquoted_char('"key: nested": value', ":", 0) == 13 - - def test_find_char_with_multiple_quoted_sections(self): - """Test finding char with multiple quoted sections.""" - # First unquoted : is right after "first" - assert find_unquoted_char('"first": "second": third', ":", 0) == 7 - - def test_find_char_with_escaped_quote_in_string(self): - """Test finding char when there are escaped quotes.""" - assert find_unquoted_char('"value\\"with\\"quotes": key', ":", 0) == 21 - - def test_find_char_not_found(self): - """Test returns -1 when character is not found outside quotes.""" - assert find_unquoted_char('"all: inside: quotes"', ":", 0) == -1 - - def test_find_char_with_start_offset(self): - """Test finding char starting from a specific offset.""" - result = find_unquoted_char("first: second: third", ":", 6) - assert result == 13 - - def test_find_char_no_quotes_in_string(self): - """Test finding char when there are no quotes at all.""" - assert find_unquoted_char("key: value", ":", 0) == 3 - - def test_find_char_empty_string(self): - """Test returns -1 for empty string.""" - assert find_unquoted_char("", ":", 0) == -1 - - def test_find_char_only_quoted_string(self): - """Test returns -1 when entire string is quoted.""" - assert find_unquoted_char('"entire:string:quoted"', ":", 0) == -1 - - def test_find_char_unclosed_quote(self): - """Test behavior with unclosed quote (char after unclosed quote).""" - # If quote is never closed, everything after is considered "in quotes" - assert find_unquoted_char('"unclosed: value', ":", 0) == -1 - - def test_find_char_escaped_backslash_before_quote(self): - """Test finding char with escaped backslash before closing quote.""" - # String: "test\\" followed by : outside - assert find_unquoted_char('"test\\\\": value', ":", 0) == 8 - - def test_find_char_with_escaped_char_in_quotes(self): - """Test that escaped characters inside quotes are properly skipped.""" - # The \\n should be skipped as an escape sequence - assert find_unquoted_char('"test\\nvalue": key', ":", 0) == 13 - - def test_find_char_quote_at_start(self): - """Test finding char when string starts with a quote.""" - assert find_unquoted_char('"quoted": unquoted', ":", 0) == 8 - - def test_find_char_quote_at_end(self): - """Test finding char when quote is at the end.""" - assert find_unquoted_char('unquoted: "quoted"', ":", 0) == 8 - - def test_find_multiple_chars_first_match(self): - """Test returns first match when character appears multiple times.""" - assert find_unquoted_char("a:b:c", ":", 0) == 1 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c78aa62 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1604 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[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 = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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 = "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.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/bd/80/4efe245037b2d2ff64e220fecb4656f41a3d5e296a65daa6d6e4245e1400/hypothesis-6.168.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:26084ef31653108da9e5eb171278a1d930e90d8631604391f828219f5c1f6cb2", size = 792323, upload-time = "2026-09-28T05:19:31.294Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/0f96010943e93d07fe0c064a6c1812b33d305f29fa43606a8b863ba806af/hypothesis-6.168.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aac5889ea7b0b86cf2bf5709250b7576c44859dd249d1be23c009a7d6fb9f742", size = 788335, upload-time = "2026-09-28T05:20:29.748Z" }, + { url = "https://files.pythonhosted.org/packages/12/a9/95ebd471169fef0ccb7c9194ed643797eab67703b927efb6e76c046a78ce/hypothesis-6.168.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7311d8ff296806ea41513d52edc832fcf43a1dc8d5c5f308cc386fc31b253a05", size = 1124433, upload-time = "2026-09-28T05:19:01.479Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3a/153bf04b7ce7f14d563b943af5f05347d5f91948f66f59655b9913b97531/hypothesis-6.168.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1872d2dc3f3758dfbf17cc39838b8e6b6a5c39a37811c067245e87b12d50f74", size = 1170640, upload-time = "2026-09-28T05:18:40.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/da/3d5ca014d00897dc5f69fa45374d86014fde89938e381303d597275b4ad3/hypothesis-6.168.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:140fe3cc7ba98a4ffeeb6492c318b43c326110a4d7b4f56bb759d5c264e2dc1c", size = 1300447, upload-time = "2026-09-28T05:18:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/38fb90efa47cab7d93b2794624a8177533bc748ee4666681a0ece1e3c23b/hypothesis-6.168.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:558625ff28e415f2f770cad618c2c9e05a045a6586949397f178d537bec2f083", size = 1336918, upload-time = "2026-09-28T05:18:45.505Z" }, + { url = "https://files.pythonhosted.org/packages/78/44/92b10c9df3029379cc67ad2d3af17cd1b35a72516b0ef37d5b55a125caec/hypothesis-6.168.3-cp310-cp310-win_amd64.whl", hash = "sha256:836eda971f25ddfce274107ed113297e8acede8d9b267775742b8ba8a404d0f0", size = 684844, upload-time = "2026-09-28T05:19:39.335Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[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 = "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 = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" }, +] + +[[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 = "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", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { 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", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { 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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[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.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +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/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[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.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/8a/84ef03c1c83eacd7cc4540b05428a93b5cd4e42f62fb0b98ac2cb6ed3a6d/platformdirs-4.12.1.tar.gz", hash = "sha256:38da801a4af303033cbffccb39030db22bf0473e6414309b02acebeee7ca8bf1", size = 59427, upload-time = "2026-09-28T05:05:16.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/31/1521c38d175108ac395941adbcb9f11e085340bed81ad166e8ad8c21c17b/platformdirs-4.12.1-py3-none-any.whl", hash = "sha256:7f251621befae78b2cb74616f9fc99d3fa3db0afba5035987731f8dcd430337d", size = 33011, upload-time = "2026-09-28T05:05:15.06Z" }, +] + +[[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 = "prek" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/f1/8f3143530bf43fa82f8873f4e536f318b6143dd5c09171aba0d465e21f04/prek-0.5.4.tar.gz", hash = "sha256:7f5bc061880141e362ef422e52188d0090794c0622e442e3157686d1e342780a", size = 565182, upload-time = "2026-09-28T04:54:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/a7/f3a4a252c0846c5846dd86118ee0c1fddb7381dc35e3d44c2a5f3cea1757/prek-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:550ecaae0601f9405526c866a57cc6882ed1e9d8696ac1117b8703bb37d087eb", size = 6011113, upload-time = "2026-09-28T04:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e3/26638cf090b620e63e60a639b8e7d7eb38b9fbea74a0827a182fc922cf8e/prek-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7ef67cc23e23c6e9c6ca0b27a7b12c19f16774d36b1c88ed035f2639995718d", size = 5534338, upload-time = "2026-09-28T04:54:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/08/64b668eb088204650b96183cea739b56e6b179130c4398a8a83cbe9b125a/prek-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:59f2206850f643bafe46b28b4431525f8dcc5b7a763f271179bff10b8157870d", size = 5840937, upload-time = "2026-09-28T04:54:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ee/a0f847194eb6a2b67921b4c94c4fbf3f33a785affb190c6a2097344dbdd6/prek-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5b0cab1219a7bc9cc90785c7436f563765440616543ede7dfa3ba5c332cede", size = 6234354, upload-time = "2026-09-28T04:54:34.176Z" }, + { url = "https://files.pythonhosted.org/packages/32/9a/14186d90ed29b46650452068d6bd1ac233ca0ea41a3b35081c37f6b77b1c/prek-0.5.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8fc22db8132dab9aed4e152bf6a58a7f791f4c91c74305d65d96c48b8ec0bc62", size = 5845649, upload-time = "2026-09-28T04:54:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/e0/88/ff5bbbada4098f59740ab767f5c2f82d4110b90ea7db309a9bcf21f3dcb9/prek-0.5.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:19d4f3986dcd1dfa15f9417e32a75fb2ba084156dbd7ace161f0764281b8064b", size = 6351516, upload-time = "2026-09-28T04:54:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/c8/1a/0cc410232ef42750b3bf34babd6def4a3943ec98259d9fecda3b26fcd63f/prek-0.5.4-py3-none-win_amd64.whl", hash = "sha256:3100233ba802eff6a4b09bc045c8f50d599bb9d810fa234823bc1642938b224f", size = 5755956, upload-time = "2026-09-28T04:54:40.227Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/3fd53cc54468db37462885d3139eae40bb469d51beae4575c986b190ab6e/prek-0.5.4-py3-none-win_arm64.whl", hash = "sha256:9fc4ad2d647f70a8a3a9900849e1dd89472cf5a71d830f083b62cf87a0406e27", size = 5502478, upload-time = "2026-09-28T04:54:41.973Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[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 = "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.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "toon-format" +version = "1.0.0" +source = { editable = "." } + +[package.optional-dependencies] +pydantic = [ + { name = "pydantic" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "attrs" }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "prek" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' and extra == 'pydantic'", specifier = ">=4.0" }, +] +provides-extras = ["pydantic"] + +[package.metadata.requires-dev] +dev = [ + { name = "attrs", specifier = ">=23" }, + { name = "hypothesis", specifier = ">=6.100" }, + { name = "mypy", specifier = ">=1.13" }, + { name = "prek", specifier = ">=0.5.4" }, + { name = "pydantic", specifier = ">=2" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, + { name = "ruff", specifier = ">=0.8" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1,<2" }, + { name = "mkdocs-material", specifier = ">=9.7.7" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.6" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[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" }, +]