amati 0.3.14__tar.gz → 0.3.15__tar.gz

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.
Files changed (107) hide show
  1. {amati-0.3.14 → amati-0.3.15}/.pre-commit-config.yaml +2 -2
  2. {amati-0.3.14 → amati-0.3.15}/PKG-INFO +1 -1
  3. amati-0.3.15/amati/_error_handler.py +48 -0
  4. amati-0.3.15/amati/_references.py +226 -0
  5. {amati-0.3.14 → amati-0.3.15}/amati/_resolve_forward_references.py +36 -60
  6. {amati-0.3.14 → amati-0.3.15}/amati/amati.py +109 -16
  7. {amati-0.3.14 → amati-0.3.15}/amati/exceptions.py +1 -1
  8. {amati-0.3.14 → amati-0.3.15}/amati/fields/email.py +1 -1
  9. {amati-0.3.14 → amati-0.3.15}/amati/fields/http_status_codes.py +2 -1
  10. {amati-0.3.14 → amati-0.3.15}/amati/fields/iso9110.py +2 -1
  11. {amati-0.3.14 → amati-0.3.15}/amati/fields/media.py +2 -1
  12. {amati-0.3.14 → amati-0.3.15}/amati/fields/oas.py +1 -1
  13. {amati-0.3.14 → amati-0.3.15}/amati/fields/spdx_licences.py +2 -1
  14. {amati-0.3.14 → amati-0.3.15}/amati/fields/uri.py +2 -1
  15. amati-0.3.15/amati/validators/_discriminators.py +8 -0
  16. {amati-0.3.14 → amati-0.3.15}/amati/validators/generic.py +24 -16
  17. {amati-0.3.14 → amati-0.3.15}/amati/validators/oas304.py +122 -50
  18. {amati-0.3.14 → amati-0.3.15}/amati/validators/oas311.py +92 -37
  19. {amati-0.3.14 → amati-0.3.15}/pyproject.toml +1 -1
  20. {amati-0.3.14 → amati-0.3.15}/scripts/setup_test_specs.py +5 -5
  21. {amati-0.3.14 → amati-0.3.15}/tests/data/.amati.tests.yaml +27 -13
  22. amati-0.3.14/tests/data/redocly.openapi.yaml.errors.json → amati-0.3.15/tests/data/api.github.com.json.errors.json +0 -9
  23. {amati-0.3.14 → amati-0.3.15}/tests/data/api.github.com.yaml.errors.json +0 -9
  24. amati-0.3.15/tests/data/discourse.yml.errors.json +11 -0
  25. amati-0.3.15/tests/data/next-api.github.com.json.errors.json +1439 -0
  26. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_http_status_codes.py +2 -2
  27. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_uri.py +1 -35
  28. {amati-0.3.14 → amati-0.3.15}/tests/model_validators/test_all_of.py +1 -1
  29. {amati-0.3.14 → amati-0.3.15}/tests/model_validators/test_at_least_one.py +1 -1
  30. {amati-0.3.14 → amati-0.3.15}/tests/model_validators/test_only_one.py +1 -1
  31. amati-0.3.15/tests/references/test_uri_collector_mixin.py +306 -0
  32. amati-0.3.15/tests/references/test_uri_reference.py +323 -0
  33. amati-0.3.15/tests/references/test_uri_registry.py +317 -0
  34. amati-0.3.15/tests/strategies.py +141 -0
  35. {amati-0.3.14 → amati-0.3.15}/tests/test_external_specs.py +9 -7
  36. {amati-0.3.14 → amati-0.3.15}/tests/validators/test_generic.py +9 -7
  37. {amati-0.3.14 → amati-0.3.15}/tests/validators/test_licence_object.py +2 -2
  38. {amati-0.3.14 → amati-0.3.15}/tests/validators/test_security_scheme_object.py +1 -1
  39. {amati-0.3.14 → amati-0.3.15}/tests/validators/test_server_variable_object.py +7 -3
  40. {amati-0.3.14 → amati-0.3.15}/uv.lock +29 -29
  41. amati-0.3.14/amati/_error_handler.py +0 -48
  42. amati-0.3.14/tests/data/discourse.yml.errors.json +0 -1
  43. amati-0.3.14/tests/data/next-api.github.com.yaml.errors.json +0 -32
  44. amati-0.3.14/tests/helpers.py +0 -51
  45. {amati-0.3.14 → amati-0.3.15}/.dockerignore +0 -0
  46. {amati-0.3.14 → amati-0.3.15}/.github/actions/setup/action.yaml +0 -0
  47. {amati-0.3.14 → amati-0.3.15}/.github/dependabot.yml +0 -0
  48. {amati-0.3.14 → amati-0.3.15}/.github/workflows/checks.yaml +0 -0
  49. {amati-0.3.14 → amati-0.3.15}/.github/workflows/codeql.yml +0 -0
  50. {amati-0.3.14 → amati-0.3.15}/.github/workflows/coverage.yaml +0 -0
  51. {amati-0.3.14 → amati-0.3.15}/.github/workflows/data-refresh.yaml +0 -0
  52. {amati-0.3.14 → amati-0.3.15}/.github/workflows/dependency-review.yml +0 -0
  53. {amati-0.3.14 → amati-0.3.15}/.github/workflows/publish.yaml +0 -0
  54. {amati-0.3.14 → amati-0.3.15}/.github/workflows/scorecards.yml +0 -0
  55. {amati-0.3.14 → amati-0.3.15}/.github/workflows/tag-and-create-release.yaml +0 -0
  56. {amati-0.3.14 → amati-0.3.15}/.gitignore +0 -0
  57. {amati-0.3.14 → amati-0.3.15}/.python-version +0 -0
  58. {amati-0.3.14 → amati-0.3.15}/Dockerfile +0 -0
  59. {amati-0.3.14 → amati-0.3.15}/LICENSE +0 -0
  60. {amati-0.3.14 → amati-0.3.15}/README.md +0 -0
  61. {amati-0.3.14 → amati-0.3.15}/SECURITY.md +0 -0
  62. {amati-0.3.14 → amati-0.3.15}/TEMPLATE.html +0 -0
  63. {amati-0.3.14 → amati-0.3.15}/amati/__init__.py +0 -0
  64. {amati-0.3.14 → amati-0.3.15}/amati/_data/files/http-status-codes.json +0 -0
  65. {amati-0.3.14 → amati-0.3.15}/amati/_data/files/iso9110.json +0 -0
  66. {amati-0.3.14 → amati-0.3.15}/amati/_data/files/media-types.json +0 -0
  67. {amati-0.3.14 → amati-0.3.15}/amati/_data/files/schemes.json +0 -0
  68. {amati-0.3.14 → amati-0.3.15}/amati/_data/files/spdx-licences.json +0 -0
  69. {amati-0.3.14 → amati-0.3.15}/amati/_data/files/tlds.json +0 -0
  70. {amati-0.3.14 → amati-0.3.15}/amati/_data/http_status_code.py +0 -0
  71. {amati-0.3.14 → amati-0.3.15}/amati/_data/iso9110.py +0 -0
  72. {amati-0.3.14 → amati-0.3.15}/amati/_data/media_types.py +0 -0
  73. {amati-0.3.14 → amati-0.3.15}/amati/_data/refresh.py +0 -0
  74. {amati-0.3.14 → amati-0.3.15}/amati/_data/schemes.py +0 -0
  75. {amati-0.3.14 → amati-0.3.15}/amati/_data/spdx_licences.py +0 -0
  76. {amati-0.3.14 → amati-0.3.15}/amati/_data/tlds.py +0 -0
  77. {amati-0.3.14 → amati-0.3.15}/amati/_logging.py +0 -0
  78. {amati-0.3.14 → amati-0.3.15}/amati/fields/__init__.py +0 -0
  79. {amati-0.3.14 → amati-0.3.15}/amati/fields/_custom_types.py +0 -0
  80. {amati-0.3.14 → amati-0.3.15}/amati/fields/commonmark.py +0 -0
  81. {amati-0.3.14 → amati-0.3.15}/amati/fields/json.py +0 -0
  82. {amati-0.3.14 → amati-0.3.15}/amati/file_handler.py +0 -0
  83. {amati-0.3.14 → amati-0.3.15}/amati/grammars/oas.py +0 -0
  84. {amati-0.3.14 → amati-0.3.15}/amati/grammars/rfc6901.py +0 -0
  85. {amati-0.3.14 → amati-0.3.15}/amati/grammars/rfc7159.py +0 -0
  86. {amati-0.3.14 → amati-0.3.15}/amati/model_validators.py +0 -0
  87. {amati-0.3.14 → amati-0.3.15}/amati/py.typed +0 -0
  88. {amati-0.3.14 → amati-0.3.15}/amati/validators/__init__.py +0 -0
  89. {amati-0.3.14 → amati-0.3.15}/bin/checks.sh +0 -0
  90. {amati-0.3.14 → amati-0.3.15}/bin/startup.sh +0 -0
  91. {amati-0.3.14 → amati-0.3.15}/bin/upgrade-python.sh +0 -0
  92. {amati-0.3.14 → amati-0.3.15}/bin/uv-upgrade-from-main.sh +0 -0
  93. {amati-0.3.14 → amati-0.3.15}/tests/__init__.py +0 -0
  94. {amati-0.3.14 → amati-0.3.15}/tests/data/DigitalOcean-public.v2.errors.json +0 -0
  95. {amati-0.3.14 → amati-0.3.15}/tests/data/invalid-openapi.yaml +0 -0
  96. {amati-0.3.14 → amati-0.3.15}/tests/data/openapi.yaml +0 -0
  97. {amati-0.3.14 → amati-0.3.15}/tests/data/openapi.yaml.gz +0 -0
  98. {amati-0.3.14 → amati-0.3.15}/tests/fields/__init__.py +0 -0
  99. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_email.py +0 -0
  100. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_iso9110.py +0 -0
  101. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_media.py +0 -0
  102. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_oas.py +0 -0
  103. {amati-0.3.14 → amati-0.3.15}/tests/fields/test_spdx_licences.py +0 -0
  104. {amati-0.3.14 → amati-0.3.15}/tests/model_validators/test_if_then.py +0 -0
  105. {amati-0.3.14 → amati-0.3.15}/tests/test_amati.py +0 -0
  106. {amati-0.3.14 → amati-0.3.15}/tests/test_logging.py +0 -0
  107. {amati-0.3.14 → amati-0.3.15}/tests/validators/__init__.py +0 -0
