Skip to content

Commit 825c68b

Browse files
authored
feat: adding a json schema command (#3446)
* feat: adding a schema command Now running this passes: uvx check-jsonschema --schemafile src/tox/tox.schema.json tox.toml Signed-off-by: Henry Schreiner <[email protected]> * refactor: leave access private Signed-off-by: Henry Schreiner <[email protected]> * fix: changelog and update test list Signed-off-by: Henry Schreiner <[email protected]> --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent fccbe2a commit 825c68b

File tree

7 files changed

+648
-1
lines changed

7 files changed

+648
-1
lines changed

docs/changelog/3446.feature.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add a ``schema`` command to produce a JSON Schema for tox and the current plugins.
2+
3+
- by :user:`henryiii`

src/tox/config/sets.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44
from abc import ABC, abstractmethod
55
from pathlib import Path
6-
from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, TypeVar, cast
6+
from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Mapping, Sequence, TypeVar, cast
77

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

36+
def get_configs(self) -> Generator[ConfigDefinition[Any], None, None]:
37+
""":return: a mapping of config keys to their definitions"""
38+
for k, v in self._defined.items():
39+
if k == next(iter(v.keys)):
40+
yield v
41+
3642
@abstractmethod
3743
def register_config(self) -> None:
3844
raise NotImplementedError

src/tox/plugin/manager.py

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
4242
legacy,
4343
list_env,
4444
quickstart,
45+
schema,
4546
show_config,
4647
version_flag,
4748
)
@@ -60,6 +61,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
6061
exec_,
6162
quickstart,
6263
show_config,
64+
schema,
6365
devenv,
6466
list_env,
6567
depends,

src/tox/session/cmd/schema.py

