amati 0.1.1__py3-none-any.whl → 0.2.1__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.
amati/__init__.py CHANGED
@@ -13,4 +13,3 @@ __version__ = importlib.metadata.version("amati")
13
13
 
14
14
  from amati.amati import dispatch, run
15
15
  from amati.exceptions import AmatiValueError
16
- from amati.references import AmatiReferenceException, Reference, References
@@ -0,0 +1,48 @@
1
+ """
2
+ Handles Pydantic errors and amati logs to provide a consistent view to the user.
3
+ """
4
+
5
+ import json
6
+ from typing import cast
7
+
8
+ from amati.logging import Log
9
+
10
+ type JSONPrimitive = str | int | float | bool | None
11
+ type JSONArray = list["JSONValue"]
12
+ type JSONObject = dict[str, "JSONValue"]
13
+ type JSONValue = JSONPrimitive | JSONArray | JSONObject
14
+
15
+
16
+ def remove_duplicates(data: list[JSONObject]) -> list[JSONObject]:
17
+ """
18
+ Remove duplicates by converting each dict to a JSON string for comparison.
19
+ """
20
+ seen: set[str] = set()
21
+ unique_data: list[JSONObject] = []
22
+
23
+ for item in data:
24
+ # Convert to JSON string with sorted keys for consistent hashing
25
+ item_json = json.dumps(item, sort_keys=True, separators=(",", ":"))
26
+ if item_json not in seen:
27
+ seen.add(item_json)
28
+ unique_data.append(item)
29
+
30
+ return unique_data
31
+
32
+
33
+ def handle_errors(errors: list[JSONObject] | None, logs: list[Log]) -> list[JSONObject]:
34
+ """
35
+ Makes errors and logs consistent for user consumption.
36
+ """
37
+
38
+ result: list[JSONObject] = []
39
+
40
+ if errors:
41
+ result.extend(errors)
42
+
43
+ if logs:
44
+ result.extend(cast(list[JSONObject], logs))
45
+
46
+ result = remove_duplicates(result)
47
+
48
+ return result
amati/amati.py CHANGED
@@ -7,15 +7,16 @@ import json
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
- import jsonpickle
10
+ from jinja2 import Environment, FileSystemLoader
11
11
  from pydantic import BaseModel, ValidationError
12
- from pydantic_core import ErrorDetails
13
12
 
14
13
  # pylint: disable=wrong-import-position
15
14
 
16
15
  sys.path.insert(0, str(Path(__file__).parent.parent))
16
+ from amati._error_handler import handle_errors
17
17
  from amati._resolve_forward_references import resolve_forward_references
18
18
  from amati.file_handler import load_file
19
+ from amati.logging import Log, LogMixin
19
20
 
20
21
  type JSONPrimitive = str | int | float | bool | None
21
22
  type JSONArray = list["JSONValue"]
@@ -23,7 +24,7 @@ type JSONObject = dict[str, "JSONValue"]
23
24
  type JSONValue = JSONPrimitive | JSONArray | JSONObject
24
25
 
25
26
 
26
- def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[ErrorDetails] | None]:
27
+ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | None]:
27
28
  """
28
29
  Returns the correct model for the passed spec
29
30
 
@@ -59,7 +60,7 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[ErrorDetails] | N
59
60
  try:
60
61
  model = module.OpenAPIObject(**data)
61
62
  except ValidationError as e:
62
- return None, e.errors()
63
+ return None, json.loads(e.json())
63
64
 
64
65
  return model, None
65
66
 
@@ -86,7 +87,12 @@ def check(original: JSONObject, validated: BaseModel) -> bool:
86
87
  return original_ == new_
87
88
 
88
89
 
89
- def run(file_path: str | Path, consistency_check: bool = False):
90
+ def run(
91
+ file_path: str | Path,
92
+ consistency_check: bool = False,
93
+ local: bool = False,
94
+ html_report: bool = False,
95
+ ):
90
96
  """
91
97
  Runs the full amati process on a specific specification file.