@@ -1,7 +1,7 @@
1
1
  repos:
2
2
  - repo: https://github.com/astral-sh/ruff-pre-commit
3
3
  # Ruff version.
4
- rev: v0.14.6
4
+ rev: v0.14.8
5
5
  hooks:
6
6
  # Run the linter.
7
7
  - id: ruff-check
@@ -16,4 +16,4 @@ repos:
16
16
  rev: v0.7.2
17
17
  hooks:
18
18
  - id: shellcheck
19
- args: [-x]
19
+ args: [-x]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amati
3
- Version: 0.3.14
3
+ Version: 0.3.15
4
4
  Summary: Validates that a .yaml or .json file conforms to the OpenAPI Specifications 3.x.
5
5
  Project-URL: Homepage, https://github.com/gwyli/amati
6
6
  Project-URL: Issues, https://github.com/gwyli/amati/issues
@@ -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
+ class ErrorHandler:
17
+ def __init__(self) -> None:
18
+ self._errors: list[JSONObject] = []
19
+
20
+ def register_logs(self, logs: list[Log]):
21
+ self._errors.extend(cast(list[JSONObject], logs))
22
+
23
+ def register_log(self, log: Log):
24
+ self._errors.append(cast(JSONObject, log))
25
+
26
+ def register_errors(self, errors: list[JSONObject]):
27
+ self._errors.extend(errors)
28
+
29
+ def deduplicate(self):
30
+ """
31
+ Remove duplicates by converting each dict to a JSON string for comparison.
32
+ """
33
+ seen: set[str] = set()
34
+ unique_data: list[JSONObject] = []
35
+
36
+ item: JSONObject
37
+ for item in self._errors:
38
+ # Convert to JSON string with sorted keys for consistent hashing
39
+ item_json = json.dumps(item, sort_keys=True, separators=(",", ":"))
40
+ if item_json not in seen:
41
+ seen.add(item_json)
42
+ unique_data.append(item)
43
+
44
+ self._errors = unique_data
45
+
46
+ @property
47
+ def errors(self) -> list[JSONObject]:
48
+ return self._errors
@@ -0,0 +1,226 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from amati.fields import URI, URIType
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class URIReference:
12
+ """Immutable record of a URI found during validation"""
13
+
14
+ uri: URI
15
+ source_document: Path
16
+ source_model_name: str # Just the string name for error reporting
17
+ source_field: str
18
+ target_model: type[BaseModel] # The model type to validate with
19
+
20
+ def resolve(self) -> Path:
21
+ """Resolve URI relative to source document, see
22
+ https://spec.openapis.org/oas/v3.1.1.html#relative-references-in-api-description-uris
23
+ """
24
+
25
+ if self.uri.scheme == "file":
26
+ if not self.uri.path:
27
+ raise ValueError("File URI must have a path component")
28
+
29
+ netloc: Path | None = (
30
+ Path(self.uri.authority)
31
+ if self.uri.authority
32
+ else Path(self.uri.host)
33
+ if self.uri.host
34
+ else None
35
+ )
36
+
37
+ return (
38
+ (netloc / self.uri.path).resolve()
39
+ if netloc
40
+ else Path(self.uri.path).resolve()
41
+ )
42
+
43
+ if self.uri.type == URIType.ABSOLUTE:
44
+ raise NotImplementedError("Absolute URI resolution not implemented")
45
+
46
+ if self.uri.type == URIType.NETWORK_PATH:
47
+ return Path(self.uri).resolve()
48
+
49
+ if self.uri.type == URIType.RELATIVE:
50
+ path: Path = self.source_document.parent / self.uri.lstrip("/")
51
+ return path.resolve()
52
+
53
+ if self.uri.type == URIType.JSON_POINTER:
54
+ path: Path = self.source_document.parent / self.uri.lstrip("#/")
55
+ return path.resolve()
56
+
57
+ # Guard against future changes
58
+ raise ValueError(f"Unknown URI type: {self.uri.type}") # pragma: no cover
59
+
60
+
61
+ class URIRegistry:
62
+ """Registry for discovered URIs using the Singleton pattern.
63
+
64
+ This class maintains a central registry of all URI references discovered
65
+ during document validation. It tracks both the URIs themselves and which
66
+ documents have already been processed to avoid duplicate validation.
67
+
68
+ Attributes:
69
+ _instance: Class-level singleton instance.
70
+ _uris: List of all registered URI references.
71
+ _processed: Set of file paths that have been validated.
72
+ """
73
+
74
+ _instance = None
75
+
76
+ def __init__(self):
77
+ """Initialize a new URIRegistry instance.
78
+
79
+ Note:
80
+ This should not be called directly. Use get_instance() instead
81
+ to obtain the singleton instance.
82
+ """
83
+ self._uris: list[URIReference] = []
84
+ self._processed: set[Path] = set()
85
+
86
+ @classmethod
87
+ def get_instance(cls) -> URIRegistry:
88
+ """Get or create the singleton instance of URIRegistry.
89
+
90
+ Returns:
91
+ URIRegistry: The singleton instance of the registry.
92
+ """
93
+ if cls._instance is None:
94
+ cls._instance = cls()
95
+
96
+ return cls._instance
97
+
98
+ def register(self, ref: URIReference):
99
+ """Register a discovered URI reference.
100
+
101
+ Args:
102
+ ref (URIReference): The URI reference to register, including
103
+ source document, and target model.
104
+ """
105
+
106
+ if not isinstance(ref, URIReference): # pyright: ignore[reportUnnecessaryIsInstance]
107
+ raise TypeError("ref must be an instance of URIReference")
108
+
109
+ self._uris.append(ref)
110
+
111
+ def mark_processed(self, path: Path):
112
+ """Mark a document as having been validated.
113
+
114
+ The path is resolved to an absolute path before storage to ensure
115
+ consistent tracking regardless of how the path was specified.
116
+
117
+ Args:
118
+ path (Path): The file path of the document that has been processed.
119
+ """
120
+ self._processed.add(path.resolve())
121
+
122
+ def is_processed(self, path: Path) -> bool:
123
+ """Check if a document has already been validated.
124
+
125
+ Args:
126
+ path (Path): The file path to check.
127
+
128
+ Returns:
129
+ bool: True if the document has been processed, False otherwise.
130
+ """
131
+ return path.resolve() in self._processed
132
+
133
+ def get_all_references(self) -> list[URIReference]:
134
+ """Get all discovered URI references.
135
+
136
+ Returns:
137
+ list[URIReference]: A copy of the list of all registered URI
138
+ references. Returns a copy to prevent external modification
139
+ of the internal registry.
140
+ """
141
+ return self._uris.copy()
142
+
143
+ def resolvable(self, path: Path) -> bool:
144
+ """Check if the file referenced by a URI exists.
145
+
146
+ Args:
147
+ path (Path): The file path to verify.
148
+
149
+ Returns:
150
+ bool: True if the path points to an existing file, False otherwise.
151
+ """
152
+ return path.is_file()
153
+
154
+ def reset(self):
155
+ """Reset the registry for a new validation run.
156
+
157
+ Clears all registered URIs and processed document records. This is
158
+ typically called at the beginning of a new validation session.
159
+ """
160
+ self._uris.clear()
161
+ self._processed.clear()
162
+
163
+
164
+ class URICollectorMixin(BaseModel):
165
+ """Mixin for Pydantic models to automatically collect URIs during validation.
166
+
167
+ This mixin hooks into the Pydantic model lifecycle to automatically
168
+ discover and register URI fields during model instantiation. It inspects
169
+ all fields after validation and registers any URI-type fields with the
170
+ URIRegistry for subsequent processing.
171
+
172
+ The mixin expects a 'current_document' key in the validation context
173
+ to track the source document for each URI reference.
174
+ """
175
+
176
+ def model_post_init(self, __context: dict[str, Any]) -> None:
177
+ """Post-initialization hook to collect URI references from model fields.
178
+
179
+ This method is automatically called by Pydantic after model validation
180
+ is complete. It inspects all fields for URI types and registers them
181
+ with the singleton URIRegistry.
182
+
183
+ Args:
184
+ __context (dict[str, Any]): Validation context dictionary. Expected
185
+ to contain a 'current_document' key with the path to the source
186
+ document being validated.
187
+
188
+ Note:
189
+ This method calls super().model_post_init() to ensure compatibility
190
+ with other mixins and the base model's initialization process.
191
+
192
+ Example:
193
+ Context should be passed during model instantiation:
194
+ >>> class MyModel(URICollectorMixin, BaseModel):
195
+ ... ref: URI
196
+ >>> model = MyModel.model_validate(
197
+ ... {"ref": "http://example.com/resource"},
198
+ ... context={"current_document": "/path/to/doc.json"}
199
+ ... )
200
+ """
201
+ super().model_post_init(__context)
202
+
203
+ if not __context:
204
+ return
205
+
206
+ current_doc = __context.get("current_document")
207
+ if not current_doc:
208
+ return
209
+
210
+ # Inspect all fields for URI types
211
+ for field_name, field_value in self.model_dump().items():
212
+ if field_value is None:
213
+ continue
214
+
215
+ # Check if this field contains a URI
216
+ # Adjust this check based on your URI type implementation
217
+ if isinstance(field_value, URI):
218
+ ref = URIReference(
219
+ uri=field_value,
220
+ source_document=Path(current_doc),
221
+ source_model_name=self.__class__.__name__,
222
+ source_field=field_name,
223
+ # The linked document should be validated with the same model type
224
+ target_model=self.__class__,
225
+ )
226
+ URIRegistry.get_instance().register(ref)
@@ -5,6 +5,7 @@ without all its dependencies. This module rebuilds all models in a module.
5
5
 
