diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile index d024bc37..14dcce97 100644 --- a/.clusterfuzzlite/Dockerfile +++ b/.clusterfuzzlite/Dockerfile @@ -1,3 +1,5 @@ +#checkov:skip=CKV_DOCKER_2: OSS-Fuzz build image; one-shot `compile` step, not a long-running service +#checkov:skip=CKV_DOCKER_3: matches upstream oss-fuzz-base/base-builder, which also runs as root for `compile` FROM gcr.io/oss-fuzz-base/base-builder-python@sha256:c5fd81433b8ceec0335387187152e856e401fa09e859ba778008e63be14d0701 # Pin dependencies diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6dffcdc1..957b6fd7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -41,6 +41,18 @@ updates: schedule: interval: "monthly" + # Node-based lint tools + - package-ecosystem: "npm" + directory: "/tools" + cooldown: + default-days: 7 + groups: + lint-tools: + patterns: + - "*" + schedule: + interval: "monthly" + # .clusterfuzzlite/requirements-bootstrap.txt. - package-ecosystem: "pip" directory: "/.clusterfuzzlite" @@ -55,5 +67,7 @@ updates: - package-ecosystem: docker directory: /.clusterfuzzlite + cooldown: + default-days: 7 schedule: interval: daily diff --git a/.github/linters/.checkov.yaml b/.github/linters/.checkov.yaml index 9a0bf878..2a103e0b 100644 --- a/.github/linters/.checkov.yaml +++ b/.github/linters/.checkov.yaml @@ -1,4 +1,12 @@ --- +directory: + - .github + - examples/openapi + - .clusterfuzzlite +framework: + - github_actions + - openapi + - dockerfile skip-check: # Demo and snapshot OpenAPI specs intentionally omit global auth requirements. - CKV_OPENAPI_4 diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json index 3159a9cf..955666d3 100644 --- a/.github/linters/.jscpd.json +++ b/.github/linters/.jscpd.json @@ -2,5 +2,10 @@ "threshold": 5, "minLines": 20, "minTokens": 120, - "format": ["python"] + "format": ["python"], + "ignore": [ + "tests/agents/official_recipes.py", + "examples/recipes.py", + "examples/recipes2.py" + ] } diff --git a/.github/linters/.yamllint.yml b/.github/linters/.yamllint.yml index 0591216c..28e0bc9e 100644 --- a/.github/linters/.yamllint.yml +++ b/.github/linters/.yamllint.yml @@ -1,11 +1,14 @@ --- extends: default +ignore-from-file: .gitignore + rules: line-length: max: 120 level: warning comments: disable + comments-indentation: disable indentation: disable truthy: check-keys: false diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a3c7151b..4c57d7fd 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,7 +12,9 @@ written as they are, with security and auditability as defaults. change generated OpenAPI. - Implementation script: [`scripts/update_openapi_snapshots.py`](../../scripts/update_openapi_snapshots.py). -- Local refresh: invoke `prek` hooks or direct script execution. +- Local refresh: invoke `prek` hooks or direct script execution. The script also + supports `--fail-on-change` for hook-style callers that want writes to fail + the current run. - CI model: [`dependabot-update-openapi-snapshots.yml`](dependabot-update-openapi-snapshots.yml) regenerates snapshots on Dependabot dependency updates and commits only when diff --git a/.github/workflows/dependabot-update-openapi-snapshots.yml b/.github/workflows/dependabot-update-openapi-snapshots.yml index 565c737f..01fa494d 100644 --- a/.github/workflows/dependabot-update-openapi-snapshots.yml +++ b/.github/workflows/dependabot-update-openapi-snapshots.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: Setup uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true cache-dependency-glob: | @@ -45,6 +45,8 @@ jobs: run: uv sync --frozen --no-default-groups --group snapshots - name: Regenerate OpenAPI snapshots + # Omit --fail-on-change here: this workflow wants rewritten snapshot + # files so it can commit and push them back to the PR branch. run: uv run python scripts/update_openapi_snapshots.py - name: Create GitHub App token @@ -54,6 +56,7 @@ jobs: app-id: ${{ vars.SNAPSHOT_APP_ID }} private-key: ${{ secrets.SNAPSHOT_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} + repositories: jsonpatchx permission-contents: write - name: Configure git remote with GitHub App token diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index d953cf99..784a1238 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -38,7 +38,7 @@ jobs: persist-credentials: false - name: Setup uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7b33fe1f..b87e28cf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,8 +27,13 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - name: Setup uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - name: Validate prek config + run: + uv run --only-group hooks prek validate-config .pre-commit-config.yaml - name: ๐Ÿงน run superlinter - uses: super-linter/super-linter@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0 + uses: super-linter/super-linter@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 967388fb..d04a0be0 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -28,9 +28,9 @@ jobs: persist-credentials: false - name: Setup uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: - enable-cache: true + enable-cache: false cache-dependency-glob: | pyproject.toml uv.lock diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 06588119..91787c1f 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.13t", "3.14", "3.14t"] steps: - name: ๐Ÿ”’ harden runner @@ -36,7 +36,7 @@ jobs: submodules: recursive - name: Setup uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: ${{ matrix.python-version }} enable-cache: true @@ -44,8 +44,34 @@ jobs: pyproject.toml uv.lock + # matrix.python-version carries a trailing "t" for free-threaded builds + # (e.g. "3.13t"), which mypy's --python-version does not accept and + # which must never get `jsonpatchx[strict-jsonpath]` installed (its + # iregexp-check dependency segfaults on import under free-threaded + # Python). Compute the base version and the strict extra here so later + # steps do not have to re-derive them. + - name: Compute base Python version and strict-jsonpath extra + id: pyver + shell: bash + run: | + PYVER="${{ matrix.python-version }}" + if [[ "$PYVER" == *t ]]; then + echo "base=${PYVER%t}" >> "$GITHUB_OUTPUT" + echo "extra_args=" >> "$GITHUB_OUTPUT" + else + echo "base=$PYVER" >> "$GITHUB_OUTPUT" + echo "extra_args=--extra strict-jsonpath" >> "$GITHUB_OUTPUT" + fi + - name: Install Dependencies - run: uv sync --frozen --no-default-groups --group ci + env: + EXTRA_ARGS: ${{ steps.pyver.outputs.extra_args }} + run: | + # EXTRA_ARGS is intentionally unquoted: it must word-split into + # zero or two argv tokens (`--extra strict-jsonpath`), not pass as + # one quoted string. + # shellcheck disable=SC2086 + uv sync --frozen --no-default-groups --group ci $EXTRA_ARGS - name: Ruff run: uv run ruff check @@ -54,21 +80,32 @@ jobs: run: uv run ruff format --check . - name: Mypy - run: uv run mypy --python-version "${{ matrix.python-version }}" . + env: + BASE_PYTHON_VERSION: ${{ steps.pyver.outputs.base }} + run: uv run mypy --python-version "$BASE_PYTHON_VERSION" . - name: Test + id: test run: | uv run pytest \ --cov=jsonpatchx \ --cov-report=xml:coverage.xml + # Upload coverage to Codecov whenever tests run (pass or fail). # Dependabot PRs skip this upload. Supporting uploads there would require # an additional Dependabot-scoped CODECOV_TOKEN secret. + # Free-threaded legs (3.13t, 3.14t) skip this too: they never install + # jsonpatchx[strict-jsonpath], but jsonpatchx's own source has no code + # path gated on that extra, so their jsonpatchx coverage is identical to + # their non-t sibling's and would only add a duplicate flag. - name: Upload coverage to Codecov - if: - ${{ github.event_name != 'pull_request' || - github.event.pull_request.user.login != 'dependabot[bot]' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + if: | + !cancelled() && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && + !endsWith(matrix.python-version, 't') && + (github.event_name != 'pull_request' || + github.event.pull_request.user.login != 'dependabot[bot]') + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./coverage.xml flags: py-${{ matrix.python-version }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 78315796..3731fa47 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -40,6 +40,7 @@ jobs: private-key: ${{ secrets.SCORECARD_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} + permission-administration: read - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: diff --git a/.gitignore b/.gitignore index e42cd647..65a7926d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ fuzzers/corpus/**/[0-9a-f][0-9a-f][0-9a-f][0-9a-f]* # Virtual environments .venv +.venv-py* +node_modules/ # draft files *temp_* @@ -30,3 +32,6 @@ tmp/** # other **/.DS_Store + +# Claude Code local state (settings overrides, agent worktrees) +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 287fc064..911279d5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,60 +1,74 @@ --- # Runs CI locally to save time on failures -minimum_pre_commit_version: "3.7.0" +minimum_prek_version: "0.4.8" +default_env: + UV_PYTHON: "" + VIRTUAL_ENV: "" + UV_PROJECT_ENVIRONMENT: "" +default_language_version: + node: lts repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + - repo: builtin hooks: - - id: end-of-file-fixer - - id: trailing-whitespace - id: check-merge-conflict + groups: [readonly] + priority: 0 - id: check-json # This fixture intentionally includes non-standard JSON numeric literals (NaN/Infinity). # In super-linter, this is FILTER_REGEX_EXCLUDE. exclude: &json_nonfinite_exclude ^tests/compliance/rfc6902/jsonpatchx_nonfinite_tests\.json$ + groups: [readonly] + priority: 0 - id: check-yaml + groups: [readonly] + priority: 0 - repo: https://github.com/rhysd/actionlint rev: v1.7.12 hooks: - id: actionlint files: ^\.github/(workflows|actions)/.*\.ya?ml$ + groups: [readonly] + priority: 0 - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.23.1 + rev: v1.26.1 hooks: - id: zizmor - files: ^\.github/(workflows|actions)/.*\.ya?ml$ + files: ^\.github/((workflows|actions)/.*\.ya?ml$|dependabot\.yml$) + groups: [readonly] + priority: 0 - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 hooks: - id: gitleaks - - - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 - hooks: - - id: markdownlint - args: - [ - --config, - .github/linters/.markdownlint.yml, - --ignore-path, - .gitignore, - --fix, - ] + groups: [readonly] + priority: 0 - repo: https://github.com/adrienverge/yamllint.git rev: v1.38.0 hooks: - # no way to respect gitignore - id: yamllint args: [-c, .github/linters/.yamllint.yml] + groups: [readonly] + priority: 0 - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell + # Machine-generated; third-party package metadata trips false positives. + exclude: ^tools/package-lock\.json$ + groups: [readonly] + priority: 0 + + - repo: https://github.com/jumanjihouse/pre-commit-hooks + rev: 3.0.0 + hooks: + - id: shellcheck + groups: [readonly] + priority: 0 - repo: local hooks: @@ -63,88 +77,157 @@ repos: entry: uv run ruff check language: system types: [python] + groups: [readonly] + priority: 0 - - id: ruff-format - name: ruff format - entry: uv run ruff format + - id: mypy-py312 + name: mypy (Py3.12) + entry: + uv run --python 3.12 mypy --no-incremental --python-version 3.12 . language: system - types: [python] + env: + UV_PROJECT_ENVIRONMENT: .venv-py312 + files: (\.py$|^pyproject\.toml$|^uv\.lock$) + pass_filenames: false + groups: [readonly] + priority: 0 - - id: mypy - name: mypy - entry: uv run mypy --no-incremental . + - id: mypy-py313 + name: mypy (Py3.13) + entry: + uv run --python 3.13 mypy --no-incremental --python-version 3.13 . language: system + env: + UV_PROJECT_ENVIRONMENT: .venv-py313 + files: (\.py$|^pyproject\.toml$|^uv\.lock$) pass_filenames: false + groups: [readonly] + priority: 0 - - id: update-openapi-snapshots - name: update openapi snapshots - entry: uv run python scripts/update_openapi_snapshots.py + - id: mypy-py314 + name: mypy (Py3.14) + entry: + uv run --python 3.14 mypy --no-incremental --python-version 3.14 . language: system - files: (^.*\.py$|^.*\.json$|^pyproject\.toml$|^uv\.lock$) + env: + UV_PROJECT_ENVIRONMENT: .venv-py314 + files: (\.py$|^pyproject\.toml$|^uv\.lock$) pass_filenames: false + groups: [readonly] + priority: 0 - id: checkov - name: - checkov - # no way to respect gitignore - entry: - uv tool run --python 3.13 checkov --config-file - .github/linters/.checkov.yaml --directory . - language: system + # No way to respect gitignore. + # Intentionally kept out of pyproject.toml's dependency groups: checkov's + # transitive deps (orjson) have no free-threaded (cp314t) wheels yet, so + # resolving it against this project's default .python-version would force + # a source build requiring a newer rustc than most machines have. Its own + # isolated environment here avoids that entirely. + name: checkov + entry: checkov --config-file .github/linters/.checkov.yaml + language: python + additional_dependencies: + - checkov==3.3.6 + files: ^(\.github/|examples/openapi/|\.clusterfuzzlite/) pass_filenames: false + groups: [readonly] + priority: 0 - id: jscpd name: jscpd - entry: npx --yes jscpd --gitignore --config .github/linters/.jscpd.json + entry: + tools/node_modules/.bin/jscpd --config .github/linters/.jscpd.json + language: system + types: [python] + pass_filenames: false + groups: [readonly] + priority: 0 + + - id: openapi + name: OpenAPI (spectral) + entry: + tools/node_modules/.bin/spectral lint --ruleset + .github/linters/.openapirc.yml + language: system + files: ^examples/openapi/.*\.json$ + groups: [readonly] + priority: 0 + + - repo: builtin + hooks: + - id: end-of-file-fixer + groups: [readwrite] + - id: trailing-whitespace + groups: [readwrite] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.49.0 + hooks: + - id: markdownlint + args: + [ + --config, + .github/linters/.markdownlint.yml, + --ignore-path, + .gitignore, + --fix, + ] + groups: [readwrite] + + - repo: local + hooks: + - id: ruff-format + name: ruff format + entry: uv run ruff format --exit-non-zero-on-format language: system types: [python] + groups: [readwrite] + + - id: update-openapi-snapshots + name: update openapi snapshots + entry: + # In commit hooks, snapshot rewrites should fail so the next commit + # attempt sees the regenerated files. + uv run python scripts/update_openapi_snapshots.py --fail-on-change + language: system + files: ^(jsonpatchx/.*\.py$|examples/.*\.py$|scripts/update_openapi_snapshots\.py$|pyproject\.toml$|uv\.lock$) pass_filenames: false + groups: [readwrite] - id: json-prettier name: json prettier entry: - npx --yes prettier --write --ignore-path .gitignore --config - .github/linters/.prettierrc.json + tools/node_modules/.bin/prettier --write --ignore-path .gitignore + --config .github/linters/.prettierrc.json language: system files: \.json$ exclude: *json_nonfinite_exclude + groups: [readwrite] - id: markdown-prettier name: markdown prettier entry: - npx --yes prettier --write --ignore-path .gitignore --config - .github/linters/.prettierrc.json + tools/node_modules/.bin/prettier --write --ignore-path .gitignore + --config .github/linters/.prettierrc.json language: system files: \.(md|markdown)$ + groups: [readwrite] - id: yaml-prettier name: yaml prettier entry: - npx --yes prettier --write --ignore-path .gitignore --config - .github/linters/.prettierrc.json + tools/node_modules/.bin/prettier --write --ignore-path .gitignore + --config .github/linters/.prettierrc.json language: system files: \.ya?ml$ + groups: [readwrite] - id: natural-language name: natural language (textlint terminology) - entry: - npx --yes --package textlint --package textlint-rule-terminology - textlint --rule terminology + entry: tools/node_modules/.bin/textlint --rule terminology --fix language: system files: \.(md|markdown)$ - - - id: openapi - name: openapi (spectral) - entry: - npx --yes @stoplight/spectral-cli lint --ruleset - .github/linters/.openapirc.yml - language: system - files: \.json$ - exclude: *json_nonfinite_exclude - - repo: https://github.com/jumanjihouse/pre-commit-hooks - rev: 3.0.0 - hooks: - - id: shellcheck + groups: [readwrite] # - repo: https://github.com/pylint-dev/pylint # rev: v4.0.5 # hooks: diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e9fd21f9..b24cda3a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -26,7 +26,7 @@ "command": "bash", "args": [ "-c", - "uv run pytest -v --cov=jsonpatchx --cov=tests --cov-report=term-missing --cov-report=html; status=$?; echo \"See HTML coverage here: file://${PWD}/htmlcov/index.html\"; exit $status" + "uv run --extra strict-jsonpath pytest -v --cov=jsonpatchx --cov=tests --cov-report=term-missing --cov-report=html; status=$?; echo \"See HTML coverage here: file://${PWD}/htmlcov/index.html\"; exit $status" ], "group": "test", "problemMatcher": [] @@ -37,7 +37,7 @@ "command": "bash", "args": [ "-c", - "uv run --managed-python -p 3.12 pytest && uv run --managed-python -p 3.13 pytest && uv run --managed-python -p 3.14 pytest" + "uv run --managed-python -p 3.12 --extra strict-jsonpath pytest && uv run --managed-python -p 3.13 --extra strict-jsonpath pytest && uv run --managed-python -p 3.13t pytest && uv run --managed-python -p 3.14 --extra strict-jsonpath pytest && uv run --managed-python -p 3.14t pytest" ], "group": "test", "problemMatcher": [] diff --git a/AGENTS.md b/AGENTS.md index 0100d00a..37562b86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,5 @@ # AGENTS.md -## Documentation Structure - -Before rewriting or compacting docs, inspect the heading outline as both a human -and a Model Context Protocol (MCP) client would. - -- Dignify the reader's intelligence. Do not explain reasonably self-obvious - behavior or benefits at unnecessary length. -- Do not create umbrella headings unless they group at least two real peer - subsections and have a short intro paragraph. -- If a heading names `X` and `Y`, then `X` and `Y` must both appear as peer - topics in the outline. -- Every heading must describe the content directly under it. -- Do not begin a heading with inline code. If a symbol or type name appears in - the heading, phrase it so the heading still reads naturally in prose. -- Use noun-phrase headings for conceptual sections. -- Use action/task headings for procedural sections. -- Introduce concepts from their actual guarantees and boundaries: say what is - parsed up front, what is enforced later, what is validated where, and what a - type or abstraction does and does not promise. -- Take extra care when adding, removing, or moving sections so that concepts are - introduced before later sections rely on them. -- Avoid lookahead prose such as "later on this page..." -- Optimize for semantic hierarchy, scanability, and machine-readable structure. - ## Docstring Style When editing public Python docstrings, match the repo's Google-style section @@ -37,6 +13,19 @@ labels and the way Zensical renders them. add placeholder examples just to fill the section. - Keep the opening sentence concrete and descriptive, then use sections for the rest. +- Before writing a docstring, check whether the function/class is public (no + leading underscore, reachable from `jsonpatchx/__init__.py` or otherwise part + of the documented surface) or private. Public docstrings are read by callers + who never see the implementation; private docstrings are read by maintainers + who already have it open. Write for that actual reader: a public docstring + states the contract (inputs, outputs, guarantees, what to catch); it does not + explain why the implementation isn't shaped some other way, why a related type + isn't used instead, or other design-decision rationale aimed at a future + maintainer. That belongs in a code comment, the PR description, or + `CHANGELOG.md`, not in text a caller has to read past to learn how to use the + thing. Private docstrings have more room for implementation reasoning, but + should still not sit as design manifestos if the actual contract is not the + code's own point. ## Changelog Writing diff --git a/CHANGELOG.md b/CHANGELOG.md index bb546b6f..7b46f5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,26 +12,171 @@ and this project adheres to ### Added +- Every `PatchError` now carries `index`/`operation`, identifying which + operation in the patch document is implicated, when there is one. Named + `operation` rather than `op` to avoid colliding with `OperationSchema.op`, the + RFC 6902 discriminator string field every operation already has (an + `exc.op.op` or `{"op": {"op": ...}}` shape would have been confusing). + `_apply_ops` attaches both to any `PatchError` raised from inside an + operation's `apply()`, including custom operations, not just the built-ins. + Neither is settable at construction; only `_apply_ops` sets them, after the + fact, since any caller-supplied value would just be overwritten. They are + `None` when a failure isn't attributable to a single operation, for example + `InvalidPatchResult` raised from the final whole-document re-validation after + every operation already applied without conflict. +- `InvalidPatchResult` gained `errors`, carrying the structured + `pydantic.ValidationError.errors()` list when raised from a wrapped validation + failure, or `None` when a custom operation raises it directly with no + underlying validation error. +- Every `PatchError` and `InvalidPatchTarget` now expose `error_type`, a stable, + machine-readable string identifier independent of the Python class name (for + example `"patch_conflict"`, `"invalid_patch_result"`). - `DEFAULT_POINTER_CLS` and `DEFAULT_SELECTOR_CLS` are now explicitly supported public API and can be imported directly when you want to bind JsonPatchX's built-in pointer and selector backends. -- `TargetState.MISSING` now distinguishes the root-document `MISSING` sentinel - case from other pointer target states. +- `InvalidPatchTarget` is a new `PatchError` subclass raised when the document + passed to the patch engine is not a valid JSON value. This is a server-side + configuration error and maps to 500 Internal Server Error. +- The custom-operation agent guide is now a published docs page at + `docs/custom-operation-agent-guide.md`, including instructions for pinning it + downstream at your installed `jsonpatchx` version. +- `JsonPatchRoute`'s content-type enforcement now sends an `Accept-Patch` header + naming the expected media type on its 415 responses, so clients that send the + wrong `Content-Type` can self-correct without guessing. + +### Removed + +- `PatchFailureDetail` has been removed from the public API. `index`/`operation` + are now plain attributes on every `PatchError` (see Added above), and + `PatchInternalError.cause_type` is now a property derived from `__cause__`, so + the separate detail object is no longer needed. `PatchInternalError`'s + constructor now takes `message`, `index`, `operation`, and `cause` directly + instead of a `PatchFailureDetail`. +- `OperationValidationError` has been removed from the public API. It was raised + inside Pydantic model validators, where Pydantic always wraps it in its own + `ValidationError` before callers can observe it, making it uncatchable as a + standalone exception. Use `PydanticCustomError` from `pydantic_core` instead, + which gives structured error codes and message templates and integrates + cleanly with Pydantic's validation error surface. `MoveOp` has been updated + accordingly. +- `STANDARD_OPS` has been removed from the public API. It was a redundant tuple + of the six built-in operation classes with no behavior of its own. The + individual op classes (`AddOp`, `RemoveOp`, etc.) and `StandardRegistry` + remain fully supported. +- `PatchInputError` has been removed from the public API. Its subclasses + (`InvalidJSONPointer`, `InvalidJSONSelector`, `InvalidPatchResult`) are now + direct `PatchError` subclasses with unchanged HTTP mappings (422). Replace any + `except PatchInputError` with the specific types you need. +- `OperationNotRecognized` has been removed. Passing an op instance that is not + registered now raises `ValidationError` from Pydantic, consistent with all + other invalid inputs to the parse methods, including for a registered `op` + literal produced by the wrong exact class (a subclass, or a different generic + specialization of a registered model) that the discriminator alone would + otherwise miss. Each such error carries `type="operation_not_recognized"` in + `.errors()`, so it can still be identified programmatically without a + dedicated exception class to catch. ### Changed +- The FastAPI error response body is now one consistent shape for every + `PatchError` and `InvalidPatchTarget`: + `{"type", "detail", "index", "operation", "errors"}`, replacing the previous + `detail: str | PatchFailureDetailResponse` union. `PatchFailureDetailResponse` + has been removed; `patch_error_openapi_responses()` now derives its schema + directly from `PatchErrorResponse` instead of a hand-maintained inline dict. +- `InvalidPatchTarget` now subclasses `TypeError` only; it is no longer a + `PatchError` subclass. Its three raise sites (wrong model instance passed to + `.apply()`, a target model's `model_dump()` producing non-JSON data, or a + plain document that isn't valid JSON) are all argument validation on the + `.apply()` call itself, not a patch document interacting badly with real + document state, so it does not belong with `PatchConflictError`/ + `InvalidPatchResult`. `install_jsonpatch_error_handlers` now registers a + dedicated handler for `InvalidPatchTarget` alongside the `PatchError` handler, + so the 500 mapping and response body are unchanged. If you were catching it + via `PatchError`, catch `InvalidPatchTarget` (or `TypeError`) directly + instead. +- `PatchValidationError` has been renamed to `InvalidPatchResult` for clarity. + The old name was easy to misread as "the patch document itself failed + validation," which is a distinct, unrelated concept already covered by + `pydantic.ValidationError` during parsing. The new name pairs it with + `InvalidPatchTarget`: a bad input document raises `InvalidPatchTarget`, and a + bad outcome after an otherwise-successful apply raises `InvalidPatchResult`. + Behavior and the 422 HTTP mapping are unchanged; update any + `except PatchValidationError` to `except InvalidPatchResult`. +- `InvalidOperationDefinition` and `InvalidOperationRegistry` now subclass + `TypeError` only; they are no longer `PatchError` subclasses. Both are raised + at class-definition time (`__init_subclass__`) or registry-construction time, + never during `op.apply()`, so they are startup/configuration errors, not + runtime patch failures, and were never reachable through + `install_jsonpatch_error_handlers`'s `PatchError` handler or `_apply_ops`'s + `except PatchError` branch. If you were catching either via `PatchError`, + catch the specific type (or `TypeError`) instead. A malformed `op: Literal` + annotation with no arguments (for example `op: Literal` with nothing in the + brackets) now consistently raises `InvalidOperationDefinition` instead of + sometimes leaking a bare `TypeError` from `get_type_hints` on Python 3.13 and + earlier. +- `InvalidJSONPointer` and `InvalidJSONSelector` now subclass `ValueError` only; + they are no longer `PatchError` subclasses, since bad pointer/selector syntax + is a plain Python input error, not a patch-domain failure. When either is + raised inside Pydantic field validation (constructing an `OperationSchema`, + parsing a `JsonPatch`, or a FastAPI route body), Pydantic still wraps it in + `ValidationError` automatically, matching normal Pydantic idioms and FastAPI's + default 422 handling; this part is unaffected by the `PatchError` change, + since it was already driven by `ValueError`. If you were catching + `InvalidJSONPointer`/`InvalidJSONSelector` directly around a + `model_validate()`/`JsonPatch(...)` call, catch `ValidationError` instead; it + still maps to 422. Two things do change now that they are not `PatchError`: if + either is raised inside a custom op's `apply()`, the patch engine wraps it as + `PatchInternalError` (500) like any other unexpected exception, instead of + propagating it unwrapped; and if either is raised anywhere else uncaught (for + example calling `JSONPointer.parse()`/`JSONSelector.parse()` directly), it is + now a plain unhandled `ValueError` (500 by default) instead of being caught by + `install_jsonpatch_error_handlers`'s `PatchError` handler as a 422. +- `JSONPointer.is_parent_of` and `is_child_of` now raise `TypeError` instead of + `InvalidJSONPointer` when called with a pointer that uses an incompatible + backend, or a plain string `other` that fails to parse under the pointer's own + backend syntax (the original `InvalidJSONPointer` is available as `__cause__` + in that case). Both are programmer errors, not patch input errors. +- `JSONSelector` methods (`get_pointers`, `getall`, `addall`, `removeall`, + `is_gettable`, `is_addable`, `is_removable`) now raise `TypeError` instead of + `InvalidJSONSelector` when the selector backend yields objects that do not + implement `PointerBackend`. Same rationale: corrupted backend state is a + programmer error, not a patch input error. +- Inputs that represent programmer misconfiguration (passing a wrong-type + argument to `parse()`, or using an abstract class as a backend type parameter) + now raise `TypeError` directly rather than surfacing as + `InvalidJSONPointer`/`InvalidJSONSelector` or `ValidationError`. Both map to + 500, not 422. +- The patch engine now validates each operation's return value against + `JSONValue` after every `apply()` call. If a custom op returns a non-JSON + value (for example, a `datetime`), a `PatchInternalError` is raised with the + op index and payload as context rather than the invalid value silently + propagating. - Simplified `SelectorBackend` so custom selector backends yield `PointerBackend` instances directly through `pointers(doc)`, removing the separate `SelectorMatch` wrapper protocol. -- Root `JSONPointer` and root `JSONSelector` operations now treat a missing - document as a distinct runtime state: root reads and removals fail when no - document exists, while root adds recreate the document. -- The JSON helper family (`JSONBoolean`, `JSONNumber`, `JSONString`, `JSONNull`, - `JSONArray[T]`, `JSONObject[T]`, and `JSONValue`) now rejects `MISSING` during - runtime validation again instead of treating it as a type-compatible value. +- Operations no longer delete documents. For example, `RemoveOp` now rejects the + root pointer with `PatchConflictError`. Fundamentally, PATCH should represent + document transformation, not creation/deletion. - Tightened `JSONPointer.parse()` and `JSONSelector.parse()` type hints with overloads so omitted `type_param` defaults no longer require ignore comments and default/custom backend return types are preserved more accurately. +- `python-jsonpath[strict]` is no longer a default dependency on any Python + version. It is now installed only via the new `jsonpatchx[strict-jsonpath]` + extra. Its upstream `iregexp-check` dependency segfaults on import under + free-threaded Python, and PEP 508 dependency markers cannot select a standard + build over a free-threaded build of the same Python version, so JsonPatchX + cannot install it automatically only on safe interpreters; install + `jsonpatchx[strict-jsonpath]` yourself, and only on a standard (GIL) + interpreter, never on a free-threaded one (3.13t, 3.14t, ...). Without it, the + built-in `JSONSelector` backend falls back to Python's built-in `re` for + `match()`/`search()` and does not validate regular expression patterns against + RFC 9485 I-Regexp; see + [Targeting Documents with JSON Pointer and JSONPath](docs/user-guide/patch-targeting.md) + for the affected behavior and install command. +- Added an explicit `pydantic-core>=2.29.0` floor. This is the first + `pydantic-core` release with a free-threaded-Python wheel; it does not change + the resolved version for standard installs. ## [0.1.0] - 2026-04-24 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6d48f326 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. + +## Companion Guides + +- [AGENTS.md](AGENTS.md): house style for docs structure, Google-style + docstrings (Zensical-rendered), and `CHANGELOG.md` writing rules. Read it + before editing docs, public docstrings, or the changelog. +- [examples/AGENTS.md](examples/AGENTS.md): guidance for authoring custom + operations. The `tests/agents/` fixtures are manual prompt-only regression + checks for that file (not part of pytest). +- [CONTRIBUTING.md](CONTRIBUTING.md): full local setup; the commands below are a + quick reference. + +## Common Commands + +This project uses [uv](https://docs.astral.sh/uv/) and +[prek](https://github.com/j178/prek). + +```sh +uv sync # install all dev deps (after `git submodule update --init`) +uv run pytest -v # full test suite +uv run pytest tests/unit/standard -v # one directory +uv run pytest tests/unit/standard/test_apply.py::test_name # one test +uv run pytest -m integration # marker: integration | contract +uv run mypy . --no-incremental # strict type-check (config in pyproject.toml) +uv run ruff format # format +uv run prek run --all-files # full local hook set (lint, markdown, yaml, codespell, ...) +uv run prek run --all-files +uv run zensical serve # docs preview +uv run zensical build --clean # build docs +uv run python scripts/update_openapi_snapshots.py # regenerate OpenAPI contract snapshots +``` + +`pyproject.toml` sets `filterwarnings = ["error"]`: any unexpected warning fails +the suite. Coverage `fail_under = 90`. + +## Architecture + +JsonPatchX is a Pydantic-based JSON Patch (RFC 6902) library with typed JSON +Pointer / JSONPath targeting and FastAPI integration. The package layout maps +directly to a layered design; read modules in this order: + +1. **`types.py`**: `JSONValue` recursive alias and JSON shape predicates. +2. **`backend.py`**: `JSONPointer[T]` / `JSONSelector[T]` runtime contracts. + Wraps `python-json-pointer` and `python-jsonpath`. `T` is enforced at + _resolve_ time, not just statically; this is what makes "type-gated" + semantics possible (e.g. a `remove` may fail because the resolved value + doesn't match `T`). `DEFAULT_POINTER_CLS` / `DEFAULT_SELECTOR_CLS` are the + defaults used when models don't pick a backend explicitly. +3. **`schema.py`**: `OperationSchema`, the abstract Pydantic base for every + operation. Subclasses declare `op: Literal["..."]` plus typed pointer fields. + `_apply_ops` is the per-op driver. +4. **`builtins.py`**: the six RFC 6902 ops (`AddOp`, `RemoveOp`, `ReplaceOp`, + `MoveOp`, `CopyOp`, `TestOp`) implemented as `OperationSchema` subclasses. +5. **`registry.py`**: `StandardRegistry` + `_RegistrySpec`. Builds a + discriminated `Annotated[Union[...], Field(discriminator="op")]` keyed on + `op` from a set of `OperationSchema` subclasses, so incoming patch JSON + parses to the correct concrete op. `StandardRegistry` is the built-in + typeform. +6. **`standard.py`**: `JsonPatch` (the public sequence type) and `apply_patch`. + The "behavioral center": defines copy/mutation semantics (`inplace=False` + deep-copies; `inplace=True` is non-transactional and may leave partial + mutations on failure) and error semantics (`PatchError` subclasses propagate; + unexpected exceptions are wrapped with op index + payload). +7. **`pydantic.py`**: `JsonPatchFor[T]`: a `JsonPatch` parameterized by a target + Pydantic model so the patched document round-trips through `T`'s validation. +8. **`fastapi.py`**: request-body integration and OpenAPI schema emission for + `JsonPatchFor[T]` route params. +9. **`exceptions.py`**: public exception hierarchy rooted at `PatchError`, plus + structured `PatchFailureDetail`. + +Public surface is reexported from `jsonpatchx/__init__.py`; treat anything not +in `__all__` as private. + +### Adding or changing operations + +- New built-in: subclass `OperationSchema`, add `Literal["..."]` for `op`, + implement `apply`, then add it to `StandardRegistry` / + `_STANDARD_REGISTRY_SPEC`. +- Custom op authoring (downstream user-facing): the canonical guidance lives in + `examples/AGENTS.md`; keep that file and any changes consistent with the + actual `OperationSchema` API. +- Pointer typing is part of the contract: widen to `JSONValue` for permissive + ops; narrow `T` to gate behavior. + +### Tests + +- `tests/unit//`: mirrors the package layout. +- `tests/integration/`: `core/` cross-module flows; `fastapi/` runtime + integration (uses `httpx`, `uvicorn`). +- `tests/contract/openapi/`: OpenAPI snapshot tests; regenerate via + `scripts/update_openapi_snapshots.py`. +- `tests/compliance/rfc6901|rfc6902|rfc9535/`: external compliance suites (some + pulled via Git submodule; run `git submodule update --init` first). +- `tests/agents/`: manual prompt-only checks for `examples/AGENTS.md`; see its + `README.md`. Not run by pytest. + +## Conventions + +- Python 3.12+; `mypy` runs in `strict` mode with extra error codes + (`explicit-any`, `exhaustive-match`, `no-any-return`, ...). New code must + type-check clean; don't loosen the config to silence errors. +- Public docstrings follow the Google-style sections defined in + [AGENTS.md](AGENTS.md) (`Arguments`, `Returns`, `Raises`, `Examples`, + `Notes`). +- Docstring opening sentences use imperative mood: "Check whether", not "Checks + if". +- `Returns` lines end with "`True` if ..., `False` otherwise" (not "otherwise + `False`"). +- Em dashes are strictly forbidden in docs and docstrings. Use a colon, + semicolon, or comma instead. +- `CHANGELOG.md` is public-API focused; see [AGENTS.md](AGENTS.md). Don't log + internal refactors there. Any addition, removal, or breaking change to the + public API (`__all__`) requires a changelog entry. +- Prefer editing existing modules over adding new ones; the layering above is + intentional. +- When changing what exception a method raises, update the `Raises` section of + every caller, then every caller of those callers, and so on until the call + tree is exhausted. Use grep to find callers across the whole codebase; do not + rely on memory. Boolean helpers with no `Raises` section still need one added + if an exception now propagates through them instead of being swallowed. After + completing the update, report the full caller hierarchy back to the user so + they can verify each level was handled correctly. +- When asked a _should_/_ought_ design question (e.g. "should X inherit from + Y?", "should this be its own exception?"), answer from what the design ought + to be on its own semantic merits, not from what current code, tests, or + plumbing already depends on. "Removing this would break behavior Z" is a fact + about migration cost, not an argument about correctness, and citing it as the + reason for an answer conflates the two. If the ought-answer implies follow-up + work (e.g. a compensating mechanism to preserve some behavior), say so as a + separate, explicit consequence after giving the ought-answer, not as the + justification for it. +- Before assuming a docstring is visible on an API reference page, check that + page's `docs/api-reference/*.md` file for its `filters:` option. + Module-specific pages use `filters: ["!^_"]`, which drops every + underscore-prefixed name, including dunders (`__init_subclass__`, `__init__`, + `__str__`, etc.). The Exports page (`api-reference-public.md`) has no filter, + so documented dunders do appear there. If key information lives only in a + filtered method's docstring, it must be duplicated in the class docstring. To + re-include specific dunders while keeping the general exclusion, put `"!^_"` + first and the dunder include patterns after it โ€” mkdocstrings uses + last-match-wins semantics. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5661d7b1..080a4ea0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,24 +60,16 @@ git submodule update --init uv sync ``` -If you want the package import to resolve from your checkout instead of the -managed environment, install it in editable mode: +Install the Node-based lint tools: ```sh -pip install -e . +npm --prefix tools ci ``` -For local FastAPI extra work: +Install the Git hook for [prek](https://github.com/j178/prek): ```sh -pip install -e .[fastapi] -``` - -Install [prek](https://github.com/j178/prek): - -```sh -uv tool install prek -prek install +uv run prek install ``` > See the [Prek quick reference](#prek-quick-reference) below for common local @@ -89,19 +81,19 @@ Run the test suite with: ```sh uv run pytest -v -uv run --managed-python -p 3.12 pytest +uv run --managed-python -p 3.12 pytest # for a specific version ``` -To manually regression-test -[`examples/AGENTS.md`](/Users/angelaliss/code/Python/json-patch-x/examples/AGENTS.md) -with a coding agent, use the harness in -[`tests/agents/README.md`](/Users/angelaliss/code/Python/json-patch-x/tests/agents/README.md). -For a quick check, you can give the agent this prompt: +To manually regression-test the +[custom-operation agent guide](/docs/custom-operation-agent-guide.md) with a +coding agent, use the harness in +[`tests/agents/README.md`](/tests/agents/README.md). For a quick check, you can +give the agent this prompt: ```text Read tests/agents/README.md and run the standard prompt-only check for -tests/agents/lowercase-op.md against examples/AGENTS.md. Return the generated -Python and a short rubric-based evaluation. +tests/agents/bound-number-op.md against docs/custom-operation-agent-guide.md. +Return the generated Python and a short rubric-based evaluation. ``` Those agent-guide fixtures are manual regression checks, not part of the @@ -122,7 +114,7 @@ uv run ruff format To run the full local hook set, use: ```sh -prek run --all-files +uv run prek run --all-files ``` ## Documenting @@ -156,19 +148,19 @@ the same PR. - Run all configured hooks manually: ```sh - prek run --all-files + uv run prek run --all-files ``` - List available hook IDs: ```sh - prek list + uv run prek list ``` - Run one specific hook: ```sh - prek run checkov --all-files + uv run prek run checkov --all-files ``` - Skip one hook for one commit: diff --git a/README.md b/README.md index 42e96fd8..918fc39b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,5 @@ # JsonPatchX - - [![PyPI Version](https://img.shields.io/pypi/v/jsonpatchx?style=flat)](https://pypi.org/project/jsonpatchx/) diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 00000000..195f82b1 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,333 @@ +# Documentation Standards for `docs/` + +Apply these standards when creating or editing any Markdown page under `docs/`. +These pages are the source for the published site at +, built with Zensical +(`zensical.toml`). + +Every page must support four goals: + +- Discoverability: readers can scan a page and navigate to what they need + quickly. +- Actionability: readers can complete real tasks accurately, safely, and + confidently. +- Retrievability: search engines and AI agents can identify the most relevant + page or section for a query. +- Self-contained excerpts: any excerpt or retrieved passage remains + understandable on its own, without unstated background information. + +## Working Process + +### Before Editing + +1. Identify the reader, the task, the scope, and the success condition. +2. Find the canonical source for every fact, command, API, setting, + compatibility, version, and behavior claim: `jsonpatchx/` for code behavior, + `tests/` for verified behavior, `pyproject.toml` for dependencies and extras, + and the governing specification or upstream documentation when the claim + depends on an external standard. +3. Check whether related pages already exist. Link to the canonical page instead + of duplicating its content. +4. Ask for missing information only when it cannot be inferred safely from the + repository. + +### While Editing + +1. Put the most useful task information before background explanation. +2. For task, reference, and troubleshooting pages, begin with a concise + statement of the page's scope, intended reader when relevant, and expected + result before expanding into background or procedure. +3. Make implicit requirements, relationships, assumptions, and consequences + explicit. +4. Convert dense prose into headings, steps, bullets, tables, examples, or + callouts when structure carries meaning. +5. Keep changes scoped to the request unless broader edits are required for + correctness. + +### Before Finishing + +1. Review headings, terminology, links, examples, warnings, and Markdown + semantics. +2. Preview rendering with the docs preview workflow described in + `CONTRIBUTING.md` whenever layout, callouts, or diagrams changed. +3. Flag unverified facts, placeholders, and open questions for human review. +4. Summarize what changed, where it changed, how it was checked, and what + remains uncertain. + +## Core Principles + +- Be specific: name exact products, commands, files, classes, exceptions, + states, and outcomes. +- Be explicit: state requirements, prerequisites, relationships, constraints, + and assumptions. +- Use examples: include commands, sample input, sample output, syntax, and + realistic scenarios. +- Be consistent: use predictable structure, terminology, formatting, and link + patterns, and match the conventions of neighboring pages. +- Preserve meaning in source: use Markdown for semantic structure, not visual + appearance. + +## Functional Headings + +Write headings that work as search results, table-of-contents entries, and +retrieval anchors. + +Do: + +- Use literal, specific, accurate, concise heading text that contains the + keywords a reader would search for. +- Use one H1 per page, and use heading levels that reflect hierarchy. Do not + skip levels. +- Write at least one sentence of prose under a heading before the next heading + begins; never stack headings back to back. +- Split a large mixed-focus section into separate pages when it covers more than + one task. +- Use the words readers are likely to search for in the page title or H1, + headings, link text, and opening summary. + +Avoid: + +- Figurative headings such as `Opening the Toolbox`. +- Vague headings such as `More Details`. +- Misleading headings such as `Getting Started` on a page that only covers + installing the `fastapi` extra. +- Verbose headings, bold text posing as a heading, and headings used only for + emphasis. + +Example rewrites: + +- `Want to help push this forward?` โ†’ + `Drafting a PEP for Recursive Generic Constraints` +- `Now Is the Time to Experiment` โ†’ `Contributing New Operation Patterns` +- `More Details` โ†’ `Backend Type-Gating Failure Modes` +- `How to Register a New Custom JSON Patch Operation with a FastAPI PATCH Route` + โ†’ `Registering a Custom Operation with FastAPI` + +## Clear Meaning and Explicit Language + +Do not make readers or AI agents infer important details. + +Prefer: + +- Exact entity names over pronouns. +- Concrete terms over broad references. +- Complete cause-and-effect statements. +- Specific file types, commands, flags, outputs, error codes, and status names. +- Project-specific names alongside established terms on first use: + `JSONPointer, an RFC 6901 JSON Pointer`. +- Alternative names alongside internal terms: + `JSONBound, the TypeVar bound for JSON-shaped values`. + +Example rewrites: + +- `Raises RFC-compliant exceptions.` โ†’ `RemoveOp.apply()` raises + `PatchConflictError` if the target is missing or is the document root (because + PATCH cannot delete the document). +- `The document is copied first for safety.` โ†’ + `apply(doc, inplace=False) deep-copies doc first, so the original is never mutated.` +- `Non-literal ops will not work.` โ†’ A missing `op: Literal[...]` annotation + raises `InvalidOperationDefinition` at class-definition time. +- `Path fields also show what type they point to.` โ†’ Every `JSONPointer[T]` + field's `path` property is typed as a string, but its `x-pointer-type-schema` + shows the schema of `T`; for `JSONPointer[JSONBoolean]`, that is + `{"type": "boolean"}`. + +## Examples, Commands, and Outputs + +Use examples whenever syntax, order, results, or interpretation could be +unclear. + +For commands and code samples: + +- Put commands, code, configuration, logs, and templates in fenced code blocks + with language tags. +- Introduce each command with the scenario, directory, prerequisite, or expected + effect. +- Prefer exact commands over generic descriptions. +- Show a full, runnable example instead of a partial snippet or a "see the API + reference" pointer, and verify it against `jsonpatchx/` or `tests/` before + publishing. +- Demonstrate a format directly instead of only naming it. +- Use an example to illustrate an abstract rule; do not restate the rule in + different words. +- Explain destructive or irreversible effects before the command that causes + them. +- Use correct, idiomatic, maintainable sample code; add comments only for + non-obvious behavior. + +Example rewrites: + +- `Send a JSON Patch document.` โ†’ Show a sample `application/json-patch+json` + request body. +- `Run the tests to make sure it works.` โ†’ + `Run uv run pytest -m integration, or uv run pytest -v for everything.` +- `The fuzzer reports a failure.` โ†’ When it finds a crash, libFuzzer writes a + reproducer file like `/tmp/jsonpatchx_artifacts/crash-` that you can + replay directly: + `uv run --python 3.13 python -m fuzzers.jsonpatchx_fuzzer crash-`. + +## Semantic Markdown Rules + +Use Markdown elements for document purpose, not visual styling. + +### Headings + +- Use headings only for page section structure. +- Do not use heading levels to control font size or to emphasize warnings. + +### Emphasis + +- Use bold or italic only for a phrase or sentence that needs emphasis. + +### Blockquotes and Callouts + +- Use a blockquote, or the callout syntax already used on neighboring pages, for + important prose blocks. Confirm rendering with the docs preview before + introducing a new callout style. +- State the consequence in the callout title or first sentence. +- Do not hide critical warnings in collapsed callouts or details blocks. + +Example rewrite: + +- `## โš ๏ธ Never Delete the Root` โ†’ + `> **Warning:** RemoveOp.apply() raises PatchConflictError on the document root.` + +### Code Blocks + +- Use fenced code blocks only for code, commands, logs, templates, + configuration, or preformatted copyable text. +- Do not put prose checklists or ordinary text inside code fences for visual + boxing. + +### Lists and Tables + +- Use numbered lists for sequenced steps, ranked items, and procedures where + order matters. +- Use bullet lists for related items where order does not matter. +- Use nested lists to show hierarchy or grouping; do not flatten related + sub-items (such as an op's fields) into one list. +- Do not write steps or list items as dense prose. +- Use tables when each item has multiple comparable attributes; use bullets when + each item has one main description. + +## Accessibility and Source-Only Content + +Make essential information available in text and in source markup. + +Do: + +- Keep critical warnings, prerequisites, and constraints visible in normal page + text. +- Add descriptive alt text to every meaningful image. +- Put essential process steps, labels, and decisions in text, not only in + images. +- Prefer Mermaid or other text-based diagrams when relationships must be + searchable or maintainable. +- Use details blocks only for optional background or secondary explanation. + +Avoid: + +- Images that contain the only copy of important instructions. +- Decorative emoji in headings or in important guidance. +- Collapsed warnings whose visible text hides the consequence. +- Visual-only cues that disappear in source, search, screen readers, or AI + retrieval. + +Example: an architecture diagram with no alt text, as the only explanation of +the module layering โ†’ +`alt="types.py feeds backend.py, schema.py, builtins.py, registry.py"`, plus the +same layering restated in page text. + +## Links and Related Information + +Use links to state explicit relationships between pages. + +Do: + +- Link to the canonical page instead of duplicating its content. +- Use descriptive link text that matches the target topic or heading. +- Embed links in sentences that explain what the reader will find. +- Update or remove links when pages move, become outdated, or are superseded. +- Remember that heading text generates the URL anchor: before renaming a + heading, search `docs/` for links to the old anchor and update them. + +Avoid: + +- Link text like `click here`, `this page`, or a bare URL. +- Standalone links with no explanatory context. +- Copying volatile details from another page when a link is safer. + +Example rewrites: + +- `More on errors here.` โ†’ + `PatchConflictError's HTTP mapping is documented in Error Semantics.` +- `See the docs for how validation works.` โ†’ + `See Evolving PATCH Contracts for how field additions and removals affect validation.` + +## HTML Comments for Editors and AI Agents + +Markdown comments (``) never render on the published site but remain +available in the Markdown source for maintainers and source-aware tooling. + +Use comments only for editor-facing maintenance notes, such as pointers to a +source of truth or invariants that edits must preserve: + +```markdown + +``` + +Rules: + +- Never put reader-essential content (warnings, prerequisites, constraints, + steps) only in a comment; it is invisible on the rendered site. +- Keep each comment short, factual, and current; delete it once the note it + carries is no longer true. +- A comment must never contradict the visible page text. When they conflict, the + visible text wins, and the comment must be fixed or removed. + +## Documentation Ecosystem Hygiene + +Prevent outdated pages from misleading readers or AI agents. + +- If a page describes a removed API, delete the page or mark it clearly + historical and link to its replacement. For example, do not leave a page + describing `PatchInputError` as current after it is removed from `__all__`. +- Keep maintainer-only process notes out of user-facing guides; they belong in + `CONTRIBUTING.md`, not in `docs/user-guide/`. +- If content changes frequently, link to the source of truth instead of copying + volatile details. + +## Required Validation + +A docs change is not done until both checks below pass. + +1. Build the docs with `uv run zensical build --clean` so a broken link, bad + front matter, or malformed page is caught before merging. + `uv run zensical serve` is fine for iterative preview, but it has no + `--clean` flag, so run `build --clean` as the actual gate. +2. Lint the change: run `uv run prek run --files ` + instead of `uv run prek run --all-files`. + +## AI Editing Boundaries + +You may help with summaries, clearer headings, explicit rewrites, parallel +structure, concise edits, undefined terms, example suggestions, alt text drafts, +and review checklists. + +Do not assume: + +- What readers already know. +- What is outside the page or project scope. +- Which page is canonical when sources conflict. +- That generated Markdown is semantically correct without review. +- That examples, commands, or API behavior are correct without source + verification. + +When facts are uncertain: + +- Say what needs verification. +- Mark placeholders clearly. +- Do not invent commands, flags, filenames, configuration keys, or behavior; + check `jsonpatchx/` or `tests/` before writing a claim about behavior. +- Prefer a short question over a confident, unsupported claim. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/api-reference/api-reference-backends.md b/docs/api-reference/api-reference-backends.md index e68a2668..529e930a 100644 --- a/docs/api-reference/api-reference-backends.md +++ b/docs/api-reference/api-reference-backends.md @@ -1,11 +1,15 @@ -# Pointer Backends +# Targeting Backends -This module contains the pointer backend protocol and related backend-level -utilities used by `JSONPointer`. +This module contains the `PointerBackend` and `SelectorBackend` protocols, the +default implementations, and related backend-level utilities. ::: jsonpatchx.backend options: show_root_heading: false + show_root_toc_entry: false + group_by_category: false filters: - "!^_" + - "^__init__$" + - "^__str__$" diff --git a/docs/api-reference/api-reference-builtins.md b/docs/api-reference/api-reference-builtins.md index 7361ee39..dc18eace 100644 --- a/docs/api-reference/api-reference-builtins.md +++ b/docs/api-reference/api-reference-builtins.md @@ -1,4 +1,4 @@ -# Built-in Operations +# Operations This module contains the built-in RFC 6902 operation models: `AddOp`, `RemoveOp`, `ReplaceOp`, `MoveOp`, `CopyOp`, and `TestOp`. @@ -7,5 +7,7 @@ This module contains the built-in RFC 6902 operation models: `AddOp`, ::: jsonpatchx.builtins options: show_root_heading: false + show_root_toc_entry: false filters: - "!^_" + - "!^op$" diff --git a/docs/api-reference/api-reference-exceptions.md b/docs/api-reference/api-reference-exceptions.md index 1554f31c..1b8f6a40 100644 --- a/docs/api-reference/api-reference-exceptions.md +++ b/docs/api-reference/api-reference-exceptions.md @@ -7,5 +7,6 @@ conflicts, model validation, and FastAPI integration. ::: jsonpatchx.exceptions options: show_root_heading: false + show_root_toc_entry: false filters: - "!^_" diff --git a/docs/api-reference/api-reference-fastapi.md b/docs/api-reference/api-reference-fastapi.md index cfe1c1c3..7f401da1 100644 --- a/docs/api-reference/api-reference-fastapi.md +++ b/docs/api-reference/api-reference-fastapi.md @@ -8,5 +8,6 @@ utilities. ::: jsonpatchx.fastapi options: show_root_heading: false + show_root_toc_entry: false filters: - "!^_" diff --git a/docs/api-reference/api-reference-pointers.md b/docs/api-reference/api-reference-pointers.md index 08e6d2d9..607d648f 100644 --- a/docs/api-reference/api-reference-pointers.md +++ b/docs/api-reference/api-reference-pointers.md @@ -7,5 +7,8 @@ and custom operations. ::: jsonpatchx.pointer options: show_root_heading: false + show_root_toc_entry: false + group_by_category: false filters: - "!^_" + - "^__repr__$" diff --git a/docs/api-reference/api-reference-public.md b/docs/api-reference/api-reference-public.md index f8d58172..03a504a5 100644 --- a/docs/api-reference/api-reference-public.md +++ b/docs/api-reference/api-reference-public.md @@ -1,11 +1,10 @@ -# Public API +# Exports -This is the top-level import surface used throughout the User Guide. - -Use this page when you want the shortest path from an example import to the -actual exported symbols. +The complete `jsonpatchx` public surface. Everything listed here is importable +directly from `jsonpatchx`. ::: jsonpatchx options: show_root_heading: false + show_root_toc_entry: false diff --git a/docs/api-reference/api-reference-pydantic.md b/docs/api-reference/api-reference-pydantic.md index 43fdcb23..7fad0111 100644 --- a/docs/api-reference/api-reference-pydantic.md +++ b/docs/api-reference/api-reference-pydantic.md @@ -1,4 +1,4 @@ -# Pydantic Contracts +# Pydantic Integration This module contains `JsonPatchFor`, the factory used to bind patch documents to a target schema and an operation registry. @@ -7,5 +7,7 @@ a target schema and an operation registry. ::: jsonpatchx.pydantic options: show_root_heading: false + show_root_toc_entry: false + group_by_category: false filters: - "!^_" diff --git a/docs/api-reference/api-reference-registries.md b/docs/api-reference/api-reference-registries.md index 5c6a0ed8..e63253c8 100644 --- a/docs/api-reference/api-reference-registries.md +++ b/docs/api-reference/api-reference-registries.md @@ -7,5 +7,6 @@ which operations a patch model will accept. ::: jsonpatchx.registry options: show_root_heading: false + show_root_toc_entry: false filters: - "!^_" diff --git a/docs/api-reference/api-reference-schema.md b/docs/api-reference/api-reference-schema.md index 38412542..9b9795a7 100644 --- a/docs/api-reference/api-reference-schema.md +++ b/docs/api-reference/api-reference-schema.md @@ -7,5 +7,7 @@ patch operations. ::: jsonpatchx.schema options: show_root_heading: false + show_root_toc_entry: false filters: - "!^_" + - "^__init_subclass__$" diff --git a/docs/api-reference/api-reference-selectors.md b/docs/api-reference/api-reference-selectors.md new file mode 100644 index 00000000..da563f09 --- /dev/null +++ b/docs/api-reference/api-reference-selectors.md @@ -0,0 +1,14 @@ +# Selectors + +This module contains `JSONSelector`, the typed query selector used to target +multiple locations in a document. + + +::: jsonpatchx.selector + options: + show_root_heading: false + show_root_toc_entry: false + group_by_category: false + filters: + - "!^_" + - "^__repr__$" diff --git a/docs/api-reference/api-reference-standard.md b/docs/api-reference/api-reference-standard.md index 7188260c..f6eae472 100644 --- a/docs/api-reference/api-reference-standard.md +++ b/docs/api-reference/api-reference-standard.md @@ -1,11 +1,19 @@ -# Standard RFC 6902 +# JSON Patch -This module is the lowest-friction entry point when you want plain JSON Patch -behavior. It contains `JsonPatch` and the `apply_patch` convenience function. +This module contains `JsonPatch` and the `apply_patch` convenience function, the +primary entry point for applying patches to documents. ::: jsonpatchx.standard options: show_root_heading: false + show_root_toc_entry: false + group_by_category: false filters: - "!^_" + - "^__init__$" + - "^__str__$" + - "^__len__$" + - "^__getitem__$" + - "^__add__$" + - "^__eq__$" diff --git a/docs/api-reference/api-reference-types.md b/docs/api-reference/api-reference-types.md index 79a98396..a1d23a09 100644 --- a/docs/api-reference/api-reference-types.md +++ b/docs/api-reference/api-reference-types.md @@ -7,6 +7,8 @@ contracts and operation models. ::: jsonpatchx.types options: show_root_heading: false + show_root_toc_entry: false + group_by_category: false members: - JSONBoolean - JSONNumber diff --git a/docs/custom-operation-agent-guide.md b/docs/custom-operation-agent-guide.md new file mode 100644 index 00000000..94a0de5e --- /dev/null +++ b/docs/custom-operation-agent-guide.md @@ -0,0 +1,242 @@ +# Custom-Operation Agent Guide + +This page is the instruction set for a coding agent writing a custom JsonPatchX +operation: an `OperationSchema` subclass. It applies whether the agent is +working inside this repository or in a downstream project that depends on +`jsonpatchx`. + +## Choose a Default File + +When a prompt asks for a custom operation and does not name a more specific +file, default to a style reference: + +- Use a `recipes.py`-style file for generally useful JSON mutation operations + with a short, direct style. In this repository, that is + [`examples/recipes.py`](https://github.com/angela-tarantula/jsonpatchx/blob/main/examples/recipes.py). +- Use a `recipes2.py`-style file for issue-driven or contract-heavy operations + where aliases, preconditions, richer conflict handling, or multi-field + validation are the point of the example. In this repository, that is + [`examples/recipes2.py`](https://github.com/angela-tarantula/jsonpatchx/blob/main/examples/recipes2.py), + which also holds the main state-aware object-key pattern for operations that + need `classify_state()` and detailed `TargetState` branching. +- Files under a `fastapi/` demo directory show how operations are assembled into + registries and exposed through FastAPI routes. Do not put reusable operation + catalogs in demo files; keep those in the recipe files. + +## Choose the Right Pattern + +- Use an intent-based operation when callers keep expressing the same + higher-level mutation awkwardly through lower-level steps. Examples: + increment, clamp, lowercase, replace substring. +- Use a contract-narrowing operation when the standard mutation is still right + but the type or allowed behavior should be narrower. Examples: replace only a + number, add only a missing key. +- Use a schema-rich operation when the request contract itself carries important + structure, such as cross-field invariants, aliases, or richer OpenAPI. +- If a prompt explicitly asks for a schema-rich operation, reflect that in the + generated schema, not only in runtime validation. +- Start with the simplest pattern that honestly captures the caller's intent. + +## Write the Operation Class + +- Subclass `OperationSchema`. +- Give the class a short docstring that states the mutation plainly. +- Set `model_config = ConfigDict(title="...")`. +- Define `op` as a snake_case `Literal[...]` with the same default value. +- Type `path` with the narrowest honest `JSONPointer[...]`. +- Use `JSONValue` or narrower JSON helper types from `jsonpatchx.types` for the + remaining fields. +- Import `JSONPointer`, `JSONValue`, `OperationSchema`, and built-in operations + from `jsonpatchx`, not from internal modules. +- Import narrower helper types such as `JSONString`, `JSONNumber`, + `JSONArray[JSONValue]`, and `JSONObject[JSONValue]` from `jsonpatchx.types`. +- Prefer composing built-ins such as `ReplaceOp`, `AddOp`, `RemoveOp`, and + `TestOp` instead of reimplementing low-level pointer mutation. +- Preserve the typed pointer through composition so the same runtime guarantees + keep applying. +- Do not make the operation class generic unless the prompt is specifically + about generic pointer backends or typing behavior. +- Implement the operation as `def apply(self, doc: JSONValue) -> JSONValue:`. +- Prefer a direct `apply()` body over extra helper methods when the example only + needs one step of logic. +- Use `@override` on `apply()`. +- Favor functional composition in `apply()`: compute the next document value, + then return it. For low-level container edits, mutate the resolved container + and return `doc`. + +Default shape for an ordinary operation: + +```python +from typing import Literal, override + +from pydantic import ConfigDict + +from jsonpatchx import JSONPointer, JSONValue, OperationSchema, ReplaceOp +from jsonpatchx.types import JSONString + + +class LowercaseOp(OperationSchema): + """Lowercase a string value.""" + + model_config = ConfigDict(title="Lowercase operation") + op: Literal["lowercase"] = "lowercase" + path: JSONPointer[JSONString] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=current.lower()).apply(doc) +``` + +## Model the Schema + +- Add `json_schema_extra` only when it expresses a real contract that field + types alone do not say. +- Use `Field(...)` metadata when it clarifies wire semantics, not just to repeat + the obvious. +- When a prompt explicitly asks for schema richness, make that visible in the + generated schema with titles, descriptions, `Field(...)` metadata, aliases, or + `json_schema_extra`, whichever honestly carries the contract. +- Use `model_validator` when the invariant involves multiple fields. +- When fields are individually valid but invalid together, decide whether the + operation benefits from a structured validation error or whether a plain + `ValueError` is enough. Prefer `PydanticCustomError` from `pydantic_core` when + stable error codes, message templates, or named context values materially + improve the contract; a plain `ValueError` is acceptable for simple one-off + invariants. +- Decide deliberately between omission and nullability. Use `MISSING` from + `pydantic_core` plus `model_fields_set` when "not provided" and "provided as + null" mean different things. Use a nullable field when `null` is itself part + of the wire contract. For bounds, limits, indices, regexes, and similar + constraint fields, prefer omission semantics when an inactive constraint has + no meaningful JSON `null` value; a very short comment may explain that `null` + is not meaningful input for that field. A narrow typed field commonly reads + `Field(default=cast(TheType, MISSING))`. +- Prefer `MISSING` over a concrete default when a field being omitted truly has + no influence on behavior, such as a field mid-deprecation in favor of a + mutually exclusive replacement. A schema that shows `default: false` on a + field also marked `deprecated: true` invites the question of what that default + value is even for. Unless of course the deprecated field used to have a + default value already, don't give it a default on deprecation. +- When alternate wire names matter, use `validation_alias` and + `serialization_alias` explicitly. + +## Keep Examples Useful + +- Prefer reusable JSON operations over business-domain actions unless the prompt + explicitly asks for domain behavior. +- Keep each operation narrow and composable. +- Use names that describe mutation intent, not implementation detail. +- Avoid helper functions unless they are reused or make the example materially + clearer. +- Prefer code that teaches one idea cleanly over code that tries to show every + extension point at once. +- For array and object examples, mutating the resolved container in place and + returning `doc` is often clearer than inventing new pointer-composition + helpers. +- Do not write pseudo-framework methods that JsonPatchX does not use. + +## Handle Failures Deliberately + +- Runtime document-state conflicts such as missing anchors, duplicate keys, or + impossible array positions usually map to `PatchConflictError`. +- Use `TestOpFailed` for failed preconditions and test-like assertions. +- Let pointer/path/type failures surface naturally; do not wrap broad + `Exception` just to rewrite the message. +- Use `TargetState` or other backend-state machinery only when state + classification is itself part of the example. If behavior depends on why a + path is invalid, prefer `classify_state(self.path.ptr, doc)` plus + `TargetState` over a generic try/except flow. +- When using `classify_state()`, branch on the real enum members: `ROOT`, + `PARENT_NOT_FOUND`, `PARENT_NOT_CONTAINER`, `OBJECT_KEY_MISSING`, + `ARRAY_KEY_INVALID`, `ARRAY_INDEX_OUT_OF_RANGE`, `ARRAY_INDEX_AT_END`, + `ARRAY_INDEX_APPEND`, `VALUE_PRESENT`, and + `VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX`. Do not invent replacement enum names + such as `PRESENT` or `EXISTS`. +- If a prompt asks for path relationships such as ancestor/descendant + constraints, use pointer methods such as `is_parent_of()` and `is_child_of()` + in a `model_validator`. +- Do not invent alternative operation hooks such as `patch()`, `expand()`, or + `to_builtin_ops()` for ordinary examples. The contract is the `apply()` + method. + +## Evolving an Operation's Contract + +An operation's fields are its wire contract. When a prompt asks you to change an +existing operation rather than write a new one, changing that contract can break +callers who already send patches against the old shape. Diagnose which kind of +change you are making before writing code: + +- **Additive change**: add a new field with a concrete default that preserves + the old behavior when the field is omitted. Keep the same `op`. +- **Field deprecation**: before removing a field, mark it `deprecated=True` and + keep honoring it exactly as before. Use `model_fields_set` to tell "the caller + never mentioned this field" apart from "the caller explicitly sent it," so + behavior for a caller who has never heard of the field cannot regress even as + the field's own default value stops mattering. On rare occasions a prompt may + ask you to change the field's default as part of deprecating it, for example + fixing a latent correctness gap in the old default; treat that as a + deliberate, called-out breaking change bundled with the deprecation, not a + routine one, and say so plainly rather than presenting it as compatible. +- **Contract tightening**: narrowing what a field accepts, such as a stricter + pattern, a new `min_length`, or dropping a previously accepted shape, is a + breaking change even though the field's name and type look unchanged. +- **Breaking semantic change**: if the operation's meaning would change, not + just its inputs, do not mutate the existing `op` in place. Introduce a new + `op`, and mark the old one deprecated with + `model_config = ConfigDict(json_schema_extra={"deprecated": True})`, so a + registry can carry both during the transition. + +For example, evolving a `replace_substring` operation that always errors when +the target substring is missing: + +```python +# Additive: strict defaults to the old (always-raise) behavior, so a caller +# that never sends it is unaffected. +strict: JSONBoolean = Field(default=True) + +def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.strict and self.old not in current: + raise PatchConflictError(f"{self.old!r} is not in {current!r}") + ... + +# Field deprecation: still honored, but model_fields_set distinguishes an +# omitted field ("strict" not in self.model_fields_set) from an explicit +# True, so a caller who never sent it keeps the exact old behavior. +strict: JSONBoolean = Field(default=True, deprecated=True) + +def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.old not in current: + if "strict" not in self.model_fields_set or self.strict: + raise PatchConflictError(f"{self.old!r} is not in {current!r}") + ... +``` + +If a later request turns out to change what the operation means rather than +merely how it is configured, for example turning "increase by 5" into "multiply +by 5%", that is a new operation, not a new interpretation of an existing field. + +## Validate the Result + +- New example operations should be complete Python code, not pseudocode. +- If you add or change runnable example behavior, prefer targeted checks over + broad repo-wide validation. +- If the prompt is only to generate an operation example, optimize for correct, + importable, readable example code first. + +## Adopt This Guide Downstream + +To use this guide in your own repository, pin it at the `jsonpatchx` version you +have installed rather than tracking `main`, since the guide can change between +releases: + +1. Find your installed version, for example with `uv pip show jsonpatchx` or + `python -m pip show jsonpatchx`. +2. From a nested `AGENTS.md` or `CLAUDE.md` in your operations directory, link + the raw file at the matching tag: + `https://raw.githubusercontent.com/angela-tarantula/jsonpatchx/v/docs/custom-operation-agent-guide.md`, + or copy its contents there and note which version it snapshots. +3. Add rules local to your directory alongside it, such as which file is the + default style reference in your codebase. diff --git a/docs/developer-reference/custom-pointers-and-selectors.md b/docs/developer-reference/custom-pointers-and-selectors.md index be236dc9..7cdd6528 100644 --- a/docs/developer-reference/custom-pointers-and-selectors.md +++ b/docs/developer-reference/custom-pointers-and-selectors.md @@ -57,6 +57,12 @@ If your backend can satisfy that contract, JsonPatchX can use it. A good pointer backend should satisfy a few operational rules. +**Do not raise `TypeError` for invalid pointer strings.** `TypeError` from a +constructor is treated as a backend misconfiguration error, not as an invalid +pointer string, so it propagates raw rather than being wrapped in +`InvalidJSONPointer`. Any other exception is wrapped. In a FastAPI route, an +unhandled `TypeError` maps to a 500 rather than a 422. + Its string form should round-trip cleanly. Constructing a backend from a string, converting it back to a string, and constructing it again should produce an equivalent pointer. @@ -68,7 +74,10 @@ The backend should be immutable or safe to reuse. JsonPatchX may cache backend instances. The backend defines its own syntax. There is no universal root string across -every possible backend. +every possible backend. This is why `JSONPointer.is_root(doc)` takes a document +argument rather than simply checking for `""`: the backend itself determines +what counts as root given the document, so the check must go through the +backend. If you want to study an intentionally extended pointer implementation, see [`python-jsonpath`'s `JSONPointer`](https://jg-rp.github.io/python-jsonpath/pointers/), @@ -106,6 +115,12 @@ class MySelectorBackend: ## Selector Rules That Matter In Practice +**Do not raise `TypeError` for invalid selector strings.** The same rule applies +as for pointer backends: `TypeError` from a constructor is treated as a backend +misconfiguration error and propagates raw, while any other exception is wrapped +in `InvalidJSONSelector` and surfaces as a Pydantic `ValidationError`. In a +FastAPI route, an unhandled `TypeError` maps to a 500 rather than a 422. + `pointers(doc)` should yield zero or more concrete pointer objects, not abstract query nodes or backend-specific match wrappers. @@ -129,14 +144,26 @@ instances. Matched values are still revalidated through `JSONSelector[T]` and The more important limitation is standards compliance, not upstream's `object` annotation. Out of the box, JsonPatchX's built-in JSONPath backend follows the -RFC 9535 path. The exception is Python 3.14 and later, where the upstream -[`iregexp-check`](https://github.com/jg-rp/rust-iregexp) dependency behind -`python-jsonpath[strict]` is not yet compatible with free-threaded Python. - -JsonPatchX still uses `JSONPathEnvironment(strict=True)` there, so this only -affects regular expression compliance: `match()` and `search()` fall back to -Python's built-in `re`, and regular expression patterns are not validated -against RFC 9485 I-Regexp. +RFC 9535 path only if the optional `jsonpatchx[strict-jsonpath]` extra is +installed: + +```sh +pip install jsonpatchx[strict-jsonpath] +``` + +> **Warning:** Do not install `jsonpatchx[strict-jsonpath]` on a free-threaded +> Python build (3.13t, 3.14t, and later free-threaded variants). Its upstream +> [`iregexp-check`](https://github.com/jg-rp/rust-iregexp) dependency segfaults +> on import there. There is no PEP 508 dependency marker that can select a +> standard build over a free-threaded build of the same Python version, so +> JsonPatchX cannot block this installation combination for you; only install +> `strict-jsonpath` on a standard (GIL) interpreter. + +JsonPatchX still uses `JSONPathEnvironment(strict=True)` regardless of whether +`strict-jsonpath` is installed, so the effect of omitting it is limited to +regular expression compliance: `match()` and `search()` fall back to Python's +built-in `re`, and regular expression patterns are not validated against RFC +9485 I-Regexp. Like pointer backends, selector backends should be immutable or otherwise safe to reuse. diff --git a/docs/developer-reference/limitations-in-python-type-system.md b/docs/developer-reference/limitations-in-python-type-system.md index 00119e31..43bb0d1e 100644 --- a/docs/developer-reference/limitations-in-python-type-system.md +++ b/docs/developer-reference/limitations-in-python-type-system.md @@ -1,158 +1,198 @@ -# Limitations in Python's Type System +# Type System Limitations -This page explains one specific typing problem in JsonPatchX from first -principles. +Two related limitations show up when working with JsonPatchX's JSON helper +types. Both come from the same root cause: Python's type system cannot express +recursive mutable container constraints the way we'd want. -The problem is: **how to choose a bound for `T` in `JSONPointer[T]`**. +This page explains both from first principles, with actionable guidance for +each. -## Why this exists at all +--- -`JSONPointer[T]` is a typed pointer. The type parameter `T` describes what shape -of JSON value the pointer is expected to resolve to. +## Limitation 1: Narrowly-typed JSON containers are not assignable to `JSONValue` -So we need a bound for `T` that accepts recursively JSON-shaped helper types. +The most common practical surprise: you narrow a pointer's type parameter to get +a typed resolved value, build a result from it, and the type checker flags the +return. -For example, we want this to be valid: +The limitation surfaces whenever the pointer's type parameter is a narrower +container, for example `JSONPointer[JSONArray[JSONNumber]]`, and the result is +returned directly from `apply`. ```python -JSONPointer[JSONObject[JSONArray[JSONBoolean]]] +from typing import Literal +from jsonpatchx import JSONPointer, JSONArray, JSONNumber, JSONValue +from jsonpatchx.schema import OperationSchema + +class DoubleAllOp(OperationSchema): + op: Literal["double_all"] + path: JSONPointer[JSONArray[JSONNumber]] + + def apply(self, doc: JSONValue) -> JSONValue: + numbers = self.path.get(doc) # type: JSONArray[JSONNumber] = list[int | float] + return [n * 2 for n in numbers] # type error: list[int | float] not assignable to JSONValue + # JSONValue requires list[JSONValue] and list is invariant ``` -## The intuitive recursive bound +The same issue applies to any narrowly-typed container: +`JSONObject[JSONString]`, `JSONArray[JSONBoolean]`, and so on; anything where +the element type is not exactly `JSONValue`. -The intuitive recursive idea is: +To understand why, you need to understand invariance. + +### Why `list` is invariant + +Imagine Python treated `list` as covariant, so `list[int]` would be a subtype of +`list[object]`. Then this code would typecheck: ```python -type JSONBound = JSONScalar | JSONContainer[JSONBound] +ints: list[int] = [1, 2] +things: list[object] = ints # pretend covariance +things.append("oops") # now ints is [1, 2, "oops"] # broken ``` -This expresses the recursive intent clearly. +This would allow inserting wrong-typed values through an alias. So Python +correctly makes `list` (and `dict`) **invariant**: `list[int]` is only a subtype +of `list[int]`, not `list[object]` or anything else. -## Why this is hard with mutable invariant containers +### Why this blocks narrowly-typed containers -In JsonPatchX, `JSONContainer[T]` is built from mutable containers (`list` and -`dict`), and those are invariant. +`JSONValue` is defined, for type checkers, as: + +```python +type JSONValue = JSONScalar | JSONContainer[JSONValue] +# where JSONContainer[T] = JSONArray[T] | JSONObject[T] +``` -Invariance means: +So `JSONValue` includes `JSONArray[JSONValue]`, a list whose items are +themselves `JSONValue`. But `JSONArray[JSONNumber]` is a list whose items are +`JSONNumber`, which is a _narrower_ type than `JSONValue`. -- `JSONContainer[X]` is not a subtype of `JSONContainer[Y]` unless `X` and `Y` - are exactly the same type. +Because `list` is invariant, `list[JSONNumber]` is **not** a subtype of +`list[JSONValue]`, even though `JSONNumber` is a subtype of `JSONValue`. The +same holds for any other narrowing: `list[JSONString]`, +`dict[str, JSONBoolean]`, and so on. The type checker rejects them all. -That is usually correct for type safety. +This is not a bug in JsonPatchX; it is a correct consequence of invariance. -### Concrete safety example +### What to do: cast at the return site -If mutable containers were treated as covariant, this would be unsafe: +Cast the return value to `JSONValue`. The limitation is in Python's type system, +not in the code: you know the value is `JSONNumber`, you are treating it as +`JSONValue`, and that is correct. `cast` is honest about that reasoning in a way +that `# type: ignore` is not. ```python -ints: list[int] = [1, 2] -values: list[object] = ints # pretend covariance for mutable list -values.append("oops") -# ints is now [1, 2, "oops"] -> violates list[int] +from typing import cast +from jsonpatchx import JSONValue + +def apply(self, doc: JSONValue) -> JSONValue: + numbers = self.path.get(doc) # JSONArray[JSONNumber] + result: list[int | float] = [n * 2 for n in numbers] + return cast(JSONValue, result) ``` -So invariance for mutable containers is good and normal. +The `cast` is safe: it tells the type checker the value is `JSONValue` without +changing the runtime object. The patch engine validates the result of each +`apply()` call against `JSONValue` automatically. If your op returns something +that is not valid JSON (for example, a `datetime` or a plain Python object), a +`PatchInternalError` is raised with the op index and payload as context, and the +original `ValidationError` as `__cause__`. For `JsonPatchFor[Model]`, the +patched document is additionally validated against the target model schema after +all ops have run. -### Why it hurts this bound +--- -For a nested type like: +## Limitation 2: Why `JSONBound` exists: the TypeVar bound problem -```python -JSONObject[JSONArray[JSONBoolean]] -``` +`JSONPointer[T]` is a typed pointer parameterized by the type of value it +resolves to. To constrain `T` to JSON-shaped types, we need a TypeVar bound. -matching `JSONContainer[JSONBound]` would require inner exact matches under -invariance, which is stricter than "recursively JSON-shaped". +The intuitive recursive bound would be: -Another way to say it: +```python +type JSONBound = JSONScalar | JSONContainer[JSONBound] +``` -- `JSONContainer[JSONBound]` behaves like "container of exactly `JSONBound`" -- what we need is "container of some subtype of the recursive JSON domain" +But this runs into the same invariance problem. For a nested type like +`JSONObject[JSONArray[JSONBoolean]]`: -## What we actually want to express +- matching against `JSONContainer[JSONBound]` under invariance requires inner + types to be _exactly_ `JSONBound` +- but the inner types are `JSONArray[JSONBoolean]`, which is a concrete + narrowing, not `JSONBound` itself -We want an existential-style constraint: +What we actually want to express is an existential constraint: ```text -JSONContainer[T] where T <: JSONValue +JSONContainer[T] where T <: JSONBound (recursively) ``` -Equivalent ideal recursive form: - -```python -type JSONBound = JSONScalar | JSONContainer[T: JSONBound] -``` +Python's type system cannot express this today. -That is the intended shape. +### The workaround -Python typing cannot currently express this existential recursive constraint in -the alias/annotation shape we need for `JSONPointer[T]`. - -## Workaround used in JsonPatchX - -The workaround is to use covariant immutable interfaces in the bound: +Instead of mutable container types (invariant), the bound uses read-only +interface types (covariant): ```python type JSONBound = JSONScalar | Sequence[JSONBound] | Mapping[str, JSONBound] -# Use it like: T = TypeVar("T", default=JSONValue, bound=JSONBound) ``` -Why this works better: - -- `Sequence` and `Mapping` are read-only interface types and are covariant in - their value parameters -- this allows recursive JSON-shaped nested types to pass the `JSONPointer[T]` - bound more naturally than mutable invariant containers +`Sequence` and `Mapping` are covariant in their value parameters, so +`Sequence[JSONNumber]` _is_ a subtype of `Sequence[JSONBound]`. This lets nested +types like `JSONObject[JSONArray[JSONBoolean]]` satisfy the bound. -This is a practical bound that works well today, but it's not the ideal form: +The trade-off: `Sequence` and `Mapping` are broader than the JSON container +semantics; they accept `tuple` and custom mappings, which are not valid JSON +containers. The static type system is permissive here; runtime validation +enforces the actual rules (`list` for arrays, `dict[str, ...]` for objects). -- `Sequence`/`Mapping` bounds are broader than JsonPatchX runtime JSON container - semantics. -- So static typing may accept container shapes that are not concrete - `list`/`dict` JSON containers. -- Runtime validation still enforces actual JsonPatchX JSON rules (`list` for - arrays, `dict[str, ...]` for objects), so those broader static cases are - rejected at runtime. +### `JSONValue` vs `JSONBound` -## Why not model `JSONArray` / `JSONObject` as immutable? +| | `JSONValue` | `JSONBound` | +| ---------- | ------------------------------------ | ----------------------------------- | +| Purpose | Data model for patch contracts | TypeVar bound for generics | +| Runtime | Full Pydantic validation, strict | No runtime behavior | +| Containers | `list` and `dict[str, ...]` only | Any `Sequence` or `Mapping` | +| Use when | Annotating fields, method signatures | `T = TypeVar("T", bound=JSONBound)` | -Another workaround would be to redesign JsonPatchX container modeling around -immutable structures. +--- -That was not chosen because JsonPatchX patch semantics intentionally operate -with standard Python JSON-like data (`list`/`dict`) and support in-place -mutation modes. Switching to immutable container primitives would add -substantial complexity and friction across runtime behavior, interoperability, -and user expectations. +## Why not use `Sequence`/`Mapping` in `JSONValue` and `apply` signatures? -So the project keeps runtime container semantics practical, and applies the -typing workaround at the generic-bound layer. +`JSONBound` uses `Sequence` and `Mapping` because they are covariant, which +dissolves the invariance problem. A natural follow-up question is: why not use +the same covariant types in `JSONValue` and in `apply` return signatures? -## Why both `JSONValue` and `JSONBound` exist +The reason is precision. `Sequence` accepts `tuple`, `str`, and any custom +sequence; `Mapping` accepts any mapping. Neither constrains you to actual JSON +containers. `JSONValue` uses `list` and `dict[str, ...]` precisely because those +are the only valid JSON containers, and that precision is enforced by Pydantic +at validation time. -`JSONValue` is the type that's actually defined like this: +Patch operations also mutate documents in place. `Sequence` and `Mapping` are +read-only interfaces; expressing mutation through them would require casting +back to concrete types at every operation site. -```python -type JSONValue = JSONScalar | JSONContainer[JSONValue] -``` - -Each type serves a different purpose: +`JSONBound` exists only as a TypeVar bound for static generics, where the +permissiveness is acceptable because runtime validation still enforces the real +rules. It is not the right type for method signatures or field annotations, +which is exactly what `JSONValue` is for. -- `JSONValue` is the semantic recursive JSON model used in runtime validation - and API contracts. -- `JSONBound` is a typing-bound utility for generics like `JSONPointer[T]`. - -So `JSONValue` is the data model; `JSONBound` is the bound used to make typed -pointer generics workable under current Python typing limits. +--- ## Want to help push this forward? -Yes, seriously: if you want to help draft a PEP (or related typing proposal) for -recursive existential constraints like this, please reach out in JsonPatchX -discussions. I would love collaborators on it. - -Impact beyond JSON: this kind of typing support would help represent any -mutable, recursively nested generic data model. - -Most immutable/read-only models can sidestep this by using covariant interface -types; the gap is mainly for mutable recursive models. +If you want to help draft a PEP (or related typing proposal) for recursive +existential constraints like this, reach out in JsonPatchX discussions. This +kind of typing support would help any mutable, recursively nested generic data +model, not just JSON. + + diff --git a/docs/developer-reference/type-system-notes.md b/docs/developer-reference/type-system-notes.md index e6621b47..159cbdbe 100644 --- a/docs/developer-reference/type-system-notes.md +++ b/docs/developer-reference/type-system-notes.md @@ -144,51 +144,27 @@ For example, `JSONPointer[tuple[int]]` is "type-safe." For more about why it has to be this way, see [Limitations in Python's Type System](limitations-in-python-type-system.md). -## Missing Document Sentinel +## Root-Level Mutation -`MISSING` is the runtime sentinel for โ€œthe document no longer exists.โ€ +JsonPatchX allows whole-document reads and whole-document replacement, but it +does not model โ€œno current document existsโ€ as a separate runtime state. -### Operation Results - -A custom operation that intends to delete the whole document should return -`MISSING`, not `None`. `None` is JSON `null`. `MISSING` means document deletion. - -This sentinel exists at the operation-result layer so composed operations can -pass along โ€œthe document was deletedโ€ without pretending that state is a JSON -value. `ReplaceOp`, for example, literally implements root replacement as -`RemoveOp` followed by `AddOp`: root removal returns `MISSING`, and root add -recreates the document from that state. - -### Validation Boundaries - -`MISSING` is not itself treated as a JSON value. The JSON helper family -(`JSONBoolean`, `JSONNumber`, `JSONString`, `JSONNull`, `JSONArray[T]`, -`JSONObject[T]`, and `JSONValue`) rejects it during normal Pydantic validation. - -So โ€œdocument missingโ€ is a runtime state that pointer- and selector-based -operations handle before type validation, not a value that happens to satisfy a -JSON helper type. +A `MISSING_DOCUMENT` sentinel was considered in `v0.1.0`, but ultimately, it was +decided that PATCH should be for document _updates_, not creation / deletion. +The same is true for the patch operations themselves. ### JSONPointer Defaults -Root-targetable pointer operations handle document absence like this: +At the root pointer (`""` in RFC 6901), the defaults are: -- root `get` fails because there is no document to read -- root `remove` fails when the document is already missing -- root `add` recreates the document -- root `remove` on an existing document returns `MISSING` +- `get(doc)` returns `doc` +- `add(doc, value)` replaces the whole document with `value` +- `remove(doc)` raises `PatchConflictError` ### JSONSelector Defaults -At the root selector `$`, the default selector behavior mirrors the root -pointer: - -- root `getall` fails because there is no document to read -- root `removeall` fails when the document is already missing -- root `addall` recreates the document +At the root selector (`$` in RFC 9535), the defaults mirror the root pointer: -An individual operation can therefore still delete or recreate the whole -document without implying that `MISSING` is intrinsically compatible with types -such as `JSONBoolean` or `JSONValue`. Higher-level boundaries such as model -revalidation or HTTP response serialization can still reject a final missing -document if that contract requires a real JSON value. +- `getall(doc)` returns `[doc]` +- `addall(doc, value)` replaces the whole document with `value` +- `removeall(doc)` raises `PatchConflictError` diff --git a/docs/user-guide/custom-operations.md b/docs/user-guide/custom-operations.md index cfa2cef7..327f2ad6 100644 --- a/docs/user-guide/custom-operations.md +++ b/docs/user-guide/custom-operations.md @@ -380,8 +380,8 @@ documentation. The operation is not just something your server can run. It is also something your API can describe clearly. > I must admit, I didn't write that operation myself. I used JsonPatchX's -> [`examples/AGENTS.md`](https://github.com/angela-tarantula/jsonpatchx/blob/main/examples/AGENTS.md) -> context to give my coding agent everything it needed to produce it. +> [custom-operation agent guide](/custom-operation-agent-guide.md) to give my +> coding agent everything it needed to produce it. ## Use Operation Instances Directly diff --git a/docs/user-guide/error-semantics.md b/docs/user-guide/error-semantics.md index 94e1db6c..d01c99cf 100644 --- a/docs/user-guide/error-semantics.md +++ b/docs/user-guide/error-semantics.md @@ -26,14 +26,15 @@ The goal is to keep three failure modes separate: If you use the optional FastAPI helpers, the default mapping is: -| Status | Default meaning | -| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `415` | `JsonPatchRoute` rejects the wrong media type when the route requires JSON Patch content. | -| `422` | FastAPI request validation and `PatchInputError` cover invalid patch documents, invalid pointers, and patched results that fail target-model validation. | -| `409` | `PatchConflictError` means the patch is valid but cannot be applied to the current resource state. | -| `500` | `PatchInternalError` is the fallback for unexpected patch execution failures or route misconfiguration. | +| Status | Default meaning | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `415` | `JsonPatchRoute` rejects the wrong media type when the route requires JSON Patch content, and includes an `Accept-Patch` header naming the expected media type. | +| `422` | FastAPI request validation and `InvalidPatchResult` cover invalid patch documents, invalid pointer/selector syntax, and patched results that fail the target model schema. | +| `409` | `PatchConflictError` means the patch cannot be applied to the current resource state. | +| `500` | `PatchInternalError` is the fallback for unexpected execution failures. `InvalidPatchTarget` signals an invalid patch target or document (not valid JSON, or, for model-aware patching, the wrong model instance or a model that produces non-JSON data). | If you do not use the helper layer, choose an equivalent mapping and keep it stable. + diff --git a/docs/user-guide/patch-targeting.md b/docs/user-guide/patch-targeting.md index db5dfca1..4bfb19cf 100644 --- a/docs/user-guide/patch-targeting.md +++ b/docs/user-guide/patch-targeting.md @@ -28,10 +28,10 @@ you exercise it. - As a subtype of `str`, it behaves like a pointer string when you want string-compatible comparison, hashing, and logging -Root-level deletion and recreation use a separate missing-document state rather -than treating โ€œno documentโ€ as a JSON value. See -[Type System Notes](../developer-reference/type-system-notes.md#missing-document-sentinel) -for the precise `MISSING` semantics. +> When targeting the document root, `add` replaces the whole document and +> `remove` is rejected. See +> [Type System Notes](../developer-reference/type-system-notes.md#root-level-mutation) +> for the precise root-level defaults. ## The `JSONSelector` Surface @@ -44,14 +44,28 @@ Path syntax standardized in ``` > Note: The -> [built-in JSONPath backend](https://github.com/jg-rp/python-jsonpath) is -> RFC-compliant out of the box, except on Python 3.14 and later, where -> [python-jsonpath](https://github.com/jg-rp/python-jsonpath)'s -> [`iregexp-check`](https://github.com/jg-rp/rust-iregexp) dependency is not yet -> compatible with free-threaded Python. On Python 3.14+, this only affects regex -> compliance: `match()` and `search()` fall back to Python's built-in `re`, and -> regex patterns are not validated against -> [RFC 9485](https://datatracker.ietf.org/doc/html/rfc9485) I-Regexp. +> [built-in JSONPath backend](https://github.com/jg-rp/python-jsonpath) follows +> RFC 9535 path syntax and semantics by default. Full regex compliance, however, +> requires the optional `strict-jsonpath` extra: +> +> ```sh +> pip install jsonpatchx[strict-jsonpath] +> ``` +> +> Without it, `match()` and `search()` fall back to Python's built-in `re` +> instead of the third-party `regex` engine, and regex patterns are not +> validated against [RFC 9485](https://datatracker.ietf.org/doc/html/rfc9485) +> I-Regexp. + + + +> **Warning:** Do not install `jsonpatchx[strict-jsonpath]` on a free-threaded +> Python build (3.13t, 3.14t, and later free-threaded variants). Its +> [`iregexp-check`](https://github.com/jg-rp/rust-iregexp) dependency segfaults +> on import there. There is no PEP 508 dependency marker that can select a +> standard build over a free-threaded build of the same Python version, so +> JsonPatchX cannot block this installation combination for you; only install +> `strict-jsonpath` on a standard (GIL) interpreter. ### Selector Semantics diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 7daf065e..d4c0ca9e 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,172 +1,17 @@ # Examples Agent Guide -## Scope - -This directory is for runnable, readable JsonPatchX examples. When a prompt asks -for a custom operation and does not name a more specific file, default to -[`examples/recipes.py`](recipes.py) or [`examples/recipes2.py`](recipes2.py). - -Use [`examples/recipes.py`](recipes.py) for generally useful JSON mutation -operations with a short, direct style. Use [`examples/recipes2.py`](recipes2.py) -for issue-driven or contract-heavy operations where aliases, preconditions, -richer conflict handling, or multi-field validation are the point of the -example. - -## Canonical Examples - -- [`examples/recipes.py`](recipes.py) is the main style reference for concise - custom operations. -- [`examples/recipes2.py`](recipes2.py) is the main style reference for richer - schema and conflict semantics. -- [`examples/recipes2.py`](recipes2.py) also contains the main state-aware - object-key pattern for operations that need `classify_state()` and detailed - `TargetState` branching. -- The [`examples/fastapi/`](fastapi/) demo files show how operations are - assembled into registries and exposed through FastAPI demos. Do not put - reusable operation catalogs in the demo files. - -## Choose the Right Pattern - -- Use an intent-based operation when callers keep expressing the same higher- - level mutation awkwardly through lower-level steps. Examples: increment, - clamp, lowercase, replace substring. -- Use a contract-narrowing operation when the standard mutation is still right - but the type or allowed behavior should be narrower. Examples: replace only a - number, add only a missing key. -- Use a schema-rich operation when the request contract itself carries important - structure, such as cross-field invariants, aliases, or richer OpenAPI. -- If a prompt explicitly asks for a schema-rich operation, reflect that in the - generated schema, not only in runtime validation. -- Start with the simplest pattern that honestly captures the caller's intent. - -## Write an Operation - -- Subclass `OperationSchema`. -- Give the class a short docstring that states the mutation plainly. -- Set `model_config = ConfigDict(title="...")`. -- Define `op` as a snake_case `Literal[...]` with the same default value. -- Type `path` with the narrowest honest `JSONPointer[...]`. -- Use `JSONValue` or narrower JSON helper types from `jsonpatchx.types` for the - remaining fields. -- Import `JSONPointer`, `JSONValue`, `OperationSchema`, and built-in operations - from `jsonpatchx`, not from internal modules. -- Import narrower helper types such as `JSONString`, `JSONNumber`, - `JSONArray[JSONValue]`, and `JSONObject[JSONValue]` from `jsonpatchx.types`. -- Prefer composing built-ins such as `ReplaceOp`, `AddOp`, `RemoveOp`, and - `TestOp` instead of reimplementing low-level pointer mutation. -- Preserve the typed pointer through composition so the same runtime guarantees - keep applying. -- Do not make the operation class generic unless the prompt is specifically - about generic pointer backends or typing behavior. -- Implement the operation as `def apply(self, doc: JSONValue) -> JSONValue:`. -- Prefer a direct `apply()` body over extra helper methods when the example only - needs one step of logic. -- Use `@override` on `apply()`. -- Favor functional composition in `apply()`: compute the next document value, - then return it. For low-level container edits, mutate the resolved container - and return `doc`. - -## Follow the Local Template - -Use this as the default shape for ordinary operations: - -```python -from typing import Literal, override - -from pydantic import ConfigDict - -from jsonpatchx import JSONPointer, JSONValue, OperationSchema, ReplaceOp -from jsonpatchx.types import JSONString - - -class LowercaseOp(OperationSchema): - """Lowercase a string value.""" - - model_config = ConfigDict(title="Lowercase operation") - op: Literal["lowercase"] = "lowercase" - path: JSONPointer[JSONString] - - @override - def apply(self, doc: JSONValue) -> JSONValue: - current = self.path.get(doc) - return ReplaceOp(path=self.path, value=current.lower()).apply(doc) -``` - -## Model the Schema - -- Add `json_schema_extra` only when it expresses a real contract that field - types alone do not say. -- Use `Field(...)` metadata when it clarifies wire semantics, not just to repeat - the obvious. -- When a prompt explicitly asks for schema richness, make that visible in the - generated schema with titles, descriptions, `Field(...)` metadata, aliases, or - `json_schema_extra`, whichever honestly carries the contract. -- Use `model_validator` when the invariant involves multiple fields. -- When fields are individually valid but invalid together, decide whether the - operation benefits from a structured validation error or whether a plain - `ValueError` is enough. -- In this repository environment, structured operation-level validation errors - should use `from pydantic_core import PydanticCustomError`. -- Plain `ValueError` is acceptable for simple one-off invariants. JsonPatchX - prefers `PydanticCustomError` when stable error codes, message templates, or - named context values materially improve the contract. -- Decide deliberately between omission and nullability. Use `MISSING` from - `pydantic_core` plus `model_fields_set` when โ€œnot providedโ€ and โ€œprovided as - nullโ€ mean different things. Use a nullable field when `null` is itself part - of the wire contract. -- For bounds, limits, indices, regexes, and similar constraint fields, prefer - omission semantics when an inactive constraint has no meaningful JSON `null` - value. A very short Python comment may explain that `null` is not meaningful - input for that field. -- When a narrow typed field needs `MISSING`, the local pattern is often - `Field(default=cast(TheType, MISSING))`. -- When alternate wire names matter, use `validation_alias` and - `serialization_alias` explicitly. - -## Keep Examples Useful - -- Prefer reusable JSON operations over business-domain actions unless the prompt - explicitly asks for domain behavior. -- Keep each operation narrow and composable. -- Use names that describe mutation intent, not implementation detail. -- Avoid helper functions unless they are reused or make the example materially - clearer. -- Prefer code that teaches one idea cleanly over code that tries to show every - extension point at once. -- For array and object examples, mutating the resolved container in place and - returning `doc` is often clearer than inventing new pointer-composition - helpers. -- Do not write pseudo-framework methods that JsonPatchX does not use. - -## Handle Failures Deliberately - -- Runtime document-state conflicts such as missing anchors, duplicate keys, or - impossible array positions usually map to `PatchConflictError`. -- Use `TestOpFailed` for failed preconditions and test-like assertions. -- Let pointer/path/type failures surface naturally; do not wrap broad - `Exception` just to rewrite the message. -- Use `TargetState` or other backend-state machinery only when state - classification is itself part of the example. -- If behavior depends on why a path is invalid, prefer - `classify_state(self.path.ptr, doc)` plus `TargetState` over a generic - try/except flow. -- When using `classify_state()`, branch on the real enum members used here: - `ROOT`, `PARENT_NOT_FOUND`, `PARENT_NOT_CONTAINER`, `OBJECT_KEY_MISSING`, - `ARRAY_KEY_INVALID`, `ARRAY_INDEX_OUT_OF_RANGE`, `ARRAY_INDEX_AT_END`, - `ARRAY_INDEX_APPEND`, `VALUE_PRESENT`, and - `VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX`. -- Do not invent replacement enum names such as `PRESENT` or `EXISTS`. -- If a prompt asks for path relationships such as ancestor/descendant - constraints, use pointer methods such as `is_parent_of()` and `is_child_of()` - in a `model_validator`. -- Do not invent alternative operation hooks such as `patch()`, `expand()`, or - `to_builtin_ops()` for ordinary examples. In this directory, the contract is - the `apply()` method. - -## Validate the Result - -- New example operations should be complete Python code, not pseudocode. +The instructions for writing a JsonPatchX custom operation live at +[`docs/custom-operation-agent-guide.md`](../docs/custom-operation-agent-guide.md). +Read that guide before writing or editing an operation in this directory. This +file is a pointer plus rules local to this directory; it must stay a pointer and +never become a second copy of the guide. + +## Rules Local to This Directory + +- Keep every example runnable, complete Python, not pseudocode. +- Follow the existing layout: reusable operations belong in + [`recipes.py`](recipes.py) or [`recipes2.py`](recipes2.py), and + [`fastapi/`](fastapi/) demo files show how operations are assembled into + registries and routes, not where new operation catalogs go. - If you add or change runnable example behavior, prefer targeted checks over broad repo-wide validation. -- If the prompt is only to generate an operation example, optimize for correct, - importable, readable example code first. diff --git a/examples/README.md b/examples/README.md index d617ed16..b05e3fb6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,10 +3,11 @@ Seven focused FastAPI demos. Each demo is a standalone file that reads cleanly in Swagger UI. -[`examples/AGENTS.md`](AGENTS.md) is the scoped instruction file for coding -agents that are asked to generate custom operations in this directory. +[`examples/AGENTS.md`](AGENTS.md) points coding agents in this directory to the +[custom-operation agent guide](../docs/custom-operation-agent-guide.md) and adds +rules local to this directory. -Prompt fixtures used to evaluate that file live in +Prompt fixtures used to evaluate that guide live in [`tests/agents/`](../tests/agents/). They are meant to test direct, state-aware, and schema-rich operation authoring without spoon-feeding repo-specific exception choices in the prompt itself. diff --git a/examples/fastapi/shared.py b/examples/fastapi/shared.py index dd4ce318..f3ca5c45 100644 --- a/examples/fastapi/shared.py +++ b/examples/fastapi/shared.py @@ -9,12 +9,12 @@ from fastapi import FastAPI from fastapi.responses import RedirectResponse from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic_core import PydanticCustomError from jsonpatchx import ( AddOp, JSONValue, OperationSchema, - OperationValidationError, PatchConflictError, RemoveOp, ReplaceOp, @@ -499,12 +499,14 @@ class SwapOp(OperationSchema): @model_validator(mode="after") def _reject_proper_prefixes(self) -> Self: if self.a.is_parent_of(self.b): - raise OperationValidationError( - "pointer 'b' cannot be a child of pointer 'a'" + raise PydanticCustomError( + "swap_path_conflict", + "pointer 'b' cannot be a child of pointer 'a'", ) if self.b.is_parent_of(self.a): - raise OperationValidationError( - "pointer 'a' cannot be a child of pointer 'b'" + raise PydanticCustomError( + "swap_path_conflict", + "pointer 'a' cannot be a child of pointer 'b'", ) return self diff --git a/examples/openapi/demo1_openapi.json b/examples/openapi/demo1_openapi.json index 04039aec..7b828128 100644 --- a/examples/openapi/demo1_openapi.json +++ b/examples/openapi/demo1_openapi.json @@ -12,13 +12,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location to add the value. Use `\"-\"` as the final token to append to an array.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Value to add.", "title": "Value" } }, @@ -27,7 +28,7 @@ "path", "value" ], - "title": "Add operation", + "title": "RFC 6902 Add Operation", "type": "object" }, "CopyOp": { @@ -35,7 +36,7 @@ "description": "RFC 6902 copy operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to copy.", "format": "json-pointer", "title": "From", "type": "string", @@ -48,7 +49,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the copied value.", "format": "json-pointer", "title": "Path", "type": "string", @@ -60,7 +61,7 @@ "op", "path" ], - "title": "Copy operation", + "title": "RFC 6902 Copy Operation", "type": "object" }, "Customer": { @@ -183,7 +184,7 @@ "description": "RFC 6902 move operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to move.", "format": "json-pointer", "title": "From", "type": "string", @@ -196,7 +197,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the moved value. Must not be a descendant of `from`.", "format": "json-pointer", "title": "Path", "type": "string", @@ -208,12 +209,12 @@ "op", "path" ], - "title": "Move operation", + "title": "RFC 6902 Move Operation", "type": "object" }, "RemoveOp": { "additionalProperties": true, - "description": "RFC 6902 remove operation. Removal of the root returns MISSING.", + "description": "RFC 6902 remove operation.", "properties": { "op": { "const": "remove", @@ -222,7 +223,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to remove.", "format": "json-pointer", "title": "Path", "type": "string", @@ -233,7 +234,7 @@ "op", "path" ], - "title": "Remove operation", + "title": "RFC 6902 Remove Operation", "type": "object" }, "ReplaceOp": { @@ -247,13 +248,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to replace.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Replacement value.", "title": "Value" } }, @@ -262,7 +264,7 @@ "path", "value" ], - "title": "Replace operation", + "title": "RFC 6902 Replace Operation", "type": "object" }, "TestOp": { @@ -276,13 +278,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to test.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Expected value.", "title": "Value" } }, @@ -291,7 +294,7 @@ "path", "value" ], - "title": "Test operation", + "title": "RFC 6902 Test Operation", "type": "object" }, "ValidationError": { @@ -471,41 +474,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -518,41 +537,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -567,41 +602,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -619,41 +670,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/openapi/demo2_openapi.json b/examples/openapi/demo2_openapi.json index c91c876c..b01f0246 100644 --- a/examples/openapi/demo2_openapi.json +++ b/examples/openapi/demo2_openapi.json @@ -12,13 +12,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location to add the value. Use `\"-\"` as the final token to append to an array.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Value to add.", "title": "Value" } }, @@ -27,7 +28,7 @@ "path", "value" ], - "title": "Add operation", + "title": "RFC 6902 Add Operation", "type": "object" }, "AppendOp": { @@ -99,7 +100,7 @@ "description": "RFC 6902 copy operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to copy.", "format": "json-pointer", "title": "From", "type": "string", @@ -112,7 +113,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the copied value.", "format": "json-pointer", "title": "Path", "type": "string", @@ -124,7 +125,7 @@ "op", "path" ], - "title": "Copy operation", + "title": "RFC 6902 Copy Operation", "type": "object" }, "EnforceMaxLenOp": { @@ -323,7 +324,7 @@ "description": "RFC 6902 move operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to move.", "format": "json-pointer", "title": "From", "type": "string", @@ -336,7 +337,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the moved value. Must not be a descendant of `from`.", "format": "json-pointer", "title": "Path", "type": "string", @@ -348,7 +349,7 @@ "op", "path" ], - "title": "Move operation", + "title": "RFC 6902 Move Operation", "type": "object" }, "Player": { @@ -472,7 +473,7 @@ }, "RemoveOp": { "additionalProperties": true, - "description": "RFC 6902 remove operation. Removal of the root returns MISSING.", + "description": "RFC 6902 remove operation.", "properties": { "op": { "const": "remove", @@ -481,7 +482,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to remove.", "format": "json-pointer", "title": "Path", "type": "string", @@ -492,7 +493,7 @@ "op", "path" ], - "title": "Remove operation", + "title": "RFC 6902 Remove Operation", "type": "object" }, "RemoveValueOp": { @@ -538,13 +539,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to replace.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Replacement value.", "title": "Value" } }, @@ -553,7 +555,7 @@ "path", "value" ], - "title": "Replace operation", + "title": "RFC 6902 Replace Operation", "type": "object" }, "RequireMinimumOp": { @@ -600,13 +602,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to test.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Expected value.", "title": "Value" } }, @@ -615,7 +618,7 @@ "path", "value" ], - "title": "Test operation", + "title": "RFC 6902 Test Operation", "type": "object" }, "ToggleBoolOp": { @@ -822,41 +825,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -869,41 +888,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -918,41 +953,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -970,41 +1021,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -1145,41 +1212,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -1192,41 +1275,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -1241,41 +1340,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -1293,41 +1408,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/openapi/demo3_openapi.json b/examples/openapi/demo3_openapi.json index f2fbf2e5..c9b7c332 100644 --- a/examples/openapi/demo3_openapi.json +++ b/examples/openapi/demo3_openapi.json @@ -530,41 +530,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -577,41 +593,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -626,41 +658,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -678,41 +726,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/openapi/demo4_openapi.json b/examples/openapi/demo4_openapi.json index ddb365f1..aa463f9c 100644 --- a/examples/openapi/demo4_openapi.json +++ b/examples/openapi/demo4_openapi.json @@ -12,13 +12,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location to add the value. Use `\"-\"` as the final token to append to an array.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Value to add.", "title": "Value" } }, @@ -27,7 +28,7 @@ "path", "value" ], - "title": "Add operation", + "title": "RFC 6902 Add Operation", "type": "object" }, "Apprentice": { @@ -130,7 +131,7 @@ "description": "RFC 6902 copy operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to copy.", "format": "json-pointer", "title": "From", "type": "string", @@ -143,7 +144,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the copied value.", "format": "json-pointer", "title": "Path", "type": "string", @@ -155,7 +156,7 @@ "op", "path" ], - "title": "Copy operation", + "title": "RFC 6902 Copy Operation", "type": "object" }, "HTTPValidationError": { @@ -176,7 +177,7 @@ "description": "RFC 6902 move operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to move.", "format": "json-pointer", "title": "From", "type": "string", @@ -189,7 +190,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the moved value. Must not be a descendant of `from`.", "format": "json-pointer", "title": "Path", "type": "string", @@ -201,12 +202,12 @@ "op", "path" ], - "title": "Move operation", + "title": "RFC 6902 Move Operation", "type": "object" }, "RemoveOp": { "additionalProperties": true, - "description": "RFC 6902 remove operation. Removal of the root returns MISSING.", + "description": "RFC 6902 remove operation.", "properties": { "op": { "const": "remove", @@ -215,7 +216,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to remove.", "format": "json-pointer", "title": "Path", "type": "string", @@ -226,7 +227,7 @@ "op", "path" ], - "title": "Remove operation", + "title": "RFC 6902 Remove Operation", "type": "object" }, "ReplaceOp": { @@ -240,13 +241,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to replace.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Replacement value.", "title": "Value" } }, @@ -255,7 +257,7 @@ "path", "value" ], - "title": "Replace operation", + "title": "RFC 6902 Replace Operation", "type": "object" }, "RuneAppendOp": { @@ -394,13 +396,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to test.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Expected value.", "title": "Value" } }, @@ -409,7 +412,7 @@ "path", "value" ], - "title": "Test operation", + "title": "RFC 6902 Test Operation", "type": "object" }, "ValidationError": { @@ -579,41 +582,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -626,41 +645,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -675,41 +710,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -727,41 +778,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -873,41 +940,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -920,41 +1003,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -969,41 +1068,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -1021,41 +1136,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/openapi/demo5_openapi.json b/examples/openapi/demo5_openapi.json index 09618880..5df9891b 100644 --- a/examples/openapi/demo5_openapi.json +++ b/examples/openapi/demo5_openapi.json @@ -12,13 +12,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location to add the value. Use `\"-\"` as the final token to append to an array.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Value to add.", "title": "Value" } }, @@ -27,7 +28,7 @@ "path", "value" ], - "title": "Add operation", + "title": "RFC 6902 Add Operation", "type": "object" }, "Apprentice": { @@ -130,7 +131,7 @@ "description": "RFC 6902 copy operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to copy.", "format": "json-pointer", "title": "From", "type": "string", @@ -143,7 +144,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the copied value.", "format": "json-pointer", "title": "Path", "type": "string", @@ -155,7 +156,7 @@ "op", "path" ], - "title": "Copy operation", + "title": "RFC 6902 Copy Operation", "type": "object" }, "ExplicitRuneAppendOp": { @@ -240,7 +241,7 @@ "description": "RFC 6902 move operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to move.", "format": "json-pointer", "title": "From", "type": "string", @@ -253,7 +254,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the moved value. Must not be a descendant of `from`.", "format": "json-pointer", "title": "Path", "type": "string", @@ -265,12 +266,12 @@ "op", "path" ], - "title": "Move operation", + "title": "RFC 6902 Move Operation", "type": "object" }, "RemoveOp": { "additionalProperties": true, - "description": "RFC 6902 remove operation. Removal of the root returns MISSING.", + "description": "RFC 6902 remove operation.", "properties": { "op": { "const": "remove", @@ -279,7 +280,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to remove.", "format": "json-pointer", "title": "Path", "type": "string", @@ -290,7 +291,7 @@ "op", "path" ], - "title": "Remove operation", + "title": "RFC 6902 Remove Operation", "type": "object" }, "ReplaceOp": { @@ -304,13 +305,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to replace.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Replacement value.", "title": "Value" } }, @@ -319,7 +321,7 @@ "path", "value" ], - "title": "Replace operation", + "title": "RFC 6902 Replace Operation", "type": "object" }, "TestOp": { @@ -333,13 +335,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to test.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Expected value.", "title": "Value" } }, @@ -348,7 +351,7 @@ "path", "value" ], - "title": "Test operation", + "title": "RFC 6902 Test Operation", "type": "object" }, "ValidationError": { @@ -498,41 +501,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -545,41 +564,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -594,41 +629,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -646,41 +697,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/openapi/demo6_openapi.json b/examples/openapi/demo6_openapi.json index c3a0f2ca..8aa83e2b 100644 --- a/examples/openapi/demo6_openapi.json +++ b/examples/openapi/demo6_openapi.json @@ -12,13 +12,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location to add the value. Use `\"-\"` as the final token to append to an array.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Value to add.", "title": "Value" } }, @@ -27,7 +28,7 @@ "path", "value" ], - "title": "Add operation", + "title": "RFC 6902 Add Operation", "type": "object" }, "Apprentice": { @@ -130,7 +131,7 @@ "description": "RFC 6902 copy operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to copy.", "format": "json-pointer", "title": "From", "type": "string", @@ -143,7 +144,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the copied value.", "format": "json-pointer", "title": "Path", "type": "string", @@ -155,7 +156,7 @@ "op", "path" ], - "title": "Copy operation", + "title": "RFC 6902 Copy Operation", "type": "object" }, "GenericAppendOp_RunePointerV2_": { @@ -240,7 +241,7 @@ "description": "RFC 6902 move operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to move.", "format": "json-pointer", "title": "From", "type": "string", @@ -253,7 +254,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the moved value. Must not be a descendant of `from`.", "format": "json-pointer", "title": "Path", "type": "string", @@ -265,12 +266,12 @@ "op", "path" ], - "title": "Move operation", + "title": "RFC 6902 Move Operation", "type": "object" }, "RemoveOp": { "additionalProperties": true, - "description": "RFC 6902 remove operation. Removal of the root returns MISSING.", + "description": "RFC 6902 remove operation.", "properties": { "op": { "const": "remove", @@ -279,7 +280,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to remove.", "format": "json-pointer", "title": "Path", "type": "string", @@ -290,7 +291,7 @@ "op", "path" ], - "title": "Remove operation", + "title": "RFC 6902 Remove Operation", "type": "object" }, "ReplaceOp": { @@ -304,13 +305,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to replace.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Replacement value.", "title": "Value" } }, @@ -319,7 +321,7 @@ "path", "value" ], - "title": "Replace operation", + "title": "RFC 6902 Replace Operation", "type": "object" }, "TestOp": { @@ -333,13 +335,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to test.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Expected value.", "title": "Value" } }, @@ -348,7 +351,7 @@ "path", "value" ], - "title": "Test operation", + "title": "RFC 6902 Test Operation", "type": "object" }, "ValidationError": { @@ -498,41 +501,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -545,41 +564,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -594,41 +629,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -646,41 +697,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/openapi/demo7_openapi.json b/examples/openapi/demo7_openapi.json index b141bede..11240b35 100644 --- a/examples/openapi/demo7_openapi.json +++ b/examples/openapi/demo7_openapi.json @@ -12,13 +12,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location to add the value. Use `\"-\"` as the final token to append to an array.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Value to add.", "title": "Value" } }, @@ -27,7 +28,7 @@ "path", "value" ], - "title": "Add operation", + "title": "RFC 6902 Add Operation", "type": "object" }, "ConfigPatchOperation": { @@ -82,7 +83,7 @@ "description": "RFC 6902 copy operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to copy.", "format": "json-pointer", "title": "From", "type": "string", @@ -95,7 +96,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the copied value.", "format": "json-pointer", "title": "Path", "type": "string", @@ -107,7 +108,7 @@ "op", "path" ], - "title": "Copy operation", + "title": "RFC 6902 Copy Operation", "type": "object" }, "HTTPValidationError": { @@ -195,7 +196,7 @@ "description": "RFC 6902 move operation.", "properties": { "from": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to move.", "format": "json-pointer", "title": "From", "type": "string", @@ -208,7 +209,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Destination for the moved value. Must not be a descendant of `from`.", "format": "json-pointer", "title": "Path", "type": "string", @@ -220,12 +221,12 @@ "op", "path" ], - "title": "Move operation", + "title": "RFC 6902 Move Operation", "type": "object" }, "RemoveOp": { "additionalProperties": true, - "description": "RFC 6902 remove operation. Removal of the root returns MISSING.", + "description": "RFC 6902 remove operation.", "properties": { "op": { "const": "remove", @@ -234,7 +235,7 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to remove.", "format": "json-pointer", "title": "Path", "type": "string", @@ -245,7 +246,7 @@ "op", "path" ], - "title": "Remove operation", + "title": "RFC 6902 Remove Operation", "type": "object" }, "ReplaceOp": { @@ -259,13 +260,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to replace.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Replacement value.", "title": "Value" } }, @@ -274,7 +276,7 @@ "path", "value" ], - "title": "Replace operation", + "title": "RFC 6902 Replace Operation", "type": "object" }, "TestOp": { @@ -288,13 +290,14 @@ "type": "string" }, "path": { - "description": "JSON Pointer (RFC 6901) string", + "description": "Location of the value to test.", "format": "json-pointer", "title": "Path", "type": "string", "x-pointer-type-schema": {} }, "value": { + "description": "Expected value.", "title": "Value" } }, @@ -303,7 +306,7 @@ "path", "value" ], - "title": "Test operation", + "title": "RFC 6902 Test Operation", "type": "object" }, "ToggleOp": { diff --git a/examples/openapi/demo8_openapi.json b/examples/openapi/demo8_openapi.json index 205a65e3..8a7edfd6 100644 --- a/examples/openapi/demo8_openapi.json +++ b/examples/openapi/demo8_openapi.json @@ -300,41 +300,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -347,41 +363,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } @@ -396,41 +428,57 @@ { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" }, { @@ -448,41 +496,57 @@ "schema": { "properties": { "detail": { - "oneOf": [ + "title": "Detail", + "type": "string" + }, + "errors": { + "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { - "properties": { - "cause_type": { - "type": [ - "string", - "null" - ] - }, - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "op": { - "type": "object" - } - }, - "required": [ - "index", - "op", - "message" - ], + "type": "null" + } + ], + "title": "Errors" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index" + }, + "operation": { + "anyOf": [ + { + "additionalProperties": true, "type": "object" + }, + { + "type": "null" } - ] + ], + "title": "Operation" + }, + "type": { + "title": "Type", + "type": "string" } }, "required": [ + "type", "detail" ], + "title": "PatchErrorResponse", "type": "object" } } diff --git a/examples/recipes2.py b/examples/recipes2.py index 19f50713..2a8216a5 100644 --- a/examples/recipes2.py +++ b/examples/recipes2.py @@ -11,6 +11,7 @@ from typing import Any, Literal, Self, assert_never, override from pydantic import AliasChoices, ConfigDict, Field, model_validator +from pydantic_core import PydanticCustomError from jsonpatchx import ( AddOp, @@ -24,7 +25,6 @@ TestOpFailed, ) from jsonpatchx.backend import TargetState, classify_state -from jsonpatchx.exceptions import OperationValidationError from jsonpatchx.types import JSONArray, JSONBoolean, JSONNumber, JSONObject, JSONString @@ -422,7 +422,10 @@ class ReplaceTextSliceOp(OperationSchema): @model_validator(mode="after") def _validate_range(self) -> Self: if self.start > self.end: - raise OperationValidationError("replace_text_slice requires start <= end") + raise PydanticCustomError( + "replace_text_slice_range", + "replace_text_slice requires start <= end", + ) return self @override @@ -458,8 +461,9 @@ class AddByValueOp(OperationSchema): @model_validator(mode="after") def _validate_anchor(self) -> Self: if (self.before is None) == (self.after is None): - raise OperationValidationError( - "add_by_value requires exactly one of 'before' or 'after'" + raise PydanticCustomError( + "add_by_value_anchor", + "add_by_value requires exactly one of 'before' or 'after'", ) return self diff --git a/fuzzers/_fuzz_shared.py b/fuzzers/_fuzz_shared.py index d533f96c..0b70238a 100644 --- a/fuzzers/_fuzz_shared.py +++ b/fuzzers/_fuzz_shared.py @@ -4,6 +4,7 @@ import math import struct +from collections.abc import Callable from dataclasses import dataclass from typing import TypeVar @@ -243,6 +244,67 @@ def random_dot_path(cursor: ByteCursor, *, max_tokens: int = 5) -> str: return ".".join(tokens) +@dataclass(frozen=True, slots=True) +class Outcome: + """Result of running one fuzz-target API call, for cross-API comparison.""" + + value: object | None = None + error_type: type[BaseException] | None = None + + @property + def ok(self) -> bool: + return self.error_type is None + + +def assert_equivalent( + left: Outcome, + right: Outcome, + *, + context: str, + error_bucket: Callable[[type[BaseException]], str], +) -> None: + """Assert two outcomes agree on success/failure and value/error bucket. + + ``error_bucket`` is supplied by the caller (rather than imported here) so + this module never imports jsonpatchx/pydantic; those must only be + imported inside each harness's own ``atheris.instrument_imports()`` + block, or coverage instrumentation for the library under test is lost. + """ + if left.ok != right.ok: + raise AssertionError( + f"Outcome mismatch in {context}: left={left.error_type}, right={right.error_type}" + ) + + if left.ok: + if left.value != right.value: + raise AssertionError(f"Value mismatch in {context}") + return + + assert left.error_type is not None + assert right.error_type is not None + if error_bucket(left.error_type) != error_bucket(right.error_type): + raise AssertionError( + f"Error bucket mismatch in {context}: " + f"{left.error_type.__name__} vs {right.error_type.__name__}" + ) + + +def random_patch( + cursor: ByteCursor, + all_ops: tuple[str, ...], + build_op: Callable[[ByteCursor, str], dict[str, object]], +) -> list[dict[str, object]]: + """Generate a random patch by sampling an op name and delegating to ``build_op``.""" + op_count = cursor.int_range(1, 10) + patch: list[dict[str, object]] = [] + + for _ in range(op_count): + op_name = cursor.choose(all_ops) + patch.append(build_op(cursor, op_name)) + + return patch + + def coerce_patch(value: object, *, max_ops: int = 10) -> list[dict[str, object]] | None: """Coerce unknown payloads into list[dict[str, object]] for patch execution.""" if not isinstance(value, list): diff --git a/fuzzers/jsonpatchx_custom_backend_fuzzer.py b/fuzzers/jsonpatchx_custom_backend_fuzzer.py index 3c548371..61d087ae 100644 --- a/fuzzers/jsonpatchx_custom_backend_fuzzer.py +++ b/fuzzers/jsonpatchx_custom_backend_fuzzer.py @@ -4,16 +4,18 @@ import json import sys from collections.abc import Iterable -from dataclasses import dataclass from typing import Literal, Self, cast, override import atheris # type: ignore[import-untyped] from fuzzers._fuzz_shared import ( ByteCursor, + Outcome, + assert_equivalent, coerce_patch, random_dot_path, random_json_value, + random_patch, random_rfc6901_path, ) @@ -28,8 +30,9 @@ apply_patch, ) from jsonpatchx.exceptions import ( + InvalidJSONPointer, PatchConflictError, - PatchInputError, + PatchError, PatchInternalError, ) from jsonpatchx.schema import OperationSchema @@ -122,80 +125,42 @@ def apply(self, doc: JSONValue) -> JSONValue: _ALL_OPS = _KNOWN_OPS + _UNKNOWN_OPS _EXPECTED_PATCH_EXCEPTIONS = ( - PatchInputError, - PatchConflictError, + PatchError, ValidationError, json.JSONDecodeError, UnicodeError, ) -@dataclass(frozen=True, slots=True) -class _Outcome: - value: object | None = None - error_type: type[BaseException] | None = None - - @property - def ok(self) -> bool: - return self.error_type is None - - def _error_bucket(error_type: type[BaseException]) -> str: if issubclass(error_type, PatchConflictError): return "conflict" - if issubclass(error_type, (PatchInputError, ValidationError)): + if issubclass(error_type, (PatchError, ValidationError)): return "input" if issubclass(error_type, (json.JSONDecodeError, UnicodeError)): return "decode" return error_type.__name__ -def _assert_equivalent(left: _Outcome, right: _Outcome, *, context: str) -> None: - if left.ok != right.ok: - raise AssertionError( - f"Outcome mismatch in {context}: left={left.error_type}, right={right.error_type}" - ) - - if left.ok: - if left.value != right.value: - raise AssertionError(f"Value mismatch in {context}") - return - - assert left.error_type is not None - assert right.error_type is not None - if _error_bucket(left.error_type) != _error_bucket(right.error_type): - raise AssertionError( - f"Error bucket mismatch in {context}: " - f"{left.error_type.__name__} vs {right.error_type.__name__}" - ) - - -def _random_patch(cursor: ByteCursor) -> list[dict[str, object]]: - op_count = cursor.int_range(1, 10) - patch: list[dict[str, object]] = [] - - for _ in range(op_count): - op_name = cursor.choose(_ALL_OPS) - use_dot = op_name.startswith("dot_") - path = random_dot_path(cursor) if use_dot else random_rfc6901_path(cursor) - - op: dict[str, object] = { - "op": op_name, - "path": path, - } +def _build_op(cursor: ByteCursor, op_name: str) -> dict[str, object]: + use_dot = op_name.startswith("dot_") + path = random_dot_path(cursor) if use_dot else random_rfc6901_path(cursor) - if op_name in {"add", "replace", "test", "dot_set", "dot_increment"}: - op["value"] = random_json_value(cursor) + op: dict[str, object] = { + "op": op_name, + "path": path, + } - if op_name in {"copy", "move"}: - op["from"] = random_rfc6901_path(cursor) + if op_name in {"add", "replace", "test", "dot_set", "dot_increment"}: + op["value"] = random_json_value(cursor) - if cursor.int_range(0, 6) == 0: - op["x-fuzz"] = random_json_value(cursor, max_depth=2) + if op_name in {"copy", "move"}: + op["from"] = random_rfc6901_path(cursor) - patch.append(op) + if cursor.int_range(0, 6) == 0: + op["x-fuzz"] = random_json_value(cursor, max_depth=2) - return patch + return op def _has_custom_ops(patch: list[dict[str, object]]) -> bool: @@ -208,44 +173,44 @@ def _has_custom_ops(patch: list[dict[str, object]]) -> bool: def _run_hybrid_patch( doc: object, patch: list[dict[str, object]], *, inplace: bool -) -> _Outcome: +) -> Outcome: try: patch_for_api = cast(list[dict[str, JSONValue]], patch) doc_for_api = cast(JSONValue, copy.deepcopy(doc)) result = JsonPatch(patch_for_api, registry=HybridRegistry).apply( doc_for_api, inplace=inplace ) - return _Outcome(value=result) + return Outcome(value=result) except PatchInternalError: raise except _EXPECTED_PATCH_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + return Outcome(error_type=type(exc)) -def _run_hybrid_from_string(doc: object, patch: list[dict[str, object]]) -> _Outcome: +def _run_hybrid_from_string(doc: object, patch: list[dict[str, object]]) -> Outcome: try: encoded_patch = json.dumps(patch, separators=(",", ":"), ensure_ascii=False) doc_for_api = cast(JSONValue, copy.deepcopy(doc)) result = JsonPatch.from_string(encoded_patch, registry=HybridRegistry).apply( doc_for_api, inplace=False ) - return _Outcome(value=result) + return Outcome(value=result) except PatchInternalError: raise except _EXPECTED_PATCH_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + return Outcome(error_type=type(exc)) -def _run_standard_apply_patch(doc: object, patch: list[dict[str, object]]) -> _Outcome: +def _run_standard_apply_patch(doc: object, patch: list[dict[str, object]]) -> Outcome: try: patch_for_api = cast(list[dict[str, JSONValue]], patch) doc_for_api = cast(JSONValue, copy.deepcopy(doc)) result = apply_patch(doc_for_api, patch_for_api, inplace=False) - return _Outcome(value=result) + return Outcome(value=result) except PatchInternalError: raise except _EXPECTED_PATCH_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + return Outcome(error_type=type(exc)) def _exercise_dot_pointer(path: str, *, source: bytes) -> None: @@ -253,7 +218,7 @@ def _exercise_dot_pointer(path: str, *, source: bytes) -> None: ptr: JSONPointer[JSONValue, DotPointer] = JSONPointer.parse( path, backend=DotPointer ) - except (PatchInputError, PatchConflictError, ValidationError): + except InvalidJSONPointer: return round_trip: JSONPointer[JSONValue, DotPointer] = JSONPointer.parse( @@ -272,7 +237,7 @@ def _exercise_dot_pointer(path: str, *, source: bytes) -> None: try: ptr.get(copy.deepcopy(doc)) get_ok = True - except (PatchInputError, PatchConflictError, ValidationError): + except PatchError: get_ok = False if gettable != get_ok: raise AssertionError("Dot pointer is_gettable disagrees with get") @@ -281,7 +246,7 @@ def _exercise_dot_pointer(path: str, *, source: bytes) -> None: try: ptr.add(copy.deepcopy(doc), value) add_ok = True - except (PatchInputError, PatchConflictError, ValidationError): + except PatchError: add_ok = False if addable != add_ok: raise AssertionError("Dot pointer is_addable disagrees with add") @@ -292,23 +257,26 @@ def _exercise_patch(doc: object, patch: list[dict[str, object]]) -> None: hybrid_inplace = _run_hybrid_patch(doc, patch, inplace=True) hybrid_string = _run_hybrid_from_string(doc, patch) - _assert_equivalent( + assert_equivalent( hybrid_copy, hybrid_inplace, context="Hybrid registry inplace=False vs inplace=True", + error_bucket=_error_bucket, ) - _assert_equivalent( + assert_equivalent( hybrid_copy, hybrid_string, context="Hybrid registry python patch vs JSON string patch", + error_bucket=_error_bucket, ) if not _has_custom_ops(patch): standard = _run_standard_apply_patch(doc, patch) - _assert_equivalent( + assert_equivalent( hybrid_copy, standard, context="Hybrid registry standard-only ops vs apply_patch", + error_bucket=_error_bucket, ) @@ -318,7 +286,7 @@ def TestOneInput(data: bytes) -> None: cursor = ByteCursor(data) generated_doc = random_json_value(cursor) - generated_patch = _random_patch(cursor) + generated_patch = random_patch(cursor, _ALL_OPS, _build_op) _exercise_dot_pointer(random_dot_path(cursor), source=data) _exercise_dot_pointer(data[:96].decode("latin-1"), source=data[::-1]) diff --git a/fuzzers/jsonpatchx_fuzzer.py b/fuzzers/jsonpatchx_fuzzer.py index d26173a4..650ce5e0 100644 --- a/fuzzers/jsonpatchx_fuzzer.py +++ b/fuzzers/jsonpatchx_fuzzer.py @@ -4,15 +4,17 @@ import json import sys from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass from typing import cast import atheris # type: ignore[import-untyped] from fuzzers._fuzz_shared import ( ByteCursor, + Outcome, + assert_equivalent, coerce_patch, random_json_value, + random_patch, random_rfc6901_path, ) @@ -21,8 +23,9 @@ from jsonpatchx import JsonPatch, JSONPointer, JSONValue, apply_patch from jsonpatchx.exceptions import ( + InvalidJSONPointer, PatchConflictError, - PatchInputError, + PatchError, PatchInternalError, ) @@ -31,142 +34,99 @@ _ALL_OPS = _KNOWN_OPS + _UNKNOWN_OPS _EXPECTED_PATCH_EXCEPTIONS = ( - PatchInputError, - PatchConflictError, + PatchError, ValidationError, json.JSONDecodeError, UnicodeError, ) -_EXPECTED_POINTER_EXCEPTIONS = ( - PatchInputError, - PatchConflictError, - ValidationError, -) - - -@dataclass(frozen=True, slots=True) -class _Outcome: - value: object | None = None - error_type: type[BaseException] | None = None - - @property - def ok(self) -> bool: - return self.error_type is None def _error_bucket(error_type: type[BaseException]) -> str: if issubclass(error_type, PatchConflictError): return "conflict" - if issubclass(error_type, (PatchInputError, ValidationError)): + if issubclass(error_type, (PatchError, ValidationError)): return "input" if issubclass(error_type, (json.JSONDecodeError, UnicodeError)): return "decode" return error_type.__name__ -def _assert_equivalent(left: _Outcome, right: _Outcome, *, context: str) -> None: - if left.ok != right.ok: - raise AssertionError( - f"Outcome mismatch in {context}: left={left.error_type}, right={right.error_type}" - ) - - if left.ok: - if left.value != right.value: - raise AssertionError(f"Value mismatch in {context}") - return - - assert left.error_type is not None - assert right.error_type is not None - if _error_bucket(left.error_type) != _error_bucket(right.error_type): - raise AssertionError( - f"Error bucket mismatch in {context}: " - f"{left.error_type.__name__} vs {right.error_type.__name__}" - ) - - -def _random_patch(cursor: ByteCursor) -> list[dict[str, object]]: - op_count = cursor.int_range(1, 10) - patch: list[dict[str, object]] = [] - - for _ in range(op_count): - op_name = cursor.choose(_ALL_OPS) - op: dict[str, object] = { - "op": op_name, - "path": random_rfc6901_path(cursor), - } - - if op_name in {"add", "replace", "test"}: - op["value"] = random_json_value(cursor) +def _build_op(cursor: ByteCursor, op_name: str) -> dict[str, object]: + op: dict[str, object] = { + "op": op_name, + "path": random_rfc6901_path(cursor), + } - if op_name in {"copy", "move"}: - op["from"] = random_rfc6901_path(cursor) + if op_name in {"add", "replace", "test"}: + op["value"] = random_json_value(cursor) - # Exercise extra fields and alias ambiguity paths. - if cursor.int_range(0, 5) == 0: - op["x-fuzz"] = random_json_value(cursor, max_depth=2) - if cursor.int_range(0, 9) == 0: - op["from_"] = random_rfc6901_path(cursor) + if op_name in {"copy", "move"}: + op["from"] = random_rfc6901_path(cursor) - # Sometimes drop required fields to hit validators. - if cursor.int_range(0, 11) == 0: - op.pop("path", None) + # Exercise extra fields and alias ambiguity paths. + if cursor.int_range(0, 5) == 0: + op["x-fuzz"] = random_json_value(cursor, max_depth=2) + if cursor.int_range(0, 9) == 0: + op["from_"] = random_rfc6901_path(cursor) - patch.append(op) + # Sometimes drop required fields to hit validators. + if cursor.int_range(0, 11) == 0: + op.pop("path", None) - return patch + return op def _run_patch_class_api( doc: object, patch: list[dict[str, object]], *, inplace: bool -) -> _Outcome: +) -> Outcome: try: patch_for_api = cast(Sequence[Mapping[str, JSONValue]], patch) doc_for_api = cast(JSONValue, copy.deepcopy(doc)) result = JsonPatch(patch_for_api).apply(doc_for_api, inplace=inplace) - return _Outcome(value=result) + return Outcome(value=result) except PatchInternalError: raise except _EXPECTED_PATCH_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + return Outcome(error_type=type(exc)) -def _run_patch_function_api(doc: object, patch: list[dict[str, object]]) -> _Outcome: +def _run_patch_function_api(doc: object, patch: list[dict[str, object]]) -> Outcome: try: patch_for_api = cast(Sequence[Mapping[str, JSONValue]], patch) doc_for_api = cast(JSONValue, copy.deepcopy(doc)) result = apply_patch(doc_for_api, patch_for_api, inplace=False) - return _Outcome(value=result) + return Outcome(value=result) except PatchInternalError: raise except _EXPECTED_PATCH_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + return Outcome(error_type=type(exc)) -def _run_patch_string_api(doc: object, patch: list[dict[str, object]]) -> _Outcome: +def _run_patch_string_api(doc: object, patch: list[dict[str, object]]) -> Outcome: try: encoded_patch = json.dumps(patch, separators=(",", ":"), ensure_ascii=False) doc_for_api = cast(JSONValue, copy.deepcopy(doc)) result = JsonPatch.from_string(encoded_patch).apply(doc_for_api, inplace=False) - return _Outcome(value=result) + return Outcome(value=result) except PatchInternalError: raise except _EXPECTED_PATCH_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + return Outcome(error_type=type(exc)) -def _run_pointer_call(fn: Callable[[], object]) -> _Outcome: +def _run_pointer_call(fn: Callable[[], object]) -> Outcome: try: - return _Outcome(value=fn()) + return Outcome(value=fn()) except PatchInternalError: raise - except _EXPECTED_POINTER_EXCEPTIONS as exc: - return _Outcome(error_type=type(exc)) + except PatchError as exc: + return Outcome(error_type=type(exc)) def _exercise_pointer(pointer_text: str, *, source: bytes) -> None: try: ptr: JSONPointer[JSONValue] = JSONPointer.parse(pointer_text) - except _EXPECTED_POINTER_EXCEPTIONS: + except InvalidJSONPointer: return # Parsing should be stable under canonical stringification. @@ -204,14 +164,23 @@ def _exercise_patch(doc: object, patch: list[dict[str, object]]) -> None: string_copy = _run_patch_string_api(doc, patch) class_inplace = _run_patch_class_api(doc, patch, inplace=True) - _assert_equivalent( - class_copy, function_copy, context="JsonPatch.apply vs apply_patch" + assert_equivalent( + class_copy, + function_copy, + context="JsonPatch.apply vs apply_patch", + error_bucket=_error_bucket, ) - _assert_equivalent( - class_copy, string_copy, context="JsonPatch() vs JsonPatch.from_string" + assert_equivalent( + class_copy, + string_copy, + context="JsonPatch() vs JsonPatch.from_string", + error_bucket=_error_bucket, ) - _assert_equivalent( - class_copy, class_inplace, context="inplace=False vs inplace=True" + assert_equivalent( + class_copy, + class_inplace, + context="inplace=False vs inplace=True", + error_bucket=_error_bucket, ) @@ -221,7 +190,7 @@ def TestOneInput(data: bytes) -> None: cursor = ByteCursor(data) generated_doc = random_json_value(cursor) - generated_patch = _random_patch(cursor) + generated_patch = random_patch(cursor, _ALL_OPS, _build_op) # Directly consume raw bytes as pointer text to preserve entropy. pointer_text = data[:96].decode("latin-1") diff --git a/jsonpatchx/__init__.py b/jsonpatchx/__init__.py index c364de3c..f56772ab 100644 --- a/jsonpatchx/__init__.py +++ b/jsonpatchx/__init__.py @@ -17,19 +17,16 @@ InvalidJSONSelector, InvalidOperationDefinition, InvalidOperationRegistry, - OperationValidationError, + InvalidPatchResult, + InvalidPatchTarget, PatchConflictError, PatchError, - PatchFailureDetail, - PatchInputError, PatchInternalError, - PatchValidationError, TestOpFailed, ) from jsonpatchx.pointer import JSONPointer from jsonpatchx.pydantic import JsonPatchFor from jsonpatchx.registry import ( - STANDARD_OPS, StandardRegistry, ) from jsonpatchx.schema import OperationSchema @@ -43,13 +40,11 @@ "InvalidJSONSelector", "InvalidOperationDefinition", "InvalidOperationRegistry", - "OperationValidationError", + "InvalidPatchResult", + "InvalidPatchTarget", "PatchConflictError", "PatchError", "PatchInternalError", - "PatchFailureDetail", - "PatchInputError", - "PatchValidationError", "TestOpFailed", # types "JSONPointer", @@ -60,7 +55,6 @@ # operation specs "OperationSchema", "StandardRegistry", - "STANDARD_OPS", # pydantic helpers "JsonPatchFor", # built-ins diff --git a/jsonpatchx/backend.py b/jsonpatchx/backend.py index 952751a3..a7aea8de 100644 --- a/jsonpatchx/backend.py +++ b/jsonpatchx/backend.py @@ -14,7 +14,6 @@ from jsonpath import JSONPathEnvironment from jsonpointer import JsonPointer # type: ignore[import-untyped] from jsonpointer import JsonPointerException as JPException -from pydantic_core import MISSING from jsonpatchx.exceptions import InvalidJSONPointer, InvalidJSONSelector from jsonpatchx.types import JSONValue, _is_array, _is_container, _is_object @@ -87,9 +86,14 @@ def __init__(self, pointer: str) -> None: """Parse a backend-specific pointer string. Arguments: - pointer: Pointer string in the backend's syntax. + pointer: A string in the backend's syntax. """ + @property + @abstractmethod + def parts(self) -> Sequence[str]: + """The pointer's unescaped parts in traversal order.""" + @classmethod @abstractmethod def from_parts(cls, parts: Iterable[str]) -> Self: @@ -99,7 +103,7 @@ def from_parts(cls, parts: Iterable[str]) -> Self: parts: Unescaped pointer parts in traversal order. Returns: - A pointer equivalent to those parts. + pointer: A pointer equivalent to those parts. """ @abstractmethod @@ -116,20 +120,7 @@ def resolve(self, doc: JSONValue) -> JSONValue: @override @abstractmethod def __str__(self) -> str: - """Return the backend's canonical string form. - - Returns: - The canonical string representation of this pointer. - """ - - @property - @abstractmethod - def parts(self) -> Sequence[str]: - """Return unescaped pointer parts. - - Returns: - The pointer's unescaped parts in traversal order. - """ + """The canonical string representation of this pointer.""" class DEFAULT_POINTER_CLS: @@ -145,32 +136,50 @@ class DEFAULT_POINTER_CLS: @override def __init__(self, pointer: str) -> None: - """Parse an RFC 6901 pointer string and cache its unescaped parts. + """Parse an RFC 6901 JSON Pointer string. Arguments: - pointer: RFC 6901 pointer string to parse. + pointer: A string in RFC 6901 syntax. `/` and `~` inside tokens + must be escaped as `~1` and `~0` respectively. + + Example: + ```python + DEFAULT_POINTER_CLS("/foo/bar~1baz/~0qux") + ``` """ self._pointer = _FixedJsonPointer(pointer) self._parts = cast(Sequence[str], self._pointer.parts) @property def parts(self) -> Sequence[str]: - """Return the pointer's unescaped RFC 6901 reference tokens. + """The pointer's unescaped RFC 6901 reference tokens in traversal order. - Returns: - The pointer's unescaped reference tokens. + RFC 6901 escapes `/` as `~1` and `~` as `~0` inside tokens. `parts` + returns the decoded values. + + Example: + ```python + ptr = DEFAULT_POINTER_CLS("/foo/bar~1baz/~0qux") + ptr.parts # ("foo", "bar/baz", "~qux") + ``` """ return self._parts @classmethod def from_parts(cls, parts: Iterable[str]) -> Self: - """Build a canonical RFC 6901 pointer from unescaped reference tokens. + """Build an RFC 6901 pointer from unescaped reference tokens. Arguments: parts: Unescaped RFC 6901 reference tokens. Returns: - A canonical RFC 6901 pointer for those tokens. + pointer: A canonical RFC 6901 pointer for those tokens. + + Example: + ```python + DEFAULT_POINTER_CLS.from_parts(["foo", "bar/baz", "~qux"]) + # DEFAULT_POINTER_CLS("/foo/bar~1baz/~0qux") + ``` """ canonical = _FixedJsonPointer.from_parts(parts) return cls(str(canonical)) @@ -183,25 +192,29 @@ def resolve(self, doc: JSONValue) -> JSONValue: Returns: The value targeted by this pointer. + + Example: + ```python + ptr = DEFAULT_POINTER_CLS("/users/0/name") + ptr.resolve({"users": [{"name": "Alice"}]}) # "Alice" + ``` """ return cast(JSONValue, self._pointer.resolve(doc)) @override def __str__(self) -> str: - """Return the canonical RFC 6901 string form. + """The canonical RFC 6901 JSON Pointer string, with tokens re-escaped. - Returns: - The canonical RFC 6901 string representation. + Example: + ```python + ptr = DEFAULT_POINTER_CLS.from_parts(["foo", "bar/baz", "~qux"]) + str(ptr) # "/foo/bar~1baz/~0qux" + ``` """ return str(self._pointer) @override def __repr__(self) -> str: - """Return a debugging representation of this backend instance. - - Returns: - A representation showing the backend name and source pointer. - """ return "JsonPointerRFC6901(" + repr(self._pointer.path) + ")" @@ -211,7 +224,7 @@ def _is_root_ptr(ptr: PointerBackend, doc: JSONValue) -> bool: Arguments: ptr: Pointer to test. - doc: Document to resolve against. + doc: JSON document to resolve against. Returns: `True` if `ptr` resolves to `doc` itself, else `False`. @@ -253,16 +266,28 @@ def _pointer_backend_instance[PB: PointerBackend]( Arguments: path: Pointer string to parse. - pointer_cls: Backend class used to parse the pointer. + pointer_cls: Backend class used to parse the pointer. Callers are + responsible for ensuring this is a concrete, non-abstract class; + `JSONPointer._validate_backend_arg` is the single canonical + place that enforces this, for every path that reaches a backend + class (a direct argument, a schema-build-time argument, or a + `TypeVar` default), before one ever reaches this function. Returns: A backend instance for `path`. Raises: - InvalidJSONPointer: If construction fails. + TypeError: If the constructor raises `TypeError`, or the constructor + returns an object that does not implement `PointerBackend`. + InvalidJSONPointer: If the backend constructor raises a non-`TypeError` + exception (path string is invalid for the given backend). """ try: ptr = pointer_cls(path) + except TypeError as e: + raise TypeError( + f"pointer_cls {pointer_cls!r} is not a usable PointerBackend: {e}" + ) from e except Exception as e: if ( pointer_cls is DEFAULT_POINTER_CLS @@ -274,7 +299,7 @@ def _pointer_backend_instance[PB: PointerBackend]( ) from e if not isinstance(ptr, PointerBackend): - raise InvalidJSONPointer( + raise TypeError( f"pointer_cls {pointer_cls!r} instances must implement the PointerBackend Protocol" ) return ptr @@ -287,41 +312,28 @@ def _pointer_backend_instance[PB: PointerBackend]( class TargetState(Enum): - """ - Resolution state for applying a pointer-like operation to a document. - - Values: - - `MISSING`: The root pointer targets a missing document. - - `ROOT`: The pointer targets the document root. - - `PARENT_NOT_FOUND`: A parent pointer segment could not be resolved. - - `PARENT_NOT_CONTAINER`: The parent resolved, but is neither an object nor - an array. - - `OBJECT_KEY_MISSING`: The parent is an object, and the final key is not - present. - - `ARRAY_KEY_INVALID`: The parent is an array, and the final token is not a - valid array index or append token. - - `ARRAY_INDEX_OUT_OF_RANGE`: The parent is an array, and the numeric index - is outside the accepted range. - - `ARRAY_INDEX_AT_END`: The parent is an array, and the numeric index is - exactly `len(array)`. - - `ARRAY_INDEX_APPEND`: The parent is an array, and the final token is - `"-"`. - - `VALUE_PRESENT`: The pointer names an existing value. - - `VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX`: The pointer names an existing - array element through a negative index. - """ + """Return type of `classify_state`, describing how a pointer relates to a specific location in a document.""" - MISSING = auto() ROOT = auto() + """The pointer targets the document root.""" PARENT_NOT_FOUND = auto() + """A parent pointer segment could not be resolved.""" PARENT_NOT_CONTAINER = auto() + """The parent resolved, but is neither an object nor an array.""" OBJECT_KEY_MISSING = auto() + """The parent is an object, and the final key is not present.""" ARRAY_KEY_INVALID = auto() + """The parent is an array, and the final token is not a valid array index or append token.""" ARRAY_INDEX_OUT_OF_RANGE = auto() + """The parent is an array, and the numeric index is outside the accepted range.""" ARRAY_INDEX_AT_END = auto() + """The parent is an array, and the numeric index is exactly `len(array)`.""" ARRAY_INDEX_APPEND = auto() + """The parent is an array, and the final token is `"-"`.""" VALUE_PRESENT = auto() + """The pointer names an existing value.""" VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX = auto() + """The pointer names an existing array element through a negative index.""" def classify_state(ptr: PointerBackend, doc: JSONValue) -> TargetState: @@ -333,14 +345,23 @@ def classify_state(ptr: PointerBackend, doc: JSONValue) -> TargetState: Arguments: ptr: Pointer to classify. - doc: Document to classify the pointer against. + doc: JSON document to classify the pointer against. Returns: The resolution state that best describes how `ptr` relates to `doc`. + + Example: + ```python + >>> doc = {"items": [1, 2, 3]} + >>> classify_state(DEFAULT_POINTER_CLS("/items/1"), doc) + TargetState.VALUE_PRESENT + >>> classify_state(DEFAULT_POINTER_CLS("/items/9"), doc) + TargetState.ARRAY_INDEX_OUT_OF_RANGE + >>> classify_state(DEFAULT_POINTER_CLS("/missing"), doc) + TargetState.OBJECT_KEY_MISSING + ``` """ if _is_root_ptr(ptr, doc): - if doc is MISSING: # type: ignore[comparison-overlap] - return TargetState.MISSING return TargetState.ROOT try: @@ -394,40 +415,40 @@ class SelectorBackend(Protocol): @abstractmethod def __init__(self, selector: str) -> None: - """Parse and construct a backend-specific selector. + """Parse a backend-specific selector string. Arguments: - selector: Selector string in the backend's syntax. + selector: A string in the backend's syntax. """ @abstractmethod def pointers(self, doc: JSONValue) -> Iterable[PointerBackend]: - """Yield exact matched pointers against a document. + """The pointers this selector matches against a JSON document. Arguments: - doc: JSON document to evaluate against. + doc: JSON document to match against. Returns: - An iterable of exact matched pointers. + pointers: The backend-specific pointers matched by this selector. """ @abstractmethod @override def __str__(self) -> str: - """Return the backend's canonical string form. - - Returns: - The canonical string representation of this selector. - """ + """The canonical string representation of this selector.""" # Out of the box, JsonPatchX's default JSONPath backend follows upstream -# python-jsonpath's RFC 9535 path. +# python-jsonpath's RFC 9535 path only if the jsonpatchx[strict-jsonpath] +# extra is installed. That extra pulls in python-jsonpath[strict], whose +# iregexp-check dependency segfaults on import under free-threaded Python. +# There is no PEP 508 marker that can express "not a free-threaded build" of +# a given Python version, so this cannot be enforced automatically: install +# jsonpatchx[strict-jsonpath] only on a standard (GIL) interpreter, never on +# a free-threaded one (3.13t, 3.14t, ...). # -# The exception is Python 3.14 and later, where the upstream iregexp-check -# dependency behind python-jsonpath[strict] is not yet compatible with -# free-threaded Python. JsonPatchX still uses JSONPathEnvironment(strict=True) -# there, so this only affects regex compliance: +# Without jsonpatchx[strict-jsonpath] (the default), JsonPatchX still uses +# JSONPathEnvironment(strict=True), so this only affects regex compliance: # - match() and search() use Python's built-in re instead of the third-party # regex engine. # - regex patterns are not validated against RFC 9485 I-Regexp. @@ -446,51 +467,53 @@ class DEFAULT_SELECTOR_CLS: environment and yields exact pointer locations for each match. Disclaimer: - This backend follows RFC 9535 path syntax and semantics, except on - Python 3.14 and later where regex-related behavior falls back to - Python's built-in `re` module because the upstream `iregexp-check` - dependency is not yet compatible with free-threaded Python. + This backend follows RFC 9535 path syntax and semantics only if the + `jsonpatchx[strict-jsonpath]` extra is installed; otherwise + regex-related behavior falls back to Python's built-in `re` module. + Do not install `jsonpatchx[strict-jsonpath]` on a free-threaded Python + build: its upstream `iregexp-check` dependency segfaults on import + there, and no PEP 508 marker can select a standard build over a + free-threaded build of the same Python version to guard against it + automatically. """ __slots__ = ("_path", "_selector") def __init__(self, selector: str) -> None: - """Compile a JSONPath selector string with the built-in strict environment. + """Parse an RFC 9535 JSONPath selector string. Arguments: - selector: JSONPath selector string to compile. + selector: A string in RFC 9535 syntax. """ self._path = selector self._selector = _DEFAULT_SELECTOR_ENV.compile(selector) def pointers(self, doc: JSONValue) -> Iterable[DEFAULT_POINTER_CLS]: - """Yield canonical RFC 6901 pointers for each matched location. + """The RFC 6901 JSON Pointers this JSONPath matches against a JSON document. Arguments: - doc: JSON document to evaluate against. + doc: JSON document to match against. Returns: - An iterable of canonical RFC 6901 pointers for each match. + pointers: The RFC 6901 JSON Pointers matched by this JSONPath. + + Example: + ```python + sel = DEFAULT_SELECTOR_CLS("$.users[*].name") + doc = {"users": [{"name": "Alice"}, {"name": "Bob"}]} + [str(p) for p in sel.pointers(doc)] # ["/users/0/name", "/users/1/name"] + ``` """ for match in self._selector.finditer(doc): yield DEFAULT_POINTER_CLS.from_parts((str(part) for part in match.parts)) @override def __str__(self) -> str: - """Return the selector's original source string. - - Returns: - The original selector source string. - """ + """The canonical RFC 9535 JSONPath string.""" return self._path @override def __repr__(self) -> str: - """Return a debugging representation of this backend instance. - - Returns: - A representation showing the backend name and source selector. - """ return "JsonPathRFC9535(" + repr(self._path) + ")" @@ -502,16 +525,29 @@ def _selector_backend_instance[SB: SelectorBackend]( Arguments: selector: Selector string to parse. - selector_cls: Backend class used to parse the selector. + selector_cls: Backend class used to parse the selector. Callers are + responsible for ensuring this is a concrete, non-abstract class; + `JSONSelector._validate_backend_arg` is the single canonical + place that enforces this, for every path that reaches + a backend class (a direct argument, a schema-build-time + argument, or a `TypeVar` default), before one ever reaches this + function. Returns: A backend instance for `selector`. Raises: - InvalidJSONSelector: If construction fails. + TypeError: If the constructor raises `TypeError`, or the constructor + returns an object that does not implement `SelectorBackend`. + InvalidJSONSelector: If the backend constructor raises a non-`TypeError` + exception (selector string is invalid for the given backend). """ try: compiled = selector_cls(selector) + except TypeError as e: + raise TypeError( + f"selector_cls {selector_cls!r} is not a usable SelectorBackend: {e}" + ) from e except Exception as e: if selector_cls is DEFAULT_SELECTOR_CLS: raise InvalidJSONSelector( @@ -522,7 +558,7 @@ def _selector_backend_instance[SB: SelectorBackend]( ) from e if not isinstance(compiled, SelectorBackend): - raise InvalidJSONSelector( + raise TypeError( f"selector_cls {selector_cls!r} instances must implement the SelectorBackend protocol" ) diff --git a/jsonpatchx/builtins.py b/jsonpatchx/builtins.py index b2005038..7d4340b6 100644 --- a/jsonpatchx/builtins.py +++ b/jsonpatchx/builtins.py @@ -1,9 +1,13 @@ import copy -from typing import Literal, Self, override +from typing import ClassVar, Literal, Self, override from pydantic import ConfigDict, Field, model_validator +from pydantic_core import PydanticCustomError -from jsonpatchx.exceptions import OperationValidationError, TestOpFailed +from jsonpatchx.exceptions import ( + PatchConflictError, + TestOpFailed, +) from jsonpatchx.pointer import JSONPointer from jsonpatchx.schema import OperationSchema from jsonpatchx.types import JSONValue @@ -13,34 +17,50 @@ class AddOp(OperationSchema): """RFC 6902 add operation.""" model_config = ConfigDict( - title="Add operation", + title="RFC 6902 Add Operation", json_schema_extra={"description": "RFC 6902 add operation."}, ) op: Literal["add"] = "add" - path: JSONPointer[JSONValue] - value: JSONValue + path: JSONPointer[JSONValue] = Field( + description='Location to add the value. Use `"-"` as the final token to append to an array.' + ) + value: JSONValue = Field(description="Value to add.") @override def apply(self, doc: JSONValue) -> JSONValue: + """Add `value` at `path` according to RFC 6902 semantics. + + Raises: + PatchConflictError: If the parent location does not exist, is not an array or + object, or the array index is out of range or invalid. + """ return self.path.add(doc, self.value) class RemoveOp(OperationSchema): - """RFC 6902 remove operation. Removal of the root returns MISSING.""" + """RFC 6902 remove operation.""" model_config = ConfigDict( - title="Remove operation", - json_schema_extra={ - "description": "RFC 6902 remove operation. Removal of the root returns MISSING." - }, + title="RFC 6902 Remove Operation", + json_schema_extra={"description": "RFC 6902 remove operation."}, ) op: Literal["remove"] = "remove" - path: JSONPointer[JSONValue] + path: JSONPointer[JSONValue] = Field(description="Location of the value to remove.") @override def apply(self, doc: JSONValue) -> JSONValue: + """Remove the value at `path` according to RFC 6902 semantics. + + Raises: + PatchConflictError: If `path` targets the document root, the target does not + exist, the parent is not an array or object, the array index is out of + range, or the final token is `"-"` (`"-"` is append-only and does not + address an existing element). + """ + if self.path.is_root(doc): + raise PatchConflictError("cannot delete the document") return self.path.remove(doc) @@ -48,61 +68,102 @@ class ReplaceOp(OperationSchema): """RFC 6902 replace operation.""" model_config = ConfigDict( - title="Replace operation", + title="RFC 6902 Replace Operation", json_schema_extra={"description": "RFC 6902 replace operation."}, ) op: Literal["replace"] = "replace" - path: JSONPointer[JSONValue] - value: JSONValue + path: JSONPointer[JSONValue] = Field( + description="Location of the value to replace." + ) + value: JSONValue = Field(description="Replacement value.") @override def apply(self, doc: JSONValue) -> JSONValue: + """Replace the value at `path` with `value` according to RFC 6902 semantics. + + Raises: + PatchConflictError: If the target does not exist, the parent is not an array + or object, the array index is out of range, or the final token is `"-"` + (which does not address an existing element). + """ + if self.path.is_root(doc): + return AddOp(path=self.path, value=self.value).apply(doc) doc = RemoveOp(path=self.path).apply(doc) return AddOp(path=self.path, value=self.value).apply(doc) class MoveOp(OperationSchema): - """RFC 6902 move operation.""" + """RFC 6902 move operation. + + Raises: + ValidationError: If `path` is a descendant of `from` (enforced at parse time). + + Notes: + When constructing directly in Python, use `from_=...`; in JSON, use `"from"` as the key. + """ model_config = ConfigDict( - title="Move operation", + title="RFC 6902 Move Operation", json_schema_extra={"description": "RFC 6902 move operation."}, ) op: Literal["move"] = "move" - from_: JSONPointer[JSONValue] = Field(alias="from") - path: JSONPointer[JSONValue] + from_: JSONPointer[JSONValue] = Field( + alias="from", description="Location of the value to move." + ) + path: JSONPointer[JSONValue] = Field( + description="Destination for the moved value. Must not be a descendant of `from`." + ) @model_validator(mode="after") def _reject_proper_prefixes(self) -> Self: if self.from_.is_parent_of(self.path): - raise OperationValidationError( - "pointer 'path' cannot be a child of pointer 'from'" + raise PydanticCustomError( + "move_path_conflict", + "pointer 'path' cannot be a child of pointer 'from'", ) return self @override def apply(self, doc: JSONValue) -> JSONValue: + """Move the value at `from_` to `path` according to RFC 6902 semantics. + + Raises: + PatchConflictError: If the `from_` target does not exist, or `path` is not a valid destination. + """ value = self.from_.get(doc) doc = RemoveOp(path=self.from_).apply(doc) return AddOp(path=self.path, value=value).apply(doc) class CopyOp(OperationSchema): - """RFC 6902 copy operation.""" + """RFC 6902 copy operation. + + Notes: + When constructing directly in Python, use `from_=...`; in JSON, use `"from"` as the key. + """ model_config = ConfigDict( - title="Copy operation", + title="RFC 6902 Copy Operation", json_schema_extra={"description": "RFC 6902 copy operation."}, ) op: Literal["copy"] = "copy" - from_: JSONPointer[JSONValue] = Field(alias="from") - path: JSONPointer[JSONValue] + from_: JSONPointer[JSONValue] = Field( + alias="from", description="Location of the value to copy." + ) + path: JSONPointer[JSONValue] = Field( + description="Destination for the copied value." + ) @override def apply(self, doc: JSONValue) -> JSONValue: + """Copy the value at `from_` to `path` according to RFC 6902 semantics. + + Raises: + PatchConflictError: If the `from_` target does not exist, or `path` is not a valid destination. + """ value = self.from_.get(doc) duplicate = copy.deepcopy(value) return AddOp(path=self.path, value=duplicate).apply(doc) @@ -111,19 +172,26 @@ def apply(self, doc: JSONValue) -> JSONValue: class TestOp(OperationSchema): """RFC 6902 test operation.""" - __test__ = False # Suppress pytest warning + __test__: ClassVar[bool] = False # Suppress pytest warning model_config = ConfigDict( - title="Test operation", + title="RFC 6902 Test Operation", json_schema_extra={"description": "RFC 6902 test operation."}, ) op: Literal["test"] = "test" - path: JSONPointer[JSONValue] - value: JSONValue + path: JSONPointer[JSONValue] = Field(description="Location of the value to test.") + value: JSONValue = Field(description="Expected value.") @override def apply(self, doc: JSONValue) -> JSONValue: + """Test that `path` resolves to `value` according to RFC 6902 semantics. + + Raises: + PatchConflictError: If the target does not exist or the final token is `"-"` + (which does not address an existing element). + TestOpFailed: If the value at `path` does not equal `value`. + """ actual = self.path.get(doc) if actual != self.value: raise TestOpFailed( diff --git a/jsonpatchx/exceptions.py b/jsonpatchx/exceptions.py index e805ca7e..7854718b 100644 --- a/jsonpatchx/exceptions.py +++ b/jsonpatchx/exceptions.py @@ -1,185 +1,280 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: from jsonpatchx.schema import OperationSchema +# Exception hierarchy and HTTP error mapping: +# +# PatchError +# โ”œโ”€โ”€ InvalidPatchResult 422 โ€” patch applied, result fails validation +# โ”‚ (output invalid) +# โ”œโ”€โ”€ PatchConflictError 409 โ€” patch valid, document state rejects it +# โ”‚ โ””โ”€โ”€ TestOpFailed 409 โ€” RFC 6902 test op value mismatch +# โ””โ”€โ”€ PatchInternalError 500 โ€” unexpected exception during apply +# +# Every PatchError carries `index`/`operation`, identifying which operation in +# the patch document is implicated, when there is one. `_apply_ops` attaches +# both to any PatchError raised from inside an operation's own apply(), +# whether a built-in raise site or a custom operation; neither is settable at +# construction time, since any caller-supplied value would just be overwritten +# by `_apply_ops` anyway. They stay `None` when a failure is not attributable +# to one operation (for example, InvalidPatchResult raised from the final +# whole-document re-validation after every operation already applied without +# conflict). +# +# Non-PatchError exceptions that may surface: +# InvalidOperationDefinition(TypeError) Raised from __init_subclass__ at +# InvalidOperationRegistry(TypeError) class-definition time or registry- +# construction time (developer/config +# errors), never during op.apply(), so +# they never reach a running request. +# InvalidPatchTarget(TypeError) A supplied patch target/document is not +# valid: wrong model instance, or a +# document/model that isn't representable +# as JSON. Argument validation on the call +# that applies the patch, not a +# patch-domain failure, so it is not a +# PatchError. install_jsonpatch_error_ +# handlers registers a dedicated handler +# for it (500) alongside the PatchError +# handler. +# InvalidJSONPointer(ValueError) Raised inside Pydantic field validation +# InvalidJSONSelector(ValueError) (parsing a real patch document), pydantic +# wraps it in ValidationError (422) automatically +# because it is a ValueError. Raised during +# op.apply(), _apply_ops wraps it as +# PatchInternalError (500), same as any other +# unexpected exception. Raised anywhere else +# uncaught (e.g. calling .parse() directly), it +# is an ordinary unhandled ValueError (500). +# ValidationError (Pydantic) 422 โ€” FastAPI's own handler catches this +# TypeError 500 โ€” backend implementation bug (wrong type +# returned or incompatible backends mixed); +# _apply_ops wraps it as PatchInternalError + class PatchError(Exception): """ - Base class for JSON Patch errors. + Base class for application-time JSON Patch errors. This type is not raised directly; it anchors the error hierarchy for tooling and API error mapping. + + Attributes: + error_type: Stable, machine-readable identifier for this error kind, + independent of the Python class name. + index: 0-based index of the implicated operation in the patch + document, or `None` if this failure is not attributable to a + single operation. Not provided by callers; JsonPatchX sets it + once the failure is attributed to an operation. + operation: The implicated operation, or `None` under the same + condition as `index`. Not provided by callers; set under the + same condition as `index`. """ + error_type: ClassVar[str] = "patch_error" -class PatchInputError(PatchError): + def __init__(self, *args: object) -> None: + super().__init__(*args) + self.index: int | None = None + self.operation: OperationSchema | None = None + + +class InvalidPatchResult(PatchError): """ - Patch input is invalid or fails validation. + Applying a patch produced a result that fails validation. + + Every operation applied without conflict, but the resulting document is + unacceptable (for example, it violates the target model's schema). Custom + operations may also raise this directly to signal that an + otherwise-successful application produced an invalid result; in that case + `index`/`operation` identify the raising operation, unlike the + whole-document re-validation case, where they are `None`. + + Attributes: + errors: Structured validation errors (in the shape of + `pydantic.ValidationError.errors()`) when this was raised from a + wrapped `ValidationError`, or `None` when raised directly with no + underlying validation error. Examples: - - Invalid JSON Pointer syntax in an incoming operation. - - Operation-specific validation failure (e.g., swap parent/child paths). - - Model revalidation fails after applying a patch. + - Model-aware patching produces a document that violates the target model. Typical HTTP mapping: 422 Unprocessable Entity. """ + error_type: ClassVar[str] = "invalid_patch_result" -class InvalidOperationDefinition(PatchError): - """ - An OperationSchema definition is invalid (developer error). - - Examples: - - `op` is missing or not declared as `Literal[...]`. - - `op` is declared as a ClassVar, so it is not a model field. - """ + def __init__( + self, + *args: object, + errors: Sequence[Mapping[str, Any]] | None = None, + ) -> None: + super().__init__(*args) + self.errors = errors -class OperationValidationError(PatchInputError): +class PatchConflictError(PatchError): """ - An OperationSchema instance failed validation (client error). + A JSON Patch failed due to a conflict with the current document state. + + Custom operations may also raise this directly to signal that the + current document state does not satisfy a precondition the operation + depends on. Examples: - - Swap operation rejects parent/child pointers via a model validator. - - Operation fields violate custom constraints in validators. + - Path does not exist or array index is out of range. + - Removing a value at a missing or invalid path. Typical HTTP mapping: - 422 Unprocessable Entity. + 409 Conflict (some APIs may prefer 422). """ + error_type: ClassVar[str] = "patch_conflict" -class InvalidJSONPointer(PatchInputError): + +class TestOpFailed(PatchConflictError): """ - A JSON Pointer definition or instance is invalid. + A test operation failed (RFC 6902). - Examples: - - Pointer string is malformed or uses an incompatible backend. - - Pointer backend class fails protocol checks. + Custom operations that implement test-like, assert-a-value-then-continue + semantics may also raise this directly. Typical HTTP mapping: - 422 Unprocessable Entity for request input. + 409 Conflict (state mismatch). """ + error_type: ClassVar[str] = "test_op_failed" -class InvalidJSONSelector(PatchInputError): - """ - A JSON selector definition or instance is invalid. + __test__ = False - Examples: - - Selector string is malformed or uses an incompatible backend. - - Selector backend class fails protocol checks. - Typical HTTP mapping: - 422 Unprocessable Entity for request input. +class PatchInternalError(PatchError): """ + Unexpected exception during patch execution wrapped with structured context. + This is meant for API layers and debuggability: it always identifies the + exact operation index and payload, and preserves the original exception + as `__cause__`. -class InvalidOperationRegistry(PatchError): - """ - An OperationRegistry has incompatible OperationSchemas (developer error). + Attributes: + cause_type: The exception class name of the underlying cause. Examples: - - Duplicate `op` identifiers across schemas. - - Non-OperationSchema classes provided to the registry. + A ZeroDivisionError raised inside a custom op implementation that fails + to catch it. + + Typical HTTP mapping: + 500 Internal Server Error (unexpected failure). """ + error_type: ClassVar[str] = "patch_internal_error" -class OperationNotRecognized(PatchError): + def __init__( + self, + message: str, + *, + index: int, + operation: OperationSchema, + cause: BaseException, + ) -> None: + op_name = getattr(operation, "op") + formatted = f"Error applying op[{index}] ({op_name}): {message}" + super().__init__(formatted) + self.index = index + self.operation = operation + self.__cause__ = cause + + @property + def cause_type(self) -> str: + """The exception class name of the underlying cause.""" + return type(self.__cause__).__name__ + + +class InvalidOperationDefinition(TypeError): """ - An OperationSchema instance does not belong to the active registry. + An OperationSchema definition is invalid (developer error). Examples: - - Passing a StandardRegistry op instance into a custom registry. + - `op` is missing or not declared as `Literal[...]`. + - `op` is declared as a ClassVar, so it is not a model field. """ -class PatchConflictError(PatchError): +class InvalidOperationRegistry(TypeError): """ - A JSON Patch failed due to a conflict with the current document state. + An OperationRegistry has incompatible OperationSchemas (developer error). Examples: - - Path does not exist or array index is out of range. - - Removing a value at a missing or invalid path. - - Typical HTTP mapping: - 409 Conflict (some APIs may prefer 422). + - Duplicate `op` identifiers across schemas. + - Non-OperationSchema classes provided to the registry. """ -class PatchValidationError(PatchInputError): +class InvalidPatchTarget(TypeError): """ - Patched data failed validation against a target schema. + The value supplied as a patch target or document is not valid. + + This is argument validation on the call that applies the patch, not + something a patch document's content can trigger: the caller supplied a + value that cannot serve as a patch target or document. + + Attributes: + error_type: Stable, machine-readable identifier for this error kind. Examples: - - Model-aware patching produces a document that violates the target model. + - The document is not a valid JSON value (for example, a `datetime` or + a custom object). + - For model-aware patching, the target is not an instance of the + model the patch was bound to, even if it is otherwise a valid, + well-formed object. + - For model-aware patching, the target model's own `model_dump()` + produces non-JSON data. Typical HTTP mapping: - 422 Unprocessable Entity. + 500 Internal Server Error. """ + error_type: ClassVar[str] = "invalid_patch_target" -class TestOpFailed(PatchConflictError): - """ - A test operation failed (RFC 6902). - Typical HTTP mapping: - 409 Conflict (state mismatch). +class InvalidJSONPointer(ValueError): """ + A JSON Pointer definition or instance is invalid. - __test__ = False - + A custom pointer backend's own exception for an unparsable string is + normalized into this exception automatically; the original exception is + available as `__cause__`. -@dataclass(frozen=True, slots=True) -class PatchFailureDetail: - """ - Structured failure details for patch application. + Examples: + - Pointer string is malformed or uses an incompatible backend. + - Pointer backend class fails protocol checks. - Attributes: - index: 0-based index of the operation within the patch document. - op: Best-effort JSON-serializable representation of the failing operation. - For OperationSchema instances, this is model_dump(mode="json", by_alias=True). - For mapping-like inputs, this is dict(op). - As a last resort, {"repr": repr(op)}. - message: Human-readable error message. - cause_type: The exception class name of the underlying cause (useful for logging / API error mapping). + Typical HTTP mapping: + 422 Unprocessable Entity when raised during Pydantic field + validation of a patch document; a plain unhandled `ValueError` + (500 by default) anywhere else. """ - index: int - op: OperationSchema - message: str - cause_type: str | None = None - -class PatchInternalError(PatchError): +class InvalidJSONSelector(ValueError): """ - Unexpected exception during patch execution wrapped with structured context. + A JSON selector definition or instance is invalid. - This is meant for API layers and debuggability: - - points at the exact op index - - includes the full op payload (best-effort JSON shape) + A custom selector backend's own exception for an unparsable string is + normalized into this exception automatically; the original exception is + available as `__cause__`. Examples: - A ZeroDivisionError raised inside a custom op implementation that fails - to catch it. + - Selector string is malformed or uses an incompatible backend. + - Selector backend class fails protocol checks. Typical HTTP mapping: - 500 Internal Server Error (unexpected failure). + 422 Unprocessable Entity when raised during Pydantic field + validation of a patch document; a plain unhandled `ValueError` + (500 by default) anywhere else. """ - - def __init__( - self, detail: PatchFailureDetail, *, cause: BaseException | None = None - ): - self.detail = detail - super().__init__(self._format(detail)) - if cause is not None: - self.__cause__ = cause - - @staticmethod - def _format(d: PatchFailureDetail) -> str: - op_name = getattr(d.op, "op") - return f"Error applying op[{d.index}] ({op_name}): {d.message}" diff --git a/jsonpatchx/fastapi.py b/jsonpatchx/fastapi.py index d6cc6a6e..b973cc9f 100644 --- a/jsonpatchx/fastapi.py +++ b/jsonpatchx/fastapi.py @@ -1,13 +1,3 @@ -""" -FastAPI integration helpers for jsonpatch. - -Default error mapping: -- 415: Wrong Content-Type for JSON Patch (application/json-patch+json) -- 422: Request validation errors (malformed JSON, invalid operationns or pointers, model revalidation failure) -- 409: Patch is valid but cannot be applied to current resource state -- 500: Server misconfiguration or unexpected failures (e.g., invalid registry/op classes) -""" - from __future__ import annotations from dataclasses import dataclass @@ -20,9 +10,10 @@ from pydantic import BaseModel from jsonpatchx.exceptions import ( + InvalidPatchResult, + InvalidPatchTarget, PatchConflictError, PatchError, - PatchInputError, PatchInternalError, ) from jsonpatchx.pydantic import JsonPatchFor @@ -30,26 +21,33 @@ JSON_PATCH_MEDIA_TYPE = "application/json-patch+json" -class PatchFailureDetailResponse(BaseModel): - index: int - op: dict[str, Any] - message: str - cause_type: str | None = None - - class PatchErrorResponse(BaseModel): - detail: str | PatchFailureDetailResponse + type: str + detail: str + index: int | None = None + operation: dict[str, Any] | None = None + errors: list[dict[str, Any]] | None = None # Public helpers def install_jsonpatch_error_handlers(app: FastAPI) -> None: - """Register a FastAPI exception handler for `PatchError`. + """Register FastAPI exception handlers for `PatchError` and `InvalidPatchTarget`. Arguments: app: The FastAPI application to configure. + Notes: + Default error mapping: + + | Status | Cause | + |--------|-------| + | 415 | Wrong Content-Type for JSON Patch (`application/json-patch+json`); response includes an `Accept-Patch` header naming the expected media type | + | 422 | Request validation errors (malformed JSON, invalid operations or pointers, model revalidation failure) | + | 409 | Patch is valid but cannot be applied to current resource state | + | 500 | Unexpected execution failures (`PatchInternalError`) or a bad patch target (`InvalidPatchTarget`) | + Examples: app = FastAPI() @@ -60,6 +58,17 @@ def install_jsonpatch_error_handlers(app: FastAPI) -> None: def _patch_error_handler(request: Request, exc: PatchError) -> JSONResponse: return _patch_error_response_map(exc) + @app.exception_handler(InvalidPatchTarget) + def _invalid_patch_target_handler( + request: Request, exc: InvalidPatchTarget + ) -> JSONResponse: + return JSONResponse( + status_code=500, + content=PatchErrorResponse( + type=InvalidPatchTarget.error_type, detail=str(exc) + ).model_dump(), + ) + def patch_error_openapi_responses() -> dict[int | str, dict[str, Any]]: """Return OpenAPI response schema entries for JSON Patch errors. @@ -74,27 +83,7 @@ def patch_error_openapi_responses() -> dict[int | str, dict[str, Any]]: def patch_item(...): ... """ - patch_error_schema = { - "type": "object", - "properties": { - "detail": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "index": {"type": "integer"}, - "op": {"type": "object"}, - "message": {"type": "string"}, - "cause_type": {"type": ["string", "null"]}, - }, - "required": ["index", "op", "message"], - }, - ] - } - }, - "required": ["detail"], - } + patch_error_schema = PatchErrorResponse.model_json_schema() validation_schema = {"$ref": "#/components/schemas/HTTPValidationError"} validation_or_patch_schema = { "oneOf": [ @@ -294,31 +283,68 @@ def Body(self) -> BodyParam: def _patch_error_response_map(exc: PatchError) -> JSONResponse: - """Map a PatchError to a JSONResponse for FastAPI exception handlers.""" + """Map a PatchError to a JSONResponse for FastAPI exception handlers. + + Expected mappings: + - InvalidPatchResult -> 422 (patched result fails the target model + schema; a content/schema problem, not a resource-state conflict) + - PatchConflictError, TestOpFailed -> 409 + - PatchInternalError, unrecognized PatchError -> 500 + + TODO: Integration tests in tests/integration/fastapi/runtime/ should cover + each failure path and assert both the HTTP status code and the + exception that triggered it, ideally with match="..." on the error + message. Once user-facing error messages are stabilized, assert their + content too. + """ + operation_payload = ( + exc.operation.model_dump(mode="json", by_alias=True) + if exc.operation is not None + else None + ) + if isinstance(exc, PatchInternalError): - detail = exc.detail - payload = PatchFailureDetailResponse( - index=detail.index, - op=detail.op.model_dump(mode="json", by_alias=True), - message=detail.message, - cause_type=detail.cause_type, - ) return JSONResponse( - status_code=500, content=PatchErrorResponse(detail=payload).model_dump() + status_code=500, + content=PatchErrorResponse( + type=exc.error_type, + detail=str(exc), + index=exc.index, + operation=operation_payload, + ).model_dump(), ) - if isinstance(exc, PatchInputError): + if isinstance(exc, InvalidPatchResult): return JSONResponse( - status_code=422, content=PatchErrorResponse(detail=str(exc)).model_dump() + status_code=422, + content=PatchErrorResponse( + type=exc.error_type, + detail=str(exc), + index=exc.index, + operation=operation_payload, + errors=exc.errors, + ).model_dump(), ) if isinstance(exc, PatchConflictError): return JSONResponse( - status_code=409, content=PatchErrorResponse(detail=str(exc)).model_dump() + status_code=409, + content=PatchErrorResponse( + type=exc.error_type, + detail=str(exc), + index=exc.index, + operation=operation_payload, + ).model_dump(), ) return JSONResponse( - status_code=500, content=PatchErrorResponse(detail=str(exc)).model_dump() + status_code=500, + content=PatchErrorResponse( + type=exc.error_type, + detail=str(exc), + index=exc.index, + operation=operation_payload, + ).model_dump(), ) @@ -332,4 +358,5 @@ def _enforce_json_patch_content_type( detail=( f"Unsupported Media Type. Use {media_type} for JSON Patch requests." ), + headers={"Accept-Patch": media_type}, ) diff --git a/jsonpatchx/pointer.py b/jsonpatchx/pointer.py index c590e132..4a4ab255 100644 --- a/jsonpatchx/pointer.py +++ b/jsonpatchx/pointer.py @@ -20,7 +20,6 @@ ValidationError, ) from pydantic.json_schema import JsonSchemaValue -from pydantic_core import MISSING from pydantic_core import core_schema as cs from typing_extensions import TypeForm, TypeVar @@ -51,7 +50,6 @@ ) _Nothing = object() -# NOTE: maybe add pydantic_core.MISSING to JSONPointer.get() on failure T_co = TypeVar("T_co", bound=JSONBound, covariant=True) @@ -67,57 +65,52 @@ class JSONPointer(str, Generic[T_co, P_co]): """ A typed RFC 6901 JSON Pointer with Pydantic integration. - `JSONPointer[T]` (or `JSONPointer[T, Backend]`) is a string-like value (subclasses `str`) - that additionally: - - - stores a parsed pointer backend (see `PointerBackend`), - - tracks a covariant type parameter `T` used to validate resolved targets, - - provides convenience methods used by patch operations: `get`, `add`, `remove`. - - ## Important design semantics (intentional) - - **Typed pointers are enforced at runtime.** - The type parameter `T` is not โ€œjust typingโ€; it is enforced whenever a value is read - through the pointer. - - - `get(doc)` always validates the resolved value against `T`. - - `add(doc, value)` optionally validates the written value against `T` (default: True). - - `remove(doc)` is intentionally *type-gated*: it first โ€œreadsโ€ the target through the pointer, - so removal can fail if the current value is not of type `T`. - - This makes patch semantics explicit: - - `JSONPointer[JSONValue]` is permissive (โ€œremove anything JSONโ€). - - `JSONPointer[JSONBoolean]` is restrictive (โ€œremove only if it is currently a booleanโ€). - - If you want to remove regardless of the current type, use a wider pointer type (e.g. `JSONValue`) - or define a dedicated permissive remove operation. - - **Pointer covariance is intentional.** - `JSONPointer` is covariant in `T`. In practice this means you can often reuse a pointer instance - (including across composed operations) and preserve stricter guarantees. - Examples: if a custom op carries a `JSONPointer[JSONBoolean]`, composing that op internally - using `AddOp` should keep the boolean-specific enforcement at runtime. - - ## Backend semantics (advanced) - - - Default backend: `jsonpointer.JsonPointer`. - - Custom backend: bound directly via `JSONPointer[T, Backend]`. - - Invalid pointer strings raise `InvalidJSONPointer`. - - Backend traversal failures in `get`/`add`/`remove` are normalized into - `PatchConflictError`. - - Mutation semantics: - - `add` and `remove` may mutate the document object they are given (or containers reachable - from it). The root pointer `""` is the exception: setting the root returns a new document - value rather than mutating an existing container. Removing the root returns - `MISSING` to represent document deletion rather than a JSON `null` value. - A missing root document is handled as its own state: root `get` and root `remove` - fail, while root `add` recreates the document. - If you want to forbid root removal, it's easy to make a custom op! - - Whether these mutations affect the original caller-owned document is determined by the patch - engine (see `_apply_ops(..., inplace=...)`), which may deep-copy the input document. - - `JSONPointer` values are intended to be created by Pydantic validation. Direct instantiation - is not permitted (except when running as `__main__` for debugging). + `JSONPointer[T]` (or `JSONPointer[T, Backend]`) is a `str` subclass that + parses a pointer string at validation time and enforces the type parameter `T` + when a value is read or written through it. The string value is the pointer + path itself, so `JSONPointer` is accepted wherever `str` is. + + Example: + Use it as a field type in `OperationSchema` subclasses: + + ```python + from typing import Literal + from jsonpatchx import JSONPointer, OperationSchema, ReplaceOp, JsonPatch, JSONValue, JSONBoolean + + class ToggleBoolOp(OperationSchema): + op: Literal[โ€œtoggleโ€] = โ€œtoggleโ€ + path: JSONPointer[JSONBoolean] + + def apply(self, doc: JSONValue) -> JSONValue: + toggled = not self.path.get(doc) + return ReplaceOp(path=self.path, value=toggled).apply(doc) + + ops = [ToggleBoolOp(path=โ€/flagโ€)] + patch = JsonPatch(ops) + doc = {โ€œflagโ€: False} + patched_doc = patch.apply(doc) + assert patched_doc == {โ€œflagโ€: True} + ``` + + Example: + Plug in a custom pointer backend for non-RFC 6901 syntax: + + ```python + from jsonpatchx import JSONPointer, JSONBoolean, PointerBackend + + class DotPointer(PointerBackend): + ... # implement the PointerBackend protocol + + class ToggleBoolOp(OperationSchema): + op: Literal[โ€œtoggleโ€] = โ€œtoggleโ€ + path: JSONPointer[JSONBoolean, DotPointer] + + op = ToggleBoolOp(path=โ€player.isAliveโ€) + ``` + + Notes: + Instances are produced by Pydantic validation. If you need direct construction, + use `JSONPointer.parse()`. """ # Choice: JSONPointer is str subclass, as opposed to Annotated[str, StringConstraints(...)]. @@ -141,8 +134,7 @@ def ptr(self) -> P_co: """ The underlying pointer backend instance. - This is exposed for advanced users who provide a custom PointerBackend with additional APIs. - The patch engine relies only on the `PointerBackend` protocol. + This is exposed for advanced users who provide a custom PointerBackend implementation with additional APIs. """ # TODO: Somehow 'Any' to the actual JSON Pointer class they pass in. # Choice: expose ptr as the user's custom PointerBackend for stronger type inferencing. @@ -152,26 +144,36 @@ def ptr(self) -> P_co: @property def parts(self) -> Sequence[str]: - """A sequence of RFC6901-unescaped pointer components.""" + """A sequence of unescaped pointer components.""" return self._ptr.parts @property def type_param(self) -> TypeForm[T_co]: - """The expected type parameter `T` used to validate resolved targets.""" + """The type parameter `T` used to validate reads and writes.""" return self._type @property def _adapter(self) -> TypeAdapter[T_co]: - """Return the cached Pydantic adapter used for strict `T` validation.""" + """The cached Pydantic adapter used for strict `T` validation.""" return _cached_adapter(self._type) @property def parent_ptr(self) -> P_co: # NOTE: add parent property for JSONPointer of parent - """Return the backend pointer for this pointer's parent path.""" + """The underlying pointer instance for this pointer's parent path. Exposed for advanced users along with `ptr`.""" return _parent_ptr_of(self._ptr) + # doc is required because custom backends may not use "" as the root token; + # the backend itself determines what counts as root given the document. def is_root(self, doc: JSONValue) -> bool: - """Check whether this JSONPointer's target is the root.""" + """ + Check whether this pointer resolves to the document root. + + Arguments: + doc: JSON document to resolve against. + + Returns: + `True` if this pointer resolves to the root of `doc`, `False` otherwise. + """ return _is_root_ptr(self._ptr, doc) @classmethod @@ -195,10 +197,12 @@ def _validator( A `JSONPointer` bound to the resolved backend and type parameter. Raises: + TypeError: If the backend TypeVar cannot be resolved to a concrete + backend. InvalidJSONPointer: If an existing pointer/backend instance cannot be rebound to the required backend. """ - resolved_backend = cls._resolve_runtime_backend_param(concrete_backend) + resolved_backend = cls._finalize_backend(concrete_backend) ptr: PointerBackend if isinstance(path, JSONPointer): path_str = str(path) @@ -245,6 +249,21 @@ def _validator( def __get_pydantic_core_schema__( cls, source_type: type[Self], handler: GetCoreSchemaHandler ) -> cs.CoreSchema: + """Build the Pydantic core schema for `JSONPointer` validation. + + Arguments: + source_type: The specialized `JSONPointer[T, Backend]` type being built. + handler: Pydantic's schema generation handler. + + Returns: + A Pydantic core schema that validates strings, `JSONPointer` instances, and + `PointerBackend` instances into a `JSONPointer` bound to `T` and the backend. + + Raises: + TypeError: If no type parameters are supplied, if the backend + parameter is not a class or `TypeVar`, or if the type parameter + is not a valid TypeForm. + """ type_param, concrete_backend = cls._parse_pointer_type_args( *get_args(source_type) ) @@ -272,11 +291,24 @@ def __get_pydantic_core_schema__( def __get_pydantic_json_schema__( cls, schema: cs.CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: + """Build the JSON Schema representation for `JSONPointer`. + Arguments: + schema: The Pydantic core schema produced by `__get_pydantic_core_schema__`. + handler: Pydantic's JSON schema generation handler. + + Returns: + A JSON Schema dict describing a JSON Pointer string, enriched with + `x-pointer-type-schema` for the declared type parameter. + + Raises: + TypeError: If the backend TypeVar cannot be resolved, or if the + type parameter cannot be adapted. + """ pointer_backend: type[PointerBackend] pointer_backend_param = schema.get("metadata", {}).get("pointer_backend_param") if isinstance(pointer_backend_param, TypeVar): - pointer_backend = cls._resolve_runtime_backend_param(pointer_backend_param) + pointer_backend = cls._finalize_backend(pointer_backend_param) else: pointer_backend = pointer_backend_param @@ -305,23 +337,80 @@ def __get_pydantic_json_schema__( def _parse_pointer_type_args( cls, *args: TypeForm[Any] ) -> tuple[TypeForm[Any], type[PointerBackend] | TypeVar]: - """Validate the JSONPointer's parameter tuple, e.g. `(JSONValue, DotPointer)` for `JSONPointer[JSONValue, DotPointer]`.""" + """Validate and unpack the `JSONPointer[T, Backend]` parameter tuple. + + Arguments: + *args: Generic type arguments from `get_args(source_type)`, e.g. + `(JSONValue, DotPointer)` for `JSONPointer[JSONValue, DotPointer]`. + + Returns: + A tuple of (type_param, backend_param): type_param is a validated TypeForm + and backend_param is a concrete backend class or TypeVar. + + Raises: + TypeError: If no type parameters are supplied, if the backend + parameter is not a class or `TypeVar`, or if the type parameter + is not a valid TypeForm. + """ if not args: raise TypeError(f"{cls} requires at least one type parameter") unverified_typeform = args[0] unverified_bound_backend = args[1] if len(args) > 1 else DEFAULT_POINTER_CLS - backend_param = cls._resolve_backend_type_param(unverified_bound_backend) - type_param = _validate_typeform(unverified_typeform, InvalidJSONPointer) + backend_param = cls._validate_backend_arg(unverified_bound_backend) + type_param = _validate_typeform(unverified_typeform) return type_param, backend_param - @staticmethod - def _resolve_backend_type_param( + @classmethod + def _validate_concrete_backend_class( + cls, + candidate: object, + ) -> type[PointerBackend]: + """ + Validate that `candidate` is usable as a concrete backend class. + + This is the single canonical place that decides whether a class is + admissible as a `PointerBackend`, independent of whether the caller + received it as a direct generic argument or as an already-resolved + `TypeVar` default. It cannot verify actual protocol conformance + (`@runtime_checkable` can only check that on an instance, not a + class); `_pointer_backend_instance` checks that once a concrete + instance exists. + + Arguments: + candidate: A value expected to be a non-abstract class. + + Returns: + The validated backend class. + + Raises: + TypeError: If `candidate` is not a class, or is an abstract class. + """ + if not isinstance(candidate, type): + raise TypeError( + f"JSONPointer backend parameter {candidate!r} must be a class or TypeVar" + ) + if isabstract(candidate): + raise TypeError( + f"JSONPointer backend parameter {candidate!r} is abstract and cannot be used as a backend" + ) + return cast(type[PointerBackend], candidate) + + @classmethod + def _validate_backend_arg( + cls, backend_param: object, ) -> type[PointerBackend] | TypeVar: """ - Validate the backend generic argument before runtime resolution. + Schema-declaration-time: validate the raw backend generic argument. + + Runs once, when `JSONPointer[T, Backend]` is built, or when + `.parse()` is called directly with a `backend=` argument, before any + value has been validated. A `TypeVar` passes through unresolved + here: `TypeVar` defaults are only resolved later, per validation + call, by `_finalize_backend`. A concrete class is validated via + `_validate_concrete_backend_class`. Arguments: backend_param: Raw second generic argument from @@ -331,46 +420,54 @@ def _resolve_backend_type_param( A backend class or unresolved `TypeVar`. Raises: - InvalidJSONPointer: If the backend argument is neither a class nor - a `TypeVar`. + TypeError: If the backend argument is neither a class nor a + `TypeVar`, or if the backend class is abstract. """ if isinstance(backend_param, TypeVar): return backend_param - if not isinstance(backend_param, type): - raise InvalidJSONPointer( - f"JSONPointer backend parameter {backend_param!r} must be a class or TypeVar" - ) - return cast(type[PointerBackend], backend_param) + return cls._validate_concrete_backend_class(backend_param) @classmethod - def _resolve_runtime_backend_param( + def _finalize_backend( cls, backend_param: type[PointerBackend] | TypeVar, ) -> type[PointerBackend]: """ - Resolve a backend parameter to a concrete runtime backend class. + Validation-time: turn a schema-time backend parameter into a + concrete class. + + Called every time a value is validated (from `_validator`) or the + schema is introspected (from `__get_pydantic_json_schema__`), using + whatever `_validate_backend_arg` produced at schema-declaration + time. A concrete class is returned unchanged; an unresolved + `TypeVar` is resolved now via `_finalize_backend_typevar`. Arguments: - backend_param: Backend class or backend `TypeVar`. + backend_param: Backend class or backend `TypeVar`, as returned + by `_validate_backend_arg`. Returns: A concrete `PointerBackend` class. Raises: - InvalidJSONPointer: If an unspecialized backend `TypeVar` cannot be - resolved to a concrete default backend. + TypeError: If an unspecialized backend `TypeVar` cannot be resolved + to a concrete default backend. """ if not isinstance(backend_param, TypeVar): return backend_param - return cls._resolve_runtime_backend_typevar(backend_param) + return cls._finalize_backend_typevar(backend_param) @classmethod - def _resolve_runtime_backend_typevar( + def _finalize_backend_typevar( cls, backend_typevar: TypeVar, ) -> type[PointerBackend]: """ - Resolve an unspecialized backend `TypeVar` using its default. + Validation-time: resolve an unspecialized backend `TypeVar` via its + default. + + Called by `_finalize_backend` when the backend generic argument was + left as an unresolved `TypeVar` by `_validate_backend_arg`. Arguments: backend_typevar: Backend `TypeVar` from the generic parameter list. @@ -379,50 +476,58 @@ def _resolve_runtime_backend_typevar( A concrete `PointerBackend` class. Raises: - InvalidJSONPointer: If the `TypeVar` has no usable default backend. + TypeError: If the `TypeVar` has no default at all, or if its + default fails `_validate_concrete_backend_class`'s + validation (not a class, or abstract). """ # Only TypeVar defaults are used for unspecialized backend TypeVars. try: has_default = backend_typevar.has_default() except AttributeError: # Py3.12 has_default = False - if has_default: - default_candidate = getattr(backend_typevar, "__default__") - default_backend = cls._coerce_runtime_backend_candidate(default_candidate) - if default_backend is not None: - return default_backend - - raise InvalidJSONPointer( - "JSONPointer backend TypeVar must define a default backend " - "or be specialized with a concrete backend type" - ) + if not has_default: + raise TypeError( + "JSONPointer backend TypeVar must define a default backend " + "or be specialized with a concrete backend type" + ) + default_candidate = getattr(backend_typevar, "__default__") + return cls._finalize_backend_default(default_candidate) @classmethod - def _coerce_runtime_backend_candidate( + def _finalize_backend_default( cls, candidate: object, - ) -> type[PointerBackend] | None: + ) -> type[PointerBackend]: """ - Coerce a potential default backend candidate into a usable class. + Validation-time: resolve a `TypeVar`'s raw default value into a + usable class. + + Called by `_finalize_backend_typevar` once it has confirmed a + default exists. The default itself may be a nested `TypeVar` + (recurse into `_finalize_backend_typevar`) or a concrete class + (validated via `_validate_concrete_backend_class`, so a bad default + raises the same specific error as a bad direct or + schema-declaration-time argument, rather than a separate, less + specific one). Arguments: candidate: Runtime object drawn from a backend `TypeVar` default. Returns: - A concrete `PointerBackend` class, or `None` if the candidate is - not usable as a runtime backend. + A concrete `PointerBackend` class. + + Raises: + TypeError: If `candidate` is a `TypeVar` with no usable default, + or if it fails `_validate_concrete_backend_class`'s + validation (not a class, or abstract). """ if isinstance(candidate, TypeVar): - return cls._resolve_runtime_backend_typevar(candidate) - if not isinstance(candidate, type): - return None - if candidate is PointerBackend or isabstract(candidate): - return None - return candidate + return cls._finalize_backend_typevar(candidate) + return cls._validate_concrete_backend_class(candidate) def _validate_target(self, target: object) -> T_co: """ - Validate a resolved or replacement value against this pointer's type. + Validate a resolved target value against this pointer's type. Arguments: target: Candidate value to validate strictly against `T`. @@ -466,7 +571,7 @@ def _validate_replacement(self, value: object) -> JSONValue: @classmethod def parse( cls, - path: str | Self | PointerBackend, + path: str | PointerBackend, *, backend: type[P_parse] | None = None, ) -> "JSONPointer[JSONValue, P_parse]": ... @@ -475,7 +580,7 @@ def parse( @classmethod def parse( cls, - path: str | Self | PointerBackend, + path: str | PointerBackend, *, type_param: TypeForm[T_parse], backend: type[P_parse] | None = None, @@ -484,36 +589,40 @@ def parse( @classmethod def parse( cls, - path: str | Self | PointerBackend, + path: str | PointerBackend, *, type_param: TypeForm[Any] | object = _Nothing, backend: type[PointerBackend] | None = None, ) -> "JSONPointer[Any, PointerBackend]": """ - Parse a pointer string or instance using Pydantic validation. + Construct a JSONPointer instance directly, outside of Pydantic models. Arguments: - path: Pointer string, parsed pointer, or pointer backend instance. - type_param: Type enforced when the pointer is exercised. - backend: Optional concrete backend class. When omitted, the built-in - RFC 6901 backend is used. + path: A pointer string, parsed pointer, or pointer backend instance. + type_param: The type that is enforced on reads and writes. + backend: The backend pointer implementation. If `None`, defaults to `DEFAULT_POINTER_CLS` (RFC 6901). Returns: - A validated `JSONPointer` instance. + pointer: A validated `JSONPointer` instance. Raises: - InvalidJSONPointer: If the pointer string, backend, or generic - parameters are invalid. + TypeError: If the `backend` argument is not a class or `TypeVar`, + if a backend `TypeVar` cannot be resolved to a concrete backend, + if the type parameter is not a valid TypeForm, or if `path` is + not a `str`, `JSONPointer`, or `PointerBackend` instance. + InvalidJSONPointer: If `path` is not a valid pointer string. + + ??? Acknowledment + The `type_param` argument places the covariant type parameter `T` in an input position, + which is technically unsound. But the intended use case of this classmethod is testing + and ad-hoc pointer construction, where the ergonomics of direct construction outweigh + the theoretical unsoundness. + """ + if not isinstance(path, (str, PointerBackend)): + raise TypeError( + f"path must be a str or a PointerBackend instance; got {type(path).__name__!r}" + ) - Notes: - `type_param` technically places the covariant `T` parameter in an - input position, which would normally be an unsound public API - shape. That tradeoff is intentional here because `parse()` is only - a convenience constructor around Pydantic validation. Normal - construction happens through Pydantic on an already-specialized - `JSONPointer[...]` type, so callers are not meant to treat - `parse()` as the primary semantic surface for consuming `T`. - """ resolved_type_param = ( JSONValue if type_param is _Nothing else cast(TypeForm[Any], type_param) ) @@ -533,7 +642,10 @@ def parse( adapter = _cached_adapter( JSONPointer[validated_type, validated_backend] # type: ignore[valid-type] ) - return adapter.validate_python(path) + try: + return adapter.validate_python(path) + except ValidationError as e: + raise InvalidJSONPointer(f"Invalid pointer: {e}") from e # Parse-time helpers @@ -541,19 +653,32 @@ def is_parent_of(self, other: str) -> bool: """ Check whether this pointer is a strict parent of `other`. - `other` may be a JSONPointer or a pointer string; strings are parsed using this pointer's syntax. + Arguments: + other: A `JSONPointer` or pointer string. Strings are parsed using + this pointer's backend syntax. - Root is treated as a parent of all paths except itself. + Returns: + `True` if this pointer is a strict prefix of `other`, `False` otherwise. + Root is a parent of all paths except itself. - Raises InvalidJSONPointer if comparison is called with an `other` pointer with different or invalid syntax. + Raises: + TypeError: If `other` is a `JSONPointer` with an incompatible backend, + or a string that cannot be parsed with this pointer's backend. """ if isinstance(other, JSONPointer) and not isinstance( other._ptr, type(self._ptr) ): - raise InvalidJSONPointer( - f"Other pointer {other._ptr!r} has incompatible syntax with {self!r}" + raise TypeError( + f"Other pointer {other._ptr!r} has incompatible backend with {self!r}" + ) + try: + other_ptr = _pointer_backend_instance( + other, pointer_cls=self._ptr.__class__ ) - other_ptr = _pointer_backend_instance(other, pointer_cls=self._ptr.__class__) + except InvalidJSONPointer as e: + raise TypeError( + f"Invalid pointer string for {self._ptr.__class__.__name__}: {e}" + ) from e # Strict parentage only if self == str(other_ptr): @@ -565,20 +690,32 @@ def is_child_of(self, other: str) -> bool: """ Check whether this pointer is a strict child of `other`. - `other` may be a JSONPointer or a pointer string; strings are parsed using this pointer's syntax. + Arguments: + other: A `JSONPointer` or pointer string. Strings are parsed using + this pointer's backend syntax. - Root is treated as a parent of all paths except itself. + Returns: + `True` if `other` is a strict prefix of this pointer, `False` otherwise. + Root is a parent of all paths except itself. - Raises InvalidJSONPointer if comparison is called with an `other` pointer with different or invalid syntax. + Raises: + TypeError: If `other` is a `JSONPointer` with an incompatible backend, + or a string that cannot be parsed with this pointer's backend. """ - # NOTE: Document which of these public helper methods work only with RFC6901 if isinstance(other, JSONPointer) and not isinstance( other._ptr, type(self._ptr) ): - raise InvalidJSONPointer( - f"Other pointer {other._ptr!r} has incompatible syntax with {self!r}" + raise TypeError( + f"Other pointer {other._ptr!r} has incompatible backend with {self!r}" + ) + try: + other_ptr = _pointer_backend_instance( + other, pointer_cls=self._ptr.__class__ ) - other_ptr = _pointer_backend_instance(other, pointer_cls=self._ptr.__class__) + except InvalidJSONPointer as e: + raise TypeError( + f"Invalid pointer string for {self._ptr.__class__.__name__}: {e}" + ) from e # Strict parentage only if self == str(other_ptr): @@ -590,14 +727,13 @@ def is_child_of(self, other: str) -> bool: def is_valid_type(self, target: object) -> bool: """ - Return `True` if `target` conforms to this pointer's type. + Check whether `target` conforms to this pointer's type parameter `T`. Arguments: target: Candidate value to validate. Returns: - `True` when `target` validates strictly against `T`, - otherwise `False`. + `True` if `target` validates strictly against `T`, `False` otherwise. """ try: self._adapter.validate_python(target, strict=True) @@ -610,7 +746,7 @@ def get(self, doc: JSONValue) -> T_co: Resolve this pointer against `doc` and return the target value (type-gated). Arguments: - doc: Target JSON document. + doc: JSON document to resolve against. Returns: The resolved value, validated against `T`. @@ -618,9 +754,6 @@ def get(self, doc: JSONValue) -> T_co: Raises: PatchConflictError: If the target does not exist, or it is not type `T`. """ - if classify_state(self._ptr, doc) is TargetState.MISSING: - raise PatchConflictError(f"target {str(self)!r} does not exist") - # Choice: always defer to the PointerBackend implementation for pointer resolution. # Why: Don't reinvent the wheel (and maintain it). Plus, give more power to custom PointerBackends. try: @@ -632,14 +765,13 @@ def get(self, doc: JSONValue) -> T_co: def is_gettable(self, doc: JSONValue) -> bool: """ - Return `True` if `get(doc)` would succeed. + Check whether `get(doc)` would succeed. Arguments: - doc: Target JSON document. + doc: JSON document to resolve against. Returns: - `True` if the pointer resolves to an existing value of type - `T`, otherwise `False`. + `True` if the pointer resolves to an existing value of type `T`, `False` otherwise. """ try: self.get(doc) @@ -653,21 +785,23 @@ def add(self, doc: JSONValue, value: object) -> JSONValue: RFC 6902 add (type-gated). Arguments: - doc: Target JSON document. - value: Value to add at this path, validated against `T`. + doc: JSON document to add to. + value: Value to add at this path. Must conform to `T` and to `JSONValue`. Returns: - The updated document. + The updated JSON document. Raises: PatchConflictError: If the target does not exist, if the target is not type `T`, or if the value being added is not type `T`. + + Notes: + May modify `doc` in place; always use the return value. Root-targeting + operations replace the document and return the new root rather than mutating it. """ target = self._validate_replacement(value) match classify_state(self._ptr, doc): - case TargetState.MISSING: - return target case TargetState.ROOT: self._validate_target(doc) return target @@ -718,15 +852,15 @@ def is_addable( value: object = _Nothing, ) -> bool: """ - Return `True` if RFC 6902 `add` would succeed for this document. + Check whether RFC 6902 `add` would succeed for this document. Arguments: - doc: Target JSON document. + doc: JSON document to add to. value: Optional value that would be written at this pointer. When provided, it must conform to `T` and to `JSONValue`. Returns: - `True` if add semantics would succeed, otherwise `False`. + `True` if add semantics would succeed, `False` otherwise. """ if value is not _Nothing: try: @@ -735,8 +869,6 @@ def is_addable( return False match classify_state(self._ptr, doc): - case TargetState.MISSING: - return True case TargetState.ROOT: return self.is_valid_type(doc) case TargetState.VALUE_PRESENT: @@ -756,25 +888,24 @@ def is_addable( def remove(self, doc: JSONValue) -> JSONValue: """ - RFC 6902 remove (type-gated). Removal of the root returns `MISSING`. + RFC 6902 remove (type-gated). Arguments: - doc: Target JSON document. + doc: JSON document to remove from. Returns: - The updated document. + The updated JSON document. Raises: PatchConflictError: If the target does not exist, or it is not type `T`. + + Notes: + Modifies `doc` in place; always use the return value. """ match classify_state(self._ptr, doc): - case TargetState.MISSING: - raise PatchConflictError(f"target {str(self)!r} does not exist") case TargetState.ROOT: - # Choice: Removal of root returns MISSING. - # Why: Root removal is document deletion, not replacement with JSON null. self._validate_target(doc) - return cast(JSONValue, MISSING) + raise PatchConflictError("cannot delete the document") case TargetState.PARENT_NOT_FOUND: raise PatchConflictError( f"cannot remove value at {str(self)!r} because parent does not exist" @@ -814,18 +945,19 @@ def remove(self, doc: JSONValue) -> JSONValue: def is_removable(self, doc: JSONValue) -> bool: """ - Return `True` if RFC 6902 `remove` would succeed for this document. + Check whether RFC 6902 `remove` would succeed for this document. Arguments: - doc: Target JSON document. + doc: JSON document to remove from. Returns: - `True` if remove semantics would succeed, otherwise `False`. + `True` if remove semantics would succeed, `False` otherwise. """ - return self.is_gettable(doc) + return self.is_gettable(doc) and not self.is_root(doc) @override def __repr__(self) -> str: + """Return `ClassName[T]('pointer_string')` representation.""" type_repr = ( self._type.__name__ if isinstance(self._type, type) else repr(self._type) ) diff --git a/jsonpatchx/pydantic.py b/jsonpatchx/pydantic.py index 1fa05286..1c6252d7 100644 --- a/jsonpatchx/pydantic.py +++ b/jsonpatchx/pydantic.py @@ -27,7 +27,7 @@ from pydantic_core import PydanticUndefined, PydanticUndefinedType from typing_extensions import TypeVar -from jsonpatchx.exceptions import PatchValidationError +from jsonpatchx.exceptions import InvalidPatchResult, InvalidPatchTarget from jsonpatchx.registry import StandardRegistry, _RegistrySpec from jsonpatchx.schema import OperationSchema, _apply_ops from jsonpatchx.types import JSONValue, _validate_JSONValue @@ -88,28 +88,33 @@ def apply(self, target: ModelT) -> ModelT: The patched model instance after re-validation. Raises: - TypeError: If `target` is not an instance of the bound model type. - PatchValidationError: If the model dump is not valid JSON or the - patched output fails validation against the model schema. + InvalidPatchTarget: If `target` is not an instance of the bound model type, + or if the model dump is not a valid JSON document. + InvalidPatchResult: If the patched output fails validation against + the model schema. `errors` carries the underlying + `ValidationError.errors()`; `index`/`op` are `None`, since + this is a whole-document re-validation, not attributable to + one operation. PatchError: Any patch-domain error raised during application. """ if not isinstance(target, self.__target_model__): - raise TypeError( + raise InvalidPatchTarget( f"{self.__class__.__name__}.apply() expects a {self.__target_model__.__name__} instance, " f"got {type(target).__name__}" ) try: data = _validate_JSONValue(target.model_dump()) - except Exception as e: - raise PatchValidationError( + except ValidationError as e: + raise InvalidPatchTarget( f"Target model produced non-JSON data for patching: {e}" ) from e patched = _apply_ops(self.ops, data, inplace=True) try: return self.__target_model__.model_validate(patched) except ValidationError as e: - raise PatchValidationError( - f"Patched data failed validation for {self.__target_model__.__name__}: {e}" + raise InvalidPatchResult( + f"Patched document failed validation for {self.__target_model__.__name__}: {e}", + errors=e.errors(), ) from e @@ -132,19 +137,21 @@ def apply( Arguments: doc: Target JSON document. - inplace: Copy policy passed through to the patch engine. + inplace: If `False` (default), `doc` is deep-copied first; the original is not + modified. If `True`, operations are applied to `doc` directly; root-targeting + operations may return a new object rather than `doc`. Returns: The patched JSON document. Raises: - PatchValidationError: If `doc` is not valid JSON. + InvalidPatchTarget: If `doc` is not a valid JSON document. PatchError: Any patch-domain error raised during application. """ try: _validate_JSONValue(doc) - except Exception as e: - raise PatchValidationError(f"Invalid JSON document: {e}") from e + except ValidationError as e: + raise InvalidPatchTarget(f"Invalid JSON document: {e}") from e return _apply_ops(self.ops, doc, inplace=inplace) @@ -179,6 +186,42 @@ class JsonPatchFor(_RegistryBoundPatchRoot, Generic[TargetT, RegistryT]): registry. `Target` is either a Pydantic model or `Literal["SchemaName"]` for JSON documents. `Registry` is a union of concrete OperationSchemas (`OpA | OpB | ...`). + + Example: + Use as a FastAPI route body type for a Pydantic model target. FastAPI + parses and validates the request body against the generated schema, and + `apply` re-validates the patched result against the model: + + ```python + class User(BaseModel): + name: str + age: int + + UserPatch = JsonPatchFor[User] + + @app.patch("/users/{user_id}", response_model=User) + async def patch_user(user_id: int, patch: UserPatch) -> User: + user = db.get_user(user_id) + updated = patch.apply(user) + db.save_user(user_id, updated) + return updated + ``` + + Example: + Restrict the allowed operations with a custom registry: + + ```python + type UserEditRegistry = ReplaceOp | TestOp + + UserPatch = JsonPatchFor[User, UserEditRegistry] + + @app.patch("/users/{user_id}", response_model=User) + async def patch_user(user_id: int, patch: UserPatch) -> User: + user = db.get_user(user_id) + updated = patch.apply(user) + db.save_user(user_id, updated) + return updated + ``` """ if TYPE_CHECKING: @@ -199,7 +242,7 @@ def apply[TargetModelM: BaseModel]( The patched BaseModel instance. Raises: - PatchValidationError: Patched data fails validation for the target model. + InvalidPatchResult: Patched document fails validation for the target model. PatchError: Any patch-domain error raised by operations, including conflicts. `PatchInternalError` is a `PatchError` raised for unexpected failures. """ @@ -225,7 +268,7 @@ def apply[TargetNameN: str]( The patched JSON document. Raises: - PatchValidationError: If the input is not a valid `JSONValue`. + InvalidPatchTarget: If the input is not a valid `JSONValue`. PatchError: Any patch-domain error raised by operations, including conflicts. `PatchInternalError` is a `PatchError` raised for unexpected failures. """ @@ -239,9 +282,8 @@ def apply(self, *args: Any, **kwargs: Any) -> Any: The patched model or JSON document, depending on specialization. Raises: - TypeError: Model variant expects a Pydantic BaseModel instance. - PatchValidationError: If the input is not a valid `JSONValue`. - PatchValidationError: Patched data fails validation for the target model. + InvalidPatchTarget: If the target is not a valid instance or the input is not a valid `JSONValue`. + InvalidPatchResult: Patched document fails validation for the target model. PatchError: Any patch-domain error raised by operations, including conflicts. `PatchInternalError` is a `PatchError` raised for unexpected failures. """ diff --git a/jsonpatchx/registry.py b/jsonpatchx/registry.py index 68a69c15..56cc9e9f 100644 --- a/jsonpatchx/registry.py +++ b/jsonpatchx/registry.py @@ -18,12 +18,14 @@ from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, Field, TypeAdapter, ValidationError, model_validator, ) +from pydantic_core import PydanticCustomError from typing_extensions import TypeForm from jsonpatchx.builtins import ( @@ -34,7 +36,7 @@ ReplaceOp, TestOp, ) -from jsonpatchx.exceptions import InvalidOperationRegistry, OperationNotRecognized +from jsonpatchx.exceptions import InvalidOperationRegistry from jsonpatchx.schema import OperationSchema from jsonpatchx.types import JSONValue @@ -42,12 +44,23 @@ def _iter_union_members[T](value: TypeForm[T]) -> Generator[type[T]]: """Yield leaf members from an operation type expression. - This performs structural unpacking only: - - unwraps type aliases - - flattens unions + Performs structural unpacking only: unwraps type aliases, flattens + unions, and strips `Annotated` wrappers. - It intentionally does not validate member types or forward references. - Pydantic enforces that in `_RegistrySpec.ops`. + Arguments: + value: A type expression to unpack, such as a union of operation + models or a type alias of those forms. + + Yields: + Leaf member types from the expression, without validation. + + Raises: + InvalidOperationRegistry: If the expression contains an + unsupported generic origin. + + Notes: + Does not validate member types or resolve forward references. + Pydantic enforces those constraints in `_RegistrySpec.ops`. """ if isinstance(value, TypeAliasType): yield from _iter_union_members(value.__value__) @@ -73,7 +86,7 @@ class _RegistrySpec(BaseModel): construction. """ - model_config = ConfigDict(frozen=True) + model_config = ConfigDict(frozen=True, validate_return=True) ops: frozenset[type[OperationSchema]] = Field(min_length=1) @@ -85,6 +98,9 @@ def from_typeform(cls, typeform: TypeForm[OperationSchema] | Any) -> _RegistrySp typeform: A single operation model or union of models, including nested type aliases of those forms. + Returns: + A validated `_RegistrySpec` for the operation declaration. + Raises: InvalidOperationRegistry: If the declaration cannot define a valid operation registry. @@ -101,14 +117,20 @@ def from_typeform(cls, typeform: TypeForm[OperationSchema] | Any) -> _RegistrySp def _validate_ops(self) -> _RegistrySpec: """Validate registry invariants for `ops`. - Ensures the registry contains only concrete operation models and that - both model names and `op` discriminator literals are unique. + Ensures the registry contains only concrete operation models with + unique class names and `op` discriminator literals. - This is a model validator rather than a field validator because that - makes it future-proof if additional fields start mattering. + Returns: + This registry spec, unchanged, if all invariants hold. Raises: - InvalidOperationRegistry: If the registry definition is unusable. + InvalidOperationRegistry: If the registry contains abstract models, + duplicate class names, or duplicate `op` literals. + + Notes: + Implemented as a model validator rather than a field validator so + that it remains correct if additional fields are added to the spec + in the future. """ non_concrete_models = sorted( @@ -166,6 +188,52 @@ def model_map(self) -> Mapping[str, type[OperationSchema]]: for op_literal in model._op_literals } + def _reject_unregistered_op_instance(self, obj: object) -> object: + """Reject an already-constructed operation whose exact class isn't registered. + + Runs before Pydantic's own union/discriminator handling touches + `obj`, so it sees the original object identity. This matters because + Pydantic's discriminated-union validation routes purely on the `op` + literal's value: if `obj` is an `OperationSchema` instance whose + exact class differs from, but is structurally compatible with, the + discriminator-selected member (a subclass, or a different generic + specialization of the same model), Pydantic can silently + reconstruct a brand-new, correctly-typed instance from `obj`'s field + data rather than rejecting it. A validator that ran after that + reconstruction would only ever see the new, already-correct object. + + Arguments: + obj: The raw value passed to the registry's `TypeAdapter`, before + any Pydantic validation. + + Returns: + `obj`, unchanged, if it is not an `OperationSchema` instance (for + example a mapping or JSON-decoded value, which proceeds to normal + discriminator-based validation) or if its exact class is a + registered member. + + Raises: + PydanticCustomError: If `obj` is an `OperationSchema` instance + whose exact class is not one of `self.ops`. This runs as part + of Pydantic's own validation, so it is wrapped into + `ValidationError` automatically. + """ + if isinstance(obj, OperationSchema) and type(obj) not in self.ops: + expected_ops = ", ".join( + f"'{op.__name__}'" + for op in sorted(self.ops, key=lambda op: op.__name__) + ) + raise PydanticCustomError( + "operation_not_recognized", + "Operation {op_name} is not registered in this registry; " + "expected one of: {expected_ops}", + { + "op_name": type(obj).__name__, + "expected_ops": expected_ops, + }, + ) + return obj + @cached_property def union_type(self) -> TypeForm[OperationSchema]: """Discriminated union alias for this registry's operation models. @@ -176,6 +244,7 @@ def union_type(self) -> TypeForm[OperationSchema]: RegistryPatchOperation = Annotated[ Union[self.ordered_ops], # type: ignore[name-defined] Field(discriminator="op"), + BeforeValidator(self._reject_unregistered_op_instance), ] return RegistryPatchOperation @@ -190,10 +259,20 @@ def patch_adapter(self) -> TypeAdapter[list[OperationSchema]]: return TypeAdapter(list[self.union_type]) # type: ignore[name-defined] def model_for(self, instruction: str) -> type[OperationSchema]: - """Resolve an `op` literal to its registered operation model.""" + """Resolve an `op` literal to its registered operation model. + + Arguments: + instruction: The `op` literal string to look up. + + Returns: + The registered `OperationSchema` subclass for `instruction`. + + Raises: + KeyError: If `instruction` is not registered. + """ model = self.model_map.get(instruction) if model is None: - raise OperationNotRecognized( + raise KeyError( f"Patch operation '{instruction}' is not allowed in this registry" ) return model @@ -201,13 +280,18 @@ def model_for(self, instruction: str) -> type[OperationSchema]: def parse_python_op( self, obj: Mapping[str, JSONValue] | OperationSchema ) -> OperationSchema: - """Validate one Python operation payload against this registry.""" - if isinstance(obj, OperationSchema): - if type(obj) not in self.ops: - raise OperationNotRecognized( - f"Operation {type(obj).__name__} is not allowed in this registry" - ) - return obj + """Validate one Python operation payload against this registry. + + Arguments: + obj: A mapping of operation fields, or an already-validated + `OperationSchema` instance. + + Returns: + A validated `OperationSchema` instance. + + Raises: + ValidationError: If `obj` fails Pydantic validation. + """ return self.op_adapter.validate_python( obj, strict=True, @@ -218,28 +302,38 @@ def parse_python_op( def parse_python_patch( self, python: Sequence[OperationSchema | Mapping[str, JSONValue]] ) -> list[OperationSchema]: - """Validate a Python patch document against this registry.""" - ops: list[OperationSchema] = [] - for item in python: - if isinstance(item, OperationSchema): - if type(item) not in self.ops: - raise OperationNotRecognized( - f"Operation {type(item).__name__} is not allowed in this registry" - ) - ops.append(item) - else: - ops.append( - self.op_adapter.validate_python( - item, - strict=True, - by_alias=True, - by_name=False, - ) - ) - return ops + """Validate a Python patch document against this registry. + + Arguments: + python: A sequence of operation mappings or `OperationSchema` + instances. + + Returns: + A list of validated `OperationSchema` instances. + + Raises: + ValidationError: If any item fails Pydantic validation. + """ + return self.patch_adapter.validate_python( + list(python), + strict=True, + by_alias=True, + by_name=False, + ) def parse_json_op(self, text: str | bytes | bytearray) -> OperationSchema: - """Validate one JSON-encoded operation against this registry.""" + """Validate one JSON-encoded operation against this registry. + + Arguments: + text: A JSON string, bytes, or bytearray encoding a single + operation object. + + Returns: + A validated `OperationSchema` instance. + + Raises: + ValidationError: If `text` fails Pydantic validation. + """ return self.op_adapter.validate_json( text, strict=True, @@ -248,7 +342,17 @@ def parse_json_op(self, text: str | bytes | bytearray) -> OperationSchema: ) def parse_json_patch(self, text: str | bytes | bytearray) -> list[OperationSchema]: - """Validate a JSON-encoded patch document against this registry.""" + """Validate a JSON-encoded patch document against this registry. + + Arguments: + text: A JSON string, bytes, or bytearray encoding a patch array. + + Returns: + A list of validated `OperationSchema` instances. + + Raises: + ValidationError: If `text` fails Pydantic validation. + """ return self.patch_adapter.validate_json( text, strict=True, @@ -267,6 +371,3 @@ def is_rfc6902(self) -> bool: _STANDARD_REGISTRY_SPEC = _RegistrySpec.from_typeform(StandardRegistry) """Resolved RFC 6902 registry spec.""" - -STANDARD_OPS = _STANDARD_REGISTRY_SPEC.ordered_ops -"""Standard RFC 6902 patch operations.""" diff --git a/jsonpatchx/schema.py b/jsonpatchx/schema.py index e1ee8e59..c21636b9 100644 --- a/jsonpatchx/schema.py +++ b/jsonpatchx/schema.py @@ -14,66 +14,86 @@ from pydantic import BaseModel, ConfigDict, GetJsonSchemaHandler from pydantic.json_schema import JsonSchemaValue -from pydantic_core import MISSING from pydantic_core import core_schema as cs from jsonpatchx.exceptions import ( InvalidOperationDefinition, PatchError, - PatchFailureDetail, PatchInternalError, - PatchValidationError, ) -from jsonpatchx.types import JSONValue +from jsonpatchx.types import JSONValue, _validate_JSONValue class OperationSchema(BaseModel, ABC): """ Base class for typed JSON Patch operations. - An `OperationSchema` is a Pydantic model representing one JSON Patch operation: - standard RFC 6902 operations (`add`/`remove`/`replace`/...) and custom domain operations. + Subclass `OperationSchema`, declare `op: Literal["..."]`, and implement `apply` to define a + typed operation. Instances are Pydantic models: frozen, strict, and validated on parse. - The library's workflow is: - - - Define operations as Pydantic models. - - Register them in an `OperationRegistry`. - - Parse incoming patch documents into concrete operation instances via a discriminated union - keyed by `op`. - - Apply operations sequentially by calling `apply`. - - Examples: - Required `op` field: + Example: + Declare a `replace` operation with a typed path and value: + ```python class ReplaceOp(OperationSchema): op: Literal["replace"] = "replace" path: JSONPointer[JSONValue] value: JSONValue - - Multiple identifiers (aliases): - - class CreateOp(OperationSchema): - op: Literal["create", "add"] = "create" + ``` + + The default value for `op` is optional but recommended. JsonPatchX always + marks `op` as required in the OpenAPI schema regardless, but a default lets + Python callers omit it when constructing instances directly. + + Example: + Support multiple identifiers with `Literal` for schema evolution: + + ```python + class IncrementOp(OperationSchema): + op: Literal["increment", "incr"] = "increment" + ``` + + + Multiple identifiers are useful for schema evolution but not recommended for new + operations. When used, the default value should be the preferred identifier; the + others act as backwards-compatible aliases. + + Configuration: + Subclasses inherit all settings below. Override individual keys by declaring + `model_config = ConfigDict(...)` on the subclass; only the keys you set are + overridden and the rest are inherited from `OperationSchema`. + + - `frozen=True`: Instances are immutable after construction. + - `strict=True`: Field values are not coerced. Opt out per field with + `Field(strict=False)` if a specific field should accept looser input. + - `extra="allow"`: Extra fields from JSON are preserved. RFC 6902 permits + extension members; overriding this to `"forbid"` would reject them. + - `validate_by_alias`, `serialize_by_alias`, `loc_by_alias` are all `True`: + alias-named fields (such as `from`, aliased to `from_`) are used + consistently in validation, serialization, and error messages. + - `validate_return=True`: Validates return values of Pydantic validator + methods (`@field_validator`, `@model_validator`). Notes: - - `op` must be a normal annotated attribute, not a `ClassVar`. `ClassVar` values are not - Pydantic fields and cannot participate in discriminated-union dispatch. - - Instances are frozen and strict by default. - - Instances are revalidated when parsed, which matters for fields that depend on validation - context (for example, registry-scoped pointer backends). - - Subclasses are validated at class-definition time. If `op` is not declared correctly, the - class raises `InvalidOperationDefinition` during import. + - Use `model_validator(mode="after")` with `PydanticCustomError` to enforce cross-field + constraints (for example, rejecting pointer pairs where one is an ancestor of the other). """ model_config = ConfigDict( frozen=True, # Patch operations are not stateful - strict=True, # Flexible validation can still be provided per field as desired + strict=True, # Can be opted out of on a per-field basis if needed, but strict by default for better error quality extra="allow", # Standard JSON Patch allows extras validate_by_alias=True, # Some JSON Patch keys are protected keywords in Python, such as 'from', and require aliases to bypass. serialize_by_alias=True, # Consistent with validation loc_by_alias=True, # So error messages also use alias. For example, when 'from' is an alias of 'from_', errors should say, "error at: from". validate_default=True, # Validate default values against their intended type annotations - validate_return=True, # For extra correctness. Also ensures that 'apply()' always results in valid JSON. + validate_return=True, # Validates @field_validator/@model_validator return values; does not affect apply(). use_enum_values=True, # For consistent serialization when values are Enums allow_inf_nan=False, # infinite values are not valid JSON validation_error_cause=False, # Consider enabling when Pydantic guarantees a stable error structure. Useful to flip when debugging locally. @@ -90,11 +110,13 @@ class raises `InvalidOperationDefinition` during import. @override def __init_subclass__(cls, **kwargs: Unpack[ConfigDict]) -> None: """ - Hook that validates subclasses at definition time. + Validate the subclass `op` field and cache its identifiers at class-definition time. - Public subclasses normally do not need to call this directly. The base class ensures that - every OperationSchema has a properly declared `op` field, and caches the allowed op - identifiers for registry dispatch. + Raises: + InvalidOperationDefinition: If `op` is missing, not annotated as + `Literal[str, ...]`, or declared as a `ClassVar` (which Pydantic + excludes from fields and cannot participate in discriminated-union + dispatch). """ super().__init_subclass__(**kwargs) cls._op_literals = cls._get_op_literals() @@ -102,18 +124,26 @@ def __init_subclass__(cls, **kwargs: Unpack[ConfigDict]) -> None: @classmethod def _get_op_literals(cls) -> tuple[str, ...]: """ - Internal: extract the string literal values from the subclass' `op` annotation. + Extract string literal values from the subclass's `op` annotation. - Supported forms: + Supports `op: Literal["add"]` and `op: Literal["add", "create"]`. - - `op: Literal["add"]` - - `op: Literal["add", "create"]` - - Raises `InvalidOperationDefinition` if the subclass does not declare a valid `Literal[str, ...]` - annotation for `op`. + Raises: + InvalidOperationDefinition: If the subclass does not declare a valid + `Literal[str, ...]` annotation for `op`. """ + try: + annotations = get_type_hints(cls, include_extras=True) + except TypeError as e: + # Py3.12-Py3.13: a bare `Literal` (no args) annotation makes get_type_hints + # raise TypeError directly instead of returning it as a hint to reject below. + raise InvalidOperationDefinition( + f"OperationSchema '{cls.__name__}' is missing valid type hints for required 'op' field. " + "'op' must be an instance field annotated as a Literal[...] of strings." + ) from e + if ( - (annotations := get_type_hints(cls, include_extras=True)) + annotations and (op_annotation := annotations.get("op")) and (get_origin(op_annotation) is Literal) and (op_literals := get_args(op_annotation)) @@ -135,15 +165,27 @@ def apply(self, doc: JSONValue) -> JSONValue: doc: Target JSON document. Returns: - The updated document. + The updated JSON document. + + Example: + ```python + class IncrementOp(OperationSchema): + op: Literal["increment"] + path: JSONPointer[JSONNumber] + amount: JSONNumber = Field(gt=0) + + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=current + self.amount).apply(doc) + ``` Notes: - - Implementations may mutate the provided `doc` object in-place and should return the - updated document (often the same object). - - Raise `PatchError` subclasses for expected patch failures. Unexpected exceptions will - be wrapped by the patch engine. - - Whether the caller-owned document is mutated is controlled by the patch engine - (see `_apply_ops(..., inplace=...)`), not by this method. + - Implementations may mutate `doc` in place; always return the resulting document. + - Raise `PatchConflictError` (or a subclass) for expected apply-time failures. + Input validation errors belong in model validators, not `apply`; unexpected + exceptions are caught by the patch engine and wrapped as `PatchInternalError`. + - Whether the caller's original document is mutated depends on the patch engine's + `inplace` policy, not on this method. """ @classmethod @@ -171,34 +213,28 @@ def _apply_ops( ops: Sequence[OperationSchema], doc: JSONValue, *, inplace: bool = False ) -> JSONValue: """ - Apply a sequence of operations to a JSON document (core patch engine). - - This function is the single source of truth for the library's copy and mutation semantics. + Apply a sequence of operations to a JSON document. Arguments: ops: Operations to apply, in order. - doc: Target JSON document. - inplace: Copy policy. `False` deep-copies `doc` before applying operations. - `True` applies operations against `doc` without that initial copy. + doc: JSON document to apply operations to. + inplace: If `False` (default), `doc` is deep-copied first; the original is not modified. + If `True`, operations are applied to `doc` directly without that initial copy. Returns: - The patched document value produced by applying all operations. + The patched JSON document. Raises: PatchError: Expected patch failures raised by operation implementations. + `index`/`operation` are set to identify the raising operation, + whether it is a built-in raise site or a custom operation raising + one of these directly. PatchInternalError: Unexpected exceptions wrapped with structured context. Notes: - - `inplace=False` (default): the engine deep-copies `doc` first, then applies operations - to that copy. Operation implementations may mutate the document object they receive. The - original input object is not modified. - - `inplace=True`: operations are applied directly to the provided `doc` object. This is faster - and avoids a deep copy, but it is **not transactional**. If an operation fails mid-patch, earlier - operations will already have mutated the document (no rollback). - This is a **copy policy**, not an object-identity guarantee for the returned root value - (root-targeting operations may rebind the root). - - In other words: operations are allowed to be โ€œmutativeโ€, and the engine decides whether those - mutations hit the original input or a deep-copied working document. + `inplace=True` is not transactional: if an operation fails mid-patch, earlier + operations will already have mutated the document with no rollback. Root-targeting + operations may also return a new object rather than `doc`. """ if not inplace: doc = copy.deepcopy( @@ -208,20 +244,14 @@ def _apply_ops( for index, op in enumerate(ops): try: doc = op.apply(doc) - except PatchError: - # Domain-specific patch errors (e.g. TestOpFailed) should propagate unchanged. + _validate_JSONValue(doc) + except PatchError as e: + # Domain-specific patch errors (e.g. TestOpFailed) should propagate + # unchanged, but identify which operation raised them. + e.index = index + e.operation = op raise except Exception as e: - detail = PatchFailureDetail( - index=index, - op=op, - message=str(e), - cause_type=type(e).__name__, - ) - raise PatchInternalError(detail, cause=e) from e + raise PatchInternalError(str(e), index=index, operation=op, cause=e) from e - if doc is MISSING: # type: ignore[comparison-overlap] - raise PatchValidationError( - "The patch deleted the document, which is not allowed." - ) return doc diff --git a/jsonpatchx/selector.py b/jsonpatchx/selector.py index 7329a438..a7593645 100644 --- a/jsonpatchx/selector.py +++ b/jsonpatchx/selector.py @@ -60,25 +60,15 @@ class JSONSelector(str, Generic[T_co, S_co]): `JSONSelector[T]` is the query analogue of `JSONPointer[T]`: it parses a selector string up front, keeps the parsed backend around, and - enforces the type parameter `T` when matches are exercised. + enforces the type parameter `T` when matches are exercised. The string value + is the selector query itself, so `JSONSelector` is accepted wherever `str` is. - Query selectors differ from pointers in one important way: they can resolve - to many locations. So the convenience surface is plural: + Unlike `JSONPointer`, a selector can match many locations; mutation resolves + each match into an exact `JSONPointer` and delegates to pointer mutation rules. - - `getall(doc)` validates and returns every matched value. - - `addall(doc, value)` validates the current matches and writes to every - matched location. - - `removeall(doc)` validates the current matches and removes every matched - location. - - Mutation is implemented by resolving the selector into exact - `JSONPointer` locations and delegating to pointer mutation rules. The - selector backend's `pointers()` output is the source of truth for which - pointer backend is being used. - - At the root selector `$`, a missing document is handled as its own runtime - state: `getall()` and `removeall()` fail, while `addall()` recreates the - document. + Notes: + Instances are produced by Pydantic validation. If you need direct + construction, use `JSONSelector.parse()`. """ __slots__ = ("_selector", "_type") @@ -91,25 +81,24 @@ def ptr(self) -> S_co: """ The underlying selector backend instance. - This is exposed for advanced users who provide a custom - `SelectorBackend` with additional APIs. + This is exposed for advanced users who provide a custom SelectorBackend implementation with additional APIs. """ return self._selector @property def type_param(self) -> TypeForm[T_co]: - """The expected type parameter `T` used to validate matched targets.""" + """The type parameter `T` used to validate matched targets.""" return self._type @property def _adapter(self) -> TypeAdapter[T_co]: - """Return the cached Pydantic adapter used for strict `T` validation.""" + """The cached Pydantic adapter used for strict `T` validation.""" return _cached_adapter(cast(Any, self._type)) @classmethod def _validator( cls, - selector: str | Self | SelectorBackend, + selector: str | SelectorBackend, *, type_param: TypeForm[Any], concrete_backend: type[SelectorBackend] | TypeVar, @@ -127,10 +116,12 @@ def _validator( A `JSONSelector` bound to the resolved backend and type parameter. Raises: + TypeError: If the backend TypeVar cannot be resolved to a concrete + backend. InvalidJSONSelector: If an existing selector/backend instance cannot be rebound to the required backend. """ - resolved_backend = cls._resolve_runtime_backend_param(concrete_backend) + resolved_backend = cls._finalize_backend(concrete_backend) compiled: SelectorBackend if isinstance(selector, JSONSelector): selector_str = str(selector) @@ -171,6 +162,21 @@ def _validator( def __get_pydantic_core_schema__( cls, source_type: type[Self], handler: GetCoreSchemaHandler ) -> cs.CoreSchema: + """Build the Pydantic core schema for `JSONSelector` validation. + + Arguments: + source_type: The specialized `JSONSelector[T, Backend]` type being built. + handler: Pydantic's schema generation handler. + + Returns: + A Pydantic core schema that validates strings, `JSONSelector` instances, and + `SelectorBackend` instances into a `JSONSelector` bound to `T` and the backend. + + Raises: + TypeError: If no type parameters are supplied, if the backend + parameter is not a class or `TypeVar`, or if the type parameter + is not a valid TypeForm. + """ type_param, concrete_backend = cls._parse_selector_type_args( *get_args(source_type) ) @@ -198,15 +204,26 @@ def __get_pydantic_core_schema__( def __get_pydantic_json_schema__( cls, schema: cs.CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: + """Build the JSON Schema representation for `JSONSelector`. + + Arguments: + schema: The Pydantic core schema produced by `__get_pydantic_core_schema__`. + handler: Pydantic's JSON schema generation handler. + + Returns: + A JSON Schema dict describing a JSON selector string, enriched with + `x-selector-type-schema` for the declared type parameter. + Raises: + TypeError: If the backend TypeVar cannot be resolved, or if the + type parameter cannot be adapted. + """ selector_backend: type[SelectorBackend] selector_backend_param = schema.get("metadata", {}).get( "selector_backend_param" ) if isinstance(selector_backend_param, TypeVar): - selector_backend = cls._resolve_runtime_backend_param( - selector_backend_param - ) + selector_backend = cls._finalize_backend(selector_backend_param) else: selector_backend = selector_backend_param @@ -237,36 +254,80 @@ def __get_pydantic_json_schema__( def _parse_selector_type_args( cls, *args: TypeForm[Any] ) -> tuple[TypeForm[Any], type[SelectorBackend] | TypeVar]: - """ - Validate the selector generic arguments. + """Validate and unpack the `JSONSelector[T, Backend]` parameter tuple. Arguments: - *args: Generic arguments from `JSONSelector[T, Backend]`. + *args: Generic type arguments from `get_args(source_type)`, e.g. + `(JSONValue, MyBackend)` for `JSONSelector[JSONValue, MyBackend]`. Returns: - The validated type parameter and backend parameter. + A tuple of (type_param, backend_param): type_param is a validated TypeForm + and backend_param is a concrete backend class or TypeVar. Raises: - TypeError: If the selector is missing its required type argument. - InvalidJSONSelector: If the type parameter or backend parameter is - invalid. + TypeError: If no type parameters are supplied, if the backend + parameter is not a class or `TypeVar`, or if the type parameter + is not a valid TypeForm. """ if not args: raise TypeError(f"{cls} requires at least one type parameter") unverified_typeform = args[0] unverified_backend = args[1] if len(args) > 1 else DEFAULT_SELECTOR_CLS - backend_param = cls._resolve_backend_type_param(unverified_backend) - type_param = _validate_typeform(unverified_typeform, InvalidJSONSelector) + backend_param = cls._validate_backend_arg(unverified_backend) + type_param = _validate_typeform(unverified_typeform) return type_param, backend_param - @staticmethod - def _resolve_backend_type_param( + @classmethod + def _validate_concrete_backend_class( + cls, + candidate: object, + ) -> type[SelectorBackend]: + """ + Validate that `candidate` is usable as a concrete backend class. + + This is the single canonical place that decides whether a class is + admissible as a `SelectorBackend`, independent of whether the caller + received it as a direct generic argument or as an already-resolved + `TypeVar` default. It cannot verify actual protocol conformance + (`@runtime_checkable` can only check that on an instance, not a + class); `_selector_backend_instance` checks that once a concrete + instance exists. + + Arguments: + candidate: A value expected to be a non-abstract class. + + Returns: + The validated backend class. + + Raises: + TypeError: If `candidate` is not a class, or is an abstract class. + """ + if not isinstance(candidate, type): + raise TypeError( + f"JSONSelector backend parameter {candidate!r} must be a class or TypeVar" + ) + if isabstract(candidate): + raise TypeError( + f"JSONSelector backend parameter {candidate!r} is abstract and cannot be used as a backend" + ) + return cast(type[SelectorBackend], candidate) + + @classmethod + def _validate_backend_arg( + cls, backend_param: object, ) -> type[SelectorBackend] | TypeVar: """ - Validate the backend generic argument before runtime resolution. + Schema-declaration-time: validate the raw backend generic argument. + + Runs once, when `JSONSelector[T, Backend]` is built, or when + `.parse()` is called directly with a `backend=` argument, before any + value has been validated. A `TypeVar` passes through unresolved + here: `TypeVar` defaults are only resolved later, per validation + call, by `_finalize_backend`. A concrete class is validated via + `_validate_concrete_backend_class`. Arguments: backend_param: Raw second generic argument from @@ -276,46 +337,54 @@ def _resolve_backend_type_param( A backend class or unresolved `TypeVar`. Raises: - InvalidJSONSelector: If the backend argument is neither a class nor - a `TypeVar`. + TypeError: If the backend argument is neither a class nor a + `TypeVar`, or if the backend class is abstract. """ if isinstance(backend_param, TypeVar): return backend_param - if not isinstance(backend_param, type): - raise InvalidJSONSelector( - f"JSONSelector backend parameter {backend_param!r} must be a class or TypeVar" - ) - return cast(type[SelectorBackend], backend_param) + return cls._validate_concrete_backend_class(backend_param) @classmethod - def _resolve_runtime_backend_param( + def _finalize_backend( cls, backend_param: type[SelectorBackend] | TypeVar, ) -> type[SelectorBackend]: """ - Resolve a backend parameter to a concrete runtime backend class. + Validation-time: turn a schema-time backend parameter into a + concrete class. + + Called every time a value is validated (from `_validator`) or the + schema is introspected, using whatever `_validate_backend_arg` + produced at schema-declaration time. A concrete class is returned + unchanged; an unresolved `TypeVar` is resolved now via + `_finalize_backend_typevar`. Arguments: - backend_param: Backend class or backend `TypeVar`. + backend_param: Backend class or backend `TypeVar`, as returned + by `_validate_backend_arg`. Returns: A concrete `SelectorBackend` class. Raises: - InvalidJSONSelector: If an unspecialized backend `TypeVar` cannot - be resolved to a concrete default backend. + TypeError: If an unspecialized backend `TypeVar` cannot be resolved + to a concrete default backend. """ if not isinstance(backend_param, TypeVar): return backend_param - return cls._resolve_runtime_backend_typevar(backend_param) + return cls._finalize_backend_typevar(backend_param) @classmethod - def _resolve_runtime_backend_typevar( + def _finalize_backend_typevar( cls, backend_typevar: TypeVar, ) -> type[SelectorBackend]: """ - Resolve an unspecialized backend `TypeVar` using its default. + Validation-time: resolve an unspecialized backend `TypeVar` via its + default. + + Called by `_finalize_backend` when the backend generic argument was + left as an unresolved `TypeVar` by `_validate_backend_arg`. Arguments: backend_typevar: Backend `TypeVar` from the generic parameter list. @@ -324,50 +393,57 @@ def _resolve_runtime_backend_typevar( A concrete `SelectorBackend` class. Raises: - InvalidJSONSelector: If the `TypeVar` has no usable default - backend. + TypeError: If the `TypeVar` has no default at all, or if its + default fails `_validate_concrete_backend_class`'s + validation (not a class, or abstract). """ try: has_default = backend_typevar.has_default() except AttributeError: # Py3.12 has_default = False - if has_default: - default_candidate = getattr(backend_typevar, "__default__") - default_backend = cls._coerce_runtime_backend_candidate(default_candidate) - if default_backend is not None: - return default_backend - - raise InvalidJSONSelector( - "JSONSelector backend TypeVar must define a default backend " - "or be specialized with a concrete backend type" - ) + if not has_default: + raise TypeError( + "JSONSelector backend TypeVar must define a default backend " + "or be specialized with a concrete backend type" + ) + default_candidate = getattr(backend_typevar, "__default__") + return cls._finalize_backend_default(default_candidate) @classmethod - def _coerce_runtime_backend_candidate( + def _finalize_backend_default( cls, candidate: object, - ) -> type[SelectorBackend] | None: + ) -> type[SelectorBackend]: """ - Coerce a potential default backend candidate into a usable class. + Validation-time: resolve a `TypeVar`'s raw default value into a + usable class. + + Called by `_finalize_backend_typevar` once it has confirmed a + default exists. The default itself may be a nested `TypeVar` + (recurse into `_finalize_backend_typevar`) or a concrete class + (validated via `_validate_concrete_backend_class`, so a bad default + raises the same specific error as a bad direct or + schema-declaration-time argument, rather than a separate, less + specific one). Arguments: candidate: Runtime object drawn from a backend `TypeVar` default. Returns: - A concrete `SelectorBackend` class, or `None` if the candidate is - not usable as a runtime backend. + A concrete `SelectorBackend` class. + + Raises: + TypeError: If `candidate` is a `TypeVar` with no usable default, + or if it fails `_validate_concrete_backend_class`'s + validation (not a class, or abstract). """ if isinstance(candidate, TypeVar): - return cls._resolve_runtime_backend_typevar(candidate) - if not isinstance(candidate, type): - return None - if candidate is SelectorBackend or isabstract(candidate): - return None - return candidate + return cls._finalize_backend_typevar(candidate) + return cls._validate_concrete_backend_class(candidate) def _validate_target(self, target: object) -> T_co: """ - Validate a matched or replacement value against this selector's type. + Validate a matched target value against this selector's type. Arguments: target: Candidate value to validate strictly against `T`. @@ -409,7 +485,7 @@ def _validate_replacement(self, value: object) -> JSONValue: @classmethod def parse( cls, - selector: str | Self | SelectorBackend, + selector: str | SelectorBackend, *, backend: type[S_parse] | None = None, ) -> "JSONSelector[JSONValue, S_parse]": ... @@ -418,7 +494,7 @@ def parse( @classmethod def parse( cls, - selector: str | Self | SelectorBackend, + selector: str | SelectorBackend, *, type_param: TypeForm[T_parse], backend: type[S_parse] | None = None, @@ -427,7 +503,7 @@ def parse( @classmethod def parse( cls, - selector: str | Self | SelectorBackend, + selector: str | SelectorBackend, *, type_param: TypeForm[Any] | object = _Nothing, backend: type[SelectorBackend] | None = None, @@ -438,26 +514,30 @@ def parse( Arguments: selector: Selector string, parsed selector, or selector backend instance. - type_param: Type enforced when matched values are exercised. - backend: Optional concrete backend class. When omitted, the built-in - JSONPath backend is used. + type_param: The type that is enforced on matched values. + backend: The backend selector implementation. If `None`, defaults to `DEFAULT_SELECTOR_CLS` (RFC 9535). Returns: A validated `JSONSelector` instance. Raises: - InvalidJSONSelector: If the selector string, backend, or generic - parameters are invalid. + TypeError: If the `backend` argument is not a class or `TypeVar`, + if a backend `TypeVar` cannot be resolved to a concrete backend, + if the type parameter is not a valid TypeForm, or if `selector` + is not a `str`, `JSONSelector`, or `SelectorBackend` instance. + InvalidJSONSelector: If `selector` is not a valid selector string. + + ??? Acknowledment + The `type_param` argument places the covariant type parameter `T` in an input position, + which is technically unsound. But the intended use case of this classmethod is testing + and ad-hoc selector construction, where the ergonomics of direct construction outweigh + the theoretical unsoundness. + """ + if not isinstance(selector, (str, SelectorBackend)): + raise TypeError( + f"selector must be a str or a SelectorBackend instance; got {type(selector).__name__!r}" + ) - Notes: - `type_param` technically places the covariant `T` parameter in an - input position, which would normally be an unsound public API - shape. That tradeoff is intentional here because `parse()` is only - a convenience constructor around Pydantic validation. Normal - construction happens through Pydantic on an already-specialized - `JSONSelector[...]` type, so callers are not meant to treat - `parse()` as the primary semantic surface for consuming `T`. - """ resolved_type_param = ( JSONValue if type_param is _Nothing else cast(TypeForm[Any], type_param) ) @@ -479,18 +559,20 @@ def parse( adapter = _cached_adapter( JSONSelector[validated_type, validated_backend] # type: ignore[valid-type] ) - return adapter.validate_python(selector) + try: + return adapter.validate_python(selector) + except ValidationError as e: + raise InvalidJSONSelector(f"Invalid selector: {e}") from e def is_valid_type(self, target: object) -> bool: """ - Return `True` if `target` conforms to this selector's type. + Check whether `target` conforms to this selector's type parameter `T`. Arguments: target: Candidate value to validate. Returns: - `True` when `target` validates strictly against `T`, - otherwise `False`. + `True` if `target` validates strictly against `T`, `False` otherwise. """ try: self._adapter.validate_python(target, strict=True) @@ -503,15 +585,15 @@ def _pointer_instances(self, doc: JSONValue) -> list[PointerBackend]: Resolve this selector and return backend pointer instances. Arguments: - doc: Target JSON document. + doc: JSON document to resolve against. Returns: - Backend pointers for each resolved match. + pointer_instances: A list of backend pointer instances for each resolved match. Raises: PatchConflictError: If the selector backend cannot resolve the selector against `doc`. - InvalidJSONSelector: If the backend yields invalid pointer objects. + TypeError: If the backend yields objects that do not implement `PointerBackend`. """ try: raw_pointers = list(self._selector.pointers(doc)) @@ -523,8 +605,9 @@ def _pointer_instances(self, doc: JSONValue) -> list[PointerBackend]: pointers: list[PointerBackend] = [] for pointer in raw_pointers: if not isinstance(pointer, PointerBackend): - raise InvalidJSONSelector( - f"selector backend returned invalid pointer {pointer!r}" + raise TypeError( + f"selector backend returned invalid pointer {pointer!r}: " + f"expected PointerBackend, got {type(pointer).__name__}" ) pointers.append(pointer) return pointers @@ -534,15 +617,16 @@ def get_pointers(self, doc: JSONValue) -> list[JSONPointer[T_co, PointerBackend] Resolve this selector against `doc` and return exact matched pointers. Arguments: - doc: Target JSON document. + doc: JSON document to resolve against. Returns: - Typed `JSONPointer` values for each matched location. + pointers: Typed `JSONPointer` values for each matched location. Raises: PatchConflictError: If selector resolution fails. - InvalidJSONSelector: If the backend yields invalid matches or - invalid pointer objects. + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer` (propagated from `JSONPointer.parse`). """ return [ JSONPointer.parse( @@ -558,7 +642,7 @@ def getall(self, doc: JSONValue) -> list[T_co]: Resolve this selector against `doc` and return all matched values. Arguments: - doc: Target JSON document. + doc: JSON document to resolve against. Returns: A list of matched values validated against `T`. If the selector @@ -567,25 +651,30 @@ def getall(self, doc: JSONValue) -> list[T_co]: Raises: PatchConflictError: If selector resolution fails or a matched pointer cannot be read as type `T`. - InvalidJSONSelector: If the backend yields invalid matches or - invalid pointer data. + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer`, propagated from `get_pointers`. """ return [pointer.get(doc) for pointer in self.get_pointers(doc)] def is_gettable(self, doc: JSONValue) -> bool: """ - Return `True` if `getall(doc)` would succeed. + Check whether `getall(doc)` would succeed. Arguments: - doc: Target JSON document. + doc: JSON document to resolve against. Returns: - `True` if selector resolution and per-match reads succeed, - otherwise `False`. + `True` if selector resolution and per-match reads succeed, `False` otherwise. + + Raises: + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer`, propagated from `get_pointers`. """ try: self.getall(doc) - except (InvalidJSONSelector, PatchConflictError): + except PatchConflictError: return False else: return True @@ -595,18 +684,23 @@ def addall(self, doc: JSONValue, value: object) -> JSONValue: Apply RFC 6902-style add semantics at every matched location. Arguments: - doc: Target JSON document. - value: Replacement value written to every matched location. + doc: JSON document to add to. + value: Value to write at every matched location. Must conform to `T` and to `JSONValue`. Returns: - The updated document. + The updated JSON document. Raises: PatchConflictError: If `value` is not valid for this selector, if selector resolution fails, or if any matched pointer cannot be updated. - InvalidJSONSelector: If the backend yields invalid matches or - invalid pointer data. + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer`, propagated from `get_pointers`. + + Notes: + May modify `doc` in place; always use the return value. Root-targeting + matches replace the document and return the new root rather than mutating it. """ target = self._validate_replacement(value) for pointer in self.get_pointers(doc): @@ -619,24 +713,29 @@ def is_addable( value: object = _Nothing, ) -> bool: """ - Return `True` if `addall()` would succeed. + Check whether `addall` would succeed for this document. Arguments: - doc: Target JSON document. + doc: JSON document to add to. value: Optional value that would be written to every matched location. When omitted, only the current matched targets are checked. Returns: `True` if the selector can be resolved and every matched pointer - accepts the requested add semantics, otherwise `False`. + accepts the requested add semantics, `False` otherwise. + + Raises: + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer`, propagated from `get_pointers`. """ if value is _Nothing: try: return all( pointer.is_addable(doc) for pointer in self.get_pointers(doc) ) - except (InvalidJSONSelector, PatchConflictError): + except PatchConflictError: return False try: @@ -644,7 +743,7 @@ def is_addable( return all( pointer.is_addable(doc, target) for pointer in self.get_pointers(doc) ) - except (InvalidJSONSelector, PatchConflictError): + except PatchConflictError: return False def removeall(self, doc: JSONValue) -> JSONValue: @@ -652,16 +751,20 @@ def removeall(self, doc: JSONValue) -> JSONValue: Apply RFC 6902-style remove semantics at every matched location. Arguments: - doc: Target JSON document. + doc: JSON document to remove from. Returns: - The updated document. + The updated JSON document. Raises: PatchConflictError: If selector resolution fails or any matched pointer cannot be removed. - InvalidJSONSelector: If the backend yields invalid matches or - invalid pointer data. + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer`, propagated from `get_pointers`. + + Notes: + Modifies `doc` in place; always use the return value. """ for pointer in self.get_pointers(doc): doc = pointer.remove(doc) @@ -669,25 +772,34 @@ def removeall(self, doc: JSONValue) -> JSONValue: def is_removable(self, doc: JSONValue) -> bool: """ - Return `True` if all matched targets are removable in principle. + Check whether `removeall` would succeed for this document. Arguments: - doc: Target JSON document. + doc: JSON document to remove from. Returns: - `True` if the selector resolves and the current matches are - gettable/removable targets, otherwise `False`. + `True` if the selector resolves and all matched targets are + removable, `False` otherwise. + + Raises: + TypeError: If the backend yields objects that do not implement `PointerBackend`. + InvalidJSONPointer: If a resolved backend pointer cannot be parsed into a + `JSONPointer`, propagated from `get_pointers`. Notes: This is intentionally looser than `removeall()`. Selector removal does not promise a stable or safety-maximizing order, so - this predicate only checks the current matches, not whether any - particular backend iteration order will succeed. + this predicate checks whether every current match is removable + under the pointer layer's rules. """ - return self.is_gettable(doc) + try: + return all(pointer.is_removable(doc) for pointer in self.get_pointers(doc)) + except PatchConflictError: + return False @override def __repr__(self) -> str: + """Return `ClassName[T]('selector_string')` representation.""" type_repr = ( self._type.__name__ if isinstance(self._type, type) else repr(self._type) ) diff --git a/jsonpatchx/standard.py b/jsonpatchx/standard.py index 3e70f612..24789c09 100644 --- a/jsonpatchx/standard.py +++ b/jsonpatchx/standard.py @@ -1,55 +1,11 @@ -""" -jsonpatch.standard - -Core patch application engine + public convenience wrappers. - -This module is the โ€œbehavioral centerโ€ of the library: it defines *copy semantics*, -*error semantics*, and the *operational contract* for applying a sequence of typed -operations to a JSON document. - -### Copy & mutation semantics (single source of truth) - -Operations are allowed to be *mutative* (i.e., they may modify the document object -they receive). The engine controls whether those mutations affect the caller's -original document: - -- inplace=False (default): deep-copy the input document first, then apply ops to the copy. - The caller's original object is not modified. - -- inplace=True: apply ops directly to the input object (faster, avoids copy) but is NOT - transactional. If an operation fails mid-patch, earlier operations may already have - mutated the document (no rollback). This is a copy policy, not an object-identity - guarantee: root-targeting operations (path "") may return a new root value. - -### Typed pointer semantics (why some failures are โ€œintentionalโ€) - -Operation schemas frequently carry typed pointers: JSONPointer[T]. - -Typing is not only static; it is a runtime contract: -- Pointer reads validate the resolved value against T. -- This implies โ€œtype-gatedโ€ behavior for composite semantics like remove/replace: - remove can fail if the current value exists but does not conform to T, because - the operation is explicitly scoped to โ€œwhat is removableโ€. - -This library prefers explicitness: -- widen T (e.g., JSONValue) to be permissive -- or define a more specific op if you want stricter behavior - -### Error semantics - -Expected patch failures (subclasses of PatchError) propagate unchanged. - -Unexpected exceptions are wrapped with structured metadata (operation index + the full -operation payload) so API layers can report actionable failures. -""" - import json from collections.abc import Mapping, Sequence from typing import Self, overload, override +from pydantic import ValidationError from typing_extensions import TypeForm -from jsonpatchx.exceptions import PatchValidationError +from jsonpatchx.exceptions import InvalidPatchTarget from jsonpatchx.registry import _STANDARD_REGISTRY_SPEC, _RegistrySpec from jsonpatchx.schema import OperationSchema, _apply_ops from jsonpatchx.types import JSONValue, _validate_JSONValue @@ -57,22 +13,13 @@ class JsonPatch(Sequence[OperationSchema]): """ - A parsed JSON Patch document (RFC 6902-style) bound to a registry declaration. + A parsed JSON Patch document bound to a registry declaration. `JsonPatch` is a convenience wrapper that: - parses and validates an input patch document using a registry of `OperationSchema` models, - stores the resulting typed `OperationSchema` instances, - applies them to JSON documents via the shared patch engine. - - Notes: - - `apply` delegates to the core engine `_apply_ops` and follows the same copy and mutation - semantics. - - `inplace=False` (default): the engine deep-copies `doc` first; operations may mutate the copy. - - `inplace=True`: operations run against the provided `doc` object (no rollback on failure). - This is a copy policy, not an object-identity guarantee for the returned value. - - `JsonPatch` is immutable with respect to its operation list after construction, but the - documents you apply it to may be mutated depending on `inplace`. """ __slots__ = ("_ops", "_registry") @@ -153,26 +100,27 @@ def apply( Arguments: doc: The target JSON document. - inplace: Copy policy. `False` deep-copies `doc` before applying operations. - `True` skips that copy and applies operations against `doc`, but does not - guarantee returned object identity for root-targeting operations. + inplace: If `False` (default), `doc` is deep-copied first; the original is not modified. + If `True`, operations are applied to `doc` directly; root-targeting operations + may return a new object rather than `doc`. Returns: patched: The patched JSON document. Raises: - PatchValidationError: If `doc` is not a valid JSON document. + InvalidPatchTarget: If `doc` is not a valid JSON document. PatchError: Any patch-domain error raised by operations, including conflicts. `PatchInternalError` is a `PatchError` raised for unexpected failures. """ try: _validate_JSONValue(doc) - except Exception as e: - raise PatchValidationError(f"Invalid JSON document: {e}") from e + except ValidationError as e: + raise InvalidPatchTarget(f"Invalid JSON document: {e}") from e return _apply_ops(self._ops, doc, inplace=inplace) @override def __len__(self) -> int: + """Return the number of operations in this patch.""" return len(self._ops) @overload @@ -187,21 +135,25 @@ def __getitem__(self, index: slice) -> Sequence[OperationSchema]: def __getitem__( self, index: int | slice ) -> OperationSchema | Sequence[OperationSchema]: + """Return the operation at `index`, or a subsequence for a slice.""" return self._ops[index] @override def __hash__(self) -> int: + # Not formally documented in the developer API. # Hashing is best-effort, user-defined ops may be unhashable. return hash((self.__class__, self._registry, tuple(self))) @override def __eq__(self, other: object) -> bool: + """Return `True` if `other` is a `JsonPatch` with the same sequence of operations. `False` otherwise.""" if not isinstance(other, self.__class__): return NotImplemented return tuple(self) == tuple(other) and self._registry == other._registry @override def __str__(self) -> str: + """Serialize this patch to a JSON string.""" return self.to_string() @override @@ -209,6 +161,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({self})" def __add__(self, other: object) -> Self: + """Return a new patch concatenating this and `other`.""" if not isinstance(other, self.__class__): return NotImplemented if self._registry != other._registry: @@ -227,7 +180,7 @@ def apply_patch( inplace: bool = False, ) -> JSONValue: """ - Apply a standard RFC 6902 JSON Patch document to `doc`. + Apply a JSON Patch document to `doc`. Arguments: doc: Target JSON document. @@ -235,15 +188,16 @@ def apply_patch( registry: A union of concrete OperationSchemas used for parsing and validation (`OpA | OpB | ...`). If omitted, the standard RFC 6902 operations are used. - inplace: Copy policy. `False` deep-copies `doc` first; `True` skips that copy. - This is not a guarantee that the returned object is the exact same root object. - See `_apply_ops(..., inplace=...)` for full semantics. + inplace: If `False` (default), `doc` is deep-copied first; the original is not modified. + If `True`, operations are applied to `doc` directly; faster, but not transactional + (no rollback on partial failure), and root-targeting operations may return a new + object rather than `doc`. Returns: The patched document. Raises: - PatchValidationError: If `doc` is not a valid JSON document. + InvalidPatchTarget: If `doc` is not a valid JSON document. PatchError: Any patch-domain error raised by patch parsing or application. diff --git a/jsonpatchx/types.py b/jsonpatchx/types.py index 26cd55df..bd23f480 100644 --- a/jsonpatchx/types.py +++ b/jsonpatchx/types.py @@ -8,26 +8,36 @@ from pydantic_core import core_schema from typing_extensions import TypeForm, TypeIs -from jsonpatchx.exceptions import InvalidJSONPointer - # TypeAdapter helpers @lru_cache(maxsize=512) def _cached_type_adapter[T](expected: TypeForm[T]) -> TypeAdapter[T]: + """Build and cache a `TypeAdapter` for `expected`; see `_cached_adapter` for unhashable TypeForms.""" # https://docs.pydantic.dev/latest/concepts/performance/#typeadapter-instantiated-once return TypeAdapter(expected) def _cached_adapter[T](expected: TypeForm[T]) -> TypeAdapter[T]: """ - Internal: return a (usually cached) Pydantic TypeAdapter for a TypeForm. + Return a (usually cached) Pydantic `TypeAdapter` for a TypeForm. JSONPointer uses adapters at apply-time to validate that the resolved target conforms to the pointer's type parameter. - Adapters are cached for performance when possible. Unhashable TypeForms are supported - but cannot be cached. + Arguments: + expected: A TypeForm to adapt. + + Returns: + A `TypeAdapter` for `expected`. + + Raises: + TypeError: If `expected` is not a valid TypeForm (for example, a type missing + `__get_pydantic_core_schema__`). + + Notes: + Adapters are cached for performance when possible. Unhashable TypeForms are + supported but cannot be cached. """ try: try: @@ -38,8 +48,8 @@ def _cached_adapter[T](expected: TypeForm[T]) -> TypeAdapter[T]: # It's really just cases like Annotated[int, {"dict":"unhashable"}] that are too rare to support for now. return TypeAdapter(expected) except Exception as e: - raise InvalidJSONPointer( - f"Invalid type parameter for JSON Pointer: {expected!r}. Cannot create TypeAdapter. Did you implement __get_pydantic_core_schema__?" + raise TypeError( + f"Invalid type parameter: {expected!r}. Cannot create TypeAdapter. Did you implement __get_pydantic_core_schema__?" ) from e @@ -82,8 +92,19 @@ def _strict_validator(typeform: TypeForm[Any]) -> core_schema.CoreSchema: """ Build a strict validator for a TypeForm using a cached TypeAdapter. - This keeps validation strict without exposing the internal helper type's full - generated JSON Schema. + Arguments: + typeform: A TypeForm to validate against. + + Returns: + A `CoreSchema` that validates values strictly against `typeform`. + + Raises: + TypeError: If `typeform` is not a valid TypeForm, propagated from + `_cached_adapter`. + + Notes: + This keeps validation strict without exposing the internal helper type's full + generated JSON Schema. """ adapter = _cached_adapter(typeform) @@ -103,12 +124,13 @@ def _validate(value: object) -> object: else: class JSONBoolean: - """Strict JSON boolean helper used in Pydantic-backed patch contracts.""" + """Strict JSON boolean. For type checkers: `bool`.""" @classmethod def __get_pydantic_core_schema__( cls, _source_type: object, _handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for strict boolean validation.""" return _strict_validator(Annotated[bool, Field(strict=True)]) @classmethod @@ -117,15 +139,17 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, _handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return the JSON Schema representation for JSON boolean.""" return {"type": "boolean"} class JSONNumber: - """Strict JSON number helper accepting `int` or finite `float` values.""" + """Strict JSON number. For type checkers: `int | float`.""" @classmethod def __get_pydantic_core_schema__( cls, _source_type: object, _handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for strict number validation (int or finite float).""" type _JSONNumberInternal = Annotated[ # NOTE: document the necessity of field strictness. adapters strict too for preventing "2" -> 2 for JSONBoolean and int/float Annotated[int, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)], @@ -141,15 +165,17 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, _handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return the JSON Schema representation for JSON number.""" return {"type": "number"} class JSONString: - """Strict JSON string helper used in operation models and patch schemas.""" + """Strict JSON string. For type checkers: `str`.""" @classmethod def __get_pydantic_core_schema__( cls, _source_type: object, _handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for strict string validation.""" return _strict_validator(Annotated[str, Field(strict=True)]) @classmethod @@ -158,15 +184,17 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, _handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return the JSON Schema representation for JSON string.""" return {"type": "string"} class JSONNull: - """Strict JSON null helper.""" + """Strict JSON null. For type checkers: `None`.""" @classmethod def __get_pydantic_core_schema__( cls, _source_type: object, _handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for strict null validation.""" return _strict_validator(Annotated[None, Field()]) @classmethod @@ -175,15 +203,17 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, _handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return the JSON Schema representation for JSON null.""" return {"type": "null"} class JSONArray[T]: - """Strict JSON array helper restricted to concrete `list` values.""" + """Strict JSON array. For type checkers: `list[T]`.""" @classmethod def __get_pydantic_core_schema__( cls, source_type: object, handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for strict list validation, parameterized by item type.""" (item_type,) = get_args(source_type) or (Any,) item_schema = handler.generate_schema(item_type) return core_schema.list_schema(item_schema, strict=True) @@ -194,15 +224,17 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return the JSON Schema representation for JSON array, delegating to the handler.""" return handler(_core_schema) class JSONObject[T]: - """Strict JSON object helper restricted to `dict[str, ...]` values.""" + """Strict JSON object. For type checkers: `dict[str, T]`.""" @classmethod def __get_pydantic_core_schema__( cls, source_type: object, handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for strict dict validation, parameterized by value type.""" (value_type,) = get_args(source_type) or (Any,) value_schema = handler.generate_schema(value_type) return core_schema.dict_schema( @@ -215,14 +247,15 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return the JSON Schema representation for JSON object, delegating to the handler.""" return handler(_core_schema) type JSONScalar = JSONBoolean | JSONNumber | JSONString | JSONNull -"""Strict JSON scalar helper union.""" +"""Strict JSON scalar. For type checkers: `JSONBoolean | JSONNumber | JSONString | JSONNull`.""" type JSONContainer[T] = JSONArray[T] | JSONObject[T] -"""Strict JSON container helper union.""" +"""Strict JSON container. For type checkers: `JSONArray[T] | JSONObject[T]`.""" # type-narrowing helpers # NOTE: consider making public type-narrowing helpers @@ -234,44 +267,46 @@ def _is_container(value: JSONValue) -> TypeIs[JSONContainer[JSONValue]]: def _is_object(value: JSONValue) -> TypeIs[JSONObject[JSONValue]]: + """Check whether `value` is a JSON object (dict).""" return isinstance(value, dict) def _is_array(value: JSONValue) -> TypeIs[JSONArray[JSONValue]]: + """Check whether `value` is a JSON array (list).""" return isinstance(value, list) if TYPE_CHECKING: # Static typing: keep JSONValue as a strict JSON union. - type JSONValue = Annotated[ + type JSONValue = Annotated[ # NOTE: document somewhere that you can't do isinstance because these are type aliases JSONScalar | JSONContainer[JSONValue], Field(), - ] # NOTE: document somewhere that you can't do isinstance because these are type aliases - """ - Pydantic-friendly type representing a strict JSON value. - - Notes: - - The standard JSON Patch operation schemas use it for `value` fields. - - `JSONPointer` uses it as the document type for `get`/`add`/`remove`. - - Patch application helpers can optionally validate that inputs are legitimate JSON. - - Containers are restricted to `list` and `dict[str, ...]`. - - Numeric values are restricted to `int` or finite `float` (no NaN/Infinity). - - Pydantic validation is strict (no implicit coercions). - """ + ] else: class JSONValue: """ - Runtime JSON value type with strict validation and minimal OpenAPI schema. - - Validation delegates to the strict JSON union, while - JSON schema is deliberately inlined as `{}` to avoid a named component. + Strict, recursively validated JSON value; no implicit coercions. + + For type checkers: `JSONBoolean | JSONNumber | JSONString | JSONNull | JSONArray[JSONValue] | JSONObject[JSONValue]`. + + Notes: + - Containers are restricted to `list` and `dict[str, ...]`. + - Numbers are restricted to `int` or finite `float` (no `NaN` or `Infinity`). + - Use `JSONBound` as a TypeVar bound when you need a static type constraint without + Pydantic enforcement; use `JSONValue` when you need strict runtime validation. + - Due to `list` invariance, narrower types such as `JSONArray[JSONNumber]` are not + statically assignable to `JSONValue`. Use `cast(JSONValue, value)` at the return + site; the patch engine validates each `apply()` return value against `JSONValue` + at runtime. + See [Limitations in Python's Type System](https://angela-tarantula.github.io/jsonpatchx/developer-reference/limitations-in-python-type-system/). """ @classmethod def __get_pydantic_core_schema__( cls, _source_type: object, _handler: core_schema.GetCoreSchemaHandler ) -> core_schema.CoreSchema: + """Build the Pydantic core schema for recursive strict JSON value validation.""" type _JSONValueInternal = Annotated[ JSONBoolean | JSONNumber @@ -289,23 +324,55 @@ def __get_pydantic_json_schema__( _core_schema: core_schema.CoreSchema, _handler: core_schema.GetJsonSchemaHandler, ) -> dict[str, object]: + """Return an empty JSON Schema for `JSONValue` to avoid polluting generated OpenAPI output.""" return {} def _validate_JSONValue(obj: object) -> JSONValue: + """Validate `obj` as a strict JSON value. + + Arguments: + obj: Candidate value to validate. + + Returns: + obj_copy: a copy of `obj` as a `JSONValue`. NOTE: propagate this copy notice + + Raises: + ValidationError: If `obj` is not a valid JSON value. + """ return _cached_adapter(JSONValue).validate_python(obj, strict=True) -def _validate_typeform(unverified: object, exc_type: type[Exception]) -> TypeForm[Any]: - """Validate a TypeForm parameter.""" # NOTE: move to JSONPointer if it's gonna raise InvalidJSONPointer - try: - _cached_adapter(unverified) # type: ignore[arg-type] - except Exception as e: - raise exc_type(f"type parameter {unverified!r} must be a valid TypeForm") from e +def _validate_typeform(unverified: object) -> TypeForm[Any]: + """Validate that `unverified` is a usable Pydantic TypeForm. + + Arguments: + unverified: Candidate type parameter to validate. + + Returns: + `unverified` cast to `TypeForm[Any]`. + + Raises: + TypeError: If `unverified` cannot be adapted by Pydantic. + """ + _cached_adapter(unverified) # type: ignore[arg-type] return cast(TypeForm[Any], unverified) type JSONBound = JSONScalar | Sequence[JSONBound] | Mapping[str, JSONBound] -"""Bound for recursively JSON-shaped values accepted by generic helpers such as -`JSONPointer[T]`.""" -# Use it like `T = TypeVar("T", default=JSONValue, bound=JSONBound)` +""" +TypeVar bound for JSON-shaped values, used by `JSONPointer[T]` and `JSONSelector[T]`. + +For type checkers: `JSONScalar | Sequence[JSONBound] | Mapping[str, JSONBound]`. + +Notes: + This bound is intentionally permissive due to Python typing limitations. It accepts any + `Sequence` (including `tuple`) and any `Mapping` (including custom mappings), rather than + just `list` and `dict[str, ...]` as strict JSON requires. There is no way to express the + exact recursive JSON container constraint in a TypeVar bound; see + [Limitations in Python's Type System](../developer-reference/limitations-in-python-type-system.md). + + `JSONBound` is a static-only constraint with no runtime validation; it exists to let type + checkers verify that a type parameter is plausibly JSON-shaped. Use `JSONValue` when you + need strict Pydantic enforcement at runtime. +""" diff --git a/pyproject.toml b/pyproject.toml index 2a85e57e..65aa7b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,9 +51,9 @@ classifiers = [ dependencies = [ "jsonpointer>=3.0.0", "pydantic>=2.12.5", - "python-jsonpath[strict]>=2.0.2; python_version <= '3.13'", - "python-jsonpath>=2.0.2; python_version >= '3.14'", + "python-jsonpath>=2.0.2", "typing-extensions>=4.15.0", + "pydantic-core>=2.29.0", ] [project.urls] @@ -68,6 +68,15 @@ Tests = "https://github.com/angela-tarantula/jsonpatchx/actions/workflows/python fastapi = [ "fastapi>=0.128.0", ] +strict-jsonpath = [ + # Full RFC 9535 JSONPath compliance (RFC 9485 I-Regexp validation and the + # third-party `regex` engine for match()/search()). Do not install this + # extra on a free-threaded Python build (3.13t, 3.14t, ...): its + # `iregexp-check` dependency segfaults on import there. There is no PEP + # 508 marker that can express "not free-threaded", so this cannot be + # enforced automatically; opt in only on standard (GIL) interpreters. + "python-jsonpath[strict]>=2.0.2", +] [dependency-groups] dev = [ @@ -75,11 +84,13 @@ dev = [ { include-group = "tests" }, { include-group = "lint" }, { include-group = "typing" }, + { include-group = "hooks" }, ] ci = [ { include-group = "tests" }, { include-group = "lint" }, { include-group = "typing" }, + { include-group = "hooks" }, ] snapshots = [ { include-group = "tests" }, @@ -87,6 +98,9 @@ snapshots = [ lint = [ "ruff>=0.15.7", ] +hooks = [ + "prek==0.4.8", +] tests = [ "fastapi>=0.135.1", "httpx>=0.28.1", @@ -102,6 +116,7 @@ fuzz = [ "pyinstaller>=6.10.0", ] docs = [ + "griffe-pydantic>=1.3.1", "mkdocstrings[python]>=1.0.3", "zensical>=0.0.30", ] diff --git a/scripts/render_agent_fixture.py b/scripts/render_agent_fixture.py new file mode 100644 index 00000000..0553709d --- /dev/null +++ b/scripts/render_agent_fixture.py @@ -0,0 +1,279 @@ +"""Render a disposable agent fixture bundle for the custom-operation agent guide. + +This script backs the manual harness described in ``tests/agents/README.md``. +It is not part of the pytest suite. For the holdout modes (``from-examples``, +``from-contract``), it takes ``tests/agents/official_recipes.py``, removes +exactly one marked operation, and writes a bundle you paste into a child +agent: the guide, the reduced recipe source, a schema artifact, and the task +prompt. For ``evolve-contract``, it removes only the later evolution stages +of one operation (see ``EVOLUTION_STAGES`` in ``official_recipes.py``), +keeping the current (v1) shape in place, and asks the agent to produce the +next stages, using the "Evolving an Operation's Contract" section already in +the guide. Nothing this script writes is meant to be checked in. +""" + +from __future__ import annotations + +import argparse +import inspect +import json +import re +import sys +import tempfile +from pathlib import Path +from typing import Annotated, Union + +from pydantic import Field, TypeAdapter + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) # predictable absolute imports + +# pylint: disable=wrong-import-position +from jsonpatchx import OperationSchema # noqa: E402 +from tests.agents import official_recipes as recipes # noqa: E402 + +MODULE_PATH = ROOT / "tests" / "agents" / "official_recipes.py" +GUIDE_PATH = ROOT / "docs" / "custom-operation-agent-guide.md" +TASK_ROOT = ROOT / "tests" / "agents" +HOLDOUT_MODES = ("from-examples", "from-contract") +MODES = (*HOLDOUT_MODES, "evolve-contract") + + +def _evolved_class_names() -> set[str]: + """Names of every operation class that represents a post-v1 stage. + + Returns: + Every class name from `EVOLUTION_STAGES` values except each list's + first (v1) entry, since v1 is already a normal pool member. + """ + return {name for stages in recipes.EVOLUTION_STAGES.values() for name in stages[1:]} + + +def _all_classes() -> list[type[OperationSchema]]: + """List every live operation class defined directly in ``official_recipes``. + + Returns: + Every `OperationSchema` subclass defined in the module, in + definition order, excluding re-exported built-in operations such as + `AddOp` that the module only imports, and excluding post-v1 + evolution-stage classes (see `_evolved_class_names`), which would + otherwise collide with v1's `op` literal in the discriminated union. + """ + evolved = _evolved_class_names() + return [ + value + for value in vars(recipes).values() + if ( + inspect.isclass(value) + and issubclass(value, OperationSchema) + and value is not OperationSchema + and value.__module__ == recipes.__name__ + and value.__name__ not in evolved + ) + ] + + +def _strip_holdout(source: str, slug: str) -> str: + """Remove one marked operation block from the recipe source. + + Arguments: + source: The full text of `official_recipes.py`. + slug: A key from `HOLDOUT_OPERATIONS`. + + Returns: + The source with the matching `# agent-example: :start` / + `:end` block removed. + + Raises: + SystemExit: If the markers for `slug` are not found exactly once. + """ + start = f"# agent-example: {slug}:start" + end = f"# agent-example: {slug}:end" + pattern = re.compile(rf"\n?{re.escape(start)}\n.*?\n{re.escape(end)}\n", re.DOTALL) + reduced, count = pattern.subn("\n", source) + if count != 1: + raise SystemExit( + f"expected exactly one marker block for {slug!r} in {MODULE_PATH}, found {count}" + ) + return re.sub(r"\n{3,}", "\n\n\n", reduced) + + +def _strip_evolution_stages(source: str, slug: str) -> str: + """Remove every post-v1 evolution-stage block for `slug`, keeping v1. + + Arguments: + source: The full text of `official_recipes.py`. + slug: A key from `EVOLUTION_STAGES`. + + Returns: + The source with every `# agent-example: :vN:start` / `:end` + block removed, for N >= 2. + + Raises: + SystemExit: If no stage marker blocks for `slug` are found. + """ + pattern = re.compile( + rf"\n?# agent-example: {re.escape(slug)}:v\d+:start\n.*?\n" + rf"# agent-example: {re.escape(slug)}:v\d+:end\n", + re.DOTALL, + ) + reduced, count = pattern.subn("\n", source) + if count < 1: + raise SystemExit( + f"expected at least one evolution-stage marker block for {slug!r} " + f"in {MODULE_PATH}, found {count}" + ) + return re.sub(r"\n{3,}", "\n\n\n", reduced) + + +def _registry_schema(classes: list[type[OperationSchema]]) -> dict[str, object]: + """Build the discriminated-union JSON Schema for a set of operations. + + Arguments: + classes: Operation classes to include. + + Returns: + The JSON Schema for a patch document restricted to `classes`. + """ + # Runtime-generated type; type checkers can't reason about it. See + # "Environment-Specific Registries" in docs/user-guide/registries-and-routes.md. + registry = Annotated[Union[tuple(classes)], Field(discriminator="op")] # type: ignore[valid-type] + return TypeAdapter(registry).json_schema() + + +def _render_holdout(slug: str, mode: str, out_dir: Path) -> None: + """Write a from-examples or from-contract bundle to `out_dir`. + + Arguments: + slug: A key from `HOLDOUT_OPERATIONS`, such as `"lowercase"`. + mode: Either `"from-examples"` or `"from-contract"`. + out_dir: Directory to write the bundle into; created if missing. + + Raises: + SystemExit: If `slug` is unknown or the matching task file is missing. + """ + if slug not in recipes.HOLDOUT_OPERATIONS: + choices = ", ".join(sorted(recipes.HOLDOUT_OPERATIONS)) + raise SystemExit(f"unknown holdout {slug!r}; choices: {choices}") + + classes = _all_classes() + by_name = {cls.__name__: cls for cls in classes} + holdout_cls = by_name[recipes.HOLDOUT_OPERATIONS[slug]] + remaining = [cls for cls in classes if cls is not holdout_cls] + + task_path = TASK_ROOT / mode / slug / "task.md" + if not task_path.exists(): + raise SystemExit(f"missing task file: {task_path}") + + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "AGENT_GUIDE.md").write_text(GUIDE_PATH.read_text()) + (out_dir / "recipes.py").write_text(_strip_holdout(MODULE_PATH.read_text(), slug)) + (out_dir / "task.md").write_text(task_path.read_text()) + + if mode == "from-examples": + schema = _registry_schema(remaining) + (out_dir / "schema.json").write_text( + json.dumps(schema, indent=2, sort_keys=True) + "\n" + ) + else: + schema = holdout_cls.model_json_schema() + (out_dir / "contract.json").write_text( + json.dumps(schema, indent=2, sort_keys=True) + "\n" + ) + + +def _render_evolve_contract(slug: str, out_dir: Path) -> None: + """Write an evolve-contract bundle to `out_dir`. + + Only the post-v1 evolution stages of `slug` are removed; v1 stays as the + agent's starting point. The agent gets the full remaining recipe pool, + the full schema, and is asked to produce the missing stages, using the + guide's "Evolving an Operation's Contract" section. + + Arguments: + slug: A key in `EVOLUTION_STAGES`, and a task directory name under + `tests/agents/evolve-contract/`. + out_dir: Directory to write the bundle into; created if missing. + + Raises: + SystemExit: If `slug` is unknown or the matching task file is missing. + """ + if slug not in recipes.EVOLUTION_STAGES: + choices = ", ".join(sorted(recipes.EVOLUTION_STAGES)) + raise SystemExit(f"unknown evolve-contract target {slug!r}; choices: {choices}") + + task_path = TASK_ROOT / "evolve-contract" / slug / "task.md" + if not task_path.exists(): + raise SystemExit(f"missing task file: {task_path}") + + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "AGENT_GUIDE.md").write_text(GUIDE_PATH.read_text()) + (out_dir / "recipes.py").write_text( + _strip_evolution_stages(MODULE_PATH.read_text(), slug) + ) + (out_dir / "task.md").write_text(task_path.read_text()) + + schema = _registry_schema(_all_classes()) + (out_dir / "schema.json").write_text( + json.dumps(schema, indent=2, sort_keys=True) + "\n" + ) + + +def render(slug: str, mode: str, out_dir: Path) -> None: + """Write one fixture bundle to `out_dir`. + + Arguments: + slug: A task directory name under `tests/agents//`. For the + holdout modes, must also be a key in `HOLDOUT_OPERATIONS`. + mode: One of `MODES`. + out_dir: Directory to write the bundle into; created if missing. + """ + if mode in HOLDOUT_MODES: + _render_holdout(slug, mode, out_dir) + else: + _render_evolve_contract(slug, out_dir) + print(f"wrote fixture bundle to {out_dir}") + + +def main() -> int: + """Parse arguments and render one fixture bundle. + + Returns: + Process exit code. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--target", + required=True, + help="For from-examples/from-contract: a key in HOLDOUT_OPERATIONS, " + "e.g. 'lowercase'. For evolve-contract: a task directory name under " + "tests/agents/evolve-contract/, e.g. 'replace-substring'.", + ) + parser.add_argument( + "--mode", + required=True, + choices=MODES, + help="from-examples: reconstruct a removed op from surrounding code. " + "from-contract: implement a removed op to match a given schema. " + "evolve-contract: evolve one op's contract through its next stages, " + "nothing removed.", + ) + parser.add_argument( + "--out", + type=Path, + default=None, + help="Output directory. Defaults to a directory under the system temp dir.", + ) + args = parser.parse_args() + + out_dir = args.out or ( + Path(tempfile.gettempdir()) + / "jsonpatchx-agent-fixture" + / f"{args.mode}-{args.target}" + ) + render(args.target, args.mode, out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_openapi_snapshots.py b/scripts/update_openapi_snapshots.py index 607774fa..79773cda 100644 --- a/scripts/update_openapi_snapshots.py +++ b/scripts/update_openapi_snapshots.py @@ -13,6 +13,7 @@ from __future__ import annotations +import argparse import json import sys from pathlib import Path @@ -24,17 +25,47 @@ from examples.loader import DEMO_MAP # noqa: E402 -def main() -> None: - """Write all generated OpenAPI snapshot files.""" +def write_snapshots() -> bool: + """Write all generated OpenAPI snapshot files. + + Returns: + True if any snapshot file content changed. + """ + changed = False for demo in DEMO_MAP.values(): path = demo.snapshot_path schema = demo.app.openapi() + rendered = json.dumps(schema, indent=2, sort_keys=True) + "\n" + previous = path.read_text() if path.exists() else None path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n") + path.write_text(rendered) + if previous != rendered: + changed = True print(f"wrote {path}") + return changed + + +def main() -> int: + """Write all generated OpenAPI snapshot files. + + Returns: + Process exit code. + """ + parser = argparse.ArgumentParser() + parser.add_argument( + "--fail-on-change", + action="store_true", + help="Return exit code 1 when snapshot files were rewritten.", + ) + args = parser.parse_args() + + changed = write_snapshots() + if changed and args.fail_on_change: + return 1 + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/tests/agents/README.md b/tests/agents/README.md index 938da394..c34310cf 100644 --- a/tests/agents/README.md +++ b/tests/agents/README.md @@ -1,84 +1,154 @@ # Agent Guide Tests These fixtures are manual regression checks for -[`examples/AGENTS.md`](../../examples/AGENTS.md). - -They are meant to answer a narrow question: if a child agent is given only the -inline text of [`examples/AGENTS.md`](../../examples/AGENTS.md) plus one prompt -fixture, does it generate a good custom operation? +[`docs/custom-operation-agent-guide.md`](../../docs/custom-operation-agent-guide.md). +They are manual fixtures, not automated tests, and are not part of the pytest +suite. + +They are a leave-one-out reconstruction test: +[`official_recipes.py`](official_recipes.py) is a pool of real JsonPatchX custom +operations. For each holdout, one operation is removed and a child agent is +asked to write it back, given only the guide, the reduced pool, and one task +prompt. + +- **`from-examples//`**: the agent gets a natural-language task plus the + JSON Schema of the remaining operations, and must infer the missing + operation's wire contract and behavior together. This is the stronger test of + the guide plus surrounding examples. +- **`from-contract//`**: the agent gets the same task plus the exact JSON + Schema the missing operation's request body must produce, and must implement + code matching a known contract. This is the more realistic "extend an existing + API to match a given schema" test. + +Current holdouts: `lowercase`, `swap`, `replace-array-value`, `bound-number`, +`add-missing-key`. Each has a `task.md` under both `from-examples/` and +`from-contract/`. + +There is also one non-holdout category: + +- **`evolve-contract//`**: only the _later_ evolution stages of one + operation are removed; the current (v1) shape stays in the pool as the agent's + starting point. `official_recipes.py`'s `EVOLUTION_STAGES` maps each slug to + its v1/v2/v3 class names; v2+ are wrapped in + `# agent-example: :vN:start` / `:end` markers, are real, tested code + (not prose), and are excluded from `schema.json` so they never leak into the + bundle. The agent is asked to produce the missing stages using the "Evolving + an Operation's Contract" section of `AGENT_GUIDE.md`. + + Note that `ReplaceSubstringOp`'s full evolution is already public in + [`evolving-patch-contracts.md`](../../docs/user-guide/evolving-patch-contracts.md). + Testing on it also measures training-data recall, not just generalization from + the guide; weigh that when interpreting its result. + + Current targets, each an additive change followed by a field deprecation: + + - `replace-substring`: adds `strict` (non-strict mode), then deprecates it. + - `add-to-set`: adds `ignore_case`, then deprecates it in favor of a mutually + exclusive `comparison` enum. + - `set-rollout-percentage`: adds `relative`, then deprecates it in favor of a + mutually exclusive `mode` enum (needed because `relative` alone cannot + express "decrease" without giving `percentage` a sign). + - `test-missing`: adds `require_parent` defaulting to `False` (backward + compatible; a negative array index is also rejected when set, and the + document root is exempt since it always exists), then deprecates it while + flipping its default to `True` โ€” a deliberate breaking change bundled with + the deprecation, not a routine one. + +## Render a Fixture Bundle + +[`scripts/render_agent_fixture.py`](../../scripts/render_agent_fixture.py) +generates the disposable bundle you paste into a child agent. Nothing it writes +is checked in; only `official_recipes.py` and the `task.md` files are. + +```sh +uv run python scripts/render_agent_fixture.py --target lowercase --mode from-examples +uv run python scripts/render_agent_fixture.py --target add-to-set --mode evolve-contract +``` -They are not a CI harness and they are not proof of workspace isolation. +For `from-examples`/`from-contract`, this writes `AGENT_GUIDE.md`, `recipes.py` +(with the holdout removed), `schema.json` (or `contract.json` for +`--mode from-contract`), and `task.md`. For `evolve-contract`, it writes +`AGENT_GUIDE.md`, `recipes.py` (with the target's v2+ stages removed, v1 kept), +`schema.json` (built from what remains, so no evolved field or class name +appears in it), and `task.md`. Bundles are written under your system temp +directory by default (or `--out DIR`). ## Run a Prompt-Only Check -Use this procedure in Codex when you want a reproducible evaluation: +Use this procedure when you want a reproducible evaluation: -1. Pick one fixture from [`tests/agents/`](./). -2. Read [`examples/AGENTS.md`](../../examples/AGENTS.md). -3. Spawn a child agent with `fork_context:false`. -4. Paste the full contents of [`examples/AGENTS.md`](../../examples/AGENTS.md) - into the child prompt. -5. Paste exactly one fixture into the same child prompt. -6. Add this instruction: +1. Pick a target and a mode, then render its bundle as shown above. +2. Spawn a child agent that does not inherit this conversation. +3. Paste every file the bundle contains into the child prompt, each clearly + labeled. +4. Add this instruction: ```text - Treat the AGENTS text below as the repository instruction source for this - task. Do not inspect workspace files. Use only the AGENTS text and the - prompt. Return only Python code. + Treat the guide text as the repository instruction source for this task. + Do not inspect workspace files. Use only the pasted text. Return only + Python code. ``` -7. Save or paste the returned Python with the fixture name. -8. Review the returned code against the rubric below. - -Use one fixture per child agent. Do not bundle multiple fixtures into one run. - -## Use the Standard Prompt Shape - -Keep the harness wording stable so changes in output are more likely to reflect -changes in [`examples/AGENTS.md`](../../examples/AGENTS.md) rather than -evaluator drift. - -```text -Treat the AGENTS text below as the repository instruction source for this task. -Do not inspect workspace files. Use only the AGENTS text and the prompt. -Return only Python code. +5. Save or paste the returned Python with the target and mode in the name. +6. Review the returned code against the rubric below. -=== AGENTS TEXT BEGIN === - -=== AGENTS TEXT END === - -=== PROMPT BEGIN === - -=== PROMPT END === -``` +Use one target per child agent. Do not bundle multiple targets into one run. ## Judge the Result -Check the output against the prompt and against the local authoring patterns in -[`examples/AGENTS.md`](../../examples/AGENTS.md). +Check the output against the task and against the local authoring patterns in +the guide and in `official_recipes.py`. - The code should be complete Python, not pseudocode. - It should subclass `OperationSchema` and implement `apply()`. - It should import from public JsonPatchX modules rather than internal ones. - It should not invent unsupported hooks such as `patch()`, `expand()`, or `to_builtin_ops()`. +- For `from-contract`, the generated schema (`ClassName.model_json_schema()`) + should match `contract.json`: same field names, types, and requiredness. - It should choose error types and schema techniques that fit the operation, rather than blindly using one pattern everywhere. -When reporting the run, include the full generated Python for each fixture. Do +When reporting the run, include the full generated Python for each holdout. Do not ask the human reviewer to rely only on a summary. -For specific fixture families, also check: - -- State-aware object-key prompts: use `classify_state()` and the real - `TargetState` members when the behavior depends on why the path is invalid. -- Schema-rich prompts: make the richness visible in generated schema, not only - in runtime validation. Titles, descriptions, `Field(...)` metadata, aliases, - and `json_schema_extra` are all fair game when they honestly express the - contract. -- Deliberate-error prompts: infer repo-appropriate errors such as - `PatchConflictError` or `PydanticCustomError` from the guide rather than from - the fixture wording. +For specific holdouts, also check: + +- `add-missing-key`: uses `classify_state()` and the real `TargetState` members + to distinguish why a path is invalid, not one generic error. +- `bound-number`: makes the schema-richness visible in the generated schema (the + `anyOf` requiring `min` or `max`), not only in runtime validation. +- `swap`: infers a repo-appropriate structured validation error + (`PydanticCustomError`) rather than a generic `ValueError`. + +For `evolve-contract`, also check: + +- The `op` literal is unchanged at both stages. +- The additive stage's new field defaults to the current behavior, so a caller + that never sends it sees no change, and `percentage`'s sign is never + reinterpreted in `set-rollout-percentage`. `test-missing`'s additive stage + (`require_parent`, default `False`) must also be fully backward compatible; it + is the deprecation stage that is the deliberate exception here (see below), + not the additive one. +- `replace-substring`, `add-to-set`, `set-rollout-percentage`: the deprecation + stage's fields are mutually exclusive, and neither has a concrete default: + check that omission is detected via `model_fields_set` + (`"field" not in self.model_fields_set`), not a truthiness check on a + `Field(default=...)` value. +- `test-missing`: the additive stage must also reject a negative array index as + a conflict when `require_parent` is set, and must exempt the document root + from the new field's conflict entirely (root is its own parent and always + exists). The deprecation stage must deprecate the field and flip its default + to `True`, but both `True` and `False` must still fully work when sent + explicitly โ€” the deprecation only signals a future removal, it does not itself + narrow what the field accepts. Say plainly that the default flip is a + deliberate breaking change bundled with the deprecation, not a routine one. + +Compare each stage against the real answer in `official_recipes.py` +(`ReplaceSubstringOpAdditive`/`Deprecated`, `AddToSetOpIgnoreCase`/`Comparison`, +`SetRolloutPercentageOpRelative`/`Mode`, +`TestMissingOpRequireParent`/`ParentRequired`) โ€” those are excluded from the +bundle, not secret from you as the reviewer. ## Know the Limits @@ -87,22 +157,11 @@ This harness is intentionally modest. - It does show whether the child agent can work from the pasted instruction text plus the fixture. - It does not prove that the child agent never inspected the workspace. -- `fork_context:false` prevents thread-history leakage, but it does not itself - remove file access. +- A non-inheriting child agent (`fork_context:false` in Codex) prevents + thread-history leakage, but it does not itself remove file access. If you need the stronger claim that the child agent only used the pasted text, you need more than this harness: - inspect the child tool logs, or - run the evaluation in a more isolated environment - -## Current Fixtures - -- [`lowercase-op.md`](lowercase-op.md): minimal happy-path operation -- [`replace-array-value-op.md`](replace-array-value-op.md): runtime conflict - inference -- [`swap-op-structured-validation.md`](swap-op-structured-validation.md): - deliberate validation-style choice -- [`bound-number-op.md`](bound-number-op.md): schema-rich contract design -- [`add-missing-key-op.md`](add-missing-key-op.md): state-aware object-key - behavior diff --git a/tests/agents/add-missing-key-op.md b/tests/agents/add-missing-key-op.md deleted file mode 100644 index a6bb62d0..00000000 --- a/tests/agents/add-missing-key-op.md +++ /dev/null @@ -1,12 +0,0 @@ -# Create Object Member Operation Prompt - -Write a JsonPatchX custom operation named `CreateObjectMemberOp`. - -It should add an object member only when `path` names a missing object key. - -If the key already exists, raise an error. - -If behavior depends on why the path is invalid, distinguish those cases instead -of collapsing them into one generic error. - -Return only Python code. diff --git a/tests/agents/bound-number-op.md b/tests/agents/bound-number-op.md deleted file mode 100644 index 0d153b8e..00000000 --- a/tests/agents/bound-number-op.md +++ /dev/null @@ -1,12 +0,0 @@ -# Bound Number Operation Prompt - -Write a JsonPatchX custom operation named `BoundNumberOp`. - -It should clamp a numeric value into an inclusive range. - -Make it schema-rich. Make that richness visible in the generated schema, not -only in runtime validation. - -The wire contract must clearly require at least one of `floor` or `ceiling`. - -Return only Python code. diff --git a/tests/agents/evolve-contract/add-to-set/task.md b/tests/agents/evolve-contract/add-to-set/task.md new file mode 100644 index 00000000..b30a14a2 --- /dev/null +++ b/tests/agents/evolve-contract/add-to-set/task.md @@ -0,0 +1,29 @@ +# Evolve: `AddToSetOp` (additive change, then field deprecation) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide, including its "Evolving an + Operation's Contract" section. +- `recipes.py`: the operation catalog, including the current `AddToSetOp`. +- `schema.json`: the JSON Schema for every operation in `recipes.py`. + +`AddToSetOp` currently adds a value to an array only if it is not already +present, using whole-JSON-value equality. + +Evolve its contract through its next two stages, in order, following +`AGENT_GUIDE.md`'s "Evolving an Operation's Contract" section: + +1. **Additive change**: string values in the array should optionally be compared + case-insensitively. Add this without breaking any caller that has never heard + of the new field. +2. **Field deprecation**: some time later, that field needs to become one of + several comparison modes instead of a single on/off switch (for example, + exact vs. case-insensitive). Deprecate the original field in favor of a new + one, and treat the two as mutually exclusive. Decide deliberately whether + either field should have a concrete default at this stage. + +Return two Python definitions of `AddToSetOp` in order, one per stage, each a +complete class ready to replace the previous one in `recipes.py`. Before each +definition, state in one sentence why that stage is a compatible change under +`AGENT_GUIDE.md`'s rules. Do not change the `op` literal at either stage, and do +not inspect any other files. diff --git a/tests/agents/evolve-contract/replace-substring/task.md b/tests/agents/evolve-contract/replace-substring/task.md new file mode 100644 index 00000000..652c4cc1 --- /dev/null +++ b/tests/agents/evolve-contract/replace-substring/task.md @@ -0,0 +1,28 @@ +# Evolve: `ReplaceSubstringOp` (additive change, then field deprecation) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide, including its "Evolving an + Operation's Contract" section. +- `recipes.py`: the operation catalog, including the current + `ReplaceSubstringOp`. +- `schema.json`: the JSON Schema for every operation in `recipes.py`. + +`ReplaceSubstringOp` currently replaces a substring within a string field, and +always raises a conflict error when the target substring is missing. + +Evolve its contract through its next two stages, in order, following +`AGENT_GUIDE.md`'s "Evolving an Operation's Contract" section: + +1. **Additive change**: clients now need a non-strict mode, where replacing a + substring that is not present is a no-op instead of an error. Add this + without breaking any existing caller that has never heard of the new field. +2. **Field deprecation**: some time later, that same field needs to go away. + Deprecate it before removal, in a way that still tells you whether a caller + is relying on the old default. + +Return two Python definitions of `ReplaceSubstringOp` in order, one per stage, +each a complete class ready to replace the previous one in `recipes.py`. Before +each definition, state in one sentence why that stage is a compatible change +under `AGENT_GUIDE.md`'s rules. Do not change the `op` literal at either stage, +and do not inspect any other files. diff --git a/tests/agents/evolve-contract/set-rollout-percentage/task.md b/tests/agents/evolve-contract/set-rollout-percentage/task.md new file mode 100644 index 00000000..951c4c15 --- /dev/null +++ b/tests/agents/evolve-contract/set-rollout-percentage/task.md @@ -0,0 +1,33 @@ +# Evolve: `SetRolloutPercentageOp` (additive change, then field deprecation) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide, including its "Evolving an + Operation's Contract" section. +- `recipes.py`: the operation catalog, including the current + `SetRolloutPercentageOp`. +- `schema.json`: the JSON Schema for every operation in `recipes.py`. + +`SetRolloutPercentageOp` currently sets a named rollout variation to an absolute +percentage, adjusting the complementary variation so the two sum to 100. + +Evolve its contract through its next two stages, in order, following +`AGENT_GUIDE.md`'s "Evolving an Operation's Contract" section: + +1. **Additive change**: a caller now wants to increase a variation's percentage + by a relative amount instead of setting it outright. Add this without + reinterpreting what `percentage` itself means (its sign should never have to + carry meaning), and without breaking any caller that has never heard of the + new field. +2. **Field deprecation**: the field you just added can only increase a + variation; it cannot express "decrease" without giving `percentage` a sign, + which would reopen the exact problem you just avoided. Deprecate it in favor + of a field that can express set, increase, and decrease explicitly, and treat + the two as mutually exclusive. Decide deliberately whether either field + should have a concrete default at this stage. + +Return two Python definitions of `SetRolloutPercentageOp` in order, one per +stage, each a complete class ready to replace the previous one in `recipes.py`. +Before each definition, state in one sentence why that stage is a compatible +change under `AGENT_GUIDE.md`'s rules. Do not change the `op` literal at either +stage, and do not inspect any other files. diff --git a/tests/agents/evolve-contract/test-missing/task.md b/tests/agents/evolve-contract/test-missing/task.md new file mode 100644 index 00000000..89c93b12 --- /dev/null +++ b/tests/agents/evolve-contract/test-missing/task.md @@ -0,0 +1,42 @@ +# Evolve: `TestMissingOp` (additive change, then field deprecation) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide, including its "Evolving an + Operation's Contract" section. +- `recipes.py`: the operation catalog, including the current `TestMissingOp`. +- `schema.json`: the JSON Schema for every operation in `recipes.py`. + +`TestMissingOp` currently succeeds whenever `path` does not resolve to an +existing value. That includes the case where `path`'s parent does not exist +either: for example, `/a/b` reads as "missing" (success) whenever `/a` itself is +missing, which hides a structural problem behind the same result as an ordinary +missing value. + +Evolve its contract through its next two stages, in order, following +`AGENT_GUIDE.md`'s "Evolving an Operation's Contract" section: + +1. **Additive change**: add a way to require that `path`'s parent actually + exists, and to reject a negative array index outright, since its target + position is not stable enough to test against. Default the new field so a + caller who never sends it keeps the exact old behavior. When a caller opts + in: a missing parent, or a negative array index, is a state conflict + (`PatchConflictError`), not a passed test. The document root is a special + case: it is its own parent and always exists once there is a document to + patch, so targeting the root should never trigger the new field's conflict, + regardless of whether the field is set. +2. **Field deprecation**: the team has judged the old vacuous-success default to + be a latent correctness gap, not a feature worth preserving, and wants every + caller on the stricter behavior by default going forward. Deprecate the field + and flip its default at the same time. The field itself keeps working exactly + as before for both values; the deprecation is only a signal that it will be + removed entirely in a future version once callers have migrated off the old + default. State plainly that this default flip is a deliberate, called-out + breaking change bundled with the deprecation, not a routine one. + +Return two Python definitions of `TestMissingOp` in order, one per stage, each a +complete class ready to replace the previous one in `recipes.py`. Before each +definition, state in one sentence why that stage is a compatible change under +`AGENT_GUIDE.md`'s rules (or, for stage 2, why its default flip is a deliberate, +called-out exception). Do not change the `op` literal at either stage, and do +not inspect any other files. diff --git a/tests/agents/from-contract/add-missing-key/task.md b/tests/agents/from-contract/add-missing-key/task.md new file mode 100644 index 00000000..8c7b49de --- /dev/null +++ b/tests/agents/from-contract/add-missing-key/task.md @@ -0,0 +1,21 @@ +# Holdout: `AddMissingKeyOp` (from contract) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `contract.json`: the exact JSON Schema the missing operation's request body + must produce. + +One operation is missing: `AddMissingKeyOp`. It behaves like `add`, but only +when `path` names a missing object key. If the key already exists, or the path +is otherwise invalid, distinguish why instead of collapsing every case into one +generic error. + +Implement `AddMissingKeyOp` so its generated schema matches `contract.json` +exactly: same field names, types, and requiredness. Use `contract.json` as the +source of truth for the wire shape, and the description above as the source of +truth for behavior. + +Return only the Python definition of `AddMissingKeyOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-contract/bound-number/task.md b/tests/agents/from-contract/bound-number/task.md new file mode 100644 index 00000000..4f0db443 --- /dev/null +++ b/tests/agents/from-contract/bound-number/task.md @@ -0,0 +1,20 @@ +# Holdout: `ClampOp` (from contract) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `contract.json`: the exact JSON Schema the missing operation's request body + must produce. + +One operation is missing: `ClampOp`. It clamps a numeric value at `path` into an +inclusive range. + +Implement `ClampOp` so its generated schema matches `contract.json` exactly: +same field names, types, requiredness, the `anyOf` constraint requiring at least +one of `min` or `max`, and the same `description`, `x-discovery-terms`, and +`examples` metadata. Use `contract.json` as the source of truth for the wire +shape, and the description above as the source of truth for behavior. + +Return only the Python definition of `ClampOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-contract/lowercase/task.md b/tests/agents/from-contract/lowercase/task.md new file mode 100644 index 00000000..09cba2f2 --- /dev/null +++ b/tests/agents/from-contract/lowercase/task.md @@ -0,0 +1,18 @@ +# Holdout: `LowercaseOp` (from contract) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `contract.json`: the exact JSON Schema the missing operation's request body + must produce. + +One operation is missing: `LowercaseOp`. It lowercases a string value at `path`. + +Implement `LowercaseOp` so its generated schema matches `contract.json` exactly: +same field names, types, and requiredness. Use `contract.json` as the source of +truth for the wire shape, and the description above as the source of truth for +behavior. + +Return only the Python definition of `LowercaseOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-contract/replace-array-value/task.md b/tests/agents/from-contract/replace-array-value/task.md new file mode 100644 index 00000000..1e039033 --- /dev/null +++ b/tests/agents/from-contract/replace-array-value/task.md @@ -0,0 +1,20 @@ +# Holdout: `ReplaceArrayValueOp` (from contract) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `contract.json`: the exact JSON Schema the missing operation's request body + must produce. + +One operation is missing: `ReplaceArrayValueOp`. It replaces matching array +members by value, first occurrence or all occurrences, and raises an error if +the value to replace is not present. + +Implement `ReplaceArrayValueOp` so its generated schema matches `contract.json` +exactly: same field names, types, and requiredness. Use `contract.json` as the +source of truth for the wire shape, and the description above as the source of +truth for behavior. + +Return only the Python definition of `ReplaceArrayValueOp`, ready to add back +into `recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-contract/swap/task.md b/tests/agents/from-contract/swap/task.md new file mode 100644 index 00000000..7748dd26 --- /dev/null +++ b/tests/agents/from-contract/swap/task.md @@ -0,0 +1,20 @@ +# Holdout: `SwapOp` (from contract) + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `contract.json`: the exact JSON Schema the missing operation's request body + must produce. + +One operation is missing: `SwapOp`. It swaps the values at pointers `a` and `b`. +Reject cases where one pointer is an ancestor of the other, and make that +validation choice deliberately rather than mechanically. + +Implement `SwapOp` so its generated schema matches `contract.json` exactly: same +field names, types, and requiredness. Use `contract.json` as the source of truth +for the wire shape, and the description above as the source of truth for +behavior. + +Return only the Python definition of `SwapOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-examples/add-missing-key/task.md b/tests/agents/from-examples/add-missing-key/task.md new file mode 100644 index 00000000..43ab6f3b --- /dev/null +++ b/tests/agents/from-examples/add-missing-key/task.md @@ -0,0 +1,19 @@ +# Holdout: `AddMissingKeyOp` + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `schema.json`: the JSON Schema for the remaining operations, for style + reference. + +One operation is missing: `AddMissingKeyOp`. It behaves like `add`, but only +when `path` names a missing object key. If the key already exists, or the path +is otherwise invalid, distinguish why instead of collapsing every case into one +generic error. + +Implement `AddMissingKeyOp` in the same style as the operations already in +`recipes.py`. + +Return only the Python definition of `AddMissingKeyOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-examples/bound-number/task.md b/tests/agents/from-examples/bound-number/task.md new file mode 100644 index 00000000..544271e6 --- /dev/null +++ b/tests/agents/from-examples/bound-number/task.md @@ -0,0 +1,17 @@ +# Holdout: `ClampOp` + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `schema.json`: the JSON Schema for the remaining operations, for style + reference. + +One operation is missing: `ClampOp`. It clamps a numeric value at `path` into an +inclusive range. Make it schema-rich: the wire contract must clearly require at +least one of `min` or `max`. + +Implement `ClampOp` in the same style as the operations already in `recipes.py`. + +Return only the Python definition of `ClampOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-examples/lowercase/task.md b/tests/agents/from-examples/lowercase/task.md new file mode 100644 index 00000000..dc95e5b0 --- /dev/null +++ b/tests/agents/from-examples/lowercase/task.md @@ -0,0 +1,16 @@ +# Holdout: `LowercaseOp` + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `schema.json`: the JSON Schema for the remaining operations, for style + reference. + +One operation is missing: `LowercaseOp`. It lowercases a string value at `path`. + +Implement `LowercaseOp` in the same style as the operations already in +`recipes.py`. + +Return only the Python definition of `LowercaseOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-examples/replace-array-value/task.md b/tests/agents/from-examples/replace-array-value/task.md new file mode 100644 index 00000000..4631f0c9 --- /dev/null +++ b/tests/agents/from-examples/replace-array-value/task.md @@ -0,0 +1,18 @@ +# Holdout: `ReplaceArrayValueOp` + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `schema.json`: the JSON Schema for the remaining operations, for style + reference. + +One operation is missing: `ReplaceArrayValueOp`. It replaces matching array +members by value, first occurrence or all occurrences, and raises an error if +the value to replace is not present. + +Implement `ReplaceArrayValueOp` in the same style as the operations already in +`recipes.py`. + +Return only the Python definition of `ReplaceArrayValueOp`, ready to add back +into `recipes.py`. Do not inspect any other files. diff --git a/tests/agents/from-examples/swap/task.md b/tests/agents/from-examples/swap/task.md new file mode 100644 index 00000000..b8e21ff3 --- /dev/null +++ b/tests/agents/from-examples/swap/task.md @@ -0,0 +1,17 @@ +# Holdout: `SwapOp` + +You are extending JsonPatchX's custom operation catalog. You have been given: + +- `AGENT_GUIDE.md`: the custom-operation agent guide. +- `recipes.py`: the operation catalog, with one operation removed. +- `schema.json`: the JSON Schema for the remaining operations, for style + reference. + +One operation is missing: `SwapOp`. It swaps the values at pointers `a` and `b`. +Reject cases where one pointer is an ancestor of the other, and make that +validation choice deliberately rather than mechanically. + +Implement `SwapOp` in the same style as the operations already in `recipes.py`. + +Return only the Python definition of `SwapOp`, ready to add back into +`recipes.py`. Do not inspect any other files. diff --git a/tests/agents/lowercase-op.md b/tests/agents/lowercase-op.md deleted file mode 100644 index 60116cdc..00000000 --- a/tests/agents/lowercase-op.md +++ /dev/null @@ -1,7 +0,0 @@ -# Lowercase Operation Prompt - -Write a minimal JsonPatchX custom operation named `LowercaseOp`. - -It should lowercase a string value at `path`. - -Return only Python code. diff --git a/tests/agents/official_recipes.py b/tests/agents/official_recipes.py new file mode 100644 index 00000000..0be52add --- /dev/null +++ b/tests/agents/official_recipes.py @@ -0,0 +1,1499 @@ +"""Canonical pool of custom JsonPatchX operations used to generate agent +regression fixtures. + +This module is not a demo file and is not imported by anything under +``examples/``. It merges the operations from ``examples/recipes.py``, +``examples/recipes2.py``, and the operations shown in +``docs/custom-operation-agent-guide.md`` and +``docs/user-guide/custom-operations.md`` into one pool, so +``scripts/render_agent_fixture.py`` can hold out one operation at a time and +give a child agent the rest as surrounding examples. + +``HOLDOUT_OPERATIONS`` maps each holdout slug used under +``tests/agents/from-examples/`` and ``tests/agents/from-contract/`` to the +class name the render script should remove. Every holdout class is wrapped in +matching ``# agent-example: :start`` / ``:end`` markers so removal is a +plain text operation instead of an AST rewrite. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any, Literal, Self, assert_never, cast, override + +from pydantic import AliasChoices, ConfigDict, Field, model_validator +from pydantic_core import MISSING, PydanticCustomError + +from jsonpatchx import ( + AddOp, + JSONPointer, + JSONValue, + OperationSchema, + PatchConflictError, + RemoveOp, + ReplaceOp, + TestOp, + TestOpFailed, +) +from jsonpatchx.backend import TargetState, classify_state +from jsonpatchx.types import JSONArray, JSONBoolean, JSONNumber, JSONObject, JSONString + +HOLDOUT_OPERATIONS: dict[str, str] = { + "lowercase": "LowercaseOp", + "swap": "SwapOp", + "replace-array-value": "ReplaceArrayValueOp", + "bound-number": "ClampOp", + "add-missing-key": "AddMissingKeyOp", +} + +# Each list's first entry (v1) is already a normal pool member above; the +# rest (v2, v3, ...) are answer-key classes wrapped in +# `# agent-example: :vN:start` / `:end` markers, and are excluded from +# the live discriminated-union pool by scripts/render_agent_fixture.py so +# they never collide with v1's `op` literal or leak into schema.json. +# evolve-contract mode strips everything after v1; v1 stays as the starting +# point the agent evolves from. Note that "replace-substring"'s full +# evolution is already public in docs/user-guide/evolving-patch-contracts.md, +# so a blind test against it also measures training-data recall, not just +# generalization from the guide. +EVOLUTION_STAGES: dict[str, list[str]] = { + "replace-substring": [ + "ReplaceSubstringOp", + "ReplaceSubstringOpAdditive", + "ReplaceSubstringOpDeprecated", + ], + "add-to-set": [ + "AddToSetOp", + "AddToSetOpIgnoreCase", + "AddToSetOpComparison", + ], + "set-rollout-percentage": [ + "SetRolloutPercentageOp", + "SetRolloutPercentageOpRelative", + "SetRolloutPercentageOpMode", + ], + "test-missing": [ + "TestMissingOp", + "TestMissingOpRequireParent", + "TestMissingOpParentRequired", + ], +} + + +class ToggleOp(OperationSchema): + """Invert a boolean value at the target path.""" + + model_config = ConfigDict(title="Toggle operation") + op: Literal["toggle"] = "toggle" + path: JSONPointer[JSONBoolean] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=not current).apply(doc) + + +class EnableOp(OperationSchema): + """Set a boolean value to true.""" + + model_config = ConfigDict(title="Enable operation") + op: Literal["enable"] = "enable" + path: JSONPointer[JSONBoolean] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + return ReplaceOp(path=self.path, value=True).apply(doc) + + +class DisableOp(OperationSchema): + """Set a boolean value to false.""" + + model_config = ConfigDict(title="Disable operation") + op: Literal["disable"] = "disable" + path: JSONPointer[JSONBoolean] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + return ReplaceOp(path=self.path, value=False).apply(doc) + + +class IncrementOp(OperationSchema): + """Increase a numeric value by an amount.""" + + model_config = ConfigDict(title="Increment operation") + op: Literal["increment"] = "increment" + path: JSONPointer[JSONNumber] + amount: JSONNumber = Field(default=1, gt=0) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=current + self.amount).apply(doc) + + +class DecrementOp(OperationSchema): + """Decrease a numeric value by an amount.""" + + model_config = ConfigDict(title="Decrement operation") + op: Literal["decrement"] = "decrement" + path: JSONPointer[JSONNumber] + amount: JSONNumber = Field(default=1, gt=0) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=current - self.amount).apply(doc) + + +# agent-example: bound-number:start +class ClampOp(OperationSchema): + """Clamp a numeric value to an inclusive range.""" + + model_config = ConfigDict( + title="Clamp operation", + validate_default=False, + json_schema_extra={ + "description": ( + "Clamp a numeric value into an inclusive range. " + "Useful for capping, limiting, bounding, flooring, " + "or applying a ceiling." + ), + "x-discovery-terms": [ + "cap", + "limit", + "bound", + "ceiling", + "floor", + "maximum", + "minimum", + ], + "examples": [{"op": "clamp", "path": "/score", "max": 100}], + "anyOf": [{"required": ["min"]}, {"required": ["max"]}], + }, + ) + op: Literal["clamp"] = "clamp" + path: JSONPointer[JSONNumber] = Field( + description="Pointer to the numeric value to clamp." + ) + min: JSONNumber = Field( + default=cast(JSONNumber, MISSING), + description="Inclusive lower bound.", + ) + max: JSONNumber = Field( + default=cast(JSONNumber, MISSING), + description="Inclusive upper bound.", + ) + + @model_validator(mode="after") + def _validate_bounds(self) -> Self: + has_min = "min" in self.model_fields_set + has_max = "max" in self.model_fields_set + + if not has_min and not has_max: + raise ValueError("clamp requires at least one of min or max") + if has_min and has_max and self.min > self.max: + raise ValueError("clamp requires min <= max") + return self + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if "min" in self.model_fields_set: + current = max(self.min, current) + if "max" in self.model_fields_set: + current = min(self.max, current) + return ReplaceOp(path=self.path, value=current).apply(doc) + + +# agent-example: bound-number:end + + +class AppendOp(OperationSchema): + """Append a value to an array.""" + + model_config = ConfigDict(title="Append operation") + op: Literal["append"] = "append" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + current.append(self.value) + return doc + + +class PrependOp(OperationSchema): + """Prepend a value to an array.""" + + model_config = ConfigDict(title="Prepend operation") + op: Literal["prepend"] = "prepend" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + current.insert(0, self.value) + return doc + + +class InsertAtOp(OperationSchema): + """Insert a value into an array at a specific index.""" + + model_config = ConfigDict(title="Insert-at operation") + op: Literal["insert_at"] = "insert_at" + path: JSONPointer[JSONArray[JSONValue]] + index: int = Field(ge=0) + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.index > len(current): + raise PatchConflictError("index out of range") + current.insert(self.index, self.value) + return doc + + +class RemoveWhereOp(OperationSchema): + """Remove objects from an array where a field matches a value.""" + + model_config = ConfigDict(title="Remove-where operation") + op: Literal["remove_where"] = "remove_where" + path: JSONPointer[JSONArray[JSONObject[JSONValue]]] + field: str + equals: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + original_len = len(current) + current[:] = [item for item in current if item.get(self.field) != self.equals] + if len(current) == original_len: + raise PatchConflictError("no matching item found") + return doc + + +class DeduplicateOp(OperationSchema): + """Remove duplicate values from an array, preserving order.""" + + model_config = ConfigDict(title="Deduplicate operation") + op: Literal["deduplicate"] = "deduplicate" + path: JSONPointer[JSONArray[JSONValue]] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + encoded = dict.fromkeys( + # treat dict as ordered set + json.dumps(item, sort_keys=True) + for item in current + ) + current[:] = [json.loads(item) for item in encoded] + return doc + + +class ReplaceSubstringOp(OperationSchema): + """Replace a substring within a string field.""" + + model_config = ConfigDict(title="Replace-substring operation") + op: Literal["replace_substring"] = "replace_substring" + path: JSONPointer[JSONString] + old: JSONString + new: JSONString + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp( + path=self.path, value=current.replace(self.old, self.new) + ).apply(doc) + + +# agent-example: replace-substring:v2:start +class ReplaceSubstringOpAdditive(OperationSchema): + """Replace a substring within a string field. + + Stage 2 of `ReplaceSubstringOp`'s evolution + (see docs/user-guide/evolving-patch-contracts.md): adds `strict`, + defaulting to the original always-raise behavior. + """ + + model_config = ConfigDict(title="Replace-substring operation") + op: Literal["replace_substring"] = "replace_substring" + path: JSONPointer[JSONString] + old: JSONString + new: JSONString + strict: JSONBoolean = Field(default=True) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.strict and self.old not in current: + raise PatchConflictError( + f"strict mode is enabled and {self.old!r} is not in {current!r}" + ) + return ReplaceOp( + path=self.path, value=current.replace(self.old, self.new) + ).apply(doc) + + +# agent-example: replace-substring:v2:end + + +# agent-example: replace-substring:v3:start +class ReplaceSubstringOpDeprecated(OperationSchema): + """Replace a substring within a string field. + + Stage 3 of `ReplaceSubstringOp`'s evolution: `strict` is now deprecated + but still honored. `model_fields_set` distinguishes an explicit value + from the default, so a future removal only affects callers who were + actually relying on it. + """ + + model_config = ConfigDict(title="Replace-substring operation") + op: Literal["replace_substring"] = "replace_substring" + path: JSONPointer[JSONString] + old: JSONString + new: JSONString + strict: JSONBoolean = Field(default=True, deprecated=True) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.old not in current: + if "strict" not in self.model_fields_set or self.strict: + raise PatchConflictError( + f"strict mode is enabled and {self.old!r} is not in {current!r}" + ) + return ReplaceOp( + path=self.path, value=current.replace(self.old, self.new) + ).apply(doc) + + +# agent-example: replace-substring:v3:end + + +class MergeOp(OperationSchema): + """Merge an object into a target object.""" + + model_config = ConfigDict(title="Merge operation") + op: Literal["merge"] = "merge" + path: JSONPointer[JSONObject[JSONValue]] + value: JSONObject[JSONValue] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + current.update(self.value) + return doc + + +class RenameKeyOp(OperationSchema): + """Rename a key inside an object.""" + + model_config = ConfigDict(title="Rename-key operation") + op: Literal["rename"] = "rename" + path: JSONPointer[JSONObject[JSONValue]] + from_: JSONString = Field(alias="from") + to: JSONString + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.from_ not in current: + raise PatchConflictError("source key does not exist") + if self.to in current: + raise PatchConflictError("destination key already exists") + current[self.to] = current.pop(self.from_) + return doc + + +class MoveOnlyIfExistsOp(OperationSchema): + """Move a value if the source path exists.""" + + model_config = ConfigDict(title="Move-if-exists operation") + op: Literal["moveonlyifexists"] = "moveonlyifexists" + from_: JSONPointer[JSONValue] = Field(alias="from") + path: JSONPointer[JSONValue] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + if not self.from_.is_gettable(doc): + return doc + value = self.from_.get(doc) + doc = AddOp(path=self.path, value=value).apply(doc) + return RemoveOp(path=self.from_).apply(doc) + + +class SortNumbersOp(OperationSchema): + """Sort a numeric array ascending or descending.""" + + model_config = ConfigDict(title="Sort numbers operation") + op: Literal["sort_numbers"] = "sort_numbers" + path: JSONPointer[JSONArray[JSONNumber]] + order: Literal["asc", "desc"] = "asc" + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + new = sorted(current, reverse=self.order == "desc") + return ReplaceOp(path=self.path, value=new).apply(doc) # type: ignore[arg-type] + + +class BitSetOp(OperationSchema): + """Set or clear a bit in a numeric bitfield.""" + + model_config = ConfigDict( + title="Bit operation", + json_schema_extra={ + "description": "Set or clear a specific bit in a numeric bitfield.", + }, + ) + op: Literal["bit_set"] = "bit_set" + path: JSONPointer[JSONNumber] + index: int = Field(ge=0) + value: JSONBoolean + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = int(self.path.get(doc)) + mask = 1 << self.index + next_val = (current | mask) if self.value else (current & ~mask) + return ReplaceOp(path=self.path, value=next_val).apply(doc) + + +class MapOp(OperationSchema): + """Replace a value based on a mapping table.""" + + model_config = ConfigDict(title="Map operation") + op: Literal["map"] = "map" + path: JSONPointer[JSONString] + mapping: JSONObject[JSONValue] + strict: JSONBoolean + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if current not in self.mapping: + if self.strict: + raise PatchConflictError(f"missing map entry for {current!r}") + return doc + return ReplaceOp(path=self.path, value=self.mapping[current]).apply(doc) + + +class TestMissingOp(OperationSchema): + """Supports explicit non-existence preconditions for optimistic workflows. + + Example: + doc={"users": {"42": {"name": "Ada"}}} + op={"op": "test_missing", "path": "/users/99"} + Useful before create flows to assert the target slot is free. + """ + + model_config = ConfigDict( + title="Test-missing operation", + json_schema_extra={ + "description": "Assert that a path does not resolve to an existing value." + }, + ) + op: Literal["test_missing"] = "test_missing" + path: JSONPointer[JSONValue] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + if self.path.is_gettable(doc): + raise TestOpFailed(f"expected missing path at {self.path!r}") + return doc + + +# agent-example: test-missing:v2:start +class TestMissingOpRequireParent(OperationSchema): + """Supports explicit non-existence preconditions for optimistic workflows. + + Stage 2 of `TestMissingOp`'s evolution: adds `require_parent`, defaulting + to `False` so a caller who never sends it keeps the exact old behavior: + `/a/b` still reads as vacuously missing whenever `/a` itself is missing. + A caller can opt in to the stricter check, where the parent must exist + and be a container, and where a negative array index is never accepted + as evidence of anything missing (its target position is not stable + enough to test against). The document root is never subject to this + check: root is its own parent and always exists once a document exists + to run a patch against. + """ + + model_config = ConfigDict( + title="Test-missing operation", + json_schema_extra={ + "description": "Assert that a path does not resolve to an existing value." + }, + ) + op: Literal["test_missing"] = "test_missing" + path: JSONPointer[JSONValue] + require_parent: JSONBoolean = Field( + default=False, + description="Require the parent to exist and forbid negative array " + "indices; violations are a conflict, not a passed test.", + ) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + if not self.require_parent: + if self.path.is_gettable(doc): + raise TestOpFailed(f"expected missing path at {self.path!r}") + return doc + state = classify_state(self.path.ptr, doc) + if state in { + TargetState.PARENT_NOT_FOUND, + TargetState.PARENT_NOT_CONTAINER, + TargetState.VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX, + }: + raise PatchConflictError( + f"require_parent rejects the state of {self.path!r}" + ) + if state in {TargetState.ROOT, TargetState.VALUE_PRESENT}: + raise TestOpFailed(f"expected missing path at {self.path!r}") + return doc + + +# agent-example: test-missing:v2:end + + +# agent-example: test-missing:v3:start +class TestMissingOpParentRequired(OperationSchema): + """Supports explicit non-existence preconditions for optimistic workflows. + + Stage 3 of `TestMissingOp`'s evolution: `require_parent` is deprecated + and its default flips to `True`. This is a deliberate, called-out + breaking change bundled with the deprecation, not a routine one: the + team wants every caller on the stricter behavior by default going + forward, and plans to remove the field entirely in a future version once + callers have migrated. Both `True` and `False` are still fully + supported for now; only the default has changed. + """ + + model_config = ConfigDict( + title="Test-missing operation", + json_schema_extra={ + "description": "Assert that a path does not resolve to an existing value." + }, + ) + op: Literal["test_missing"] = "test_missing" + path: JSONPointer[JSONValue] + require_parent: JSONBoolean = Field( + default=True, + deprecated=True, + description="Deprecated; now defaults to true and will be removed " + "in a future version.", + ) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + if not self.require_parent: + if self.path.is_gettable(doc): + raise TestOpFailed(f"expected missing path at {self.path!r}") + return doc + state = classify_state(self.path.ptr, doc) + if state in { + TargetState.PARENT_NOT_FOUND, + TargetState.PARENT_NOT_CONTAINER, + TargetState.VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX, + }: + raise PatchConflictError( + f"require_parent rejects the state of {self.path!r}" + ) + if state in {TargetState.ROOT, TargetState.VALUE_PRESENT}: + raise TestOpFailed(f"expected missing path at {self.path!r}") + return doc + + +# agent-example: test-missing:v3:end + + +# agent-example: add-missing-key:start +class AddMissingKeyOp(OperationSchema): + """AddOp but only for objects missing the target key. Prevents silent overwrite. + + Example: + doc={"profile": {}} + op={"op": "add_missing_key", "path": "/profile/email", "value": "a@x.test"} + If "/profile/email" already exists, this op fails instead of replacing it. + """ + + model_config = ConfigDict( + title="Add Missing Key Operation", + json_schema_extra={ + "description": "Add a key-value pair to an object, but only if the key is missing." + }, + ) + op: Literal["add_missing_key"] = "add_missing_key" + path: JSONPointer[JSONValue] + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + state = classify_state(self.path.ptr, doc) + if state is TargetState.OBJECT_KEY_MISSING: + return AddOp(path=self.path, value=self.value).apply(doc) + if state is TargetState.VALUE_PRESENT: + raise PatchConflictError(f"path {self.path!r} already exists") + if state in { + TargetState.ARRAY_INDEX_APPEND, + TargetState.ARRAY_INDEX_AT_END, + TargetState.ARRAY_INDEX_OUT_OF_RANGE, + TargetState.ARRAY_KEY_INVALID, + TargetState.VALUE_PRESENT_AT_NEGATIVE_ARRAY_INDEX, + }: + raise PatchConflictError( + f"add_missing_key expects an object member path, got array path {self.path!r}" + ) + if state is TargetState.PARENT_NOT_FOUND: + raise PatchConflictError( + f"cannot add key at {self.path!r} because parent does not exist" + ) + if state is TargetState.PARENT_NOT_CONTAINER: + raise PatchConflictError( + f"cannot add key at {self.path!r} because parent is not a container" + ) + if state is TargetState.ROOT: + raise PatchConflictError("add_missing_key does not support root path") + raise PatchConflictError(f"unsupported path state for {self.path!r}") + + +# agent-example: add-missing-key:end + + +def is_sensitive(path: JSONPointer[Any]) -> bool: + """Check whether a password is sensitive; use to avoid leaking information.""" + markers = ("password", "passwd", "secret", "token", "api_key", "apikey") + return any(path.parts[-1] == marker for marker in markers) + + +class SensitiveAwareTestOp(OperationSchema): + """Blocks test on sensitive paths so probes cannot leak secret matches. + + Example: + doc={"password": "p@ssw0rd"} + op={"op": "test_sensitive_aware", "path": "/password", "value": "guess"} + Always fails on sensitive paths, even if guessed value is correct. + """ + + model_config = ConfigDict( + title="Sensitive-aware test operation", + json_schema_extra={ + "description": "Run RFC test except on sensitive paths, where it always fails." + }, + ) + op: Literal["test_sensitive_aware"] = "test_sensitive_aware" + path: JSONPointer[JSONValue] + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + if is_sensitive(self.path): + raise TestOpFailed("test is not allowed on sensitive paths") + return TestOp(path=self.path, value=self.value).apply(doc) + + +class ReplaceWithPriorOp(OperationSchema): + """Captures previous value checks for auditable and reversible replace flows. + + Example: + doc={"status": "draft"} + op={ + "op": "replace_with_prior", + "path": "/status", + "priorValue": "draft", + "value": "published" + } + The operation fails if current value is not "draft". + """ + + model_config = ConfigDict( + title="Replace-with-prior operation", + json_schema_extra={ + "description": "Replace only when the current value matches priorValue." + }, + ) + op: Literal["replace_with_prior", "replace with prior"] = "replace_with_prior" + path: JSONPointer[JSONValue] + prior_value: JSONValue = Field( + validation_alias=AliasChoices("prior value", "priorValue"), + serialization_alias="priorValue", # openapi requires ^[a-zA-Z0-9\.\-_]+$ but for parsing json 'prior value' is also accepted + ) + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if current != self.prior_value: + raise TestOpFailed( + f"prior value mismatch at {self.path!r}: " + f"expected {self.prior_value!r}, got {current!r}" + ) + return ReplaceOp(path=self.path, value=self.value).apply(doc) + + +class RemoveWithOldValueOp(OperationSchema): + """Makes remove invertible by carrying and validating old value. + + Example: + doc={"nickname": "max"} + op={"op": "remove_with_old", "path": "/nickname", "oldValue": "max"} + The remove fails unless oldValue matches the current document value. + """ + + model_config = ConfigDict( + title="Remove-with-old-value operation", + json_schema_extra={ + "description": "Remove only when current value matches oldValue." + }, + ) + op: Literal["remove_with_old"] = "remove_with_old" + path: JSONPointer[JSONValue] + old_value: JSONValue = Field( + validation_alias=AliasChoices("old value", "oldValue"), + serialization_alias="oldValue", + ) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if current != self.old_value: + raise TestOpFailed( + f"old value mismatch at {self.path!r}: " + f"expected {self.old_value!r}, got {current!r}" + ) + return RemoveOp(path=self.path).apply(doc) + + +def _deep_merge_object( + base: JSONObject[JSONValue], incoming: JSONObject[JSONValue] +) -> JSONObject[JSONValue]: + """Recursively merge nested objects without dropping unrelated sibling keys. + + Example: + base={"attributes": {"age": 15, "city": "Rome"}} + incoming={"attributes": {"continent": "Europe"}} + result={"attributes": {"age": 15, "city": "Rome", "continent": "Europe"}} + """ + merged: JSONObject[JSONValue] = copy.deepcopy(base) + for key, value in incoming.items(): + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = _deep_merge_object(current, value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +class MergeObjectOp(OperationSchema): + """Preserves sibling fields when independent patches target one object key. + + Example: + doc={"attributes": {"age": 15}} + op={"op": "merge_object", "path": "/attributes", "value": {"continent": "EU"}} + Result keeps "age" and adds "continent". + """ + + model_config = ConfigDict( + title="Merge-object operation", + json_schema_extra={ + "description": "Merge object fields at path; optionally merge nested objects recursively." + }, + ) + op: Literal["merge_object"] = "merge_object" + path: JSONPointer[JSONObject[JSONValue]] + value: JSONObject[JSONValue] + deep: JSONBoolean = False + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + merged = ( + _deep_merge_object(current, self.value) + if self.deep + else {**current, **self.value} + ) + return ReplaceOp(path=self.path, value=merged).apply(doc) + + +class IncrementByOp(OperationSchema): + """Avoids read-modify-write races for simple counters. + + Example: + doc={"votes": 10} + op={"op": "increment_by", "path": "/votes", "amount": 1} + Result is {"votes": 11} without a separate client read step. + """ + + model_config = ConfigDict( + title="Increment-by operation", + json_schema_extra={ + "description": "Increase a numeric value by amount in one patch step." + }, + ) + op: Literal["increment_by"] = "increment_by" + path: JSONPointer[JSONNumber] + amount: JSONNumber + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=current + self.amount).apply(doc) + + +class RemoveArrayValueOp(OperationSchema): + """Removes by value so callers are not forced to discover unstable indexes. + + Example: + doc={"roles": ["admin", "viewer", "editor"]} + op={"op": "remove_array_value", "path": "/roles", "value": "viewer"} + Removes "viewer" even if indexes shifted since the client last read. + """ + + model_config = ConfigDict( + title="Remove-array-value operation", + json_schema_extra={ + "description": "Remove matching array members by value (first or all)." + }, + ) + op: Literal["remove_array_value"] = "remove_array_value" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + mode: Literal["first", "all"] = "first" + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.value not in current: + raise PatchConflictError("array value not found") + match self.mode: + case "first": + current.remove(self.value) + case "all": + current[:] = [item for item in current if item != self.value] + case _ as unreachable: + assert_never(unreachable) + return doc + + +# agent-example: replace-array-value:start +class ReplaceArrayValueOp(OperationSchema): + """Replaces by value to support set-like arrays without index coupling. + + Example: + doc={"tags": ["alpha", "beta", "beta"]} + op={ + "op": "replace_array_value", + "path": "/tags", + "oldValue": "beta", + "value": "stable", + "mode": "first" + } + Replaces a matching value without requiring an index. + """ + + model_config = ConfigDict( + title="Replace-array-value operation", + json_schema_extra={ + "description": "Replace matching array members by value (first or all)." + }, + ) + op: Literal["replace_array_value"] = "replace_array_value" + path: JSONPointer[JSONArray[JSONValue]] + old_value: JSONValue = Field(alias="oldValue") + value: JSONValue + mode: Literal["first", "all"] = "first" + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.value not in current: + raise PatchConflictError("array value to replace not found") + match self.mode: + case "first": + for index, item in enumerate(current): + if item == self.old_value: + current[index] = self.value + break + case "all": + current[:] = [ + item if item != self.old_value else self.value for item in current + ] + case _ as unreachable: + assert_never(unreachable) + return doc + + +# agent-example: replace-array-value:end + + +class SetUnionOp(OperationSchema): + """Adds missing members only, matching set-style collection semantics. + + Example: + doc={"features": ["chat"]} + op={"op": "set_union", "path": "/features", "values": ["chat", "audit"]} + Result is ["chat", "audit"] (no duplicate "chat"). + """ + + model_config = ConfigDict( + title="Set-union operation", + json_schema_extra={ + "description": "Append only values that are not already present in an array." + }, + ) + op: Literal["set_union"] = "set_union" + path: JSONPointer[JSONArray[JSONValue]] + values: JSONArray[JSONValue] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + for item in self.values: + if item not in current: + current.append(copy.deepcopy(item)) + return doc + + +class ReplaceTextSliceOp(OperationSchema): + """Changes large strings surgically without replacing the full field value. + + Example: + doc={"title": "Hello world"} + op={ + "op": "replace_text_slice", + "path": "/title", + "start": 6, + "end": 11, + "text": "team" + } + Result is {"title": "Hello team"}. + """ + + model_config = ConfigDict( + title="Replace-text-slice operation", + json_schema_extra={ + "description": "Replace a substring by start/end offsets instead of replacing full text." + }, + ) + op: Literal["replace_text_slice"] = "replace_text_slice" + path: JSONPointer[JSONString] + start: int = Field(ge=0) + end: int = Field(ge=0) + text: JSONString + + @model_validator(mode="after") + def _validate_range(self) -> Self: + if self.start > self.end: + raise PydanticCustomError( + "replace_text_slice_range", + "replace_text_slice requires start <= end", + ) + return self + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.end > len(current): + raise PatchConflictError("text slice end is out of range") + updated = current[: self.start] + self.text + current[self.end :] + return ReplaceOp(path=self.path, value=updated).apply(doc) + + +class AddByValueOp(OperationSchema): + """Insert relative to a matched value when index position is not reliable. + + Example: + doc={"items": ["a", "c", "e", "z"]} + op={"op": "add_by_value", "path": "/items", "value": "b", "before": "c"} + Result is {"items": ["a", "b", "c", "e", "z"]}. + """ + + model_config = ConfigDict( + title="Add-by-value operation", + json_schema_extra={ + "description": "Insert array value before/after a matched anchor value." + }, + ) + op: Literal["add_by_value"] = "add_by_value" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + before: JSONValue | None = None + after: JSONValue | None = None + + @model_validator(mode="after") + def _validate_anchor(self) -> Self: + if (self.before is None) == (self.after is None): + raise PydanticCustomError( + "add_by_value_anchor", + "add_by_value requires exactly one of 'before' or 'after'", + ) + return self + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + anchor = self.before if self.before is not None else self.after + assert anchor is not None # validated above + for index, item in enumerate(current): + if item == anchor: + insert_at = index if self.before is not None else index + 1 + current.insert(insert_at, self.value) + return doc + raise PatchConflictError("anchor value not found") + + +class ReplaceByValueOp(OperationSchema): + """Replace matched array values without depending on current index positions. + + Example: + doc={"items": ["a", "c", "e", "z"]} + op={"op": "replace_by_value", "path": "/items", "replace": "e", "value": "d"} + Result is {"items": ["a", "c", "d", "z"]}. + """ + + model_config = ConfigDict( + title="Replace-by-value operation", + json_schema_extra={ + "description": "Replace first/all matched array values by value." + }, + ) + op: Literal["replace_by_value"] = "replace_by_value" + path: JSONPointer[JSONArray[JSONValue]] + replace: JSONValue + value: JSONValue + mode: Literal["first", "all"] = "first" + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + replaced = False + for index, item in enumerate(current): + if item == self.replace: + current[index] = self.value + replaced = True + if self.mode == "first": + return doc + if not replaced: + raise PatchConflictError("replace target value not found") + return doc + + +class RemoveByValueOp(OperationSchema): + """Remove first/all matched array values to avoid index-race removals. + + Example: + doc={"items": ["a", "b", "c", "d"]} + op={"op": "remove_by_value", "path": "/items", "value": "d"} + Result is {"items": ["a", "b", "c"]}. + """ + + model_config = ConfigDict( + title="Remove-by-value operation", + json_schema_extra={ + "description": "Remove first/all matched array values by value." + }, + ) + op: Literal["remove_by_value"] = "remove_by_value" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + mode: Literal["first", "all"] = "first" + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.mode == "first": + for index, item in enumerate(current): + if item == self.value: + del current[index] + return doc + raise PatchConflictError("remove target value not found") + + filtered = [item for item in current if item != self.value] + if len(filtered) == len(current): + raise PatchConflictError("remove target value not found") + current[:] = filtered + return doc + + +# agent-example: lowercase:start +class LowercaseOp(OperationSchema): + """Lowercase a string value.""" + + model_config = ConfigDict(title="Lowercase operation") + op: Literal["lowercase"] = "lowercase" + path: JSONPointer[JSONString] + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + return ReplaceOp(path=self.path, value=current.lower()).apply(doc) + + +# agent-example: lowercase:end + + +# agent-example: swap:start +class SwapOp(OperationSchema): + """Swap the values at two pointers.""" + + model_config = ConfigDict( + title="Swap operation", + json_schema_extra={ + "description": ( + "Swaps the values at paths a and b. " + "Paths a and b may not be proper prefixes of each other." + ) + }, + ) + op: Literal["swap"] = "swap" + a: JSONPointer[JSONValue] + b: JSONPointer[JSONValue] + + @model_validator(mode="after") + def _reject_proper_prefixes(self) -> Self: + if self.a.is_parent_of(self.b): + raise PydanticCustomError( + "swap_path_conflict", + "pointer 'b' cannot be a child of pointer 'a'", + ) + if self.b.is_parent_of(self.a): + raise PydanticCustomError( + "swap_path_conflict", + "pointer 'a' cannot be a child of pointer 'b'", + ) + return self + + @override + def apply(self, doc: JSONValue) -> JSONValue: + value_a = self.a.get(doc) + value_b = self.b.get(doc) + doc = AddOp(path=self.a, value=value_b).apply(doc) + return AddOp(path=self.b, value=value_a).apply(doc) + + +# agent-example: swap:end + + +class ReplaceNumberOp(OperationSchema): + """Replace a numeric value at the target path.""" + + model_config = ConfigDict(title="Replace-number operation") + op: Literal["replace_number"] = "replace_number" + path: JSONPointer[JSONNumber] + value: JSONNumber + + @override + def apply(self, doc: JSONValue) -> JSONValue: + return ReplaceOp(path=self.path, value=self.value).apply(doc) + + +class AddToSetOp(OperationSchema): + """Add a value to an array only if it is not already present. + + Example: + doc={"tags": ["alpha", "beta"]} + op={"op": "add_to_set", "path": "/tags", "value": "beta"} + Result is unchanged; "beta" is already present. + """ + + model_config = ConfigDict( + title="Add-to-set operation", + json_schema_extra={ + "description": "Add a value to an array only if it is not already " + "present, using whole-value equality. A duplicate is a no-op." + }, + ) + op: Literal["add_to_set"] = "add_to_set" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.value in current: + return doc + return ReplaceOp(path=self.path, value=[*current, self.value]).apply(doc) + + +# agent-example: add-to-set:v2:start +class AddToSetOpIgnoreCase(OperationSchema): + """Add a value to an array only if it is not already present. + + Stage 2 of `AddToSetOp`'s evolution: adds `ignore_case` for + case-insensitive comparison of string values, defaulting to the original + exact-equality behavior. + """ + + model_config = ConfigDict( + title="Add-to-set operation", + json_schema_extra={ + "description": "Add a value to an array only if it is not already present." + }, + ) + op: Literal["add_to_set"] = "add_to_set" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + ignore_case: JSONBoolean = Field( + default=False, + description="Compare string values case-insensitively.", + ) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if self.ignore_case and isinstance(self.value, str): + needle = self.value.casefold() + found = any( + isinstance(item, str) and item.casefold() == needle for item in current + ) + else: + found = self.value in current + if found: + return doc + return ReplaceOp(path=self.path, value=[*current, self.value]).apply(doc) + + +# agent-example: add-to-set:v2:end + + +# agent-example: add-to-set:v3:start +class AddToSetOpComparison(OperationSchema): + """Add a value to an array only if it is not already present. + + Stage 3 of `AddToSetOp`'s evolution: `ignore_case` is deprecated in favor + of `comparison`. The two are mutually exclusive, and neither has a + concrete default: once split, "the default" no longer belongs to either + field alone. + """ + + model_config = ConfigDict( + title="Add-to-set operation", + validate_default=False, + json_schema_extra={ + "description": "Add a value to an array only if it is not already present." + }, + ) + op: Literal["add_to_set"] = "add_to_set" + path: JSONPointer[JSONArray[JSONValue]] + value: JSONValue + ignore_case: JSONBoolean = Field( + default=cast(JSONBoolean, MISSING), + deprecated=True, + description="Deprecated; use 'comparison' instead.", + ) + comparison: Literal["exact", "case_insensitive"] = Field( + default=cast(Literal["exact", "case_insensitive"], MISSING), + description="Comparison mode. Mutually exclusive with the deprecated 'ignore_case'.", + ) + + @model_validator(mode="after") + def _validate_mutually_exclusive(self) -> Self: + if ( + "ignore_case" in self.model_fields_set + and "comparison" in self.model_fields_set + ): + raise ValueError( + "add_to_set accepts 'ignore_case' or 'comparison', not both" + ) + return self + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if "comparison" in self.model_fields_set: + case_insensitive = self.comparison == "case_insensitive" + elif "ignore_case" in self.model_fields_set: + case_insensitive = self.ignore_case + else: + case_insensitive = False + if case_insensitive and isinstance(self.value, str): + needle = self.value.casefold() + found = any( + isinstance(item, str) and item.casefold() == needle for item in current + ) + else: + found = self.value in current + if found: + return doc + return ReplaceOp(path=self.path, value=[*current, self.value]).apply(doc) + + +# agent-example: add-to-set:v3:end + + +class SetRolloutPercentageOp(OperationSchema): + """Set a named rollout variation's percentage. + + Example: + doc={"rollout": {"enabled": 25, "disabled": 75}} + op={ + "op": "set_rollout_percentage", + "path": "/rollout", + "variation": "enabled", + "percentage": 40 + } + Result is {"rollout": {"enabled": 40, "disabled": 60}}: the named + variation is set directly, and its complementary variation is + adjusted so the two continue to sum to 100. + """ + + model_config = ConfigDict( + title="Set-rollout-percentage operation", + json_schema_extra={ + "description": "Set a named variation's percentage in a " + "two-variation rollout, adjusting the complementary variation " + "so the two sum to 100." + }, + ) + op: Literal["set_rollout_percentage"] = "set_rollout_percentage" + path: JSONPointer[JSONObject[JSONNumber]] + variation: JSONString + percentage: JSONNumber = Field(ge=0, le=100) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if len(current) != 2: + raise PatchConflictError( + "set_rollout_percentage requires exactly two variations" + ) + if self.variation not in current: + raise PatchConflictError(f"unknown rollout variation {self.variation!r}") + other = next(name for name in current if name != self.variation) + updated = dict(current) + updated[self.variation] = self.percentage + updated[other] = 100 - self.percentage + return ReplaceOp(path=self.path, value=updated).apply(doc) # type: ignore[arg-type] + + +# agent-example: set-rollout-percentage:v2:start +class SetRolloutPercentageOpRelative(OperationSchema): + """Set a named rollout variation's percentage. + + Stage 2 of `SetRolloutPercentageOp`'s evolution: adds `relative`, so a + caller can increase a variation by a delta instead of setting an + absolute value, defaulting to the original absolute-set behavior. + `percentage` keeps its original non-negative range in both modes, so its + sign never has to carry meaning. + """ + + model_config = ConfigDict( + title="Set-rollout-percentage operation", + json_schema_extra={ + "description": "Set a named variation's percentage in a two-variation rollout." + }, + ) + op: Literal["set_rollout_percentage"] = "set_rollout_percentage" + path: JSONPointer[JSONObject[JSONNumber]] + variation: JSONString + percentage: JSONNumber = Field(ge=0, le=100) + relative: JSONBoolean = Field( + default=False, + description="If true, increase the variation by percentage instead " + "of setting it outright.", + ) + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if len(current) != 2: + raise PatchConflictError( + "set_rollout_percentage requires exactly two variations" + ) + if self.variation not in current: + raise PatchConflictError(f"unknown rollout variation {self.variation!r}") + other = next(name for name in current if name != self.variation) + target = ( + current[self.variation] + self.percentage + if self.relative + else self.percentage + ) + target = max(0, min(100, target)) + updated = dict(current) + updated[self.variation] = target + updated[other] = 100 - target + return ReplaceOp(path=self.path, value=updated).apply(doc) # type: ignore[arg-type] + + +# agent-example: set-rollout-percentage:v2:end + + +# agent-example: set-rollout-percentage:v3:start +class SetRolloutPercentageOpMode(OperationSchema): + """Set a named rollout variation's percentage. + + Stage 3 of `SetRolloutPercentageOp`'s evolution: `relative` is deprecated + in favor of `mode`, which can also express "decrease" without ever + letting `percentage`'s sign carry meaning. The two are mutually + exclusive, and neither has a concrete default once split. + """ + + model_config = ConfigDict( + title="Set-rollout-percentage operation", + validate_default=False, + json_schema_extra={ + "description": "Set a named variation's percentage in a two-variation rollout." + }, + ) + op: Literal["set_rollout_percentage"] = "set_rollout_percentage" + path: JSONPointer[JSONObject[JSONNumber]] + variation: JSONString + percentage: JSONNumber = Field(ge=0, le=100) + relative: JSONBoolean = Field( + default=cast(JSONBoolean, MISSING), + deprecated=True, + description="Deprecated; use 'mode' instead.", + ) + mode: Literal["set", "increase", "decrease"] = Field( + default=cast(Literal["set", "increase", "decrease"], MISSING), + description="How to apply percentage. Mutually exclusive with the deprecated 'relative'.", + ) + + @model_validator(mode="after") + def _validate_mutually_exclusive(self) -> Self: + if "relative" in self.model_fields_set and "mode" in self.model_fields_set: + raise ValueError( + "set_rollout_percentage accepts 'relative' or 'mode', not both" + ) + return self + + @override + def apply(self, doc: JSONValue) -> JSONValue: + current = self.path.get(doc) + if len(current) != 2: + raise PatchConflictError( + "set_rollout_percentage requires exactly two variations" + ) + if self.variation not in current: + raise PatchConflictError(f"unknown rollout variation {self.variation!r}") + other = next(name for name in current if name != self.variation) + + if "mode" in self.model_fields_set: + effective_mode = self.mode + elif "relative" in self.model_fields_set and self.relative: + effective_mode = "increase" + else: + effective_mode = "set" + + match effective_mode: + case "set": + target = self.percentage + case "increase": + target = current[self.variation] + self.percentage + case "decrease": + target = current[self.variation] - self.percentage + case _ as unreachable: + assert_never(unreachable) + target = max(0, min(100, target)) + updated = dict(current) + updated[self.variation] = target + updated[other] = 100 - target + return ReplaceOp(path=self.path, value=updated).apply(doc) # type: ignore[arg-type] + + +# agent-example: set-rollout-percentage:v3:end diff --git a/tests/agents/replace-array-value-op.md b/tests/agents/replace-array-value-op.md deleted file mode 100644 index 71cf070f..00000000 --- a/tests/agents/replace-array-value-op.md +++ /dev/null @@ -1,10 +0,0 @@ -# Replace Array Value Operation Prompt - -Write a JsonPatchX custom operation named `ReplaceArrayValueOp`. - -It should replace the first exact occurrence of `old_value` in an array with -`new_value`. - -Raise an error if `old_value` is not present. - -Return only Python code. diff --git a/tests/agents/swap-op-structured-validation.md b/tests/agents/swap-op-structured-validation.md deleted file mode 100644 index e7b516a2..00000000 --- a/tests/agents/swap-op-structured-validation.md +++ /dev/null @@ -1,11 +0,0 @@ -# Swap Operation Structured Validation Prompt - -Write a JsonPatchX custom operation named `SwapOp`. - -It should swap the values at pointers `a` and `b`. - -Reject cases where one pointer is an ancestor of the other. - -Make the validation choice deliberately rather than mechanically. - -Return only Python code. diff --git a/tests/compliance/rfc6902/jsonpatchx_tests.json b/tests/compliance/rfc6902/jsonpatchx_tests.json index d222ce43..008d6d21 100644 --- a/tests/compliance/rfc6902/jsonpatchx_tests.json +++ b/tests/compliance/rfc6902/jsonpatchx_tests.json @@ -47,73 +47,8 @@ { "op": "remove", "path": "" }, { "op": "add", "path": "", "value": [1, 2, 3] } ], - "expected": [1, 2, 3], - "comment": "remove then add at root should work" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "remove", "path": "" } - ], - "error": "remove target must exist beforehand", - "comment": "removal of missing document" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "replace", "path": "", "value": [1, 2, 3] } - ], - "error": "replace target must exist beforehand", - "comment": "replacement of missing document" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "move", "from": "", "path": "/a/b" } - ], - "error": "move source must exist beforehand", - "comment": "move from missing document" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "copy", "from": "", "path": "" }, - { "op": "add", "path": "/a/b", "value": 1 } - ], - "error": "copy source must exist beforehand", - "comment": "copy from missing document" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "copy", "from": "", "path": "/a/b" }, - { "op": "add", "path": "/a/b", "value": 1 } - ], - "error": "copy source must exist beforehand (2)", - "comment": "copy from missing document" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "test", "path": "", "value": null } - ], - "error": "root removal is does not produce null", - "comment": "test on missing document (1)" - }, - { - "doc": [1, 2, 3], - "patch": [ - { "op": "remove", "path": "" }, - { "op": "test", "path": "", "value": "" } - ], - "error": "root removal is does not produce empty string", - "comment": "test on missing document (2)" + "error": "cannot delete the document", + "comment": "remove then add at root is rejected" }, { "doc": { "foo": "should-not-be-indexable" }, diff --git a/tests/compliance/rfc6902/test_patch.py b/tests/compliance/rfc6902/test_patch.py index 5b710aa7..763a81f2 100644 --- a/tests/compliance/rfc6902/test_patch.py +++ b/tests/compliance/rfc6902/test_patch.py @@ -89,8 +89,7 @@ def _assert_fail_case_with_builder( ) -> None: try: patch = build_patch(case) - except Exception as exc: - assert isinstance(exc, (PatchError, ValidationError)) + except (PatchError, ValidationError) as exc: assert not isinstance(exc, PatchInternalError), f"Internal error: {exc!r}" return diff --git a/tests/compliance/rfc9535/README.md b/tests/compliance/rfc9535/README.md index 76bf6ca5..83a974b4 100644 --- a/tests/compliance/rfc9535/README.md +++ b/tests/compliance/rfc9535/README.md @@ -13,15 +13,23 @@ JsonPatchX's built-in `JSONSelector` backend. - [`test_selector.py`](./test_selector.py): executes the suite through `JSONSelector.parse(...).getall(...)`. -## Python 3.14 and Later - -On Python 3.14 and later, the built-in backend xfails a small set of regular -expression compliance cases. - -This is due to an upstream compatibility gap in -[`python-jsonpath`](https://github.com/jg-rp/python-jsonpath) / -[`iregexp-check`](https://github.com/jg-rp/rust-iregexp), so JsonPatchX falls -back to Python's built-in `re` engine instead of the full RFC/I-Regexp path. +## Without `jsonpatchx[strict-jsonpath]` + +`python-jsonpath[strict]` is an opt-in extra, installed via +`jsonpatchx[strict-jsonpath]`, not a default dependency. Its upstream +[`iregexp-check`](https://github.com/jg-rp/rust-iregexp) dependency segfaults on +import under free-threaded Python, and PEP 508 dependency markers cannot select +a standard build over a free-threaded build of the same Python version, so +JsonPatchX cannot install it automatically only on safe interpreters; installing +it is left to the caller. See +[Targeting Documents with JSON Pointer and JSONPath](../../../docs/user-guide/patch-targeting.md) +for the install command and the free-threading warning. + +`test_selector.py` detects whether `iregexp-check` is actually importable in the +current environment (not the Python version) and xfails a small, hand-picked set +of regular expression compliance cases when it is missing: without it, the +built-in backend falls back to Python's built-in `re` engine instead of the full +RFC/I-Regexp path. The manifest is intentionally narrow and strict: only empirically failing cases are listed, so an unexpected pass becomes an `XPASS`. @@ -34,7 +42,17 @@ If [`external`](./external/) is empty, initialize the submodule first: git submodule update --init --recursive ``` -Then run the compliance tests normally: +Then run the compliance tests normally. To exercise the full RFC 9535 path (not +just the `re`-fallback behavior), install `strict-jsonpath` first, and only do +so on a standard (non-free-threaded) interpreter: + +```bash +uv sync --extra strict-jsonpath +uv run pytest tests/compliance/rfc9535/test_selector.py +``` + +Without the extra, the same command still runs, but the cases listed in +`test_selector.py` are expected to xfail instead of pass: ```bash uv sync diff --git a/tests/compliance/rfc9535/test_selector.py b/tests/compliance/rfc9535/test_selector.py index cf619b42..855456ff 100644 --- a/tests/compliance/rfc9535/test_selector.py +++ b/tests/compliance/rfc9535/test_selector.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from operator import attrgetter from typing import Final @@ -17,39 +16,54 @@ pytestmark = pytest.mark.integration +try: + import iregexp_check # noqa: F401 +except ImportError: # pragma: no cover + _STRICT_JSONPATH_INSTALLED = False +else: + _STRICT_JSONPATH_INSTALLED = True -PY314_EXPECTED_FAILURES: Final[dict[str, str]] = { + +STRICT_JSONPATH_MISSING_EXPECTED_FAILURES: Final[dict[str, str]] = { "functions, match, filter, match function, unicode char class, uppercase": ( - "Python 3.14+ falls back to re for match()/search(), so RFC 9485 " - "Unicode property classes are not available out of the box" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and RFC 9485 Unicode property classes are not " + "available out of the box" ), "functions, match, filter, match function, unicode char class negated, uppercase": ( - "Python 3.14+ falls back to re for match()/search(), so RFC 9485 " - "Unicode property classes are not available out of the box" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and RFC 9485 Unicode property classes are not " + "available out of the box" ), "functions, match, dot matcher on \\u2028": ( - "Python 3.14+ falls back to re for match()/search(), so dot-matcher " - "behavior follows re instead of the RFC/I-Regexp path" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and dot-matcher behavior follows re instead of the " + "RFC/I-Regexp path" ), "functions, match, dot matcher on \\u2029": ( - "Python 3.14+ falls back to re for match()/search(), so dot-matcher " - "behavior follows re instead of the RFC/I-Regexp path" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and dot-matcher behavior follows re instead of the " + "RFC/I-Regexp path" ), "functions, search, filter, search function, unicode char class, uppercase": ( - "Python 3.14+ falls back to re for match()/search(), so RFC 9485 " - "Unicode property classes are not available out of the box" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and RFC 9485 Unicode property classes are not " + "available out of the box" ), "functions, search, filter, search function, unicode char class negated, uppercase": ( - "Python 3.14+ falls back to re for match()/search(), so RFC 9485 " - "Unicode property classes are not available out of the box" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and RFC 9485 Unicode property classes are not " + "available out of the box" ), "functions, search, dot matcher on \\u2028": ( - "Python 3.14+ falls back to re for match()/search(), so dot-matcher " - "behavior follows re instead of the RFC/I-Regexp path" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and dot-matcher behavior follows re instead of the " + "RFC/I-Regexp path" ), "functions, search, dot matcher on \\u2029": ( - "Python 3.14+ falls back to re for match()/search(), so dot-matcher " - "behavior follows re instead of the RFC/I-Regexp path" + "jsonpatchx[strict-jsonpath] is not installed, so match()/search() " + "fall back to re, and dot-matcher behavior follows re instead of the " + "RFC/I-Regexp path" ), } @@ -58,10 +72,13 @@ def _valid_case_params() -> list[pytest.ParameterSet]: params: list[pytest.ParameterSet] = [] for case in valid_cases(): marks: list[object] = [] - if sys.version_info >= (3, 14) and case.id in PY314_EXPECTED_FAILURES: + if ( + not _STRICT_JSONPATH_INSTALLED + and case.id in STRICT_JSONPATH_MISSING_EXPECTED_FAILURES + ): # pragma: no cover marks.append( pytest.mark.xfail( - reason=PY314_EXPECTED_FAILURES[case.id], + reason=STRICT_JSONPATH_MISSING_EXPECTED_FAILURES[case.id], strict=True, ) ) diff --git a/tests/integration/core/test_custom_ops_runtime.py b/tests/integration/core/test_custom_ops_runtime.py index 89c0f920..f9d7deb1 100644 --- a/tests/integration/core/test_custom_ops_runtime.py +++ b/tests/integration/core/test_custom_ops_runtime.py @@ -3,6 +3,7 @@ import pytest from jsonpatchx import ReplaceOp +from jsonpatchx.builtins import RemoveOp from jsonpatchx.exceptions import PatchConflictError, PatchInternalError from jsonpatchx.pointer import JSONPointer from jsonpatchx.schema import OperationSchema @@ -52,12 +53,20 @@ def test_custom_op_apply() -> None: def test_custom_op_internal_error_wrapped() -> None: - type Registry = ExplodeOp - patch = JsonPatch([{"op": "explode", "path": "/"}], registry=Registry) + type Registry = ExplodeOp | RemoveOp + patch = JsonPatch( + [ + {"op": "remove", "path": "/a"}, + {"op": "remove", "path": "/b"}, + {"op": "explode", "path": "/"}, + ], + registry=Registry, + ) with pytest.raises(PatchInternalError) as exc: - patch.apply({"a": 1}) - assert exc.value.detail.index == 0 - assert exc.value.detail.cause_type == "ValueError" + patch.apply({"a": 1, "b": 2, "c": 3}) + assert exc.value.index == 2 + assert exc.value.operation == ExplodeOp(path="/") + assert exc.value.cause_type == "ValueError" def test_replace_number_op_runtime() -> None: diff --git a/tests/integration/core/test_pydantic_apply_runtime.py b/tests/integration/core/test_pydantic_apply_runtime.py index f62b660b..a569f8c8 100644 --- a/tests/integration/core/test_pydantic_apply_runtime.py +++ b/tests/integration/core/test_pydantic_apply_runtime.py @@ -4,7 +4,7 @@ import pytest from pydantic import BaseModel, ConfigDict -from jsonpatchx.exceptions import PatchValidationError +from jsonpatchx.exceptions import InvalidPatchResult, InvalidPatchTarget from jsonpatchx.pydantic import JsonPatchFor from jsonpatchx.registry import StandardRegistry @@ -32,7 +32,7 @@ class Event(BaseModel): def test_model_validation_failure() -> None: UserPatch = JsonPatchFor[User] patch = UserPatch.model_validate([{"op": "replace", "path": "/name", "value": 123}]) - with pytest.raises(PatchValidationError): + with pytest.raises(InvalidPatchResult): patch.apply(User(id=1, name="Ada")) @@ -44,7 +44,7 @@ def test_wrong_model_instance() -> None: {"op": "add", "path": "/name", "value": "Ada"}, ] ) - with pytest.raises(TypeError, match="expects a User instance"): + with pytest.raises(InvalidPatchTarget, match="expects a User instance"): patch.apply(NonUser(id=1, title="Dr.")) # type: ignore[arg-type] @@ -53,7 +53,7 @@ def test_model_dump_failure() -> None: patch = EventPatch.model_validate([]) with pytest.raises( - PatchValidationError, match="Target model produced non-JSON data for patching" + InvalidPatchTarget, match="Target model produced non-JSON data for patching" ): patch.apply(Event(id=1, at=datetime.now(timezone.utc))) @@ -62,5 +62,5 @@ def test_json_body_patch_rejects_non_json_document() -> None: ConfigPatch = JsonPatchFor[Literal["Config"]] patch = ConfigPatch.model_validate([]) - with pytest.raises(PatchValidationError, match="Invalid JSON document"): + with pytest.raises(InvalidPatchTarget, match="Invalid JSON document"): patch.apply(object()) # type: ignore[arg-type] diff --git a/tests/integration/fastapi/runtime/test_demo1.py b/tests/integration/fastapi/runtime/test_demo1.py index 6d97b1a1..03692421 100644 --- a/tests/integration/fastapi/runtime/test_demo1.py +++ b/tests/integration/fastapi/runtime/test_demo1.py @@ -101,3 +101,16 @@ async def test_demo1_accepts_patch_model_dump_json_payload( assert payload["id"] == 2 assert payload["tags"][-1] == "loyal" assert payload["status"] == "champion" + + +async def test_demo1_rejects_wrong_content_type(demo1_client: AsyncClient) -> None: + patch = [{"op": "replace", "path": "/email", "value": "morgan@example.com"}] + + response = await demo1_client.patch( + "/customers/2", + json=patch, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 415 + assert response.headers["accept-patch"] == "application/json-patch+json" diff --git a/tests/integration/fastapi/test_invalid_patch_target_and_result.py b/tests/integration/fastapi/test_invalid_patch_target_and_result.py new file mode 100644 index 00000000..e5a0f189 --- /dev/null +++ b/tests/integration/fastapi/test_invalid_patch_target_and_result.py @@ -0,0 +1,129 @@ +""" +Minimal, deliberately-misconfigured FastAPI app exercising `InvalidPatchTarget` +(500) and `InvalidPatchResult` (422) end-to-end over real HTTP requests. + +Both exceptions signal server-side misconfiguration, not bad client input, so +none of the shipped `examples/fastapi` demos naturally trigger them. Each route +below is deliberately broken in one specific way to exercise one raise site. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from datetime import datetime +from typing import Literal + +import pytest +from fastapi import Body, FastAPI +from httpx import ASGITransport, AsyncClient +from pydantic import BaseModel, ConfigDict + +from jsonpatchx import AddOp, ReplaceOp +from jsonpatchx.fastapi import install_jsonpatch_error_handlers +from jsonpatchx.pydantic import JsonPatchFor + +pytestmark = [pytest.mark.anyio, pytest.mark.integration] + + +class Event(BaseModel): + id: int + at: datetime + + +class StrictUser(BaseModel): + model_config = ConfigDict(extra="forbid") + id: int + name: str + + +type EventRegistry = ReplaceOp +type StrictUserRegistry = AddOp | ReplaceOp + +EventPatch = JsonPatchFor[Event, EventRegistry] +StrictUserPatch = JsonPatchFor[StrictUser, StrictUserRegistry] +DocPatch = JsonPatchFor[Literal["Doc"], EventRegistry] + +app = FastAPI() +install_jsonpatch_error_handlers(app) + + +@app.patch("/events/{event_id}/wrong-instance") +def patch_event_wrong_instance(event_id: int, patch: EventPatch = Body(...)) -> Event: + # Misconfigured on purpose: hands `.apply()` an object that isn't an Event. + return patch.apply(StrictUser(id=event_id, name="not an event")) # type: ignore[arg-type] + + +@app.patch("/events/{event_id}/non-json-dump") +def patch_event_non_json_dump(event_id: int, patch: EventPatch = Body(...)) -> Event: + # Misconfigured on purpose: Event.at is a datetime, and model_dump()'s + # default mode="python" leaves it as a non-JSON Python object. + return patch.apply(Event(id=event_id, at=datetime.now())) + + +@app.patch("/docs/{doc_id}/non-json-doc") +def patch_doc_non_json_doc(doc_id: str, patch: DocPatch = Body(...)) -> object: + # Misconfigured on purpose: hands `.apply()` a "document" that isn't valid + # JSON, as if the server's own store returned an unserialized value. + return patch.apply({"created": datetime.now()}) + + +@app.patch("/strict-users/{user_id}") +def patch_strict_user(user_id: int, patch: StrictUserPatch = Body(...)) -> StrictUser: + return patch.apply(StrictUser(id=user_id, name="Ada")) + + +@pytest.fixture +async def client() -> AsyncGenerator[AsyncClient]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def test_invalid_patch_target_wrong_model_instance(client: AsyncClient) -> None: + response = await client.patch( + "/events/1/wrong-instance", + json=[{"op": "replace", "path": "/id", "value": 2}], + ) + + assert response.status_code == 500 + body = response.json() + assert body["type"] == "invalid_patch_target" + assert body["index"] is None + assert body["operation"] is None + assert "expects a Event instance, got StrictUser" in body["detail"] + + +async def test_invalid_patch_target_non_json_model_dump(client: AsyncClient) -> None: + response = await client.patch( + "/events/1/non-json-dump", + json=[{"op": "replace", "path": "/id", "value": 2}], + ) + + assert response.status_code == 500 + assert "non-JSON data" in response.json()["detail"] + + +async def test_invalid_patch_target_non_json_document(client: AsyncClient) -> None: + response = await client.patch( + "/docs/service/non-json-doc", + json=[{"op": "replace", "path": "/id", "value": 2}], + ) + + assert response.status_code == 500 + assert "Invalid JSON document" in response.json()["detail"] + + +async def test_invalid_patch_result_extra_field_rejected(client: AsyncClient) -> None: + response = await client.patch( + "/strict-users/1", + json=[{"op": "add", "path": "/nickname", "value": "Ace"}], + ) + + assert response.status_code == 422 + body = response.json() + assert body["type"] == "invalid_patch_result" + assert body["index"] is None + assert body["operation"] is None + assert "failed validation for StrictUser" in body["detail"] + assert body["errors"] is not None + assert body["errors"][0]["type"] == "extra_forbidden" diff --git a/tests/unit/backend/test_backend_protocols_and_state.py b/tests/unit/backend/test_backend_protocols_and_state.py index a3833218..be363f00 100644 --- a/tests/unit/backend/test_backend_protocols_and_state.py +++ b/tests/unit/backend/test_backend_protocols_and_state.py @@ -2,12 +2,11 @@ from dataclasses import dataclass from operator import attrgetter -from typing import Final, cast +from typing import Final import pytest from jsonpath import JSONPointer as ExtendedJsonPointer from jsonpointer import JsonPointer as CustomJsonPointer -from pydantic_core import MISSING from pytest import Subtests from jsonpatchx.backend import ( @@ -76,7 +75,6 @@ class StateScenario: STATE_SCENARIOS: Final[tuple[StateScenario, ...]] = ( - StateScenario("missing-root", cast(JSONValue, MISSING), "", TargetState.MISSING), StateScenario("root", {"a": 1}, "", TargetState.ROOT), StateScenario( "parent-not-found", {"a": {}}, "/a/missing/b", TargetState.PARENT_NOT_FOUND diff --git a/tests/unit/pointer/test_jsonpointer_type_and_semantics.py b/tests/unit/pointer/test_jsonpointer_type_and_semantics.py index 11439aaf..1200c63c 100644 --- a/tests/unit/pointer/test_jsonpointer_type_and_semantics.py +++ b/tests/unit/pointer/test_jsonpointer_type_and_semantics.py @@ -2,13 +2,12 @@ import copy from functools import partial -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import TYPE_CHECKING, Any, Generic import pytest from jsonpath import JSONPointer as ExtendedJsonPointer from jsonpointer import JsonPointer as CustomJsonPointer -from pydantic import BaseModel, TypeAdapter -from pydantic_core import MISSING +from pydantic import BaseModel, TypeAdapter, ValidationError from pytest import Subtests from typing_extensions import TypeVar @@ -115,21 +114,14 @@ def test_jsonpointer_root_semantics(subtests: Subtests) -> None: root = JSONPointer.parse("") with subtests.test("root pointer with existing document"): - assert root.get({"a": 1}) == {"a": 1} - assert root.add({"a": 1}, {"b": 2}) == {"b": 2} - assert root.remove({"a": 1}) is MISSING - - with subtests.test("root pointer with missing document"): - deleted = cast(JSONValue, MISSING) - assert root.is_valid_type(MISSING) is False - assert root.is_gettable(deleted) is False - assert root.is_removable(deleted) is False - assert root.is_addable(deleted, {"c": 3}) is True - assert root.add(deleted, {"c": 3}) == {"c": 3} - with pytest.raises(PatchConflictError): - root.get(deleted) - with pytest.raises(PatchConflictError): - root.remove(deleted) + doc: JSONValue = {"a": 1} + assert root.get(doc) == doc + assert root.is_gettable(doc) is True + assert root.is_addable(doc, {"b": 2}) is True + assert root.add(doc, {"b": 2}) == {"b": 2} + assert root.is_removable(doc) is False + with pytest.raises(PatchConflictError, match="cannot delete the document"): + root.remove(doc) def test_jsonpointer_backend_corruption_paths( @@ -247,11 +239,34 @@ def test_jsonpointer_parent_child_edge_cases(subtests: Subtests) -> None: with subtests.test("backend mismatch errors"): dot_ptr = JSONPointer.parse("a.b", backend=DotPointer) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): parent.is_parent_of(dot_ptr) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): parent.is_child_of(dot_ptr) + with subtests.test("malformed pointer string errors"): + # A plain str `other` that fails to parse under this pointer's own + # backend syntax is a caller-usage error (TypeError), not patch input + # (InvalidJSONPointer), even though InvalidJSONPointer is the cause. + with pytest.raises(TypeError) as exc_info: + parent.is_parent_of("not~2valid") + assert isinstance(exc_info.value.__cause__, InvalidJSONPointer) + with pytest.raises(TypeError) as exc_info: + parent.is_child_of("not~2valid") + assert isinstance(exc_info.value.__cause__, InvalidJSONPointer) + + with subtests.test("malformed pointer string errors (custom backend)"): + # Same wrapping behavior for a non-default backend, exercising + # _pointer_backend_instance's other message branch (pointer_cls is + # not DEFAULT_POINTER_CLS). + dot_ptr = JSONPointer.parse("a.b", backend=DotPointer) + with pytest.raises(TypeError) as exc_info: + dot_ptr.is_parent_of("bad..string") + assert isinstance(exc_info.value.__cause__, InvalidJSONPointer) + with pytest.raises(TypeError) as exc_info: + dot_ptr.is_child_of("bad..string") + assert isinstance(exc_info.value.__cause__, InvalidJSONPointer) + @pytest.mark.parametrize( ( @@ -309,10 +324,10 @@ def test_jsonpointer_public_methods_are_backend_agnostic( assert parent.is_parent_of(ptr) is True assert ptr.is_parent_of(parent) is False if custom_pointer_cls is None: - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): ptr.is_parent_of(DotPointer("a.b")) else: - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): ptr.is_parent_of(CustomJsonPointer("/a/b")) with subtests.test("is_child_of"): @@ -320,10 +335,10 @@ def test_jsonpointer_public_methods_are_backend_agnostic( assert ptr.is_child_of(child) is False assert ptr.is_child_of(ptr) is False if custom_pointer_cls is None: - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): ptr.is_child_of(DotPointer("a.b")) else: - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): ptr.is_child_of(CustomJsonPointer("/a/b")) with subtests.test("is_valid_type"): @@ -383,7 +398,7 @@ class BoundPointer(DotPointer): assert isinstance(ptr.ptr, DEFAULT_POINTER_CLS) with subtests.test("bound PointerBackend is invalid"): - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): JSONPointer.parse("/a", backend=PointerBackend) @@ -426,7 +441,7 @@ def test_jsonpointer_backend_reuse(subtests: Subtests) -> None: def test_jsonpointer_type_args_validation(subtests: Subtests) -> None: with subtests.test("invalid type param"): - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): TypeAdapter(JSONPointer[int()]) with subtests.test("not enough args"): @@ -438,21 +453,27 @@ def test_jsonpointer_type_args_validation(subtests: Subtests) -> None: TypeAdapter(JSONPointer[JSONValue, DotPointer, int]) with subtests.test("invalid backend"): + # Non-type values or abstract classes: TypeError at schema-build time. for invalid_backend in [ - object, object(), - JSONValue, - str, - IncompletePointerBackend, + DotPointer(""), + "DotPointer", AnotherIncompletePointerBackend, PointerMissingParts, PointerBackend, + ]: + with pytest.raises(TypeError): + TypeAdapter(JSONPointer[JSONValue, invalid_backend]) + # Concrete classes with unusable constructors or wrong return type: TypeError at validation time. + for invalid_backend in [ + object, + JSONValue, + str, + IncompletePointerBackend, BadDotPointer, - DotPointer(""), - "DotPointer", ]: - with pytest.raises(InvalidJSONPointer): - adapter = TypeAdapter(JSONPointer[JSONValue, invalid_backend]) + adapter = TypeAdapter(JSONPointer[JSONValue, invalid_backend]) + with pytest.raises(TypeError): adapter.validate_python("") with subtests.test("valid backend"): @@ -469,25 +490,25 @@ def test_jsonpointer_type_args_validation(subtests: Subtests) -> None: ): P_backend = TypeVar("P_backend", bound=PointerBackend) adapter = TypeAdapter(JSONPointer[JSONValue, P_backend]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("") with subtests.test("backend typevar constraints require specialization or default"): P_constrained = TypeVar("P_constrained", DotPointer, CustomJsonPointer) adapter = TypeAdapter(JSONPointer[JSONValue, P_constrained]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("") with subtests.test("backend typevar non-backend bound fails at runtime"): P_invalid_bound = TypeVar("P_invalid_bound", bound=str) adapter = TypeAdapter(JSONPointer[JSONValue, P_invalid_bound]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("") with subtests.test("backend typevar without constraints or bound is rejected"): P_unbound = TypeVar("P_unbound") adapter = TypeAdapter(JSONPointer[JSONValue, P_unbound]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("") with subtests.test("backend typevar default is honored at runtime"): @@ -514,7 +535,7 @@ class DefaultDotPointer(DotPointer): "P_non_type_default", bound=PointerBackend, default=123 ) adapter = TypeAdapter(JSONPointer[JSONValue, P_non_type_default]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("/a/b") with subtests.test("backend typevar PointerBackend default is rejected"): @@ -522,7 +543,7 @@ class DefaultDotPointer(DotPointer): "P_protocol_default", bound=PointerBackend, default=PointerBackend ) adapter = TypeAdapter(JSONPointer[JSONValue, P_protocol_default]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("/a/b") with subtests.test("TypeVar without default works in Python 3.12 and below"): @@ -530,25 +551,30 @@ class DefaultDotPointer(DotPointer): P_backend = typing.TypeVar("P_backend", bound=PointerBackend) adapter = TypeAdapter(JSONPointer[JSONValue, P_backend]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): adapter.validate_python("/a/b") with subtests.test("reject invalid default backend string syntax"): adapter = TypeAdapter(JSONPointer[JSONValue]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(ValidationError) as exc_info: adapter.validate_python("a.b") + ctx = exc_info.value.errors()[0].get("ctx") or {} + assert isinstance(ctx.get("error"), InvalidJSONPointer) with subtests.test("reject invalid custom backend string syntax"): adapter = TypeAdapter(JSONPointer[JSONValue, DotPointer]) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(ValidationError) as exc_info: adapter.validate_python("a..b") + ctx = exc_info.value.errors()[0].get("ctx") or {} + assert isinstance(ctx.get("error"), InvalidJSONPointer) def test_backend_typevar_explicit_policy_cases(subtests: Subtests) -> None: - with subtests.test("explicit PointerBackend parameter is rejected at runtime"): - adapter = TypeAdapter(JSONPointer[JSONValue, PointerBackend]) - with pytest.raises(InvalidJSONPointer): - adapter.validate_python("/a/b") + with subtests.test( + "explicit PointerBackend parameter is rejected at schema-build time" + ): + with pytest.raises(TypeError): + TypeAdapter(JSONPointer[JSONValue, PointerBackend]) with subtests.test("direct specialization uses explicit backend"): @@ -579,7 +605,7 @@ def test_jsonpointer_json_schema_backend_resolution(subtests: Subtests) -> None: with subtests.test("backend TypeVar without default cannot produce JSON schema"): P_backend = TypeVar("P_backend", bound=PointerBackend) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): TypeAdapter(JSONPointer[JSONValue, P_backend]).json_schema() with subtests.test("backend TypeVar defaulting to RFC backend reports RFC format"): @@ -620,6 +646,11 @@ def test_jsonpointer_path_validation(subtests: Subtests) -> None: JSONPointer.parse(ExtendedJsonPointer("/hello"), backend=DotPointer) with subtests.test("accepts narrower PointerBackends"): JSONPointer.parse(DotPointerSubclass("a.b"), backend=DotPointer) + with subtests.test("reject wrong-type path (not str/JSONPointer/PointerBackend)"): + with pytest.raises(TypeError): + JSONPointer.parse(123) # type: ignore[arg-type] + with pytest.raises(TypeError): + JSONPointer.parse(["/a"]) # type: ignore[arg-type] def test_jsonpointer_covariance_narrow_to_wide(subtests: Subtests) -> None: diff --git a/tests/unit/registry/test_registry_runtime.py b/tests/unit/registry/test_registry_runtime.py index cfd8ced5..c2dc78a3 100644 --- a/tests/unit/registry/test_registry_runtime.py +++ b/tests/unit/registry/test_registry_runtime.py @@ -2,11 +2,11 @@ from typing import Generic, Literal, cast, override import pytest +from pydantic import ValidationError from pytest import Subtests from typing_extensions import TypeVar from jsonpatchx.builtins import MoveOp, RemoveOp -from jsonpatchx.exceptions import OperationNotRecognized from jsonpatchx.pointer import JSONPointer from jsonpatchx.registry import StandardRegistry, _RegistrySpec from jsonpatchx.schema import OperationSchema @@ -99,10 +99,12 @@ def apply(self, doc: JSONValue) -> JSONValue: # pragma: no cover registry = _RegistrySpec.from_typeform(NoOp) - with pytest.raises(OperationNotRecognized): + with pytest.raises(ValidationError) as exc_info: registry.parse_python_op(op_instance) - with pytest.raises(OperationNotRecognized): + assert exc_info.value.errors()[0]["type"] == "operation_not_recognized" + with pytest.raises(ValidationError) as exc_info: registry.parse_python_patch([op_instance]) + assert exc_info.value.errors()[0]["type"] == "operation_not_recognized" def test_parse_json_op_validates_single_json_operation() -> None: @@ -160,10 +162,12 @@ class SubGenericOp(GenericOp[Backend1]): wrong_backend_specialization = GenericOp[Backend2].model_validate( {"path": "a.b"} ) - with pytest.raises(OperationNotRecognized): + with pytest.raises(ValidationError) as exc_info: registry.parse_python_op(wrong_backend_specialization) + assert exc_info.value.errors()[0]["type"] == "operation_not_recognized" with subtests.test("subclass instance mismatch is rejected"): subclass_instance = SubGenericOp.model_validate({"path": "a.b"}) - with pytest.raises(OperationNotRecognized): + with pytest.raises(ValidationError) as exc_info: registry.parse_python_op(subclass_instance) + assert exc_info.value.errors()[0]["type"] == "operation_not_recognized" diff --git a/tests/unit/registry/test_registry_validation.py b/tests/unit/registry/test_registry_validation.py index 20c1b020..47e44911 100644 --- a/tests/unit/registry/test_registry_validation.py +++ b/tests/unit/registry/test_registry_validation.py @@ -8,7 +8,7 @@ from jsonpatchx.builtins import AddOp, RemoveOp from jsonpatchx.builtins import TestOp as BuiltinTestOp -from jsonpatchx.exceptions import InvalidOperationRegistry, OperationNotRecognized +from jsonpatchx.exceptions import InvalidOperationRegistry from jsonpatchx.registry import StandardRegistry, _RegistrySpec from jsonpatchx.schema import OperationSchema from jsonpatchx.types import JSONValue @@ -134,7 +134,7 @@ def test_registry_spec_repr_hash_equality_are_set_based() -> None: def test_registry_spec_model_for_and_unknown_rejection() -> None: registry = _RegistrySpec.from_typeform(BuiltinTestOp) assert registry.model_for("test") is BuiltinTestOp - with pytest.raises(OperationNotRecognized): + with pytest.raises(KeyError): registry.model_for("not-allowed") diff --git a/tests/unit/schema/test_operation_schema_definition.py b/tests/unit/schema/test_operation_schema_definition.py index 248f664a..69a29384 100644 --- a/tests/unit/schema/test_operation_schema_definition.py +++ b/tests/unit/schema/test_operation_schema_definition.py @@ -33,9 +33,7 @@ class NonLiteralOp(OperationSchema): path: str with subtests.test("OperationSchema op literal must declare at least one value"): - with pytest.raises( - (InvalidOperationDefinition, TypeError) - ): # TypeError for Py3.13- from get_type_hints + with pytest.raises(InvalidOperationDefinition): class EmptyLiteral(OperationSchema): op: Literal # type: ignore[valid-type] diff --git a/tests/unit/schema/test_operation_schema_pointer_fields.py b/tests/unit/schema/test_operation_schema_pointer_fields.py index 7f2c9275..17492e7a 100644 --- a/tests/unit/schema/test_operation_schema_pointer_fields.py +++ b/tests/unit/schema/test_operation_schema_pointer_fields.py @@ -3,6 +3,7 @@ from typing import Generic, Literal, override import pytest +from pydantic import ValidationError from typing_extensions import TypeVar from jsonpatchx.builtins import AddOp, RemoveOp, ReplaceOp @@ -24,8 +25,10 @@ class ReadOp(OperationSchema): def apply(self, doc: JSONValue) -> JSONValue: return doc # pragma: no cover - with pytest.raises(InvalidJSONPointer): + with pytest.raises(ValidationError) as exc_info: ReadOp.model_validate({"path": "/a~2"}) + ctx = exc_info.value.errors()[0].get("ctx") or {} + assert isinstance(ctx.get("error"), InvalidJSONPointer) def test_jsonpointer_type_gating() -> None: @@ -64,7 +67,7 @@ def apply(self, doc: JSONValue) -> JSONValue: dot = DotOp.model_validate({"path": "a.b"}) slash = SlashOp.model_validate({"path": "/a/b"}) - with pytest.raises(InvalidJSONPointer): + with pytest.raises(TypeError): dot.path.is_parent_of(slash.path) diff --git a/tests/unit/selector/test_jsonselector_type_and_semantics.py b/tests/unit/selector/test_jsonselector_type_and_semantics.py index ce879624..9339e762 100644 --- a/tests/unit/selector/test_jsonselector_type_and_semantics.py +++ b/tests/unit/selector/test_jsonselector_type_and_semantics.py @@ -2,11 +2,10 @@ import copy from functools import partial -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import TYPE_CHECKING, Any, Generic import pytest -from pydantic import BaseModel, TypeAdapter -from pydantic_core import MISSING +from pydantic import BaseModel, TypeAdapter, ValidationError from pytest import Subtests from typing_extensions import TypeVar @@ -122,23 +121,11 @@ def test_jsonselector_root_semantics(subtests: Subtests) -> None: assert selector.getall(doc) == [doc] assert selector.get_pointers(doc) == [JSONPointer.parse("")] assert selector.is_gettable(doc) is True - assert selector.is_removable(doc) is True - assert selector.is_addable(doc, {"b": 2}) is True - assert selector.addall(copy.deepcopy(doc), {"b": 2}) == {"b": 2} - assert selector.removeall(copy.deepcopy(doc)) is MISSING - - with subtests.test("root selector with missing document"): - doc: JSONValue = cast(JSONValue, MISSING) - assert selector.is_valid_type(MISSING) is False - with pytest.raises(PatchConflictError): - selector.getall(doc) - assert selector.get_pointers(doc) == [JSONPointer.parse("")] - assert selector.is_gettable(doc) is False assert selector.is_removable(doc) is False assert selector.is_addable(doc, {"b": 2}) is True - assert selector.addall(doc, {"b": 2}) == {"b": 2} - with pytest.raises(PatchConflictError): - selector.removeall(doc) + assert selector.addall(copy.deepcopy(doc), {"b": 2}) == {"b": 2} + with pytest.raises(PatchConflictError, match="cannot delete the document"): + selector.removeall(copy.deepcopy(doc)) def test_default_jsonselector_zero_matches() -> None: @@ -174,9 +161,7 @@ def test_default_jsonselector_backend_corruption_paths( assert selector.is_addable({"a": 1}, 1) is False assert selector.is_removable({"a": 1}) is False - with subtests.test( - "invalid backend pointer raises in accessors and returns False in boolean helpers" - ): + with subtests.test("invalid backend pointer raises TypeError in all helpers"): class InvalidPointerBackend: def pointers(self, _doc: JSONValue) -> list[object]: @@ -184,18 +169,22 @@ def pointers(self, _doc: JSONValue) -> list[object]: selector: JSONSelector[JSONValue] = JSONSelector.parse("$.a") monkeypatch.setattr(selector, "_selector", InvalidPointerBackend()) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): selector.get_pointers({"a": 1}) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): selector.getall({"a": 1}) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): selector.addall({"a": 1}, 1) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): selector.removeall({"a": 1}) - assert selector.is_gettable({"a": 1}) is False - assert selector.is_addable({"a": 1}) is False - assert selector.is_addable({"a": 1}, 1) is False - assert selector.is_removable({"a": 1}) is False + with pytest.raises(TypeError): + selector.is_gettable({"a": 1}) + with pytest.raises(TypeError): + selector.is_addable({"a": 1}) + with pytest.raises(TypeError): + selector.is_addable({"a": 1}, 1) + with pytest.raises(TypeError): + selector.is_removable({"a": 1}) @pytest.mark.parametrize( @@ -338,7 +327,7 @@ class BoundSelector(SimpleSelector): assert isinstance(selector.ptr, DEFAULT_SELECTOR_CLS) with subtests.test("bound SelectorBackend is invalid"): - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): JSONSelector.parse("$.a", backend=SelectorBackend) @@ -386,7 +375,7 @@ class SimpleSelectorSubclass(SimpleSelector): def test_jsonselector_type_args_validation(subtests: Subtests) -> None: with subtests.test("invalid type param"): - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): TypeAdapter(JSONSelector[int()]) with subtests.test("not enough args"): @@ -398,21 +387,27 @@ def test_jsonselector_type_args_validation(subtests: Subtests) -> None: TypeAdapter(JSONSelector[JSONValue, SimpleSelector, int]) with subtests.test("invalid backend"): + # Non-type values or abstract classes: TypeError at schema-build time. for invalid_backend in [ - object, object(), - JSONValue, - str, - IncompleteSelectorBackend, + SimpleSelector("a"), + "SimpleSelector", AnotherIncompleteSelectorBackend, SelectorMissingPointers, SelectorBackend, + ]: + with pytest.raises(TypeError): + TypeAdapter(JSONSelector[JSONValue, invalid_backend]) + # Concrete classes with unusable constructors or wrong return type: TypeError at validation time. + for invalid_backend in [ + object, + JSONValue, + str, + IncompleteSelectorBackend, BadSimpleSelector, - SimpleSelector("a"), - "SimpleSelector", ]: - with pytest.raises(InvalidJSONSelector): - adapter = TypeAdapter(JSONSelector[JSONValue, invalid_backend]) + adapter = TypeAdapter(JSONSelector[JSONValue, invalid_backend]) + with pytest.raises(TypeError): adapter.validate_python("a") with subtests.test("valid backend"): @@ -427,25 +422,25 @@ def test_jsonselector_type_args_validation(subtests: Subtests) -> None: ): S_backend = TypeVar("S_backend", bound=SelectorBackend) adapter = TypeAdapter(JSONSelector[JSONValue, S_backend]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("backend typevar constraints require specialization or default"): S_constrained = TypeVar("S_constrained", SimpleSelector, DEFAULT_SELECTOR_CLS) adapter = TypeAdapter(JSONSelector[JSONValue, S_constrained]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("backend typevar non-backend bound fails at runtime"): S_invalid_bound = TypeVar("S_invalid_bound", bound=str) adapter = TypeAdapter(JSONSelector[JSONValue, S_invalid_bound]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("backend typevar without constraints or bound is rejected"): S_unbound = TypeVar("S_unbound") adapter = TypeAdapter(JSONSelector[JSONValue, S_unbound]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("backend typevar default is honored at runtime"): @@ -472,7 +467,7 @@ class DefaultSimpleSelector(SimpleSelector): "S_non_type_default", bound=SelectorBackend, default=123 ) adapter = TypeAdapter(JSONSelector[JSONValue, S_non_type_default]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("backend typevar SelectorBackend default is rejected"): @@ -480,7 +475,7 @@ class DefaultSimpleSelector(SimpleSelector): "S_protocol_default", bound=SelectorBackend, default=SelectorBackend ) adapter = TypeAdapter(JSONSelector[JSONValue, S_protocol_default]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("TypeVar without default works in Python 3.12 and below"): @@ -488,25 +483,30 @@ class DefaultSimpleSelector(SimpleSelector): S_backend = typing.TypeVar("S_backend", bound=SelectorBackend) adapter = TypeAdapter(JSONSelector[JSONValue, S_backend]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): adapter.validate_python("$.a") with subtests.test("reject invalid default backend string syntax"): adapter = TypeAdapter(JSONSelector[JSONValue]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(ValidationError) as exc_info: adapter.validate_python("a") + ctx = exc_info.value.errors()[0].get("ctx") or {} + assert isinstance(ctx.get("error"), InvalidJSONSelector) with subtests.test("reject invalid custom backend string syntax"): adapter = TypeAdapter(JSONSelector[JSONValue, SimpleSelector]) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(ValidationError) as exc_info: adapter.validate_python("$.a") + ctx = exc_info.value.errors()[0].get("ctx") or {} + assert isinstance(ctx.get("error"), InvalidJSONSelector) def test_selector_backend_typevar_explicit_policy_cases(subtests: Subtests) -> None: - with subtests.test("explicit SelectorBackend parameter is rejected at runtime"): - adapter = TypeAdapter(JSONSelector[JSONValue, SelectorBackend]) - with pytest.raises(InvalidJSONSelector): - adapter.validate_python("$.a") + with subtests.test( + "explicit SelectorBackend parameter is rejected at schema-build time" + ): + with pytest.raises(TypeError): + TypeAdapter(JSONSelector[JSONValue, SelectorBackend]) with subtests.test("direct specialization uses explicit backend"): @@ -537,7 +537,7 @@ def test_jsonselector_json_schema_backend_resolution(subtests: Subtests) -> None with subtests.test("backend TypeVar without default cannot produce JSON schema"): S_backend = TypeVar("S_backend", bound=SelectorBackend) - with pytest.raises(InvalidJSONSelector): + with pytest.raises(TypeError): TypeAdapter(JSONSelector[JSONValue, S_backend]).json_schema() with subtests.test( @@ -589,6 +589,13 @@ class SimpleSelectorSubclass(SimpleSelector): with subtests.test("accepts narrower SelectorBackends"): JSONSelector.parse(SimpleSelectorSubclass("a"), backend=SimpleSelector) + with subtests.test( + "reject wrong-type selector (not str/JSONSelector/SelectorBackend)" + ): + with pytest.raises(TypeError): + JSONSelector.parse(123) # type: ignore[arg-type] + with pytest.raises(TypeError): + JSONSelector.parse(["$.a"]) # type: ignore[arg-type] def test_jsonselector_covariance_narrow_to_wide(subtests: Subtests) -> None: diff --git a/tests/unit/standard/test_apply_patch.py b/tests/unit/standard/test_apply_patch.py index db42e183..c8696144 100644 --- a/tests/unit/standard/test_apply_patch.py +++ b/tests/unit/standard/test_apply_patch.py @@ -6,7 +6,7 @@ import pytest from jsonpatchx import apply_patch -from jsonpatchx.exceptions import PatchValidationError +from jsonpatchx.exceptions import InvalidPatchTarget def test_apply_patch_is_thin_wrapper_over_jsonpatch_apply() -> None: @@ -34,5 +34,5 @@ def test_apply_patch_is_thin_wrapper_over_jsonpatch_apply() -> None: def test_apply_patch_rejects_non_jsonvalue_doc() -> None: - with pytest.raises(PatchValidationError, match="Invalid JSON document"): + with pytest.raises(InvalidPatchTarget, match="Invalid JSON document"): apply_patch(object(), []) diff --git a/tests/unit/standard/test_builtin_ops_runtime.py b/tests/unit/standard/test_builtin_ops_runtime.py new file mode 100644 index 00000000..80b697fe --- /dev/null +++ b/tests/unit/standard/test_builtin_ops_runtime.py @@ -0,0 +1,42 @@ +import pytest + +from jsonpatchx.builtins import AddOp, RemoveOp, ReplaceOp +from jsonpatchx.exceptions import PatchConflictError +from jsonpatchx.pointer import JSONPointer +from jsonpatchx.standard import JsonPatch +from jsonpatchx.types import JSONValue + + +def test_addop_root_add_still_works() -> None: + assert AddOp(path=JSONPointer.parse(""), value={"b": 2}).apply({"a": 1}) == {"b": 2} + + +def test_removeop_root_delete_rejected() -> None: + with pytest.raises(PatchConflictError, match="cannot delete the document"): + RemoveOp(path=JSONPointer.parse("")).apply({"a": 1}) + + +def test_replaceop_root_replace_still_works() -> None: + assert ReplaceOp(path=JSONPointer.parse(""), value={"b": 2}).apply({"a": 1}) == { + "b": 2 + } + + +def test_jsonpatch_root_remove_then_add_is_rejected() -> None: + payload: list[dict[str, JSONValue]] = [ + {"op": "remove", "path": ""}, + {"op": "add", "path": "", "value": {"a": 1}}, + ] + patch = JsonPatch(payload) + + with pytest.raises(PatchConflictError, match="cannot delete the document"): + patch.apply({"a": 1}) + + +def test_jsonpatch_root_replace_still_works() -> None: + payload: list[dict[str, JSONValue]] = [ + {"op": "replace", "path": "", "value": {"a": 1}}, + ] + patch = JsonPatch(payload) + + assert patch.apply({"b": 2}) == {"a": 1} diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 00000000..cea3723a --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,6335 @@ +{ + "name": "jsonpatchx-lint-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jsonpatchx-lint-tools", + "devDependencies": { + "@stoplight/spectral-cli": "6.16.1", + "jscpd": "5.0.11", + "prettier": "3.9.4", + "textlint": "15.7.1", + "textlint-rule-terminology": "5.2.16" + } + }, + "node_modules/@asyncapi/specs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", + "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", + "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "commondir": "^1.0.1", + "estree-walker": "^2.0.1", + "glob": "^7.1.6", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7", + "resolve": "^1.17.0" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@stoplight/json": { + "version": "3.21.7", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.7.tgz", + "integrity": "sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.3", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "jsonc-parser": "~2.2.1", + "lodash": "^4.17.21", + "safe-stable-stringify": "^1.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-readers/-/json-ref-readers-1.2.2.tgz", + "integrity": "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0", + "tslib": "^1.14.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/json-ref-resolver": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.6.tgz", + "integrity": "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.21.0", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^12.3.0 || ^13.0.0", + "@types/urijs": "^1.19.19", + "dependency-graph": "~0.11.0", + "fast-memoize": "^2.5.2", + "immer": "^9.0.6", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "urijs": "^1.19.11" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/spectral-cli": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.16.1.tgz", + "integrity": "sha512-6DdYx94d+BNVTdgJRkb1KJIcqYQsOxstAZtH7Kh63SDUsXFRkdfsBKsL7l6csxLRYYh5Qm6CSBF7AUFYBFJ23w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": "^1.19.5", + "@stoplight/spectral-formatters": "^1.4.1", + "@stoplight/spectral-parsers": "^1.0.4", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-bundler": "^1.6.0", + "@stoplight/spectral-ruleset-migrator": "^1.11.0", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "chalk": "4.1.2", + "fast-glob": "~3.2.12", + "hpagent": "~1.2.0", + "lodash": "^4.18.1", + "pony-cause": "^1.1.1", + "stacktracey": "^2.1.8", + "tslib": "^2.8.1", + "yargs": "~17.7.2" + }, + "bin": { + "spectral": "dist/index.js" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.23.1.tgz", + "integrity": "sha512-VLC8OhpO/pMJKb6IHhurxJjXO1qB56Ng1unIb8b+hNxdw0+SEcASvmR+RpjfHYX/jv/DfSaA1x8QhFBJBmqBOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-parsers": "^1.0.0", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "~13.6.0", + "@types/es-aggregate-error": "^1.0.2", + "@types/json-schema": "^7.0.11", + "ajv": "^8.18.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "es-aggregate-error": "^1.0.7", + "expr-eval-fork": "^3.0.1", + "jsonpath-plus": "^10.3.0", + "lodash": "^4.18.1", + "lodash.topath": "^4.5.2", + "minimatch": "^3.1.4", + "nimma": "0.2.3", + "pony-cause": "^1.1.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/@stoplight/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", + "integrity": "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-formats": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.5.tgz", + "integrity": "sha512-xaC0rCH0p7/bzNJsz+JgLSj+Cp6uwYGWpePQxdLkF2G6a8Zyp3OyS7umkGYNiimEwKrOjvCNNTFJpeuiENZSBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@types/json-schema": "^7.0.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-formatters": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.5.1.tgz", + "integrity": "sha512-mGXaiIrPglPokSnbFqbkWN3DoozIbwrZAA6OgqSIl+djeD5+e6PMELg0g6r3ot3ZzntO+6/GXaDnxEQ/p9M/EQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/path": "^1.3.2", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.15.0", + "@types/markdown-escape": "^1.1.3", + "chalk": "4.1.2", + "cliui": "7.0.4", + "lodash": "^4.18.1", + "markdown-escape": "^2.0.0", + "node-sarif-builder": "^2.0.3", + "strip-ansi": "6.0", + "text-table": "^0.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.5.tgz", + "integrity": "sha512-vDCd0NJ93715bcUpZZ5vNHiyxd4cgHF6tuXsDiXOXKAByg+I1fR5/dMijEo6Ce1Lz95a+RZ22JKYhF1YuzVvuA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-runtime": "^1.1.2", + "ajv": "^8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", + "integrity": "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml": "~4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-ref-resolver": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.5.tgz", + "integrity": "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json-ref-readers": "1.2.2", + "@stoplight/json-ref-resolver": "~3.1.6", + "@stoplight/spectral-runtime": "^1.1.2", + "dependency-graph": "0.11.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-bundler": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.7.0.tgz", + "integrity": "sha512-PpIdj5Wje0T7ktxY8EUzBWLU0+mGGQHznT8nlQxTMnRhWLNYsm6HvSZDXLtMi+86yqvTuf7loJy6JvLBDzHGAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@rollup/plugin-commonjs": "~22.0.2", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": ">=1", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": ">=1", + "@stoplight/spectral-parsers": ">=1", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/node": "*", + "pony-cause": "1.1.1", + "rollup": "~2.80.0", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.12.1.tgz", + "integrity": "sha512-IUEbDmmTro0oF6VoAtrUySRV/b6bvYmV7wV6lB99f0Ym5lF9M2DXcgPLo7VMbKTPjCOQcaBzWRnIMXAyLjIRMA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/ordered-object-literal": "~1.0.4", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@stoplight/yaml": "~4.2.3", + "@types/node": "*", + "ajv": "^8.18.0", + "ast-types": "0.14.2", + "astring": "^1.9.0", + "reserved": "0.1.2", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", + "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.1", + "@stoplight/types": "^13.0.0", + "@stoplight/yaml-ast-parser": "0.0.48", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", + "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/spectral-rulesets": { + "version": "1.22.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.6.tgz", + "integrity": "sha512-xBwrb2zjx+7AzGS3aX7aOtddChRRw8aoQMu8ZT5AmTfEr0VAj4ydGC6Pl7lIvAIomcO7hw+P4I2oylTOOCkUVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.8.0", + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/json-schema": "^7.0.7", + "ajv": "^8.18.0", + "ajv-formats": "~2.1.1", + "json-schema-traverse": "^1.0.0", + "leven": "3.1.0", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", + "integrity": "sha512-Y8rEDyMN4bSMJCrDs2shdcVHYyCnH3FvXRP4dBhha4Z8iJv+JPp7KqOV/hwVB/hWFC209upiwj2oDmLfR0qCDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.20.1", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "lodash": "^4.18.1", + "node-fetch": "^2.7.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/yaml/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/ast-tester": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-tester/-/ast-tester-15.7.1.tgz", + "integrity": "sha512-0CZWFKB7D21y8LA4Dv5irOX1/iGDGwM4OTaW7PxJpfRhXCL40uMOhS+P+1bjFmpSkM/DF/5HPRph0E744iJobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1", + "debug": "^4.4.3" + } + }, + "node_modules/@textlint/ast-traverse": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-traverse/-/ast-traverse-15.7.1.tgz", + "integrity": "sha512-tFgFiSbh6fdQo7MF4FYQoQBHqM8BoTEfvubGXm7Xi65QRYefNz+iuXYaoyto6OlMj5M95u9Gk/Ni7577rZ8uow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, + "node_modules/@textlint/config-loader": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/config-loader/-/config-loader-15.7.1.tgz", + "integrity": "sha512-K5KTpQFclHn0zby48wvckhOzUg2gXJyFXeagYt9mFgYOnCgMzTYL/P/TIE6ljhtZ0arhK1aH0e1uFZYasGbXog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/kernel": "15.7.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "@textlint/utils": "15.7.1", + "debug": "^4.4.3", + "rc-config-loader": "^4.1.4" + } + }, + "node_modules/@textlint/feature-flag": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/feature-flag/-/feature-flag-15.7.1.tgz", + "integrity": "sha512-ZFIJ2edV7K04UJ7/7ptvL61vs54MHYzDaIfTW+vaXwq5KDeCD2QyjsKt+TbOHQ8n8sW6fO171n4p0gwRo01Ojg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/fixer-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/fixer-formatter/-/fixer-formatter-15.7.1.tgz", + "integrity": "sha512-vqF17A1aUJeIulvWQUWX4SX0tYWT6Burx8L1qPHbf8ZlpUAHSXPVzorwmZyw6bAkKl6cODHj2EtK2HrnqjV1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "diff": "^8.0.4", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/kernel": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/kernel/-/kernel-15.7.1.tgz", + "integrity": "sha512-OGghiMbXLD3ULHedZYP1B+A8Bj4snlCatzsOdz0M8q0v4I79NdcamWt/5GYmbp6AOQg3HBQG8+9V+0H+do19mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1", + "@textlint/ast-tester": "15.7.1", + "@textlint/ast-traverse": "15.7.1", + "@textlint/feature-flag": "15.7.1", + "@textlint/source-code-fixer": "15.7.1", + "@textlint/types": "15.7.1", + "@textlint/utils": "15.7.1", + "debug": "^4.4.3", + "fast-equals": "^4.0.3", + "structured-source": "^4.0.0" + } + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/markdown-to-ast": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/markdown-to-ast/-/markdown-to-ast-15.7.1.tgz", + "integrity": "sha512-9DLSah7g6mYNHvO6pssLdFvFPAl3HHyEIm4RE5of/1QN9FXJXDgdOcVV3YpQmbYT/YntuIvOQiqOndCSPWTW2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1", + "debug": "^4.4.3", + "mdast-util-gfm-autolink-literal": "^0.1.3", + "neotraverse": "^0.6.18", + "remark-footnotes": "^3.0.0", + "remark-frontmatter": "^3.0.0", + "remark-gfm": "^1.0.0", + "remark-parse": "^9.0.0", + "structured-source": "^4.0.0", + "unified": "^9.2.2" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/source-code-fixer": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/source-code-fixer/-/source-code-fixer-15.7.1.tgz", + "integrity": "sha512-Z8ZfQQbVH2GIUMzSGEQWKKnAFCH+mbQtw5ipigFHExUImx2RC2ppaR1m8ZAy7SYgbXGhvfkPKZ4ndEmVWTs5Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/types": "15.7.1", + "debug": "^4.4.3" + } + }, + "node_modules/@textlint/text-to-ast": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/text-to-ast/-/text-to-ast-15.7.1.tgz", + "integrity": "sha512-W6AYCOcEAtw9hs0u69+Tte8hmWaLKYGdo3yBxgOpl20Q6AOkxxEaG305Nr5rgbO6caWJWkR/t2WxBkZzm3KG0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, + "node_modules/@textlint/textlint-plugin-markdown": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/textlint-plugin-markdown/-/textlint-plugin-markdown-15.7.1.tgz", + "integrity": "sha512-vnTU1/0ZLEC0cSBK5bBfR3aXYJbn3rMFR455y848Df6DHI4gLc1L2uNXDkYYPh84KvzIs7RfEUHZyHSwSISFLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/markdown-to-ast": "15.7.1", + "@textlint/types": "15.7.1" + } + }, + "node_modules/@textlint/textlint-plugin-text": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/textlint-plugin-text/-/textlint-plugin-text-15.7.1.tgz", + "integrity": "sha512-krU0KiDmG2LrESF3LchgfbSwclB/8I2itA4uzSY9Tln3miaWGKzUpLu00bQtvua0f2Ux7tsLnSmwAgGFf9gERA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/text-to-ast": "15.7.1", + "@textlint/types": "15.7.1" + } + }, + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, + "node_modules/@textlint/utils": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/utils/-/utils-15.7.1.tgz", + "integrity": "sha512-+q5Z5fsNk/ixDY5D70ZCQungr7ppzBAAhmi207qDBzSBFeA5MM2ASGwMjPc5aMJ/3DvonI/01B3UIgbpVgIXVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/es-aggregate-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", + "integrity": "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-escape": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/markdown-escape/-/markdown-escape-1.1.3.tgz", + "integrity": "sha512-JIc1+s3y5ujKnt/+N+wq6s/QdL2qZ11fP79MijrVXsAAnzSxCbT2j/3prHRouJdZ2yFLN3vkP0HytfnoCczjOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/urijs": { + "version": "1.19.26", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cpd-darwin-arm64": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/cpd-darwin-arm64/-/cpd-darwin-arm64-5.0.11.tgz", + "integrity": "sha512-3QvH+4Dv7A7esVFM2tsRVWN3kn9EDu8dMYog6gYAVsCtxEf4xyxAwS/ef6LjC7/dh4+ATADFbg3H09A2fD//Qw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/cpd-darwin-x64": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/cpd-darwin-x64/-/cpd-darwin-x64-5.0.11.tgz", + "integrity": "sha512-OvgM2ps0OFR5jUzx7+FK9URdJGxUzzM5KKk2F1V3vf1LooGDKwkivfIDyKsqEwp37zcbyUo7COvBpJXOT0dZmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/cpd-linux-arm64-gnu": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/cpd-linux-arm64-gnu/-/cpd-linux-arm64-gnu-5.0.11.tgz", + "integrity": "sha512-pXMINibAeruglni8ZajlXEefZHDs7QFSG+vPtkBDu7uiIMpNU8aoktgO2vP+PbIRFD0vHkqTMb64kDtIOqQcwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/cpd-linux-x64-gnu": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/cpd-linux-x64-gnu/-/cpd-linux-x64-gnu-5.0.11.tgz", + "integrity": "sha512-rQ7DuF0lH1HLzjGxlE0aEP2ycfhXgZH/CLSeS7FXNJ38lRVp+iXkrlcrrY6mC4WW/NgbL+DkF7/0lv3tFvGmvg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/cpd-linux-x64-musl": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/cpd-linux-x64-musl/-/cpd-linux-x64-musl-5.0.11.tgz", + "integrity": "sha512-Yh+7Go5+fA++I5ssAZg7gUkDCT5CxnzCPvrspbwDrfnwaY6nNM5g1C6Vs0+GJhsspuAKwydJl4nf7jkxzMwRQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/cpd-windows-x64-msvc": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/cpd-windows-x64-msvc/-/cpd-windows-x64-msvc-5.0.11.tgz", + "integrity": "sha512-uV6w85qdfE0WJsrLcGw9A4Kv9ovSnlXZCybMK0esvuiJ7clgaZmDiDPozt5PvrOOShkK/NxzTZSORBSdVnquHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "dev": true, + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/file-entry-cache": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.1.4.tgz", + "integrity": "sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jscpd": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.0.11.tgz", + "integrity": "sha512-NfLrFJHRM6rIf3oVcdZ4sfhMVop1qxi5r8aC99lpj55YC8hiWaN4VmzU2wcXTwoAo+NS4npXPs9EVEQJ6jyRlg==", + "dev": true, + "license": "MIT", + "bin": { + "jscpd": "run-jscpd.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "cpd-darwin-arm64": "5.0.11", + "cpd-darwin-x64": "5.0.11", + "cpd-linux-arm64-gnu": "5.0.11", + "cpd-linux-x64-gnu": "5.0.11", + "cpd-linux-x64-musl": "5.0.11", + "cpd-windows-x64-msvc": "5.0.11" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", + "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", + "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/markdown-escape": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", + "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-1.1.1.tgz", + "integrity": "sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-footnote": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/mdast-util-footnote/-/mdast-util-footnote-0.1.7.tgz", + "integrity": "sha512-QxNdO8qSxqbO2e3m09KwDKfWiLgqyCurdWTQ198NpbZ2hxntdc+VKS4fDJCmNWbAroUdYnSthu+XbZ8ovh8C3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "^0.6.0", + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", + "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-string": "^2.0.0", + "micromark": "~2.11.0", + "parse-entities": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-0.2.0.tgz", + "integrity": "sha512-FHKL4w4S5fdt1KjJCwB0178WJ0evnyyQr5kXTM3wrOVpytD0hrkvd+AOOjU9Td8onOejCkmZ+HQRT3CZ3coHHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-frontmatter": "^0.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.2.tgz", + "integrity": "sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-gfm-autolink-literal": "^0.1.0", + "mdast-util-gfm-strikethrough": "^0.2.0", + "mdast-util-gfm-table": "^0.1.0", + "mdast-util-gfm-task-list-item": "^0.1.0", + "mdast-util-to-markdown": "^0.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.3.tgz", + "integrity": "sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ccount": "^1.0.0", + "mdast-util-find-and-replace": "^1.1.0", + "micromark": "^2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.3.tgz", + "integrity": "sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.6.tgz", + "integrity": "sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "markdown-table": "^2.0.0", + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.6.tgz", + "integrity": "sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz", + "integrity": "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "longest-streak": "^2.0.0", + "mdast-util-to-string": "^2.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", + "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "parse-entities": "^2.0.0" + } + }, + "node_modules/micromark-extension-footnote": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/micromark-extension-footnote/-/micromark-extension-footnote-0.3.2.tgz", + "integrity": "sha512-gr/BeIxbIWQoUm02cIfK7mdMZ/fbroRpLsck4kvFtjbzP4yi+OPVbnukTc/zy0i7spC2xYE/dbX1Sur8BEDJsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-frontmatter": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-0.2.2.tgz", + "integrity": "sha512-q6nPLFCMTLtfsctAuS0Xh4vaolxSFUWUWR6PZSrXXiRy+SANGllpcqdXFv2z07l0Xz/6Hl40hK0ffNCJPH2n1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fault": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz", + "integrity": "sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0", + "micromark-extension-gfm-autolink-literal": "~0.5.0", + "micromark-extension-gfm-strikethrough": "~0.6.5", + "micromark-extension-gfm-table": "~0.4.0", + "micromark-extension-gfm-tagfilter": "~0.3.0", + "micromark-extension-gfm-task-list-item": "~0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.7.tgz", + "integrity": "sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "~2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz", + "integrity": "sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.3.tgz", + "integrity": "sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz", + "integrity": "sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.3.tgz", + "integrity": "sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-sarif-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", + "integrity": "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.4", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-glob-pattern": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-to-glob-pattern/-/path-to-glob-pattern-2.0.1.tgz", + "integrity": "sha512-tmciSlVyHnX0LC86+zSr+0LURw9rDPw8ilhXcmTpVUOnI6OsKdCzXQs5fTG10Bjz26IBdnKL3XIaP+QvGsk5YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-footnotes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-3.0.0.tgz", + "integrity": "sha512-ZssAvH9FjGYlJ/PBVKdSmfyPc3Cz4rTWgZLI4iE/SX8Nt5l3o3oEjv3wwG5VD7xOjktzdwp5coac+kJV9l4jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-footnote": "^0.1.0", + "micromark-extension-footnote": "^0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-frontmatter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-3.0.0.tgz", + "integrity": "sha512-mSuDd3svCHs+2PyO29h7iijIZx4plX0fheacJcAoYAASfgzgVIcXGYSq9GFyYocFLftQs8IOmmkgtOovs6d4oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-frontmatter": "^0.2.0", + "micromark-extension-frontmatter": "^0.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz", + "integrity": "sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-gfm": "^0.1.0", + "micromark-extension-gfm": "^0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz", + "integrity": "sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved/-/reserved-0.1.2.tgz", + "integrity": "sha512-/qO54MWj5L8WCBP9/UNe2iefJc+L9yETbH32xO/ft/EYPOTCR5k+azvDUgdCOKwZH8hXwPd0b8XBL78Nn2U69g==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", + "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textlint": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/textlint/-/textlint-15.7.1.tgz", + "integrity": "sha512-T6WRImq6XBnf7kRUE401/gz4USDcrsr9ksDfpwspPAHcvlptsTmd5CrRuPc2brd6t93Z6xSGIGlvV4QAMUHx+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@textlint/ast-node-types": "15.7.1", + "@textlint/ast-traverse": "15.7.1", + "@textlint/config-loader": "15.7.1", + "@textlint/feature-flag": "15.7.1", + "@textlint/fixer-formatter": "15.7.1", + "@textlint/kernel": "15.7.1", + "@textlint/linter-formatter": "15.7.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/textlint-plugin-markdown": "15.7.1", + "@textlint/textlint-plugin-text": "15.7.1", + "@textlint/types": "15.7.1", + "@textlint/utils": "15.7.1", + "debug": "^4.4.3", + "file-entry-cache": "^10.1.4", + "glob": "^13.0.6", + "md5": "^2.3.0", + "optionator": "^0.9.4", + "path-to-glob-pattern": "^2.0.1", + "rc-config-loader": "^4.1.4", + "read-package-up": "^11.0.0", + "structured-source": "^4.0.0", + "zod": "^3.25.76" + }, + "bin": { + "textlint": "bin/textlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/textlint-rule-helper": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/textlint-rule-helper/-/textlint-rule-helper-2.5.0.tgz", + "integrity": "sha512-QIbFPtyqLy0g5BJn8mryk9iHzGYicNaFIpLFPiEnb4RXxrEGeQ2W2aARQ9yEXLIAqo+OwK4ndWBAWkbgJEPzTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "^15.2.1", + "structured-source": "^4.0.0", + "unist-util-visit": "^2.0.3" + } + }, + "node_modules/textlint-rule-terminology": { + "version": "5.2.16", + "resolved": "https://registry.npmjs.org/textlint-rule-terminology/-/textlint-rule-terminology-5.2.16.tgz", + "integrity": "sha512-1HQotOLJb6kGAgmRYEA9KiIU+fQupZcOVpZEEhFwmRempvpNnhKowa7Zfpss2v6sbp8Ya+iXRs9xKGw+xheCbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-json-comments": "^5.0.3", + "textlint-rule-helper": "^2.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/textlint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/textlint/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/textlint/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/textlint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unified": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", + "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", + "dev": true, + "license": "ISC", + "dependencies": { + "builtins": "^1.0.3" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 00000000..af8e0e43 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,11 @@ +{ + "name": "jsonpatchx-lint-tools", + "private": true, + "devDependencies": { + "@stoplight/spectral-cli": "6.16.1", + "jscpd": "5.0.11", + "prettier": "3.9.4", + "textlint": "15.7.1", + "textlint-rule-terminology": "5.2.16" + } +} diff --git a/uv.lock b/uv.lock index 68ecc503..78ac2c24 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.12" resolution-markers = [ "python_full_version == '3.14.*'", "python_full_version >= '3.15'", - "python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [[package]] @@ -208,6 +209,18 @@ 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 = "griffe-pydantic" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/bd/d2eaeaf3f9910c9cd72793af0de18ee3d3a3a27bb30ab01cfd7659c08dc4/griffe_pydantic-1.3.1.tar.gz", hash = "sha256:f7caedfa0effedb22893bf01cc411fd567614f7b4de7ce0c1f4293eb7acb5c44", size = 38176, upload-time = "2026-02-21T09:38:49.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/88/17d656aa97406cd509e2be0e6eddcf81fc1666104bf81042b89721d4a44c/griffe_pydantic-1.3.1-py3-none-any.whl", hash = "sha256:475103794a1d5da3933d3968e82a2bd9d9a1fa507c06d5ee1a39ee1fadcca628", size = 15189, upload-time = "2026-02-20T11:34:14.965Z" }, +] + [[package]] name = "griffelib" version = "2.0.2" @@ -313,8 +326,8 @@ source = { editable = "." } dependencies = [ { name = "jsonpointer" }, { name = "pydantic" }, + { name = "pydantic-core" }, { name = "python-jsonpath" }, - { name = "python-jsonpath", extra = ["strict"], marker = "python_full_version < '3.14'" }, { name = "typing-extensions" }, ] @@ -322,12 +335,16 @@ dependencies = [ fastapi = [ { name = "fastapi" }, ] +strict-jsonpath = [ + { name = "python-jsonpath", extra = ["strict"] }, +] [package.dev-dependencies] ci = [ { name = "fastapi" }, { name = "httpx" }, { name = "mypy" }, + { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -335,9 +352,11 @@ ci = [ ] dev = [ { name = "fastapi" }, + { name = "griffe-pydantic" }, { name = "httpx" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, + { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -345,6 +364,7 @@ dev = [ { name = "zensical" }, ] docs = [ + { name = "griffe-pydantic" }, { name = "mkdocstrings", extra = ["python"] }, { name = "zensical" }, ] @@ -352,6 +372,9 @@ fuzz = [ { name = "atheris", marker = "python_full_version < '3.14'" }, { name = "pyinstaller" }, ] +hooks = [ + { name = "prek" }, +] lint = [ { name = "ruff" }, ] @@ -378,17 +401,19 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.128.0" }, { name = "jsonpointer", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "python-jsonpath", marker = "python_full_version >= '3.14'", specifier = ">=2.0.2" }, - { name = "python-jsonpath", extras = ["strict"], marker = "python_full_version < '3.14'", specifier = ">=2.0.2" }, + { name = "pydantic-core", specifier = ">=2.29.0" }, + { name = "python-jsonpath", specifier = ">=2.0.2" }, + { name = "python-jsonpath", extras = ["strict"], marker = "extra == 'strict-jsonpath'", specifier = ">=2.0.2" }, { name = "typing-extensions", specifier = ">=4.15.0" }, ] -provides-extras = ["fastapi"] +provides-extras = ["fastapi", "strict-jsonpath"] [package.metadata.requires-dev] ci = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=1.19.1" }, + { name = "prek", specifier = "==0.4.8" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.15.7" }, @@ -396,9 +421,11 @@ ci = [ ] dev = [ { name = "fastapi", specifier = ">=0.135.1" }, + { name = "griffe-pydantic", specifier = ">=1.3.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "mypy", specifier = ">=1.19.1" }, + { name = "prek", specifier = "==0.4.8" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.15.7" }, @@ -406,6 +433,7 @@ dev = [ { name = "zensical", specifier = ">=0.0.30" }, ] docs = [ + { name = "griffe-pydantic", specifier = ">=1.3.1" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "zensical", specifier = ">=0.0.30" }, ] @@ -413,6 +441,7 @@ fuzz = [ { name = "atheris", marker = "python_full_version >= '3.11' and python_full_version < '3.14'", specifier = ">=3.0.0" }, { name = "pyinstaller", specifier = ">=6.10.0" }, ] +hooks = [{ name = "prek", specifier = "==0.4.8" }] lint = [{ name = "ruff", specifier = ">=0.15.7" }] snapshots = [ { name = "fastapi", specifier = ">=0.135.1" }, @@ -777,6 +806,30 @@ 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.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" }, + { url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" }, + { url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" }, + { url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -979,8 +1032,8 @@ wheels = [ [package.optional-dependencies] strict = [ - { name = "iregexp-check", marker = "python_full_version < '3.14'" }, - { name = "regex", marker = "python_full_version < '3.14'" }, + { name = "iregexp-check" }, + { name = "regex" }, ] [[package]] diff --git a/zensical.toml b/zensical.toml index a2f7b27d..435cd246 100644 --- a/zensical.toml +++ b/zensical.toml @@ -8,7 +8,7 @@ docs_dir = "docs" site_dir = "site" plugins = [ "search", - { mkdocstrings = { handlers = { python = { paths = ["."], options = { docstring_style = "google" } } } } }, + { mkdocstrings = { handlers = { python = { paths = ["."], options = { docstring_style = "google", show_signature_annotations = true, separate_signature = true, members_order = "source", extensions = ["griffe_pydantic"] } } } } }, ] nav = [ { "About" = "about/about.md" }, @@ -16,6 +16,7 @@ nav = [ { "Getting Started" = "user-guide/getting-started.md" }, { "Registries and Routes" = "user-guide/registries-and-routes.md" }, { "Custom Operations" = "user-guide/custom-operations.md" }, + { "Custom-Operation Agent Guide" = "custom-operation-agent-guide.md" }, { "Patch Clients" = "user-guide/patch-clients.md" }, { "Patch Targeting" = "user-guide/patch-targeting.md" }, { "Evolving PATCH Contracts" = "user-guide/evolving-patch-contracts.md" }, @@ -30,17 +31,18 @@ nav = [ ] }, { "API Reference" = [ { "API Overview" = "api-reference/api-reference.md" }, - { "Public API" = "api-reference/api-reference-public.md" }, - { "Standard RFC 6902" = "api-reference/api-reference-standard.md" }, - { "Built-in Operations" = "api-reference/api-reference-builtins.md" }, - { "Pydantic Contracts" = "api-reference/api-reference-pydantic.md" }, + { "Exports" = "api-reference/api-reference-public.md" }, + { "JSON Patch" = "api-reference/api-reference-standard.md" }, + { "Operations" = "api-reference/api-reference-builtins.md" }, + { "Operation Schema" = "api-reference/api-reference-schema.md" }, { "Registries" = "api-reference/api-reference-registries.md" }, { "Pointers" = "api-reference/api-reference-pointers.md" }, - { "Pointer Backends" = "api-reference/api-reference-backends.md" }, - { "Operation Schema" = "api-reference/api-reference-schema.md" }, + { "Selectors" = "api-reference/api-reference-selectors.md" }, + { "Targeting Backends" = "api-reference/api-reference-backends.md" }, + { "Pydantic Integration" = "api-reference/api-reference-pydantic.md" }, + { "FastAPI Helpers" = "api-reference/api-reference-fastapi.md" }, { "JSON Types" = "api-reference/api-reference-types.md" }, { "Exceptions" = "api-reference/api-reference-exceptions.md" }, - { "FastAPI Helpers" = "api-reference/api-reference-fastapi.md" }, ] }, ]