92
98
 
@@ -101,24 +107,56 @@ def run(file_path: str | Path, consistency_check: bool = False):
101
107
  consistency_check: Whether or not to verify the output against the input
102
108
  """
103
109
 
104
- data = load_file(file_path)
110
+ spec = Path(file_path)
105
111
 
106
- result, errors = dispatch(data)
112
+ data = load_file(spec)
107
113
 
108
- if result and consistency_check:
109
- if check(data, result):
110
- print("Consistency check successful")
111
- else:
112
- print("Consistency check failed")
114
+ logs: list[Log] = []
115
+
116
+ with LogMixin.context():
117
+ result, errors = dispatch(data)
118
+ logs.extend(LogMixin.logs)
119
+
120
+ if errors or logs:
121
+
122
+ handled_errors: list[JSONObject] = handle_errors(errors, logs)
123
+
124
+ file_name = Path(Path(file_path).parts[-1])
125
+ error_file = file_name.with_suffix(file_name.suffix + ".errors")
126
+ error_path = spec.parent
113
127
 
114
- if errors:
115
- if not Path(".amati").exists():
116
- Path(".amati").mkdir()
128
+ if local:
129
+ error_path = Path(".amati")
117
130
 
118
- error_file = Path(file_path).parts[-1]
131
+ if not error_path.exists():
132
+ error_path.mkdir()
119
133
 
120
- with open(f".amati/{error_file}.json", "w", encoding="utf-8") as f:
121
- f.write(jsonpickle.encode(errors, unpicklable=False)) # type: ignore
134
+ with open(
135
+ error_path / error_file.with_suffix(error_file.suffix + ".json"),
136
+ "w",
137
+ encoding="utf-8",
138
+ ) as f:
139
+ f.write(json.dumps(handled_errors))
140
+
141
+ if html_report:
142
+ env = Environment(
143
+ loader=FileSystemLoader(".")
144
+ ) # Assumes template is in the same directory
145
+ template = env.get_template("TEMPLATE.html")
146
+
147
+ # Render the template with your data
148
+ html_output = template.render(errors=handled_errors)
149
+
150
+ # Save the output to a file
151
+ with open(
152
+ error_path / error_file.with_suffix(error_file.suffix + ".html"),
153
+ "w",
154
+ encoding="utf-8",
155
+ ) as f:
156
+ f.write(html_output)
157
+
158
+ if result and consistency_check:
159
+ return check(data, result)
122
160
 
123
161
 
124
162
  def discover(discover_dir: str = ".") -> list[Path]:
@@ -170,6 +208,9 @@ if __name__ == "__main__":
170
208
  --discover is set will search the directory tree. If the specification
171
209
  does not follow the naming recommendation the --spec switch should be
172
210
  used.
211
+
212
+ Creates a file <filename>.errors.json alongside the original specification
213
+ containing a JSON representation of all the errors.
173
214
  """,
174
215
  )
175
216
 
@@ -185,7 +226,8 @@ if __name__ == "__main__":
185
226
  "--consistency-check",
186
227
  required=False,
187
228
  action="store_true",
188
- help="Runs a consistency check between the input specification and amati",
229
+ help="Runs a consistency check between the input specification and the"
230
+ " parsed specification",
189
231
  )
190
232
 
191
233
  parser.add_argument(
@@ -196,6 +238,25 @@ if __name__ == "__main__":
196
238
  help="Searches the specified directory tree for openapi.yaml or openapi.json.",
197
239
  )
198
240
 
241
+ parser.add_argument(
242
+ "-l",
243
+ "--local",
244
+ required=False,
245
+ action="store_true",
246
+ help="Store errors local to the caller in a file called <file-name>.errors.json"
247
+ "; a .amati/ directory will be created.",
248
+ )
249
+
250
+ parser.add_argument(
251
+ "-hr",
252
+ "--html-report",
253
+ required=False,
254
+ action="store_true",
255
+ help="Creates an HTML report of the errors, called <file-name>.errors.html,"
256
+ " alongside the original file or in a .amati/ directory if the --local switch"
257
+ " is used",
258
+ )
259
+
199
260
  args = parser.parse_args()