6
6
  import inspect
7
7
  import sys
8
+ import typing
8
9
  from collections import defaultdict
9
10
  from types import ModuleType
10
11
 
@@ -26,20 +27,46 @@ class ModelDependencyResolver:
26
27
  """Register a Pydantic model for dependency analysis."""
27
28
  self.models[model.__name__] = model
28
29
 
29
- def register_models(self, models: list[type[BaseModel]]) -> None:
30
- """Register multiple Pydantic models."""
31
- for model in models:
32
- self.register_model(model)
30
+ @staticmethod
31
+ def extract_all_references(annotation: typing.Any, refs: set[str] | None = None):
32
+ """
33
+ Recursively extract all ForwardRef and type references from an annotation.
34
+
35
+ Args:
36
+ annotation: A type annotation (potentially deeply nested)
37
+ refs: Set to accumulate references (used internally for recursion)
38
+
39
+ Returns:
40
+ Set of either ForwardRef objects or actual type/class objects
41
+ """
42
+ if refs is None:
43
+ refs = set()
44
+
45
+ # Direct ForwardRef
46
+ if isinstance(annotation, typing.ForwardRef):
47
+ refs.add(annotation.__forward_arg__)
48
+ return refs
49
+
50
+ # Direct class reference
51
+ if isinstance(annotation, type):
52
+ refs.add(annotation.__name__)
53
+ return refs
54
+
55
+ for origin in typing.get_args(annotation):
56
+ ModelDependencyResolver.extract_all_references(origin, refs)
57
+
58
+ return refs
33
59
 
