# Copyright (c) 2025 The University of Manchester
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from collections import defaultdict
import os
from typing import Collection, Dict, List, Set
from spinn_utilities.config_holder import get_default_cfgs
from spinn_utilities.configs.camel_case_config_parser import (
CamelCaseConfigParser, TypedConfigParser)
from spinn_utilities.exceptions import ConfigException
[docs]
class ConfigChecker(object):
"""
Checks the py and cfg files after the defaults have been set,
"""
__slots__ = ["_configs",
"_default_cfgs",
"_directories",
"_file_path",
"_lines",
"_used_cfgs"]
def __init__(self, directories: Collection[str]) -> None:
"""
:param directories: Path to find the cfg and py files to check
"""
# SpiNNGym needs check_all_used
self._configs = TypedConfigParser()
self._default_cfgs = get_default_cfgs()
for default_cfg in self._default_cfgs:
self._configs.read(default_cfg)
self._directories = directories
self._file_path = ""
self._lines: List[str] = []
self._used_cfgs: Dict[str, Set[str]] = defaultdict(set)
[docs]
def check(self, local_defaults: bool = True) -> None:
"""
Runs the checks of py and cfg files
:param local_defaults:
"""
if local_defaults:
self._check_defaults()
self._read_files()
if local_defaults:
self._check_all_used()
def _check_defaults(self) -> None:
"""
Testing function to identify any configuration options /
in multiple default files.
:raises ConfigException:
If two defaults configuration files set the same value
"""
config1 = CamelCaseConfigParser()
for default in self._default_cfgs[:-1]:
config1.read(default)
config2 = CamelCaseConfigParser()
config2.read(self._default_cfgs[-1])
for section in config2.sections():
for option in config2.options(section):
if config1.has_option(section, option):
raise ConfigException(
f"cfg:{self._default_cfgs} "
f"repeats [{section}]{option}")
def _read_files(self) -> None:
for directory in self._directories:
for root, _, files in os.walk(directory):
for file_name in files:
if file_name.endswith(".cfg"):
self._file_path = os.path.join(root, file_name)
self._check_cfg_file()
elif file_name.endswith(".py"):
self._file_path = os.path.join(root, file_name)
self._check_python_file()
def _check_cfg_file(self) -> None:
"""
Support method for :py:func:`check_cfgs`.
:raises ConfigException: If an unexpected option is found
"""
if self._file_path in self._default_cfgs:
return
config2 = TypedConfigParser()
config2.read(self._file_path)
for section in config2.sections():
if not self._configs.has_section(section):
raise ConfigException(f"cfg:{self._file_path} has "
f"unexpected section [{section}]")
for option in config2.options(section):
if not self._configs.has_option(section, option):
raise ConfigException(
f"cfg:{self._file_path} "
f"has unexpected options [{section}]{option}")
def _check_python_file(self) -> None:
"""
A testing function to check that all the `get_config` calls work.
:raises ConfigException:
If an unexpected or uncovered `get_config` found
"""
with open(self._file_path, 'r', encoding="utf-8") as py_file:
self._lines = list(py_file)
for index, line in enumerate(self._lines):
if ("skip_if_cfg" in line):
parts = self._get_parts(line, index, "skip_if_cfg")
self._check_lines(parts)
elif ("configuration.get" in line):
parts = self._get_parts(line, index, "configuration.get")
self._check_lines(parts)
if ("get_report_path(" in line):
parts = self._get_parts(line, index, "get_report")
self._check_get_report_path(parts)
if ("get_timestamp_path(" in line):
parts = self._get_parts(line, index, "get_timestamp")
self._check_get_report_path(parts)
if "get_config" not in line:
continue
if (("get_config_bool(" in line) or
("get_config_bool_or_none(" in line) or
("get_config_float(" in line) or
("get_config_float_or_none(" in line) or
("get_config_int(" in line) or
("get_config_int_or_none(" in line) or
("get_config_str(" in line) or
("get_config_str_or_none(" in line) or
("get_config_str_list(" in line)):
parts = self._get_parts(line, index, "get_config")
self._check_lines(parts)
def _get_parts(self, line: str, index: int, start: str) -> List[str]:
while ")" not in line:
index += 1
line += self._lines[index]
parts = line[line.find("(", line.find(start)) + 1:
line.find(")")].split(",")
return parts
def _check_lines(self, parts: List[str]) -> None:
section = parts[0].strip().replace("'", "").replace('"', '')
for i in range(1, len(parts)):
option = parts[i].strip()
if option[0] == "'":
option = option.replace("'", "")
elif option[0] == '"':
option = option.replace('"', '')
else:
return
if option.startswith("@"):
raise ConfigException(
f"{self._file_path} has {option=} which starts with @")
if not self._configs.has_option(section, option):
raise ConfigException(
f"{self._file_path} has unexpected {section=} {option=}")
self._used_cfgs[section].add(option)
def _check_get_report_path(self, parts: List[str]) -> None:
section = "Reports"
option = "No Option found"
for part in parts:
part = part.strip()
if "=" not in part:
option = part
elif part.startswith("option="):
option = part[7:]
elif part.startswith("section="):
section = part[8:]
elif part.startswith("is_dir="):
pass
elif part.startswith("n_run="):
pass
else:
if self._file_path.endswith("config_holder.py"):
return
raise ConfigException(
f"in {self._file_path} unexpected {parts=}")
if option == "option":
return
option = option.replace("'", "").replace('"', '')
section = section.replace("'", "").replace('"', '')
if not self._configs.has_option(section, option):
if self._file_path.endswith("config_checker.py"):
return
raise ConfigException(
f"{self._file_path} has unexpected {section=} {option=}")
self._used_cfgs[section].add(option)
def _check_all_used(self) -> None:
current_config = TypedConfigParser()
current_config.read(self._default_cfgs[-1])
for section in current_config:
if section not in self._used_cfgs:
if section == "DEFAULT":
continue
raise ConfigException(
f"in {self._default_cfgs[-1]} {section=} was never used")
for option in current_config.options(section):
if option.startswith("@"):
continue
if option not in self._used_cfgs[section]:
raise ConfigException(
f"in {self._default_cfgs[-1]} cfg "
f"{section=} {option=} was never used")