amati 0.1.1__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 +0 -1
- amati/_error_handler.py +48 -0
- amati/amati.py +86 -20
- amati/exceptions.py +2 -4
- amati/fields/email.py +3 -7
- amati/fields/http_status_codes.py +4 -5
- amati/fields/iso9110.py +4 -5
- amati/fields/media.py +3 -7
- amati/fields/oas.py +6 -12
- amati/fields/spdx_licences.py +4 -7
- amati/fields/uri.py +3 -11
- amati/logging.py +8 -8
- amati/model_validators.py +42 -33
- amati/validators/generic.py +18 -13
- amati/validators/oas304.py +93 -130
- amati/validators/oas311.py +58 -156
- {amati-0.1.1.dist-info → amati-0.2.dist-info}/METADATA +71 -9
- amati-0.2.dist-info/RECORD +37 -0
- amati/references.py +0 -33
- amati-0.1.1.dist-info/RECORD +0 -37
- {amati-0.1.1.dist-info → amati-0.2.dist-info}/WHEEL +0 -0
- {amati-0.1.1.dist-info → amati-0.2.dist-info}/entry_points.txt +0 -0
- {amati-0.1.1.dist-info → amati-0.2.dist-info}/licenses/LICENSE +0 -0
amati/__init__.py
CHANGED
amati/_error_handler.py
ADDED
@@ -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
|
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[
|
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.
|
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(
|
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
|
-
|
110
|
+
spec = Path(file_path)
|
105
111
|
|
106
|
-
|
112
|
+
data = load_file(spec)
|
107
113
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
115
|
-
|
116
|
-
Path(".amati").mkdir()
|
128
|
+
if local:
|
129
|
+
error_path = Path(".amati")
|
117
130
|
|
118
|
-
|
131
|
+
if not error_path.exists():
|
132
|
+
error_path.mkdir()
|
119
133
|
|
120
|
-
with open(
|
121
|
-
|
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
|
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(
|
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,
|
22
|
+
def __init__(self, message: str, reference_uri: Optional[str] = None):
|
25
23
|
self.message = message
|
26
|
-
self.
|
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
|
8
|
+
from amati import AmatiValueError
|
9
9
|
from amati.fields import Str as _Str
|
10
10
|
|
11
|
-
|
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
|
-
|
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
|
14
|
+
from amati import AmatiValueError
|
15
15
|
from amati.fields import Str as _Str
|
16
16
|
|
17
|
-
|
18
|
-
|
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",
|
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
|
12
|
+
from amati import AmatiValueError
|
13
13
|
from amati.fields import Str as _Str
|
14
14
|
|
15
|
-
|
16
|
-
|
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
|
-
|
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
|
12
|
+
from amati import AmatiValueError
|
13
13
|
from amati.fields import Str as _Str
|
14
14
|
|
15
|
-
|
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",
|
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
|
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
|
-
|
35
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
72
|
+
self._reference_uri,
|
79
73
|
)
|
amati/fields/spdx_licences.py
CHANGED
@@ -6,14 +6,11 @@ Exchange (SPDX) licence list.
|
|
6
6
|
import json
|
7
7
|
import pathlib
|
8
8
|
|
9
|
-
from amati import AmatiValueError
|
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
|
-
|
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",
|
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.",
|
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
|
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
|
-
|
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
|
-
|
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,
|
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
|
-
|
17
|
-
|
18
|
-
|
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
|
21
|
+
class LogMixin:
|
22
22
|
"""
|
23
23
|
A mixin class that provides logging functionality.
|
24
24
|
|