34
60
  def _analyze_model_dependencies(self, model: type[BaseModel]) -> set[str]:
35
61
  """Analyze a single model's dependencies from its annotations."""
36
62
  dependencies: set[str] = set()
37
63
 
38
64
  for field_info in model.model_fields.values():
39
- # Use a magic value that's an invalid class name for getattr so if
40
- # there is no __name__ attribute it won't appear in self.models
41
- if (name := getattr(field_info.annotation, "__name__", "!")) in self.models:
42
- dependencies.update(name)
65
+ references = ModelDependencyResolver.extract_all_references(
66
+ field_info.annotation
67
+ )
68
+
69
+ dependencies.update(ref for ref in references if ref in self.models)
43
70
 
44
71
  return dependencies
45
72
 
@@ -48,18 +75,10 @@ class ModelDependencyResolver:
48
75
  self.dependencies.clear()
49
76
  self.graph.clear()
50
77
 
51
- # First pass: collect all dependencies
52
78
  for model_name, model in self.models.items():
53
79
  deps = self._analyze_model_dependencies(model)
54
80
  self.dependencies[model_name] = deps
55
81
 
56
- # Second pass: build directed graph
57
- for model_name, deps in self.dependencies.items():
58
- for dep in deps:
59
- if dep in self.models:
60
- # Build forward graph (dependency -> dependent)
61
- self.graph[dep].append(model_name)
62
-
63
82
  def _tarjan_scc(self) -> list[list[str]]:
