Skip to content

Commit 7a78eed

Browse files
authored
chore: add mypy and pytest CI step (#275)
Signed-off-by: gruebel <[email protected]>
1 parent 3c737a6 commit 7a78eed

File tree

4 files changed

+119
-43
lines changed

4 files changed

+119
-43
lines changed

.github/workflows/pr-python.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,9 @@ jobs:
3030
- name: Lint
3131
working-directory: ${{ env.WORKING_DIR }}
3232
run: ruff check
33+
- name: Typing
34+
working-directory: ${{ env.WORKING_DIR }}
35+
run: mypy
36+
- name: Unit tests
37+
working-directory: ${{ env.WORKING_DIR }}
38+
run: pytest

tools/repo_parser/pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
[tool.mypy]
2+
files = "spec_finder.py"
3+
local_partial_types = true # will become the new default from version 2
4+
pretty = true
5+
strict = true
6+
17
[tool.ruff]
28
line-length = 120
39
target-version = "py312"

tools/repo_parser/spec_finder.py

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,23 @@
1212

1313
class Config(TypedDict):
1414
file_extension: str
15-
multiline_regex: str | None
16-
number_subregex: str | None
17-
text_subregex: str | None
15+
multiline_regex: str
16+
number_subregex: str
17+
text_subregex: str
1818
inline_comment_prefix: str | None
1919

2020

21+
Report = TypedDict(
22+
'Report',
23+
{
24+
'extra': list[str],
25+
'missing': list[str],
26+
'different-text': list[str],
27+
'good': list[str],
28+
},
29+
)
30+
31+
2132
def _demarkdown(t: str) -> str:
2233
return t.replace('**', '').replace('`', '').replace('"', '')
2334

@@ -47,7 +58,6 @@ def get_spec_parser(code_dir: str) -> Config:
4758
def get_spec(force_refresh: bool = False, path_prefix: str = './') -> dict[str, Any]:
4859
spec_path = os.path.join(path_prefix, 'specification.json')
4960
print('Going to look in ', spec_path)
50-
data = ''
5161
if os.path.exists(spec_path) and not force_refresh:
5262
with open(spec_path) as f:
5363
data = ''.join(f.readlines())
@@ -62,25 +72,29 @@ def get_spec(force_refresh: bool = False, path_prefix: str = './') -> dict[str,
6272
data = ''.join(raw)
6373
with open(spec_path, 'w') as f:
6474
f.write(data)
65-
return json.loads(data)
75+
return cast('dict[str, Any]', json.loads(data))
6676

6777

6878
def specmap_from_file(actual_spec: dict[str, Any]) -> dict[str, str]:
6979
spec_map = {}
7080
for entry in actual_spec['rules']:
71-
number = re.search(r'[\d.]+', entry['id']).group()
7281
if 'requirement' in entry['machine_id']:
73-
spec_map[number] = _demarkdown(entry['content'])
82+
if number := re.search(r'[\d.]+', entry['id']):
83+
spec_map[number.group()] = _demarkdown(entry['content'])
84+
else:
85+
print(f'Skipping invalid ID {entry["id"]}')
7486

75-
if len(entry['children']) > 0:
87+
if entry['children']:
7688
for ch in entry['children']:
77-
number = re.search(r'[\d.]+', ch['id']).group()
7889
if 'requirement' in ch['machine_id']:
79-
spec_map[number] = _demarkdown(ch['content'])
90+
if number := re.search(r'[\d.]+', ch['id']):
91+
spec_map[number.group()] = _demarkdown(ch['content'])
92+
else:
93+
print(f'Skipping invalid child ID {ch["id"]}')
8094
return spec_map
8195

8296

83-
def find_covered_specs(config: Config, data: str) -> dict[str, dict[str, str]]:
97+
def find_covered_specs(config: Config, data: str) -> dict[str, str]:
8498
repo_specs = {}
8599
for match in re.findall(config['multiline_regex'], data, re.MULTILINE | re.DOTALL):
86100
match = match.replace('\n', '').replace(config['inline_comment_prefix'], '')
@@ -93,16 +107,13 @@ def find_covered_specs(config: Config, data: str) -> dict[str, dict[str, str]]:
93107
text = ''.join(text_with_concat_chars).strip()
94108
# We have to match for ") to capture text with parens inside, so we add the trailing " back in.
95109
text = _demarkdown(eval('"%s"' % text))
96-
entry = repo_specs[number] = {
97-
'number': number,
98-
'text': text,
99-
}
110+
repo_specs[number] = text
100111
except Exception as e:
101112
print(f"Skipping {match} b/c we couldn't parse it")
102113
return repo_specs
103114

104115

105-
def gen_report(from_spec: dict[str, str], from_repo: dict[str, dict[str, str]]) -> dict[str, set[str]]:
116+
def gen_report(from_spec: dict[str, str], from_repo: dict[str, str]) -> Report:
106117
extra = set()
107118
different_text = set()
108119
good = set()
@@ -121,34 +132,26 @@ def gen_report(from_spec: dict[str, str], from_repo: dict[str, dict[str, str]])
121132
different_text.add(number)
122133

123134
return {
124-
'extra': extra,
125-
'missing': missing,
126-
'different-text': different_text,
127-
'good': good,
135+
'extra': sorted(extra),
136+
'missing': sorted(missing),
137+
'different-text': sorted(different_text),
138+
'good': sorted(good),
128139
}
129140

130141

131142
def main(
143+
code_directory: str,
132144
refresh_spec: bool = False,
133145
diff_output: bool = False,
134146
limit_numbers: str | None = None,
135-
code_directory: str | None = None,
136147
json_report: bool = False,
137148
) -> None:
138-
report = {
139-
'extra': set(),
140-
'missing': set(),
141-
'different-text': set(),
142-
'good': set(),
143-
}
144-
145149
actual_spec = get_spec(refresh_spec, path_prefix=code_directory)
146150
config = get_spec_parser(code_directory)
147151

148152
spec_map = specmap_from_file(actual_spec)
149153

150-
repo_specs = {}
151-
missing = set(spec_map.keys())
154+
repo_specs: dict[str, str] = {}
152155
bad_num = 0
153156

154157
for root, dirs, files in os.walk('.', topdown=False):
@@ -179,14 +182,12 @@ def main(
179182

180183
missing = report['missing']
181184
bad_num += len(missing)
182-
if len(missing) > 0:
185+
if missing:
183186
print('In the spec, but not in our tests: ')
184187
for m in sorted(missing):
185188
print(f' {m}: {spec_map[m]}')
186189

187190
if json_report:
188-
for k in report.keys():
189-
report[k] = sorted(list(report[k]))
190191
report_txt = json.dumps(report, indent=4)
191192
loc = os.path.join(code_directory, '%s-report.json' % config['file_extension'])
192193
with open(loc, 'w') as f:
@@ -206,9 +207,9 @@ def main(
206207

207208
args = parser.parse_args()
208209
main(
210+
code_directory=args.code_directory,
209211
refresh_spec=args.refresh_spec,
210212
diff_output=args.diff_output,
211213
limit_numbers=args.specific_numbers,
212-
code_directory=args.code_directory,
213214
json_report=args.json_report,
214215
)

tools/repo_parser/test_spec_finder.py

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from spec_finder import find_covered_specs, gen_report
1+
from spec_finder import Config, find_covered_specs, gen_report, specmap_from_file
22

33

44
def test_simple_singleline():
55
text = """
66
// spec:4.3.6:The after stage MUST run after flag resolution occurs. It accepts a hook context (required), flag evaluation details (required) and hook hints (optional). It has no return value.:end
77
"""
8-
cfg = {
8+
cfg: Config = {
9+
'file_extension': 'rust',
910
'multiline_regex': r'spec:(.*):end',
1011
'number_subregex': r'(?P<number>[\d.]+):',
1112
'text_subregex': r'[\d.]+:(.*)',
@@ -14,7 +15,7 @@ def test_simple_singleline():
1415
output = find_covered_specs(cfg, text)
1516
assert '4.3.6' in output
1617
assert (
17-
output['4.3.6']['text']
18+
output['4.3.6']
1819
== 'The after stage MUST run after flag resolution occurs. It accepts a hook context (required), flag evaluation details (required) and hook hints (optional). It has no return value.'
1920
)
2021

@@ -26,7 +27,8 @@ def test_multiline_comment():
2627
// context (required), exception representing what went wrong (required), and
2728
// hook hints (optional). It has no return value.:end
2829
"""
29-
cfg = {
30+
cfg: Config = {
31+
'file_extension': 'rust',
3032
'multiline_regex': r'spec:(.*):end',
3133
'number_subregex': r'(?P<number>[\d.]+):',
3234
'text_subregex': r'[\d.]+:(.*)',
@@ -35,7 +37,7 @@ def test_multiline_comment():
3537
output = find_covered_specs(cfg, text)
3638
assert '4.3.7' in output
3739
assert (
38-
output['4.3.7']['text']
40+
output['4.3.7']
3941
== """The error hook MUST run when errors are encountered in the before stage, the after stage or during flag resolution. It accepts hook context (required), exception representing what went wrong (required), and hook hints (optional). It has no return value."""
4042
)
4143

@@ -59,7 +61,68 @@ def test_report():
5961
assert len(report['missing']) == 1
6062
assert len(report['extra']) == 1
6163

62-
assert report['good'] == set(['1.2.3'])
63-
assert report['different-text'] == set(['2.3.4'])
64-
assert report['missing'] == set(['3.4.5'])
65-
assert report['extra'] == set(['4.5.6'])
64+
assert report['good'] == ['1.2.3']
65+
assert report['different-text'] == ['2.3.4']
66+
assert report['missing'] == ['3.4.5']
67+
assert report['extra'] == ['4.5.6']
68+
69+
70+
def test_report_with_found_spec():
71+
spec = {
72+
'4.3.6': 'good text',
73+
}
74+
text = """
75+
// spec:4.3.6:good text:end
76+
"""
77+
cfg: Config = {
78+
'file_extension': 'rust',
79+
'multiline_regex': r'spec:(.*):end',
80+
'number_subregex': r'(?P<number>[\d.]+):',
81+
'text_subregex': r'[\d.]+:(.*)',
82+
'inline_comment_prefix': '//',
83+
}
84+
output = find_covered_specs(cfg, text)
85+
report = gen_report(spec, output)
86+
87+
assert report['good'] == ['4.3.6']
88+
89+
90+
def test_specmap_from_file():
91+
actual_spec = {
92+
'rules': [
93+
{
94+
'id': 'Requirement 1.1.1',
95+
'machine_id': 'requirement_1_1_1',
96+
'content': 'The `API`, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the `API` are present at runtime.',
97+
'RFC 2119 keyword': 'SHOULD',
98+
'children': [],
99+
},
100+
{
101+
'id': 'Condition 2.2.2',
102+
'machine_id': 'condition_2_2_2',
103+
'content': 'The implementing language type system differentiates between strings, numbers, booleans and structures.',
104+
'RFC 2119 keyword': None,
105+
'children': [
106+
{
107+
'id': 'Conditional Requirement 2.2.2.1',
108+
'machine_id': 'conditional_requirement_2_2_2_1',
109+
'content': 'The `feature provider` interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.',
110+
'RFC 2119 keyword': 'MUST',
111+
'children': [],
112+
}
113+
],
114+
},
115+
]
116+
}
117+
118+
spec_map = specmap_from_file(actual_spec)
119+
120+
assert len(spec_map) == 2
121+
assert (
122+
spec_map['1.1.1']
123+
== 'The API, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the API are present at runtime.'
124+
)
125+
assert (
126+
spec_map['2.2.2.1']
127+
== 'The feature provider interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.'
128+
)

0 commit comments

Comments
 (0)