├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── build.yaml │ ├── cron.yaml │ ├── publish_to_pypi.yaml │ └── publish_to_test_pypi.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .readthedocs.yml ├── CHANGELOG.rst ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── RELEASING.md ├── docs ├── .gitignore ├── changelog.rst ├── conf.py ├── faq.rst ├── index.rst ├── install.rst ├── optional_parsers.rst ├── precommit_usage.rst └── usage.rst ├── pyproject.toml ├── scripts ├── aggregate-pytest-reports.py ├── bump-version.py ├── changelog2md.py ├── generate-hooks-config.py ├── set-dev-version.py └── vendor-schemas.py ├── setup.py ├── src └── check_jsonschema │ ├── __init__.py │ ├── __main__.py │ ├── builtin_schemas │ ├── __init__.py │ ├── custom │ │ ├── __init__.py │ │ └── github-workflows-require-timeout.json │ └── vendor │ │ ├── README.md │ │ ├── __init__.py │ │ ├── azure-pipelines.json │ │ ├── bamboo-spec.json │ │ ├── bitbucket-pipelines.json │ │ ├── buildkite.json │ │ ├── circle-ci.json │ │ ├── cloudbuild.json │ │ ├── compose-spec.json │ │ ├── dependabot.json │ │ ├── drone-ci.json │ │ ├── github-actions.json │ │ ├── github-workflows.json │ │ ├── gitlab-ci.json │ │ ├── licenses │ │ ├── LICENSE.azure-pipelines │ │ ├── LICENSE.bitbucket-pipelines │ │ ├── LICENSE.buildkite │ │ ├── LICENSE.compose-spec │ │ ├── LICENSE.gitlab │ │ ├── LICENSE.meltano │ │ ├── LICENSE.readthedocs │ │ ├── LICENSE.renovate │ │ ├── LICENSE.schemastore │ │ ├── LICENSE.snapcraft │ │ └── LICENSE.task │ │ ├── meltano.json │ │ ├── mergify.json │ │ ├── readthedocs.json │ │ ├── renovate.json │ │ ├── sha256 │ │ ├── README.md │ │ ├── azure-pipelines.sha256 │ │ ├── bamboo-spec.sha256 │ │ ├── bitbucket-pipelines.sha256 │ │ ├── buildkite.sha256 │ │ ├── circle-ci.sha256 │ │ ├── cloudbuild.sha256 │ │ ├── compose-spec.sha256 │ │ ├── dependabot.sha256 │ │ ├── drone-ci.sha256 │ │ ├── github-actions.sha256 │ │ ├── github-workflows.sha256 │ │ ├── gitlab-ci.sha256 │ │ ├── meltano.sha256 │ │ ├── mergify.sha256 │ │ ├── readthedocs.sha256 │ │ ├── renovate.sha256 │ │ ├── snapcraft.sha256 │ │ ├── taskfile.sha256 │ │ ├── travis.sha256 │ │ └── woodpecker-ci.sha256 │ │ ├── snapcraft.json │ │ ├── taskfile.json │ │ ├── travis.json │ │ └── woodpecker-ci.json │ ├── cachedownloader.py │ ├── catalog.py │ ├── checker.py │ ├── cli │ ├── __init__.py │ ├── main_command.py │ ├── param_types.py │ ├── parse_result.py │ └── warnings.py │ ├── formats │ ├── __init__.py │ └── implementations │ │ ├── __init__.py │ │ ├── iso8601_time.py │ │ └── rfc3339.py │ ├── identify_filetype.py │ ├── instance_loader.py │ ├── parsers │ ├── __init__.py │ ├── json5.py │ ├── json_.py │ ├── toml.py │ └── yaml.py │ ├── regex_variants.py │ ├── reporter.py │ ├── result.py │ ├── schema_loader │ ├── __init__.py │ ├── errors.py │ ├── main.py │ ├── readers.py │ └── resolver.py │ ├── transforms │ ├── __init__.py │ ├── azure_pipelines.py │ ├── base.py │ └── gitlab.py │ └── utils.py ├── tests ├── acceptance │ ├── conftest.py │ ├── custom_schemas │ │ └── test_github_workflow_require_explicit_timeout.py │ ├── test_custom_validator_class.py │ ├── test_example_files.py │ ├── test_fill_defaults.py │ ├── test_format_failure.py │ ├── test_format_regex_opts.py │ ├── test_gitlab_reference_handling.py │ ├── test_hook_file_matches.py │ ├── test_invalid_schema_files.py │ ├── test_local_relative_ref.py │ ├── test_malformed_instances.py │ ├── test_nonjson_instance_files.py │ ├── test_nonjson_schema_handling.py │ ├── test_remote_ref_resolution.py │ └── test_special_filetypes.py ├── conftest.py ├── example-files │ ├── config_schema.json │ ├── explicit-schema │ │ ├── negative │ │ │ └── unicode_pattern │ │ │ │ ├── instance.json │ │ │ │ └── schema.json │ │ └── positive │ │ │ ├── 2020-meta │ │ │ ├── instance.json │ │ │ └── schema.json │ │ │ ├── complex-toml │ │ │ ├── instance.toml │ │ │ └── schema.json │ │ │ ├── complex-yaml │ │ │ ├── instance.yaml │ │ │ └── schema.json │ │ │ ├── integer-keys-yaml │ │ │ ├── instance.yaml │ │ │ └── schema.json │ │ │ ├── simple-toml │ │ │ ├── instance.toml │ │ │ └── schema.json │ │ │ └── unicode_pattern │ │ │ ├── instance.json │ │ │ └── schema.json │ └── hooks │ │ ├── negative │ │ ├── cloudbuild │ │ │ └── empty.yaml │ │ ├── drone-ci │ │ │ └── unkown-type-pipeline.yml │ │ ├── github-workflows │ │ │ └── empty.json │ │ ├── jsonschema │ │ │ ├── _config.yaml │ │ │ └── github-workflow-timeout-minutes-expression.yaml │ │ ├── mergify │ │ │ └── example-pr-rules.yaml │ │ ├── metaschema │ │ │ ├── 2020_invalid_format_value.json │ │ │ ├── _config.yaml │ │ │ ├── draft7_title_array.json │ │ │ └── draft7_title_array.yaml │ │ ├── readthedocs │ │ │ └── pyversion-float.yml │ │ ├── snapcraft │ │ │ └── snapcraft.yaml │ │ └── woodpecker-ci │ │ │ └── empty.yaml │ │ └── positive │ │ ├── azure-pipelines │ │ ├── expression-from-lang-server.yaml │ │ ├── expression-transform.yaml │ │ ├── marshmallow.yaml │ │ └── object-defined-by-expression-map.yaml │ │ ├── bitbucket-pipelines │ │ └── bitbucket-pipelines.yml │ │ ├── buildkite │ │ └── matrix.yml │ │ ├── cloudbuild │ │ └── hello_world.yaml │ │ ├── drone-ci │ │ ├── digitalocean-pipeline.yml │ │ ├── docker-pipeline.yml │ │ ├── exec-pipeline.yaml │ │ ├── kubernetes-pipeline.yml │ │ ├── macstadium-pipeline.yml │ │ └── ssh-pipeline.yml │ │ ├── github-actions │ │ └── redis-simple.yml │ │ ├── github-workflows │ │ ├── has-unicode.yaml │ │ └── self-build.yaml │ │ ├── gitlab-ci │ │ └── reference-tag.yaml │ │ ├── jsonschema │ │ ├── _config.yaml │ │ └── github-workflow-timeout-minutes-expression.yaml │ │ ├── meltano │ │ └── multiple-plugins.yml │ │ ├── mergify │ │ └── example-pr-rules.yaml │ │ ├── metaschema │ │ ├── 2020_invalid_format_value.json │ │ ├── _config.yaml │ │ ├── almost_empty.yaml │ │ ├── draft3.json │ │ └── draft7.json │ │ ├── readthedocs │ │ └── simple.yaml │ │ ├── renovate │ │ ├── starter-config.json │ │ └── starter-config.json5 │ │ ├── snapcraft │ │ └── snapcraft.yaml │ │ ├── travis │ │ └── python-build.yaml │ │ └── woodpecker-ci │ │ └── pipeline-clone.yaml └── unit │ ├── cli │ ├── test_annotations.py │ ├── test_callbacks.py │ └── test_parse.py │ ├── formats │ ├── test_rfc3339.py │ └── test_time.py │ ├── test_cachedownloader.py │ ├── test_catalog.py │ ├── test_gitlab_data_transform.py │ ├── test_instance_loader.py │ ├── test_lazy_file_handling.py │ ├── test_reporters.py │ ├── test_schema_loader.py │ └── test_utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | parallel = True 3 | source = src/ 4 | omit = src/check_jsonschema/__main__.py 5 | 6 | [report] 7 | show_missing = True 8 | 9 | exclude_lines = 10 | # the pragma to disable coverage 11 | pragma: no cover 12 | # don't complain if tests don't hit unimplemented methods/modes 13 | raise NotImplementedError 14 | # don't check on executable components of importable modules 15 | if __name__ == .__main__.: 16 | # don't check coverage on type checking conditionals 17 | if TYPE_CHECKING: 18 | # skip overloads 19 | @overload 20 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.tox,__pycache__,dist,.venv*,docs,build 3 | max-line-length = 90 4 | # black related: W503/W504 conflict, black causes E203 5 | extend-ignore = W503,W504,E203,B019 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | - github-actions 7 | -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | name: weekly 2 | on: 3 | # build on Sunday at 4:00 AM UTC 4 | schedule: 5 | - cron: '0 4 * * 0' 6 | workflow_dispatch: 7 | 8 | 9 | jobs: 10 | vendor-schemas: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.x' 17 | 18 | - name: install tox 19 | run: python -m pip install 'tox' 20 | 21 | - name: vendor-schemas 22 | run: make vendor-schemas 23 | 24 | # this action seems to have significant usage, so it should be stable 25 | # can always re-implement with `git status --porcelain`, etc. if necessary 26 | - name: Create Pull Request 27 | uses: peter-evans/create-pull-request@v7 28 | with: 29 | title: 'Update vendored schemas' 30 | commit-message: '[vendor-schemas] automated update' 31 | branch: vendor-schemas-auto 32 | reviewers: sirosen 33 | body: '' 34 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish PyPI Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-dists: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.11" 16 | 17 | - run: python -m pip install build twine 18 | 19 | - name: Build Dists 20 | run: python -m build . 21 | 22 | - name: Check Dists (twine) 23 | run: twine check dist/* 24 | 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: packages 28 | path: dist/* 29 | 30 | publish_pypi: 31 | needs: [build-dists] 32 | runs-on: ubuntu-latest 33 | environment: publish 34 | permissions: 35 | id-token: write 36 | 37 | steps: 38 | - uses: actions/download-artifact@v4 39 | with: 40 | name: packages 41 | path: dist 42 | 43 | - name: Publish to PyPI 44 | uses: pypa/gh-action-pypi-publish@release/v1 45 | 46 | publish_release_asset: 47 | needs: [build-dists] 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - uses: actions/download-artifact@v4 52 | with: 53 | name: packages 54 | path: dist 55 | 56 | - name: Publish to GitHub Release 57 | env: 58 | GH_TOKEN: ${{ github.token }} 59 | GH_REPO: ${{ github.repository }} 60 | run: gh release upload "${{ github.ref_name }}" dist/* 61 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_test_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Test PyPI Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | devNumber: 7 | required: false 8 | type: number 9 | description: 'The number to use as a ".devN" suffix. Defaults to 1.' 10 | 11 | push: 12 | tags: ["*"] 13 | 14 | jobs: 15 | build-dists: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.11" 23 | 24 | - run: python -m pip install build twine 25 | 26 | - name: Set dev version prior to upload (auto) 27 | if: ${{ github.event.inputs.devNumber == '' }} 28 | run: python ./scripts/set-dev-version.py 29 | 30 | - name: Set dev version prior to upload (workflow_dispatch) 31 | if: ${{ github.event.inputs.devNumber != '' }} 32 | run: python ./scripts/set-dev-version.py -n ${{ github.event.inputs.devNumber }} 33 | 34 | - name: Build Dists 35 | run: python -m build . 36 | 37 | - name: Check Dists (twine) 38 | run: twine check dist/* 39 | 40 | - uses: actions/upload-artifact@v4 41 | with: 42 | name: packages 43 | path: dist/* 44 | 45 | 46 | publish: 47 | needs: [build-dists] 48 | runs-on: ubuntu-latest 49 | environment: publish-testpypi 50 | permissions: 51 | id-token: write 52 | 53 | steps: 54 | - uses: actions/download-artifact@v4 55 | with: 56 | name: packages 57 | path: dist 58 | 59 | - name: Publish to TestPyPI 60 | uses: pypa/gh-action-pypi-publish@release/v1 61 | with: 62 | repository-url: https://test.pypi.org/legacy/ 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | *.egg-info 3 | __pycache__/ 4 | build/ 5 | dist/ 6 | .coverage 7 | .coverage.* 8 | .tox 9 | .vscode 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # dogfood 3 | - repo: https://github.com/python-jsonschema/check-jsonschema 4 | rev: 0.33.0 5 | hooks: 6 | - id: check-dependabot 7 | - id: check-github-workflows 8 | - id: check-readthedocs 9 | - id: check-metaschema 10 | name: Validate Vendored Schemas 11 | files: ^src/check_jsonschema/builtin_schemas/vendor/.*\.json$ 12 | exclude: ^src/check_jsonschema/builtin_schemas/vendor/azure-pipelines\.json$ 13 | - id: check-metaschema 14 | name: Validate Vendored Schemas (nonunicode regexes) 15 | files: ^src/check_jsonschema/builtin_schemas/vendor/azure-pipelines\.json$ 16 | args: ["--regex-variant", "nonunicode"] 17 | - id: check-jsonschema 18 | name: Validate Test Configs 19 | args: ["--schemafile", "tests/example-files/config_schema.json"] 20 | files: ^tests/example-files/.*/_config.yaml$ 21 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 22 | rev: v5.0.0 23 | hooks: 24 | - id: check-merge-conflict 25 | - id: trailing-whitespace 26 | - repo: https://github.com/psf/black-pre-commit-mirror 27 | rev: 25.1.0 28 | hooks: 29 | - id: black 30 | - repo: https://github.com/PyCQA/flake8 31 | rev: 7.2.0 32 | hooks: 33 | - id: flake8 34 | additional_dependencies: 35 | - 'flake8-bugbear==24.4.26' 36 | - 'flake8-typing-as-t==0.0.3' 37 | - 'flake8-comprehensions==3.15.0' 38 | - repo: https://github.com/sirosen/slyp 39 | rev: 0.8.2 40 | hooks: 41 | - id: slyp 42 | - repo: https://github.com/PyCQA/isort 43 | rev: 6.0.1 44 | hooks: 45 | - id: isort 46 | - repo: https://github.com/asottile/pyupgrade 47 | rev: v3.20.0 48 | hooks: 49 | - id: pyupgrade 50 | args: ["--py39-plus"] 51 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: ubuntu-24.04 8 | tools: 9 | python: "3.13" 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - docs 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Interested in contributing to `check-jsonschema`? That's great! 4 | 5 | ## Questions, Feature Requests, Bug Reports, and Feedback... 6 | 7 | …should be reported the [Issue Tracker](https://github.com/python-jsonschema/check-jsonschema/issues)_ 8 | 9 | ## Contributing Code 10 | 11 | If you want to help work on the code, here are a few tips for getting setup. 12 | 13 | ### Testing 14 | 15 | Testing is done with `tox`. To run the tests, just run 16 | 17 | tox 18 | 19 | and a full test run will execute. 20 | 21 | ### pre-commit linting 22 | 23 | `check-jsonschema` lints with [`pre-commit`](pre-commit.com). 24 | To setup the `pre-commit` integration, first ensure you have it installed 25 | 26 | pipx install pre-commit 27 | 28 | Then setup the hooks: 29 | 30 | pre-commit install 31 | 32 | ### pre-commit hook test cases 33 | 34 | There are example files for the various pre-commit hooks defined in 35 | `tests/example-files/`. 36 | 37 | The `positive/` test cases should pass the hook with the matching name, and 38 | the `negative/` test cases should fail the hook with the matching name. 39 | 40 | These files are automatically picked up by the testsuite and checked. 41 | 42 | ### Adding new hooks 43 | 44 | Hooks are defined in `src/check_jsonschema/catalog.py`, and rendered to 45 | `.pre-commit-hooks.yaml` with the generator script. It can be run with 46 | 47 | make generate-hooks 48 | 49 | Schemas are pulled down based on this same config. If you want to update the 50 | vendored schema copies, run 51 | 52 | make vendor-schemas 53 | 54 | ## Documentation 55 | 56 | All of the features of `check-jsonschema` should be documented in the readme 57 | and changelog. If you notice missing documentation, that's a bug! 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021, Stephen Rosen 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | ## Core Package Requirements 2 | 3 | # data files in the distribution 4 | include src/check_jsonschema/builtin_schemas/vendor/* 5 | include src/check_jsonschema/builtin_schemas/custom/*.json 6 | 7 | ## Testing Requirements 8 | 9 | # include all test files and test data files 10 | recursive-include tests *.py *.json *.yaml *.yml *.json5 *.toml 11 | 12 | # the test runner 13 | include tox.ini 14 | 15 | # needed as a data file for the tests (several tests check integrity 16 | # against this file) 17 | include .pre-commit-hooks.yaml 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKG_VERSION=$(shell grep '^version' pyproject.toml | head -n1 | cut -d '"' -f2) 2 | 3 | .PHONY: lint test vendor-schemas generate-hooks release showvars 4 | lint: 5 | pre-commit run -a 6 | tox run -e mypy 7 | test: 8 | tox run 9 | vendor-schemas: 10 | tox run -e vendor-schemas 11 | generate-hooks: 12 | tox run -e generate-hooks-config 13 | showvars: 14 | @echo "PKG_VERSION=$(PKG_VERSION)" 15 | release: 16 | git tag -s "$(PKG_VERSION)" -m "v$(PKG_VERSION)" 17 | -git push $(shell git rev-parse --abbrev-ref @{push} | cut -d '/' -f1) refs/tags/$(PKG_VERSION) 18 | 19 | .PHONY: collated-test-report 20 | collated-test-report: 21 | tox p 22 | python ./scripts/aggregate-pytest-reports.py .tox/*/pytest.xml 23 | 24 | .PHONY: clean 25 | clean: 26 | rm -rf dist build *.egg-info .tox .coverage.* 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pypi version](https://img.shields.io/pypi/v/check-jsonschema.svg)](https://pypi.org/project/check-jsonschema/) 2 | [![supported pythons](https://img.shields.io/pypi/pyversions/check-jsonschema.svg)](https://pypi.org/project/check-jsonschema/) 3 | [![build](https://github.com/python-jsonschema/check-jsonschema/actions/workflows/build.yaml/badge.svg)](https://github.com/python-jsonschema/check-jsonschema/actions/workflows/build.yaml) 4 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/python-jsonschema/check-jsonschema/main.svg)](https://results.pre-commit.ci/latest/github/python-jsonschema/check-jsonschema/main) 5 | [![readthedocs documentation](https://readthedocs.org/projects/check-jsonschema/badge/?version=stable&style=flat)](https://check-jsonschema.readthedocs.io/en/stable) 6 | 7 | 8 | # check-jsonschema 9 | 10 | A JSON Schema CLI and [pre-commit](https://pre-commit.com/) hook built on [jsonschema](https://github.com/python-jsonschema/jsonschema/). 11 | The schema may be specified as a local or remote (HTTP or HTTPS) file. 12 | 13 | Remote files are automatically downloaded and cached if possible. 14 | 15 | ## Usage 16 | 17 | `check-jsonschema` can be installed and run as a CLI tool, or via pre-commit. 18 | 19 | ### Example pre-commit config 20 | 21 | The following configuration uses `check-jsonschema` to validate Github Workflow 22 | files. 23 | 24 | ```yaml 25 | - repo: https://github.com/python-jsonschema/check-jsonschema 26 | rev: 0.33.0 27 | hooks: 28 | - id: check-github-workflows 29 | args: ["--verbose"] 30 | ``` 31 | 32 | ### Installing and Running as a CLI Tool 33 | 34 | Install with `pipx` or `brew`: 35 | 36 | pipx install check-jsonschema 37 | 38 | or 39 | 40 | brew install check-jsonschema 41 | 42 | Then run, as in 43 | 44 | check-jsonschema --schemafile schema.json instance.json 45 | 46 | ## Documentation 47 | 48 | Full documentation can be found at https://check-jsonschema.readthedocs.io/ 49 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | - Bump the version with `./scripts/bump-version.py NEW_VERSION` 4 | - Add, commit with `git commit -m 'Bump version for release'`, and push 5 | - Create a release tag, which will auto-publish to testpypi (`make release`) 6 | - Create a GitHub release, which will auto-publish to pypi (web UI) 7 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.rst -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import importlib.metadata 3 | 4 | project = "check-jsonschema" 5 | copyright = f"2021-{datetime.datetime.today().strftime('%Y')}, Stephen Rosen" 6 | author = "Stephen Rosen" 7 | 8 | # The full version, including alpha/beta/rc tags 9 | release = importlib.metadata.version("check_jsonschema") 10 | 11 | extensions = ["sphinx_issues"] 12 | issues_github_path = "python-jsonschema/check-jsonschema" 13 | 14 | # List of patterns, relative to source directory, that match files and 15 | # directories to ignore when looking for source files. 16 | # This pattern also affects html_static_path and html_extra_path. 17 | exclude_patterns = ["_build"] 18 | 19 | # HTML theme options 20 | html_theme = "furo" 21 | pygments_style = "friendly" 22 | pygments_dark_style = "monokai" # this is a furo-specific option 23 | html_theme_options = { 24 | "source_repository": "https://github.com/python-jsonschema/check-jsonschema/", 25 | "source_branch": "main", 26 | "source_directory": "docs/", 27 | } 28 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | check-jsonschema 2 | ================ 3 | 4 | A JSON Schema CLI and `pre-commit `_ hook built on 5 | `jsonschema `_. 6 | 7 | The schema may be specified as a local or remote (HTTP or HTTPS) file. 8 | Remote files are automatically downloaded and cached if possible. 9 | 10 | The source code is hosted in `a GitHub repo 11 | `_, and bugs and 12 | features are tracked in the associated `issue tracker 13 | `_. 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Contents: 18 | 19 | install 20 | usage 21 | precommit_usage 22 | optional_parsers 23 | faq 24 | 25 | .. toctree:: 26 | :maxdepth: 1 27 | :caption: Change History: 28 | 29 | changelog 30 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | ``check-jsonschema`` is distributed as a python package. 5 | Install with ``pip`` or ``pipx``: 6 | 7 | .. code-block:: bash 8 | 9 | pip install check-jsonschema 10 | 11 | # or pipx 12 | pipx install check-jsonschema 13 | 14 | You may also want to install additional packages that ``jsonschema`` uses to `validate 15 | specific string formats `_: 16 | 17 | .. code-block:: bash 18 | 19 | pip install check-jsonschema 'jsonschema[format]' 20 | 21 | # or pipx 22 | pipx install check-jsonschema 23 | pipx inject check-jsonschema 'jsonschema[format]' 24 | 25 | 26 | Supported Pythons 27 | ----------------- 28 | 29 | ``check-jsonschema`` requires Python 3 and supports all of the versions which 30 | are maintained by the CPython team. 31 | 32 | It is only tested on cpython but should work on other interpreters as well. 33 | 34 | Do not use ``pip install --user`` 35 | --------------------------------- 36 | 37 | If installing with ``pip``, make sure you use a virtualenv to isolate the 38 | installation from the rest of your system. 39 | 40 | ``pip install --user check-jsonschema`` might work and seem convenient, but it 41 | has problems which ``pipx`` or virtualenv usage resolve. 42 | 43 | 44 | No install for pre-commit 45 | ------------------------- 46 | 47 | If you are using ``pre-commit`` to run ``check-jsonschema``, no installation is 48 | necessary. ``pre-commit`` will automatically install and run the hooks. 49 | 50 | -------------------------------------------------------------------------------- /docs/optional_parsers.rst: -------------------------------------------------------------------------------- 1 | Optional Parsers 2 | ================ 3 | 4 | ``check-jsonschema`` comes with out-of-the-box support for the JSON and YAML file 5 | formats. Additional optional parsers may be installed, and are supported when 6 | present. 7 | 8 | JSON5 9 | ----- 10 | 11 | - Supported for Instances: yes 12 | - Supported for Schemas: yes 13 | 14 | In order to support JSON5 files, either the ``pyjson5`` or ``json5`` package must 15 | be installed. 16 | 17 | In ``pre-commit-config.yaml``, this can be done with ``additional_dependencies``. 18 | For example, 19 | 20 | .. code-block:: yaml 21 | 22 | - repo: https://github.com/python-jsonschema/check-jsonschema 23 | rev: 0.33.0 24 | hooks: 25 | - id: check-renovate 26 | additional_dependencies: ['pyjson5'] 27 | 28 | TOML 29 | ---- 30 | 31 | .. note:: 32 | 33 | In the latest versions of ``check-jsonschema``, the TOML parser is no 34 | longer optional. It is always available, using ``tomli`` or ``tomllib`` 35 | depending on the environment. 36 | 37 | - Supported for Instances: yes 38 | - Supported for Schemas: no 39 | 40 | In order to support TOML files, the ``tomli`` package must be installed. 41 | 42 | In ``pre-commit-config.yaml``, this can be done with ``additional_dependencies``. 43 | For example, 44 | 45 | .. code-block:: yaml 46 | 47 | - repo: https://github.com/python-jsonschema/check-jsonschema 48 | rev: 0.33.0 49 | hooks: 50 | - id: check-jsonschema 51 | name: 'Check GitHub Workflows' 52 | files: ^mydata/ 53 | types_or: [toml] 54 | args: ['--schemafile', 'schemas/toml-data.json'] 55 | additional_dependencies: ['tomli'] 56 | 57 | The TOML format has support for dates and times as first-class types, meaning 58 | that they are parsed as part of the data format. 59 | 60 | ``check-jsonschema`` will convert the parsed data back into strings so that they 61 | can be checked by a schema. In general, the string conversion should be 62 | checkable using ``"format": "date-time"``, ``"format": "date"``, and 63 | ``"format": "time"``. 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "check-jsonschema" 7 | version = "0.33.0" 8 | description = "A jsonschema CLI and pre-commit hook" 9 | authors = [ 10 | { name = "Stephen Rosen", email = "sirosen0@gmail.com" }, 11 | ] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: Apache Software License", 16 | "Programming Language :: Python :: 3", 17 | ] 18 | requires-python = ">=3.9" 19 | dependencies = [ 20 | 'tomli>=2.0;python_version<"3.11"', 21 | "ruamel.yaml>=0.18.10,<0.19.0", 22 | "jsonschema>=4.18.0,<5.0", 23 | "regress>=2024.11.1", 24 | "requests<3.0", 25 | "click>=8,<9", 26 | ] 27 | 28 | [project.readme] 29 | file = "README.md" 30 | content-type = "text/markdown" 31 | 32 | [project.urls] 33 | Homepage = "https://github.com/python-jsonschema/check-jsonschema" 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "pytest<9", 38 | 'click-type-test==1.1.0;python_version>="3.10"', 39 | "coverage<8", 40 | "identify>=2.6.9", 41 | "pytest-xdist<4", 42 | "responses==0.25.7", 43 | ] 44 | docs = [ 45 | "sphinx<9", 46 | "sphinx-issues<6", 47 | "furo==2024.8.6", 48 | ] 49 | 50 | [project.scripts] 51 | check-jsonschema = "check_jsonschema:main" 52 | 53 | [tool.setuptools] 54 | include-package-data = true 55 | 56 | [tool.setuptools.packages.find] 57 | where = ["src"] 58 | namespaces = false 59 | 60 | [tool.isort] 61 | profile = "black" 62 | 63 | [tool.mypy] 64 | # strict = true # TODO: enable 65 | disallow_untyped_defs = true 66 | ignore_missing_imports = true 67 | warn_unreachable = true 68 | warn_no_return = true 69 | 70 | [tool.pytest.ini_options] 71 | filterwarnings = [ 72 | "error", 73 | # dateutil has a Python 3.12 compatibility issue. 74 | 'ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated:DeprecationWarning', 75 | ] 76 | addopts = [ 77 | "--color=yes", 78 | ] 79 | -------------------------------------------------------------------------------- /scripts/aggregate-pytest-reports.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from collections import defaultdict 4 | from xml.etree import ElementTree # nosec 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument("FILES", nargs="+") 10 | args = parser.parse_args() 11 | 12 | tests_by_name = defaultdict(dict) 13 | skipped_module_counts = defaultdict(int) 14 | for filename in args.FILES: 15 | tree = ElementTree.parse(filename) 16 | root = tree.getroot() 17 | 18 | for testcase in root.findall("./testsuite/testcase"): 19 | classname = testcase.get("classname") 20 | name = testcase.get("name") 21 | 22 | skip_node = testcase.find("skipped") 23 | 24 | if classname: 25 | nodename = f"{classname.replace('.', '/')}.py::{name}" 26 | if skip_node is not None: 27 | if "skipped" not in tests_by_name[nodename]: 28 | tests_by_name[nodename]["skipped"] = True 29 | else: 30 | tests_by_name[nodename]["skipped"] = False 31 | else: 32 | if skip_node is not None: 33 | skipped_module_counts[name] += 1 34 | 35 | skipped_modules = { 36 | modname 37 | for modname, count in skipped_module_counts.items() 38 | if count == len(args.FILES) 39 | } 40 | 41 | fail = False 42 | for nodename, attributes in tests_by_name.items(): 43 | if attributes.get("skipped") is True: 44 | print(f"ALWAYS SKIPPED: {nodename}") 45 | fail = True 46 | 47 | if skipped_modules: 48 | for modname in skipped_modules: 49 | print(f"ALWAYS SKIPPED MODULE: {modname}") 50 | fail = True 51 | 52 | if fail: 53 | print("fail") 54 | sys.exit(1) 55 | print("ok") 56 | sys.exit(0) 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /scripts/bump-version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | import sys 4 | 5 | 6 | def get_old_version(): 7 | with open("pyproject.toml") as fp: 8 | content = fp.read() 9 | match = re.search(r'^version = "(\d+\.\d+\.\d+)"$', content, flags=re.MULTILINE) 10 | assert match 11 | return match.group(1) 12 | 13 | 14 | def replace_version(filename, formatstr, old_version, new_version): 15 | print(f"updating {filename}") 16 | with open(filename) as fp: 17 | content = fp.read() 18 | old_str = formatstr.format(old_version) 19 | new_str = formatstr.format(new_version) 20 | content = content.replace(old_str, new_str) 21 | with open(filename, "w") as fp: 22 | fp.write(content) 23 | 24 | 25 | def update_changelog(new_version): 26 | print("updating CHANGELOG.rst") 27 | with open("CHANGELOG.rst") as fp: 28 | content = fp.read() 29 | 30 | vendor_marker = ".. vendor-insert-here" 31 | 32 | content = re.sub( 33 | r""" 34 | Unreleased 35 | ---------- 36 | (\s*\n)+""" 37 | + re.escape(vendor_marker), 38 | f""" 39 | Unreleased 40 | ---------- 41 | 42 | {vendor_marker} 43 | 44 | {new_version} 45 | {"-" * len(new_version)}""", 46 | content, 47 | ) 48 | with open("CHANGELOG.rst", "w") as fp: 49 | fp.write(content) 50 | 51 | 52 | def parse_version(s): 53 | vals = s.split(".") 54 | assert len(vals) == 3 55 | return tuple(int(x) for x in vals) 56 | 57 | 58 | def comparse_versions(old_version, new_version): 59 | assert parse_version(new_version) > parse_version(old_version) 60 | 61 | 62 | def main(): 63 | if len(sys.argv) != 2: 64 | sys.exit(2) 65 | 66 | new_version = sys.argv[1] 67 | old_version = get_old_version() 68 | print(f"old = {old_version}, new = {new_version}") 69 | comparse_versions(old_version, new_version) 70 | 71 | replace_version("pyproject.toml", 'version = "{}"', old_version, new_version) 72 | replace_version("README.md", "rev: {}", old_version, new_version) 73 | replace_version("docs/precommit_usage.rst", "rev: {}", old_version, new_version) 74 | replace_version("docs/optional_parsers.rst", "rev: {}", old_version, new_version) 75 | update_changelog(new_version) 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /scripts/changelog2md.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Extract a changelog section from the full changelog contents, convert ReST and 4 | sphinx-issues syntaxes to GitHub-flavored Markdown, and print the results. 5 | 6 | Defaults to selecting the most recent (topmost) changelog section. 7 | Can alternatively provide output for a specific version with `--target`. 8 | e.g. 9 | 10 | ./scripts/changelog2md.py --target 3.20.0 11 | """ 12 | from __future__ import annotations 13 | 14 | import argparse 15 | import pathlib 16 | import re 17 | import typing as t 18 | 19 | REPO_ROOT = pathlib.Path(__file__).parent.parent 20 | CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.rst" 21 | 22 | CHANGELOG_HEADER_PATTERN = re.compile(r"^(\d+\.\d+\.\d+).*$", re.MULTILINE) 23 | 24 | H2_RST_PATTERN = re.compile(r"-+") 25 | H3_RST_PATTERN = re.compile(r"~+") 26 | 27 | SPHINX_ISSUES_PR_PATTERN = re.compile(r":pr:`(\d+)`") 28 | SPHINX_ISSUES_ISSUE_PATTERN = re.compile(r":issue:`(\d+)`") 29 | SPHINX_ISSUES_USER_PATTERN = re.compile(r":user:`([^`]+)`") 30 | 31 | 32 | def _trim_empty_lines(lines: list[str]) -> None: 33 | if not lines: 34 | return 35 | while lines[0] == "": 36 | lines.pop(0) 37 | while lines[-1] == "": 38 | lines.pop() 39 | 40 | 41 | def _iter_target_section(target: str | None, changelog_content: str) -> t.Iterator[str]: 42 | started = False 43 | for line in changelog_content.split("\n"): 44 | if m := CHANGELOG_HEADER_PATTERN.match(line): 45 | if not started: 46 | if target is None or m.group(1) == target: 47 | started = True 48 | continue 49 | else: 50 | return 51 | if H2_RST_PATTERN.fullmatch(line): 52 | continue 53 | if started: 54 | yield line 55 | 56 | 57 | def get_last_changelog(changelog_content: str) -> list[str]: 58 | latest_changes = list(_iter_target_section(None, changelog_content)) 59 | _trim_empty_lines(latest_changes) 60 | return latest_changes 61 | 62 | 63 | def get_changelog_section(target: str, changelog_content: str) -> list[str]: 64 | lines = list(_iter_target_section(target, changelog_content)) 65 | _trim_empty_lines(lines) 66 | return lines 67 | 68 | 69 | def convert_rst_to_md(lines: list[str]) -> t.Iterator[str]: 70 | skip = False 71 | for i, line in enumerate(lines): 72 | if skip: 73 | skip = False 74 | continue 75 | 76 | try: 77 | peek = lines[i + 1] 78 | except IndexError: 79 | peek = None 80 | 81 | updated = line 82 | 83 | if peek is not None and H3_RST_PATTERN.fullmatch(peek): 84 | skip = True 85 | updated = f"## {updated}" 86 | 87 | updated = SPHINX_ISSUES_PR_PATTERN.sub(r"#\1", updated) 88 | updated = SPHINX_ISSUES_ISSUE_PATTERN.sub(r"#\1", updated) 89 | updated = SPHINX_ISSUES_USER_PATTERN.sub(r"@\1", updated) 90 | updated = updated.replace("``", "`") 91 | yield updated 92 | 93 | 94 | def main(): 95 | parser = argparse.ArgumentParser( 96 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 97 | ) 98 | parser.add_argument( 99 | "--target", "-t", help="A target version to use. Defaults to latest." 100 | ) 101 | args = parser.parse_args() 102 | 103 | full_changelog = CHANGELOG_PATH.read_text() 104 | 105 | if args.target: 106 | changelog_section = get_changelog_section(args.target, full_changelog) 107 | else: 108 | changelog_section = get_last_changelog(full_changelog) 109 | 110 | for line in convert_rst_to_md(changelog_section): 111 | print(line) 112 | 113 | 114 | if __name__ == "__main__": 115 | main() 116 | -------------------------------------------------------------------------------- /scripts/generate-hooks-config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import importlib.metadata 5 | import typing as t 6 | 7 | from check_jsonschema.catalog import SCHEMA_CATALOG 8 | 9 | version = importlib.metadata.version("check_jsonschema") 10 | 11 | 12 | def iter_catalog_hooks(): 13 | for name in SCHEMA_CATALOG: 14 | # copy config (new dict) 15 | config = dict(SCHEMA_CATALOG[name]["hook_config"]) 16 | # set computed attributes 17 | config["schema_name"] = name 18 | config["id"] = f"check-{name}" 19 | config["description"] = ( 20 | config.get("description") 21 | or f"{config['name']} against the schema provided by SchemaStore" 22 | ) 23 | if "types" in config and isinstance(config["types"], str): 24 | config["types"] = [config["types"]] 25 | yield config 26 | 27 | 28 | def update_hook_config(new_config: str) -> None: 29 | print("updating .pre-commit-hooks.yaml") 30 | with open(".pre-commit-hooks.yaml") as fp: 31 | content = fp.read() 32 | 33 | autogen_marker = "# --AUTOGEN_HOOKS_START-- #" 34 | 35 | # get the first part of the content (and assert that it's OK) 36 | content = content.split(autogen_marker)[0] 37 | assert "check-jsonschema" in content 38 | 39 | content = content + autogen_marker + "\n\n" + new_config 40 | with open(".pre-commit-hooks.yaml", "w") as fp: 41 | fp.write(content) 42 | 43 | 44 | def generate_hook_lines(config) -> t.Iterator[str]: 45 | yield "# this hook is autogenerated from a script" 46 | yield "# to modify this hook, update `src/check_jsonschema/catalog.py`" 47 | yield "# and run `make generate-hooks` or `tox run -e generate-hooks-config`" 48 | yield f"- id: {config['id']}" 49 | yield f" name: {config['name']}" 50 | yield f" description: '{config['description']}'" 51 | 52 | add_args = " ".join(config.get("add_args", [])) 53 | if add_args: 54 | add_args = " " + add_args 55 | yield ( 56 | " entry: check-jsonschema --builtin-schema " 57 | f"vendor.{config['schema_name']}{add_args}" 58 | ) 59 | 60 | yield " language: python" 61 | 62 | if isinstance(config["files"], list): 63 | config["files"] = ( 64 | r"""> 65 | (?x)^( 66 | {} 67 | )$""".format( 68 | "|\n ".join(config["files"]) 69 | ) 70 | ) 71 | 72 | yield f" files: {config['files']}" 73 | 74 | if "types" in config: 75 | yield f" types: [{','.join(config['types'])}]" 76 | if "types_or" in config: 77 | yield f" types_or: [{','.join(config['types_or'])}]" 78 | 79 | yield "" 80 | 81 | 82 | def generate_all_hook_config_lines() -> t.Iterator[str]: 83 | for hook_config in iter_catalog_hooks(): 84 | yield from generate_hook_lines(hook_config) 85 | 86 | 87 | def format_all_hook_config() -> str: 88 | return "\n".join(generate_all_hook_config_lines()) 89 | 90 | 91 | def update_usage_list_schemas() -> None: 92 | print("updating docs/usage.rst -- list schemas") 93 | with open("docs/usage.rst") as fp: 94 | content = fp.read() 95 | 96 | vendored_list_start = ".. vendored-schema-list-start\n" 97 | vendored_list_end = "\n.. vendored-schema-list-end" 98 | 99 | content_head = content.split(vendored_list_start)[0] 100 | content_tail = content.split(vendored_list_end)[-1] 101 | 102 | generated_list = "\n".join( 103 | [vendored_list_start] 104 | + [f"- ``vendor.{n}``" for n in SCHEMA_CATALOG] 105 | + [vendored_list_end] 106 | ) 107 | 108 | content = content_head + generated_list + content_tail 109 | with open("docs/usage.rst", "w") as fp: 110 | fp.write(content) 111 | 112 | 113 | def update_precommit_usage_supported_hooks() -> None: 114 | print("updating docs/precommit_usage.rst -- generated hooks") 115 | with open("docs/precommit_usage.rst") as fp: 116 | content = fp.read() 117 | 118 | generated_list_start = ".. generated-hook-list-start\n" 119 | generated_list_end = ".. generated-hook-list-end" 120 | 121 | content_head = content.split(generated_list_start)[0] 122 | content_tail = content.split(generated_list_end)[-1] 123 | 124 | generated_list = "\n\n".join( 125 | [generated_list_start] 126 | + [ 127 | f"""\ 128 | ``{config["id"]}`` 129 | {"~" * (len(config["id"]) + 4)} 130 | 131 | {config["description"]} 132 | 133 | .. code-block:: yaml 134 | :caption: example config 135 | 136 | - repo: https://github.com/python-jsonschema/check-jsonschema 137 | rev: {version} 138 | hooks: 139 | - id: {config["id"]} 140 | """ 141 | for config in iter_catalog_hooks() 142 | ] 143 | + [generated_list_end] 144 | ) 145 | 146 | content = content_head + generated_list + content_tail 147 | with open("docs/precommit_usage.rst", "w") as fp: 148 | fp.write(content) 149 | 150 | 151 | def update_docs() -> None: 152 | update_usage_list_schemas() 153 | update_precommit_usage_supported_hooks() 154 | 155 | 156 | def main() -> None: 157 | update_hook_config(format_all_hook_config()) 158 | update_docs() 159 | 160 | 161 | if __name__ == "__main__": 162 | main() 163 | -------------------------------------------------------------------------------- /scripts/set-dev-version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import re 4 | 5 | 6 | def get_old_version(): 7 | with open("pyproject.toml") as fp: 8 | content = fp.read() 9 | match = re.search(r'^version = "(\d+\.\d+\.\d+)"$', content, flags=re.MULTILINE) 10 | assert match 11 | return match.group(1) 12 | 13 | 14 | def replace_version(filename, formatstr, old_version, new_version): 15 | print(f"updating {filename}") 16 | with open(filename) as fp: 17 | content = fp.read() 18 | old_str = formatstr.format(old_version) 19 | new_str = formatstr.format(new_version) 20 | content = content.replace(old_str, new_str) 21 | with open(filename, "w") as fp: 22 | fp.write(content) 23 | 24 | 25 | def main(): 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument( 28 | "-n", "--number", help="dev number to use, defaults to 1", type=int, default=1 29 | ) 30 | args = parser.parse_args() 31 | 32 | old_version = get_old_version() 33 | new_version = old_version + f".dev{args.number}" 34 | 35 | replace_version("pyproject.toml", 'version = "{}"', old_version, new_version) 36 | print("done") 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/check_jsonschema/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .cli import main 4 | 5 | __all__ = ("main",) 6 | -------------------------------------------------------------------------------- /src/check_jsonschema/__main__.py: -------------------------------------------------------------------------------- 1 | from check_jsonschema import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.resources 4 | import json 5 | import typing as t 6 | 7 | 8 | class NoSuchSchemaError(ValueError): 9 | pass 10 | 11 | 12 | def _get(package: str, resource: str, name: str) -> dict[str, t.Any]: 13 | try: 14 | return t.cast( 15 | "dict[str, t.Any]", 16 | json.loads( 17 | importlib.resources.files(package).joinpath(resource).read_bytes() 18 | ), 19 | ) 20 | except (FileNotFoundError, ModuleNotFoundError): 21 | raise NoSuchSchemaError(f"no builtin schema named {name} was found") 22 | 23 | 24 | def _get_vendored_schema(name: str) -> dict[str, t.Any]: 25 | return _get("check_jsonschema.builtin_schemas.vendor", f"{name}.json", name) 26 | 27 | 28 | def _get_custom_schema(name: str) -> dict[str, t.Any]: 29 | return _get("check_jsonschema.builtin_schemas.custom", f"{name}.json", name) 30 | 31 | 32 | def get_builtin_schema(name: str) -> dict[str, t.Any]: 33 | # first, look for an identifying prefix 34 | if name.startswith("vendor."): 35 | return _get_vendored_schema(name[7:]) 36 | elif name.startswith("custom."): 37 | return _get_custom_schema(name[7:]) 38 | 39 | # if there is no prefix, just try in order: first custom, then vendored 40 | try: 41 | return _get_custom_schema(name) 42 | except NoSuchSchemaError: 43 | return _get_vendored_schema(name) 44 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/custom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-jsonschema/check-jsonschema/34760a5b25c03e2d85f63838c9d05e765b4a3ee0/src/check_jsonschema/builtin_schemas/custom/__init__.py -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/custom/github-workflows-require-timeout.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$comment": "A schema which requires that github workflow jobs define job timeouts", 4 | "definitions": { 5 | "expressionSyntax": { 6 | "type": "string", 7 | "$comment": "pattern definition taken from schemastore 'github-workflow.json'", 8 | "pattern": "^\\$\\{\\{(.|[\r\n])*\\}\\}$" 9 | } 10 | }, 11 | "properties": { 12 | "jobs": { 13 | "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobs", 14 | "type": "object", 15 | "patternProperties": { 16 | "^[_a-zA-Z][a-zA-Z0-9_-]*$": { 17 | "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobsjob_id", 18 | "oneOf": [ 19 | { 20 | "type": "object", 21 | "properties": { 22 | "timeout-minutes": { 23 | "$comment": "https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes", 24 | "description": "The maximum number of minutes to let a workflow run before GitHub automatically cancels it. Default: 360", 25 | "default": 360, 26 | "oneOf": [ 27 | { 28 | "type": "number" 29 | }, 30 | { 31 | "$ref": "#/definitions/expressionSyntax" 32 | } 33 | ] 34 | } 35 | }, 36 | "required": [ 37 | "runs-on", 38 | "timeout-minutes" 39 | ], 40 | "additionalProperties": true 41 | }, 42 | { 43 | "$comment": "https://docs.github.com/en/actions/learn-github-actions/reusing-workflows#calling-a-reusable-workflow", 44 | "type": "object", 45 | "required": [ 46 | "uses" 47 | ], 48 | "additionalProperties": true 49 | } 50 | ] 51 | } 52 | } 53 | } 54 | }, 55 | "additionalProperties": true 56 | } 57 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/README.md: -------------------------------------------------------------------------------- 1 | # Vendorized Schemas 2 | 3 | These are vendored versions of the schemas referred to by the hooks. 4 | For the exact list of schemas here, see the repo root for hook config. 5 | 6 | ## Licenses 7 | 8 | Most of the schemas in this directory are provided by SchemaStore. They are 9 | therefore licensed under the SchemaStore license, included in this directory. 10 | Additional licenses can be found in [`licenses`](licenses). 11 | 12 | ### Azure Pipelines 13 | 14 | The Azure pipelines schema is provided by Microsoft and is licensed under the 15 | license for azure-pipelines-vscode. 16 | 17 | ### ReadTheDocs 18 | 19 | The ReadTheDocs schema is provided by ReadTheDocs and licensed under the 20 | license for readthedocs.org 21 | 22 | ### Renovate 23 | 24 | The Renovate schema is provided by Renovate and licensed under the license for 25 | renovatebot 26 | 27 | ### GitLab 28 | 29 | The GitLab CI schema is provided by GitLab and licensed under the license for 30 | the public gitlab repo. In particular, it falls under the "MIT Expat" portion 31 | of the license for that repo. 32 | 33 | ### Buildkite 34 | 35 | The Buildkite schema is provided by Buildkite and licensed under the license 36 | for their 'pipeline-schema' repo. 37 | 38 | ### Taskfile 39 | 40 | The Taskfile schema is provided by Task and licensed under the license 41 | for their 'task' repo. 42 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-jsonschema/check-jsonschema/34760a5b25c03e2d85f63838c9d05e765b4a3ee0/src/check_jsonschema/builtin_schemas/vendor/__init__.py -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.azure-pipelines: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.bitbucket-pipelines: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Atlassian and others. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.buildkite: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Buildkite 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.gitlab: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-present GitLab B.V. 2 | 3 | Portions of this software are licensed as follows: 4 | 5 | * All content residing under the "doc/" directory of this repository is licensed under "Creative Commons: CC BY-SA 4.0 license". 6 | * All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE". 7 | * All content that resides under the "jh/" directory of this repository, if that directory exists, is licensed under the license defined in "jh/LICENSE". 8 | * All client-side JavaScript (when served directly or after being compiled, arranged, augmented, or combined), is licensed under the "MIT Expat" license. 9 | * All third party components incorporated into the GitLab Software are licensed under the original license provided by the owner of the applicable component. 10 | * Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.meltano: -------------------------------------------------------------------------------- 1 | Copyright Arch Data, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.readthedocs: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2021 Read the Docs, Inc. & contributors 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.task: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Andrey Nering 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/README.md: -------------------------------------------------------------------------------- 1 | # sha256 2 | Files in this directory are used for updating vendor schemas when they change. 3 | -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/azure-pipelines.sha256: -------------------------------------------------------------------------------- 1 | 2ec6cc72f601459bc8b71ecc2ee49f3419c2daba554cd8653c7f72811446aa28 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/bamboo-spec.sha256: -------------------------------------------------------------------------------- 1 | c7724c5e67e2d3fcb081a36adcbe2ba5f59c884937a09397139d85afc86985a2 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/bitbucket-pipelines.sha256: -------------------------------------------------------------------------------- 1 | cc2fb023c3e71181eabbd569187acd1e7d813e4d7960d662e2c65c9e97b60711 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/buildkite.sha256: -------------------------------------------------------------------------------- 1 | ff30bd5f6d22b965472c1c289bc90e92e837ce136c83a4a7f6ad6cc54132fa99 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/circle-ci.sha256: -------------------------------------------------------------------------------- 1 | ac4bf992b13c06c6f23f13fbe90d784662defec6ab27b9f9a1f097fcc8ef8b72 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/cloudbuild.sha256: -------------------------------------------------------------------------------- 1 | e2444a4bcf66bdb777f954ff294800c94d1f1a54cf1104d2600d6bded0dd5b3b -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/compose-spec.sha256: -------------------------------------------------------------------------------- 1 | 211cb0f6633d057a28f26c73f01c182a6833ea7b7f80a1a428a6c2bff63e3932 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/dependabot.sha256: -------------------------------------------------------------------------------- 1 | 928e4906e3c56f56d95c39c216949b9bb4e987aae7e6676a50dcaf29d80a917c -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/drone-ci.sha256: -------------------------------------------------------------------------------- 1 | 808953201a4919eae19007cde74e27679f547e95619c2f926f21e6fb1cc814f5 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/github-actions.sha256: -------------------------------------------------------------------------------- 1 | 589d4a1519173a8659b097ffdf5bdd00aa20fa4296720fe39c8a3a43c849dd6b -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/github-workflows.sha256: -------------------------------------------------------------------------------- 1 | ed91988dbee827e8f305664e3c68c7ee9b1d7344708878596ffb05d7a815fea9 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/gitlab-ci.sha256: -------------------------------------------------------------------------------- 1 | 7dd1d799baca79aa7842f0d5c6d902ec59c33abfa438685cf4fb8728e11c5a3d -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/meltano.sha256: -------------------------------------------------------------------------------- 1 | 4c0239109ae72a02005fc5c8aef74b3b0298cc98684a00de87b600f45484d7b3 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/mergify.sha256: -------------------------------------------------------------------------------- 1 | 0a3b9e017472207494a0ff45dddba64db25fc6c890f2f4886d1e113ae73455ff -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/readthedocs.sha256: -------------------------------------------------------------------------------- 1 | 00586f5737a5717824d8d594add706f549d3fc405c92fd1151f1c59dd2efbd9f -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/renovate.sha256: -------------------------------------------------------------------------------- 1 | 1678cc33a2f1fb6f60157c437d10d07964b670873c1c4c9c39d432eebe9e26c4 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/snapcraft.sha256: -------------------------------------------------------------------------------- 1 | 5284c12e66fcc7fdd171fc211d7d7fb47166e114d5ed69993685e288b0078525 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/taskfile.sha256: -------------------------------------------------------------------------------- 1 | 0cd16e7df47d129c51fbea320f34361eb177e6447ccce10df769dcd672cccdad -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/travis.sha256: -------------------------------------------------------------------------------- 1 | 89c9ec99a48c5c2b630cea5d245697ff691221832017135ffca6248bc975bab0 -------------------------------------------------------------------------------- /src/check_jsonschema/builtin_schemas/vendor/sha256/woodpecker-ci.sha256: -------------------------------------------------------------------------------- 1 | c31e3bd22a6f9ad1b591e79703e5a784df413633648ccc065d8c2695d18a9422 -------------------------------------------------------------------------------- /src/check_jsonschema/checker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import typing as t 5 | 6 | import click 7 | import jsonschema 8 | import referencing.exceptions 9 | 10 | from . import utils 11 | from .formats import FormatOptions 12 | from .instance_loader import InstanceLoader 13 | from .parsers import ParseError 14 | from .regex_variants import RegexImplementation 15 | from .reporter import Reporter 16 | from .result import CheckResult 17 | from .schema_loader import SchemaLoaderBase, SchemaParseError, UnsupportedUrlScheme 18 | 19 | 20 | class _Exit(Exception): 21 | def __init__(self, code: int) -> None: 22 | self.code = code 23 | 24 | 25 | class SchemaChecker: 26 | def __init__( 27 | self, 28 | schema_loader: SchemaLoaderBase, 29 | instance_loader: InstanceLoader, 30 | reporter: Reporter, 31 | *, 32 | format_opts: FormatOptions, 33 | regex_impl: RegexImplementation, 34 | traceback_mode: str = "short", 35 | fill_defaults: bool = False, 36 | ) -> None: 37 | self._schema_loader = schema_loader 38 | self._instance_loader = instance_loader 39 | self._reporter = reporter 40 | 41 | self._format_opts = format_opts 42 | self._regex_impl = regex_impl 43 | self._traceback_mode = traceback_mode 44 | self._fill_defaults = fill_defaults 45 | 46 | def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn: 47 | click.echo(msg, err=True) 48 | if err is not None: 49 | utils.print_error(err, mode=self._traceback_mode) 50 | raise _Exit(1) 51 | 52 | def get_validator( 53 | self, path: pathlib.Path | str, doc: dict[str, t.Any] 54 | ) -> jsonschema.protocols.Validator: 55 | try: 56 | return self._schema_loader.get_validator( 57 | path, doc, self._format_opts, self._regex_impl, self._fill_defaults 58 | ) 59 | except SchemaParseError as e: 60 | self._fail("Error: schemafile could not be parsed as JSON", e) 61 | except jsonschema.SchemaError as e: 62 | self._fail("Error: schemafile was not valid\n", e) 63 | except UnsupportedUrlScheme as e: 64 | self._fail(f"Error: {e}\n", e) 65 | except Exception as e: 66 | self._fail("Error: Unexpected Error building schema validator", e) 67 | 68 | def _build_result(self) -> CheckResult: 69 | result = CheckResult() 70 | for path, data in self._instance_loader.iter_files(): 71 | if isinstance(data, ParseError): 72 | result.record_parse_error(path, data) 73 | else: 74 | validator = self.get_validator(path, data) 75 | passing = True 76 | for err in validator.iter_errors(data): 77 | result.record_validation_error(path, err) 78 | passing = False 79 | if passing: 80 | result.record_validation_success(path) 81 | return result 82 | 83 | def _run(self) -> None: 84 | try: 85 | result = self._build_result() 86 | except ( 87 | referencing.exceptions.NoSuchResource, 88 | referencing.exceptions.Unretrievable, 89 | referencing.exceptions.Unresolvable, 90 | ) as e: 91 | self._fail("Failure resolving $ref within schema\n", e) 92 | 93 | self._reporter.report_result(result) 94 | if not result.success: 95 | raise _Exit(1) 96 | 97 | def run(self) -> int: 98 | try: 99 | self._run() 100 | except _Exit as e: 101 | return e.code 102 | return 0 103 | -------------------------------------------------------------------------------- /src/check_jsonschema/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .main_command import main 2 | 3 | __all__ = ("main",) 4 | -------------------------------------------------------------------------------- /src/check_jsonschema/cli/parse_result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | import typing as t 5 | 6 | import click 7 | import jsonschema 8 | 9 | from ..formats import FormatOptions 10 | from ..regex_variants import RegexImplementation, RegexVariantName 11 | from ..transforms import Transform 12 | 13 | 14 | class SchemaLoadingMode(enum.Enum): 15 | filepath = "filepath" 16 | builtin = "builtin" 17 | metaschema = "metaschema" 18 | 19 | 20 | class ParseResult: 21 | def __init__(self) -> None: 22 | # primary options: schema + instances 23 | self.schema_mode: SchemaLoadingMode = SchemaLoadingMode.filepath 24 | self.schema_path: str | None = None 25 | self.base_uri: str | None = None 26 | self.instancefiles: tuple[t.IO[bytes], ...] = () 27 | # cache controls 28 | self.disable_cache: bool = False 29 | self.cache_filename: str | None = None 30 | # filetype detection (JSON, YAML, TOML, etc) 31 | self.default_filetype: str = "json" 32 | self.force_filetype: str | None = None 33 | # data-transform (for Azure Pipelines and potentially future transforms) 34 | self.data_transform: Transform | None = None 35 | # validation behavioral controls 36 | self.validator_class: type[jsonschema.protocols.Validator] | None = None 37 | self.fill_defaults: bool = False 38 | # regex format options 39 | self.disable_all_formats: bool = False 40 | self.disable_formats: tuple[str, ...] = () 41 | self.regex_variant: RegexVariantName = RegexVariantName.default 42 | # error and output controls 43 | self.verbosity: int = 1 44 | self.traceback_mode: str = "short" 45 | self.output_format: str = "text" 46 | 47 | def set_regex_variant( 48 | self, 49 | variant_opt: t.Literal["python", "nonunicode", "default"] | None, 50 | *, 51 | legacy_opt: t.Literal["python", "nonunicode", "default"] | None = None, 52 | ) -> None: 53 | variant_name: t.Literal["python", "nonunicode", "default"] | None = ( 54 | variant_opt or legacy_opt 55 | ) 56 | if variant_name: 57 | self.regex_variant = RegexVariantName(variant_name) 58 | 59 | def set_schema( 60 | self, schemafile: str | None, builtin_schema: str | None, check_metaschema: bool 61 | ) -> None: 62 | mutex_arg_count = sum( 63 | 1 if x else 0 for x in (schemafile, builtin_schema, check_metaschema) 64 | ) 65 | if mutex_arg_count == 0: 66 | raise click.UsageError( 67 | "Either --schemafile, --builtin-schema, or --check-metaschema " 68 | "must be provided" 69 | ) 70 | if mutex_arg_count > 1: 71 | raise click.UsageError( 72 | "--schemafile, --builtin-schema, and --check-metaschema " 73 | "are mutually exclusive" 74 | ) 75 | 76 | if schemafile: 77 | self.schema_mode = SchemaLoadingMode.filepath 78 | self.schema_path = schemafile 79 | elif builtin_schema: 80 | self.schema_mode = SchemaLoadingMode.builtin 81 | self.schema_path = builtin_schema 82 | else: 83 | self.schema_mode = SchemaLoadingMode.metaschema 84 | 85 | def set_validator( 86 | self, validator_class: type[jsonschema.protocols.Validator] | None 87 | ) -> None: 88 | if validator_class is None: 89 | return 90 | if self.schema_mode != SchemaLoadingMode.filepath: 91 | raise click.UsageError( 92 | "--validator-class can only be used with --schemafile for schema loading" 93 | ) 94 | self.validator_class = validator_class 95 | 96 | @property 97 | def format_opts(self) -> FormatOptions: 98 | return FormatOptions( 99 | regex_impl=RegexImplementation(self.regex_variant), 100 | enabled=not self.disable_all_formats, 101 | disabled_formats=self.disable_formats, 102 | ) 103 | -------------------------------------------------------------------------------- /src/check_jsonschema/cli/warnings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | import warnings 5 | 6 | import click 7 | 8 | 9 | def deprecation_warning_callback( 10 | optstring: str, *, is_flag: bool = False, append_message: str | None = None 11 | ) -> t.Callable[[click.Context, click.Parameter, t.Any], t.Any]: 12 | def callback(ctx: click.Context, param: click.Parameter, value: t.Any) -> t.Any: 13 | if not value: 14 | return value 15 | if (is_flag and bool(value) is True) or (value is not None): 16 | message = ( 17 | f"'{optstring}' is deprecated and will be removed in a future release." 18 | ) 19 | if append_message is not None: 20 | message += f" {append_message}" 21 | warnings.warn(message, stacklevel=2) 22 | 23 | return value 24 | 25 | return callback 26 | -------------------------------------------------------------------------------- /src/check_jsonschema/formats/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | 5 | import jsonschema 6 | import jsonschema.validators 7 | 8 | from ..regex_variants import RegexImplementation 9 | from .implementations import validate_rfc3339, validate_time 10 | 11 | # all known format strings except for a selection from draft3 which have either 12 | # been renamed or removed: 13 | # - color 14 | # - host-name 15 | # - ip-address 16 | KNOWN_FORMATS: tuple[str, ...] = ( 17 | "date", 18 | "date-time", 19 | "duration", 20 | "email", 21 | "hostname", 22 | "idn-email", 23 | "idn-hostname", 24 | "ipv4", 25 | "ipv6", 26 | "iri", 27 | "iri-reference", 28 | "json-pointer", 29 | "regex", 30 | "relative-json-pointer", 31 | "time", 32 | "uri", 33 | "uri-reference", 34 | "uri-template", 35 | "uuid", 36 | ) 37 | 38 | 39 | class FormatOptions: 40 | def __init__( 41 | self, 42 | *, 43 | regex_impl: RegexImplementation, 44 | enabled: bool = True, 45 | disabled_formats: tuple[str, ...] = (), 46 | ) -> None: 47 | self.enabled = enabled 48 | self.regex_impl = regex_impl 49 | self.disabled_formats = disabled_formats 50 | 51 | 52 | def get_base_format_checker(schema_dialect: str | None) -> jsonschema.FormatChecker: 53 | # resolve the dialect, if given, to a validator class 54 | # default to the latest draft 55 | validator_class = jsonschema.validators.validator_for( 56 | {} if schema_dialect is None else {"$schema": schema_dialect}, 57 | default=jsonschema.Draft202012Validator, 58 | ) 59 | return validator_class.FORMAT_CHECKER 60 | 61 | 62 | def make_format_checker( 63 | opts: FormatOptions, 64 | schema_dialect: str | None = None, 65 | ) -> jsonschema.FormatChecker | None: 66 | if not opts.enabled: 67 | return None 68 | 69 | # customize around regex checking first 70 | checker = format_checker_for_regex_impl(opts.regex_impl) 71 | 72 | # add other custom format checks 73 | checker.checks("date-time")(validate_rfc3339) 74 | checker.checks("time")(validate_time) 75 | 76 | # remove the disabled checks, which may include the regex check 77 | for checkname in opts.disabled_formats: 78 | if checkname not in checker.checkers: 79 | continue 80 | del checker.checkers[checkname] 81 | 82 | return checker 83 | 84 | 85 | def format_checker_for_regex_impl( 86 | regex_impl: RegexImplementation, schema_dialect: str | None = None 87 | ) -> jsonschema.FormatChecker: 88 | # convert to a schema-derived format checker, and copy it 89 | # for safe modification 90 | base_checker = get_base_format_checker(schema_dialect) 91 | checker = copy.deepcopy(base_checker) 92 | 93 | # replace the regex check 94 | del checker.checkers["regex"] 95 | checker.checks("regex")(regex_impl.check_format) 96 | 97 | return checker 98 | -------------------------------------------------------------------------------- /src/check_jsonschema/formats/implementations/__init__.py: -------------------------------------------------------------------------------- 1 | from .iso8601_time import validate as validate_time 2 | from .rfc3339 import validate as validate_rfc3339 3 | 4 | __all__ = ("validate_rfc3339", "validate_time") 5 | -------------------------------------------------------------------------------- /src/check_jsonschema/formats/implementations/iso8601_time.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | TIME_REGEX = re.compile( 4 | r""" 5 | ^ 6 | (?:[01]\d|2[0123]) 7 | : 8 | (?:[0-5]\d) 9 | : 10 | (?:[0-5]\d) 11 | # (optional) fractional seconds 12 | (?:(\.|,)\d+)? 13 | # UTC or offset 14 | (?: 15 | Z 16 | | z 17 | | [+-](?:[01]\d|2[0123]):[0-5]\d 18 | ) 19 | $ 20 | """, 21 | re.VERBOSE | re.ASCII, 22 | ) 23 | 24 | 25 | def validate(time_str: object) -> bool: 26 | if not isinstance(time_str, str): 27 | return False 28 | return bool(TIME_REGEX.match(time_str)) 29 | 30 | 31 | if __name__ == "__main__": 32 | import timeit 33 | 34 | N = 100_000 35 | tests = ( 36 | ("basic", "23:59:59Z"), 37 | ("long_fracsec", "23:59:59.8446519776713Z"), 38 | ) 39 | 40 | print("benchmarking") 41 | for name, val in tests: 42 | all_times = timeit.repeat( 43 | f"validate({val!r})", globals=globals(), repeat=3, number=N 44 | ) 45 | print(f"{name} (valid={validate(val)}): {int(min(all_times) / N * 10**9)}ns") 46 | -------------------------------------------------------------------------------- /src/check_jsonschema/formats/implementations/rfc3339.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # this regex is based on the one from the rfc3339-validator package 4 | # credit to the original author 5 | # original license: 6 | # 7 | # MIT License 8 | # 9 | # Copyright (c) 2019, Nicolas Aimetti 10 | # 11 | # Permission is hereby granted, free of charge, to any person obtaining a copy 12 | # of this software and associated documentation files (the "Software"), to deal 13 | # in the Software without restriction, including without limitation the rights 14 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | # copies of the Software, and to permit persons to whom the Software is 16 | # furnished to do so, subject to the following conditions: 17 | # 18 | # The above copyright notice and this permission notice shall be included in all 19 | # copies or substantial portions of the Software. 20 | # 21 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | # SOFTWARE. 28 | # 29 | # modifications have been made for additional corner cases and speed 30 | RFC3339_REGEX = re.compile( 31 | r""" 32 | ^ 33 | (?:\d{4}) 34 | - 35 | (?:0[1-9]|1[0-2]) 36 | - 37 | (?:[0-3]\d) 38 | (?:[Tt]) 39 | (?:[01]\d|2[0123]) 40 | : 41 | (?:[0-5]\d) 42 | : 43 | (?:[0-5]\d) 44 | # (optional) fractional seconds 45 | (?:[\.,]\d+)? 46 | # UTC or offset 47 | (?: 48 | [Zz] 49 | | [+-](?:[01]\d|2[0123]):[0-5]\d 50 | ) 51 | $ 52 | """, 53 | re.VERBOSE | re.ASCII, 54 | ) 55 | 56 | 57 | def validate(date_str: object) -> bool: 58 | """Validate a string as a RFC3339 date-time.""" 59 | if not isinstance(date_str, str): 60 | return False 61 | if not RFC3339_REGEX.match(date_str): 62 | return False 63 | 64 | year, month, day = int(date_str[:4]), int(date_str[5:7]), int(date_str[8:10]) 65 | 66 | if month in {4, 6, 9, 11}: 67 | max_day = 30 68 | elif month == 2: 69 | max_day = 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28 70 | else: 71 | max_day = 31 72 | if not 1 <= day <= max_day: 73 | return False 74 | return True 75 | 76 | 77 | if __name__ == "__main__": 78 | import timeit 79 | 80 | N = 100_000 81 | tests = ( 82 | ("long_fracsec", "2018-12-31T23:59:59.8446519776713Z"), 83 | ("basic", "2018-12-31T23:59:59Z"), 84 | ("in_february", "2018-02-12T23:59:59Z"), 85 | ("in_february_invalid", "2018-02-29T23:59:59Z"), 86 | ("missing_t", "2018-12-31 23:59:59Z"), 87 | ("invalid_day", "2018-12-41T23:59:59Z"), 88 | ) 89 | 90 | print("benchmarking") 91 | for name, val in tests: 92 | all_times = timeit.repeat( 93 | f"validate({val!r})", globals=globals(), repeat=3, number=N 94 | ) 95 | print(f"{name} (valid={validate(val)}): {int(min(all_times) / N * 10**9)}ns") 96 | -------------------------------------------------------------------------------- /src/check_jsonschema/identify_filetype.py: -------------------------------------------------------------------------------- 1 | """ 2 | Identify filetypes by extension 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import pathlib 8 | 9 | _EXTENSION_MAP = { 10 | "json": "json", 11 | "jsonld": "json", 12 | "geojson": "json", 13 | "yaml": "yaml", 14 | "yml": "yaml", 15 | "ymlld": "yaml", 16 | "eyaml": "yaml", 17 | "json5": "json5", 18 | "toml": "toml", 19 | } 20 | 21 | 22 | def path_to_type(path: str | pathlib.Path, *, default_type: str = "json") -> str: 23 | if isinstance(path, str): 24 | ext = path.rpartition(".")[2] 25 | else: 26 | ext = path.suffix.lstrip(".") 27 | 28 | if ext in _EXTENSION_MAP: 29 | return _EXTENSION_MAP[ext] 30 | 31 | return default_type 32 | -------------------------------------------------------------------------------- /src/check_jsonschema/instance_loader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import typing as t 5 | 6 | from check_jsonschema.cli.param_types import CustomLazyFile 7 | 8 | from .parsers import ParseError, ParserSet 9 | from .transforms import Transform 10 | 11 | 12 | class InstanceLoader: 13 | def __init__( 14 | self, 15 | files: t.Sequence[t.IO[bytes] | CustomLazyFile], 16 | default_filetype: str = "json", 17 | force_filetype: str | None = None, 18 | data_transform: Transform | None = None, 19 | ) -> None: 20 | self._files = files 21 | self._default_filetype = default_filetype 22 | self._force_filetype = force_filetype 23 | self._data_transform = ( 24 | data_transform if data_transform is not None else Transform() 25 | ) 26 | 27 | self._parsers = ParserSet( 28 | modify_yaml_implementation=self._data_transform.modify_yaml_implementation 29 | ) 30 | 31 | def iter_files(self) -> t.Iterator[tuple[str, ParseError | t.Any]]: 32 | for file in self._files: 33 | if hasattr(file, "name"): 34 | name = file.name 35 | # allowing for BytesIO to be special-cased here is useful for 36 | # simpler test setup, since this is what tests will pass and we naturally 37 | # support it here 38 | elif isinstance(file, io.BytesIO) or file.fileno() == 0: 39 | name = "" 40 | else: 41 | raise ValueError(f"File {file} has no name attribute") 42 | 43 | try: 44 | if isinstance(file, CustomLazyFile): 45 | stream: t.IO[bytes] = t.cast(t.IO[bytes], file.open()) 46 | else: 47 | stream = file 48 | 49 | try: 50 | data: t.Any = self._parsers.parse_data_with_path( 51 | stream, name, self._default_filetype, self._force_filetype 52 | ) 53 | except ParseError as err: 54 | data = err 55 | else: 56 | data = self._data_transform(data) 57 | finally: 58 | file.close() 59 | yield (name, data) 60 | -------------------------------------------------------------------------------- /src/check_jsonschema/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import pathlib 5 | import typing as t 6 | 7 | import ruamel.yaml 8 | 9 | from ..identify_filetype import path_to_type 10 | from . import json5, json_, toml, yaml 11 | 12 | _PARSER_ERRORS: set[type[Exception]] = { 13 | json_.JSONDecodeError, 14 | yaml.ParseError, 15 | toml.ParseError, 16 | } 17 | DEFAULT_LOAD_FUNC_BY_TAG: dict[str, t.Callable[[t.IO[bytes]], t.Any]] = { 18 | "json": json_.load, 19 | "toml": toml.load, 20 | } 21 | SUPPORTED_FILE_FORMATS = ["json", "toml", "yaml"] 22 | if json5.ENABLED: 23 | SUPPORTED_FILE_FORMATS.append("json5") 24 | DEFAULT_LOAD_FUNC_BY_TAG["json5"] = json5.load 25 | _PARSER_ERRORS.add(json5.ParseError) 26 | MISSING_SUPPORT_MESSAGES: dict[str, str] = { 27 | "json5": json5.MISSING_SUPPORT_MESSAGE, 28 | } 29 | LOADING_FAILURE_ERROR_TYPES: tuple[type[Exception], ...] = tuple(_PARSER_ERRORS) 30 | 31 | 32 | class ParseError(ValueError): 33 | pass 34 | 35 | 36 | class BadFileTypeError(ParseError): 37 | pass 38 | 39 | 40 | class FailedFileLoadError(ParseError): 41 | pass 42 | 43 | 44 | class ParserSet: 45 | def __init__( 46 | self, 47 | *, 48 | modify_yaml_implementation: t.Callable[[ruamel.yaml.YAML], None] | None = None, 49 | supported_formats: t.Sequence[str] | None = None, 50 | ) -> None: 51 | yaml_impl = yaml.construct_yaml_implementation() 52 | failover_yaml_impl = yaml.construct_yaml_implementation(pure=True) 53 | if modify_yaml_implementation: 54 | modify_yaml_implementation(yaml_impl) 55 | modify_yaml_implementation(failover_yaml_impl) 56 | base_by_tag = { 57 | "yaml": yaml.impl2loader(yaml_impl, failover_yaml_impl), 58 | **DEFAULT_LOAD_FUNC_BY_TAG, 59 | } 60 | if supported_formats is None: 61 | self._by_tag = base_by_tag 62 | else: 63 | self._by_tag = { 64 | k: v for k, v in base_by_tag.items() if k in supported_formats 65 | } 66 | 67 | def get( 68 | self, 69 | path: pathlib.Path | str, 70 | default_filetype: str, 71 | force_filetype: str | None = None, 72 | ) -> t.Callable[[t.IO[bytes]], t.Any]: 73 | if force_filetype: 74 | filetype = force_filetype 75 | else: 76 | filetype = path_to_type(path, default_type=default_filetype) 77 | 78 | if filetype in self._by_tag: 79 | return self._by_tag[filetype] 80 | 81 | if filetype in MISSING_SUPPORT_MESSAGES: 82 | raise BadFileTypeError( 83 | f"cannot parse {path} because support is missing for {filetype}\n" 84 | + MISSING_SUPPORT_MESSAGES[filetype] 85 | ) 86 | raise BadFileTypeError( 87 | f"cannot parse {path} as it is not one of the supported filetypes: " 88 | + ",".join(self._by_tag.keys()) 89 | ) 90 | 91 | def parse_data_with_path( 92 | self, 93 | data: t.IO[bytes] | bytes, 94 | path: pathlib.Path | str, 95 | default_filetype: str, 96 | force_filetype: str | None = None, 97 | ) -> t.Any: 98 | loadfunc = self.get(path, default_filetype, force_filetype) 99 | try: 100 | if isinstance(data, bytes): 101 | data = io.BytesIO(data) 102 | return loadfunc(data) 103 | except LOADING_FAILURE_ERROR_TYPES as e: 104 | raise FailedFileLoadError(f"Failed to parse {path}") from e 105 | 106 | def parse_file( 107 | self, 108 | path: pathlib.Path | str, 109 | default_filetype: str, 110 | force_filetype: str | None = None, 111 | ) -> t.Any: 112 | with open(path, "rb") as fp: 113 | return self.parse_data_with_path(fp, path, default_filetype, force_filetype) 114 | -------------------------------------------------------------------------------- /src/check_jsonschema/parsers/json5.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | # try to import pyjson5 first 6 | # this is the CPython implementation and therefore preferred for its speec 7 | try: 8 | import pyjson5 9 | 10 | ParseError: type[Exception] = pyjson5.Json5DecoderException 11 | _load: t.Callable | None = pyjson5.load 12 | except ImportError: 13 | # if pyjson5 was not available, try to import 'json5', the pure-python implementation 14 | try: 15 | import json5 16 | 17 | # json5 doesn't define a custom decoding error class 18 | ParseError = ValueError 19 | _load = json5.load 20 | except ImportError: 21 | ParseError = ValueError 22 | _load = None 23 | 24 | # present a bool for detecting that it's enabled 25 | ENABLED = _load is not None 26 | 27 | if _load is not None: 28 | _load_concrete: t.Callable = _load 29 | 30 | def load(stream: t.IO[bytes]) -> t.Any: 31 | return _load_concrete(stream) 32 | 33 | else: 34 | 35 | def load(stream: t.IO[bytes]) -> t.Any: 36 | raise NotImplementedError 37 | 38 | 39 | MISSING_SUPPORT_MESSAGE = """ 40 | check-jsonschema can only parse json5 files when a json5 parser is installed 41 | 42 | If you are running check-jsonschema as an installed python package, either 43 | pip install json5 44 | or 45 | pip install pyjson5 46 | 47 | If you are running check-jsonschema as a pre-commit hook, set 48 | additional_dependencies: ['json5'] 49 | or 50 | additional_dependencies: ['pyjson5'] 51 | """ 52 | -------------------------------------------------------------------------------- /src/check_jsonschema/parsers/json_.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing as t 5 | 6 | try: 7 | import orjson 8 | 9 | has_orjson = True 10 | except ImportError: 11 | has_orjson = False 12 | 13 | JSONDecodeError = json.JSONDecodeError 14 | 15 | 16 | def load(stream: t.IO[bytes]) -> t.Any: 17 | bin_data = stream.read() 18 | # if orjson is available, try it first 19 | if has_orjson: 20 | # in the event of a decode error, it may be that the data contains 21 | # `Infinity`, `-Infinity`, or `NaN` 22 | # 23 | # however, do not failover to stdlib JSON -- it is not obvious that there's any 24 | # need for check-jsonschema to support these invalid JSON datatypes 25 | # if users encounter issues with this behavior in the future, we can revisit how 26 | # JSON loading is handled 27 | return orjson.loads(bin_data) 28 | # failover to stdlib json 29 | return json.loads(bin_data) 30 | -------------------------------------------------------------------------------- /src/check_jsonschema/parsers/toml.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import sys 5 | import typing as t 6 | 7 | if sys.version_info < (3, 11): 8 | import tomli as toml_implementation 9 | else: 10 | import tomllib as toml_implementation 11 | 12 | 13 | def _normalize(data: t.Any) -> t.Any: 14 | """ 15 | Normalize TOML data to fit the requirements to be JSON-encodeable. 16 | 17 | Currently this applies the following transformations: 18 | 19 | offset-aware datetime.datetime values are converted to strings using isoformat() 20 | naive datetime.datetime values are converted to strings using isoformat() + "Z" 21 | 22 | offset-aware datetime.time values are converted to strings using isoformat() 23 | naive datetime.time values are converted to strings using isoformat() + "Z" 24 | 25 | datetime.date values are converted to strings using isoformat() 26 | """ 27 | if isinstance(data, dict): 28 | return {k: _normalize(v) for k, v in data.items()} 29 | elif isinstance(data, list): 30 | return [_normalize(x) for x in data] 31 | else: 32 | # python's datetime will format to an ISO partial time when handling a naive 33 | # time/datetime , but JSON Schema format validation specifies that date-time is 34 | # taken from RFC3339, which defines "date-time" as including 'Z|offset' 35 | # the specification for "time" is less clear because JSON Schema does not specify 36 | # which RFC3339 definition should be used, and the RFC has no format named "time", 37 | # only "full-time" (with Z|offset) and "partial-time" (no offset) 38 | # 39 | # rfc3339_validator (used by 'jsonschema') requires the offset, so we will do the 40 | # same 41 | if isinstance(data, datetime.datetime) or isinstance(data, datetime.time): 42 | if data.tzinfo is None: 43 | return data.isoformat() + "Z" 44 | return data.isoformat() 45 | elif isinstance(data, datetime.date): 46 | return data.isoformat() 47 | return data 48 | 49 | 50 | ParseError: type[Exception] = toml_implementation.TOMLDecodeError 51 | 52 | 53 | def load(stream: t.IO[bytes]) -> t.Any: 54 | data = toml_implementation.load(stream) 55 | return _normalize(data) 56 | -------------------------------------------------------------------------------- /src/check_jsonschema/parsers/yaml.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | import warnings 5 | 6 | import ruamel.yaml 7 | 8 | ParseError = ruamel.yaml.YAMLError 9 | 10 | 11 | def construct_yaml_implementation( 12 | typ: str = "safe", pure: bool = False 13 | ) -> ruamel.yaml.YAML: 14 | implementation = ruamel.yaml.YAML(typ=typ, pure=pure) 15 | 16 | # workaround global state 17 | # see: https://sourceforge.net/p/ruamel-yaml/tickets/341/ 18 | class GeneratedSafeConstructor(ruamel.yaml.SafeConstructor): 19 | pass 20 | 21 | implementation.Constructor = GeneratedSafeConstructor 22 | 23 | # ruamel.yaml parses timestamp values into datetime.datetime values 24 | # however, JSON does not support native datetimes, so JSON Schema formats for 25 | # dates apply to strings 26 | # Turn off this feature, instructing the parser to load datetimes as strings 27 | implementation.constructor.yaml_constructors["tag:yaml.org,2002:timestamp"] = ( 28 | implementation.constructor.yaml_constructors["tag:yaml.org,2002:str"] 29 | ) 30 | 31 | return implementation 32 | 33 | 34 | def _normalize(data: t.Any) -> t.Any: 35 | """ 36 | Normalize YAML data to fit the requirements to be JSON-encodeable. 37 | 38 | Currently this applies the following transformation: 39 | dict keys are converted to strings 40 | 41 | Additional tweaks can be added in this layer in the future if necessary. 42 | """ 43 | if isinstance(data, dict): 44 | return {str(k): _normalize(v) for k, v in data.items()} 45 | elif isinstance(data, list): 46 | return [_normalize(x) for x in data] 47 | else: 48 | return data 49 | 50 | 51 | _data_sentinel = object() 52 | 53 | 54 | def impl2loader( 55 | primary: ruamel.yaml.YAML, *fallbacks: ruamel.yaml.YAML 56 | ) -> t.Callable[[t.IO[bytes]], t.Any]: 57 | def load(stream: t.IO[bytes]) -> t.Any: 58 | stream_bytes = stream.read() 59 | lasterr: ruamel.yaml.YAMLError | None = None 60 | data: t.Any = _data_sentinel 61 | with warnings.catch_warnings(): 62 | warnings.simplefilter("ignore", ruamel.yaml.error.ReusedAnchorWarning) 63 | for impl in [primary] + list(fallbacks): 64 | try: 65 | data = impl.load(stream_bytes) 66 | except ruamel.yaml.YAMLError as e: 67 | lasterr = e 68 | else: 69 | break 70 | if data is _data_sentinel and lasterr is not None: 71 | raise lasterr 72 | return _normalize(data) 73 | 74 | return load 75 | -------------------------------------------------------------------------------- /src/check_jsonschema/regex_variants.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | import typing as t 4 | 5 | import jsonschema 6 | import regress 7 | 8 | 9 | class RegexVariantName(enum.Enum): 10 | default = "default" 11 | nonunicode = "nonunicode" 12 | python = "python" 13 | 14 | 15 | class RegexImplementation: 16 | """ 17 | A high-level interface for getting at the different possible 18 | implementations of regex behaviors. 19 | """ 20 | 21 | _concrete: "_ConcreteImplementation" 22 | 23 | def __init__(self, variant: RegexVariantName) -> None: 24 | self.variant = variant 25 | 26 | if self.variant == RegexVariantName.default: 27 | self._concrete = _RegressImplementation() 28 | elif self.variant == RegexVariantName.nonunicode: 29 | self._concrete = _NonunicodeRegressImplementation() 30 | else: 31 | self._concrete = _PythonImplementation() 32 | 33 | self.check_format = self._concrete.check_format 34 | self.pattern_keyword = self._concrete.pattern_keyword 35 | self.patternProperties_keyword = self._concrete.patternProperties_keyword 36 | 37 | 38 | class _ConcreteImplementation(t.Protocol): 39 | def check_format(self, instance: t.Any) -> bool: ... 40 | 41 | def pattern_keyword( 42 | self, validator: t.Any, pattern: str, instance: str, schema: t.Any 43 | ) -> t.Iterator[jsonschema.ValidationError]: ... 44 | 45 | def patternProperties_keyword( 46 | self, 47 | validator: t.Any, 48 | patternProperties: dict[str, t.Any], 49 | instance: dict[str, t.Any], 50 | schema: t.Any, 51 | ) -> t.Iterator[jsonschema.ValidationError]: ... 52 | 53 | 54 | class _RegressImplementation: 55 | def _compile_pattern(self, pattern: str) -> regress.Regex: 56 | return regress.Regex(pattern, flags="u") 57 | 58 | def check_format(self, instance: t.Any) -> bool: 59 | if not isinstance(instance, str): 60 | return True 61 | try: 62 | self._compile_pattern(instance) 63 | except regress.RegressError: 64 | return False 65 | return True 66 | 67 | def pattern_keyword( 68 | self, validator: t.Any, pattern: str, instance: str, schema: t.Any 69 | ) -> t.Iterator[jsonschema.ValidationError]: 70 | if not validator.is_type(instance, "string"): 71 | return 72 | 73 | regress_pattern = self._compile_pattern(pattern) 74 | if not regress_pattern.find(instance): 75 | yield jsonschema.ValidationError(f"{instance!r} does not match {pattern!r}") 76 | 77 | def patternProperties_keyword( 78 | self, 79 | validator: t.Any, 80 | patternProperties: dict[str, t.Any], 81 | instance: dict[str, t.Any], 82 | schema: t.Any, 83 | ) -> t.Iterator[jsonschema.ValidationError]: 84 | if not validator.is_type(instance, "object"): 85 | return 86 | 87 | for pattern, subschema in patternProperties.items(): 88 | regress_pattern = self._compile_pattern(pattern) 89 | for k, v in instance.items(): 90 | if regress_pattern.find(k): 91 | yield from validator.descend( 92 | v, 93 | subschema, 94 | path=k, 95 | schema_path=pattern, 96 | ) 97 | 98 | 99 | class _NonunicodeRegressImplementation(_RegressImplementation): 100 | def _compile_pattern(self, pattern: str) -> regress.Regex: 101 | return regress.Regex(pattern) 102 | 103 | 104 | class _PythonImplementation: 105 | def check_format(self, instance: t.Any) -> bool: 106 | if not isinstance(instance, str): 107 | return True 108 | try: 109 | re.compile(instance) 110 | except re.error: 111 | return False 112 | return True 113 | 114 | def pattern_keyword( 115 | self, validator: t.Any, pattern: str, instance: str, schema: t.Any 116 | ) -> t.Iterator[jsonschema.ValidationError]: 117 | if not validator.is_type(instance, "string"): 118 | return 119 | 120 | re_pattern = re.compile(pattern) 121 | if not re_pattern.search(instance): 122 | yield jsonschema.ValidationError(f"{instance!r} does not match {pattern!r}") 123 | 124 | def patternProperties_keyword( 125 | self, 126 | validator: t.Any, 127 | patternProperties: dict[str, t.Any], 128 | instance: dict[str, t.Any], 129 | schema: t.Any, 130 | ) -> t.Iterator[jsonschema.ValidationError]: 131 | if not validator.is_type(instance, "object"): 132 | return 133 | 134 | for pattern, subschema in patternProperties.items(): 135 | for k, v in instance.items(): 136 | if re.search(pattern, k): 137 | yield from validator.descend( 138 | v, 139 | subschema, 140 | path=k, 141 | schema_path=pattern, 142 | ) 143 | -------------------------------------------------------------------------------- /src/check_jsonschema/result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | 5 | import jsonschema 6 | 7 | from .parsers import ParseError 8 | 9 | 10 | class CheckResult: 11 | def __init__(self) -> None: 12 | self.validation_errors: dict[str, list[jsonschema.ValidationError]] = {} 13 | self.parse_errors: dict[str, list[ParseError]] = {} 14 | self.successes: list[str] = [] 15 | 16 | @property 17 | def success(self) -> bool: 18 | return not (bool(self.parse_errors) or bool(self.validation_errors)) 19 | 20 | def record_validation_success(self, path: pathlib.Path | str) -> None: 21 | self.successes.append(str(path)) 22 | 23 | def record_validation_error( 24 | self, path: pathlib.Path | str, err: jsonschema.ValidationError 25 | ) -> None: 26 | filename = str(path) 27 | if filename not in self.validation_errors: 28 | self.validation_errors[filename] = [] 29 | self.validation_errors[filename].append(err) 30 | 31 | def record_parse_error(self, path: pathlib.Path | str, err: ParseError) -> None: 32 | filename = str(path) 33 | if filename not in self.parse_errors: 34 | self.parse_errors[filename] = [] 35 | self.parse_errors[filename].append(err) 36 | -------------------------------------------------------------------------------- /src/check_jsonschema/schema_loader/__init__.py: -------------------------------------------------------------------------------- 1 | from .errors import SchemaParseError, UnsupportedUrlScheme 2 | from .main import BuiltinSchemaLoader, MetaSchemaLoader, SchemaLoader, SchemaLoaderBase 3 | 4 | __all__ = ( 5 | "SchemaParseError", 6 | "UnsupportedUrlScheme", 7 | "BuiltinSchemaLoader", 8 | "MetaSchemaLoader", 9 | "SchemaLoader", 10 | "SchemaLoaderBase", 11 | ) 12 | -------------------------------------------------------------------------------- /src/check_jsonschema/schema_loader/errors.py: -------------------------------------------------------------------------------- 1 | class SchemaParseError(ValueError): 2 | pass 3 | 4 | 5 | class UnsupportedUrlScheme(ValueError): 6 | pass 7 | -------------------------------------------------------------------------------- /src/check_jsonschema/schema_loader/readers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import json 5 | import sys 6 | import typing as t 7 | 8 | import ruamel.yaml 9 | 10 | from ..cachedownloader import CacheDownloader 11 | from ..parsers import ParseError, ParserSet 12 | from ..utils import filename2path 13 | from .errors import SchemaParseError 14 | 15 | yaml = ruamel.yaml.YAML(typ="safe") 16 | 17 | 18 | class _UnsetType: 19 | pass 20 | 21 | 22 | _UNSET = _UnsetType() 23 | 24 | 25 | def _run_load_callback(schema_location: str, callback: t.Callable) -> dict: 26 | try: 27 | schema = callback() 28 | # only local loads can raise the YAMLError, but catch for both cases for simplicity 29 | except (ValueError, ruamel.yaml.error.YAMLError) as e: 30 | raise SchemaParseError(schema_location) from e 31 | if not isinstance(schema, dict): 32 | raise SchemaParseError(schema_location) 33 | return schema 34 | 35 | 36 | class LocalSchemaReader: 37 | def __init__(self, filename: str) -> None: 38 | self.path = filename2path(filename) 39 | self.filename = str(self.path) 40 | self.parsers = ParserSet() 41 | self._parsed_schema: dict | _UnsetType = _UNSET 42 | 43 | def get_retrieval_uri(self) -> str | None: 44 | return self.path.as_uri() 45 | 46 | def _read_impl(self) -> t.Any: 47 | return self.parsers.parse_file(self.path, default_filetype="json") 48 | 49 | def read_schema(self) -> dict: 50 | if self._parsed_schema is _UNSET: 51 | self._parsed_schema = _run_load_callback(self.filename, self._read_impl) 52 | return t.cast(dict, self._parsed_schema) 53 | 54 | 55 | class StdinSchemaReader: 56 | def __init__(self) -> None: 57 | self.parsers = ParserSet() 58 | self._parsed_schema: dict | _UnsetType = _UNSET 59 | 60 | def get_retrieval_uri(self) -> str | None: 61 | return None 62 | 63 | def read_schema(self) -> dict: 64 | if self._parsed_schema is _UNSET: 65 | try: 66 | self._parsed_schema = json.load(sys.stdin) 67 | except ValueError as e: 68 | raise ParseError("Failed to parse JSON from stdin") from e 69 | return t.cast(dict, self._parsed_schema) 70 | 71 | 72 | class HttpSchemaReader: 73 | def __init__( 74 | self, 75 | url: str, 76 | disable_cache: bool, 77 | ) -> None: 78 | self.url = url 79 | self.parsers = ParserSet() 80 | self.downloader = CacheDownloader("schemas", disable_cache=disable_cache).bind( 81 | url, validation_callback=self._parse 82 | ) 83 | self._parsed_schema: dict | _UnsetType = _UNSET 84 | 85 | def _parse(self, schema_bytes: bytes) -> t.Any: 86 | return self.parsers.parse_data_with_path( 87 | io.BytesIO(schema_bytes), self.url, default_filetype="json" 88 | ) 89 | 90 | def get_retrieval_uri(self) -> str | None: 91 | return self.url 92 | 93 | def _read_impl(self) -> t.Any: 94 | with self.downloader.open() as fp: 95 | return self._parse(fp.read()) 96 | 97 | def read_schema(self) -> dict: 98 | if self._parsed_schema is _UNSET: 99 | self._parsed_schema = _run_load_callback(self.url, self._read_impl) 100 | return t.cast(dict, self._parsed_schema) 101 | -------------------------------------------------------------------------------- /src/check_jsonschema/schema_loader/resolver.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | import urllib.parse 5 | 6 | import referencing 7 | from referencing.jsonschema import DRAFT202012, Schema 8 | 9 | from ..cachedownloader import CacheDownloader 10 | from ..parsers import ParserSet 11 | from ..utils import filename2path 12 | 13 | 14 | def make_reference_registry( 15 | parsers: ParserSet, retrieval_uri: str | None, schema: dict, disable_cache: bool 16 | ) -> referencing.Registry: 17 | id_attribute_: t.Any = schema.get("$id") 18 | if isinstance(id_attribute_, str): 19 | id_attribute: str | None = id_attribute_ 20 | else: 21 | id_attribute = None 22 | 23 | schema_resource = referencing.Resource.from_contents( 24 | schema, default_specification=DRAFT202012 25 | ) 26 | # mypy does not recognize that Registry is an `attrs` class and has `retrieve` as an 27 | # argument to its implicit initializer 28 | registry: referencing.Registry = referencing.Registry( # type: ignore[call-arg] 29 | retrieve=create_retrieve_callable( 30 | parsers, retrieval_uri, id_attribute, disable_cache 31 | ) 32 | ) 33 | 34 | if retrieval_uri is not None: 35 | registry = registry.with_resource(uri=retrieval_uri, resource=schema_resource) 36 | if id_attribute is not None: 37 | registry = registry.with_resource(uri=id_attribute, resource=schema_resource) 38 | 39 | return registry 40 | 41 | 42 | def create_retrieve_callable( 43 | parser_set: ParserSet, 44 | retrieval_uri: str | None, 45 | id_attribute: str | None, 46 | disable_cache: bool, 47 | ) -> t.Callable[[str], referencing.Resource[Schema]]: 48 | base_uri = id_attribute 49 | if base_uri is None: 50 | base_uri = retrieval_uri 51 | 52 | cache = ResourceCache() 53 | downloader = CacheDownloader("refs", disable_cache=disable_cache) 54 | 55 | def get_local_file(uri: str) -> t.Any: 56 | path = filename2path(uri) 57 | return parser_set.parse_file(path, "json") 58 | 59 | def retrieve_reference(uri: str) -> referencing.Resource[Schema]: 60 | scheme = urllib.parse.urlsplit(uri).scheme 61 | if scheme == "" and base_uri is not None: 62 | full_uri = urllib.parse.urljoin(base_uri, uri) 63 | else: 64 | full_uri = uri 65 | 66 | if full_uri in cache: 67 | return cache[full_uri] 68 | 69 | full_uri_scheme = urllib.parse.urlsplit(full_uri).scheme 70 | if full_uri_scheme in ("http", "https"): 71 | 72 | def validation_callback(content: bytes) -> None: 73 | parser_set.parse_data_with_path(content, full_uri, "json") 74 | 75 | bound_downloader = downloader.bind( 76 | full_uri, validation_callback=validation_callback 77 | ) 78 | with bound_downloader.open() as fp: 79 | data = fp.read() 80 | 81 | parsed_object = parser_set.parse_data_with_path(data, full_uri, "json") 82 | else: 83 | parsed_object = get_local_file(full_uri) 84 | 85 | cache[full_uri] = parsed_object 86 | return cache[full_uri] 87 | 88 | return retrieve_reference 89 | 90 | 91 | class ResourceCache: 92 | def __init__(self) -> None: 93 | self._cache: t.Dict[str, referencing.Resource[Schema]] = {} 94 | 95 | def __setitem__(self, uri: str, data: t.Any) -> referencing.Resource[Schema]: 96 | resource = referencing.Resource.from_contents( 97 | data, default_specification=DRAFT202012 98 | ) 99 | self._cache[uri] = resource 100 | return resource 101 | 102 | def __getitem__(self, uri: str) -> referencing.Resource[Schema]: 103 | return self._cache[uri] 104 | 105 | def __contains__(self, uri: str) -> bool: 106 | return uri in self._cache 107 | -------------------------------------------------------------------------------- /src/check_jsonschema/transforms/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .azure_pipelines import AZURE_TRANSFORM 4 | from .base import Transform 5 | from .gitlab import GITLAB_TRANSFORM 6 | 7 | TRANSFORM_LIBRARY: dict[str, Transform] = { 8 | "azure-pipelines": AZURE_TRANSFORM, 9 | "gitlab-ci": GITLAB_TRANSFORM, 10 | } 11 | 12 | __all__ = ("TRANSFORM_LIBRARY",) 13 | -------------------------------------------------------------------------------- /src/check_jsonschema/transforms/azure_pipelines.py: -------------------------------------------------------------------------------- 1 | """ 2 | A data transform which unpacks "compile-time expressions" from Azure Pipelines files. 3 | 4 | For the original source which inspired this transform, see the YAML parser used in the 5 | Azure Pipelines Language Server: 6 | https://github.com/microsoft/azure-pipelines-language-server/blob/71b20f92874c02dfe82ad2cc2dcc7fa64996be91/language-service/src/parser/yamlParser.ts#L182 7 | 8 | That source is licensed under the MIT License. 9 | The original license can be found in 10 | src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.azure-pipelines 11 | 12 | 13 | The transform does not deeply interpret the expressions. It just "unnests" them. 14 | 15 | It will turn this input 16 | 17 | jobs: 18 | - ${{ each val in parameter.vals }}: 19 | - job: foo 20 | steps: 21 | - bash: echo ${{ val }} 22 | 23 | into 24 | 25 | jobs: 26 | - job: foo 27 | steps: 28 | - bash: echo ${{ val }} 29 | """ 30 | 31 | from __future__ import annotations 32 | 33 | import typing as t 34 | 35 | from .base import Transform 36 | 37 | 38 | class AzurePipelinesDataError(ValueError): 39 | def __init__(self, message: str) -> None: 40 | super().__init__(f"azure-pipelines transform: {message}") 41 | 42 | 43 | def is_expression(s: str) -> bool: 44 | return s.startswith("${{") and s.endswith("}}") 45 | 46 | 47 | def traverse_data(data: t.Any) -> t.Any: 48 | if isinstance(data, dict): 49 | return traverse_dict(data) 50 | if isinstance(data, list): 51 | return traverse_list(data) 52 | return data 53 | 54 | 55 | def traverse_list(data: list) -> list: 56 | ret = [] 57 | for item in data: 58 | # is the current item a single-value dict with an expression as its key? 59 | item_is_expr = ( 60 | isinstance(item, dict) 61 | and len(item) == 1 62 | and is_expression(tuple(item)[0]) # tuple() gets keys 63 | ) 64 | 65 | if item_is_expr: 66 | # unpack the expression item and recurse over the value 67 | item_key, item_value = list(item.items())[0] 68 | item_value = traverse_data(item_value) 69 | 70 | if isinstance(item_value, list): 71 | ret.extend(item_value) 72 | else: 73 | ret.append(item_value) 74 | # not expression? process the item and append 75 | else: 76 | ret.append(traverse_data(item)) 77 | return ret 78 | 79 | 80 | def traverse_dict(data: dict) -> dict: 81 | newdata = {} 82 | for key, value in data.items(): 83 | newvalue = traverse_data(value) 84 | if is_expression(key): 85 | # WARNING -- correctness unclear 86 | # 87 | # "lift" any dict by moving its attributes up into the object being evaluated 88 | # 89 | # e.g. 90 | # parent: 91 | # ${{ each x in xs }}: 92 | # - k: v-${{ x }} 93 | # 94 | # becomes 95 | # 96 | # parent: 97 | # - k: v-${{ x }} 98 | if isinstance(newvalue, dict): 99 | for add_k, add_v in newvalue.items(): 100 | newdata[add_k] = add_v 101 | # In all other cases, drop the content from the data. This is based on the 102 | # azure-pipelines-language server behavior: 103 | # https://github.com/microsoft/azure-pipelines-language-server/blob/71b20f92874c02dfe82ad2cc2dcc7fa64996be91/language-service/src/parser/yamlParser.ts#L185 104 | # 105 | # earlier versions would raise an error here, but this caused issues with 106 | # data in which expressions were mapped to simple strings 107 | # 108 | # e.g. 109 | # 110 | # parent: 111 | # ${{ x }}: ${{ y }} 112 | # 113 | # which occurs naturally *after* a lifting operation, as in 114 | # 115 | # parent: 116 | # ${{ each x, y in attrs }}: 117 | # ${{ x }}: ${{ y }} 118 | else: 119 | continue 120 | else: 121 | newdata[key] = newvalue 122 | return newdata 123 | 124 | 125 | def azure_main(data: dict | list) -> dict | list: 126 | if isinstance(data, list): 127 | raise AzurePipelinesDataError( 128 | "this transform requires that the data be an object, got list" 129 | ) 130 | return traverse_dict(data) 131 | 132 | 133 | AZURE_TRANSFORM = Transform(on_data=azure_main) 134 | -------------------------------------------------------------------------------- /src/check_jsonschema/transforms/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import ruamel.yaml 6 | 7 | 8 | class Transform: 9 | def __init__( 10 | self, 11 | *, 12 | on_data: t.Callable[[list | dict], list | dict] | None = None, 13 | ) -> None: 14 | self.on_data = on_data 15 | 16 | def modify_yaml_implementation(self, implementation: ruamel.yaml.YAML) -> None: 17 | pass 18 | 19 | def __call__(self, data: list | dict) -> list | dict: 20 | if self.on_data is not None: 21 | return self.on_data(data) 22 | return data 23 | -------------------------------------------------------------------------------- /src/check_jsonschema/transforms/gitlab.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import ruamel.yaml 6 | 7 | from .base import Transform 8 | 9 | 10 | class GitLabReferenceExpectationViolation(ValueError): 11 | def __init__(self, msg: str, data: t.Any) -> None: 12 | super().__init__( 13 | f"check-jsonschema rejects this gitlab !reference tag: {msg}\n{data!r}" 14 | ) 15 | 16 | 17 | class GitLabReference: 18 | yaml_tag = "!reference" 19 | 20 | @classmethod 21 | def from_yaml( 22 | cls, constructor: ruamel.yaml.BaseConstructor, node: ruamel.yaml.Node 23 | ) -> list[str]: 24 | if not isinstance(node.value, list): 25 | raise GitLabReferenceExpectationViolation("non-list value", node) 26 | return [item.value for item in node.value] 27 | 28 | 29 | # this "transform" is actually a no-op on the data, but it registers the GitLab !reference 30 | # tag with the instance YAML loader 31 | class GitLabDataTransform(Transform): 32 | def modify_yaml_implementation(self, implementation: ruamel.yaml.YAML) -> None: 33 | implementation.register_class(GitLabReference) 34 | 35 | 36 | GITLAB_TRANSFORM = GitLabDataTransform() 37 | -------------------------------------------------------------------------------- /src/check_jsonschema/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import linecache 4 | import os 5 | import pathlib 6 | import re 7 | import textwrap 8 | import traceback 9 | import typing as t 10 | import urllib.parse 11 | import urllib.request 12 | 13 | import click 14 | import jsonschema 15 | 16 | WINDOWS = os.name == "nt" 17 | 18 | PROC_FD_PATH_PATTERN = re.compile(r"/proc/(self|\d+)/fd/\d+") 19 | 20 | # this is a short list of schemes which will be recognized as being 21 | # schemes at all; anything else will not even be reported as an 22 | # unsupported scheme 23 | KNOWN_URL_SCHEMES = [ 24 | "", 25 | "ftp", 26 | "gopher", 27 | "http", 28 | "file", 29 | "https", 30 | "shttp", 31 | "rsync", 32 | "svn", 33 | "svn+ssh", 34 | "sftp", 35 | "nfs", 36 | "git", 37 | "git+ssh", 38 | "ws", 39 | "wss", 40 | ] 41 | 42 | 43 | def is_url_ish(path: str) -> bool: 44 | r""" 45 | Returns true if the input path looks like a URL. 46 | 47 | NB: This needs to be done carefully to avoid mishandling of Windows paths 48 | starting with 'C:\' (and so forth) as URLs. urlparse from urllib will treat 49 | 'C' as a scheme if asked to parse a Windows path. 50 | """ 51 | if ":" not in path: 52 | return False 53 | scheme = path.split(":", 1)[0].lower() 54 | return scheme in KNOWN_URL_SCHEMES 55 | 56 | 57 | def filename2path(filename: str) -> pathlib.Path: 58 | """ 59 | Convert a filename which may be a local file URI to a pathlib.Path object 60 | 61 | This implementation was influenced strongly by how pip handles this problem: 62 | https://github.com/pypa/pip/blob/bf91a079791f2daf4339115fb39ce7d7e33a9312/src/pip/_internal/utils/urls.py#L26 63 | """ 64 | if not filename.startswith("file://"): 65 | # for local paths, support use of `~` 66 | p = pathlib.Path(filename).expanduser() 67 | else: 68 | urlinfo = urllib.parse.urlsplit(filename) 69 | # local (vs UNC paths) 70 | is_local_path = urlinfo.netloc in (None, "", "localhost") 71 | 72 | if is_local_path: 73 | netloc = "" 74 | elif WINDOWS: 75 | netloc = "\\\\" + urlinfo.netloc 76 | else: 77 | netloc = urlinfo.netloc 78 | 79 | filename = urllib.request.url2pathname(netloc + urlinfo.path) 80 | 81 | # url2pathname on windows local paths can produce paths like 82 | # /C:/Users/foo/... 83 | # the leading slash messes up a lot of logic for pathlib and similar functions 84 | # so strip the leading slash in this case 85 | if WINDOWS and is_local_path and filename.startswith("/"): 86 | filename = filename[1:] 87 | 88 | p = pathlib.Path(filename) 89 | 90 | # if passed a file descriptor object, do not try to resolve it 91 | # the resolution behavior when using zsh `<()` redirection seems to result in 92 | # an incorrect path being used 93 | if PROC_FD_PATH_PATTERN.fullmatch(filename): 94 | return p 95 | return p.resolve() 96 | 97 | 98 | def format_shortened_error(err: Exception, *, indent: int = 0) -> str: 99 | lines = [] 100 | lines.append(textwrap.indent(f"{type(err).__name__}: {err}", indent * " ")) 101 | if err.__traceback__ is not None: 102 | lineno = err.__traceback__.tb_lineno 103 | tb_frame = err.__traceback__.tb_frame 104 | filename = tb_frame.f_code.co_filename 105 | line = linecache.getline(filename, lineno) 106 | lines.append((indent + 2) * " " + f'in "{filename}", line {lineno}') 107 | lines.append((indent + 2) * " " + ">>> " + line.strip()) 108 | return "\n".join(lines) 109 | 110 | 111 | def format_shortened_trace(caught_err: Exception) -> str: 112 | err_stack: list[Exception] = [caught_err] 113 | while err_stack[-1].__context__ is not None: 114 | err_stack.append(err_stack[-1].__context__) # type: ignore[arg-type] 115 | 116 | parts = [format_shortened_error(caught_err)] 117 | indent = 0 118 | for err in err_stack[1:]: 119 | indent += 2 120 | parts.append("\n" + indent * " " + "caused by\n") 121 | parts.append(format_shortened_error(err, indent=indent)) 122 | return "\n".join(parts) 123 | 124 | 125 | def format_error(err: Exception, mode: str = "short") -> str: 126 | if mode == "short": 127 | return format_shortened_trace(err) 128 | else: 129 | return "".join(traceback.format_exception(type(err), err, err.__traceback__)) 130 | 131 | 132 | def print_error(err: Exception, mode: str = "short") -> None: 133 | click.echo(format_error(err, mode=mode), err=True) 134 | 135 | 136 | def iter_validation_error( 137 | err: jsonschema.ValidationError, 138 | ) -> t.Iterator[jsonschema.ValidationError]: 139 | if err.context: 140 | for e in err.context: 141 | yield e 142 | yield from iter_validation_error(e) 143 | -------------------------------------------------------------------------------- /tests/acceptance/conftest.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import pytest 4 | 5 | from check_jsonschema import main as cli_main 6 | 7 | 8 | def _render_result(result): 9 | return f""" 10 | output: 11 | {textwrap.indent(result.output, " ")} 12 | 13 | stderr: 14 | {textwrap.indent(result.stderr, " ")} 15 | """ 16 | 17 | 18 | @pytest.fixture 19 | def run_line(cli_runner): 20 | def func(cli_args, *args, **kwargs): 21 | assert cli_args[0] == "check-jsonschema" 22 | if "catch_exceptions" not in kwargs: 23 | kwargs["catch_exceptions"] = False 24 | return cli_runner.invoke(cli_main, cli_args[1:], *args, **kwargs) 25 | 26 | return func 27 | 28 | 29 | @pytest.fixture 30 | def run_line_simple(run_line): 31 | def func(cli_args, *args, full_traceback: bool = True, **kwargs): 32 | res = run_line( 33 | ["check-jsonschema"] 34 | + (["--traceback-mode", "full"] if full_traceback else []) 35 | + cli_args, 36 | *args, 37 | **kwargs, 38 | ) 39 | assert res.exit_code == 0, _render_result(res) 40 | 41 | return func 42 | -------------------------------------------------------------------------------- /tests/acceptance/custom_schemas/test_github_workflow_require_explicit_timeout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | PASSING_WORKFLOW = """\ 4 | name: build 5 | on: 6 | push: 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | - name: install requirements 15 | run: python -m pip install tox 16 | - name: test 17 | run: python -m tox -e py 18 | """ 19 | FAILING_WORKFLOW = """\ 20 | name: build 21 | on: 22 | push: 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-python@v2 29 | - name: install requirements 30 | run: python -m pip install tox 31 | - name: test 32 | run: python -m tox -e py 33 | """ 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "schemaname", 38 | ["github-workflows-require-timeout", "custom.github-workflows-require-timeout"], 39 | ) 40 | @pytest.mark.parametrize( 41 | "vendor_schemaname", 42 | ["github-workflows", "vendor.github-workflows"], 43 | ) 44 | def test_github_require_timeouts_passing( 45 | run_line_simple, tmp_path, schemaname, vendor_schemaname 46 | ): 47 | workflow = tmp_path / "doc.yaml" 48 | workflow.write_text(PASSING_WORKFLOW) 49 | 50 | # vendored github workflow schema passes on it 51 | run_line_simple(["--builtin-schema", vendor_schemaname, str(workflow)]) 52 | 53 | run_line_simple(["--builtin-schema", schemaname, str(workflow)]) 54 | 55 | 56 | @pytest.mark.parametrize( 57 | "schemaname", 58 | ["github-workflows-require-timeout", "custom.github-workflows-require-timeout"], 59 | ) 60 | @pytest.mark.parametrize( 61 | "vendor_schemaname", 62 | ["github-workflows", "vendor.github-workflows"], 63 | ) 64 | def test_github_require_timeouts_failing( 65 | run_line, tmp_path, schemaname, vendor_schemaname 66 | ): 67 | workflow = tmp_path / "doc.yaml" 68 | workflow.write_text(FAILING_WORKFLOW) 69 | 70 | # vendored github workflow schema passes on it 71 | res1 = run_line( 72 | ["check-jsonschema", "--builtin-schema", vendor_schemaname, str(workflow)] 73 | ) 74 | assert res1.exit_code == 0 75 | 76 | res2 = run_line( 77 | ["check-jsonschema", "--builtin-schema", schemaname, str(workflow)], 78 | ) 79 | assert res2.exit_code == 1 80 | -------------------------------------------------------------------------------- /tests/acceptance/test_fill_defaults.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | SCHEMA = { 4 | "$schema": "http://json-schema.org/draft-07/schema", 5 | "properties": { 6 | "title": { 7 | "type": "string", 8 | "default": "Untitled", 9 | }, 10 | }, 11 | "required": ["title"], 12 | } 13 | 14 | VALID_DOC = { 15 | "title": "doc one", 16 | } 17 | 18 | INVALID_DOC = {"title": {"foo": "bar"}} 19 | 20 | MISSING_FIELD_DOC = {} 21 | 22 | 23 | def test_run_with_fill_defaults_does_not_make_valid_doc_invalid( 24 | run_line_simple, tmp_path 25 | ): 26 | schemafile = tmp_path / "schema.json" 27 | schemafile.write_text(json.dumps(SCHEMA)) 28 | 29 | doc = tmp_path / "instance.json" 30 | doc.write_text(json.dumps(VALID_DOC)) 31 | 32 | run_line_simple(["--fill-defaults", "--schemafile", str(schemafile), str(doc)]) 33 | 34 | 35 | def test_run_with_fill_defaults_does_not_make_invalid_doc_valid(run_line, tmp_path): 36 | schemafile = tmp_path / "schema.json" 37 | schemafile.write_text(json.dumps(SCHEMA)) 38 | 39 | doc = tmp_path / "instance.json" 40 | doc.write_text(json.dumps(INVALID_DOC)) 41 | 42 | res = run_line( 43 | [ 44 | "check-jsonschema", 45 | "--fill-defaults", 46 | "--schemafile", 47 | str(schemafile), 48 | str(doc), 49 | ] 50 | ) 51 | assert res.exit_code == 1 52 | 53 | 54 | def test_run_with_fill_defaults_adds_required_field(run_line, tmp_path): 55 | schemafile = tmp_path / "schema.json" 56 | schemafile.write_text(json.dumps(SCHEMA)) 57 | 58 | doc = tmp_path / "instance.json" 59 | doc.write_text(json.dumps(MISSING_FIELD_DOC)) 60 | 61 | # step 1: run without '--fill-defaults' and confirm failure 62 | result_without_fill_defaults = run_line( 63 | [ 64 | "check-jsonschema", 65 | "--schemafile", 66 | str(schemafile), 67 | str(doc), 68 | ] 69 | ) 70 | assert result_without_fill_defaults.exit_code == 1 71 | 72 | # step 2: run with '--fill-defaults' and confirm success 73 | result_with_fill_defaults = run_line( 74 | [ 75 | "check-jsonschema", 76 | "--fill-defaults", 77 | "--schemafile", 78 | str(schemafile), 79 | str(doc), 80 | ] 81 | ) 82 | assert result_with_fill_defaults.exit_code == 0 83 | -------------------------------------------------------------------------------- /tests/acceptance/test_format_failure.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | FORMAT_SCHEMA = { 4 | "$schema": "http://json-schema.org/draft-07/schema", 5 | "properties": { 6 | "title": { 7 | "type": "string", 8 | }, 9 | "date": { 10 | "type": "string", 11 | "format": "date", 12 | }, 13 | }, 14 | } 15 | 16 | PASSING_DOCUMENT = { 17 | "title": "doc one", 18 | "date": "2021-10-28", 19 | } 20 | 21 | FAILING_DOCUMENT = { 22 | "title": "doc one", 23 | "date": "foo", 24 | } 25 | 26 | 27 | def test_format_check_passing(run_line_simple, tmp_path): 28 | schemafile = tmp_path / "schema.json" 29 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 30 | 31 | doc1 = tmp_path / "doc1.json" 32 | doc1.write_text(json.dumps(PASSING_DOCUMENT)) 33 | 34 | run_line_simple(["--schemafile", str(schemafile), str(doc1)]) 35 | 36 | 37 | def test_format_failure_exit_error(run_line, tmp_path): 38 | schemafile = tmp_path / "schema.json" 39 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 40 | 41 | doc1 = tmp_path / "doc1.json" 42 | doc1.write_text(json.dumps(FAILING_DOCUMENT)) 43 | 44 | res = run_line(["check-jsonschema", "--schemafile", str(schemafile), str(doc1)]) 45 | assert res.exit_code == 1 46 | 47 | 48 | def test_format_failure_ignore(run_line_simple, tmp_path): 49 | schemafile = tmp_path / "schema.json" 50 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 51 | 52 | doc1 = tmp_path / "doc1.json" 53 | doc1.write_text(json.dumps(FAILING_DOCUMENT)) 54 | 55 | run_line_simple( 56 | [ 57 | "--disable-formats", 58 | "*", 59 | "--schemafile", 60 | str(schemafile), 61 | str(doc1), 62 | ] 63 | ) 64 | 65 | 66 | def test_format_failure_ignore_multidoc(run_line_simple, tmp_path): 67 | schemafile = tmp_path / "schema.json" 68 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 69 | 70 | doc1 = tmp_path / "doc1.json" 71 | doc1.write_text(json.dumps(FAILING_DOCUMENT)) 72 | 73 | doc2 = tmp_path / "doc2.json" 74 | doc2.write_text(json.dumps(PASSING_DOCUMENT)) 75 | 76 | run_line_simple( 77 | [ 78 | "--disable-formats", 79 | "*", 80 | "--schemafile", 81 | str(schemafile), 82 | str(doc1), 83 | str(doc2), 84 | ] 85 | ) 86 | -------------------------------------------------------------------------------- /tests/acceptance/test_format_regex_opts.py: -------------------------------------------------------------------------------- 1 | # test on a JavaScript regex which is not a valid python regex 2 | # `--regex-variant=default` should accept it 3 | # `--regex-variant=python` should reject it 4 | # 5 | # check these options against documents with invalid and valid python regexes to confirm 6 | # that they are behaving as expected 7 | import json 8 | 9 | import pytest 10 | 11 | FORMAT_SCHEMA = { 12 | "$schema": "http://json-schema.org/draft-07/schema", 13 | "properties": {"pattern": {"type": "string", "format": "regex"}}, 14 | } 15 | 16 | ALWAYS_PASSING_DOCUMENT = { 17 | "pattern": "ab*c", 18 | } 19 | 20 | ALWAYS_FAILING_DOCUMENT = { 21 | "pattern": "a(b*c", 22 | } 23 | 24 | JS_REGEX_DOCUMENT = { 25 | "pattern": "a(?)bc", 26 | } 27 | 28 | # taken from https://github.com/python-jsonschema/check-jsonschema/issues/25 29 | RENOVATE_DOCUMENT = { 30 | "regexManagers": [ 31 | { 32 | "fileMatch": ["^Dockerfile$"], 33 | "matchStrings": ["ENV YARN_VERSION=(?.*?)\n"], 34 | "depNameTemplate": "yarn", 35 | "datasourceTemplate": "npm", 36 | } 37 | ] 38 | } 39 | 40 | 41 | @pytest.fixture( 42 | params=[ 43 | ("--disable-formats", "regex"), 44 | ("--format-regex", "default"), 45 | ("--format-regex", "python"), 46 | ("--regex-variant", "python"), 47 | ("--regex-variant", "default"), 48 | ("--regex-variant", "default", "--format-regex", "python"), 49 | ("--regex-variant", "python", "--format-regex", "default"), 50 | ] 51 | ) 52 | def regexopts(request): 53 | return request.param 54 | 55 | 56 | def test_regex_format_good(run_line_simple, tmp_path, regexopts): 57 | schemafile = tmp_path / "schema.json" 58 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 59 | 60 | doc = tmp_path / "doc.json" 61 | doc.write_text(json.dumps(ALWAYS_PASSING_DOCUMENT)) 62 | 63 | run_line_simple([*regexopts, "--schemafile", str(schemafile), str(doc)]) 64 | 65 | 66 | def test_regex_format_accepts_non_str_inputs(run_line_simple, tmp_path, regexopts): 67 | # potentially confusing, but a format checker is allowed to check non-str instances 68 | # validate the format checker behavior on such a case 69 | schemafile = tmp_path / "schema.json" 70 | schemafile.write_text( 71 | json.dumps( 72 | { 73 | "$schema": "http://json-schema.org/draft-07/schema", 74 | "properties": {"pattern": {"type": "integer", "format": "regex"}}, 75 | } 76 | ) 77 | ) 78 | doc = tmp_path / "doc.json" 79 | doc.write_text(json.dumps({"pattern": 0})) 80 | run_line_simple([*regexopts, "--schemafile", str(schemafile), str(doc)]) 81 | 82 | 83 | def test_regex_format_bad(run_line, tmp_path, regexopts): 84 | schemafile = tmp_path / "schema.json" 85 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 86 | 87 | doc = tmp_path / "doc.json" 88 | doc.write_text(json.dumps(ALWAYS_FAILING_DOCUMENT)) 89 | 90 | expect_ok = regexopts == ("--disable-formats", "regex") 91 | 92 | res = run_line( 93 | [ 94 | "check-jsonschema", 95 | *regexopts, 96 | "--schemafile", 97 | str(schemafile), 98 | str(doc), 99 | ], 100 | ) 101 | if expect_ok: 102 | assert res.exit_code == 0 103 | else: 104 | assert res.exit_code == 1 105 | assert "is not a 'regex'" in res.stdout 106 | 107 | 108 | def test_regex_format_js_specific(run_line, tmp_path, regexopts): 109 | schemafile = tmp_path / "schema.json" 110 | schemafile.write_text(json.dumps(FORMAT_SCHEMA)) 111 | 112 | doc = tmp_path / "doc.json" 113 | doc.write_text(json.dumps(JS_REGEX_DOCUMENT)) 114 | 115 | expect_ok = regexopts[:2] not in ( 116 | ("--format-regex", "python"), 117 | ("--regex-variant", "python"), 118 | ) 119 | 120 | res = run_line( 121 | [ 122 | "check-jsonschema", 123 | *regexopts, 124 | "--schemafile", 125 | str(schemafile), 126 | str(doc), 127 | ], 128 | ) 129 | if expect_ok: 130 | assert res.exit_code == 0 131 | else: 132 | assert res.exit_code == 1 133 | assert "is not a 'regex'" in res.stdout 134 | 135 | 136 | def test_regex_format_in_renovate_config(run_line_simple, tmp_path): 137 | doc = tmp_path / "doc.json" 138 | doc.write_text(json.dumps(RENOVATE_DOCUMENT)) 139 | 140 | run_line_simple(["--builtin-schema", "vendor.renovate", str(doc)]) 141 | -------------------------------------------------------------------------------- /tests/acceptance/test_gitlab_reference_handling.py: -------------------------------------------------------------------------------- 1 | def test_gitlab_reference_handling_on_bad_data(run_line, tmp_path): 2 | doc = tmp_path / "data.yml" 3 | doc.write_text( 4 | """\ 5 | include: 6 | - local: setup.yml 7 | 8 | test: 9 | script: 10 | # !reference not a list, error 11 | - !reference .setup 12 | - echo running my own command 13 | """ 14 | ) 15 | 16 | res = run_line( 17 | [ 18 | "check-jsonschema", 19 | "--builtin-schema", 20 | "gitlab-ci", 21 | "--data-transform", 22 | "gitlab-ci", 23 | str(doc), 24 | ], 25 | catch_exceptions=True, 26 | ) 27 | assert res.exit_code == 1 28 | -------------------------------------------------------------------------------- /tests/acceptance/test_invalid_schema_files.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_checker_non_json_schemafile(run_line, tmp_path): 5 | foo = tmp_path / "foo.json" 6 | bar = tmp_path / "bar.json" 7 | foo.write_text("{") 8 | bar.write_text("{}") 9 | 10 | res = run_line(["check-jsonschema", "--schemafile", str(foo), str(bar)]) 11 | assert res.exit_code == 1 12 | assert "schemafile could not be parsed" in res.stderr 13 | 14 | 15 | def test_checker_invalid_schemafile(run_line, tmp_path): 16 | foo = tmp_path / "foo.json" 17 | bar = tmp_path / "bar.json" 18 | foo.write_text('{"title": {"foo": "bar"}}') 19 | bar.write_text("{}") 20 | 21 | res = run_line(["check-jsonschema", "--schemafile", str(foo), str(bar)]) 22 | assert res.exit_code == 1 23 | assert "schemafile was not valid" in res.stderr 24 | 25 | 26 | def test_checker_invalid_schemafile_scheme(run_line, tmp_path): 27 | foo = tmp_path / "foo.json" 28 | bar = tmp_path / "bar.json" 29 | foo.write_text('{"title": "foo"}') 30 | bar.write_text("{}") 31 | 32 | res = run_line(["check-jsonschema", "--schemafile", f"ftp://{foo}", str(bar)]) 33 | assert res.exit_code == 1 34 | assert "only supports http, https" in res.stderr 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "add_args", 39 | [ 40 | pytest.param([], id="noargs"), 41 | # ensure that this works even when regex checking is disabled 42 | pytest.param(["--disable-formats", "*"], id="all-formats-disabled"), 43 | pytest.param(["--disable-formats", "regex"], id="regex-format-disabled"), 44 | ], 45 | ) 46 | def test_checker_invalid_schemafile_due_to_bad_regex(run_line, tmp_path, add_args): 47 | foo = tmp_path / "foo.json" 48 | bar = tmp_path / "bar.json" 49 | # too many backslash escapes -- not a valid Unicode-mode regex 50 | foo.write_text(r'{"properties": {"foo": {"pattern": "\\\\p{N}"}}}') 51 | bar.write_text("{}") 52 | 53 | res = run_line(["check-jsonschema", "--schemafile", str(foo), str(bar), *add_args]) 54 | assert res.exit_code == 1 55 | assert "schemafile was not valid" in res.stderr 56 | -------------------------------------------------------------------------------- /tests/acceptance/test_local_relative_ref.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | CASE1_MAIN_SCHEMA = { 6 | "$schema": "http://json-schema.org/draft-07/schema", 7 | "properties": { 8 | "title": {"$ref": "./title_schema.json"}, 9 | }, 10 | "additionalProperties": False, 11 | } 12 | CASE1_TITLE_SCHEMA = { 13 | "type": "string", 14 | } 15 | CASE1_PASSING_DOCUMENT = {"title": "doc one"} 16 | CASE1_FAILING_DOCUMENT = {"title": 2} 17 | 18 | 19 | CASE2_MAIN_SCHEMA = { 20 | "$schema": "http://json-schema.org/draft-07/schema", 21 | "type": "object", 22 | "required": ["test"], 23 | "properties": {"test": {"$ref": "./values.json#/$defs/test"}}, 24 | } 25 | CASE2_VALUES_SCHEMA = { 26 | "$schema": "http://json-schema.org/draft-07/schema", 27 | "$defs": {"test": {"type": "string"}}, 28 | } 29 | CASE2_PASSING_DOCUMENT = {"test": "some data"} 30 | CASE2_FAILING_DOCUMENT = {"test": {"foo": "bar"}} 31 | 32 | 33 | def _prep_files(tmp_path, main_schema, other_schema_data, instance): 34 | main_schemafile = tmp_path / "main_schema.json" 35 | main_schemafile.write_text(json.dumps(main_schema)) 36 | for k, v in other_schema_data.items(): 37 | schemafile = tmp_path / k 38 | schemafile.write_text(json.dumps(v)) 39 | doc = tmp_path / "doc.json" 40 | doc.write_text(json.dumps(instance)) 41 | return main_schemafile, doc 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "main_schema, other_schema_data, instance", 46 | [ 47 | ( 48 | CASE1_MAIN_SCHEMA, 49 | {"title_schema.json": CASE1_TITLE_SCHEMA}, 50 | CASE1_PASSING_DOCUMENT, 51 | ), 52 | ( 53 | CASE2_MAIN_SCHEMA, 54 | {"values.json": CASE2_VALUES_SCHEMA}, 55 | CASE2_PASSING_DOCUMENT, 56 | ), 57 | ], 58 | ) 59 | @pytest.mark.parametrize("with_file_scheme", [True, False]) 60 | def test_local_ref_schema( 61 | run_line_simple, 62 | tmp_path, 63 | main_schema, 64 | other_schema_data, 65 | instance, 66 | with_file_scheme, 67 | ): 68 | main_schemafile, doc = _prep_files( 69 | tmp_path, main_schema, other_schema_data, instance 70 | ) 71 | if with_file_scheme: 72 | schemafile = main_schemafile.resolve().as_uri() 73 | else: 74 | schemafile = str(main_schemafile) 75 | run_line_simple(["--schemafile", schemafile, str(doc)]) 76 | 77 | 78 | @pytest.mark.parametrize( 79 | "main_schema, other_schema_data, instance, expect_err", 80 | [ 81 | ( 82 | CASE1_MAIN_SCHEMA, 83 | {"title_schema.json": CASE1_TITLE_SCHEMA}, 84 | CASE1_FAILING_DOCUMENT, 85 | None, 86 | ), 87 | ( 88 | CASE2_MAIN_SCHEMA, 89 | {"values.json": CASE2_VALUES_SCHEMA}, 90 | CASE2_FAILING_DOCUMENT, 91 | "{'foo': 'bar'} is not of type 'string'", 92 | ), 93 | ], 94 | ) 95 | @pytest.mark.parametrize("with_file_scheme", [True, False]) 96 | def test_local_ref_schema_failure_case( 97 | run_line, 98 | tmp_path, 99 | main_schema, 100 | other_schema_data, 101 | instance, 102 | expect_err, 103 | with_file_scheme, 104 | ): 105 | main_schemafile, doc = _prep_files( 106 | tmp_path, main_schema, other_schema_data, instance 107 | ) 108 | if with_file_scheme: 109 | schemafile = main_schemafile.resolve().as_uri() 110 | else: 111 | schemafile = str(main_schemafile) 112 | res = run_line(["check-jsonschema", "--schemafile", schemafile, str(doc)]) 113 | assert res.exit_code == 1 114 | if expect_err is not None: 115 | assert expect_err in res.stdout 116 | -------------------------------------------------------------------------------- /tests/acceptance/test_malformed_instances.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | TITLE_SCHEMA = { 6 | "$schema": "http://json-schema.org/draft-07/schema", 7 | "properties": {"title": {"type": "string"}}, 8 | "required": ["title"], 9 | } 10 | 11 | 12 | def test_non_json_instance(run_line, tmp_path): 13 | schema = tmp_path / "schema.json" 14 | instance = tmp_path / "instance.json" 15 | schema.write_text("{}") 16 | instance.write_text("{") 17 | 18 | res = run_line(["check-jsonschema", "--schemafile", str(schema), str(instance)]) 19 | assert res.exit_code == 1 20 | assert f"Failed to parse {str(instance)}" in res.stdout 21 | 22 | 23 | @pytest.mark.parametrize("outformat", ["TEXT", "JSON"]) 24 | def test_non_json_instance_mixed_with_valid_data(run_line, tmp_path, outformat): 25 | schema = tmp_path / "schema.json" 26 | malformed_instance = tmp_path / "malformed_instance.json" 27 | good_instance = tmp_path / "good_instance.json" 28 | schema.write_text(json.dumps(TITLE_SCHEMA)) 29 | malformed_instance.write_text("{") 30 | good_instance.write_text('{"title": "ohai"}') 31 | 32 | res = run_line( 33 | [ 34 | "check-jsonschema", 35 | "-o", 36 | outformat, 37 | "--schemafile", 38 | str(schema), 39 | str(malformed_instance), 40 | str(good_instance), 41 | ] 42 | ) 43 | assert res.exit_code == 1 44 | if outformat == "TEXT": 45 | assert f"Failed to parse {str(malformed_instance)}" in res.stdout 46 | else: 47 | report = json.loads(res.stdout) 48 | assert report["status"] == "fail" 49 | assert "errors" in report 50 | assert report["errors"] == [] 51 | assert "parse_errors" in report 52 | assert len(report["parse_errors"]) == 1 53 | error_item = report["parse_errors"][0] 54 | assert error_item["filename"] == str(malformed_instance) 55 | assert f"Failed to parse {str(malformed_instance)}" in error_item["message"] 56 | 57 | 58 | @pytest.mark.parametrize("outformat", ["TEXT", "JSON"]) 59 | def test_non_json_instance_mixed_with_valid_and_invalid_data( 60 | run_line, tmp_path, outformat 61 | ): 62 | schema = tmp_path / "schema.json" 63 | malformed_instance = tmp_path / "malformed_instance.json" 64 | good_instance = tmp_path / "good_instance.json" 65 | bad_instance = tmp_path / "bad_instance.json" 66 | schema.write_text(json.dumps(TITLE_SCHEMA)) 67 | malformed_instance.write_text("{") 68 | good_instance.write_text('{"title": "ohai"}') 69 | bad_instance.write_text('{"title": false}') 70 | 71 | res = run_line( 72 | [ 73 | "check-jsonschema", 74 | "-o", 75 | outformat, 76 | "--schemafile", 77 | str(schema), 78 | str(good_instance), 79 | str(malformed_instance), 80 | str(bad_instance), 81 | ] 82 | ) 83 | assert res.exit_code == 1 84 | if outformat == "TEXT": 85 | assert f"Failed to parse {str(malformed_instance)}" in res.stdout 86 | assert ( 87 | f"{str(bad_instance)}::$.title: False is not of type 'string'" in res.stdout 88 | ) 89 | else: 90 | report = json.loads(res.stdout) 91 | assert report["status"] == "fail" 92 | 93 | assert "errors" in report 94 | assert len(report["errors"]) == 1 95 | 96 | assert "parse_errors" in report 97 | assert len(report["parse_errors"]) == 1 98 | error_item = report["parse_errors"][0] 99 | assert error_item["filename"] == str(malformed_instance) 100 | assert f"Failed to parse {str(malformed_instance)}" in error_item["message"] 101 | -------------------------------------------------------------------------------- /tests/acceptance/test_nonjson_instance_files.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from check_jsonschema.parsers.json5 import ENABLED as JSON5_ENABLED 6 | 7 | SIMPLE_SCHEMA = { 8 | "$schema": "http://json-schema.org/draft-07/schema", 9 | "properties": { 10 | "title": {"type": "string"}, 11 | }, 12 | "additionalProperties": False, 13 | } 14 | PASSING_DOCUMENT = """ 15 | // a comment 16 | {"title": "doc one"} 17 | """ 18 | FAILING_DOCUMENT = """ 19 | // a comment 20 | {"title": 2} 21 | """ 22 | 23 | 24 | @pytest.mark.skipif(not JSON5_ENABLED, reason="test requires json5") 25 | @pytest.mark.parametrize("passing_data", [True, False]) 26 | def test_json5_filetype_forced_on_json_suffixed_instance( 27 | run_line, tmp_path, passing_data 28 | ): 29 | schemafile = tmp_path / "schema.json" 30 | schemafile.write_text(json.dumps(SIMPLE_SCHEMA)) 31 | 32 | doc = tmp_path / "doc.json" 33 | if passing_data: 34 | doc.write_text(PASSING_DOCUMENT) 35 | else: 36 | doc.write_text(FAILING_DOCUMENT) 37 | 38 | result = run_line( 39 | [ 40 | "check-jsonschema", 41 | "--force-filetype", 42 | "json5", 43 | "--schemafile", 44 | str(schemafile), 45 | str(doc), 46 | ] 47 | ) 48 | assert result.exit_code == (0 if passing_data else 1) 49 | 50 | # but even in the passing case, a rerun without the force flag will fail 51 | if passing_data: 52 | result_without_filetype = run_line( 53 | [ 54 | "check-jsonschema", 55 | "--schemafile", 56 | str(schemafile), 57 | str(doc), 58 | ] 59 | ) 60 | assert result_without_filetype.exit_code == 1 61 | -------------------------------------------------------------------------------- /tests/acceptance/test_special_filetypes.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | import platform 4 | import sys 5 | 6 | import pytest 7 | import responses 8 | 9 | 10 | @pytest.mark.skipif( 11 | platform.system() != "Linux", reason="test requires /proc/self/ mechanism" 12 | ) 13 | @pytest.mark.skipif(sys.version_info < (3, 8), reason="test uses os.memfd_create") 14 | def test_schema_and_instance_in_memfds(run_line_simple): 15 | """ 16 | create memory file descriptors and write schema and instance data into those 17 | ensure the result works when the paths to those fds are passed on the CLI 18 | """ 19 | schemafd = os.memfd_create("test_memfd_schema") 20 | instancefd = os.memfd_create("test_memfd_instance") 21 | try: 22 | os.write(schemafd, b'{"type": "integer"}') 23 | os.write(instancefd, b"42") 24 | 25 | schema_path = f"/proc/self/fd/{schemafd}" 26 | instance_path = f"/proc/self/fd/{instancefd}" 27 | 28 | run_line_simple(["--schemafile", schema_path, instance_path]) 29 | finally: 30 | os.close(schemafd) 31 | os.close(instancefd) 32 | 33 | 34 | # helper (in global scope) for multiprocessing "spawn" to be able to use to launch 35 | # background writers 36 | def _fifo_write(path, data): 37 | fd = os.open(path, os.O_WRONLY) 38 | try: 39 | os.write(fd, data) 40 | finally: 41 | os.close(fd) 42 | 43 | 44 | @pytest.mark.skipif(os.name != "posix", reason="test requires mkfifo") 45 | @pytest.mark.parametrize("check_succeeds", (True, False)) 46 | def test_schema_and_instance_in_fifos(tmp_path, run_line, check_succeeds): 47 | """ 48 | create fifos and write schema and instance data into those 49 | ensure the result works when the paths to those fds are passed on the CLI 50 | """ 51 | schema_path = tmp_path / "schema" 52 | instance_path = tmp_path / "instance" 53 | os.mkfifo(schema_path) 54 | os.mkfifo(instance_path) 55 | 56 | spawn_ctx = multiprocessing.get_context("spawn") 57 | 58 | schema_proc = spawn_ctx.Process( 59 | target=_fifo_write, args=(schema_path, b'{"type": "integer"}') 60 | ) 61 | schema_proc.start() 62 | instance_data = b"42" if check_succeeds else b'"foo"' 63 | instance_proc = spawn_ctx.Process( 64 | target=_fifo_write, args=(instance_path, instance_data) 65 | ) 66 | instance_proc.start() 67 | 68 | try: 69 | result = run_line( 70 | ["check-jsonschema", "--schemafile", str(schema_path), str(instance_path)] 71 | ) 72 | if check_succeeds: 73 | assert result.exit_code == 0 74 | else: 75 | assert result.exit_code == 1 76 | finally: 77 | schema_proc.terminate() 78 | instance_proc.terminate() 79 | 80 | 81 | @pytest.mark.parametrize("check_passes", (True, False)) 82 | def test_remote_schema_requiring_retry(run_line, check_passes, tmp_path): 83 | """ 84 | a "remote schema" (meaning HTTPS) with bad data, therefore requiring that a retry 85 | fires in order to parse 86 | """ 87 | schema_loc = "https://example.com/schema1.json" 88 | responses.add("GET", schema_loc, body="", match_querystring=None) 89 | responses.add( 90 | "GET", 91 | schema_loc, 92 | headers={"Last-Modified": "Sun, 01 Jan 2000 00:00:01 GMT"}, 93 | json={"type": "integer"}, 94 | match_querystring=None, 95 | ) 96 | 97 | instance_path = tmp_path / "instance.json" 98 | instance_path.write_text("42" if check_passes else '"foo"') 99 | 100 | result = run_line( 101 | ["check-jsonschema", "--schemafile", schema_loc, str(instance_path)] 102 | ) 103 | if check_passes: 104 | assert result.exit_code == 0 105 | else: 106 | assert result.exit_code == 1 107 | 108 | 109 | @pytest.mark.parametrize("check_passes", (True, False)) 110 | @pytest.mark.parametrize("using_stdin", ("schema", "instance")) 111 | def test_schema_or_instance_from_stdin( 112 | run_line, check_passes, tmp_path, monkeypatch, using_stdin 113 | ): 114 | """ 115 | a "remote schema" (meaning HTTPS) with bad data, therefore requiring that a retry 116 | fires in order to parse 117 | """ 118 | if using_stdin == "schema": 119 | instance_path = tmp_path / "instance.json" 120 | instance_path.write_text("42" if check_passes else '"foo"') 121 | 122 | result = run_line( 123 | ["check-jsonschema", "--schemafile", "-", str(instance_path)], 124 | input='{"type": "integer"}', 125 | ) 126 | elif using_stdin == "instance": 127 | schema_path = tmp_path / "schema.json" 128 | schema_path.write_text('{"type": "integer"}') 129 | instance = "42" if check_passes else '"foo"' 130 | 131 | result = run_line( 132 | ["check-jsonschema", "--schemafile", schema_path, "-"], 133 | input=instance, 134 | ) 135 | else: 136 | raise NotImplementedError 137 | if check_passes: 138 | assert result.exit_code == 0 139 | else: 140 | assert result.exit_code == 1 141 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import pathlib 4 | import sys 5 | 6 | import pytest 7 | import responses 8 | from click.testing import CliRunner 9 | 10 | 11 | @pytest.fixture 12 | def cli_runner(): 13 | # compatibility for click==8.2.0 vs click<=8.1 14 | sig = inspect.signature(CliRunner) 15 | if "mix_stderr" in sig.parameters: 16 | return CliRunner(mix_stderr=False) 17 | return CliRunner() 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def mocked_responses(): 22 | responses.start() 23 | yield 24 | responses.stop() 25 | responses.reset() 26 | 27 | 28 | @pytest.fixture 29 | def mock_module(tmp_path, monkeypatch): 30 | monkeypatch.syspath_prepend(tmp_path) 31 | all_names_to_clear = [] 32 | 33 | def func(path, text): 34 | path = pathlib.Path(path) 35 | mod_dir = tmp_path / path.parent 36 | mod_dir.mkdir(parents=True, exist_ok=True) 37 | for part in path.parts[:-1]: 38 | (tmp_path / part / "__init__.py").touch() 39 | 40 | (tmp_path / path).write_text(text) 41 | 42 | for i in range(len(path.parts)): 43 | modname = ".".join(path.parts[: i + 1]) 44 | if modname.endswith(".py"): 45 | modname = modname[:-3] 46 | all_names_to_clear.append(modname) 47 | 48 | yield func 49 | 50 | for name in all_names_to_clear: 51 | if name in sys.modules: 52 | del sys.modules[name] 53 | 54 | 55 | @pytest.fixture 56 | def in_tmp_dir(request, tmp_path): 57 | os.chdir(str(tmp_path)) 58 | yield 59 | os.chdir(request.config.invocation_dir) 60 | 61 | 62 | @pytest.fixture 63 | def cache_dir(tmp_path): 64 | return tmp_path / ".cache" 65 | 66 | 67 | @pytest.fixture(autouse=True) 68 | def patch_cache_dir(monkeypatch, cache_dir): 69 | with monkeypatch.context() as m: 70 | m.setattr( 71 | "check_jsonschema.cachedownloader._base_cache_dir", lambda: str(cache_dir) 72 | ) 73 | yield m 74 | 75 | 76 | @pytest.fixture 77 | def url2cachepath(): 78 | from check_jsonschema.cachedownloader import url_to_cache_filename 79 | 80 | def _get(cache_dir, url): 81 | return cache_dir / url_to_cache_filename(url) 82 | 83 | return _get 84 | 85 | 86 | @pytest.fixture 87 | def downloads_cache_dir(tmp_path): 88 | return tmp_path / ".cache" / "check_jsonschema" / "downloads" 89 | 90 | 91 | @pytest.fixture 92 | def get_download_cache_loc(downloads_cache_dir, url2cachepath): 93 | def _get(url): 94 | return url2cachepath(downloads_cache_dir, url) 95 | 96 | return _get 97 | 98 | 99 | @pytest.fixture 100 | def inject_cached_download(downloads_cache_dir, get_download_cache_loc): 101 | def _write(uri, content): 102 | downloads_cache_dir.mkdir(parents=True) 103 | path = get_download_cache_loc(uri) 104 | if isinstance(content, str): 105 | path.write_text(content) 106 | else: 107 | path.write_bytes(content) 108 | 109 | return _write 110 | 111 | 112 | @pytest.fixture 113 | def refs_cache_dir(tmp_path): 114 | return tmp_path / ".cache" / "check_jsonschema" / "refs" 115 | 116 | 117 | @pytest.fixture 118 | def get_ref_cache_loc(refs_cache_dir, url2cachepath): 119 | def _get(url): 120 | return url2cachepath(refs_cache_dir, url) 121 | 122 | return _get 123 | 124 | 125 | @pytest.fixture 126 | def inject_cached_ref(refs_cache_dir, get_ref_cache_loc): 127 | def _write(uri, content): 128 | refs_cache_dir.mkdir(parents=True) 129 | get_ref_cache_loc(uri).write_text(content) 130 | 131 | return _write 132 | -------------------------------------------------------------------------------- /tests/example-files/config_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$comment": "An internal schema used to check the testsuite _config.yaml files.", 3 | "$schema": "https://json-schema.org/draft/2020-12/schema", 4 | "$defs": { 5 | "spec": { 6 | "type": "object", 7 | "properties": { 8 | "requires_packages": { 9 | "type": "array", 10 | "items": { 11 | "type": "string" 12 | } 13 | }, 14 | "add_args": { 15 | "type": "array", 16 | "items": { 17 | "type": "string" 18 | } 19 | } 20 | }, 21 | "additionalProperties": false 22 | } 23 | }, 24 | "type": "object", 25 | "properties": { 26 | "files": { 27 | "type": "object", 28 | "patternProperties": { 29 | "^.+\\.(json|yml|yaml|json5|toml)$": { 30 | "$ref": "#/$defs/spec" 31 | } 32 | }, 33 | "additionalProperties": false 34 | } 35 | }, 36 | "additionalProperties": false 37 | } 38 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/negative/unicode_pattern/instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "foo 1", 3 | "value": "bar 2" 4 | } 5 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/negative/unicode_pattern/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "key": { 5 | "description": "some key", 6 | "maxLength": 128, 7 | "minLength": 1, 8 | "pattern": "^\\p{L}\\p{Z}\\p{N}$", 9 | "type": "string" 10 | }, 11 | "value": { 12 | "description": "some value", 13 | "maxLength": 256, 14 | "minLength": 0, 15 | "pattern": "^\\p{L}\\p{Z}\\p{N}$", 16 | "type": "string" 17 | } 18 | }, 19 | "type": "object" 20 | } 21 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/2020-meta/instance.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/2020-meta/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/schema", 4 | "$vocabulary": { 5 | "https://json-schema.org/draft/2020-12/vocab/core": true, 6 | "https://json-schema.org/draft/2020-12/vocab/applicator": true, 7 | "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, 8 | "https://json-schema.org/draft/2020-12/vocab/validation": true, 9 | "https://json-schema.org/draft/2020-12/vocab/meta-data": true, 10 | "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, 11 | "https://json-schema.org/draft/2020-12/vocab/content": true 12 | }, 13 | "$dynamicAnchor": "meta", 14 | "title": "Core and Validation specifications meta-schema", 15 | "allOf": [ 16 | { 17 | "$ref": "meta/core" 18 | }, 19 | { 20 | "$ref": "meta/applicator" 21 | }, 22 | { 23 | "$ref": "meta/unevaluated" 24 | }, 25 | { 26 | "$ref": "meta/validation" 27 | }, 28 | { 29 | "$ref": "meta/meta-data" 30 | }, 31 | { 32 | "$ref": "meta/format-annotation" 33 | }, 34 | { 35 | "$ref": "meta/content" 36 | } 37 | ], 38 | "type": [ 39 | "object", 40 | "boolean" 41 | ], 42 | "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", 43 | "properties": { 44 | "definitions": { 45 | "$comment": "\"definitions\" has been replaced by \"$defs\".", 46 | "type": "object", 47 | "additionalProperties": { 48 | "$dynamicRef": "#meta" 49 | }, 50 | "deprecated": true, 51 | "default": {} 52 | }, 53 | "dependencies": { 54 | "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", 55 | "type": "object", 56 | "additionalProperties": { 57 | "anyOf": [ 58 | { 59 | "$dynamicRef": "#meta" 60 | }, 61 | { 62 | "$ref": "meta/validation#/$defs/stringArray" 63 | } 64 | ] 65 | }, 66 | "deprecated": true, 67 | "default": {} 68 | }, 69 | "$recursiveAnchor": { 70 | "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", 71 | "$ref": "meta/core#/$defs/anchorString", 72 | "deprecated": true 73 | }, 74 | "$recursiveRef": { 75 | "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", 76 | "$ref": "meta/core#/$defs/uriReferenceString", 77 | "deprecated": true 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/complex-toml/instance.toml: -------------------------------------------------------------------------------- 1 | [object] 2 | # Comment 3 | 4 | string = "I'm a string." 5 | multiline_string = '''I\'m 6 | a multiline 7 | string''' 8 | 9 | integer_1 = 1 10 | integer_2 = +1 11 | integer_3 = -1 12 | 13 | float_1 = +1.0 14 | float_2 = 3.1415 15 | float_3 = -0.01 16 | float_4 = 5e+22 17 | float_5 = 1e06 18 | float_6 = -2E-2 19 | float_7 = 6.626e-34 20 | float_8 = 224_617.445_991_228 21 | 22 | infinite_1 = inf 23 | infinite_2 = +inf 24 | infinite_3 = -inf 25 | 26 | not_a_number_1 = nan 27 | not_a_number_2 = +nan 28 | not_a_number_3 = -nan 29 | 30 | hexadecimal_1 = 0xDEADBEEF 31 | hexadecimal_2 = 0xdeadbeef 32 | hexadecimal_3 = 0xdead_beef 33 | 34 | octal_1 = 0o01234567 35 | octal_2 = 0o755 36 | 37 | binary = 0b11010110 38 | 39 | # The null doesn't exists in TOML, but getting a value from an object 40 | # using a non existent key will be validated against the "null" type 41 | # null = Nil # https://github.com/toml-lang/toml/issues/30 42 | 43 | boolean_1 = true 44 | boolean_2 = false 45 | 46 | # tomli converts dates to datetime objects, so check-jsonschema must convert 47 | # back to strings to check against date-time -- similar for date and time types 48 | # See https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.7.3.1 49 | offset_datetime_1 = 1979-05-27T07:32:00Z 50 | offset_datetime_2 = 1979-05-27T00:32:00-07:00 51 | offset_datetime_3 = 1979-05-27T00:32:00.999999-07:00 52 | offset_datetime_4 = '1979-05-27T07:32:00Z' 53 | offset_datetime_5 = '1979-05-27T00:32:00-07:00' 54 | offset_datetime_6 = '1979-05-27T00:32:00.999999-07:00' 55 | 56 | naive_datetime_1 = 1979-05-27T07:32:00 57 | local_datetime_2 = 1979-05-27T00:32:00.999999 58 | 59 | # these are invalid strings under the RFC because they lack the offset|Z 60 | # local_datetime_3 = '1979-05-27T07:32:00' 61 | # local_datetime_4 = '1979-05-27T00:32:00.999999' 62 | 63 | local_date_1 = 1979-05-27 64 | local_date_2 = '1979-05-27' 65 | 66 | local_time_1 = 07:32:00 67 | local_time_2 = 00:32:00.999999 68 | # these are invalid strings under the RFC because they lack the offset|Z 69 | # local_time_3 = '07:32:00' 70 | # local_time_4 = '00:32:00.999999' 71 | 72 | # TOML does not have a native duration type which translates to 73 | # datetime.timedelta under tomli 74 | # so ISO durations can only be represented as strings 75 | duration_1 = "P1D" 76 | 77 | array_1 = ["a", 2, true] 78 | array_2 = [ 79 | "b", 80 | 3.1, 81 | false, 82 | ] 83 | 84 | [nested_object_1] 85 | foo = "bar" 86 | 87 | nested_object_2 = { foo = "bar" } 88 | 89 | [[array_of_objects_1]] 90 | foo = "bar" 91 | [[array_of_objects_1]] 92 | foo = "bar" 93 | 94 | [[nested_array_of_objects_1]] 95 | foo = "bar" 96 | [[nested_array_of_objects_1]] 97 | foo = "bar" 98 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/complex-toml/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "type": "object", 4 | "properties": { 5 | "object": { 6 | "type": "object", 7 | "properties": { 8 | "string": { "type": "string" }, 9 | "multiline_string": { "type": "string" }, 10 | "integer_1": { "type": "integer" }, 11 | "integer_2": { "type": "integer" }, 12 | "integer_3": { "type": "integer" }, 13 | "float_1": { "type": "number" }, 14 | "float_2": { "type": "number" }, 15 | "float_3": { "type": "number" }, 16 | "float_4": { "type": "number" }, 17 | "float_5": { "type": "number" }, 18 | "float_6": { "type": "number" }, 19 | "float_7": { "type": "number" }, 20 | "float_8": { "type": "number" }, 21 | "infinite_1": { "type": "number" }, 22 | "infinite_2": { "type": "number" }, 23 | "infinite_3": { "type": "number" }, 24 | "not_a_number_1": { "type": "number" }, 25 | "not_a_number_2": { "type": "number" }, 26 | "not_a_number_3": { "type": "number" }, 27 | "hexadecimal_1": { "type": "number" }, 28 | "hexadecimal_2": { "type": "number" }, 29 | "hexadecimal_3": { "type": "number" }, 30 | "octal_1": { "type": "number" }, 31 | "octal_2": { "type": "number" }, 32 | "binary": { "type": "number" }, 33 | "null": { "type": "null" }, 34 | "boolean_1": { "type": "boolean" }, 35 | "boolean_2": { "type": "boolean" }, 36 | "offset_datetime_1": { "type": "string", "format": "date-time" }, 37 | "offset_datetime_2": { "type": "string", "format": "date-time" }, 38 | "offset_datetime_3": { "type": "string", "format": "date-time" }, 39 | "offset_datetime_4": { "type": "string", "format": "date-time" }, 40 | "offset_datetime_5": { "type": "string", "format": "date-time" }, 41 | "offset_datetime_6": { "type": "string", "format": "date-time" }, 42 | "local_datetime_1": { "type": "string", "format": "date-time" }, 43 | "local_datetime_2": { "type": "string", "format": "date-time" }, 44 | "local_datetime_3": { "type": "string", "format": "date-time" }, 45 | "local_datetime_4": { "type": "string", "format": "date-time" }, 46 | "local_date_1": { "type": "string", "format": "date" }, 47 | "local_date_2": { "type": "string", "format": "date" }, 48 | "local_time_1": { "type": "string", "format": "time" }, 49 | "local_time_2": { "type": "string", "format": "time" }, 50 | "local_time_3": { "type": "string", "format": "time" }, 51 | "local_time_4": { "type": "string", "format": "time" }, 52 | "duration_1": { "type": "string", "format": "duration" }, 53 | "array_1": { "type": "array" }, 54 | "array_2": { "type": "array" }, 55 | "nested_object_1": { 56 | "type": "object", 57 | "properties": { "foo": { "type": "string" } } 58 | }, 59 | "nested_object_2": { 60 | "type": "object", 61 | "properties": { "foo": { "type": "string" } } 62 | }, 63 | "array_of_objects_1": { 64 | "type": "array", 65 | "items": { 66 | "type": "object", 67 | "properties": { "foo": { "type": "string" } } 68 | } 69 | }, 70 | "nested_array_of_objects_1": { 71 | "type": "array", 72 | "items": { 73 | "type": "object", 74 | "properties": { "foo": { "type": "string" } } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/complex-yaml/instance.yaml: -------------------------------------------------------------------------------- 1 | # Comment 2 | object: 3 | string: "I'm a string." 4 | multiline_string: | 5 | I'm 6 | a multiline 7 | string 8 | 9 | integer_1: 1 10 | integer_2: +1 11 | integer_3: -1 12 | 13 | float_1: +1.0 14 | float_2: 3.1415 15 | float_3: -0.01 16 | float_4: 5e+22 17 | float_5: 1e06 18 | float_6: -2E-2 19 | float_7: 6.626e-34 20 | float_8: 224_617.445_991_228 21 | 22 | infinite_1: .inf 23 | infinite_2: +.inf 24 | infinite_3: -.inf 25 | 26 | not_a_number_1: .nan 27 | not_a_number_2: .NaN 28 | not_a_number_3: .NAN 29 | 30 | hexadecimal_1: 0xDEADBEEF 31 | hexadecimal_2: 0xdeadbeef 32 | hexadecimal_3: 0xdead_beef 33 | 34 | octal_1: 0o01234567 35 | octal_2: 0o755 36 | 37 | binary: 0b11010110 38 | 39 | null_1: null 40 | null_2: ~ 41 | 42 | boolean_1: true 43 | boolean_2: false 44 | boolean_3: True 45 | boolean_4: False 46 | boolean_5: TRUE 47 | boolean_6: FALSE 48 | 49 | # ruamel.yaml by default converts dates to datetime objects, so check-jsonschema parses 50 | # as strings 51 | offset_datetime_1: 1979-05-27T07:32:00Z 52 | offset_datetime_2: 1979-05-27T00:32:00-07:00 53 | offset_datetime_3: 1979-05-27T00:32:00.999999-07:00 54 | offset_datetime_4: '1979-05-27T07:32:00Z' 55 | offset_datetime_5: '1979-05-27T00:32:00-07:00' 56 | offset_datetime_6: '1979-05-27T00:32:00.999999-07:00' 57 | 58 | datetime_1: 1979-05-27T07:32:00Z 59 | datetime_2: 1979-05-27T00:32:00.999999z 60 | 61 | local_date_1: 1979-05-27 62 | local_date_2: '1979-05-27' 63 | 64 | time_1: 07:32:00Z 65 | time_2: 00:32:00.999999z 66 | 67 | # YAML does not have a native duration type which translates to 68 | # datetime.timedelta under ruamel.yaml 69 | # so ISO durations can only be represented as strings 70 | duration_1: "P1D" 71 | 72 | array_1: ["a", 2, true] 73 | array_2: [ 74 | "b", 75 | 3.1, 76 | false, 77 | ] 78 | 79 | nested_object_1: 80 | foo: "bar" 81 | 82 | nested_object_2: { foo: "bar" } 83 | 84 | array_of_objects_1: 85 | - foo: "bar" 86 | - foo: "bar" 87 | 88 | nested_array_of_objects_1: 89 | - foo: "bar" 90 | - foo: "bar" 91 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/complex-yaml/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "type": "object", 4 | "properties": { 5 | "object": { 6 | "type": "object", 7 | "properties": { 8 | "string": { "type": "string" }, 9 | "multiline_string": { "type": "string" }, 10 | "integer_1": { "type": "integer" }, 11 | "integer_2": { "type": "integer" }, 12 | "integer_3": { "type": "integer" }, 13 | "float_1": { "type": "number" }, 14 | "float_2": { "type": "number" }, 15 | "float_3": { "type": "number" }, 16 | "float_4": { "type": "number" }, 17 | "float_5": { "type": "number" }, 18 | "float_6": { "type": "number" }, 19 | "float_7": { "type": "number" }, 20 | "float_8": { "type": "number" }, 21 | "infinite_1": { "type": "number" }, 22 | "infinite_2": { "type": "number" }, 23 | "infinite_3": { "type": "number" }, 24 | "not_a_number_1": { "type": "number" }, 25 | "not_a_number_2": { "type": "number" }, 26 | "not_a_number_3": { "type": "number" }, 27 | "hexadecimal_1": { "type": "number" }, 28 | "hexadecimal_2": { "type": "number" }, 29 | "hexadecimal_3": { "type": "number" }, 30 | "octal_1": { "type": "number" }, 31 | "octal_2": { "type": "number" }, 32 | "binary": { "type": "number" }, 33 | "null_1": { "type": "null" }, 34 | "null_2": { "type": "null" }, 35 | "boolean_1": { "type": "boolean" }, 36 | "boolean_2": { "type": "boolean" }, 37 | "boolean_3": { "type": "boolean" }, 38 | "boolean_4": { "type": "boolean" }, 39 | "boolean_5": { "type": "boolean" }, 40 | "boolean_6": { "type": "boolean" }, 41 | "offset_datetime_1": { "type": "string", "format": "date-time" }, 42 | "offset_datetime_2": { "type": "string", "format": "date-time" }, 43 | "offset_datetime_3": { "type": "string", "format": "date-time" }, 44 | "offset_datetime_4": { "type": "string", "format": "date-time" }, 45 | "offset_datetime_5": { "type": "string", "format": "date-time" }, 46 | "offset_datetime_6": { "type": "string", "format": "date-time" }, 47 | "datetime_1": { "type": "string", "format": "date-time" }, 48 | "datetime_2": { "type": "string", "format": "date-time" }, 49 | "local_date_1": { "type": "string", "format": "date" }, 50 | "local_date_2": { "type": "string", "format": "date" }, 51 | "time_1": { "type": "string", "format": "time" }, 52 | "time_2": { "type": "string", "format": "time" }, 53 | "duration_1": { "type": "string", "format": "duration" }, 54 | "array_1": { "type": "array" }, 55 | "array_2": { "type": "array" }, 56 | "nested_object_1": { 57 | "type": "object", 58 | "properties": { "foo": { "type": "string" } } 59 | }, 60 | "nested_object_2": { 61 | "type": "object", 62 | "properties": { "foo": { "type": "string" } } 63 | }, 64 | "array_of_objects_1": { 65 | "type": "array", 66 | "items": { 67 | "type": "object", 68 | "properties": { "foo": { "type": "string" } } 69 | } 70 | }, 71 | "nested_array_of_objects_1": { 72 | "type": "array", 73 | "items": { 74 | "type": "object", 75 | "properties": { "foo": { "type": "string" } } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/integer-keys-yaml/instance.yaml: -------------------------------------------------------------------------------- 1 | items: 2 | 1: 3 | name: "one" 4 | 2: 5 | name: "two" 6 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/integer-keys-yaml/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "type": "object", 4 | "properties": { 5 | "items": { 6 | "patternProperties": { 7 | "^[a-zA-Z0-9_-]*$": { 8 | "type": "object", 9 | "properties": { 10 | "name": { 11 | "type": "string" 12 | } 13 | } 14 | } 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/simple-toml/instance.toml: -------------------------------------------------------------------------------- 1 | [item] 2 | title = "an item" 3 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/simple-toml/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "type": "object", 4 | "properties": { 5 | "item": { 6 | "type": "object", 7 | "properties": { 8 | "title": { "type": "string"} 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/unicode_pattern/instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "a 1", 3 | "value": "b 2" 4 | } 5 | -------------------------------------------------------------------------------- /tests/example-files/explicit-schema/positive/unicode_pattern/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "key": { 5 | "description": "some key", 6 | "maxLength": 128, 7 | "minLength": 1, 8 | "pattern": "^\\p{L}\\p{Z}\\p{N}$", 9 | "type": "string" 10 | }, 11 | "value": { 12 | "description": "some value", 13 | "maxLength": 256, 14 | "minLength": 0, 15 | "pattern": "^\\p{L}\\p{Z}\\p{N}$", 16 | "type": "string" 17 | } 18 | }, 19 | "type": "object" 20 | } 21 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/cloudbuild/empty.yaml: -------------------------------------------------------------------------------- 1 | # empty 2 | {} 3 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/drone-ci/unkown-type-pipeline.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: unknown-type 3 | name: default 4 | 5 | steps: 6 | - name: greeting 7 | image: alpine 8 | commands: 9 | - echo hello 10 | - echo world 11 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/github-workflows/empty.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/jsonschema/_config.yaml: -------------------------------------------------------------------------------- 1 | files: 2 | github-workflow-timeout-minutes-expression.yaml: 3 | add_args: ["--builtin-schema", "custom.github-workflows-require-timeout"] 4 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/jsonschema/github-workflow-timeout-minutes-expression.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow-call: 3 | inputs: 4 | qemu: 5 | default: '' 6 | required: false 7 | 8 | jobs: 9 | job-id: 10 | runs-on: ubuntu-latest 11 | # missing trailing '}' 12 | timeout-minutes: ${{ inputs.qemu && '60' || '20' } 13 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/mergify/example-pr-rules.yaml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | name: add label when author is jd 3 | description: jd needs his own label because reasons 4 | conditions: 5 | - author = jd 6 | actions: 7 | label: 8 | add: 9 | - jd 10 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/metaschema/2020_invalid_format_value.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "title": "Meta-schema defining a ref with invalid URI reference", 4 | "$defs": { 5 | "prop<(str|list)>": { 6 | "oneOf": [ 7 | { 8 | "type": "string" 9 | }, 10 | { 11 | "type": "array", 12 | "items": true 13 | } 14 | ] 15 | }, 16 | "anchorString": { 17 | "type": "string", 18 | "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" 19 | }, 20 | "uriString": { 21 | "type": "string", 22 | "format": "uri" 23 | }, 24 | "uriReferenceString": { 25 | "type": "string", 26 | "format": "uri-reference" 27 | }, 28 | "original2020metaschema": { 29 | "$schema": "https://json-schema.org/draft/2020-12/schema", 30 | "$vocabulary": { 31 | "https://json-schema.org/draft/2020-12/vocab/core": true 32 | }, 33 | "$dynamicAnchor": "meta", 34 | "title": "Core vocabulary meta-schema", 35 | "type": [ 36 | "object", 37 | "boolean" 38 | ], 39 | "properties": { 40 | "$id": { 41 | "$ref": "#/$defs/uriReferenceString", 42 | "$comment": "Non-empty fragments not allowed.", 43 | "pattern": "^[^#]*#?$" 44 | }, 45 | "$schema": { 46 | "$ref": "#/$defs/uriString" 47 | }, 48 | "$ref": { 49 | "$ref": "#/$defs/uriReferenceString" 50 | }, 51 | "$anchor": { 52 | "$ref": "#/$defs/anchorString" 53 | }, 54 | "$dynamicRef": { 55 | "$ref": "#/$defs/uriReferenceString" 56 | }, 57 | "$dynamicAnchor": { 58 | "$ref": "#/$defs/anchorString" 59 | }, 60 | "$vocabulary": { 61 | "type": "object", 62 | "propertyNames": { 63 | "$ref": "#/$defs/uriString" 64 | }, 65 | "additionalProperties": { 66 | "type": "boolean" 67 | } 68 | }, 69 | "$comment": { 70 | "type": "string" 71 | }, 72 | "$defs": { 73 | "type": "object", 74 | "additionalProperties": { 75 | "$dynamicRef": "#meta" 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "allOf": [ 82 | { 83 | "$ref": "#/$defs/original2020metaschema" 84 | }, 85 | { 86 | "properties": { 87 | "title": { 88 | "$ref": "#/$defs/prop<(str|list)>" 89 | } 90 | } 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/metaschema/_config.yaml: -------------------------------------------------------------------------------- 1 | files: 2 | 2020_invalid_format_value.json: 3 | requires_packages: 4 | - rfc3987 5 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/metaschema/draft7_title_array.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": [ 4 | "Core schema meta-schema" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/metaschema/draft7_title_array.yaml: -------------------------------------------------------------------------------- 1 | $schema: "http://json-schema.org/draft-07/schema#" 2 | title: 3 | - "ohai" 4 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/readthedocs/pyversion-float.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: ubuntu-20.04 8 | tools: 9 | # this will fail because the version is a float (should be str) 10 | python: 3.10 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | extra_requirements: 17 | - dev 18 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/snapcraft/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: broken 2 | base: core22 3 | version: '1.2' 4 | summary: Broken snap file 5 | description: | 6 | Broken snap file which is missing a parts block. 7 | -------------------------------------------------------------------------------- /tests/example-files/hooks/negative/woodpecker-ci/empty.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/azure-pipelines/expression-from-lang-server.yaml: -------------------------------------------------------------------------------- 1 | # this data was taken from the azure-pipelines-language-server 2 | # https://github.com/microsoft/azure-pipelines-language-server/blob/71b20f92874c02dfe82ad2cc2dcc7fa64996be91/language-service/test/pipelinesTests/yamlvalidation.test.ts#L50 3 | # 4 | # original license can be found in 5 | # src/check_jsonschema/builtin_schemas/vendor/licenses/LICENSE.azure-pipelines 6 | # 7 | steps: 8 | - ${{ if succeeded() }}: 9 | - task: npmAuthenticate@0 10 | inputs: 11 | ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: 12 | workingFile: .npmrc 13 | ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: 14 | workingFile: .other_npmrc 15 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/azure-pipelines/expression-transform.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: vals 3 | default: "" 4 | 5 | jobs: 6 | - ${{ each val in parameter.vals }}: 7 | - job: foo 8 | steps: 9 | - bash: echo ${{ val }} 10 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/azure-pipelines/marshmallow.yaml: -------------------------------------------------------------------------------- 1 | # config pulled from marshmallow-code/marshmallow 2022-01 2 | 3 | trigger: 4 | branches: 5 | include: [dev, 2.x-line, test-me-*] 6 | tags: 7 | include: ['*'] 8 | 9 | resources: 10 | repositories: 11 | - repository: sloria 12 | type: github 13 | endpoint: github 14 | name: sloria/azure-pipeline-templates 15 | ref: refs/heads/sloria 16 | 17 | jobs: 18 | - template: job--python-tox.yml@sloria 19 | parameters: 20 | toxenvs: [lint, py37, py310] 21 | os: linux 22 | - template: job--pypi-release.yml@sloria 23 | parameters: 24 | dependsOn: 25 | - tox_linux 26 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/azure-pipelines/object-defined-by-expression-map.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: env 3 | default: 4 | - key: FOO 5 | value: foo 6 | - key: BAR 7 | value: bar 8 | 9 | jobs: 10 | - job: echo-foo-bar 11 | steps: 12 | - bash: 'echo "$FOO-$BAR"' 13 | env: 14 | ${{ each pair in parameters.env }}: 15 | ${{ pair.key }}: ${{ pair.value }} 16 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/bitbucket-pipelines/bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | options: 2 | max-time: 30 3 | docker: true 4 | size: 2x 5 | 6 | pipelines: 7 | default: 8 | - step: 9 | name: Hello world example 10 | script: 11 | - echo "Hello, World!" 12 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/buildkite/matrix.yml: -------------------------------------------------------------------------------- 1 | # this example copied from the buildkite pipeline-schema repo 2 | # it is therefore licensed under the existing buildkite MIT license 3 | # see src/check_jsonschema/builtin_schemas/vendor/licenses for license details 4 | 5 | steps: 6 | - command: "echo {{matrix}}" 7 | label: "{{matrix}}" 8 | matrix: 9 | - one 10 | - two 11 | 12 | - command: "echo {{matrix}}" 13 | label: "{{matrix}}" 14 | matrix: 15 | setup: 16 | - one 17 | - two 18 | adjustments: 19 | - with: ["three"] 20 | skip: true 21 | 22 | - command: "echo {{matrix.color}} {{matrix.shape}}" 23 | label: "{{matrix.color}} {{matrix.shape}}" 24 | matrix: 25 | setup: 26 | color: 27 | - green 28 | - blue 29 | shape: 30 | - triangle 31 | - hexagon 32 | adjustments: 33 | - with: {color: blue, shape: triangle} 34 | skip: true 35 | - with: {color: green, shape: triangle} 36 | skip: "look, hexagons are just better" 37 | - with: {color: purple, shape: octagon} 38 | 39 | 40 | - group: matrices 41 | steps: 42 | - command: "echo {{matrix}}" 43 | label: "{{matrix}}" 44 | matrix: 45 | - one 46 | - two 47 | 48 | - command: "echo {{matrix.color}} {{matrix.shape}}" 49 | label: "{{matrix.color}} {{matrix.shape}}" 50 | matrix: 51 | setup: 52 | color: 53 | - green 54 | - blue 55 | shape: 56 | - triangle 57 | - hexagon 58 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/cloudbuild/hello_world.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | entrypoint: "/bin/bash" 4 | args: ['-c', '"echo hello"'] 5 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/drone-ci/digitalocean-pipeline.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: digitalocean 3 | name: default 4 | 5 | token: 6 | from_secret: token 7 | 8 | steps: 9 | - name: greeting 10 | commands: 11 | - echo hello world 12 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/drone-ci/docker-pipeline.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: docker 3 | name: default 4 | 5 | steps: 6 | - name: greeting 7 | image: alpine 8 | commands: 9 | - echo hello 10 | - echo world 11 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/drone-ci/exec-pipeline.yaml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: exec 3 | name: default 4 | 5 | platform: 6 | os: linux 7 | arch: amd64 8 | 9 | steps: 10 | - name: greeting 11 | commands: 12 | - echo hello world 13 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/drone-ci/kubernetes-pipeline.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: kubernetes 3 | name: default 4 | 5 | steps: 6 | - name: greeting 7 | image: alpine 8 | commands: 9 | - echo hello 10 | - echo world 11 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/drone-ci/macstadium-pipeline.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: macstadium 3 | name: default 4 | 5 | steps: 6 | - name: greeting 7 | commands: 8 | - echo hello world 9 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/drone-ci/ssh-pipeline.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: ssh 3 | name: default 4 | 5 | server: 6 | host: 1.2.3.4 7 | user: root 8 | password: 9 | from_secret: password 10 | 11 | steps: 12 | - name: greeting 13 | commands: 14 | - echo hello world 15 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/github-actions/redis-simple.yml: -------------------------------------------------------------------------------- 1 | name: Run Redis 2 | description: 'This action spins up a Redis instance that can be accessed and used in subsequent steps.' 3 | branding: 4 | icon: 'database' 5 | color: 'green' 6 | 7 | inputs: 8 | redis-version: 9 | description: 'The version of Redis to use' 10 | required: false 11 | default: '6.2.5' 12 | 13 | runs: 14 | using: 'docker' 15 | image: 'Dockerfile' 16 | env: 17 | REDIS_VERSION: ${{ inputs.redis-version }} 18 | 19 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/github-workflows/has-unicode.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | name: "Всё хорошо?" 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | - run: python -m pip install tox 14 | - run: python -m tox -e py 15 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/github-workflows/self-build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, windows-latest, macos-latest] 11 | py: ['3.6', '3.10'] 12 | name: "Run Tests on ${{ matrix.os }}, py${{ matrix.py }}" 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.py }} 19 | - name: install requirements 20 | run: python -m pip install tox 21 | - name: test 22 | run: python -m tox -e py 23 | - name: twine-check 24 | run: python -m tox -e twine-check 25 | 26 | self-check: 27 | name: "Self-Check" 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: actions/setup-python@v2 32 | with: 33 | python-version: 3.9 34 | - name: install from source 35 | run: | 36 | python -m pip install -U pip setuptools 37 | python -m pip install . 38 | - name: run on own workflows via HTTPS schema 39 | run: check-jsonschema --schemafile "https://json.schemastore.org/github-workflow" .github/workflows/*.yaml 40 | - name: run on own workflows via vendored schema 41 | run: check-jsonschema --builtin-schema vendor.github-workflow .github/workflows/*.yaml 42 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/gitlab-ci/reference-tag.yaml: -------------------------------------------------------------------------------- 1 | include: 2 | - local: setup.yml 3 | 4 | .teardown: 5 | after_script: 6 | - echo deleting environment 7 | 8 | test: 9 | script: 10 | - !reference [.setup, script] 11 | - echo running my own command 12 | after_script: 13 | - !reference [.teardown, after_script] 14 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/jsonschema/_config.yaml: -------------------------------------------------------------------------------- 1 | files: 2 | github-workflow-timeout-minutes-expression.yaml: 3 | add_args: ["--builtin-schema", "custom.github-workflows-require-timeout"] 4 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/jsonschema/github-workflow-timeout-minutes-expression.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow-call: 3 | inputs: 4 | qemu: 5 | default: '' 6 | required: false 7 | 8 | jobs: 9 | job-id: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: ${{ inputs.qemu && '60' || '20' }} 12 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/meltano/multiple-plugins.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | env: 3 | DBT_CLEAN_PROJECT_FILES_ONLY: 'false' 4 | venv: 5 | backend: uv 6 | default_environment: dev 7 | project_id: 90f75496-2018-4b3a-97ac-9662e11c0094 8 | send_anonymous_usage_stats: false 9 | plugins: 10 | extractors: 11 | - name: tap-gitlab 12 | variant: meltanolabs 13 | pip_url: git+https://github.com/MeltanoLabs/tap-gitlab.git 14 | loaders: 15 | - name: target-postgres 16 | variant: transferwise 17 | pip_url: > 18 | git+https://github.com/transferwise/pipelinewise.git#subdirectory=singer-connectors/target-postgres 19 | utilities: 20 | - name: dbt-postgres 21 | variant: dbt-labs 22 | pip_url: dbt-core dbt-postgres meltano-dbt-ext~=0.3.0 23 | environments: 24 | - name: dev 25 | config: 26 | plugins: 27 | extractors: 28 | - name: tap-gitlab 29 | config: 30 | projects: meltano/meltano 31 | start_date: '2022-04-25T00:00:00' 32 | select: 33 | - commits.* 34 | - '!commits.stats.commits.stats*' 35 | loaders: 36 | - name: target-postgres 37 | config: 38 | user: postgres 39 | dbname: warehouse 40 | default_target_schema: public 41 | utilities: 42 | - name: dbt-postgres 43 | config: 44 | host: localhost 45 | user: postgres 46 | port: 5432 47 | dbname: warehouse 48 | schema: analytics 49 | - name: staging 50 | - name: prod 51 | jobs: 52 | - name: gitlab-to-postgres 53 | tasks: 54 | - tap-gitlab target-postgres 55 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/mergify/example-pr-rules.yaml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: add label when author is jd 3 | description: jd needs his own label because reasons 4 | conditions: 5 | - author = jd 6 | actions: 7 | label: 8 | add: 9 | - jd 10 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/metaschema/2020_invalid_format_value.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "title": "Meta-schema defining a ref with invalid URI reference", 4 | "$defs": { 5 | "prop<(str|list)>": { 6 | "oneOf": [ 7 | { 8 | "type": "string" 9 | }, 10 | { 11 | "type": "array", 12 | "items": true 13 | } 14 | ] 15 | }, 16 | "anchorString": { 17 | "type": "string", 18 | "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" 19 | }, 20 | "uriString": { 21 | "type": "string", 22 | "format": "uri" 23 | }, 24 | "uriReferenceString": { 25 | "type": "string", 26 | "format": "uri-reference" 27 | }, 28 | "original2020metaschema": { 29 | "$schema": "https://json-schema.org/draft/2020-12/schema", 30 | "$vocabulary": { 31 | "https://json-schema.org/draft/2020-12/vocab/core": true 32 | }, 33 | "$dynamicAnchor": "meta", 34 | "title": "Core vocabulary meta-schema", 35 | "type": [ 36 | "object", 37 | "boolean" 38 | ], 39 | "properties": { 40 | "$id": { 41 | "$ref": "#/$defs/uriReferenceString", 42 | "$comment": "Non-empty fragments not allowed.", 43 | "pattern": "^[^#]*#?$" 44 | }, 45 | "$schema": { 46 | "$ref": "#/$defs/uriString" 47 | }, 48 | "$ref": { 49 | "$ref": "#/$defs/uriReferenceString" 50 | }, 51 | "$anchor": { 52 | "$ref": "#/$defs/anchorString" 53 | }, 54 | "$dynamicRef": { 55 | "$ref": "#/$defs/uriReferenceString" 56 | }, 57 | "$dynamicAnchor": { 58 | "$ref": "#/$defs/anchorString" 59 | }, 60 | "$vocabulary": { 61 | "type": "object", 62 | "propertyNames": { 63 | "$ref": "#/$defs/uriString" 64 | }, 65 | "additionalProperties": { 66 | "type": "boolean" 67 | } 68 | }, 69 | "$comment": { 70 | "type": "string" 71 | }, 72 | "$defs": { 73 | "type": "object", 74 | "additionalProperties": { 75 | "$dynamicRef": "#meta" 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "allOf": [ 82 | { 83 | "$ref": "#/$defs/original2020metaschema" 84 | }, 85 | { 86 | "properties": { 87 | "title": { 88 | "$ref": "#/$defs/prop<(str|list)>" 89 | } 90 | } 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/metaschema/_config.yaml: -------------------------------------------------------------------------------- 1 | files: 2 | 2020_invalid_format_value.json: 3 | add_args: ["--disable-formats", "*"] 4 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/metaschema/almost_empty.yaml: -------------------------------------------------------------------------------- 1 | $schema: http://json-schema.org/draft-07/schema# 2 | title: "an almost empty schema" 3 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/metaschema/draft3.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema" : "http://json-schema.org/draft-03/schema#", 3 | "id" : "http://json-schema.org/draft-03/schema#", 4 | "type" : "object", 5 | 6 | "properties" : { 7 | "type" : { 8 | "type" : ["string", "array"], 9 | "items" : { 10 | "type" : ["string", {"$ref" : "#"}] 11 | }, 12 | "uniqueItems" : true, 13 | "default" : "any" 14 | }, 15 | 16 | "properties" : { 17 | "type" : "object", 18 | "additionalProperties" : {"$ref" : "#", "type" : "object"}, 19 | "default" : {} 20 | }, 21 | 22 | "patternProperties" : { 23 | "type" : "object", 24 | "additionalProperties" : {"$ref" : "#"}, 25 | "default" : {} 26 | }, 27 | 28 | "additionalProperties" : { 29 | "type" : [{"$ref" : "#"}, "boolean"], 30 | "default" : {} 31 | }, 32 | 33 | "items" : { 34 | "type" : [{"$ref" : "#"}, "array"], 35 | "items" : {"$ref" : "#"}, 36 | "default" : {} 37 | }, 38 | 39 | "additionalItems" : { 40 | "type" : [{"$ref" : "#"}, "boolean"], 41 | "default" : {} 42 | }, 43 | 44 | "required" : { 45 | "type" : "boolean", 46 | "default" : false 47 | }, 48 | 49 | "dependencies" : { 50 | "type" : ["string", "array", "object"], 51 | "additionalProperties" : { 52 | "type" : ["string", "array", {"$ref" : "#"}], 53 | "items" : { 54 | "type" : "string" 55 | } 56 | }, 57 | "default" : {} 58 | }, 59 | 60 | "minimum" : { 61 | "type" : "number" 62 | }, 63 | 64 | "maximum" : { 65 | "type" : "number" 66 | }, 67 | 68 | "exclusiveMinimum" : { 69 | "type" : "boolean", 70 | "default" : false 71 | }, 72 | 73 | "exclusiveMaximum" : { 74 | "type" : "boolean", 75 | "default" : false 76 | }, 77 | 78 | "maxDecimal": { 79 | "minimum": 0, 80 | "type": "number" 81 | }, 82 | 83 | "minItems" : { 84 | "type" : "integer", 85 | "minimum" : 0, 86 | "default" : 0 87 | }, 88 | 89 | "maxItems" : { 90 | "type" : "integer", 91 | "minimum" : 0 92 | }, 93 | 94 | "uniqueItems" : { 95 | "type" : "boolean", 96 | "default" : false 97 | }, 98 | 99 | "pattern" : { 100 | "type" : "string", 101 | "format" : "regex" 102 | }, 103 | 104 | "minLength" : { 105 | "type" : "integer", 106 | "minimum" : 0, 107 | "default" : 0 108 | }, 109 | 110 | "maxLength" : { 111 | "type" : "integer" 112 | }, 113 | 114 | "enum" : { 115 | "type" : "array" 116 | }, 117 | 118 | "default" : { 119 | "type" : "any" 120 | }, 121 | 122 | "title" : { 123 | "type" : "string" 124 | }, 125 | 126 | "description" : { 127 | "type" : "string" 128 | }, 129 | 130 | "format" : { 131 | "type" : "string" 132 | }, 133 | 134 | "divisibleBy" : { 135 | "type" : "number", 136 | "minimum" : 0, 137 | "exclusiveMinimum" : true, 138 | "default" : 1 139 | }, 140 | 141 | "disallow" : { 142 | "type" : ["string", "array"], 143 | "items" : { 144 | "type" : ["string", {"$ref" : "#"}] 145 | }, 146 | "uniqueItems" : true 147 | }, 148 | 149 | "extends" : { 150 | "type" : [{"$ref" : "#"}, "array"], 151 | "items" : {"$ref" : "#"}, 152 | "default" : {} 153 | }, 154 | 155 | "id" : { 156 | "type" : "string", 157 | "format" : "uri" 158 | }, 159 | 160 | "$ref" : { 161 | "type" : "string", 162 | "format" : "uri" 163 | }, 164 | 165 | "$schema" : { 166 | "type" : "string", 167 | "format" : "uri" 168 | } 169 | }, 170 | 171 | "dependencies" : { 172 | "exclusiveMinimum" : "minimum", 173 | "exclusiveMaximum" : "maximum" 174 | }, 175 | 176 | "default" : {} 177 | } 178 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/readthedocs/simple.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: ubuntu-20.04 8 | tools: 9 | python: "3.10" 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - dev 17 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/renovate/starter-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "schedule:nonOfficeHours"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/renovate/starter-config.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "schedule:nonOfficeHours"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/snapcraft/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: hello 2 | base: core22 3 | version: '2.10' 4 | summary: GNU Hello, the "hello world" snap 5 | description: | 6 | GNU hello prints a friendly greeting. 7 | grade: stable 8 | confinement: strict 9 | apps: 10 | hello: 11 | command: bin/hello 12 | parts: 13 | gnu-hello: 14 | source: http://ftp.gnu.org/gnu/hello/hello-2.10.tar.gz 15 | plugin: autotools 16 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/travis/python-build.yaml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | language: python 4 | matrix: 5 | include: 6 | - name: "lint" 7 | python: "3.6" 8 | env: TOXENV=lint 9 | - python: "2.7" 10 | env: TOXENV=py 11 | - python: "3.5" 12 | env: TOXENV=py 13 | - python: "3.6" 14 | env: TOXENV=py 15 | - python: "3.7" 16 | env: TOXENV=py 17 | - python: "3.8" 18 | env: TOXENV=py 19 | # non-Linux testing 20 | # https://docs.travis-ci.com/user/languages/python/#running-python-tests-on-multiple-operating-systems 21 | # 22 | # Windows 23 | - name: "py2 + windows" 24 | os: windows 25 | language: shell 26 | env: TOXENV=py PATH=/c/Python27:/c/Python27/Scripts:$PATH 27 | before_install: 28 | - choco install python2 29 | - python -m pip install --upgrade pip wheel 30 | - name: "py3.8 + windows" 31 | os: windows 32 | language: shell 33 | env: TOXENV=py PATH=/c/Python38:/c/Python38/Scripts:$PATH 34 | before_install: 35 | - choco install python3 36 | - python -m pip install --upgrade pip wheel 37 | # macOS 38 | - name: "py3 + macOS" 39 | os: osx 40 | osx_image: xcode10.2 # py3.7 on macOS 10.14 41 | language: shell 42 | env: TOXENV=py3 43 | cache: pip 44 | install: 45 | - pip install -U pip setuptools 46 | - pip install tox 47 | script: 48 | - tox 49 | -------------------------------------------------------------------------------- /tests/example-files/hooks/positive/woodpecker-ci/pipeline-clone.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # this file is copied from the woodpecker repo and therefore licensed under the 3 | # Woodpecker Apache 2.0 license: 4 | # 5 | # Copyright 2018 Drone.IO Inc. 6 | # Copyright 2020 Woodpecker Authors 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | # see: 21 | # https://github.com/woodpecker-ci/woodpecker/blob/f529b609c3356c671270db9f0a78191ef8b93590/pipeline/frontend/yaml/linter/schema/.woodpecker/test-clone.yaml 22 | clone: 23 | git: 24 | image: plugins/git:next 25 | depth: 50 26 | path: bitbucket.org/foo/bar 27 | recursive: true 28 | submodule_override: 29 | my-module: https://github.com/octocat/my-module.git 30 | 31 | steps: 32 | test: 33 | image: alpine 34 | commands: 35 | - echo "test" 36 | -------------------------------------------------------------------------------- /tests/unit/cli/test_annotations.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pytest 4 | 5 | from check_jsonschema.cli import main as cli_main 6 | 7 | click_type_test = pytest.importorskip( 8 | "click_type_test", reason="tests require 'click-type-test'" 9 | ) 10 | 11 | 12 | def test_annotations_match_click_params(): 13 | click_type_test.check_param_annotations( 14 | cli_main, 15 | overrides={ 16 | # don't bother with a Literal for this, since it's relatively dynamic data 17 | "builtin_schema": str | None, 18 | # force default_filetype to be a Literal including `json5`, which is only 19 | # included in the choices if a parser is installed 20 | "default_filetype": t.Literal["json", "yaml", "toml", "json5"], 21 | "force_filetype": t.Literal["json", "yaml", "toml", "json5"] | None, 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /tests/unit/cli/test_callbacks.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | 4 | from check_jsonschema.cli.warnings import deprecation_warning_callback 5 | 6 | 7 | @click.command("foo") 8 | @click.option( 9 | "--bar", 10 | is_flag=True, 11 | callback=deprecation_warning_callback("--bar", is_flag=True), 12 | ) 13 | @click.option( 14 | "--baz", 15 | callback=deprecation_warning_callback( 16 | "--baz", append_message="Use --frob instead!" 17 | ), 18 | ) 19 | def mycli(bar, baz): 20 | print(bar) 21 | if baz: 22 | print(baz) 23 | 24 | 25 | def test_deprecation_warning_callback_on_missing_opts(cli_runner): 26 | result = cli_runner.invoke(mycli, []) 27 | assert result.exit_code == 0 28 | assert result.stdout == "False\n" 29 | 30 | 31 | def test_deprecation_warning_callback_on_flag(cli_runner): 32 | with pytest.warns( 33 | UserWarning, 34 | match="'--bar' is deprecated and will be removed in a future release", 35 | ): 36 | result = cli_runner.invoke(mycli, ["--bar"], catch_exceptions=False) 37 | assert result.exit_code == 0, result.stdout 38 | assert result.stdout == "True\n" 39 | 40 | 41 | def test_deprecation_warning_callback_added_message(cli_runner): 42 | with pytest.warns( 43 | UserWarning, 44 | match=( 45 | "'--baz' is deprecated and will be removed in a future release. " 46 | "Use --frob instead!" 47 | ), 48 | ): 49 | result = cli_runner.invoke(mycli, ["--baz", "ok"], catch_exceptions=False) 50 | assert result.exit_code == 0, result.stdout 51 | assert result.stdout == "False\nok\n" 52 | -------------------------------------------------------------------------------- /tests/unit/formats/test_rfc3339.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | 5 | from check_jsonschema.formats.implementations.rfc3339 import validate 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "datestr", 10 | ( 11 | "2018-12-31T23:59:59Z", 12 | "2018-12-31t23:59:59Z", 13 | "2018-12-31t23:59:59z", 14 | "2018-12-31T23:59:59+00:00", 15 | "2018-12-31T23:59:59-00:00", 16 | ), 17 | ) 18 | def test_simple_positive_cases(datestr): 19 | assert validate(datestr) 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "datestr", 24 | ( 25 | object(), 26 | "2018-12-31T23:59:59", 27 | "2018-12-31T23:59:59+00:00Z", 28 | "2018-12-31 23:59:59", 29 | ), 30 | ) 31 | def test_simple_negative_case(datestr): 32 | assert not validate(datestr) 33 | 34 | 35 | @pytest.mark.parametrize("precision", list(range(20))) 36 | @pytest.mark.parametrize( 37 | "offsetstr", 38 | ( 39 | "Z", 40 | "+00:00", 41 | "-00:00", 42 | "+23:59", 43 | ), 44 | ) 45 | def test_allows_fracsec(precision, offsetstr): 46 | fracsec = random.randint(0, 10**precision) 47 | assert validate(f"2018-12-31T23:59:59.{fracsec}{offsetstr}") 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "datestr", 52 | ( 53 | # no such month 54 | "2020-13-01T00:00:00Z", 55 | "2020-00-01T00:00:00Z", 56 | # no such day 57 | "2020-01-00T00:00:00Z", 58 | "2020-01-32T00:00:00Z", 59 | ), 60 | ) 61 | def test_basic_bounds_validated(datestr): 62 | assert not validate(datestr) 63 | 64 | 65 | @pytest.mark.parametrize( 66 | "month, maxday", 67 | ( 68 | (1, 31), 69 | (3, 31), 70 | (4, 30), 71 | (5, 31), 72 | (6, 30), 73 | (7, 31), 74 | (8, 31), 75 | (9, 30), 76 | (10, 31), 77 | (11, 30), 78 | ), 79 | ) 80 | def test_day_bounds_by_month(month, maxday): 81 | good_date = f"2020-{month:02}-{maxday:02}T00:00:00Z" 82 | bad_date = f"2020-{month:02}-{(maxday + 1):02}T00:00:00Z" 83 | assert validate(good_date) 84 | assert not validate(bad_date) 85 | 86 | 87 | @pytest.mark.parametrize( 88 | "year, maxday", 89 | ( 90 | (2018, 28), 91 | (2016, 29), 92 | (2400, 29), 93 | (2500, 28), 94 | ), 95 | ) 96 | def test_day_bounds_for_february(year, maxday): 97 | good_date = f"{year}-02-{maxday:02}T00:00:00Z" 98 | bad_date = f"{year}-02-{(maxday + 1):02}T00:00:00Z" 99 | assert validate(good_date) 100 | assert not validate(bad_date) 101 | -------------------------------------------------------------------------------- /tests/unit/formats/test_time.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | 5 | from check_jsonschema.formats.implementations.iso8601_time import validate 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "timestr", 10 | ( 11 | "12:34:56Z", 12 | "23:59:59z", 13 | "23:59:59+00:00", 14 | "01:59:59-00:00", 15 | ), 16 | ) 17 | def test_simple_positive_cases(timestr): 18 | assert validate(timestr) 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "timestr", 23 | ( 24 | object(), 25 | "12:34:56", 26 | "23:59:60Z", 27 | "23:59:59+24:00", 28 | "01:59:59-00:60", 29 | "01:01:00:00:60", 30 | ), 31 | ) 32 | def test_simple_negative_cases(timestr): 33 | assert not validate(timestr) 34 | 35 | 36 | @pytest.mark.parametrize("precision", list(range(20))) 37 | @pytest.mark.parametrize( 38 | "offsetstr", 39 | ( 40 | "Z", 41 | "+00:00", 42 | "-00:00", 43 | "+23:59", 44 | ), 45 | ) 46 | def test_allows_fracsec(precision, offsetstr): 47 | fracsec = random.randint(0, 10**precision) 48 | assert validate(f"23:59:59.{fracsec}{offsetstr}") 49 | -------------------------------------------------------------------------------- /tests/unit/test_catalog.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import ruamel.yaml 4 | 5 | from check_jsonschema.catalog import SCHEMA_CATALOG 6 | 7 | yaml = ruamel.yaml.YAML(typ="safe") 8 | HERE = Path(__file__).parent 9 | CONFIG_FILE = HERE.parent.parent / ".pre-commit-hooks.yaml" 10 | 11 | 12 | def test_schema_catalog_is_alphabetized(): 13 | catalog_keys = list(SCHEMA_CATALOG.keys()) 14 | sorted_keys = sorted(catalog_keys) 15 | assert catalog_keys == sorted_keys 16 | 17 | 18 | def test_hooks_cover_catalog(): 19 | with open(CONFIG_FILE, "rb") as fp: 20 | config = yaml.load(fp) 21 | config_hook_ids = {x["id"] for x in config} 22 | catalog_hook_ids = {f"check-{name}" for name in SCHEMA_CATALOG} 23 | assert catalog_hook_ids <= config_hook_ids 24 | -------------------------------------------------------------------------------- /tests/unit/test_gitlab_data_transform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from check_jsonschema.parsers.yaml import ParseError, construct_yaml_implementation 4 | from check_jsonschema.transforms.gitlab import ( 5 | GITLAB_TRANSFORM, 6 | GitLabReferenceExpectationViolation, 7 | ) 8 | 9 | 10 | def test_can_parse_yaml_with_transform(): 11 | rawdata = """\ 12 | a: b 13 | c: d 14 | """ 15 | 16 | impl = construct_yaml_implementation() 17 | 18 | data = impl.load(rawdata) 19 | assert data == {"a": "b", "c": "d"} 20 | 21 | GITLAB_TRANSFORM.modify_yaml_implementation(impl) 22 | data = impl.load(rawdata) 23 | assert data == {"a": "b", "c": "d"} 24 | 25 | 26 | def test_can_parse_ok_gitlab_yaml_with_transform(): 27 | rawdata = """\ 28 | foo: 29 | - !reference [bar, baz] 30 | """ 31 | impl = construct_yaml_implementation() 32 | 33 | with pytest.raises(ParseError): 34 | data = impl.load(rawdata) 35 | 36 | GITLAB_TRANSFORM.modify_yaml_implementation(impl) 37 | data = impl.load(rawdata) 38 | assert data == {"foo": [["bar", "baz"]]} 39 | 40 | 41 | def test_cannot_parse_bad_gitlab_yaml_with_transform(): 42 | rawdata = """\ 43 | foo: 44 | - !reference true 45 | """ 46 | impl = construct_yaml_implementation() 47 | 48 | with pytest.raises(ParseError): 49 | impl.load(rawdata) 50 | 51 | GITLAB_TRANSFORM.modify_yaml_implementation(impl) 52 | with pytest.raises( 53 | GitLabReferenceExpectationViolation, 54 | match=r"check-jsonschema rejects this gitlab \!reference tag: .*", 55 | ): 56 | impl.load(rawdata) 57 | -------------------------------------------------------------------------------- /tests/unit/test_lazy_file_handling.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | 4 | import pytest 5 | 6 | from check_jsonschema.cli.main_command import build_checker 7 | from check_jsonschema.cli.main_command import main as cli_main 8 | 9 | 10 | @pytest.mark.skipif( 11 | platform.system() != "Linux", reason="test requires /proc/self/ mechanism" 12 | ) 13 | def test_open_file_usage_never_exceeds_1000(cli_runner, monkeypatch, tmp_path): 14 | schema_path = tmp_path / "schema.json" 15 | schema_path.write_text("{}") 16 | 17 | args = [ 18 | "--schemafile", 19 | str(schema_path), 20 | ] 21 | 22 | for i in range(2000): 23 | instance_path = tmp_path / f"file{i}.json" 24 | instance_path.write_text("{}") 25 | args.append(str(instance_path)) 26 | 27 | checker = None 28 | 29 | def fake_execute(argv): 30 | nonlocal checker 31 | checker = build_checker(argv) 32 | 33 | monkeypatch.setattr("check_jsonschema.cli.main_command.execute", fake_execute) 34 | res = cli_runner.invoke(cli_main, args) 35 | assert res.exit_code == 0, res.stderr 36 | 37 | assert checker is not None 38 | assert len(os.listdir("/proc/self/fd")) < 2000 39 | for _fname, _data in checker._instance_loader.iter_files(): 40 | assert len(os.listdir("/proc/self/fd")), 2000 41 | -------------------------------------------------------------------------------- /tests/unit/test_schema_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | import pytest 5 | import responses 6 | 7 | from check_jsonschema.schema_loader import SchemaLoader, SchemaParseError 8 | from check_jsonschema.schema_loader.readers import HttpSchemaReader, LocalSchemaReader 9 | 10 | 11 | @pytest.fixture 12 | def in_tmp_dir(request, tmp_path): 13 | os.chdir(str(tmp_path)) 14 | yield 15 | os.chdir(request.config.invocation_dir) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "filename", 20 | [ 21 | "schema.json", 22 | "schema.yaml", 23 | ], 24 | ) 25 | def test_schemaloader_path_handling_relative_local_path(in_tmp_dir, filename): 26 | # ensure that the file exists so that the behavior of pathlib resolution will be 27 | # correct on Windows with older python versions 28 | # see: https://bugs.python.org/issue38671 29 | path = pathlib.Path("path", "to") / filename 30 | path.parent.mkdir(parents=True) 31 | path.touch() 32 | 33 | sl = SchemaLoader(str(path)) 34 | assert isinstance(sl.reader, LocalSchemaReader) 35 | assert sl.reader.filename == os.path.abspath(str(path)) 36 | assert str(sl.reader.path) == str(path.resolve()) 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "filename", 41 | [ 42 | "schema.yaml", 43 | "schema.yml", 44 | "https://foo.example.com/schema.yaml", 45 | "https://foo.example.com/schema.yml", 46 | ], 47 | ) 48 | def test_schemaloader_yaml_data(tmp_path, filename): 49 | schema_text = """ 50 | --- 51 | "$schema": https://json-schema.org/draft/2020-12/schema 52 | type: object 53 | properties: 54 | a: 55 | type: object 56 | properties: 57 | b: 58 | type: array 59 | items: 60 | type: integer 61 | c: 62 | type: string 63 | """ 64 | if filename.startswith("http"): 65 | responses.add("GET", filename, body=schema_text) 66 | path = filename 67 | else: 68 | f = tmp_path / filename 69 | f.write_text(schema_text) 70 | path = str(f) 71 | sl = SchemaLoader(path) 72 | schema = sl.get_schema() 73 | assert schema == { 74 | "$schema": "https://json-schema.org/draft/2020-12/schema", 75 | "type": "object", 76 | "properties": { 77 | "a": { 78 | "type": "object", 79 | "properties": { 80 | "b": {"type": "array", "items": {"type": "integer"}}, 81 | "c": {"type": "string"}, 82 | }, 83 | }, 84 | }, 85 | } 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "schemafile", 90 | [ 91 | "https://foo.example.com/schema.json", 92 | "http://foo.example.com/schema.json", 93 | ], 94 | ) 95 | def test_schemaloader_remote_path(schemafile): 96 | sl = SchemaLoader(schemafile) 97 | assert isinstance(sl.reader, HttpSchemaReader) 98 | assert sl.reader.url == schemafile 99 | 100 | 101 | def test_schemaloader_local_yaml_dup_anchor(tmp_path): 102 | f = tmp_path / "schema.yaml" 103 | f.write_text( 104 | """ 105 | --- 106 | "$schema": https://json-schema.org/draft/2020-12/schema 107 | type: object 108 | properties: 109 | a: 110 | type: object 111 | properties: 112 | b: &anchor 113 | type: array 114 | items: 115 | type: integer 116 | c: &anchor 117 | type: string 118 | """ 119 | ) 120 | sl = SchemaLoader(str(f)) 121 | schema = sl.get_schema() 122 | assert schema == { 123 | "$schema": "https://json-schema.org/draft/2020-12/schema", 124 | "type": "object", 125 | "properties": { 126 | "a": { 127 | "type": "object", 128 | "properties": { 129 | "b": {"type": "array", "items": {"type": "integer"}}, 130 | "c": {"type": "string"}, 131 | }, 132 | }, 133 | }, 134 | } 135 | 136 | 137 | def test_schemaloader_invalid_yaml_data(tmp_path): 138 | f = tmp_path / "foo.yaml" 139 | f.write_text( 140 | """\ 141 | a: {b 142 | """ 143 | ) 144 | sl = SchemaLoader(str(f)) 145 | with pytest.raises(SchemaParseError): 146 | sl.get_schema() 147 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import sys 4 | 5 | import pytest 6 | 7 | from check_jsonschema.utils import filename2path 8 | 9 | 10 | @pytest.mark.skipif( 11 | not (platform.system() == "Linux"), reason="test requires /proc/self/ mechanism" 12 | ) 13 | @pytest.mark.skipif(sys.version_info < (3, 8), reason="test uses os.memfd_create") 14 | @pytest.mark.parametrize("use_pid_in_path", (True, False)) 15 | def test_filename2path_on_memfd(use_pid_in_path): 16 | """ 17 | create a memory file descriptor with a path in /proc/self/fd/ 18 | and then attempt to resolve that to an absolute Path object 19 | the end result should be untouched 20 | 21 | pathlib behavior is, for example, 22 | 23 | >>> pathlib.Path("/proc/self/fd/4").resolve() 24 | PosixPath('/memfd:myfd (deleted)') 25 | """ 26 | testfd = os.memfd_create("test_filename2path") 27 | try: 28 | pid = os.getpid() if use_pid_in_path else "self" 29 | filename = f"/proc/{pid}/fd/{testfd}" 30 | path = filename2path(filename) 31 | 32 | assert str(path) == filename 33 | finally: 34 | os.close(testfd) 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | mypy 4 | cov_clean 5 | py39-mindeps{,-format} 6 | py{313,312,311,310,39} 7 | py{39,313}-{json5,pyjson5}{,-format} 8 | py{39,313}-{disable_orjson} 9 | cov_combine 10 | cov_report 11 | skip_missing_interpreters = true 12 | minversion = 4.0.0 13 | 14 | labels = 15 | ci = py, py-notoml, py-tomli-format, py-json5, py-pyjson5 16 | 17 | [testenv] 18 | description = "run tests with pytest" 19 | usedevelop = true 20 | extras = dev 21 | deps = 22 | # attrs v23.2.0 is needed for mindeps because jsonschema==4.18.0 23 | # is uses `hash=True` which was deprecated after this version 24 | mindeps: attrs==23.2.0 25 | mindeps: jsonschema==4.18.0 26 | mindeps: click==8.0.0 27 | mindeps: requests==2.0.0 28 | !disable_orjson: orjson 29 | json5: json5 30 | pyjson5: pyjson5 31 | format: jsonschema[format] 32 | commands = 33 | coverage run -m pytest {posargs:--junitxml={envdir}/pytest.xml} 34 | depends = cov_clean 35 | 36 | [testenv:cov_clean] 37 | description = "erase coverage data to prepare for a new run" 38 | deps = coverage 39 | skip_install = true 40 | commands = coverage erase 41 | depends = 42 | 43 | [testenv:cov_combine] 44 | description = "combine coverage data" 45 | deps = coverage 46 | skip_install = true 47 | commands = coverage combine 48 | depends = py{,38,39,310,311,312,313}{,-mindeps,-format,-json5,-pyjson5,-disable_orjson} 49 | 50 | [testenv:cov_report] 51 | description = "report test coverage" 52 | deps = coverage 53 | skip_install = true 54 | commands = coverage report --skip-covered 55 | depends = cov_combine 56 | 57 | [testenv:mypy] 58 | description = "check type annotations with mypy" 59 | deps = mypy 60 | types-jsonschema 61 | types-requests 62 | click 63 | commands = mypy src/ {posargs} 64 | depends = 65 | 66 | [testenv:pyright] 67 | description = "check type annotations with pyright" 68 | deps = pyright 69 | types-jsonschema 70 | types-requests 71 | commands = pyright src/ {posargs} 72 | 73 | [testenv:docs] 74 | description = "build docs with sphinx" 75 | basepython = python3.10 76 | extras = docs 77 | allowlist_externals = rm 78 | changedir = docs/ 79 | # clean the build dir before rebuilding 80 | commands_pre = rm -rf _build/ 81 | commands = sphinx-build -d _build/doctrees -b dirhtml -W . _build/dirhtml {posargs} 82 | 83 | [testenv:twine-check] 84 | description = "check the metadata on a package build" 85 | skip_install = true 86 | deps = twine 87 | build 88 | allowlist_externals = rm 89 | commands_pre = rm -rf dist/ 90 | # check that twine validating package data works 91 | commands = python -m build 92 | twine check dist/* 93 | 94 | [testenv:vendor-schemas] 95 | description = "update the vendored schemas" 96 | deps = pre-commit 97 | commands = python ./scripts/vendor-schemas.py 98 | 99 | [testenv:generate-hooks-config] 100 | description = "update autogenerated pre-commit hooks" 101 | commands = python ./scripts/generate-hooks-config.py 102 | --------------------------------------------------------------------------------