64
83
  """Find strongly connected components using Tarjan's algorithm."""
65
84
  index_counter = [0]
@@ -76,13 +95,6 @@ class ModelDependencyResolver:
76
95
  stack.append(node)
77
96
  on_stack[node] = True
78
97
 
79
- for successor in self.graph[node]:
80
- if successor not in index:
81
- strongconnect(successor)
82
- lowlinks[node] = min(lowlinks[node], lowlinks[successor])
83
- elif on_stack[successor]:
84
- lowlinks[node] = min(lowlinks[node], index[successor])
85
-
86
98
  if lowlinks[node] == index[node]:
87
99
  component: list[str] = []
88
100
  while True:
@@ -99,41 +111,6 @@ class ModelDependencyResolver:
99
111
 
100
112
  return sccs
101
113
 
102
- def _topological_sort_sccs(self, sccs: list[list[str]]) -> list[list[str]]:
103
- """Topologically sort the strongly connected components."""
104
- # Map each node to its SCC index
105
- node_to_scc = {node: i for i, scc in enumerate(sccs) for node in scc}
106
-
107
- # Find dependencies between SCCs
108
- dependencies: set[tuple[int, ...]] = set()
109
- for node in self.models:
110
- for neighbor in self.graph[node]:
111
- src_scc, dst_scc = node_to_scc[node], node_to_scc[neighbor]
112
- if src_scc != dst_scc:
113
- dependencies.add((src_scc, dst_scc))
114
-
115
- # Count incoming edges for each SCC
116
- in_degree = [0] * len(sccs)
117
- for _, dst in dependencies:
118
- in_degree[dst] += 1
119
-
120
- # Process SCCs with no dependencies first
121
- ready = [i for i, deg in enumerate(in_degree) if deg == 0]
122
- result: list[list[str]] = []
123
-
124
- while ready:
125
- current = ready.pop()
126
- result.append(sccs[current])
127
-
128
- # Remove this SCC and update in-degrees
129
- for src, dst in dependencies:
130
- if src == current:
131
- in_degree[dst] -= 1
132
- if in_degree[dst] == 0:
133
- ready.append(dst)
134
-
135
- return result
136
-
137
114
  def get_rebuild_order(self) -> list[list[str]]:
