PySerials 0.0.0.dev2__py3-none-any.whl → 0.0.0.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,16 @@
1
1
  """Exceptions raised by `pyserials.validate` module."""
2
2
 
3
+ from __future__ import annotations
4
+ from typing import Any as _Any, Literal as _Literal
3
5
 
4
- from typing import Any as _Any
6
+ import jsonschema as _jsonschema
7
+ from markitup.html import elem as _html
8
+ from markitup.md import elem as _md
9
+ from pyserials import write as _write
10
+ from pyserials.exception import _base
5
11
 
6
- from pyserials.exception._base import PySerialsException as _PySerialsException
7
12
 
8
-
9
- class PySerialsValidateException(_PySerialsException):
13
+ class PySerialsValidateException(_base.PySerialsException):
10
14
  """Base class for all exceptions raised by `pyserials.validate` module.
11
15
 
12
16
  Attributes
@@ -21,53 +25,219 @@ class PySerialsValidateException(_PySerialsException):
21
25
 
22
26
  def __init__(
23
27
  self,
24
- message: str,
28
+ description: str,
25
29
  data: dict | list | str | int | float | bool,
26
30
  schema: dict,
27
31
  validator: _Any,
32
+ registry: _Any = None,
33
+ description_html: str | _html.Element | None = None,
28
34
  ):
29
- message = (
30
- "Failed to validate data against schema "
31
- f"using validator '{validator.__class__.__name__}'; {message}."
35
+ validator_name = validator.__class__.__name__
36
+ message_template = "Failed to validate data against schema using validator {validator_name}."
37
+ validator_name_console, validator_name_html = _base.format_code(validator_name)
38
+ super().__init__(
39
+ message=message_template.format(validator_name=validator_name_console),
40
+ message_html=message_template.format(validator_name=validator_name_html),
41
+ description=description,
42
+ description_html=description_html,
43
+ report_heading="PySerials Schema Validation Error Report",
32
44
  )
33
- super().__init__(message=message)
34
45
  self.data = data
35
46
  self.schema = schema
36
47
  self.validator = validator
48
+ self.registry = registry
37
49
  return
38
50
 
39
51
 
40
- class PySerialsSchemaValidationError(PySerialsValidateException):
41
- """Exception raised when data validation fails due to the data being invalid against the schema."""
52
+ class PySerialsInvalidJsonSchemaError(PySerialsValidateException):
53
+ """Exception raised when data validation fails due to the schema being invalid."""
42
54
 
43
55
  def __init__(
44
56
  self,
45
57
  data: dict | list | str | int | float | bool,
46
58
  schema: dict,
47
59
  validator: _Any,
60
+ registry: _Any = None,
48
61
  ):
49
62
  super().__init__(
50
- message="the data is invalid",
63
+ description="The schema is invalid.",
51
64
  data=data,
52
65
  schema=schema,
53
66
  validator=validator,
67
+ registry=registry,
54
68
  )
55
69
  return
56
70
 
57
71
 
58
- class PySerialsInvalidSchemaError(PySerialsValidateException):
59
- """Exception raised when data validation fails due to the schema being invalid."""
72
+ class PySerialsJsonSchemaValidationError(PySerialsValidateException):
73
+ """Exception raised when data validation fails due to the data being invalid against the schema."""
60
74
 
61
75
  def __init__(
62
76
  self,
77
+ causes: list[_jsonschema.exceptions.ValidationError],
63
78
  data: dict | list | str | int | float | bool,
64
79
  schema: dict,
65
80
  validator: _Any,
81
+ registry: _Any = None,
66
82
  ):
83
+ self.causes = causes
84
+ description, description_html = self._parse_errors()
67
85
  super().__init__(
68
- message="the schema is invalid",
86
+ description=description,
87
+ description_html=description_html,
69
88
  data=data,
70
89
  schema=schema,
71
90
  validator=validator,
91
+ registry=registry,
72
92
  )
73
93
  return
94
+
95
+ def _report_content(
96
+ self, mode: _Literal["full", "short"], md: bool
97
+ ) -> list[str | _html.Element] | str | _html.Element | None:
98
+ html_details = []
99
+ for idx, error in enumerate(self.causes):
100
+ section_html = self._report_error(error, section=[idx + 1], mode=mode, md=md)
101
+ html_details.append(section_html)
102
+ return _html.ul([_html.li(detail) for detail in html_details])
103
+
104
+ def _parse_errors(self) -> tuple[str, str]:
105
+ count_errors = len(self.causes)
106
+ errors = "an error" if count_errors == 1 else f"{count_errors} errors"
107
+ intro = intro_html = f"Found {errors} in the data at "
108
+ reports = []
109
+ errors_loc = []
110
+ errors_loc_html = []
111
+ for idx, error in enumerate(self.causes):
112
+ error_loc, error_loc_html = _base.format_code(error.json_path)
113
+ errors_loc.append(error_loc)
114
+ errors_loc_html.append(error_loc_html)
115
+ section_console = self._parse_error(error, section=[idx + 1])
116
+ reports.extend(section_console)
117
+ reports_str = "\n".join(reports)
118
+ if len(errors_loc) == 1:
119
+ errors_loc_str = errors_loc[0]
120
+ errors_loc_html_str = errors_loc_html[0]
121
+ elif len(errors_loc) == 2:
122
+ errors_loc_str = " and ".join(errors_loc)
123
+ errors_loc_html_str = " and ".join(errors_loc_html)
124
+ else:
125
+ errors_loc_str = ", ".join(errors_loc[:-1]) + " and " + errors_loc[-1]
126
+ errors_loc_html_str = ", ".join(errors_loc_html[:-1]) + " and " + errors_loc_html[-1]
127
+ intro += f"{errors_loc_str}:\n\n{reports_str}"
128
+ intro_html += f"{errors_loc_html_str}."
129
+ return intro, intro_html
130
+
131
+ def _parse_error(
132
+ self,
133
+ error: _jsonschema.exceptions.ValidationError,
134
+ section: list[int]
135
+ ) -> list:
136
+ schema_path = self._create_path(error.absolute_schema_path)
137
+ title, _ = _base.format_code(error.json_path)
138
+ console = [
139
+ _base.ansi_heading(section, title),
140
+ f"- {_base.ansi_bold('Problem')}: {self._parse_error_message(error)}",
141
+ f"- {_base.ansi_bold("Validator Path")}: {schema_path}",
142
+ ]
143
+ if error.context:
144
+ console.append(f"- {_base.ansi_bold("Context")}:")
145
+ for idx, sub_error in enumerate(sorted(error.context, key=lambda x: len(x.context))):
146
+ sub_console = self._parse_error(
147
+ sub_error,
148
+ section=section + [idx+1]
149
+ )
150
+ console.extend(self._indent(sub_console, 2))
151
+ return console
152
+
153
+ def _report_error(
154
+ self,
155
+ error: _jsonschema.exceptions.ValidationError,
156
+ section: list[int],
157
+ mode: _Literal["full", "short"],
158
+ md: bool
159
+ ) -> _html.Details:
160
+ details = [
161
+ _html.p(f"{_html.b("Problem")}: {self._parse_error_message(error)}"),
162
+ self._create_validator_details(error, mode=mode, md=md),
163
+ self._create_schema_details(error, mode=mode, md=md),
164
+ self._create_instance_details(error, mode=mode, md=md),
165
+ ]
166
+ if error.context:
167
+ contexts = []
168
+ for idx, sub_error in enumerate(sorted(error.context, key=lambda x: len(x.context))):
169
+ sub_html = self._report_error(
170
+ sub_error,
171
+ section=section + [idx+1],
172
+ mode=mode,
173
+ md=md,
174
+ )
175
+ contexts.append(sub_html)
176
+ context_list = _html.ul([_html.li(context) for context in contexts])
177
+ details.append(f"{_html.b("Context")}: {context_list}")
178
+ _, title = _base.format_code(error.json_path)
179
+ html = _html.details(
180
+ [_html.summary(title), _html.ul([_html.li(elem) for elem in details])]
181
+ )
182
+ return html
183
+
184
+ def _create_validator_details(self, error: _jsonschema.exceptions.ValidationError, mode: _Literal["full", "short"], md: bool) -> _html.Details:
185
+ summary = self._create_details_summary("Validator", str(error.validator))
186
+ return summary if mode == "short" else self._make_details(
187
+ content=error.validator_value,
188
+ summary=summary,
189
+ md=md,
190
+ )
191
+
192
+ def _create_schema_details(self, error: _jsonschema.exceptions.ValidationError, mode: _Literal["full", "short"], md: bool) -> _html.Details:
193
+ summary = self._create_details_summary("Schema", self._create_path(error.absolute_schema_path))
194
+ return summary if mode == "short" else self._make_details(
195
+ content=error.schema,
196
+ summary=summary,
197
+ md=md,
198
+ )
199
+
200
+ def _create_instance_details(self, error: _jsonschema.exceptions.ValidationError, mode: _Literal["full", "short"], md: bool) -> _html.Details:
201
+ summary = self._create_details_summary("Instance", self._create_path(error.absolute_path))
202
+ return summary if mode == "short" else self._make_details(
203
+ content=error.instance,
204
+ summary=summary,
205
+ md=md,
206
+ )
207
+
208
+ @staticmethod
209
+ def _make_details(content: dict, summary: str, md: bool) -> _html.Details:
210
+ yaml = _write.to_yaml_string(content, end_of_file_newline=False)
211
+ details_summary = _html.summary(summary)
212
+ details_content = _html.pre(
213
+ _html.code(yaml, {"class": "language-yaml"})
214
+ ) if not md else _md.code_fence(yaml, info="yaml")
215
+ return _html.details([details_summary, details_content])
216
+
217
+ def _create_title(self, error: _jsonschema.exceptions.ValidationError) -> tuple[str, str]:
218
+ problem = self._parse_error_message(error)
219
+ title_shell = f"'{error.json_path}': {problem}"
220
+ title_html = f"{_html.code(error.json_path)}: {problem}"
221
+ return title_shell, title_html
222
+
223
+ @staticmethod
224
+ def _parse_error_message(error: _jsonschema.exceptions.ValidationError) -> str:
225
+ instance_str = str(error.instance)
226
+ if error.message.startswith(instance_str):
227
+ msg = error.message.removeprefix(str(error.instance)).strip()
228
+ problem = f"Data {msg}"
229
+ else:
230
+ problem = error.message
231
+ return problem
232
+
233
+ @staticmethod
234
+ def _indent(text: list, indent: int = 2) -> list:
235
+ return [f"{' ' * indent}{line}" for line in text]
236
+
237
+ @staticmethod
238
+ def _create_path(path):
239
+ return "$." + ".".join(str(path_component) for path_component in path)
240
+
241
+ @staticmethod
242
+ def _create_details_summary(title: str, code: str) -> str:
243
+ return f"{_html.b(title)}: {_html.code(code)}"
@@ -0,0 +1,124 @@
1
+ import pyserials as _ps
2
+
3
+
4
+ class NestedDict:
5
+
6
+ def __init__(
7
+ self,
8
+ data: dict | None = None,
9
+ template_marker_start: str = "${{",
10
+ template_marker_end: str = "}}",
11
+ template_implicit_root: bool = True,
12
+ ):
13
+ self._data = data or {}
14
+ self._templater = _ps.update.TemplateFiller(
15
+ marker_start=template_marker_start,
16
+ marker_end=template_marker_end,
17
+ implicit_root=template_implicit_root,
18
+ )
19
+ return
20
+
21
+ def fill(
22
+ self,
23
+ path: str = "",
24
+ always_list: bool = False,
25
+ recursive: bool = True,
26
+ ):
27
+ if not path:
28
+ value = self._data
29
+ else:
30
+ value = self.__getitem__(path)
31
+ if not value:
32
+ return
33
+ filled_value = self.fill_data(data=value, current_path=path, always_list=always_list, recursive=recursive)
34
+ if not path:
35
+ self._data = filled_value
36
+ else:
37
+ self.__setitem__(path, filled_value)
38
+ return filled_value
39
+
40
+ def fill_data(
41
+ self,
42
+ data,
43
+ current_path: str = "",
44
+ always_list: bool = False,
45
+ recursive: bool = True,
46
+ ):
47
+ return self._templater.fill(
48
+ templated_data=data,
49
+ source_data=self._data,
50
+ current_path=current_path,
51
+ always_list=always_list,
52
+ recursive=recursive,
53
+ )
54
+
55
+ def __call__(self):
56
+ return self._data
57
+
58
+ def __getitem__(self, item: str):
59
+ keys = item.split(".")
60
+ data = self._data
61
+ for key in keys:
62
+ if not isinstance(data, dict):
63
+ raise KeyError(f"Key '{key}' not found in '{data}'.")
64
+ if key not in data:
65
+ return
66
+ data = data[key]
67
+ # if isinstance(data, dict):
68
+ # return NestedDict(data)
69
+ # if isinstance(data, list) and all(isinstance(item, dict) for item in data):
70
+ # return [NestedDict(item) for item in data]
71
+ return data
72
+
73
+ def __setitem__(self, key, value):
74
+ key = key.split(".")
75
+ data = self._data
76
+ for k in key[:-1]:
77
+ if k not in data:
78
+ data[k] = {}
79
+ data = data[k]
80
+ data[key[-1]] = value
81
+ return
82
+
83
+ def __contains__(self, item):
84
+ keys = item.split(".")
85
+ data = self._data
86
+ for key in keys:
87
+ if not isinstance(data, dict) or key not in data:
88
+ return False
89
+ data = data[key]
90
+ return True
91
+
92
+ def __bool__(self):
93
+ return bool(self._data)
94
+
95
+ def setdefault(self, key, value):
96
+ key = key.split(".")
97
+ data = self._data
98
+ for k in key[:-1]:
99
+ if k not in data:
100
+ data[k] = {}
101
+ data = data[k]
102
+ return data.setdefault(key[-1], value)
103
+
104
+ def get(self, key, default=None):
105
+ keys = key.split(".")
106
+ data = self._data
107
+ for key in keys:
108
+ if not isinstance(data, dict) or key not in data:
109
+ return default
110
+ data = data[key]
111
+ return data
112
+
113
+ def items(self):
114
+ return self._data.items()
115
+
116
+ def keys(self):
117
+ return self._data.keys()
118
+
119
+ def values(self):
120
+ return self._data.values()
121
+
122
+ def update(self, data: dict):
123
+ self._data.update(data)
124
+ return
pyserials/read.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Literal as _Literal
1
+ from typing import Literal as _Literal, Callable as _Callable, Any as _Any
2
2
  import json as _json
3
3
  from pathlib import Path as _Path
4
4
  from functools import partial as _partial
@@ -17,11 +17,11 @@ def from_file(
17
17
  toml_as_dict: bool = False,
18
18
  ):
19
19
  if data_type is None:
20
- data_type = _Path(path).suffix.removeprefix(".")
20
+ data_type = _Path(path).suffix.removeprefix(".").lower()
21
21
  if data_type == "yml":
22
22
  data_type = "yaml"
23
- if data_type not in ("json", "yaml", "toml"):
24
- raise _exception.read.PySerialsInvalidFileExtensionError(filepath=path)
23
+ if data_type not in ("json", "yaml", "toml"):
24
+ raise _exception.read.PySerialsInvalidFileExtensionError(filepath=path)
25
25
  if data_type == "json":
26
26
  return json_from_file(path=path, strict=json_strict)
27
27
  if data_type == "yaml":
@@ -97,32 +97,56 @@ def json_from_file(path: str | _Path, strict: bool = True) -> dict | list | str
97
97
 
98
98
 
99
99
  def yaml(
100
- source: str | _Path, safe: bool = True
100
+ source: str | _Path,
101
+ safe: bool = True,
102
+ constructors: dict[str, _Callable[[_yaml.Constructor, _yaml.ScalarNode], _Any]] | None = None,
101
103
  ) -> dict | list | str | int | float | bool | _yaml.CommentedMap | _yaml.CommentedSeq:
104
+ """Load YAML data from a file or string.
105
+
106
+ Parameters
107
+ ----------
108
+ source : str | _Path
109
+ Path to the file or YAML data string.
110
+ safe : bool, default: True
111
+ Use safe YAML loader.
112
+ constructors : dict[str, Callable], default: None
113
+ Custom YAML constructors.
114
+
115
+ References
116
+ ----------
117
+ - See `add_constructor` method in
118
+ `ruamel.yaml.constructor.SafeConstructor` and `ruamel.yaml.constructor.RoundTripConstructor`.
119
+ - See `add_constructor` in [PyYAML documentation](https://pyyaml.org/wiki/PyYAMLDocumentation).
120
+ - See Tags in [YAML documentation](https://yaml.org/spec/1.2.0/#id2560445)
121
+ """
102
122
  if isinstance(source, str):
103
- return yaml_from_string(data=source, safe=safe)
123
+ return yaml_from_string(data=source, safe=safe, constructors=constructors)
104
124
  return yaml_from_file(path=source, safe=safe)
105
125
 
106
126
 
107
127
  def yaml_from_string(
108
- data: str, safe: bool = True
128
+ data: str,
129
+ safe: bool = True,
130
+ constructors: dict[str, _Callable[[_yaml.Constructor, _yaml.ScalarNode], _Any]] | None = None,
109
131
  ) -> dict | list | str | int | float | bool | _yaml.CommentedMap | _yaml.CommentedSeq:
110
132
  content = _read_from_string(
111
133
  data=data,
112
134
  data_type="yaml",
113
- loader=_yaml.YAML(typ="safe" if safe else "rt").load,
135
+ loader=_make_yaml_loader(safe=safe, constructors=constructors).load,
114
136
  exception=_yaml.YAMLError
115
137
  )
116
138
  return content
117
139
 
118
140
 
119
141
  def yaml_from_file(
120
- path: str | _Path, safe: bool = True
142
+ path: str | _Path,
143
+ safe: bool = True,
144
+ constructors: dict[str, _Callable[[_yaml.Constructor, _yaml.ScalarNode], _Any]] | None = None,
121
145
  ) -> dict | list | str | int | float | bool | _yaml.CommentedMap | _yaml.CommentedSeq:
122
146
  content = _read_from_file(
123
147
  path=path,
124
148
  data_type="yaml",
125
- loader=_yaml.YAML(typ="safe" if safe else "rt").load,
149
+ loader=_make_yaml_loader(safe=safe, constructors=constructors).load,
126
150
  exception=_yaml.YAMLError
127
151
  )
128
152
  return content
@@ -136,16 +160,16 @@ def _read_from_file(
136
160
  ):
137
161
  path = _Path(path).resolve()
138
162
  if not path.is_file():
139
- raise _exception.read.PySerialsMissingFileError(filepath=path)
163
+ raise _exception.read.PySerialsMissingFileError(data_type=data_type, filepath=path)
140
164
  data = path.read_text()
141
165
  if data.strip() == "":
142
- raise _exception.read.PySerialsEmptyFileError(filepath=path)
166
+ raise _exception.read.PySerialsEmptyFileError(data_type=data_type, filepath=path)
143
167
  try:
144
168
  content = loader(data)
145
169
  except exception as e:
146
- raise _exception.read.PySerialsInvalidDataFileError(
147
- filepath=path, data_type=data_type, data=data
148
- ) from e
170
+ raise _exception.read.PySerialsInvalidDataError(
171
+ source_type="file", filepath=path, data_type=data_type, data=data, cause=e
172
+ ) from None
149
173
  return content
150
174
 
151
175
 
@@ -160,5 +184,18 @@ def _read_from_string(
160
184
  try:
161
185
  content = loader(data)
162
186
  except exception as e:
163
- raise _exception.read.PySerialsInvalidDataStringError(data_type=data_type, data=data) from e
187
+ raise _exception.read.PySerialsInvalidDataError(
188
+ source_type="string", data_type=data_type, data=data, cause=e
189
+ ) from None
164
190
  return content
191
+
192
+
193
+ def _make_yaml_loader(
194
+ safe: bool,
195
+ constructors: dict[str, _Callable[[_yaml.Constructor, _yaml.ScalarNode], _Any]] | None = None
196
+ ) -> _yaml.YAML:
197
+ loader = _yaml.YAML(typ="safe" if safe else "rt")
198
+ if constructors:
199
+ for key, constructor in constructors.items():
200
+ loader.constructor.add_constructor(key, constructor)
201
+ return loader