+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Generate schema for tox configuration, respecting the current plugins."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
import typing
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING
10+
11+
import packaging.requirements
12+
import packaging.version
13+
14+
import tox.config.set_env
15+
import tox.config.types
16+
import tox.tox_env.python.pip.req_file
17+
from tox.plugin import impl
18+
19+
if TYPE_CHECKING:
20+
from tox.config.cli.parser import ToxParser
21+
from tox.config.sets import ConfigSet
22+
from tox.session.state import State
23+
24+
25+
@impl
26+
def tox_add_option(parser: ToxParser) -> None:
27+
our = parser.add_command("schema", [], "Generate schema for tox configuration", gen_schema)
28+
our.add_argument("--strict", action="store_true", help="Disallow extra properties in configuration")
29+
30+
31+
def _process_type(of_type: typing.Any) -> dict[str, typing.Any]: # noqa: C901, PLR0911
32+
if of_type in {
33+
Path,
34+
str,
35+
packaging.version.Version,
36+
packaging.requirements.Requirement,
37+
tox.tox_env.python.pip.req_file.PythonDeps,
38+
}:
39+
return {"type": "string"}
40+
if typing.get_origin(of_type) is typing.Union:
41+
types = [x for x in typing.get_args(of_type) if x is not type(None)]
42+
if len(types) == 1:
43+
return _process_type(types[0])
44+
msg = f"Union types are not supported: {of_type}"
45+
raise ValueError(msg)
46+
if of_type is bool:
47+
return {"type": "boolean"}
48+
if of_type is float:
49+
return {"type": "number"}
50+
if typing.get_origin(of_type) is typing.Literal:
51+
return {"enum": list(typing.get_args(of_type))}
52+
if of_type in {tox.config.types.Command, tox.config.types.EnvList}:
53+
return {"type": "array", "items": {"$ref": "#/definitions/subs"}}
54+
if typing.get_origin(of_type) in {list, set}:
55+
if typing.get_args(of_type)[0] in {str, packaging.requirements.Requirement}:
56+
return {"type": "array", "items": {"$ref": "#/definitions/subs"}}
57+
if typing.get_args(of_type)[0] is tox.config.types.Command:
58+
return {"type": "array", "items": _process_type(typing.get_args(of_type)[0])}
59+
msg = f"Unknown list type: {of_type}"
60+
raise ValueError(msg)
61+
if of_type is tox.config.set_env.SetEnv:
62+
return {
63+
"type": "object",
64+
"additionalProperties": {"$ref": "#/definitions/subs"},
65+
}
66+
if typing.get_origin(of_type) is dict:
67+
return {
68+
"type": "object",
69+
"additionalProperties": {**_process_type(typing.get_args(of_type)[1])},
70+
}
71+
msg = f"Unknown type: {of_type}"
72+
raise ValueError(msg)
73+
74+
75+
def _get_schema(conf: ConfigSet, path: str) -> dict[str, dict[str, typing.Any]]:
76+
properties = {}
77+
for x in conf.get_configs():
78+
name, *aliases = x.keys
79+
of_type = getattr(x, "of_type", None)
80+
if of_type is None:
81+
continue
82+
desc = getattr(x, "desc", None)
83+
try:
84+
properties[name] = {**_process_type(of_type), "description": desc}
85+
except ValueError:
86+
print(name, "has unrecoginsed type:", of_type, file=sys.stderr) # noqa: T201
87+
for alias in aliases:
88+
properties[alias] = {"$ref": f"{path}/{name}"}
89+
return properties
90+
91+
92+
def gen_schema(state: State) -> int:
93+
core = state.conf.core
94+
strict = state.conf.options.strict
95+
96+
# Accessing this adds extra stuff to core, so we need to do it first
97+
env_properties = _get_schema(state.envs["py"].conf, path="#/properties/env_run_base/properties")
98+
99+
properties = _get_schema(core, path="#/properties")
100+
101+
# This accesses plugins that register new sections (like tox-gh)
102+
# Accessing a private member since this is not exposed yet and the
103+
# interface includes the internal storage tuple
104+
sections = {
105+
key: conf
106+
for s, conf in state.conf._key_to_conf_set.items() # noqa: SLF001
107+
if (key := s[0].split(".")[0]) not in {"env_run_base", "env_pkg_base", "env"}
108+
}
109+
for key, conf in sections.items():
110+
properties[key] = {
111+
"type": "object",
112+
"additionalProperties": not strict,
113+
"properties": _get_schema(conf, path=f"#/properties/{key}/properties"),
114+
}
115+
116+
json_schema = {
117+
"$schema": "http://json-schema.org/draft-07/schema",
118+
"$id": "https://github.com/tox-dev/tox/blob/main/src/tox/util/tox.schema.json",
119+
"type": "object",
120+
"properties": {
121+
**properties,
122+
"env_run_base": {
123+
"type": "object",
124+
"properties": env_properties,
125+
"additionalProperties": not strict,
126+
},
127+
"env_pkg_base": {
128+
"$ref": "#/properties/env_run_base",
129+
"additionalProperties": not strict,
130+
},
131+
"env": {"type": "object", "patternProperties": {"^.*$": {"$ref": "#/properties/env_run_base"}}},
132+
"legacy_tox_ini": {"type": "string"},
133+
},
134+
"additionalProperties": not strict,
135+
"definitions": {
136+
"subs": {
137+
"anyOf": [
138+
{"type": "string"},
139+
{
140+
"type": "object",
141+
"properties": {
142+
"replace": {"type": "string"},
143+
"name": {"type": "string"},
144+
"default": {
145+
"oneOf": [
146+
{"type": "string"},
147+
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
148+
]
149+
},
150+
"extend": {"type": "boolean"},
151+
},
152+
"required": ["replace"],
153+
"additionalProperties": False,
154+
},
155+
{
156+
"type": "object",
157+
"properties": {
158+
"replace": {"type": "string"},
159+
"of": {"type": "array", "items": {"type": "string"}},
160+
"default": {
161+
"oneOf": [
162+
{"type": "string"},
163+
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
164+
]
165+
},
166+
"extend": {"type": "boolean"},
167+
},
168+
"required": ["replace", "of"],
169+
"additionalProperties": False,
170+
},
171+
],
172+
},
173+
},
174+
}
175+
print(json.dumps(json_schema, indent=2)) # noqa: T201
176+
return 0

0 commit comments

Comments
 (0)