138
115
  """
139
116
  Get the order in which models should be rebuilt.
@@ -141,8 +118,7 @@ class ModelDependencyResolver:
141
118
  rebuilt together.
142
119
  """
143
120
  self.build_dependency_graph()
144
- sccs = self._tarjan_scc()
145
- return self._topological_sort_sccs(sccs)
121
+ return self._tarjan_scc()
146
122
 
147
123
  def rebuild_models(self) -> None:
148
124
  """Rebuild all registered models in the correct dependency order."""
@@ -6,6 +6,7 @@ import importlib
6
6
  import json
7
7
  import sys
8
8
  from pathlib import Path
9
+ from typing import Any
9
10
 
10
11
  from jinja2 import Environment, FileSystemLoader
11
12
  from loguru import logger
@@ -13,9 +14,11 @@ from pydantic import BaseModel, ValidationError
13
14
 
14
15
  sys.path.insert(0, str(Path(__file__).parent.parent))
15
16
  from amati._data.refresh import refresh
16
- from amati._error_handler import handle_errors
17
+ from amati._error_handler import ErrorHandler
17
18
  from amati._logging import Log, Logger
19
+ from amati._references import URIRegistry
18
20
  from amati._resolve_forward_references import resolve_forward_references
21
+ from amati.fields import URIType
19
22
  from amati.file_handler import load_file
