amati 0.1.1__tar.gz → 0.2__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 (91) hide show
  1. amati-0.2/.dockerignore +1 -0
  2. amati-0.2/Dockerfile +16 -0
  3. {amati-0.1.1 → amati-0.2}/PKG-INFO +71 -9
  4. amati-0.2/README.md +127 -0
  5. amati-0.2/TEMPLATE.html +116 -0
  6. {amati-0.1.1 → amati-0.2}/amati/__init__.py +0 -1
  7. amati-0.2/amati/_error_handler.py +48 -0
  8. {amati-0.1.1 → amati-0.2}/amati/amati.py +86 -20
  9. {amati-0.1.1 → amati-0.2}/amati/exceptions.py +2 -4
  10. {amati-0.1.1 → amati-0.2}/amati/fields/email.py +3 -7
  11. {amati-0.1.1 → amati-0.2}/amati/fields/http_status_codes.py +4 -5
  12. {amati-0.1.1 → amati-0.2}/amati/fields/iso9110.py +4 -5
  13. {amati-0.1.1 → amati-0.2}/amati/fields/media.py +3 -7
  14. {amati-0.1.1 → amati-0.2}/amati/fields/oas.py +6 -12
  15. {amati-0.1.1 → amati-0.2}/amati/fields/spdx_licences.py +4 -7
  16. {amati-0.1.1 → amati-0.2}/amati/fields/uri.py +3 -11
  17. {amati-0.1.1 → amati-0.2}/amati/logging.py +8 -8
  18. {amati-0.1.1 → amati-0.2}/amati/model_validators.py +42 -33
  19. {amati-0.1.1 → amati-0.2}/amati/validators/generic.py +18 -13
  20. {amati-0.1.1 → amati-0.2}/amati/validators/oas304.py +93 -130
  21. {amati-0.1.1 → amati-0.2}/amati/validators/oas311.py +58 -156
  22. {amati-0.1.1 → amati-0.2}/bin/checks.sh +5 -1
  23. {amati-0.1.1 → amati-0.2}/pyproject.toml +2 -1
  24. {amati-0.1.1 → amati-0.2}/tests/data/.amati.tests.yaml +22 -11
  25. amati-0.1.1/tests/data/DigitalOcean-public.v2.errors → amati-0.2/tests/data/DigitalOcean-public.v2.errors.json +0 -13
  26. amati-0.2/tests/data/api.github.com.yaml.errors.json +23 -0
  27. amati-0.2/tests/data/next-api.github.com.yaml.errors.json +32 -0
  28. amati-0.2/tests/data/redocly.openapi.yaml.errors.json +23 -0
  29. {amati-0.1.1 → amati-0.2}/tests/fields/test_email.py +1 -2
  30. {amati-0.1.1 → amati-0.2}/tests/fields/test_http_status_codes.py +6 -1
  31. {amati-0.1.1 → amati-0.2}/tests/fields/test_media.py +1 -1
  32. {amati-0.1.1 → amati-0.2}/tests/model_validators/test_all_of.py +3 -4
  33. {amati-0.1.1 → amati-0.2}/tests/model_validators/test_at_least_one.py +3 -4
  34. {amati-0.1.1 → amati-0.2}/tests/model_validators/test_if_then.py +18 -17
  35. {amati-0.1.1 → amati-0.2}/tests/model_validators/test_only_one.py +3 -4
  36. amati-0.2/tests/test_external_specs.py +93 -0
  37. amati-0.2/tests/test_logging.py +31 -0
  38. {amati-0.1.1 → amati-0.2}/tests/validators/test_generic.py +6 -4
  39. {amati-0.1.1 → amati-0.2}/tests/validators/test_licence_object.py +10 -10
  40. {amati-0.1.1 → amati-0.2}/tests/validators/test_security_scheme_object.py +4 -4
  41. {amati-0.1.1 → amati-0.2}/tests/validators/test_server_variable_object.py +2 -2
  42. {amati-0.1.1 → amati-0.2}/uv.lock +43 -1
  43. amati-0.1.1/README.md +0 -66
  44. amati-0.1.1/amati/references.py +0 -33
  45. amati-0.1.1/tests/test_external_specs.py +0 -81
  46. amati-0.1.1/tests/test_logging.py +0 -43
  47. amati-0.1.1/tests/test_references.py +0 -25
  48. {amati-0.1.1 → amati-0.2}/.github/dependabot.yml +0 -0
  49. {amati-0.1.1 → amati-0.2}/.github/workflows/checks.yaml +0 -0
  50. {amati-0.1.1 → amati-0.2}/.github/workflows/codeql.yml +0 -0
  51. {amati-0.1.1 → amati-0.2}/.github/workflows/coverage.yaml +0 -0
  52. {amati-0.1.1 → amati-0.2}/.github/workflows/publish.yaml +0 -0
  53. {amati-0.1.1 → amati-0.2}/.gitignore +0 -0
  54. {amati-0.1.1 → amati-0.2}/.pre-commit-config.yaml +0 -0
  55. {amati-0.1.1 → amati-0.2}/.pylintrc +0 -0
  56. {amati-0.1.1 → amati-0.2}/.python-version +0 -0
  57. {amati-0.1.1 → amati-0.2}/LICENSE +0 -0
  58. {amati-0.1.1 → amati-0.2}/amati/_resolve_forward_references.py +0 -0
  59. {amati-0.1.1 → amati-0.2}/amati/data/http-status-codes.json +0 -0
  60. {amati-0.1.1 → amati-0.2}/amati/data/iso9110.json +0 -0
  61. {amati-0.1.1 → amati-0.2}/amati/data/media-types.json +0 -0
  62. {amati-0.1.1 → amati-0.2}/amati/data/schemes.json +0 -0
  63. {amati-0.1.1 → amati-0.2}/amati/data/spdx-licences.json +0 -0
  64. {amati-0.1.1 → amati-0.2}/amati/data/tlds.json +0 -0
  65. {amati-0.1.1 → amati-0.2}/amati/fields/__init__.py +0 -0
  66. {amati-0.1.1 → amati-0.2}/amati/fields/_custom_types.py +0 -0
  67. {amati-0.1.1 → amati-0.2}/amati/fields/commonmark.py +0 -0
  68. {amati-0.1.1 → amati-0.2}/amati/fields/json.py +0 -0
  69. {amati-0.1.1 → amati-0.2}/amati/file_handler.py +0 -0
  70. {amati-0.1.1 → amati-0.2}/amati/grammars/oas.py +0 -0
  71. {amati-0.1.1 → amati-0.2}/amati/grammars/rfc6901.py +0 -0
  72. {amati-0.1.1 → amati-0.2}/amati/grammars/rfc7159.py +0 -0
  73. {amati-0.1.1 → amati-0.2}/amati/validators/__init__.py +0 -0
  74. {amati-0.1.1 → amati-0.2}/bin/startup.sh +0 -0
  75. {amati-0.1.1 → amati-0.2}/scripts/data/http_status_code.py +0 -0
  76. {amati-0.1.1 → amati-0.2}/scripts/data/iso9110.py +0 -0
  77. {amati-0.1.1 → amati-0.2}/scripts/data/media_types.py +0 -0
  78. {amati-0.1.1 → amati-0.2}/scripts/data/schemes.py +0 -0
  79. {amati-0.1.1 → amati-0.2}/scripts/data/spdx_licences.py +0 -0
  80. {amati-0.1.1 → amati-0.2}/scripts/data/tlds.py +0 -0
  81. {amati-0.1.1 → amati-0.2}/scripts/tests/setup_test_specs.py +0 -0
  82. {amati-0.1.1 → amati-0.2}/tests/__init__.py +0 -0
  83. {amati-0.1.1 → amati-0.2}/tests/data/openapi.yaml +0 -0
  84. {amati-0.1.1 → amati-0.2}/tests/fields/__init__.py +0 -0
  85. {amati-0.1.1 → amati-0.2}/tests/fields/test_iso9110.py +0 -0
  86. {amati-0.1.1 → amati-0.2}/tests/fields/test_oas.py +0 -0
  87. {amati-0.1.1 → amati-0.2}/tests/fields/test_spdx_licences.py +0 -0
  88. {amati-0.1.1 → amati-0.2}/tests/fields/test_uri.py +0 -0
  89. {amati-0.1.1 → amati-0.2}/tests/helpers.py +0 -0
  90. {amati-0.1.1 → amati-0.2}/tests/test_amati.py +0 -0
  91. {amati-0.1.1 → amati-0.2}/tests/validators/__init__.py +0 -0
