blob: 99af69cc1c04f873b400181553524a3ad3198a03 [file] [log] [blame]
Brad Bishopc04b3f42020-05-01 08:17:59 -04001#!/usr/bin/env python3
2# SPDX-License-Identifier: Apache-2.0
3"""
4A tool for validating entity manager configurations.
5"""
6import argparse
7import json
Brad Bishopc04b3f42020-05-01 08:17:59 -04008import os
Potin Lai0f3a4d92023-12-05 00:13:55 +08009import re
Brad Bishopc04b3f42020-05-01 08:17:59 -040010import sys
Patrick Williamsf8f60272025-05-03 01:36:31 -040011from concurrent.futures import ProcessPoolExecutor
Brad Bishopc04b3f42020-05-01 08:17:59 -040012
Patrick Williamsfa8ee872022-12-07 07:00:42 -060013import jsonschema.validators
14
Brad Bishopc04b3f42020-05-01 08:17:59 -040015DEFAULT_SCHEMA_FILENAME = "global.json"
16
17
Patrick Williamsf8f60272025-05-03 01:36:31 -040018def get_default_thread_count() -> int:
19 """
20 Returns the number of CPUs available to the current process.
21 """
22 try:
23 # This will respect CPU affinity settings
24 return len(os.sched_getaffinity(0))
25 except AttributeError:
26 # Fallback for systems without sched_getaffinity
27 return os.cpu_count() or 1
28
29
Potin Lai0f3a4d92023-12-05 00:13:55 +080030def remove_c_comments(string):
31 # first group captures quoted strings (double or single)
32 # second group captures comments (//single-line or /* multi-line */)
33 pattern = r"(\".*?(?<!\\)\"|\'.*?(?<!\\)\')|(/\*.*?\*/|//[^\r\n]*$)"
34 regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
35
36 def _replacer(match):
37 if match.group(2) is not None:
38 return ""
39 else:
40 return match.group(1)
41
42 return regex.sub(_replacer, string)
43
44
Brad Bishopc04b3f42020-05-01 08:17:59 -040045def main():
46 parser = argparse.ArgumentParser(
47 description="Entity manager configuration validator",
48 )
49 parser.add_argument(
Patrick Williamsfa8ee872022-12-07 07:00:42 -060050 "-s",
51 "--schema",
52 help=(
Brad Bishopc04b3f42020-05-01 08:17:59 -040053 "Use the specified schema file instead of the default "
Patrick Williamsfa8ee872022-12-07 07:00:42 -060054 "(__file__/../../schemas/global.json)"
55 ),
56 )
Brad Bishopc04b3f42020-05-01 08:17:59 -040057 parser.add_argument(
Patrick Williamsfa8ee872022-12-07 07:00:42 -060058 "-c",
59 "--config",
60 action="append",
61 help=(
Brad Bishopc04b3f42020-05-01 08:17:59 -040062 "Validate the specified configuration files (can be "
63 "specified more than once) instead of the default "
Patrick Williamsfa8ee872022-12-07 07:00:42 -060064 "(__file__/../../configurations/**.json)"
65 ),
66 )
Brad Bishopc04b3f42020-05-01 08:17:59 -040067 parser.add_argument(
Patrick Williamsfa8ee872022-12-07 07:00:42 -060068 "-e",
69 "--expected-fails",
70 help=(
Brad Bishopc04b3f42020-05-01 08:17:59 -040071 "A file with a list of configurations to ignore should "
Patrick Williamsfa8ee872022-12-07 07:00:42 -060072 "they fail to validate"
73 ),
74 )
Brad Bishopc04b3f42020-05-01 08:17:59 -040075 parser.add_argument(
Patrick Williamsfa8ee872022-12-07 07:00:42 -060076 "-k",
77 "--continue",
78 action="store_true",
79 help="keep validating after a failure",
80 )
Brad Bishopc04b3f42020-05-01 08:17:59 -040081 parser.add_argument(
Patrick Williamsfa8ee872022-12-07 07:00:42 -060082 "-v", "--verbose", action="store_true", help="be noisy"
83 )
Patrick Williamsf8f60272025-05-03 01:36:31 -040084 parser.add_argument(
85 "-t",
86 "--threads",
87 type=int,
88 default=get_default_thread_count(),
89 help="Number of threads to use for parallel validation (default: number of CPUs)",
90 )
Brad Bishopc04b3f42020-05-01 08:17:59 -040091 args = parser.parse_args()
92
93 schema_file = args.schema
94 if schema_file is None:
95 try:
96 source_dir = os.path.realpath(__file__).split(os.sep)[:-2]
97 schema_file = os.sep + os.path.join(
Patrick Williamsfa8ee872022-12-07 07:00:42 -060098 *source_dir, "schemas", DEFAULT_SCHEMA_FILENAME
99 )
Patrick Williamscad2d1f2022-12-04 14:38:16 -0600100 except Exception:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400101 print(
102 f"Could not guess location of {DEFAULT_SCHEMA_FILENAME}",
103 file=sys.stderr,
Patrick Williamsfa8ee872022-12-07 07:00:42 -0600104 )
Brad Bishopc04b3f42020-05-01 08:17:59 -0400105 sys.exit(2)
106
Brad Bishopc04b3f42020-05-01 08:17:59 -0400107 config_files = args.config or []
108 if len(config_files) == 0:
109 try:
110 source_dir = os.path.realpath(__file__).split(os.sep)[:-2]
Patrick Williamsfa8ee872022-12-07 07:00:42 -0600111 configs_dir = os.sep + os.path.join(*source_dir, "configurations")
Brad Bishopc04b3f42020-05-01 08:17:59 -0400112 data = os.walk(configs_dir)
113 for root, _, files in data:
114 for f in files:
Patrick Williamsfa8ee872022-12-07 07:00:42 -0600115 if f.endswith(".json"):
Brad Bishopc04b3f42020-05-01 08:17:59 -0400116 config_files.append(os.path.join(root, f))
Patrick Williamscad2d1f2022-12-04 14:38:16 -0600117 except Exception:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400118 print(
119 "Could not guess location of configurations", file=sys.stderr
120 )
Brad Bishopc04b3f42020-05-01 08:17:59 -0400121 sys.exit(2)
122
123 configs = []
124 for config_file in config_files:
125 try:
126 with open(config_file) as fd:
Potin Lai0f3a4d92023-12-05 00:13:55 +0800127 configs.append(json.loads(remove_c_comments(fd.read())))
Patrick Williamscad2d1f2022-12-04 14:38:16 -0600128 except FileNotFoundError:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400129 print(
130 f"Could not parse config file: {config_file}", file=sys.stderr
Patrick Williamsfa8ee872022-12-07 07:00:42 -0600131 )
Brad Bishopc04b3f42020-05-01 08:17:59 -0400132 sys.exit(2)
133
134 expected_fails = []
135 if args.expected_fails:
136 try:
137 with open(args.expected_fails) as fd:
138 for line in fd:
139 expected_fails.append(line.strip())
Patrick Williamscad2d1f2022-12-04 14:38:16 -0600140 except Exception:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400141 print(
142 f"Could not read expected fails file: {args.expected_fails}",
143 file=sys.stderr,
Patrick Williamsfa8ee872022-12-07 07:00:42 -0600144 )
Brad Bishopc04b3f42020-05-01 08:17:59 -0400145 sys.exit(2)
146
Brad Bishopc04b3f42020-05-01 08:17:59 -0400147 results = {
148 "invalid": [],
149 "unexpected_pass": [],
150 }
Patrick Williamsf8f60272025-05-03 01:36:31 -0400151
152 should_continue = getattr(args, "continue")
153
154 with ProcessPoolExecutor(max_workers=args.threads) as executor:
155 # Submit all validation tasks
156 config_to_future = {}
157 for config_file, config in zip(config_files, configs):
158 filename = os.path.split(config_file)[1]
159 future = executor.submit(
160 validate_single_config,
161 args,
162 filename,
163 config,
164 expected_fails,
165 schema_file,
166 )
167 config_to_future[config_file] = future
168
169 # Process results as they complete
170 for config_file, future in config_to_future.items():
171 # Wait for the future to complete and get its result
172 is_invalid, is_unexpected_pass = future.result()
173 # Update the results with the validation result
174 filename = os.path.split(config_file)[1]
175 if is_invalid:
176 results["invalid"].append(filename)
177 if is_unexpected_pass:
178 results["unexpected_pass"].append(filename)
179
180 # Stop validation if validation failed unexpectedly and --continue is not set
181 validation_failed = is_invalid or is_unexpected_pass
182 if validation_failed and not should_continue:
183 executor.shutdown(wait=False, cancel_futures=True)
184 break
Brad Bishopc04b3f42020-05-01 08:17:59 -0400185
186 exit_status = 0
187 if len(results["invalid"]) + len(results["unexpected_pass"]):
188 exit_status = 1
189 unexpected_pass_suffix = " **"
190 show_suffix_explanation = False
191 print("results:")
192 for f in config_files:
193 if any([x in f for x in results["unexpected_pass"]]):
194 show_suffix_explanation = True
Patrick Williamsf8f60272025-05-03 01:36:31 -0400195 print(f" '{f}' passed!{unexpected_pass_suffix}")
Brad Bishopc04b3f42020-05-01 08:17:59 -0400196 if any([x in f for x in results["invalid"]]):
Patrick Williamsf8f60272025-05-03 01:36:31 -0400197 print(f" '{f}' failed!")
Brad Bishopc04b3f42020-05-01 08:17:59 -0400198
199 if show_suffix_explanation:
200 print("\n** configuration expected to fail")
201
202 sys.exit(exit_status)
203
204
Alexander Hansen46072c42025-04-11 16:16:07 +0200205def validator_from_file(schema_file):
Alexander Hansen46072c42025-04-11 16:16:07 +0200206 schema = {}
Patrick Williamsf8f60272025-05-03 01:36:31 -0400207 with open(schema_file) as fd:
208 schema = json.load(fd)
Alexander Hansen46072c42025-04-11 16:16:07 +0200209
210 spec = jsonschema.Draft202012Validator
211 spec.check_schema(schema)
212 base_uri = "file://{}/".format(
213 os.path.split(os.path.realpath(schema_file))[0]
214 )
215 resolver = jsonschema.RefResolver(base_uri, schema)
216 validator = spec(schema, resolver=resolver)
217
218 return validator
219
220
Alexander Hansena47bdad2025-04-11 16:05:28 +0200221def validate_single_config(
Patrick Williamsf8f60272025-05-03 01:36:31 -0400222 args, filename, config, expected_fails, schema_file
Alexander Hansena47bdad2025-04-11 16:05:28 +0200223):
Patrick Williamsf8f60272025-05-03 01:36:31 -0400224 expect_fail = filename in expected_fails
225
226 is_invalid = False
227 is_unexpected_pass = False
228
Alexander Hansena47bdad2025-04-11 16:05:28 +0200229 try:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400230 validator = validator_from_file(schema_file)
Alexander Hansena47bdad2025-04-11 16:05:28 +0200231 validator.validate(config)
232 if expect_fail:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400233 is_unexpected_pass = True
Alexander Hansena47bdad2025-04-11 16:05:28 +0200234 except jsonschema.exceptions.ValidationError as e:
235 if not expect_fail:
Patrick Williamsf8f60272025-05-03 01:36:31 -0400236 is_invalid = True
Alexander Hansena47bdad2025-04-11 16:05:28 +0200237 if args.verbose:
238 print(e)
Patrick Williamsf8f60272025-05-03 01:36:31 -0400239 except FileNotFoundError:
240 is_invalid = True
241 if args.verbose:
242 print(f"Could not read schema file: {schema_file}")
243
244 return (is_invalid, is_unexpected_pass)
Alexander Hansena47bdad2025-04-11 16:05:28 +0200245
246
Brad Bishopc04b3f42020-05-01 08:17:59 -0400247if __name__ == "__main__":
248 main()