200
261
 
201
262
  if args.spec:
@@ -204,4 +265,9 @@ if __name__ == "__main__":
204
265
  specifications = discover(args.discover)
205
266
 
206
267
  for specification in specifications:
207
- run(specification, args.consistency_check)
268
+ if successful_check := run(
269
+ specification, args.consistency_check, args.local, args.html_report
270
+ ):
271
+ print("Consistency check successful for {specification}")
272
+ else:
273
+ print("Consistency check failed for {specification}")
amati/exceptions.py CHANGED
@@ -4,8 +4,6 @@ Exceptions, declared here to not put in __init__
4
4
 
5
5
  from typing import Optional
6
6
 
7
- from amati.references import References
8
-
9
7
 
10
8
  class AmatiValueError(ValueError):
11
9
  """
@@ -21,6 +19,6 @@ class AmatiValueError(ValueError):
21
19
  ValueError
22
20
  """
23
21
 
24
- def __init__(self, message: str, reference: Optional[References] = None):
22
+ def __init__(self, message: str, reference_uri: Optional[str] = None):
25
23
  self.message = message
26
- self.reference = reference
24
+ self.reference_uri = reference_uri
amati/fields/email.py CHANGED
@@ -5,14 +5,10 @@ Validates an email according to the RFC5322 ABNF grammar - §3:
5
5
  from abnf import ParseError
6
6
  from abnf.grammars import rfc5322
7
7
 
8
- from amati import AmatiValueError, Reference
8
+ from amati import AmatiValueError
9
9
  from amati.fields import Str as _Str
10
10
 
11
- reference = Reference(
12
- title="Internet Message Format",
13
- url="https://www.rfc-editor.org/rfc/rfc5322#section-3",
14
- section="Syntax",
15
- )
11
+ reference_uri = "https://www.rfc-editor.org/rfc/rfc5322#section-3"
16
12
 
17
13
 
18
14
  class Email(_Str):
@@ -23,5 +19,5 @@ class Email(_Str):
23
19
  rfc5322.Rule("address").parse_all(value)
24
20
  except ParseError as e:
25
21
  raise AmatiValueError(
26
- message=f"{value} is not a valid email address", reference=reference
22
+ f"{value} is not a valid email address", reference_uri
27
23
  ) from e
@@ -11,12 +11,11 @@ import pathlib
11
11
  import re
12
12
  from typing import Optional, Self
13
13
 
14
- from amati import AmatiValueError, Reference
14
+ from amati import AmatiValueError
15
15
  from amati.fields import Str as _Str
16
16
 