20
23
 
21
24
  type JSONPrimitive = str | int | float | bool | None
@@ -24,15 +27,15 @@ type JSONObject = dict[str, "JSONValue"]
24
27
  type JSONValue = JSONPrimitive | JSONArray | JSONObject
25
28
 
26
29
 
27
- def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | None]:
30
+ def _determine_version(data: JSONObject) -> str:
28
31
  """
29
- Returns the correct model for the passed spec
32
+ Determines the OpenAPI specification version from the provided data.
30
33
 
31
34
  Args:
32
35
  data: A dictionary representing an OpenAPI specification
33
36
 
34
37
  Returns:
35
- A pydantic model representing the API specification
38
+ The OpenAPI specification version as a string
36
39
  """
37
40
 
38
41
  version: JSONValue = data.get("openapi")
@@ -43,6 +46,28 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | Non
43
46
  if not version:
44
47
  raise TypeError("An OpenAPI Specfication must contain a version.")
45
48
 
49
+ return version
50
+
51
+
52
+ def dispatch(
53
+ data: JSONObject,
54
+ context: dict[str, Any],
55
+ version: str,
56
+ obj: str = "OpenAPIObject",
57
+ ) -> tuple[BaseModel | None, list[JSONObject] | None]:
58
+ """
59
+ Returns the correct model for the passed spec
60
+
61
+ Args:
62
+ data: A dictionary representing an OpenAPI specification
63
+ version: An optional Open API version string to override automatic detection.
64
+ The most common reason to provide the version is when validating references
65
+ outside of the context of a full specification document.
66
+
67
+ Returns:
68
+ A pydantic model representing the API specification
69
+ """
70
+
46
71
  version_map: dict[str, str] = {
47
72
  "3.1.1": "311",
48
73
  "3.1.0": "311",
@@ -53,18 +78,91 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | Non
53
78
  "3.0.0": "304",
54
79
  }
55
80
 
56
- module = importlib.import_module(f"amati.validators.oas{version_map[version]}")
81
+ module_name: str = f"amati.validators.oas{version_map[version]}"
57
82
 
83
+ module = importlib.import_module(module_name)
58
84
  resolve_forward_references(module)
59
85
 
60
86
  try:
61
- model = module.OpenAPIObject(**data)
87
+ model = getattr(module, obj).model_validate(data, context=context)
62
88
  except ValidationError as e:
63
89
  return None, json.loads(e.json())
64
90
 
65
91
  return model, None
66
92
 
67
93
 
