Skip to content

Commit b35c8ee

Browse files
🐛 fix(schema): cover every replace form in the TOML schema (#3941)
Closes #3939. The conditional `replace = "if"` form added in #3771 was never wired into the JSON schema or its generator, so editors that consume `tox.schema.json` (such as tombi) reject `commands = [{ replace = "if", ... }]` with `Expected a value of type Array, but found Table`. The same gap applied to `replace = "posargs"` and `replace = "glob"`, neither of which had a schema representation either. Only `env` and `ref` were partly modelled. The fix replaces the loose two-branch `subs` definition with one explicit object per replace form (`replace_env`, `replace_ref`, `replace_posargs`, `replace_glob`, `replace_if`), discriminated by a `const` on `replace`. A `replace_object` union groups all of them and is referenced wherever a list item can extend its parent. The most important case is the items of every `list[Command]` field, which is what makes the extend-into-list pattern from the reproducer lint cleanly. 🔧 Encoding `then`/`else` as the literal `true` schema (rather than `{}`) keeps tombi happy while remaining valid draft-07. To stop the same class of gap from shipping again, a guard test scrapes `replace_type == \"...\"` tokens from `_replace.py` and fails if any are missing a schema definition or a tombi-lint fixture. The existing tombi-lint suite now hard-fails when `tombi` is absent instead of silently skipping, and `tombi` is declared in the `test` dependency group. A future PR that introduces a sixth replace form cannot reach `main` without updating both the generator and the committed schema. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6eb5c4f commit b35c8ee

5 files changed

Lines changed: 517 additions & 136 deletions

File tree

‎docs/changelog/3939.bugfix.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Extend the generated TOML schema to cover every ``replace`` table form (``env``, ``ref``, ``posargs``, ``glob``,
2+
``if``), including conditional replacements used inside ``commands``. A guard test asserts the schema stays in sync with
3+
the loader implementation so future replace types cannot be added without a corresponding schema entry.

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ test = [
105105
"re-assert>=1.1",
106106
"setuptools<82,>=81",
107107
"time-machine>=3.2; implementation_name!='pypy'",
108+
"tombi>=0.11.3",
108109
"wheel>=0.46.3",
109110
]
110111
type = [

‎src/tox/session/cmd/schema.py‎

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -98,38 +98,103 @@ def gen_schema(state: State) -> int:
9898
"subs": {
9999
"anyOf": [
100100
{"type": "string"},
101-
{
102-
"type": "object",
103-
"properties": {
104-
"replace": {"type": "string"},
105-
"name": {"type": "string"},
106-
"default": {
107-
"oneOf": [
108-
{"type": "string"},
109-
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
110-
]
111-
},
112-
"extend": {"type": "boolean"},
113-
},
114-
"required": ["replace"],
115-
"additionalProperties": False,
101+
{"$ref": "#/definitions/replace_env"},
102+
{"$ref": "#/definitions/replace_ref"},
103+
{"$ref": "#/definitions/replace_posargs"},
104+
{"$ref": "#/definitions/replace_glob"},
105+
{"$ref": "#/definitions/replace_if"},
106+
],
107+
},
108+
"replace_env": {
109+
"type": "object",
110+
"description": "substitute the value of an environment variable",
111+
"properties": {
112+
"replace": {"const": "env"},
113+
"name": {"type": "string"},
114+
"default": {
115+
"oneOf": [
116+
{"type": "string"},
117+
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
118+
]
116119
},
117-
{
118-
"type": "object",
119-
"properties": {
120-
"replace": {"type": "string"},
121-
"of": {"type": "array", "items": {"type": "string"}},
122-
"default": {
123-
"oneOf": [
124-
{"type": "string"},
125-
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
126-
]
127-
},
128-
"extend": {"type": "boolean"},
129-
},
130-
"required": ["replace", "of"],
131-
"additionalProperties": False,
120+
"extend": {"type": "boolean"},
121+
"marker": {"type": "string"},
122+
},
123+
"required": ["replace", "name"],
124+
"additionalProperties": False,
125+
},
126+
"replace_ref": {
127+
"type": "object",
128+
"description": "substitute the value of another configuration key",
129+
"properties": {
130+
"replace": {"const": "ref"},
131+
"of": {"type": "array", "items": {"type": "string"}},
132+
"env": {"type": "string"},
133+
"key": {"type": "string"},
134+
"default": {
135+
"oneOf": [
136+
{"type": "string"},
137+
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
138+
]
132139
},
140+
"extend": {"type": "boolean"},
141+
"marker": {"type": "string"},
142+
},
143+
"required": ["replace"],
144+
"additionalProperties": False,
145+
},
146+
"replace_posargs": {
147+
"type": "object",
148+
"description": "substitute the positional arguments passed to tox",
149+
"properties": {
150+
"replace": {"const": "posargs"},
151+
"default": {"type": "array", "items": {"$ref": "#/definitions/subs"}},
152+
"extend": {"type": "boolean"},
153+
"marker": {"type": "string"},
154+
},
155+
"required": ["replace"],
156+
"additionalProperties": False,
157+
},
158+
"replace_glob": {
159+
"type": "object",
160+
"description": "substitute matches of a filesystem glob pattern",
161+
"properties": {
162+
"replace": {"const": "glob"},
163+
"pattern": {"type": "string"},
164+
"default": {
165+
"oneOf": [
166+
{"type": "string"},
167+
{"type": "array", "items": {"$ref": "#/definitions/subs"}},
168+
]
169+
},
170+
"extend": {"type": "boolean"},
171+
"marker": {"type": "string"},
172+
},
173+
"required": ["replace", "pattern"],
174+
"additionalProperties": False,
175+
},
176+
"replace_if": {
177+
"type": "object",
178+
"description": "conditional substitution based on env vars, factors, or env_name",
179+
"properties": {
180+
"replace": {"const": "if"},
181+
"condition": {"type": "string"},
182+
"then": True,
183+
"else": True,
184+
"extend": {"type": "boolean"},
185+
"marker": {"type": "string"},
186+
},
187+
"required": ["replace", "condition", "then"],
188+
"additionalProperties": False,
189+
},
190+
"replace_object": {
191+
"description": "any of the table-form replacements; usable wherever a list item can be a replacement",
192+
"anyOf": [
193+
{"$ref": "#/definitions/replace_env"},
194+
{"$ref": "#/definitions/replace_ref"},
195+
{"$ref": "#/definitions/replace_posargs"},
196+
{"$ref": "#/definitions/replace_glob"},
197+
{"$ref": "#/definitions/replace_if"},
133198
],
134199
},
135200
"factor_range_dict": {
@@ -245,7 +310,15 @@ def _process_type(of_type: typing.Any) -> dict[str, typing.Any]: # noqa: C901,
245310
if typing.get_args(of_type)[0] in {str, packaging.requirements.Requirement}:
246311
return {"type": "array", "items": {"$ref": "#/definitions/subs"}}
247312
if typing.get_args(of_type)[0] is tox.config.types.Command:
248-
return {"type": "array", "items": _process_type(typing.get_args(of_type)[0])}
313+
return {
314+
"type": "array",
315+
"items": {
316+
"oneOf": [
317+
_process_type(typing.get_args(of_type)[0]),
318+
{"$ref": "#/definitions/replace_object"},
319+
]
320+
},
321+
}
249322
msg = f"Unknown list type: {of_type}"
250323
raise ValueError(msg)
251324
if of_type is tox.config.set_env.SetEnv:

0 commit comments

Comments
 (0)