Skip to content

Commit 5be227d

Browse files
committed
feat: preprocess jsonc, fallback for json5
Adds a preprocessor that can deal with most comments and trailing commas in JSONC and JSON%, but will fall back to an optional json5 for full format support.
1 parent 356d124 commit 5be227d

7 files changed

Lines changed: 264 additions & 15 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ repos:
5050
args: []
5151
additional_dependencies:
5252
- click
53+
- json5>=0.15.0
5354
- markdown-it-py
5455
- pytest
5556
- nox

docs/guides/gha_basic.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ static. And old versioned images are decommissioned.
159159

160160
## Updating
161161

162-
{rr}`DEP200` {rr}`GH200` {rr}`GH210` {rr}`REN200` {rr}`REN210`
162+
{rr}`DEP200` {rr}`GH200` {rr}`GH210`
163163
If you use non-default actions in your repository
164164
(you will see some in the following pages), then it's a good idea to keep them
165165
up to date. GitHub provided a way to do this with dependabot. Just add the
@@ -190,9 +190,9 @@ which is both cleaner and sometimes required for dependent actions, like
190190

191191
You can use this for other ecosystems too, including Python.
192192

193-
[Renovate](https://docs.renovatebot.com/) can also be used for keeping GitHub
194-
Actions (and other ecosystems) up to date as well. A good starting point for
195-
`renovate.json` with the
193+
{rr}`REN200` {rr}`REN210` [Renovate](https://docs.renovatebot.com/) can also be
194+
used for keeping GitHub Actions (and other ecosystems) up to date as well. A
195+
good starting point for `renovate.json` with the
196196
[hosted version](https://docs.renovatebot.com/getting-started/installing-onboarding/)
197197
which will cover GitHub Actions and most other ecosystems is:
198198

@@ -202,6 +202,12 @@ which will cover GitHub Actions and most other ecosystems is:
202202
}
203203
```
204204

205+
:::{tip}
206+
Most Renovate `.jsonc` or `.json5` configs can be parsed without extra
207+
dependencies, but include the `json5` optional dependency in order to load the
208+
full range of the formats.
209+
:::
210+
205211
## Common needs
206212

207213
### Single OS steps

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ async = [
4848
"repo-review[async]",
4949
]
5050
all = [
51-
"sp-repo-review[cli,pyproject,async]",
51+
"sp-repo-review[cli,pyproject,async,json5]",
52+
]
53+
json5 = [
54+
"json5>=0.15.0",
5255
]
5356

5457
[project.urls]
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Minimal, string-aware JSONC preprocessing.
2+
3+
Strips ``//`` and ``/* */`` comments and trailing commas so that JSONC (and the
4+
subset of JSON5 that only uses those features) can be parsed by the standard
5+
library :mod:`json`. Full JSON5 (single-quoted strings, unquoted keys, hex
6+
numbers, etc.) is *not* handled here and requires the optional ``json5``
7+
dependency.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
_WHITESPACE = " \t\r\n"
13+
14+
15+
def _copy_string(text: str, i: int, out: list[str]) -> int:
16+
"""Copy a double-quoted string verbatim, returning the index after it."""
17+
length = len(text)
18+
out.append(text[i]) # opening quote
19+
i += 1
20+
while i < length:
21+
char = text[i]
22+
out.append(char)
23+
if char == "\\" and i + 1 < length:
24+
# Copy the escaped character so an escaped quote does not
25+
# prematurely close the string.
26+
out.append(text[i + 1])
27+
i += 2
28+
elif char == '"':
29+
i += 1
30+
break
31+
else:
32+
i += 1
33+
return i
34+
35+
36+
def _skip_line_comment(text: str, i: int) -> int:
37+
"""Skip a ``// ...`` comment, returning the index of the line ending."""
38+
i += 2
39+
length = len(text)
40+
while i < length and text[i] not in "\r\n":
41+
i += 1
42+
return i
43+
44+
45+
def _skip_block_comment(text: str, i: int) -> int:
46+
"""Skip a ``/* ... */`` comment, returning the index after it."""
47+
i += 2
48+
length = len(text)
49+
while i + 1 < length and not (text[i] == "*" and text[i + 1] == "/"):
50+
i += 1
51+
return i + 2 # Past the end is harmless; the caller re-checks bounds.
52+
53+
54+
def _drop_trailing_comma(out: list[str]) -> None:
55+
"""Remove a trailing comma before a closing bracket, ignoring whitespace."""
56+
last = len(out) - 1
57+
while last >= 0 and out[last] in _WHITESPACE:
58+
last -= 1
59+
if last >= 0 and out[last] == ",":
60+
del out[last]
61+
62+
63+
def strip_jsonc(text: str) -> str:
64+
"""Remove comments and trailing commas from JSONC ``text``.
65+
66+
The scan is string-aware: characters inside double-quoted strings (and their
67+
backslash escapes) are copied verbatim, so comment markers or commas that
68+
appear inside string values are left untouched. The function is total and
69+
never raises; validating the result is left to the caller's ``json.loads``.
70+
"""
71+
out: list[str] = []
72+
i = 0
73+
length = len(text)
74+
75+
while i < length:
76+
char = text[i]
77+
if char == '"':
78+
i = _copy_string(text, i, out)
79+
elif char == "/" and text.startswith("//", i):
80+
i = _skip_line_comment(text, i)
81+
elif char == "/" and text.startswith("/*", i):
82+
i = _skip_block_comment(text, i)
83+
elif char in "}]":
84+
_drop_trailing_comma(out)
85+
out.append(char)
86+
i += 1
87+
else:
88+
out.append(char)
89+
i += 1
90+
91+
return "".join(out)

src/sp_repo_review/checks/dependencies.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ def check(dependabot: dict[str, Any], renovate: dict[str, Any]) -> bool:
4141
}}
4242
```
4343
Renovate configurations in `package.json` are not supported.
44-
Configurations in `.jsonc` or `.json5` files are not fully supported.
44+
`.jsonc` files (and `.json5` files that only use comments or trailing
45+
commas) are supported out of the box. Full JSON5 configs require the
46+
optional `json5` dependency (`pip install sp-repo-review[json5]`).
4547
"""
4648
return bool(dependabot or renovate)
4749

src/sp_repo_review/checks/renovate.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import TYPE_CHECKING, Any
99

1010
from . import mk_url
11+
from ._jsonc import strip_jsonc
1112

1213
if TYPE_CHECKING:
1314
from .._compat.importlib.resources.abc import Traversable
@@ -35,14 +36,31 @@ def renovate(root: Traversable) -> dict[str, Any]:
3536
renovate_paths = [root.joinpath(f) for f in SUPPORTED_RENOVATE_FILES]
3637

3738
for renovate_path in renovate_paths:
38-
if renovate_path.is_file():
39-
with renovate_path.open() as f:
40-
try:
41-
result: dict[str, Any] = json.load(f)
42-
except json.JSONDecodeError:
43-
continue
44-
else:
45-
return result
39+
if not renovate_path.is_file():
40+
continue
41+
with renovate_path.open() as f:
42+
text = f.read()
43+
try:
44+
# Handles JSON and JSONC (comments / trailing commas) with no
45+
# dependency, plus JSON5 files that use only those features.
46+
result: dict[str, Any] = json.loads(strip_jsonc(text))
47+
except json.JSONDecodeError:
48+
# Full JSON5 (single quotes, unquoted keys, ...) needs a real
49+
# parser, provided by the optional json5 extra.
50+
try:
51+
import json5 # noqa: PLC0415
52+
except ImportError:
53+
msg = (
54+
f"{renovate_path} could not be parsed as JSON/JSONC and needs "
55+
"full JSON5 support. Install the extra: "
56+
"pip install sp-repo-review[json5]"
57+
)
58+
raise ImportError(msg) from None
59+
try:
60+
result = json5.loads(text)
61+
except ValueError:
62+
continue
63+
return result
4664
return {}
4765

4866

@@ -69,7 +87,9 @@ def check(renovate: dict[str, Any]) -> bool | None:
6987
```
7088
7189
Renovate configurations in `package.json` are not supported.
72-
Configurations in `.jsonc` or `.json5` files are not fully supported.
90+
`.jsonc` files (and `.json5` files that only use comments or trailing
91+
commas) are supported out of the box. Full JSON5 configs require the
92+
optional `json5` dependency (`pip install sp-repo-review[json5]`).
7393
"""
7494
if not renovate:
7595
return None

tests/test_renovate.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,131 @@
1+
import json
2+
import sys
3+
4+
import pytest
5+
from repo_review.ghpath import GHPath
16
from repo_review.testing import compute_check
27

8+
from sp_repo_review.checks._jsonc import strip_jsonc
9+
from sp_repo_review.checks.renovate import renovate
10+
11+
# Real-world Renovate configs, one per supported format/location, pinned to a
12+
# specific commit so upstream edits cannot break this. Skipped by default
13+
# (network access, GitHub rate limits); run manually to confirm coverage.
14+
REAL_WORLD_CONFIGS = [
15+
(
16+
"gulfofmaine/climatology_py_dash",
17+
"e4ead74d17d7c07dbb40b9c99fc1138f927abdd3",
18+
"renovate.json",
19+
),
20+
(
21+
"jumpstarter-dev/jumpstarter",
22+
"8bae226d0a2d9cbe156bded24628e85c6d9f6cd9",
23+
"renovate.jsonc",
24+
),
25+
(
26+
"SonarSource/docker-sonarqube",
27+
"9f00ce57d8654a3737ae0b22997d7198f71660d8",
28+
"renovate.json5",
29+
),
30+
(
31+
"adobe/spectrum-css",
32+
"37620864c60c4c142a506017e1a15348a26abb0e",
33+
".github/renovate.json",
34+
),
35+
(
36+
"paddyroddy/.github",
37+
"c97ca7c448df211268616ce438777228fe103733",
38+
".renovaterc.json5",
39+
),
40+
(
41+
"zammad/zammad",
42+
"93fb7f107b07b4b4294e21249b277bc48c431da5",
43+
".gitlab/renovate.json",
44+
),
45+
(
46+
"prettier/eslint-config-prettier",
47+
"07829b4912d173986610a4985247896b09f9fcaf",
48+
".renovaterc",
49+
),
50+
(
51+
"Esri/calcite-design-system",
52+
"5613d9f8000ba12bf55c7da50e2f119c12435302",
53+
".renovaterc.json",
54+
),
55+
]
56+
57+
58+
def test_strip_jsonc_line_comment() -> None:
59+
text = '{\n // a comment\n "a": 1 // trailing\n}'
60+
assert json.loads(strip_jsonc(text)) == {"a": 1}
61+
62+
63+
def test_strip_jsonc_block_comment() -> None:
64+
text = '{\n /* multi\n line */ "a": 1\n}'
65+
assert json.loads(strip_jsonc(text)) == {"a": 1}
66+
67+
68+
def test_strip_jsonc_preserves_string_content() -> None:
69+
# Comment markers and commas inside strings must survive untouched.
70+
text = '{"url": "https://x/y", "csv": "a,b,", "block": "/* not a comment */"}'
71+
assert json.loads(strip_jsonc(text)) == {
72+
"url": "https://x/y",
73+
"csv": "a,b,",
74+
"block": "/* not a comment */",
75+
}
76+
77+
78+
def test_strip_jsonc_escaped_quote_in_string() -> None:
79+
text = r'{"a": "she said \"hi\" // ok"}'
80+
assert json.loads(strip_jsonc(text)) == {"a": 'she said "hi" // ok'}
81+
82+
83+
def test_strip_jsonc_trailing_commas() -> None:
84+
text = '{\n "a": [1, 2, 3,],\n "b": {"c": 1,},\n}'
85+
assert json.loads(strip_jsonc(text)) == {"a": [1, 2, 3], "b": {"c": 1}}
86+
87+
88+
def test_strip_jsonc_plain_json_unchanged() -> None:
89+
text = '{"a": 1, "b": [1, 2]}'
90+
assert strip_jsonc(text) == text
91+
92+
93+
def test_renovate_fixture_jsonc(tmp_path) -> None:
94+
(tmp_path / "renovate.jsonc").write_text(
95+
'{\n // pin digests\n "extends": ["config:recommended"],\n}',
96+
encoding="utf-8",
97+
)
98+
assert renovate(tmp_path) == {"extends": ["config:recommended"]}
99+
100+
101+
def test_renovate_fixture_json5_fallback(tmp_path) -> None:
102+
pytest.importorskip("json5")
103+
# Unquoted keys are true JSON5 and cannot be stripped to plain JSON.
104+
(tmp_path / "renovate.json5").write_text(
105+
'{\n extends: ["config:recommended"],\n}',
106+
encoding="utf-8",
107+
)
108+
assert renovate(tmp_path) == {"extends": ["config:recommended"]}
109+
110+
111+
def test_renovate_fixture_json5_missing_errors(tmp_path, monkeypatch) -> None:
112+
(tmp_path / "renovate.json5").write_text(
113+
'{\n extends: ["config:recommended"],\n}',
114+
encoding="utf-8",
115+
)
116+
# Simulate the json5 extra not being installed.
117+
monkeypatch.setitem(sys.modules, "json5", None)
118+
with pytest.raises(ImportError, match=r"sp-repo-review\[json5\]"):
119+
renovate(tmp_path)
120+
121+
122+
@pytest.mark.skip(reason="Network access, can be rate limited")
123+
@pytest.mark.parametrize(("repo", "sha", "location"), REAL_WORLD_CONFIGS)
124+
def test_renovate_real_world(repo, sha, location) -> None:
125+
root = GHPath(repo=repo, branch=sha)
126+
assert root.joinpath(location).is_file()
127+
assert renovate(root)
128+
3129

4130
def test_ren200() -> None:
5131
renovate = {"extends": ["config:recommended"]}

0 commit comments

Comments
 (0)