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/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()