diff --git a/pyproject.toml b/pyproject.toml index 4cc5a9bf..6a530a67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ namespaces = true # ----------------------------------------- Project Metadata ------------------------------------- # [project] -version = "0.0.0.dev267" +version = "0.0.0.dev268" name = "ControlMan" dependencies = [ "packaging >= 23.2, < 24", diff --git a/src/controlman/__init__.py b/src/controlman/__init__.py index be6ed3c9..3a5184cc 100644 --- a/src/controlman/__init__.py +++ b/src/controlman/__init__.py @@ -12,6 +12,7 @@ from controlman import const, exception from controlman import data_validator as _data_validator from controlman import _file_util +from controlman import exception as _exception from controlman.center_manager import CenterManager @@ -54,7 +55,6 @@ def manager( def from_json_file( repo_path: str | _Path, filepath: str = const.FILEPATH_METADATA, - validate: bool = True, ) -> _ps.NestedDict: """Load control center data from the full JSON file. @@ -64,42 +64,40 @@ def from_json_file( Path to the repository root. filepath : str, default: controlman.const.FILEPATH_METADATA Relative path to the JSON file in the repository. - validate : bool, default: True - Validate the data against the schema. Raises ------ controlman.exception.ControlManFileReadError If the file cannot be read. """ - data_dict = _file_util.read_data_from_file( - path=_Path(repo_path) / filepath, - base_path=repo_path, - extension="json", - raise_errors=True, - ) - if validate: - _data_validator.validate(data=data_dict) - return _ps.NestedDict(data_dict) + try: + data = _ps.read.json_from_file(path=_Path(repo_path) / filepath) + except _ps.exception.read.PySerialsReadException as e: + raise _exception.load.ControlManInvalidMetadataError(cause=e, filepath=filepath) from None + _data_validator.validate(data=data) + return _ps.NestedDict(data) def from_json_file_at_commit( git_manager: _Git, commit_hash: str, filepath: str = const.FILEPATH_METADATA, - validate: bool = True, ) -> _ps.NestedDict: data_str = git_manager.file_at_hash( commit_hash=commit_hash, path=filepath, ) - return from_json_string(data=data_str, validate=validate) + try: + data = _ps.read.json_from_string(data=data_str) + except _ps.exception.read.PySerialsReadException as e: + raise _exception.load.ControlManInvalidMetadataError( + cause=e, filepath=filepath, commit_hash=commit_hash + ) from None + _data_validator.validate(data=data) + return _ps.NestedDict(data) -def from_json_string( - data: str, - validate: bool = True, -) -> _ps.NestedDict: +def from_json_string(data: str) -> _ps.NestedDict: """Load control center data from the full JSON string. Parameters @@ -114,11 +112,9 @@ def from_json_string( controlman.exception.ControlManFileReadError If the data cannot be read. """ - data_dict = _file_util.read_datafile_from_string( - data=data, - extension="json", - raise_errors=True, - ) - if validate: - _data_validator.validate(data=data_dict) - return _ps.NestedDict(data_dict) + try: + data = _ps.read.json_from_string(data=data) + except _ps.exception.read.PySerialsReadException as e: + raise _exception.load.ControlManInvalidMetadataError(e) from None + _data_validator.validate(data=data) + return _ps.NestedDict(data) diff --git a/src/controlman/_file_util.py b/src/controlman/_file_util.py index f51cdc80..5d6e2a24 100644 --- a/src/controlman/_file_util.py +++ b/src/controlman/_file_util.py @@ -1,74 +1,10 @@ -from pathlib import Path -from typing import Literal - import pkgdata as _pkgdata -import pyserials - -from controlman import exception as _exception +import pyserials as _ps _data_dir_path = _pkgdata.get_package_path_from_caller(top_level=True) / "_data" -def read_data_from_file( - path: Path | str, - base_path: Path | str | None = None, - extension: Literal["json", "yaml", "toml"] | None = None, - raise_errors: bool = True, -) -> dict | None: - try: - data = pyserials.read.from_file( - path=path, - data_type=extension, - json_strict=True, - yaml_safe=True, - toml_as_dict=False, - ) - except pyserials.exception.read.PySerialsReadException as e: - if raise_errors: - raise _exception.ControlManFileReadError( - path=Path(path).relative_to(base_path) if base_path else path, - data=getattr(e, "data", None), - ) from e - return - if not isinstance(data, dict): - if raise_errors: - raise _exception.ControlManFileDataTypeError( - expected_type=dict, - path=Path(path).relative_to(base_path) if base_path else path, - data=data, - ) - return - return data - - -def read_datafile_from_string( - data: str, - extension: Literal["json", "yaml", "toml"], - raise_errors: bool = True, -) -> dict | None: - try: - data = pyserials.read.from_string( - data=data, - data_type=extension, - json_strict=True, - yaml_safe=True, - toml_as_dict=False, - ) - except pyserials.exception.read.PySerialsReadException as e: - if raise_errors: - raise _exception.ControlManFileReadError(data=data) from e - return - if not isinstance(data, dict): - if raise_errors: - raise _exception.ControlManFileDataTypeError( - expected_type=dict, - data=data, - ) - return - return data - - def get_package_datafile(path: str) -> str | dict | list: """ Get a data file in the package's '_data' directory. @@ -81,5 +17,5 @@ def get_package_datafile(path: str) -> str | dict | list: full_path = _data_dir_path / path data = full_path.read_text() if full_path.suffix == ".yaml": - return pyserials.read.yaml_from_string(data=data, safe=True) + return _ps.read.yaml_from_string(data=data, safe=True) return data diff --git a/src/controlman/cache_manager.py b/src/controlman/cache_manager.py index de15520f..4b9a70e9 100644 --- a/src/controlman/cache_manager.py +++ b/src/controlman/cache_manager.py @@ -3,7 +3,7 @@ from loggerman import logger as _logger -import pyserials as _pyserials +import pyserials as _ps from controlman import exception as _exception, const as _const, _file_util from controlman import data_validator as _data_validator @@ -26,13 +26,8 @@ def __init__( self._cache = {} else: try: - self._cache = _file_util.read_data_from_file( - path=self._path, - base_path=path_repo, - extension="yaml", - raise_errors=True - ) - except _exception.ControlManException as e: + self._cache = _ps.read.yaml_from_file(path=self._path) + except _ps.exception.read.PySerialsReadException as e: self._cache = {} _logger.info( "Caching", f"API cache file at '{self._path}' is corrupted; initialized new cache." @@ -81,7 +76,7 @@ def set(self, typ: str, key: str, value: dict | list | str | int | float | bool) return def save(self): - _pyserials.write.to_yaml_file( + _ps.write.to_yaml_file( data=self._cache, path=self._path, make_dirs=True, diff --git a/src/controlman/center_manager.py b/src/controlman/center_manager.py index 8fdaa311..47fa2b1e 100644 --- a/src/controlman/center_manager.py +++ b/src/controlman/center_manager.py @@ -70,7 +70,7 @@ def load(self) -> _ps.NestedDict: const.FUNCNAME_CC_HOOK_POST_LOAD, full_data, ) - _data_validator.validate(data=full_data, before_substitution=True) + _data_validator.validate(data=full_data, source="source", before_substitution=True) self._data_raw = _ps.NestedDict(full_data) return self._data_raw @@ -94,7 +94,7 @@ def generate_data(self) -> _ps.NestedDict: ) self._cache_manager.save() data.fill() - _data_validator.validate(data=data()) + _data_validator.validate(data=data(), source="source") self._data = data return self._data diff --git a/src/controlman/data_gen/main.py b/src/controlman/data_gen/main.py index e30db046..71b8a824 100644 --- a/src/controlman/data_gen/main.py +++ b/src/controlman/data_gen/main.py @@ -59,11 +59,11 @@ def _repo(self) -> None: fallback_purpose=False ) if not repo_address: - _exception.ControlManRepositoryError( - "Failed to determine GitHub address. " - "The Git repository has not remote set for 'push' to 'origin'. " - f"Following remotes were found: {str(self._git.get_remotes())}", + raise _exception.data_gen.ControlManRepositoryError( repo_path=self._git.repo_path, + description="Failed to determine GitHub address. " + "The Git repository has no remote set for push to origin. " + f"Following remotes were found: {str(self._git.get_remotes())}", ) username, repo_name = repo_address self._gh_api_repo = self._gh_api.user(username).repo(repo_name) @@ -127,9 +127,12 @@ def _license(self): if not license_info: for key in ("name", "text", "notice"): if key not in data: - raise _exception.ControlManSchemaValidationError( - f"`license.{key}` is required when `license.id` is not a supported ID.", - key="license" + raise _exception.load.ControlManSchemaValidationError( + source="source", + before_substitution=True, + description=f"`license.{key}` is required when `license.id` is not a supported ID.", + json_path="license", + data=self._data(), ) _logger.info("License data is manually set.") return @@ -161,12 +164,14 @@ def _copyright(self): _logger.info(f"Project start year set from repository creation date: {start_year}") else: if start_year > current_year: - raise _exception.ControlManSchemaValidationError( - msg=( + raise _exception.load.ControlManSchemaValidationError( + source="source", + description=( f"Project start year ({start_year}) cannot be greater " f"than current year ({current_year})." ), - key="copyright.start_year" + json_path="copyright.start_year", + data=self._data(), ) _logger.info(f"Project start year already set manually in metadata: {start_year}") year_range = f"{start_year}{'' if start_year == current_year else f'–{current_year}'}" diff --git a/src/controlman/data_gen/python.py b/src/controlman/data_gen/python.py index e4036432..7181ff6d 100644 --- a/src/controlman/data_gen/python.py +++ b/src/controlman/data_gen/python.py @@ -74,17 +74,23 @@ def get_python_releases(): version_spec_key = "pkg.python.version.spec" spec_str = self._data.fill(version_spec_key) if not spec_str: - _exception.ControlManSchemaValidationError( - "The package has not specified a Python version specifier.", - key=version_spec_key, + _exception.load.ControlManSchemaValidationError( + source="source", + before_substitution=True, + description="The package has not specified a Python version specifier.", + json_path=version_spec_key, + data=self._data(), ) try: spec = _specifiers.SpecifierSet(spec_str) except _specifiers.InvalidSpecifier as e: - raise _exception.ControlManSchemaValidationError( - f"Invalid Python version specifier '{spec_str}'.", - key=version_spec_key, - ) from e + raise _exception.load.ControlManSchemaValidationError( + source="source", + before_substitution=True, + description=f"Invalid Python version specifier '{spec_str}'.", + json_path=version_spec_key, + data=self._data(), + ) from None current_python_versions = get_python_releases() micro_str = [] @@ -104,10 +110,13 @@ def get_python_releases(): minor_str_pyxy.append(f"py{''.join(map(str, compat_ver_micro_int[:2]))}") if len(micro_str) == 0: - raise _exception.ControlManSchemaValidationError( - f"The Python version specifier '{spec_str}' does not match any " + raise _exception.load.ControlManSchemaValidationError( + source="source", + before_substitution=True, + description=f"The Python version specifier '{spec_str}' does not match any " f"released Python version: '{current_python_versions}'.", - key=version_spec_key, + json_path=version_spec_key, + data=self._data(), ) output = { "micros": sorted(micro_str, key=lambda x: tuple(map(int, x.split(".")))), @@ -124,9 +133,12 @@ def get_python_releases(): def _package_operating_systems(self): data_os = self._data.fill("pkg.os") if not isinstance(data_os, dict): - raise _exception.ControlManSchemaValidationError( - "The package has not specified any operating systems.", - key="pkg.os", + raise _exception.load.ControlManSchemaValidationError( + source="source", + before_substitution=True, + description="The package has not specified any operating systems.", + json_path="pkg.os", + data=self._data(), ) pure_python = not any("ci_build" in os for os in data_os.values()) self._data["pkg.python.pure"] = pure_python @@ -182,9 +194,12 @@ def operating_system(): classifiers = self._data.get(f"{path}.classifiers", []) for classifier in classifiers: if classifier not in _trove_classifiers.classifiers: - raise _exception.ControlManSchemaValidationError( - f"Trove classifier '{classifier}' is not valid.", - key=f"{path}.classifiers" + raise _exception.load.ControlManSchemaValidationError( + source="source", + before_substitution=True, + description=f"Trove classifier '{classifier}' is not valid.", + json_path=f"{path}.classifiers", + data=self._data(), ) classifiers.extend(common_classifiers) self._data[f"{path}.classifiers"] = sorted(classifiers) diff --git a/src/controlman/data_gen/web.py b/src/controlman/data_gen/web.py index 6e33f10e..19a03f0b 100644 --- a/src/controlman/data_gen/web.py +++ b/src/controlman/data_gen/web.py @@ -58,7 +58,7 @@ def _process_website_toctrees(self) -> None: key_singular = key.removesuffix('s') final_key = f"blog_{key_singular}_{value_slug}" if final_key in pages: - raise _exception.ControlManWebsiteError( + raise _exception.data_gen.ControlManWebsiteError( "Duplicate page ID. " f"Generated ID '{final_key}' already exists " f"for page '{pages[final_key]['path']}'. " diff --git a/src/controlman/data_validator.py b/src/controlman/data_validator.py index e145225c..700913b7 100644 --- a/src/controlman/data_validator.py +++ b/src/controlman/data_validator.py @@ -22,14 +22,11 @@ def validate( data: dict, schema: _Literal["main", "local", "cache"] = "main", + source: _Literal["source", "compiled"] = "compiled", before_substitution: bool = False, ) -> None: """Validate data against a schema.""" - schema_dict = _file_util.read_data_from_file( - path=_schema_dir_path / f"{schema}.yaml", - extension="yaml", - raise_errors=True, - ) + schema_dict = _ps.read.yaml_from_file(path=_schema_dir_path / f"{schema}.yaml") schema_dict = _js.edit.required_last(schema_dict) if before_substitution: schema_dict = modify_schema(schema_dict)["anyOf"][0] @@ -43,17 +40,20 @@ def validate( iter_errors=True, ) except _ps.exception.validate.PySerialsJsonSchemaValidationError as e: - raise _exception.ControlManSchemaValidationError( - msg="Validation against schema failed." - ) from e + raise _exception.load.ControlManSchemaValidationError( + source=source, + before_substitution=before_substitution, + cause=e, + ) from None if schema == "main" and not before_substitution: - DataValidator(data).validate() + DataValidator(data=data, source=source).validate() return class DataValidator: - def __init__(self, data: dict): + def __init__(self, data: dict, source: _Literal["source", "compiled"] = "compiled"): self._data = data + self._source = source return @_logger.sectioner("Validate Control Center Contents") @@ -94,10 +94,12 @@ def dir_paths(self): rel_key = path_keys[idx + idx2 + 1] else: continue - raise _exception.ControlManSchemaValidationError( - f"Directory path '{rel_path}' defined at '{rel_key}' is relative to" + raise _exception.load.ControlManSchemaValidationError( + source=self._source, + description=f"Directory path '{rel_path}' defined at '{rel_key}' is relative to" f"directory path '{main_path}' defined at '{main_key}'.", - key=rel_key, + json_path=rel_key, + data=self._data, ) return @@ -111,10 +113,12 @@ def branch_names(self): for idx, branch_name in enumerate(branch_names): for idx2, branch_name2 in enumerate(branch_names[idx + 1:]): if branch_name.startswith(branch_name2) or branch_name2.startswith(branch_name): - raise _exception.ControlManSchemaValidationError( - f"Branch name '{branch_name}' defined at 'branch.{branch_keys[idx]}' " + raise _exception.load.ControlManSchemaValidationError( + source=self._source, + description=f"Branch name '{branch_name}' defined at 'branch.{branch_keys[idx]}' " f"overlaps with branch name '{branch_name2}' defined at 'branch.{branch_keys[idx + idx2 + 1]}'.", - key=branch_keys[idx], + json_path=branch_keys[idx], + data=self._data, ) return @@ -124,17 +128,21 @@ def changelogs(self): changelog_names = [] for changelog_id, changelog_data in self._data["changelog"].items(): if changelog_data["path"] in changelog_paths: - raise _exception.ControlManSchemaValidationError( - f"The path '{changelog_data['path']}' set for changelog '{changelog_id}' " + raise _exception.load.ControlManSchemaValidationError( + source=self._source, + description=f"The path '{changelog_data['path']}' set for changelog '{changelog_id}' " f"is already used by another earlier changelog.", - key=f"changelog.{changelog_id}.path" + json_path=f"changelog.{changelog_id}.path", + data=self._data, ) changelog_paths.append(changelog_data["path"]) if changelog_data["name"] in changelog_names: - raise _exception.ControlManSchemaValidationError( - f"The name '{changelog_data['name']}' set for changelog '{changelog_id}' " + raise _exception.load.ControlManSchemaValidationError( + source=self._source, + description=f"The name '{changelog_data['name']}' set for changelog '{changelog_id}' " f"is already used by another earlier changelog.", - key=f"changelog.{changelog_id}.name" + json_path=f"changelog.{changelog_id}.name", + data=self._data, ) changelog_names.append(changelog_data["name"]) # if changelog_id == "package_public_prerelease": #TODO: check package_public_prerelease @@ -142,10 +150,12 @@ def changelogs(self): section_ids = [] for idx, section in enumerate(changelog_data.get("sections", [])): if section["id"] in section_ids: - raise _exception.ControlManSchemaValidationError( - f"The changelog section ID '{section['id']}' set for changelog '{changelog_id}' " + raise _exception.load.ControlManSchemaValidationError( + source=self._source, + description=f"The changelog section ID '{section['id']}' set for changelog '{changelog_id}' " f"is already used by another earlier section.", - key=f"changelog.{changelog_id}.sections[{idx}]" + json_path=f"changelog.{changelog_id}.sections[{idx}]", + data=self._data, ) section_ids.append(section["id"]) return @@ -156,10 +166,12 @@ def commits(self): for main_type in ("primary", "primary_custom"): for commit_id, commit_data in self._data["commit"][main_type].items(): if commit_data["type"] in commit_types: - raise _exception.ControlManSchemaValidationError( - f"The commit type '{commit_data['type']}' set for commit '{main_type}.{commit_id}' " + raise _exception.load.ControlManSchemaValidationError( + source=self._source, + description=f"The commit type '{commit_data['type']}' set for commit '{main_type}.{commit_id}' " f"is already used by another earlier commit.", - key=f"commit.{main_type}.{commit_id}.type" + json_path=f"commit.{main_type}.{commit_id}.type", + data=self._data, ) commit_types.append(commit_data["type"]) for subtype_type, subtypes in commit_data["subtypes"]: @@ -385,11 +397,7 @@ def make_resource( resources = [] def_schemas_path = _schema_dir_path for schema_filepath in def_schemas_path.glob("**/*.yaml"): - schema_dict = _file_util.read_data_from_file( - path=schema_filepath, - extension="yaml", - raise_errors=True, - ) + schema_dict = _ps.read.yaml_from_file(path=schema_filepath) _js.edit.required_last(schema_dict) resources.append(make_resource(schema_dict)) registry_after, _ = _docsman_schema.load(dynamic=False, crawl=True, add_resources=resources) diff --git a/src/controlman/exception.py b/src/controlman/exception.py deleted file mode 100644 index f56fdb13..00000000 --- a/src/controlman/exception.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Exceptions raised by ControlMan.""" - -from pathlib import Path as _Path -from typing import Type as _Type - -from markitup import html as _html, doc as _doc - - - - - - - -class ControlManRepositoryError(ControlManException): - """Exception raised when issues are encountered with the Git(Hub) repository.""" - - def __init__(self, msg: str, repo_path: str | _Path): - super().__init__(f"An error occurred with the repository at '{repo_path}': {msg}") - self.repo_path = repo_path - return - - -class ControlManWebsiteError(ControlManException): - """Exception raised when issues are encountered with the website.""" - - def __init__(self, msg: str): - super().__init__(f"An error occurred with the website: {msg}") - return - - -class ControlManSchemaValidationError(ControlManException): - """Exception raised when a control center file is invalid against its schema.""" - - def __init__(self, msg: str, key: str | None = None): - intro = "Control center data is not valid" + (f" at '{key}'" if key else "") - super().__init__(f"{intro}: {msg}") - self.key = key - return - - -class ControlManFileReadError(ControlManException): - """Exception raised when a file cannot be read.""" - - def __init__(self, path: str | _Path | None = None, data: str | None = None, msg: str | None = None): - msg = msg or "Please check the error details below and fix the issue." - content = f' File content:\n{data}\n' if data else '' - source = f"at '{path}'" if path else 'from string data' - super().__init__(f"Failed to read file {source}. {msg}{content}") - self.path = path - self.data = data - return - - -class ControlManFileDataTypeError(ControlManFileReadError): - """Exception raised when a control center file's data is of an unexpected type.""" - - def __init__( - self, - expected_type: _Type[dict | list], - path: str | _Path | None = None, - data: str | None = None, - msg: str | None = None, - ): - super().__init__( - path=path, - data=data, - msg=msg or f"Expected data type '{expected_type.__name__}' but got '{type(data).__name__}'." - ) - self.expected_type = expected_type - return - - diff --git a/src/controlman/exception/__init__.py b/src/controlman/exception/__init__.py index b44a784d..2dd3c133 100644 --- a/src/controlman/exception/__init__.py +++ b/src/controlman/exception/__init__.py @@ -1,2 +1,2 @@ from controlman.exception.base import ControlManException -from controlman.exception import load +from controlman.exception import load, data_gen diff --git a/src/controlman/exception/base.py b/src/controlman/exception/base.py index 87d2cbeb..302d6ec1 100644 --- a/src/controlman/exception/base.py +++ b/src/controlman/exception/base.py @@ -1,3 +1,5 @@ +from typing import Literal as _Literal + from exceptionman import ReporterException as _ReporterException from markitup import html as _html import ansi_sgr as _sgr @@ -12,6 +14,7 @@ def __init__( description: str, message_html: str | _html.Element | None = None, description_html: str | _html.Element | None = None, + cause: Exception | None = None, report_heading: str = "ControlMan Error Report", ): super().__init__( @@ -21,6 +24,14 @@ def __init__( description_html=description_html, report_heading=report_heading, ) + self.cause = cause + return + + def _report_content(self, mode: _Literal["full", "short"], md: bool) -> list[str | _html.Element] | str | _html.Element | None: + if isinstance(self.cause, _ReporterException): + return self.cause._report_content(mode, md) + if self.cause: + return str(self.cause) return diff --git a/src/controlman/exception/data_gen.py b/src/controlman/exception/data_gen.py new file mode 100644 index 00000000..18d881c1 --- /dev/null +++ b/src/controlman/exception/data_gen.py @@ -0,0 +1,33 @@ +from pathlib import Path as _Path + +from controlman.exception import ControlManException as _ControlManException +from controlman.exception.base import format_code as _format_code + + +class ControlManRepositoryError(_ControlManException): + """Exception raised when issues are encountered with the Git(Hub) repository.""" + + def __init__(self, repo_path: _Path, description: str, description_html: str | None = None): + message_template = "An error occurred with the Git repository at {repo_path}." + repo_path_console, repo_path_html = _format_code(str(repo_path)) + super().__init__( + message=message_template.format(repo_path=repo_path_console), + message_html=message_template.format(repo_path=repo_path_html), + description=description, + description_html=description_html, + report_heading="ControlMan Data Generation Error Report", + ) + self.repo_path = repo_path + return + + +class ControlManWebsiteError(_ControlManException): + """Exception raised when issues are encountered with the website.""" + + def __init__(self, description: str): + super().__init__( + message=f"An error occurred with the website.", + description=description, + report_heading="ControlMan Data Generation Error Report", + ) + return diff --git a/src/controlman/exception/load.py b/src/controlman/exception/load.py index 60880de6..16fb8846 100644 --- a/src/controlman/exception/load.py +++ b/src/controlman/exception/load.py @@ -9,7 +9,31 @@ from controlman.exception.base import format_code as _format_code -class ControlManConfigFileReadException(_ControlManException): +class ControlManDataReadException(_ControlManException): + """Base class for all exceptions raised when a data cannot be read.""" + + def __init__( + self, + message: str, + description: str, + message_html: str | _html.Element | None = None, + description_html: str | _html.Element | None = None, + data: str | dict | None = None, + cause: Exception | None = None, + ): + super().__init__( + message=message, + message_html=message_html, + description=description, + description_html=description_html, + cause=cause, + report_heading="ControlMan Data Read Error Report", + ) + self.data = data + return + + +class ControlManConfigFileReadException(ControlManDataReadException): """Base class for all exceptions raised when a control center configuration file cannot be read.""" def __init__( @@ -18,18 +42,19 @@ def __init__( data: str | dict, description: str, description_html: str | _html.Element | None = None, + cause: Exception | None = None, ): message_template = "Failed to read control center configuration file at {filepath}." filepath_console, filepath_html = _format_code(filepath) super().__init__( + data=data, message=message_template.format(filepath=filepath_console), message_html=message_template.format(filepath=filepath_html), description=description, description_html=description_html, - report_heading="ControlMan Configuration File Read Error Report", + cause=cause, ) self.filepath = filepath - self.data = data return @@ -42,13 +67,10 @@ def __init__(self, cause: _ps.exception.read.PySerialsInvalidDataError): data=cause.data, description=cause.description, description_html=cause.description_html, + cause=cause, ) - self.cause = cause return - def _report_content(self, mode: _Literal["full", "short"], md: bool) -> _html.elem.Ul: - return self.cause._report_content(mode=mode, md=md) - class ControlManDuplicateConfigFileDataError(ControlManConfigFileReadException): """Exception raised when a control center configuration file contains duplicate data.""" @@ -77,8 +99,8 @@ def __init__( data=cause.data_addon_full, description=description_template.format(**kwargs_console), description_html=description_template.format(**kwargs_html), + cause=cause, ) - self.cause = cause return @@ -92,6 +114,7 @@ def __init__( description: str, description_html: str | _html.Element, node: _yaml.ScalarNode, + cause: Exception | None = None, ): self.node = node self.start_line = node.start_mark.line + 1 @@ -105,6 +128,7 @@ def __init__( data=data, description=description.format(tag_name=tag_name_console, start_line=self.start_line), description_html=description_html.format(tag_name=tag_name_html, start_line=self.start_line), + cause=cause, ) return @@ -138,7 +162,7 @@ def __init__( data: str, node: _yaml.ScalarNode, url: str, - cause + cause: Exception, ): description_template = ( "Failed to download external data from {url} defined in {tag_name} tag at line {start_line}." @@ -150,6 +174,66 @@ def __init__( description=description_template.format(url=url_console), description_html=description_template.format(url=url_html), node=node, + cause=cause, + ) + return + + +class ControlManInvalidMetadataError(ControlManDataReadException): + """Exception raised when a control center metadata file contains invalid data.""" + + def __init__( + self, + cause: _ps.exception.read.PySerialsReadException, + filepath: str | _Path | None = None, + commit_hash: str | None = None, + ): + filepath_console, filepath_html = _format_code(str(filepath)) + commit_hash_console, commit_hash_html = _format_code(str(commit_hash)) + if filepath: + from_source = "file at {filepath}" + if commit_hash: + from_source += " from commit hash {commit_hash}" + else: + from_source = "from input string" + message_template = f"Failed to read project metadata {from_source}." + super().__init__( + data=getattr(cause, "data", None), + message=message_template.format(filepath=filepath_console, commit_hash=commit_hash_console), + message_html=message_template.format(filepath=filepath_html, commit_hash=commit_hash_html), + description=cause.description, + description_html=cause.description_html, + cause=cause, + ) + return + + +class ControlManSchemaValidationError(ControlManDataReadException): + """Exception raised when a control center file is invalid against its schema.""" + + def __init__( + self, + source: _Literal["source", "compiled"] = "source", + before_substitution: bool = False, + cause: _ps.exception.validate.PySerialsValidateException | None = None, + description: str | None = None, + description_html: str | _html.Element | None = None, + json_path: str | None = None, + data: dict | None = None, + ): + source_desc = "Control center configurations are" if source == "source" else "Project metadata is" + problem_end = "." if not json_path else f" at path '$.{json_path}'." + message = f"{source_desc} invalid against the schema{problem_end}" + + super().__init__( + data=data or cause.data, + message=message, + message_html=message, + description=description or cause.description, + description_html=description_html or cause.description_html if cause else None, + cause=cause, ) - self.cause = cause + self.source = source + self.before_substitution = before_substitution + self.key = json_path return