diff --git a/.github/workflows/update-schemastore.yaml b/.github/workflows/update-schemastore.yaml new file mode 100644 index 0000000000..933f354785 --- /dev/null +++ b/.github/workflows/update-schemastore.yaml @@ -0,0 +1,64 @@ +name: Update SchemaStore +on: + push: + tags: ["*"] + +permissions: + contents: read + +jobs: + update-schemastore: + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ secrets.SCHEMASTORE_TOKEN }} + BRANCH: update-tox-schema + steps: + - uses: actions/checkout@v6 + - name: Fork and clone SchemaStore + run: gh repo fork SchemaStore/schemastore --clone --remote -- /tmp/schemastore + - name: Create or reset branch + run: git switch -C "$BRANCH" + working-directory: /tmp/schemastore + - name: Copy schema with SchemaStore $id + run: | + python3 -c " + import json + with open('${{ github.workspace }}/src/tox/tox.schema.json') as f: + schema = json.load(f) + schema['\$id'] = 'https://json.schemastore.org/tox.json' + with open('/tmp/schemastore/src/schemas/json/tox.json', 'w') as f: + json.dump(schema, f, indent=2) + f.write('\n') + " + - name: Check for changes + id: diff + run: | + git add src/schemas/json/tox.json + if git diff --cached --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + working-directory: /tmp/schemastore + - name: Commit and push + if: steps.diff.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Update tox JSON Schema to ${{ github.ref_name }}" + git push --force origin "$BRANCH" + working-directory: /tmp/schemastore + - name: Create or update pull request + if: steps.diff.outputs.changed == 'true' + run: | + if ! gh pr view "$BRANCH" --repo SchemaStore/schemastore > /dev/null 2>&1; then + gh pr create \ + --repo SchemaStore/schemastore \ + --title "Update tox JSON Schema to ${{ github.ref_name }}" \ + --body "Updates tox's JSON Schema to [${{ github.sha }}](https://github.com/tox-dev/tox/commit/${{ github.sha }}) (release ${{ github.ref_name }})." + else + gh pr edit "$BRANCH" --repo SchemaStore/schemastore \ + --title "Update tox JSON Schema to ${{ github.ref_name }}" \ + --body "Updates tox's JSON Schema to [${{ github.sha }}](https://github.com/tox-dev/tox/commit/${{ github.sha }}) (release ${{ github.ref_name }})." + fi + working-directory: /tmp/schemastore diff --git a/docs/changelog/1388.feature.rst b/docs/changelog/1388.feature.rst new file mode 100644 index 0000000000..9021c2a5de --- /dev/null +++ b/docs/changelog/1388.feature.rst @@ -0,0 +1,3 @@ +Enhance ``tox schema`` command: add ``x-taplo`` metadata for IDE integration, product dict support for ``env_list``, +handle ``int`` and ``PythonConstraints`` types, fix ``$schema`` draft-07 URI, and add schema freshness test. Add +``tox.toml`` to SchemaStore catalog for automatic IDE validation - by :user:`gaborbernat`. diff --git a/src/tox/session/cmd/schema.py b/src/tox/session/cmd/schema.py index ae98b970e9..c96ff385fa 100644 --- a/src/tox/session/cmd/schema.py +++ b/src/tox/session/cmd/schema.py @@ -35,8 +35,9 @@ def gen_schema(state: State) -> int: core = state.conf.core strict = state.conf.options.strict - # Accessing this adds extra stuff to core, so we need to do it first - env_properties = _get_schema(state.envs["py"].conf, path="#/properties/env_run_base/properties") + # Use any available run environment for introspection (fall back to "py" which is always defined) + env_name = next(state.envs.iter(only_active=False), "py") + env_properties = _get_schema(state.envs[env_name].conf, path="#/properties/env_run_base/properties") properties = _get_schema(core, path="#/properties") @@ -55,23 +56,41 @@ def gen_schema(state: State) -> int: "properties": _get_schema(conf, path=f"#/properties/{key}/properties"), } + docs_base = "https://tox.wiki/en/stable" json_schema = { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://github.com/tox-dev/tox/blob/main/src/tox/util/tox.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/tox-dev/tox/main/src/tox/tox.schema.json", + "title": "tox configuration", + "description": "tox configuration file (tox.toml or [tool.tox] in pyproject.toml)", + "x-taplo": {"links": {"key": f"{docs_base}/config.html"}}, "type": "object", "properties": { **properties, "env_run_base": { "type": "object", + "description": "base configuration for run environments", + "x-taplo": {"links": {"key": f"{docs_base}/config.html#run-environment"}}, "properties": env_properties, "additionalProperties": not strict, }, "env_pkg_base": { + "type": "object", "$ref": "#/properties/env_run_base", + "description": "base configuration for packaging environments", + "x-taplo": {"links": {"key": f"{docs_base}/config.html#packaging-environment"}}, "additionalProperties": not strict, }, - "env": {"type": "object", "patternProperties": {"^.*$": {"$ref": "#/properties/env_run_base"}}}, - "legacy_tox_ini": {"type": "string"}, + "env": { + "type": "object", + "description": "per-environment overrides (keyed by environment name)", + "x-taplo": {"links": {"key": f"{docs_base}/config.html#run-environment"}}, + "patternProperties": {"^.*$": {"$ref": "#/properties/env_run_base"}}, + }, + "legacy_tox_ini": { + "type": "string", + "description": "tox configuration in INI format embedded in a TOML file", + "x-taplo": {"links": {"key": f"{docs_base}/config.html#pyproject-toml-ini"}}, + }, }, "additionalProperties": not strict, "definitions": { @@ -119,11 +138,10 @@ def gen_schema(state: State) -> int: def _get_schema(conf: ConfigSet, path: str) -> dict[str, dict[str, typing.Any]]: - properties = {} + properties: dict[str, dict[str, typing.Any]] = {} for x in conf.get_configs(): name, *aliases = x.keys - of_type = getattr(x, "of_type", None) - if of_type is None: + if (of_type := getattr(x, "of_type", None)) is None: continue desc = getattr(x, "desc", None) try: @@ -131,17 +149,22 @@ def _get_schema(conf: ConfigSet, path: str) -> dict[str, dict[str, typing.Any]]: except ValueError: print(name, "has unrecoginsed type:", of_type, file=sys.stderr) # noqa: T201 for alias in aliases: - properties[alias] = {"$ref": f"{path}/{name}"} + properties[alias] = { + "$ref": f"{path}/{name}", + "description": f"Deprecated: use {name!r} instead", + "deprecated": True, + } return properties -def _process_type(of_type: typing.Any) -> dict[str, typing.Any]: # noqa: C901, PLR0911 +def _process_type(of_type: typing.Any) -> dict[str, typing.Any]: # noqa: C901, PLR0911, PLR0912 if of_type in { Path, str, packaging.version.Version, packaging.requirements.Requirement, tox.tox_env.python.pip.req_file.PythonDeps, + tox.tox_env.python.pip.req_file.PythonConstraints, }: return {"type": "string"} if typing.get_origin(of_type) is typing.Union: @@ -152,11 +175,53 @@ def _process_type(of_type: typing.Any) -> dict[str, typing.Any]: # noqa: C901, raise ValueError(msg) if of_type is bool: return {"type": "boolean"} + if of_type is int: + return {"type": "integer", "minimum": 0} if of_type is float: return {"type": "number"} if typing.get_origin(of_type) is typing.Literal: return {"enum": list(typing.get_args(of_type))} - if of_type in {tox.config.types.Command, tox.config.types.EnvList}: + if of_type is tox.config.types.EnvList: + return { + "type": "array", + "items": { + "oneOf": [ + {"$ref": "#/definitions/subs"}, + { + "type": "object", + "required": ["product"], + "properties": { + "product": { + "type": "array", + "items": { + "oneOf": [ + {"type": "array", "items": {"type": "string"}}, + { + "type": "object", + "required": ["prefix"], + "properties": { + "prefix": {"type": "string"}, + "start": {"type": "integer"}, + "stop": {"type": "integer"}, + }, + "additionalProperties": False, + }, + ], + }, + "description": "factor groups for cartesian product expansion", + }, + "exclude": { + "type": "array", + "items": {"type": "string"}, + "description": "environment names to exclude from product", + }, + }, + "additionalProperties": False, + }, + ], + }, + } + if of_type is tox.config.types.Command: return {"type": "array", "items": {"$ref": "#/definitions/subs"}} if typing.get_origin(of_type) in {list, set}: if typing.get_args(of_type)[0] in {str, packaging.requirements.Requirement}: diff --git a/src/tox/tox.schema.json b/src/tox/tox.schema.json index e213227ca5..d9f4fe2637 100644 --- a/src/tox/tox.schema.json +++ b/src/tox/tox.schema.json @@ -1,6 +1,13 @@ { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://github.com/tox-dev/tox/blob/main/src/tox/util/tox.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/tox-dev/tox/main/src/tox/tox.schema.json", + "title": "tox configuration", + "description": "tox configuration file (tox.toml or [tool.tox] in pyproject.toml)", + "x-taplo": { + "links": { + "key": "https://tox.wiki/en/stable/config.html" + } + }, "type": "object", "properties": { "tox_root": { @@ -8,14 +15,18 @@ "description": "the root directory (where the configuration file is found)" }, "toxinidir": { - "$ref": "#/properties/tox_root" + "$ref": "#/properties/tox_root", + "description": "Deprecated: use 'tox_root' instead", + "deprecated": true }, "work_dir": { "type": "string", "description": "working directory" }, "toxworkdir": { - "$ref": "#/properties/work_dir" + "$ref": "#/properties/work_dir", + "description": "Deprecated: use 'work_dir' instead", + "deprecated": true }, "temp_dir": { "type": "string", @@ -30,6 +41,7 @@ }, { "type": "object", + "required": ["product"], "properties": { "product": { "type": "array", @@ -69,7 +81,6 @@ "description": "environment names to exclude from product" } }, - "required": ["product"], "additionalProperties": false } ] @@ -77,7 +88,9 @@ "description": "define environments to automatically run" }, "envlist": { - "$ref": "#/properties/env_list" + "$ref": "#/properties/env_list", + "description": "Deprecated: use 'env_list' instead", + "deprecated": true }, "base": { "type": "array", @@ -91,7 +104,9 @@ "description": "Define the minimal tox version required to run" }, "minversion": { - "$ref": "#/properties/min_version" + "$ref": "#/properties/min_version", + "description": "Deprecated: use 'min_version' instead", + "deprecated": true }, "provision_tox_env": { "type": "string", @@ -109,7 +124,55 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/definitions/subs" + "oneOf": [ + { + "$ref": "#/definitions/subs" + }, + { + "type": "object", + "required": ["product"], + "properties": { + "product": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "required": ["prefix"], + "properties": { + "prefix": { + "type": "string" + }, + "start": { + "type": "integer" + }, + "stop": { + "type": "integer" + } + }, + "additionalProperties": false + } + ] + }, + "description": "factor groups for cartesian product expansion" + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "environment names to exclude from product" + } + }, + "additionalProperties": false + } + ] } }, "description": "core labels" @@ -119,7 +182,9 @@ "description": "do not raise error if the environment name conflicts with base python" }, "ignore_basepython_conflict": { - "$ref": "#/properties/ignore_base_python_conflict" + "$ref": "#/properties/ignore_base_python_conflict", + "description": "Deprecated: use 'ignore_base_python_conflict' instead", + "deprecated": true }, "skip_missing_interpreters": { "type": "boolean", @@ -130,24 +195,36 @@ "description": "is there any packaging involved in this project" }, "skipsdist": { - "$ref": "#/properties/no_package" + "$ref": "#/properties/no_package", + "description": "Deprecated: use 'no_package' instead", + "deprecated": true }, "package_env": { "type": "string", "description": "tox environment used to package" }, "isolated_build_env": { - "$ref": "#/properties/package_env" + "$ref": "#/properties/package_env", + "description": "Deprecated: use 'package_env' instead", + "deprecated": true }, "package_root": { "type": "string", "description": "indicates where the packaging root file exists (historically setup.py file or pyproject.toml now)" }, "setupdir": { - "$ref": "#/properties/package_root" + "$ref": "#/properties/package_root", + "description": "Deprecated: use 'package_root' instead", + "deprecated": true }, "env_run_base": { "type": "object", + "description": "base configuration for run environments", + "x-taplo": { + "links": { + "key": "https://tox.wiki/en/stable/config.html#run-environment" + } + }, "properties": { "set_env": { "type": "object", @@ -157,7 +234,9 @@ "description": "environment variables to set when running commands in the tox environment" }, "setenv": { - "$ref": "#/properties/env_run_base/properties/set_env" + "$ref": "#/properties/env_run_base/properties/set_env", + "description": "Deprecated: use 'set_env' instead", + "deprecated": true }, "base": { "type": "array", @@ -177,7 +256,55 @@ "depends": { "type": "array", "items": { - "$ref": "#/definitions/subs" + "oneOf": [ + { + "$ref": "#/definitions/subs" + }, + { + "type": "object", + "required": ["product"], + "properties": { + "product": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "required": ["prefix"], + "properties": { + "prefix": { + "type": "string" + }, + "start": { + "type": "integer" + }, + "stop": { + "type": "integer" + } + }, + "additionalProperties": false + } + ] + }, + "description": "factor groups for cartesian product expansion" + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "environment names to exclude from product" + } + }, + "additionalProperties": false + } + ] }, "description": "tox environments that this environment depends on (must be run after those)" }, @@ -193,21 +320,27 @@ "description": "directory assigned to the tox environment" }, "envdir": { - "$ref": "#/properties/env_run_base/properties/env_dir" + "$ref": "#/properties/env_run_base/properties/env_dir", + "description": "Deprecated: use 'env_dir' instead", + "deprecated": true }, "env_tmp_dir": { "type": "string", "description": "a folder that is always reset at the start of the run" }, "envtmpdir": { - "$ref": "#/properties/env_run_base/properties/env_tmp_dir" + "$ref": "#/properties/env_run_base/properties/env_tmp_dir", + "description": "Deprecated: use 'env_tmp_dir' instead", + "deprecated": true }, "env_log_dir": { "type": "string", "description": "a folder for logging where tox will put logs of tool invocation" }, "envlogdir": { - "$ref": "#/properties/env_run_base/properties/env_log_dir" + "$ref": "#/properties/env_run_base/properties/env_log_dir", + "description": "Deprecated: use 'env_log_dir' instead", + "deprecated": true }, "suicide_timeout": { "type": "number", @@ -233,7 +366,9 @@ "description": "environment variables to pass on to the tox environment" }, "passenv": { - "$ref": "#/properties/env_run_base/properties/pass_env" + "$ref": "#/properties/env_run_base/properties/pass_env", + "description": "Deprecated: use 'pass_env' instead", + "deprecated": true }, "disallow_pass_env": { "type": "array", @@ -275,6 +410,10 @@ }, "description": "command used to install packages" }, + "constraints": { + "type": "string", + "description": "constraints to apply to installed python dependencies" + }, "constrain_package_deps": { "type": "boolean", "description": "If true, apply constraints during install_package_deps." @@ -328,7 +467,9 @@ "description": "change to this working directory when executing the test command" }, "changedir": { - "$ref": "#/properties/env_run_base/properties/change_dir" + "$ref": "#/properties/env_run_base/properties/change_dir", + "description": "Deprecated: use 'change_dir' instead", + "deprecated": true }, "args_are_paths": { "type": "boolean", @@ -347,6 +488,17 @@ "type": "boolean", "description": "if set to true a failing result of this testenv will not make tox fail (instead just warn)" }, + "fail_fast": { + "type": "boolean", + "description": "if set to true, tox will stop executing remaining environments when this environment fails" + }, + "default_base_python": { + "type": "array", + "items": { + "$ref": "#/definitions/subs" + }, + "description": "fallback python interpreter used when no factor or explicit base_python is defined" + }, "base_python": { "type": "array", "items": { @@ -355,48 +507,72 @@ "description": "environment identifier for python, first one found wins" }, "basepython": { - "$ref": "#/properties/env_run_base/properties/base_python" - }, - "skip_missing_interpreters": { - "type": "boolean", - "description": "override core skip_missing_interpreters for this environment" + "$ref": "#/properties/env_run_base/properties/base_python", + "description": "Deprecated: use 'base_python' instead", + "deprecated": true }, "deps": { "type": "string", "description": "python dependencies with optional version specifiers, as specified by PEP-440" }, - "constraints": { + "dependency_groups": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/subs" }, - "description": "constraints to apply to installed python dependencies" + "description": "dependency groups to install of the target package" }, - "dependency_groups": { + "extras": { "type": "array", "items": { "$ref": "#/definitions/subs" }, - "description": "dependency groups to install of the target package" + "description": "extras to install of the target package" + }, + "pylock": { + "type": "string", + "description": "PEP 751 pylock.toml lock file path to install locked dependencies from" + }, + "extra_setup_commands": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/subs" + } + }, + "description": "commands to execute after setup (deps and package install) but before test commands" + }, + "skip_missing_interpreters": { + "type": "boolean", + "description": "override core skip_missing_interpreters for this environment" }, "system_site_packages": { "type": "boolean", "description": "create virtual environments that also have access to globally installed packages." }, "sitepackages": { - "$ref": "#/properties/env_run_base/properties/system_site_packages" + "$ref": "#/properties/env_run_base/properties/system_site_packages", + "description": "Deprecated: use 'system_site_packages' instead", + "deprecated": true }, "always_copy": { "type": "boolean", "description": "force virtualenv to always copy rather than symlink" }, "alwayscopy": { - "$ref": "#/properties/env_run_base/properties/always_copy" + "$ref": "#/properties/env_run_base/properties/always_copy", + "description": "Deprecated: use 'always_copy' instead", + "deprecated": true }, "download": { "type": "boolean", "description": "true if you want virtualenv to upgrade pip/wheel/setuptools to the latest version" }, + "virtualenv_spec": { + "type": "string", + "description": "PEP 440 version spec for virtualenv (e.g. virtualenv<20.22.0). When set, tox bootstraps this version in an isolated environment and runs it via subprocess, enabling Python versions incompatible with the installed virtualenv." + }, "skip_install": { "type": "boolean", "description": "skip installation" @@ -406,36 +582,40 @@ "description": "use develop mode" }, "usedevelop": { - "$ref": "#/properties/env_run_base/properties/use_develop" + "$ref": "#/properties/env_run_base/properties/use_develop", + "description": "Deprecated: use 'use_develop' instead", + "deprecated": true }, "package": { "type": "string", "description": "package installation mode - wheel | sdist | sdist-wheel | editable | editable-legacy | deps-only | skip | external " }, - "extras": { - "type": "array", - "items": { - "$ref": "#/definitions/subs" - }, - "description": "extras to install of the target package" - }, "package_env": { "type": "string", "description": "tox environment used to package" - }, - "wheel_build_env": { - "type": "string", - "description": "wheel tag to use for building applications" } }, "additionalProperties": true }, "env_pkg_base": { + "type": "object", "$ref": "#/properties/env_run_base", + "description": "base configuration for packaging environments", + "x-taplo": { + "links": { + "key": "https://tox.wiki/en/stable/config.html#packaging-environment" + } + }, "additionalProperties": true }, "env": { "type": "object", + "description": "per-environment overrides (keyed by environment name)", + "x-taplo": { + "links": { + "key": "https://tox.wiki/en/stable/config.html#run-environment" + } + }, "patternProperties": { "^.*$": { "$ref": "#/properties/env_run_base" @@ -443,7 +623,13 @@ } }, "legacy_tox_ini": { - "type": "string" + "type": "string", + "description": "tox configuration in INI format embedded in a TOML file", + "x-taplo": { + "links": { + "key": "https://tox.wiki/en/stable/config.html#pyproject-toml-ini" + } + } } }, "additionalProperties": true, diff --git a/tests/session/cmd/test_schema.py b/tests/session/cmd/test_schema.py index 53e53b14d7..74a2b41402 100644 --- a/tests/session/cmd/test_schema.py +++ b/tests/session/cmd/test_schema.py @@ -1,13 +1,14 @@ from __future__ import annotations import json +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: - from pathlib import Path - from tox.pytest import MonkeyPatch, ToxProjectCreator +SCHEMA_PATH = Path(__file__).parents[3] / "src" / "tox" / "tox.schema.json" + def test_show_schema_empty_dir(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.chdir(tmp_path) @@ -17,3 +18,17 @@ def test_show_schema_empty_dir(tox_project: ToxProjectCreator, monkeypatch: Monk schema = json.loads(result.out) assert "properties" in schema assert "tox_root" in schema["properties"] + + +def test_schema_freshness(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + project = tox_project({ + "tox.toml": 'env_list = ["py"]', + "pyproject.toml": '[build-system]\nrequires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"', + }) + result = project.run("-qq", "schema") + generated = json.loads(result.out) + committed = json.loads(SCHEMA_PATH.read_text()) + assert generated == committed, ( + "tox.schema.json is out of date — regenerate with: tox schema > src/tox/tox.schema.json" + )