Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding a json schema command #3446

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/3446.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add a ``schema`` command to produce a JSON Schema for tox and the current plugins.

- by :user:`henryiii`
8 changes: 7 additions & 1 deletion src/tox/config/sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, TypeVar, cast
from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Mapping, Sequence, TypeVar, cast

from .of_type import ConfigConstantDefinition, ConfigDefinition, ConfigDynamicDefinition, ConfigLoadArgs
from .set_env import SetEnv
Expand Down Expand Up @@ -33,6 +33,12 @@ def __init__(self, conf: Config, section: Section, env_name: str | None) -> None
self._final = False
self.register_config()

def get_configs(self) -> Generator[ConfigDefinition[Any], None, None]:
""":return: a mapping of config keys to their definitions"""
for k, v in self._defined.items():
if k == next(iter(v.keys)):
yield v

@abstractmethod
def register_config(self) -> None:
raise NotImplementedError
Expand Down
2 changes: 2 additions & 0 deletions src/tox/plugin/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
legacy,
list_env,
quickstart,
schema,
show_config,
version_flag,
)
Expand All @@ -60,6 +61,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
exec_,
quickstart,
show_config,
schema,
devenv,
list_env,
depends,
Expand Down
176 changes: 176 additions & 0 deletions src/tox/session/cmd/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Generate schema for tox configuration, respecting the current plugins."""

from __future__ import annotations

import json
import sys
import typing
from pathlib import Path
from typing import TYPE_CHECKING

import packaging.requirements
import packaging.version

import tox.config.set_env
import tox.config.types
import tox.tox_env.python.pip.req_file
from tox.plugin import impl

if TYPE_CHECKING:
from tox.config.cli.parser import ToxParser
from tox.config.sets import ConfigSet
from tox.session.state import State


@impl
def tox_add_option(parser: ToxParser) -> None:
our = parser.add_command("schema", [], "Generate schema for tox configuration", gen_schema)
our.add_argument("--strict", action="store_true", help="Disallow extra properties in configuration")


def _process_type(of_type: typing.Any) -> dict[str, typing.Any]: # noqa: C901, PLR0911
if of_type in {
Path,
str,
packaging.version.Version,
packaging.requirements.Requirement,
tox.tox_env.python.pip.req_file.PythonDeps,
}:
return {"type": "string"}
if typing.get_origin(of_type) is typing.Union:
types = [x for x in typing.get_args(of_type) if x is not type(None)]
if len(types) == 1:
return _process_type(types[0])
msg = f"Union types are not supported: {of_type}"
raise ValueError(msg)
if of_type is bool:
return {"type": "boolean"}
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}:
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}:
return {"type": "array", "items": {"$ref": "#/definitions/subs"}}
if typing.get_args(of_type)[0] is tox.config.types.Command:
return {"type": "array", "items": _process_type(typing.get_args(of_type)[0])}
msg = f"Unknown list type: {of_type}"
raise ValueError(msg)
if of_type is tox.config.set_env.SetEnv:
return {
"type": "object",
"additionalProperties": {"$ref": "#/definitions/subs"},
}
if typing.get_origin(of_type) is dict:
return {
"type": "object",
"additionalProperties": {**_process_type(typing.get_args(of_type)[1])},
}
msg = f"Unknown type: {of_type}"
raise ValueError(msg)


def _get_schema(conf: ConfigSet, path: str) -> dict[str, dict[str, typing.Any]]:
properties = {}
for x in conf.get_configs():
name, *aliases = x.keys
of_type = getattr(x, "of_type", None)
if of_type is None:
continue
desc = getattr(x, "desc", None)
try:
properties[name] = {**_process_type(of_type), "description": desc}
except ValueError:
print(name, "has unrecoginsed type:", of_type, file=sys.stderr) # noqa: T201
for alias in aliases:
properties[alias] = {"$ref": f"{path}/{name}"}
return properties


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")

properties = _get_schema(core, path="#/properties")

# This accesses plugins that register new sections (like tox-gh)
# Accessing a private member since this is not exposed yet and the
# interface includes the internal storage tuple
sections = {
key: conf
for s, conf in state.conf._key_to_conf_set.items() # noqa: SLF001
if (key := s[0].split(".")[0]) not in {"env_run_base", "env_pkg_base", "env"}
}
for key, conf in sections.items():
properties[key] = {
"type": "object",
"additionalProperties": not strict,
"properties": _get_schema(conf, path=f"#/properties/{key}/properties"),
}

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",
"type": "object",
"properties": {
**properties,
"env_run_base": {
"type": "object",
"properties": env_properties,
"additionalProperties": not strict,
},
"env_pkg_base": {
"$ref": "#/properties/env_run_base",
"additionalProperties": not strict,
},
"env": {"type": "object", "patternProperties": {"^.*$": {"$ref": "#/properties/env_run_base"}}},
"legacy_tox_ini": {"type": "string"},
},
"additionalProperties": not strict,
"definitions": {
"subs": {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"replace": {"type": "string"},
"name": {"type": "string"},
"default": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
]
},
"extend": {"type": "boolean"},
},
"required": ["replace"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"replace": {"type": "string"},
"of": {"type": "array", "items": {"type": "string"}},
"default": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
]
},
"extend": {"type": "boolean"},
},
"required": ["replace", "of"],
"additionalProperties": False,
},
],
},
},
}
print(json.dumps(json_schema, indent=2)) # noqa: T201
return 0
Loading