amati 0.1.0__py3-none-any.whl → 0.2__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
@@ -3,7 +3,9 @@ Amati is a specification validator, built to put a specification into
3
3
  a single datatype and validate on instantiation.
4
4
  """
5
5
 
6
- __version__ = "0.1.0"
6
+ import importlib.metadata
7
+
8
+ __version__ = importlib.metadata.version("amati")
7
9
 
8
10
  # Imports are here for convenience, they're not going to be used here
9
11
  # pylint: disable=unused-import
@@ -11,4 +13,3 @@ __version__ = "0.1.0"
11
13
 
12
14
  from amati.amati import dispatch, run
13
15
  from amati.exceptions import AmatiValueError
14
- 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
 
@@ -37,10 +38,10 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[ErrorDetails] | N
37
38
  version: JSONValue = data.get("openapi")
38
39
 
39
40
  if not isinstance(version, str):
40
- raise ValueError("A OpenAPI specification version must be a string.")
41
+ raise TypeError("A OpenAPI specification version must be a string.")
41
42
 
42
43
  if not version:
43
- raise ValueError("An OpenAPI Specfication must contain a version.")
44
+ raise TypeError("An OpenAPI Specfication must contain a version.")
44
45
 
45
46
  version_map: dict[str, str] = {
46
47
  "3.1.1": "311",
@@ -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,27 +87,113 @@ def check(original: JSONObject, validated: BaseModel) -> bool:
86
87
  return original_ == new_
87
88
 
88
89
 
89
- def run(file_path: str, consistency_check: bool = False, store_errors: 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
- Runs the full amati process
97
+ Runs the full amati process on a specific specification file.
98
+
99
+ * Parses the YAML or JSON specification, gunzipping if necessary.
100
+ * Validates the specification.
101
+ * Runs a consistency check on the ouput of the validation to verify
102
+ that the output is identical to the input.
103
+ * Stores any errors found during validation.
104
+
105
+ Args:
106
+ file_path: The specification to be validated
107
+ consistency_check: Whether or not to verify the output against the input
92
108
  """
93
109
 
94
- data = load_file(file_path)
110
+ spec = Path(file_path)
111
+
112
+ data = load_file(spec)
113
+
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
127
+
128
+ if local:
129
+ error_path = Path(".amati")
95
130
 
96
- result, errors = dispatch(data)
131
+ if not error_path.exists():
132
+ error_path.mkdir()
133
+
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)
97
157
 
98
158
  if result and consistency_check:
99
- if check(data, result):
100
- print("Consistency check successful")
101
- else:
102
- print("Consistency check failed")
159
+ return check(data, result)
160
+
161
+
162
+ def discover(discover_dir: str = ".") -> list[Path]:
163
+ """
164
+ Finds OpenAPI Specification files to validate
165
+
166
+ Args:
167
+ discover_dir: The directory to search through.
168
+ Returns:
169
+ A list of paths to validate.
170
+ """
171
+
172
+ specs: list[Path] = []
173
+
174
+ if Path("openapi.json").exists():
175
+ specs.append(Path("openapi.json"))
176
+
177
+ if Path("openapi.yaml").exists():
178
+ specs.append(Path("openapi.yaml"))
103
179
 
104
- if errors and store_errors:
105
- if not Path(".amati").exists():
106
- Path(".amati").mkdir()
180
+ if specs:
181
+ return specs
107
182
 
108
- with open(".amati/pydantic.json", "w", encoding="utf-8") as f:
109
- f.write(jsonpickle.encode(errors, unpicklable=False)) # type: ignore
183
+ if discover_dir == ".":
184
+ raise FileNotFoundError(
185
+ "openapi.json or openapi.yaml can't be found, use --discover or --spec."
186
+ )
187
+
188
+ specs = specs + list(Path(discover_dir).glob("**/openapi.json"))
189
+ specs = specs + list(Path(discover_dir).glob("**/openapi.yaml"))
190
+
191
+ if not specs:
192
+ raise FileNotFoundError(
193
+ "openapi.json or openapi.yaml can't be found, use --spec."
194
+ )
195
+
196
+ return specs
110
197
 
111
198
 
112
199
  if __name__ == "__main__":
@@ -115,11 +202,23 @@ if __name__ == "__main__":
115
202
 
116
203
  parser = argparse.ArgumentParser(
117
204
  prog="amati",
118
- description="Test whether a OpenAPI specification is valid.",
205
+ description="""
206
+ Tests whether a OpenAPI specification is valid. Will look an openapi.json
207
+ or openapi.yaml file in the directory that amati is called from. If
208
+ --discover is set will search the directory tree. If the specification
209
+ does not follow the naming recommendation the --spec switch should be
210
+ used.
211
+
212
+ Creates a file <filename>.errors.json alongside the original specification
213
+ containing a JSON representation of all the errors.
214
+ """,
119
215
  )
120
216
 
121
217
  parser.add_argument(
122
- "-s", "--spec", required=True, help="The specification to be parsed"
218
+ "-s",
219
+ "--spec",
220
+ required=False,
221
+ help="The specification to be parsed",
123
222
  )
124
223
 
125
224
  parser.add_argument(
@@ -127,17 +226,48 @@ if __name__ == "__main__":
127
226
  "--consistency-check",
128
227
  required=False,
129
228
  action="store_true",
130
- 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",
231
+ )
232
+
233
+ parser.add_argument(
234
+ "-d",
235
+ "--discover",
236
+ required=False,
237
+ default=".",
238
+ help="Searches the specified directory tree for openapi.yaml or openapi.json.",
239
+ )
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.",
131
248
  )
132
249
 
133
250
  parser.add_argument(
134
- "-se",
135
- "--store-errors",
251
+ "-hr",
252
+ "--html-report",
136
253
  required=False,
137
254
  action="store_true",
138
- help="Stores and errors in a file for visibility.",
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",
139
258
  )
140
259
 
141
260
  args = parser.parse_args()
142
261
 
143
- run(args.spec, args.consistency_check, args.store_errors)
262
+ if args.spec:
263
+ specifications: list[Path] = [Path(args.spec)]
264
+ else:
265
+ specifications = discover(args.discover)
266
+
267
+ for specification in specifications:
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