94
+ def dispatch_all(spec: Path) -> tuple[BaseModel | None, ErrorHandler]:
95
+ """ """
96
+
97
+ registry = URIRegistry.get_instance()
98
+ registry.reset()
99
+
100
+ error_handler = ErrorHandler()
101
+ result: BaseModel | None = None
102
+ version: str | None = None
103
+
104
+ to_process: list[tuple[Path, str]] = [(spec, "OpenAPIObject")]
105
+
106
+ while to_process:
107
+ doc_path, obj = to_process.pop(0)
108
+
109
+ # Skip if already validated (handles circular references)
110
+ if registry.is_processed(doc_path):
111
+ continue
112
+
113
+ logger.info(f"Validating: {doc_path}")
114
+
115
+ data: JSONObject = load_file(spec)
116
+
117
+ # Create validation context with current document path
118
+ context = {"current_document": str(doc_path)}
119
+
120
+ if obj == "OpenAPIObject":
121
+ version = _determine_version(data)
122
+ elif version is None:
123
+ raise ValueError("Version must be set in the OpenAPI object")
124
+
125
+ with Logger.context():
126
+ result, errors = dispatch(data, context, version, obj)
127
+
128
+ if errors:
129
+ error_handler.register_errors(errors)
130
+
131
+ error_handler.register_logs(Logger.logs)
132
+
133
+ registry.mark_processed(doc_path)
134
+
135
+ references = registry.get_all_references()
136
+
137
+ # Find references that originated from the document we just validated
138
+ # and haven't been processed yet
139
+ for ref in references:
140
+ if ref.source_document != doc_path:
141
+ continue
142
+
143
+ resolved_path = ref.resolve()
144
+
145
+ if registry.is_processed(resolved_path):
146
+ continue
147
+
148
+ if resolved_path.exists() and ref.uri.type != URIType.ABSOLUTE:
149
+ to_process.append((resolved_path, ref.target_model.__name__))
150
+ else:
151
+ # File doesn't exist - record error
152
+ error_handler.register_log(
153
+ Log(
154
+ type="missing_reference",
155
+ loc=(ref.source_document.name,),
156
+ msg=f"Could not locate {ref.uri.type.value} URI",
157
+ input=ref.uri,
158
+ )
159
+ )
160
+
161
+ references = registry.get_all_references()
162
+
163
+ return result, error_handler
164
+
165
+
68
166
  def check(original: JSONObject, validated: BaseModel) -> bool:
69
167
  """
70
168
  Runs a consistency check on the output of amati.
@@ -113,15 +211,10 @@ def run(
113
211
 
114
212
  data = load_file(spec)
115
213
 
116
- logs: list[Log] = []
117
-
118
- with Logger.context():
119
- result, errors = dispatch(data)
120
- logs.extend(Logger.logs)
121
-
122
- if errors or logs:
123
- handled_errors: list[JSONObject] = handle_errors(errors, logs)
214
+ result, handled_errors = dispatch_all(spec)
215
+ handled_errors.deduplicate()
124
216
 
217
+ if handled_errors.errors:
125
218
  file_name = Path(Path(file_path).parts[-1])
126
219
  error_file = file_name.with_suffix(file_name.suffix + ".errors")
127
220
  error_path = spec.parent
@@ -137,7 +230,7 @@ def run(
137
230
  )
138
231
 
139
232
  with json_error_file.open("w", encoding="utf-8") as f:
140
- f.write(json.dumps(handled_errors))
233
+ f.write(json.dumps(handled_errors.errors))
141
234
 
142
235
  if html_report:
143
236
  env = Environment(
@@ -147,7 +240,7 @@ def run(
147
240
  template = env.get_template("TEMPLATE.html")
148
241
 
149
242
  # Render the template with your data
150
- html_output = template.render(errors=handled_errors)
243
+ html_output = template.render(errors=handled_errors.errors)
151
244
 
152
245
  # Save the output to a file
153
246
 
@@ -10,7 +10,7 @@ class AmatiValueError(ValueError):
10
10
 
11
11
  Attributes:
12
12
  message (str): The explanation of why the exception was raised
13
- authority (Optional[ReferenceModel]): The reference to the standard that
13
+ reference_uri (str | None): The reference to the standard that
14
14
  explains why the exception was raised
15
15
 
16
16
  Inherits:
@@ -5,7 +5,7 @@ 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.exceptions import AmatiValueError
9
9
  from amati.fields import Str as _Str
10
10
 
11
11
  reference_uri = "https://www.rfc-editor.org/rfc/rfc5322#section-3"
@@ -9,7 +9,8 @@ or the numeric codes can be accessed via HTTPStatusCodeN.
9
9
  import re
10
10
  from typing import Self, cast
11
11
 
12
- from amati import AmatiValueError, get
12
+ from amati import get
13
+ from amati.exceptions import AmatiValueError
13
14
  from amati.fields import Str as _Str
14
15
 
15
16
  reference_uri = (
@@ -8,7 +8,8 @@ and a class for scheme validation.
8
8
 
9
9
  from typing import cast
10
10
 
11
- from amati import AmatiValueError, get
11
+ from amati import get
12
+ from amati.exceptions import AmatiValueError
12
13
  from amati.fields import Str as _Str
13
14
 
14
15
  reference_uri = (