amati 0.1.0__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 +14 -0
- amati/_resolve_forward_references.py +185 -0
- amati/amati.py +143 -0
- amati/data/http-status-codes.json +1 -0
- amati/data/iso9110.json +1 -0
- amati/data/media-types.json +1 -0
- amati/data/schemes.json +1 -0
- amati/data/spdx-licences.json +1 -0
- amati/data/tlds.json +1 -0
- amati/exceptions.py +26 -0
- amati/fields/__init__.py +15 -0
- amati/fields/_custom_types.py +71 -0
- amati/fields/commonmark.py +9 -0
- amati/fields/email.py +27 -0
- amati/fields/http_status_codes.py +95 -0
- amati/fields/iso9110.py +61 -0
- amati/fields/json.py +13 -0
- amati/fields/media.py +100 -0
- amati/fields/oas.py +79 -0
- amati/fields/spdx_licences.py +92 -0
- amati/fields/uri.py +342 -0
- amati/file_handler.py +155 -0
- amati/grammars/oas.py +45 -0
- amati/grammars/rfc6901.py +26 -0
- amati/grammars/rfc7159.py +65 -0
- amati/logging.py +57 -0
- amati/model_validators.py +438 -0
- amati/references.py +33 -0
- amati/validators/__init__.py +0 -0
- amati/validators/generic.py +133 -0
- amati/validators/oas304.py +1031 -0
- amati/validators/oas311.py +615 -0
- amati-0.1.0.dist-info/METADATA +89 -0
- amati-0.1.0.dist-info/RECORD +37 -0
- amati-0.1.0.dist-info/WHEEL +4 -0
- amati-0.1.0.dist-info/entry_points.txt +2 -0
- amati-0.1.0.dist-info/licenses/LICENSE +21 -0
amati/fields/uri.py
ADDED
@@ -0,0 +1,342 @@
|
|
1
|
+
"""
|
2
|
+
Validates a URI according to the RFC3986 ABNF grammar
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
import pathlib
|
7
|
+
from enum import Enum
|
8
|
+
from typing import Optional, Self
|
9
|
+
|
10
|
+
import idna
|
11
|
+
from abnf import Node, ParseError, Rule
|
12
|
+
from abnf.grammars import rfc3986, rfc3987
|
13
|
+
|
14
|
+
from amati import AmatiValueError, Reference
|
15
|
+
from amati.fields import Str as _Str
|
16
|
+
from amati.grammars import rfc6901
|
17
|
+
|
18
|
+
DATA_DIRECTORY = pathlib.Path(__file__).parent.parent.resolve() / "data"
|
19
|
+
|
20
|
+
with open(DATA_DIRECTORY / "schemes.json", "r", encoding="utf-8") as f:
|
21
|
+
SCHEMES = json.loads(f.read())
|
22
|
+
|
23
|
+
|
24
|
+
class Scheme(_Str):
|
25
|
+
"""Represents a URI scheme with validation and status tracking.
|
26
|
+
|
27
|
+
This class validates URI schemes according to RFC 3986 standards and
|
28
|
+
provides information about their registration status with IANA. It
|
29
|
+
inherits from _Str to provide string-like behavior while adding
|
30
|
+
scheme-specific functionality.
|
31
|
+
|
32
|
+
Attributes:
|
33
|
+
status: The IANA registration status of the scheme. Common values
|
34
|
+
include "Permanent", "Provisional", "Historical", or None for
|
35
|
+
unregistered schemes.
|
36
|
+
|
37
|
+
Example:
|
38
|
+
>>> Scheme("https").status
|
39
|
+
'Permanent'
|
40
|
+
"""
|
41
|
+
|
42
|
+
status: Optional[str] = None
|
43
|
+
|
44
|
+
def __init__(self, value: str) -> None:
|
45
|
+
"""Initialize a new Scheme instance with validation.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
value: The scheme string
|
49
|
+
|
50
|
+
Raises:
|
51
|
+
AmatiValueError: If the provided value does not conform to RFC 3986
|
52
|
+
scheme syntax rules.
|
53
|
+
"""
|
54
|
+
|
55
|
+
super().__init__()
|
56
|
+
|
57
|
+
# Validate the scheme against RFC 3986 syntax rules
|
58
|
+
# This will raise ParseError if the scheme is invalid
|
59
|
+
try:
|
60
|
+
rfc3986.Rule("scheme").parse_all(value)
|
61
|
+
except ParseError as e:
|
62
|
+
raise AmatiValueError(
|
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
|
+
),
|
69
|
+
) from e
|
70
|
+
|
71
|
+
# Look up the scheme in the IANA registry to get status info
|
72
|
+
# Returns None if the scheme is not in the registry
|
73
|
+
self.status = SCHEMES.get(value, None)
|
74
|
+
|
75
|
+
|
76
|
+
class URIType(str, Enum):
|
77
|
+
|
78
|
+
ABSOLUTE = "absolute"
|
79
|
+
RELATIVE = "relative"
|
80
|
+
NON_RELATIVE = "non-relative"
|
81
|
+
JSON_POINTER = "JSON pointer"
|
82
|
+
|
83
|
+
|
84
|
+
class URI(_Str):
|
85
|
+
"""
|
86
|
+
A class representing a Uniform Resource Identifier (URI) as defined in
|
87
|
+
RFC 3986/3987.
|
88
|
+
|
89
|
+
This class parses and validates URI strings, supporting standard URIs, IRIs
|
90
|
+
(Internationalized Resource Identifiers), and JSON pointers. It provides attributes
|
91
|
+
for accessing URI components and determining the URI type and validity.
|
92
|
+
|
93
|
+
The class attempts to parse URIs using multiple RFC specifications in order of
|
94
|
+
preference, falling back to less restrictive parsing when necessary.
|
95
|
+
|
96
|
+
Attributes:
|
97
|
+
scheme: The URI scheme component (e.g., "http", "https").
|
98
|
+
authority: The authority component.
|
99
|
+
path: The path component
|
100
|
+
query: The query string component
|
101
|
+
fragment: The fragment identifier
|
102
|
+
is_iri: Whether this is an Internationalized Resource Identifier.
|
103
|
+
|
104
|
+
Example:
|
105
|
+
>>> uri = URI("https://example.com/path?query#fragment")
|
106
|
+
>>> uri.scheme
|
107
|
+
'https'
|
108
|
+
>>> uri.authority
|
109
|
+
'example.com'
|
110
|
+
>>> uri.type
|
111
|
+
<URIType.ABSOLUTE: 'absolute'>
|
112
|
+
"""
|
113
|
+
|
114
|
+
scheme: Optional[Scheme] = None
|
115
|
+
authority: Optional[str] = None
|
116
|
+
host: Optional[str] = None
|
117
|
+
path: Optional[str] = None
|
118
|
+
query: Optional[str] = None
|
119
|
+
fragment: Optional[str] = None
|
120
|
+
# RFC 3987 Internationalized Resource Identifier (IRI) flag
|
121
|
+
is_iri: bool = False
|
122
|
+
|
123
|
+
_attribute_map: dict[str, str] = {
|
124
|
+
"authority": "authority",
|
125
|
+
"iauthority": "authority",
|
126
|
+
"host": "host",
|
127
|
+
"ihost": "host",
|
128
|
+
"path-abempty": "path",
|
129
|
+
"path-absolute": "path",
|
130
|
+
"path-noscheme": "path",
|
131
|
+
"path-rootless": "path",
|
132
|
+
"path-empty": "path",
|
133
|
+
"ipath-abempty": "path",
|
134
|
+
"ipath-absolute": "path",
|
135
|
+
"ipath-noscheme": "path",
|
136
|
+
"ipath-rootless": "path",
|
137
|
+
"ipath-empty": "path",
|
138
|
+
"query": "query",
|
139
|
+
"iquery": "query",
|
140
|
+
"fragment": "fragment",
|
141
|
+
"ifragment": "fragment",
|
142
|
+
}
|
143
|
+
|
144
|
+
@property
|
145
|
+
def type(self) -> URIType:
|
146
|
+
"""
|
147
|
+
Determine the type of the URI based on its components.
|
148
|
+
|
149
|
+
This property analyzes the URI components to classify the URI according to the
|
150
|
+
URIType enumeration. The classification follows a hierarchical approach:
|
151
|
+
absolute URIs take precedence over non-relative, which take precedence over
|
152
|
+
relative URIs.
|
153
|
+
|
154
|
+
Returns:
|
155
|
+
URIType: The classified type of the URI (ABSOLUTE, NON_RELATIVE, RELATIVE,
|
156
|
+
or JSON_POINTER).
|
157
|
+
|
158
|
+
Raises:
|
159
|
+
TypeError: If the URI has no scheme, authority, or path components.
|
160
|
+
"""
|
161
|
+
|
162
|
+
if self.scheme:
|
163
|
+
return URIType.ABSOLUTE
|
164
|
+
if self.authority:
|
165
|
+
return URIType.NON_RELATIVE
|
166
|
+
if self.path:
|
167
|
+
if str(self).startswith("#"):
|
168
|
+
return URIType.JSON_POINTER
|
169
|
+
return URIType.RELATIVE
|
170
|
+
|
171
|
+
# Should theoretically never be reached as if a URI does not have a scheme
|
172
|
+
# authority or path an AmatiValueError should be raised. However, without
|
173
|
+
# an additional return there is a code path in type() that doesn't return a
|
174
|
+
# value. It's better to deal with the potential error case than ignore the
|
175
|
+
# lack of a return value.
|
176
|
+
raise TypeError(f"{str(self)} does not have a URI type.") # pragma: no cover
|
177
|
+
|
178
|
+
def __init__(self, value: str):
|
179
|
+
"""
|
180
|
+
Initialize a URI object by parsing a URI string.
|
181
|
+
|
182
|
+
Parses the input string according to RFC 3986/3987 grammar rules for URIs/IRIs.
|
183
|
+
Handles special cases like JSON pointers (RFC 6901) and performs validation.
|
184
|
+
Attempts multiple parsing strategies in order of preference.
|
185
|
+
|
186
|
+
Args:
|
187
|
+
value: A string representing a URI.
|
188
|
+
|
189
|
+
Raises:
|
190
|
+
AmatiValueError: If the input string is None, not a valid URI according to
|
191
|
+
any supported RFC specification, is a JSON pointer with invalid syntax,
|
192
|
+
or contains only a fragment without other components.
|
193
|
+
"""
|
194
|
+
|
195
|
+
super().__init__()
|
196
|
+
|
197
|
+
if value is None: # type: ignore
|
198
|
+
raise AmatiValueError("None is not a valid URI; declare as Optional")
|
199
|
+
|
200
|
+
candidate = value
|
201
|
+
|
202
|
+
# Handle JSON pointers as per OpenAPI Specification (OAS) standard.
|
203
|
+
# OAS uses fragment identifiers to indicate JSON pointers per RFC 6901,
|
204
|
+
# e.g., "$ref": "#/components/schemas/pet".
|
205
|
+
# The hash symbol does not indicate a URI fragment in this context.
|
206
|
+
|
207
|
+
if value.startswith("#"):
|
208
|
+
candidate = value[1:]
|
209
|
+
try:
|
210
|
+
rfc6901.Rule("json-pointer").parse_all(candidate)
|
211
|
+
except ParseError as e:
|
212
|
+
raise AmatiValueError(
|
213
|
+
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
|
+
),
|
219
|
+
) from e
|
220
|
+
|
221
|
+
# Attempt parsing with multiple RFC specifications in order of preference.
|
222
|
+
# Start with most restrictive (RFC 3986 URI) and fall back to more permissive
|
223
|
+
# specifications as needed.
|
224
|
+
rules_to_attempt: tuple[Rule, ...] = (
|
225
|
+
rfc3986.Rule("URI"),
|
226
|
+
rfc3987.Rule("IRI"),
|
227
|
+
rfc3986.Rule("hier-part"),
|
228
|
+
rfc3987.Rule("ihier-part"),
|
229
|
+
rfc3986.Rule("relative-ref"),
|
230
|
+
rfc3987.Rule("irelative-ref"),
|
231
|
+
)
|
232
|
+
|
233
|
+
for rule in rules_to_attempt:
|
234
|
+
try:
|
235
|
+
result = rule.parse_all(candidate)
|
236
|
+
except ParseError:
|
237
|
+
# If the rule fails, continue to the next rule
|
238
|
+
continue
|
239
|
+
|
240
|
+
self._add_attributes(result)
|
241
|
+
|
242
|
+
# Mark as IRI if parsed using RFC 3987 rules
|
243
|
+
if rule.__module__ == rfc3987.__name__:
|
244
|
+
self.is_iri = True
|
245
|
+
elif self.host:
|
246
|
+
# If the host is IDNA encoded then the URI is an IRI.
|
247
|
+
# IDNA encoded URIs will successfully parse with RFC 3986
|
248
|
+
self.is_iri = idna.decode(self.host, uts46=True) != self.host.lower()
|
249
|
+
|
250
|
+
# Successfully parsed - stop attempting other rules
|
251
|
+
break
|
252
|
+
|
253
|
+
# A URI is invalid if it contains only a fragment without scheme, authority,
|
254
|
+
# or path.
|
255
|
+
if not self.scheme and not self.authority and not self.path:
|
256
|
+
raise AmatiValueError(
|
257
|
+
f"{value} does not contain a scheme, authority or path"
|
258
|
+
)
|
259
|
+
|
260
|
+
def _add_attributes(self: Self, node: Node):
|
261
|
+
"""
|
262
|
+
Recursively extract and set attributes from the parsed ABNF grammar tree.
|
263
|
+
|
264
|
+
This method traverses the parsed grammar tree and assigns values to the
|
265
|
+
appropriate class attributes based on the node names and types encountered.
|
266
|
+
Special handling is provided for scheme nodes (converted to Scheme objects).
|
267
|
+
|
268
|
+
Args:
|
269
|
+
node: The current node from the parsed ABNF grammar tree.
|
270
|
+
"""
|
271
|
+
|
272
|
+
for child in node.children:
|
273
|
+
|
274
|
+
# If the node name is in the URI annotations, set the attribute
|
275
|
+
if child.name == "scheme":
|
276
|
+
self.__dict__["scheme"] = Scheme(child.value)
|
277
|
+
elif child.name in self._attribute_map:
|
278
|
+
self.__dict__[self._attribute_map[child.name]] = child.value
|
279
|
+
|
280
|
+
# If the child is a node with children, recursively add attributes
|
281
|
+
# This is necessary for nodes that have nested structures, such as
|
282
|
+
# the hier-part that may contain subcomponents.
|
283
|
+
if child.children:
|
284
|
+
self._add_attributes(child)
|
285
|
+
|
286
|
+
|
287
|
+
class URIWithVariables(URI):
|
288
|
+
"""
|
289
|
+
Extends URI to cope with URIs with variable components, e.g.
|
290
|
+
https://{username}.example.com/api/v1/{resource}
|
291
|
+
|
292
|
+
Expected to be used where tooling is required to use string interpolation to
|
293
|
+
generate a valid URI. Will change `{username}` to `username` for validation,
|
294
|
+
but return the original string when called.
|
295
|
+
|
296
|
+
Attributes:
|
297
|
+
scheme: The URI scheme component (e.g., "http", "https").
|
298
|
+
authority: The authority component.
|
299
|
+
path: The path component
|
300
|
+
query: The query string component
|
301
|
+
fragment: The fragment identifier
|
302
|
+
is_iri: Whether this is an Internationalized Resource Identifier.
|
303
|
+
tld_registered: Whether the top-level domain is registered with IANA.
|
304
|
+
|
305
|
+
Inherits:
|
306
|
+
URI: Represents a Uniform Resource Identifier (URI) as defined in RFC 3986/3987.
|
307
|
+
"""
|
308
|
+
|
309
|
+
def __init__(self, value: str):
|
310
|
+
"""
|
311
|
+
Validate that the URI is a valid URI with variables.
|
312
|
+
e.g. of the form:
|
313
|
+
|
314
|
+
https://{username}.example.com/api/v1/{resource}
|
315
|
+
|
316
|
+
Args:
|
317
|
+
value: The URI to validate
|
318
|
+
|
319
|
+
Raises:
|
320
|
+
ValueError: If there are unbalanced or embedded braces in the URI
|
321
|
+
AmatiValueError: If the value is None
|
322
|
+
"""
|
323
|
+
|
324
|
+
if value is None: # type: ignore
|
325
|
+
raise AmatiValueError("None is not a valid URI; declare as Optional")
|
326
|
+
|
327
|
+
# `string.format()` takes a dict of the key, value pairs to
|
328
|
+
# replace to replace the keys inside braces. As we don't have the keys a dict
|
329
|
+
# that returns the keys that `string.format()` is expecting will have the
|
330
|
+
# effect of replacing '{a}b{c} with 'abc'.
|
331
|
+
class MissingKeyDict(dict[str, str]):
|
332
|
+
def __missing__(self, key: str) -> str:
|
333
|
+
return key
|
334
|
+
|
335
|
+
# Unbalanced or embedded braces, e.g. /example/{id{a}}/ or /example/{id
|
336
|
+
# will cause a ValueError in .format_map().
|
337
|
+
try:
|
338
|
+
candidate = value.format_map(MissingKeyDict())
|
339
|
+
except ValueError as e:
|
340
|
+
raise ValueError(f"Unbalanced or embedded braces in {value}") from e
|
341
|
+
|
342
|
+
super().__init__(candidate)
|
amati/file_handler.py
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
"""
|
2
|
+
File Loader Module with Gzip Support
|
3
|
+
|
4
|
+
A file loading system that provides automatic format detection and gzip decompression
|
5
|
+
for JSON and YAML files. The module uses the Strategy pattern to allow easy extension
|
6
|
+
for additional file formats while maintaining type safety and comprehensive error
|
7
|
+
handling.
|
8
|
+
|
9
|
+
Features:
|
10
|
+
- Automatic gzip detection using magic byte inspection
|
11
|
+
- Safe loading of JSON and YAML files using json.loads() and yaml.safe_load()
|
12
|
+
- Extensible architecture for adding new file format support
|
13
|
+
- Comprehensive type hints for Python 3.13+
|
14
|
+
- Detailed error handling with descriptive messages
|
15
|
+
- Support for both regular and compressed files
|
16
|
+
|
17
|
+
Supported File Types:
|
18
|
+
- JSON: .json, .js (optionally gzip compressed)
|
19
|
+
- YAML: .yaml, .yml (optionally gzip compressed)
|
20
|
+
"""
|
21
|
+
|
22
|
+
import gzip
|
23
|
+
import json
|
24
|
+
from abc import ABC, abstractmethod
|
25
|
+
from pathlib import Path
|
26
|
+
|
27
|
+
import yaml
|
28
|
+
|
29
|
+
type JSONPrimitive = str | int | float | bool | None
|
30
|
+
type JSONArray = list["JSONValue"]
|
31
|
+
type JSONObject = dict[str, "JSONValue"]
|
32
|
+
type JSONValue = JSONPrimitive | JSONArray | JSONObject
|
33
|
+
|
34
|
+
|
35
|
+
class FileLoader(ABC):
|
36
|
+
"""Abstract base class for file loaders."""
|
37
|
+
|
38
|
+
@abstractmethod
|
39
|
+
def can_handle(self, file_path: Path) -> bool:
|
40
|
+
"""Check if this loader can handle the given file."""
|
41
|
+
pass
|
42
|
+
|
43
|
+
@abstractmethod
|
44
|
+
def load(self, content: str) -> JSONObject:
|
45
|
+
"""Load and parse the file content."""
|
46
|
+
pass
|
47
|
+
|
48
|
+
|
49
|
+
class JSONLoader(FileLoader):
|
50
|
+
"""Loader for JSON files."""
|
51
|
+
|
52
|
+
def can_handle(self, file_path: Path) -> bool:
|
53
|
+
return file_path.suffix.lower() in {".json", ".js"}
|
54
|
+
|
55
|
+
def load(self, content: str) -> JSONObject:
|
56
|
+
return json.loads(content)
|
57
|
+
|
58
|
+
|
59
|
+
class YAMLLoader(FileLoader):
|
60
|
+
"""Loader for YAML files."""
|
61
|
+
|
62
|
+
def can_handle(self, file_path: Path) -> bool:
|
63
|
+
return file_path.suffix.lower() in {".yaml", ".yml"}
|
64
|
+
|
65
|
+
def load(self, content: str) -> JSONObject:
|
66
|
+
return yaml.safe_load(content)
|
67
|
+
|
68
|
+
|
69
|
+
class FileProcessor:
|
70
|
+
"""Main processor for handling gzipped and regular files."""
|
71
|
+
|
72
|
+
def __init__(self) -> None:
|
73
|
+
self.loaders: list[FileLoader] = [JSONLoader(), YAMLLoader()]
|
74
|
+
|
75
|
+
def _is_gzip_file(self, file_path: Path) -> bool:
|
76
|
+
"""Check if file is gzipped by reading magic bytes."""
|
77
|
+
try:
|
78
|
+
with open(file_path, "rb") as f:
|
79
|
+
magic = f.read(2)
|
80
|
+
return magic == b"\x1f\x8b"
|
81
|
+
except (IOError, OSError):
|
82
|
+
return False
|
83
|
+
|
84
|
+
def _get_decompressed_path(self, file_path: Path) -> Path:
|
85
|
+
"""Get the path without .gz extension for determining file type."""
|
86
|
+
if file_path.suffix.lower() == ".gz":
|
87
|
+
return file_path.with_suffix("")
|
88
|
+
return file_path
|
89
|
+
|
90
|
+
def _read_file_content(self, file_path: Path) -> str:
|
91
|
+
"""Read file content, decompressing if necessary."""
|
92
|
+
if self._is_gzip_file(file_path):
|
93
|
+
with gzip.open(file_path, "rt", encoding="utf-8") as f:
|
94
|
+
return f.read()
|
95
|
+
else:
|
96
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
97
|
+
return f.read()
|
98
|
+
|
99
|
+
def _get_appropriate_loader(self, file_path: Path) -> FileLoader:
|
100
|
+
"""Get the appropriate loader for the file type."""
|
101
|
+
# Use the decompressed path to determine file type
|
102
|
+
target_path = self._get_decompressed_path(file_path)
|
103
|
+
|
104
|
+
for loader in self.loaders:
|
105
|
+
if loader.can_handle(target_path):
|
106
|
+
return loader
|
107
|
+
|
108
|
+
raise ValueError(f"No suitable loader found for file: {file_path}")
|
109
|
+
|
110
|
+
def load_file(self, file_path: str | Path) -> JSONObject:
|
111
|
+
"""
|
112
|
+
Load a file, handling gzip decompression and format detection.
|
113
|
+
|
114
|
+
Args:
|
115
|
+
file_path: Path to the file to load
|
116
|
+
|
117
|
+
Returns:
|
118
|
+
Parsed content as dict, list, or other appropriate type
|
119
|
+
|
120
|
+
Raises:
|
121
|
+
FileNotFoundError: If the file doesn't exist
|
122
|
+
ValueError: If no suitable loader is found
|
123
|
+
json.JSONDecodeError: If JSON parsing fails
|
124
|
+
yaml.YAMLError: If YAML parsing fails
|
125
|
+
OSError: If file reading fails
|
126
|
+
"""
|
127
|
+
path = Path(file_path)
|
128
|
+
|
129
|
+
if not path.exists():
|
130
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
131
|
+
|
132
|
+
try:
|
133
|
+
content = self._read_file_content(path)
|
134
|
+
loader = self._get_appropriate_loader(path)
|
135
|
+
return loader.load(content)
|
136
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
137
|
+
raise TypeError(f"Failed to parse {path}: {e}") from e
|
138
|
+
except (IOError, OSError) as e:
|
139
|
+
raise OSError(f"Failed to read file {path}: {e}") from e
|
140
|
+
|
141
|
+
|
142
|
+
def load_file(file_path: str | Path) -> JSONObject:
|
143
|
+
"""
|
144
|
+
Convenience function to load a file with automatic format detection and gzip
|
145
|
+
support.
|
146
|
+
|
147
|
+
Args:
|
148
|
+
file_path: Path to the file to load (.json, .yaml, .yml, optionally .gz
|
149
|
+
compressed)
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
Parsed content as dict, list, or other appropriate type
|
153
|
+
"""
|
154
|
+
processor = FileProcessor()
|
155
|
+
return processor.load_file(file_path)
|
amati/grammars/oas.py
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
"""
|
2
|
+
Collected rules from the OpenAPI Specification Runtime Expression
|
3
|
+
grammar - Section 4.8.20.4
|
4
|
+
|
5
|
+
https://spec.openapis.org/oas/latest.html#runtime-expressions
|
6
|
+
|
7
|
+
"""
|
8
|
+
|
9
|
+
from typing import ClassVar
|
10
|
+
|
11
|
+
from abnf.grammars import rfc7230
|
12
|
+
from abnf.grammars.misc import load_grammar_rules
|
13
|
+
from abnf.parser import Rule as _Rule
|
14
|
+
|
15
|
+
from amati.grammars import rfc6901, rfc7159
|
16
|
+
|
17
|
+
|
18
|
+
@load_grammar_rules(
|
19
|
+
[
|
20
|
+
("json-pointer", rfc6901.Rule("json-pointer")),
|
21
|
+
("char", rfc7159.Rule("char")),
|
22
|
+
("token", rfc7230.Rule("token")),
|
23
|
+
]
|
24
|
+
)
|
25
|
+
class Rule(_Rule):
|
26
|
+
"""Parser rules for grammar from OpenAPI Specification"""
|
27
|
+
|
28
|
+
grammar: ClassVar[list[str] | str] = [
|
29
|
+
'expression = "$url" / "$method" / "$statusCode" / "$request." source / "$response." source', # pylint: disable=line-too-long
|
30
|
+
"source = header-reference / query-reference / path-reference / body-reference", # pylint: disable=line-too-long
|
31
|
+
'header-reference = "header." token',
|
32
|
+
'query-reference = "query." name',
|
33
|
+
'path-reference = "path." name',
|
34
|
+
'body-reference = "body" ["#" json-pointer ]',
|
35
|
+
# json-pointer = *( "/" reference-token )
|
36
|
+
# reference-token = *( unescaped / escaped )
|
37
|
+
# unescaped = %x00-2E / %x30-7D / %x7F-10FFFF
|
38
|
+
# ; %x2F ('/') and %x7E ('~') are excluded from 'unescaped'
|
39
|
+
# escaped = "~" ( "0" / "1" )
|
40
|
+
# ; representing '~' and '/', respectively
|
41
|
+
"name = *( CHAR )",
|
42
|
+
# token = 1*tchar,
|
43
|
+
# tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "."
|
44
|
+
# / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
|
45
|
+
]
|
@@ -0,0 +1,26 @@
|
|
1
|
+
"""
|
2
|
+
Collected rules from RFC 6901, Section 3.
|
3
|
+
https://www.rfc-editor.org/rfc/rfc6901#section-3
|
4
|
+
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import ClassVar
|
8
|
+
|
9
|
+
from abnf.grammars.misc import load_grammar_rulelist
|
10
|
+
from abnf.parser import Rule as _Rule
|
11
|
+
|
12
|
+
|
13
|
+
@load_grammar_rulelist()
|
14
|
+
class Rule(_Rule):
|
15
|
+
"""Parser rules for grammar from RFC 6901"""
|
16
|
+
|
17
|
+
grammar: ClassVar[
|
18
|
+
list[str] | str
|
19
|
+
] = r"""
|
20
|
+
json-pointer = *( "/" reference-token )
|
21
|
+
reference-token = *( unescaped / escaped )
|
22
|
+
unescaped = %x00-2E / %x30-7D / %x7F-10FFFF
|
23
|
+
; %x2F ('/') and %x7E ('~') are excluded from 'unescaped'
|
24
|
+
escaped = "~" ( "0" / "1" )
|
25
|
+
; representing '~' and '/', respectively
|
26
|
+
"""
|
@@ -0,0 +1,65 @@
|
|
1
|
+
"""
|
2
|
+
Collected rules from RFC 7159, Sections 2 to 7.
|
3
|
+
https://www.rfc-editor.org/rfc/rfc7159#section-2
|
4
|
+
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import ClassVar
|
8
|
+
|
9
|
+
from abnf.grammars.misc import load_grammar_rulelist
|
10
|
+
from abnf.parser import Rule as _Rule
|
11
|
+
|
12
|
+
|
13
|
+
@load_grammar_rulelist()
|
14
|
+
class Rule(_Rule):
|
15
|
+
"""Parser rules for grammar from RFC 7159"""
|
16
|
+
|
17
|
+
grammar: ClassVar[
|
18
|
+
list[str] | str
|
19
|
+
] = r"""
|
20
|
+
JSON-text = ws value ws
|
21
|
+
begin-array = ws %x5B ws ; [ left square bracket
|
22
|
+
begin-object = ws %x7B ws ; { left curly bracket
|
23
|
+
end-array = ws %x5D ws ; ] right square bracket
|
24
|
+
end-object = ws %x7D ws ; } right curly bracket
|
25
|
+
name-separator = ws %x3A ws ; : colon
|
26
|
+
value-separator = ws %x2C ws ; , comma
|
27
|
+
ws = *(
|
28
|
+
%x20 / ; Space
|
29
|
+
%x09 / ; Horizontal tab
|
30
|
+
%x0A / ; Line feed or New line
|
31
|
+
%x0D ) ; Carriage return
|
32
|
+
value = false / null / true / object / array / number / string
|
33
|
+
false = %x66.61.6c.73.65 ; false
|
34
|
+
null = %x6e.75.6c.6c ; null
|
35
|
+
true = %x74.72.75.65 ; true
|
36
|
+
object = begin-object [ member *( value-separator member ) ]
|
37
|
+
end-object
|
38
|
+
member = string name-separator value
|
39
|
+
array = begin-array [ value *( value-separator value ) ] end-array
|
40
|
+
number = [ minus ] int [ frac ] [ exp ]
|
41
|
+
decimal-point = %x2E ; .
|
42
|
+
digit1-9 = %x31-39 ; 1-9
|
43
|
+
e = %x65 / %x45 ; e E
|
44
|
+
exp = e [ minus / plus ] 1*DIGIT
|
45
|
+
frac = decimal-point 1*DIGIT
|
46
|
+
int = zero / ( digit1-9 *DIGIT )
|
47
|
+
minus = %x2D ; -
|
48
|
+
plus = %x2B ; +
|
49
|
+
zero = %x30 ; 0
|
50
|
+
string = quotation-mark *char quotation-mark
|
51
|
+
char = unescaped /
|
52
|
+
escape (
|
53
|
+
%x22 / ; " quotation mark U+0022
|
54
|
+
%x5C / ; \ reverse solidus U+005C
|
55
|
+
%x2F / ; / solidus U+002F
|
56
|
+
%x62 / ; b backspace U+0008
|
57
|
+
%x66 / ; f form feed U+000C
|
58
|
+
%x6E / ; n line feed U+000A
|
59
|
+
%x72 / ; r carriage return U+000D
|
60
|
+
%x74 / ; t tab U+0009
|
61
|
+
%x75 4HEXDIG ) ; uXXXX U+XXXX
|
62
|
+
escape = %x5C ; \
|
63
|
+
quotation-mark = %x22 ; "
|
64
|
+
unescaped = %x20-21 / %x23-5B / %x5D-10FFFF
|
65
|
+
"""
|
amati/logging.py
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
"""
|
2
|
+
Logging utilities for Amati.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from contextlib import contextmanager
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from typing import ClassVar, Generator, Optional, Type
|
8
|
+
|
9
|
+
from amati.references import References
|
10
|
+
|
11
|
+
type LogType = Exception | Warning
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class Log:
|
16
|
+
message: str
|
17
|
+
type: Type[LogType]
|
18
|
+
reference: Optional[References] = None
|
19
|
+
|
20
|
+
|
21
|
+
class LogMixin(object):
|
22
|
+
"""
|
23
|
+
A mixin class that provides logging functionality.
|
24
|
+
|
25
|
+
This class maintains a list of Log messages that are added.
|
26
|
+
It is NOT thread-safe. State is maintained at a global level.
|
27
|
+
"""
|
28
|
+
|
29
|
+
logs: ClassVar[list[Log]] = []
|
30
|
+
|
31
|
+
@classmethod
|
32
|
+
def log(cls, message: Log) -> None:
|
33
|
+
"""Add a new message to the logs list.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
message: A Log object containing the message to be logged.
|
37
|
+
|
38
|
+
Returns:
|
39
|
+
The current list of logs after adding the new message.
|
40
|
+
"""
|
41
|
+
cls.logs.append(message)
|
42
|
+
|
43
|
+
@classmethod
|
44
|
+
@contextmanager
|
45
|
+
def context(cls) -> Generator[list[Log], None, None]:
|
46
|
+
"""Create a context manager for handling logs.
|
47
|
+
|
48
|
+
Yields:
|
49
|
+
The current list of logs.
|
50
|
+
|
51
|
+
Notes:
|
52
|
+
Automatically clears the logs when exiting the context.
|
53
|
+
"""
|
54
|
+
try:
|
55
|
+
yield cls.logs
|
56
|
+
finally:
|
57
|
+
cls.logs.clear()
|