@@ -0,0 +1 @@
1
+ .venv
amati-0.2/Dockerfile ADDED
@@ -0,0 +1,16 @@
1
+ FROM python:3.13-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1
4
+
5
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
6
+
7
+ WORKDIR /app
8
+ COPY pyproject.toml uv.lock README.md ./
9
+ COPY amati/ amati/
10
+
11
+ RUN uv sync --locked --no-dev
12
+
13
+ RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app
14
+ USER appuser
15
+
16
+ ENTRYPOINT ["uv", "run", "python", "amati/amati.py"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amati
3
- Version: 0.1.1
3
+ Version: 0.2
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/ben-alexander/amati
6
6
  Project-URL: Issues, https://github.com/ben-alexander/amati/issues
@@ -15,6 +15,7 @@ Classifier: Topic :: Software Development :: Testing :: Acceptance
15
15
  Requires-Python: >=3.13
16
16
  Requires-Dist: abnf>=2.3.1
17
17
  Requires-Dist: idna>=3.10
18
+ Requires-Dist: jinja2>=3.1.6
18
19
  Requires-Dist: jsonpickle>=4.1.1
19
20
  Requires-Dist: jsonschema>=4.24.0
20
21
  Requires-Dist: pydantic>=2.11.5
@@ -23,26 +24,64 @@ Description-Content-Type: text/markdown
23
24
 
24
25
  # amati
25
26
 
26
- A programme designed to validate that a file conforms to [OpenAPI Specification](https://spec.openapis.org/oas/v3.1.1.html) (OAS).
27
-
28
- Currently a proof of concept.
27
+ amati is designed to validate that a file conforms to the [OpenAPI Specification v3.x](https://spec.openapis.org/) (OAS).
29
28
 
30
29
  ## Name
31
30
 
32
31
  amati means to observe in Malay, especially with attention to detail. It's also one of the plurals of beloved or favourite in Italian.
33
32
 
33
+ ## Usage
34
+
35
+ ```sh
36
+ python amati/amati.py --help
37
+ usage: amati [-h] [-s SPEC] [-cc] [-d DISCOVER] [-l] [-hr]
38
+
39
+ Tests whether a OpenAPI specification is valid. Will look an openapi.json or openapi.yaml file in the directory that
40
+ amati is called from. If --discover is set will search the directory tree. If the specification does not follow the
41
+ naming recommendation the --spec switch should be used. Creates a file <filename>.errors.json alongside the original
42
+ specification containing a JSON representation of all the errors.
43
+
44
+ options:
45
+ -h, --help show this help message and exit
46
+ -s, --spec SPEC The specification to be parsed
47
+ -cc, --consistency-check
48
+ Runs a consistency check between the input specification and the parsed specification
49
+ -d, --discover DISCOVER
50
+ Searches the specified directory tree for openapi.yaml or openapi.json.
51
+ -l, --local Store errors local to the caller in a file called <file-name>.errors.json; a .amati/ directory
52
+ will be created.
53
+ -hr, --html-report Creates an HTML report of the errors, called <file-name>.errors.html, alongside the original
54
+ file or in a .amati/ directory if the --local switch is used
55
+ ```
56
+
57
+ A Dockerfile is available on [DockerHub](https://hub.docker.com/r/benale/amati/tags)
58
+
59
+ To run against a specific specification the location of the specification needs to be mounted in the container.
60
+
61
+ ```sh
62
+ docker run -v "<path-to-mount>:/<mount-name> amati <options>
63
+ ```
64
+
65
+ e.g.
66
+
67
+ ```sh
68
+ docker run -v /Users/myuser/myrepo:/data amati --spec data/myspec.yaml --hr
69
+ ```
70
+
34
71
  ## Architecture
35
72
 
36
73
  This uses Pydantic, especially the validation, and Typing to construct the entire OAS as a single data type. Passing a dictionary to the top-level data type runs all the validation in the Pydantic models constructing a single set of inherited classes and datatypes that validate that the API specification is accurate.
37
74
 
38
75
  Where the specification conforms, but relies on implementation-defined behavior (e.g. [data type formats](https://spec.openapis.org/oas/v3.1.1.html#data-type-format)), a warning will be raised.
39
76
 
40
- ## Requirements
77
+ ## Contributing
78
+
79
+ ### Requirements
41
80
 
42
81
  * The latest version of [uv](https://docs.astral.sh/uv/)
43
82
  * [git 2.49+](https://git-scm.com/downloads/linux)
44
83
 
45
- ## Testing and formatting
84
+ ### Testing and formatting
46
85
 
47
86
  This project uses:
48
87
 
@@ -54,7 +93,7 @@ This project uses:
54
93
  * [Black](https://black.readthedocs.io/en/stable/index.html) for automated formatting
55
94
  * [isort](https://pycqa.github.io/isort/) for import sorting
56
95
 
57
- It's expected that there are no errors, no surviving mutants and 100% of the code is reached and executed.
96
+ It's expected that there are no errors and 100% of the code is reached and executed. The strategy for test coverage is based on parsing test specifications and not unit tests.
58
97
 
59
98
  amati runs tests on external specifications, detailed in `tests/data/.amati.tests.yaml`. To be able to run these tests the appropriate GitHub repos need to be local. Specific revisions of the repos can be downloaded by running
60
99
 
@@ -62,13 +101,15 @@ amati runs tests on external specifications, detailed in `tests/data/.amati.test
62
101
  python scripts/tests/setup_test_specs.py
63
102
  ```
64
103
 
65
- To run everything, from linting, type checking to downloading test specs run:
104
+ To run everything, from linting, type checking to downloading test specs and building and testing the Docker image run:
66
105
 
67
106
  ```sh
68
107
  sh bin/checks.sh
69
108
  ```
70
109
 
71
- ## Building
110
+ You will need to have Docker installed.
111
+
112
+ ### Building
72
113
 
73
114
  The project uses a [`pyproject.toml` file](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-pyproject-toml) to determine what to build.
74
115
 
@@ -80,6 +121,27 @@ uv venv
80
121
  uv sync
81
122
  ```
82
123
 
124
+ ### Docker
125
+
126
+ A development Docker image is provided, `Dockerfile.dev`, to build:
127
+
128
+ ```sh
129
+ docker build -t amati -f Dockerfile .
130
+ ```
131
+
132
+ and to run against a specific specification the location of the specification needs to be mounted in the container.
133
+
134
+ ```sh
135
+ docker run -v "<path-to-mount>:/<mount-name> amati <options>
136
+ ```
137
+
138
+ This can be tested against a provided specification, from the root directory
139
+
140
+ ```sh
141
+ docker run --detach -v "$(pwd):/data" amati
142
+ ```
143
+
144
+
83
145
  ### Data
84
146
 
85
147
  There are some scripts to create the data needed by the project, for example, all the possible licences. If the data needs to be refreshed this can be done by running the contents of `/scripts/data`.
amati-0.2/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # amati
2
+
3
+ amati is designed to validate that a file conforms to the [OpenAPI Specification v3.x](https://spec.openapis.org/) (OAS).
4
+
5
+ ## Name
6
+
7
+ amati means to observe in Malay, especially with attention to detail. It's also one of the plurals of beloved or favourite in Italian.
8
+
9
+ ## Usage
10
+
11
+ ```sh
12
+ python amati/amati.py --help
13
+ usage: amati [-h] [-s SPEC] [-cc] [-d DISCOVER] [-l] [-hr]
14
+
15
+ Tests whether a OpenAPI specification is valid. Will look an openapi.json or openapi.yaml file in the directory that
16
+ amati is called from. If --discover is set will search the directory tree. If the specification does not follow the
17
+ naming recommendation the --spec switch should be used. Creates a file <filename>.errors.json alongside the original
18
+ specification containing a JSON representation of all the errors.
19
+
20
+ options:
21
+ -h, --help show this help message and exit
22
+ -s, --spec SPEC The specification to be parsed
23
+ -cc, --consistency-check
24
+ Runs a consistency check between the input specification and the parsed specification
25
+ -d, --discover DISCOVER
26
+ Searches the specified directory tree for openapi.yaml or openapi.json.
27
+ -l, --local Store errors local to the caller in a file called <file-name>.errors.json; a .amati/ directory
28
+ will be created.
29
+ -hr, --html-report Creates an HTML report of the errors, called <file-name>.errors.html, alongside the original
30
+ file or in a .amati/ directory if the --local switch is used
31
+ ```
32
+
33
+ A Dockerfile is available on [DockerHub](https://hub.docker.com/r/benale/amati/tags)
34
+
35
+ To run against a specific specification the location of the specification needs to be mounted in the container.
36
+
37
+ ```sh
38
+ docker run -v "<path-to-mount>:/<mount-name> amati <options>
39
+ ```
40
+
41
+ e.g.
42
+
43
+ ```sh
44
+ docker run -v /Users/myuser/myrepo:/data amati --spec data/myspec.yaml --hr
45
+ ```
46
+
47
+ ## Architecture
48
+
49
+ This uses Pydantic, especially the validation, and Typing to construct the entire OAS as a single data type. Passing a dictionary to the top-level data type runs all the validation in the Pydantic models constructing a single set of inherited classes and datatypes that validate that the API specification is accurate.
50
+
51
+ Where the specification conforms, but relies on implementation-defined behavior (e.g. [data type formats](https://spec.openapis.org/oas/v3.1.1.html#data-type-format)), a warning will be raised.
52
+
53
+ ## Contributing
54
+
55
+ ### Requirements
56
+
57
+ * The latest version of [uv](https://docs.astral.sh/uv/)
58
+ * [git 2.49+](https://git-scm.com/downloads/linux)
59
+
60
+ ### Testing and formatting
61
+
62
+ This project uses:
63
+
64
+ * [Pytest](https://docs.pytest.org/en/stable/) as a testing framework
65
+ * [PyLance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) on strict mode for type checking
66
+ * [Pylint](https://www.pylint.org/) as a linter, using a modified version from [Google's style guide](https://google.github.io/styleguide/pyguide.html)
67
+ * [Hypothesis](https://hypothesis.readthedocs.io/en/latest/index.html) for test data generation
68
+ * [Coverage](https://coverage.readthedocs.io/en/7.6.8/) on both the tests and code for test coverage
69
+ * [Black](https://black.readthedocs.io/en/stable/index.html) for automated formatting
70
+ * [isort](https://pycqa.github.io/isort/) for import sorting
71
+
72
+ It's expected that there are no errors and 100% of the code is reached and executed. The strategy for test coverage is based on parsing test specifications and not unit tests.
73
+
74
+ amati runs tests on external specifications, detailed in `tests/data/.amati.tests.yaml`. To be able to run these tests the appropriate GitHub repos need to be local. Specific revisions of the repos can be downloaded by running
75
+
76
+ ```sh
77
+ python scripts/tests/setup_test_specs.py
78
+ ```
79
+
80
+ To run everything, from linting, type checking to downloading test specs and building and testing the Docker image run:
81
+
82
+ ```sh
83
+ sh bin/checks.sh
84
+ ```
85
+
86
+ You will need to have Docker installed.
87
+
88
+ ### Building
89
+
90
+ The project uses a [`pyproject.toml` file](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-pyproject-toml) to determine what to build.
91
+
92
+ To install, assuming that [uv](https://docs.astral.sh/uv/) is already installed and initialised
93
+
94
+ ```sh
95
+ uv python install
96
+ uv venv
97
+ uv sync
98
+ ```
99
+
100
+ ### Docker
101
+
102
+ A development Docker image is provided, `Dockerfile.dev`, to build:
103
+
104
+ ```sh
105
+ docker build -t amati -f Dockerfile .
106
+ ```
107
+
108
+ and to run against a specific specification the location of the specification needs to be mounted in the container.
109
+
110
+ ```sh
111
+ docker run -v "<path-to-mount>:/<mount-name> amati <options>
112
+ ```
113
+
114
+ This can be tested against a provided specification, from the root directory
115
+
116
+ ```sh
117
+ docker run --detach -v "$(pwd):/data" amati
118
+ ```
119
+
120
+
121
+ ### Data
122
+
123
+ There are some scripts to create the data needed by the project, for example, all the possible licences. If the data needs to be refreshed this can be done by running the contents of `/scripts/data`.
124
+
125
+
126
+
127
+
@@ -0,0 +1,116 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>amati</title>
7
+ <!-- Tailwind CSS CDN -->
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <style>
10
+ body {
11
+ font-family: "Inter", sans-serif;
12
+ background-color: #f3f4f6;
13
+ display: flex;
14
+ justify-content: center;
15
+ align-items: flex-start;
16
+ min-height: 100vh;
17
+ padding: 2rem;
18
+ box-sizing: border-box;
19
+ }
20
+ .container {
21
+ background-color: #ffffff;
22
+ padding: 2rem;
23
+ border-radius: 1rem;
24
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
25
+ width: 100%;
26
+ max-width: 1200px; /* Increased max-width for more columns */
27
+ overflow-x: auto; /* Added for horizontal scrolling on small screens */
28
+ }
29
+ table {
30
+ width: 100%;
31
+ border-collapse: collapse;
32
+ }
33
+ th, td {
34
+ padding: 0.75rem;
35
+ text-align: left;
36
+ border-bottom: 1px solid #e5e7eb;
37
+ }
38
+ th {
39
+ background-color: #f9fafb;
40
+ font-weight: 600;
41
+ color: #4b5563;
42
+ text-transform: uppercase;
43
+ font-size: 0.875rem;
44
+ }
45
+ tr:hover {
46
+ background-color: #f3f4f6;
47
+ }
48
+ .code-block {
49
+ background-color: #e0e7ff;
50
+ padding: 0.25rem 0.5rem;
51
+ border-radius: 0.375rem;
52
+ font-family: monospace;
53
+ color: #4f46e5;
54
+ white-space: nowrap; /* Prevent line breaks for code */
55
+ }
56
+ .error-url {
57
+ color: #3b82f6;
58
+ text-decoration: none;
59
+ }
60
+ .error-url:hover {
61
+ text-decoration: underline;
62
+ }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div class="container">
67
+ <h1 class="text-3xl font-bold text-center text-gray-800 mb-6">amati error report</h1>
68
+
69
+ {% if errors %}
70
+ <div class="overflow-x-auto rounded-lg shadow-sm border border-gray-200">
71
+ <table class="min-w-full divide-y divide-gray-200">
72
+ <thead class="bg-gray-50">
73
+ <tr>
74
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider rounded-tl-lg">Type</th>
75
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
76
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Message</th>
77
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Input</th>
78
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider rounded-tr-lg">URL</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody class="bg-white divide-y divide-gray-200">
82
+ {% for item in errors %}
83
+ <tr>
84
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ item.type | default('N/A') }}</td>
85
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
86
+ <code class="code-block">
87
+ {# Check if 'loc' exists and is iterable before joining #}
88
+ {% if item.loc is defined and item.loc is iterable %}
89
+ {{ item.loc | join(' -> ') }}
90
+ {% else %}
91
+ N/A
92
+ {% endif %}
93
+ </code>
94
+ </td>
95
+ <td class="px-6 py-4 text-sm text-gray-500">{{ item.msg | default('N/A') }}</td>
96
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
97
+ <code class="code-block">
98
+ {% if item.input is defined and item.input %}
99
+ {{ item.input | default('N/A') }}
100
+ {% else %}
101
+ N/A
102
+ {% endif %}
103
+ </code>
104
+ </td>
105
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-blue-600"><a href="{{ item.url | default('#') }}" class="error-url" target="_blank">{{ item.url | default('N/A') }}</a></td>
106
+ </tr>
107
+ {% endfor %}
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ {% else %}
112
+ <p class="text-center text-gray-600 mt-8">No data available to display.</p>
113
+ {% endif %}
114
+ </div>
115
+ </body>
116
+ </html>
@@ -13,4 +13,3 @@ __version__ = importlib.metadata.version("amati")
13
13
 
14
14
  from amati.amati import dispatch, run
15
15
  from amati.exceptions import AmatiValueError
16
- from amati.references import AmatiReferenceException, Reference, References
@@ -0,0 +1,48 @@
1
+ """
2
+ Handles Pydantic errors and amati logs to provide a consistent view to the user.
3
+ """
4
+
5
+ import json
6
+ from typing import cast
7
+
8
+ from amati.logging import Log
9
+
10
+ type JSONPrimitive = str | int | float | bool | None
11
+ type JSONArray = list["JSONValue"]
12
+ type JSONObject = dict[str, "JSONValue"]
13
+ type JSONValue = JSONPrimitive | JSONArray | JSONObject
14
+
15
+
16
+ def remove_duplicates(data: list[JSONObject]) -> list[JSONObject]:
17
+ """
18
+ Remove duplicates by converting each dict to a JSON string for comparison.
19
+ """
20
+ seen: set[str] = set()
21
+ unique_data: list[JSONObject] = []
22
+
23
+ for item in data:
24
+ # Convert to JSON string with sorted keys for consistent hashing
25
+ item_json = json.dumps(item, sort_keys=True, separators=(",", ":"))
26
+ if item_json not in seen:
27
+ seen.add(item_json)
28
+ unique_data.append(item)
29
+
30
+ return unique_data
31
+
32
+
33
+ def handle_errors(errors: list[JSONObject] | None, logs: list[Log]) -> list[JSONObject]:
34
+ """
35
+ Makes errors and logs consistent for user consumption.
36
+ """
37
+
38
+ result: list[JSONObject] = []
39
+
40
+ if errors:
41
+ result.extend(errors)
42
+
43
+ if logs:
44
+ result.extend(cast(list[JSONObject], logs))
45
+
46
+ result = remove_duplicates(result)
47
+
48
+ return result
@@ -7,15 +7,16 @@ import json
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
- import jsonpickle
10
+ from jinja2 import Environment, FileSystemLoader
11
11
  from pydantic import BaseModel, ValidationError
12
- from pydantic_core import ErrorDetails
13
12
 
14
13
  # pylint: disable=wrong-import-position
15
14
 
16
15
  sys.path.insert(0, str(Path(__file__).parent.parent))
16
+ from amati._error_handler import handle_errors
17
17
  from amati._resolve_forward_references import resolve_forward_references
18
18
  from amati.file_handler import load_file
19
+ from amati.logging import Log, LogMixin
19
20
 
20
21
  type JSONPrimitive = str | int | float | bool | None
21
22
  type JSONArray = list["JSONValue"]
@@ -23,7 +24,7 @@ type JSONObject = dict[str, "JSONValue"]
23
24
  type JSONValue = JSONPrimitive | JSONArray | JSONObject
24
25
 
25
26
 
26
- def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[ErrorDetails] | None]:
27
+ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | None]:
27
28
  """
28
29
  Returns the correct model for the passed spec
29
30
 
@@ -59,7 +60,7 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[ErrorDetails] | N
59
60
  try:
60
61
  model = module.OpenAPIObject(**data)
61
62
  except ValidationError as e:
62
- return None, e.errors()
63
+ return None, json.loads(e.json())
63
64
 
64
65
  return model, None
65
66
 
@@ -86,7 +87,12 @@ def check(original: JSONObject, validated: BaseModel) -> bool:
86
87
  return original_ == new_
87
88
 
88
89
 
89
- def run(file_path: str | Path, consistency_check: bool = False):
90
+ def run(
91
+ file_path: str | Path,
92
+ consistency_check: bool = False,
93
+ local: bool = False,
94
+ html_report: bool = False,
95
+ ):
90
96
  """
91
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
- data = load_file(file_path)
110
+ spec = Path(file_path)
105
111
 
106
- result, errors = dispatch(data)
112
+ data = load_file(spec)
107
113
 
108
- if result and consistency_check:
109
- if check(data, result):
110
- print("Consistency check successful")
111
- else:
112
- print("Consistency check failed")
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
- if errors:
115
- if not Path(".amati").exists():
116
- Path(".amati").mkdir()
128
+ if local:
129
+ error_path = Path(".amati")
117
130
 
118
- error_file = Path(file_path).parts[-1]
131
+ if not error_path.exists():
132
+ error_path.mkdir()
119
133
 
120
- with open(f".amati/{error_file}.json", "w", encoding="utf-8") as f:
121
- f.write(jsonpickle.encode(errors, unpicklable=False)) # type: ignore
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 amati",
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(specification, args.consistency_check)
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}")
@@ -4,8 +4,6 @@ Exceptions, declared here to not put in __init__
4
4
 
5
5
  from typing import Optional
6
6
 
7
- from amati.references import References
8
-
9
7
 
10
8
  class AmatiValueError(ValueError):
11
9
  """
@@ -21,6 +19,6 @@ class AmatiValueError(ValueError):
21
19
  ValueError
22
20
  """
23
21
 
24
- def __init__(self, message: str, reference: Optional[References] = None):
22
+ def __init__(self, message: str, reference_uri: Optional[str] = None):
25
23
  self.message = message
26
- self.reference = reference
24
+ self.reference_uri = reference_uri
@@ -5,14 +5,10 @@ Validates an email according to the RFC5322 ABNF grammar - §3:
5
5
  from abnf import ParseError
6
6
  from abnf.grammars import rfc5322
7
7
 
8
- from amati import AmatiValueError, Reference
8
+ from amati import AmatiValueError
9
9
  from amati.fields import Str as _Str
10
10
 
11
- reference = Reference(
12
- title="Internet Message Format",
13
- url="https://www.rfc-editor.org/rfc/rfc5322#section-3",
14
- section="Syntax",
15
- )
11
+ reference_uri = "https://www.rfc-editor.org/rfc/rfc5322#section-3"
16
12
 
17
13
 
18
14
  class Email(_Str):
@@ -23,5 +19,5 @@ class Email(_Str):
23
19
  rfc5322.Rule("address").parse_all(value)
24
20
  except ParseError as e:
25
21
  raise AmatiValueError(
26
- message=f"{value} is not a valid email address", reference=reference
22
+ f"{value} is not a valid email address", reference_uri
27
23
  ) from e