17
- reference = Reference(
18
- title="Hypertext Transfer Protocol (HTTP) Status Code Registry",
19
- url="https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml",
17
+ reference_uri = (
18
+ "https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml"
20
19
  )
21
20
 
22
21
  DATA_DIRECTORY = pathlib.Path(__file__).parent.parent.resolve() / "data"
@@ -88,7 +87,7 @@ class HTTPStatusCode(_Str):
88
87
  self.is_range = True
89
88
  else:
90
89
  raise AmatiValueError(
91
- f"{value} is not a valid HTTP Status Code", reference=reference
90
+ f"{value} is not a valid HTTP Status Code", reference_uri
92
91
  )
93
92
 
94
93
  if self.description != "Unassigned":
amati/fields/iso9110.py CHANGED
@@ -9,12 +9,11 @@ and a class for scheme validation.
9
9
  import json
10
10
  import pathlib
11
11
 
12
- from amati import AmatiValueError, Reference
12
+ from amati import AmatiValueError
13
13
  from amati.fields import Str as _Str
14
14
 
15
- reference = Reference(
16
- title="Hypertext Transfer Protocol (HTTP) Authentication Scheme Registry (ISO9110)",
17
- url="https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml",
15
+ reference_uri = (
16
+ "https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml"
18
17
  )
19
18
 
20
19
 
@@ -57,5 +56,5 @@ class HTTPAuthenticationScheme(_Str):
57
56
  if value.lower() not in HTTP_AUTHENTICATION_SCHEMES:
58
57
  raise AmatiValueError(
59
58
  f"{value} is not a valid HTTP authentication schema.",
60
- reference=reference,
59
+ reference_uri,
61
60
  )
amati/fields/media.py CHANGED
@@ -9,14 +9,10 @@ from typing import Optional
9
9
  from abnf import ParseError
10
10
  from abnf.grammars import rfc7231
11
11
 
12
- from amati import AmatiValueError, Reference
12
+ from amati import AmatiValueError
13
13
  from amati.fields import Str as _Str
14
14
 
15
- reference = Reference(
16
- title="Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content",
17
- url="https://datatracker.ietf.org/doc/html/rfc7231#appendix-D",
18
- section="Appendix D",
19
- )
15
+ reference_uri = "https://datatracker.ietf.org/doc/html/rfc7231#appendix-D"
20
16
 
21
17
  DATA_DIRECTORY = pathlib.Path(__file__).parent.parent.resolve() / "data"
22
18
 
@@ -69,7 +65,7 @@ class MediaType(_Str):
69
65
 
70
66
  except ParseError as e:
71
67
  raise AmatiValueError(
72
- "Invalid media type or media type range", reference=reference
68
+ "Invalid media type or media type range", reference_uri
73
69
  ) from e
74
70
 
75
71
  if self.type in MEDIA_TYPES:
amati/fields/oas.py CHANGED
@@ -6,7 +6,7 @@ from typing import ClassVar
6
6
 
7
7
  from abnf import ParseError
8
8
 
9
- from amati import AmatiValueError, Reference
9
+ from amati import AmatiValueError
10
10
  from amati.fields import Str as _Str
11
11
  from amati.grammars import oas
12
12
 
@@ -31,10 +31,8 @@ class RuntimeExpression(_Str):
31
31
  It is validated against the ABNF grammar in the OpenAPI spec.
32
32
  """
33
33
 
34
- _reference: ClassVar[Reference] = Reference(
35
- title="OpenAPI Specification v3.1.1",
36
- section="Runtime Expressions",
37
- url="https://spec.openapis.org/oas/v3.1.1.html#runtime-expressions",
34
+ _reference_uri: ClassVar[str] = (
35
+ "https://spec.openapis.org/oas/v3.1.1.html#runtime-expressions"
38
36
  )
39
37
 
40
38
  def __init__(self, value: str):
@@ -50,7 +48,7 @@ class RuntimeExpression(_Str):
50
48
  except ParseError as e:
51
49
  raise AmatiValueError(
52
50
  f"{value} is not a valid runtime expression",
53
- reference=self._reference,
51
+ self._reference_uri,
54
52
  ) from e
55
53
 
56
54
 
@@ -59,11 +57,7 @@ class OpenAPI(_Str):
59
57
  Represents an OpenAPI version string.s
60
58
  """
61
59
 
62
- _reference: ClassVar[Reference] = Reference(
63
- title="OpenAPI Initiative Publications",
64
- url="https://spec.openapis.org/#openapi-specification",
65
- section="OpenAPI Specification ",
66
- )
60
+ _reference_uri: ClassVar[str] = "https://spec.openapis.org/#openapi-specification"
67
61
 
68
62
  def __init__(self, value: str):
69
63
  """
@@ -75,5 +69,5 @@ class OpenAPI(_Str):
75
69
  if value not in OPENAPI_VERSIONS:
76
70
  raise AmatiValueError(
77
71
  f"{value} is not a valid OpenAPI version",
78
- reference=self._reference,
72
+ self._reference_uri,
79
73
  )
@@ -6,14 +6,11 @@ Exchange (SPDX) licence list.
6
6
  import json
7
7
  import pathlib
8
8
 
9
- from amati import AmatiValueError, Reference
9
+ from amati import AmatiValueError
10
10
  from amati.fields import Str as _Str
11
11
  from amati.fields.uri import URI
12
12
 
13
- reference = Reference(
14
- title="SPDX License List",
15
- url="https://spdx.org/licenses/",
16
- )
13
+ reference_uri = "https://spdx.org/licenses/"
17
14
 
18
15
 
19
16
  DATA_DIRECTORY = pathlib.Path(__file__).parent.parent.resolve() / "data"
@@ -55,7 +52,7 @@ class SPDXIdentifier(_Str):
55
52
 
56
53
  if value not in VALID_LICENCES:
57
54
  raise AmatiValueError(
58
- f"{value} is not a valid SPDX licence identifier", reference=reference
55
+ f"{value} is not a valid SPDX licence identifier", reference_uri
59
56
  )
60
57
 
61
58
 
@@ -88,5 +85,5 @@ class SPDXURL(URI): # pylint: disable=invalid-name
88
85
 
89
86
  if value not in VALID_URLS:
90
87
  raise AmatiValueError(
91
- f"{value} is not associated with any identifier.", reference=reference
88
+ f"{value} is not associated with any identifier.", reference_uri
92
89
  )
amati/fields/uri.py CHANGED
@@ -11,7 +11,7 @@ import idna
11
11
  from abnf import Node, ParseError, Rule
12
12
  from abnf.grammars import rfc3986, rfc3987
13
13
 
14
- from amati import AmatiValueError, Reference
14
+ from amati import AmatiValueError
15
15
  from amati.fields import Str as _Str
16
16
  from amati.grammars import rfc6901
17
17
 
@@ -61,11 +61,7 @@ class Scheme(_Str):
61
61
  except ParseError as e:
62
62
  raise AmatiValueError(
63
63
  f"{value} is not a valid URI scheme",
64
- reference=Reference(
65
- title="Uniform Resource Identifier (URI): Generic Syntax",
66
- section="3.1 - Scheme",
67
- url="https://www.rfc-editor.org/rfc/rfc3986#section-3.1",
68
- ),
64
+ "https://www.rfc-editor.org/rfc/rfc3986#section-3.1",
69
65
  ) from e
70
66
 
71
67
  # Look up the scheme in the IANA registry to get status info
@@ -211,11 +207,7 @@ class URI(_Str):
211
207
  except ParseError as e:
212
208
  raise AmatiValueError(
213
209
  f"{value} is not a valid JSON pointer",
214
- reference=Reference(
215
- title="JavaScript Object Notation (JSON) Pointer",
216
- section="6 - URI Fragment Identifier Representation",
217
- url="https://www.rfc-editor.org/rfc/rfc6901#section-6",
218
- ),
210
+ "https://www.rfc-editor.org/rfc/rfc6901#section-6",
219
211
  ) from e
220
212
 
221
213
  # Attempt parsing with multiple RFC specifications in order of preference.
amati/logging.py CHANGED
@@ -4,21 +4,21 @@ Logging utilities for Amati.
4
4
 
5
5
  from contextlib import contextmanager
6
6
  from dataclasses import dataclass
7
- from typing import ClassVar, Generator, Optional, Type
8
-
9
- from amati.references import References
7
+ from typing import Any, ClassVar, Generator, NotRequired, TypedDict
10
8
 
11
9
  type LogType = Exception | Warning
12
10
 
13
11
 
14
12
  @dataclass
15
- class Log:
16
- message: str
17
- type: Type[LogType]
18
- reference: Optional[References] = None
13
+ class Log(TypedDict):
14
+ type: str
15
+ loc: NotRequired[tuple[int | str, ...]]
16
+ msg: str
17
+ input: NotRequired[Any]
18
+ url: NotRequired[str]
19
19
 
20
20
 
21
- class LogMixin(object):
21
+ class LogMixin:
22
22
  """
23
23
  A mixin class that provides logging functionality.
24
24