accessible-math-reader 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.
- accessible_math_reader/__init__.py +64 -0
- accessible_math_reader/api/__init__.py +16 -0
- accessible_math_reader/api/app.py +315 -0
- accessible_math_reader/api/errors.py +118 -0
- accessible_math_reader/api/middleware.py +172 -0
- accessible_math_reader/api/schemas.py +112 -0
- accessible_math_reader/braille/__init__.py +4 -0
- accessible_math_reader/braille/nemeth.py +426 -0
- accessible_math_reader/braille/ueb.py +253 -0
- accessible_math_reader/cli.py +391 -0
- accessible_math_reader/config.py +306 -0
- accessible_math_reader/core/__init__.py +4 -0
- accessible_math_reader/core/accessibility_contract.py +312 -0
- accessible_math_reader/core/aria_navigator.py +540 -0
- accessible_math_reader/core/aria_renderer.py +263 -0
- accessible_math_reader/core/parser.py +1186 -0
- accessible_math_reader/core/renderer.py +176 -0
- accessible_math_reader/core/semantic.py +462 -0
- accessible_math_reader/grpc_service/__init__.py +11 -0
- accessible_math_reader/grpc_service/proto/math_service.proto +89 -0
- accessible_math_reader/grpc_service/server.py +126 -0
- accessible_math_reader/jupyter/__init__.py +154 -0
- accessible_math_reader/observability/__init__.py +23 -0
- accessible_math_reader/observability/logging.py +141 -0
- accessible_math_reader/observability/metrics.py +243 -0
- accessible_math_reader/observability/tracing.py +133 -0
- accessible_math_reader/plugins/__init__.py +4 -0
- accessible_math_reader/plugins/base.py +399 -0
- accessible_math_reader/reader.py +195 -0
- accessible_math_reader/server.py +130 -0
- accessible_math_reader/speech/__init__.py +4 -0
- accessible_math_reader/speech/coqui_backend.py +105 -0
- accessible_math_reader/speech/engine.py +217 -0
- accessible_math_reader/speech/espeak_backend.py +124 -0
- accessible_math_reader/speech/pyttsx3_backend.py +107 -0
- accessible_math_reader/speech/rules.py +402 -0
- accessible_math_reader/validation/__init__.py +15 -0
- accessible_math_reader/validation/validator.py +233 -0
- accessible_math_reader-0.1.0.dist-info/METADATA +666 -0
- accessible_math_reader-0.1.0.dist-info/RECORD +43 -0
- accessible_math_reader-0.1.0.dist-info/WHEEL +4 -0
- accessible_math_reader-0.1.0.dist-info/entry_points.txt +2 -0
- accessible_math_reader-0.1.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""!
|
|
2
|
+
@file __init__.py
|
|
3
|
+
@brief Accessible Math Reader - Screen-reader-first math accessibility toolkit.
|
|
4
|
+
|
|
5
|
+
@details
|
|
6
|
+
This package provides tools for converting mathematical notation
|
|
7
|
+
(LaTeX, MathML, and plaintext/Unicode math) into accessible formats
|
|
8
|
+
including speech output and Braille notation.
|
|
9
|
+
|
|
10
|
+
@section usage Public API Usage
|
|
11
|
+
|
|
12
|
+
@code{.py}
|
|
13
|
+
from accessible_math_reader import MathReader, VerbosityLevel
|
|
14
|
+
|
|
15
|
+
# Create a reader instance
|
|
16
|
+
reader = MathReader()
|
|
17
|
+
|
|
18
|
+
# Convert LaTeX to speech text
|
|
19
|
+
speech = reader.to_speech(r"\\frac{a}{b}")
|
|
20
|
+
# Output: "a divided by b"
|
|
21
|
+
|
|
22
|
+
# Convert to Nemeth Braille
|
|
23
|
+
braille = reader.to_braille(r"\\frac{a}{b}", notation="nemeth")
|
|
24
|
+
# Output: "⠹⠁⠌⠃⠼"
|
|
25
|
+
|
|
26
|
+
# Generate audio file
|
|
27
|
+
reader.to_audio(r"\\frac{a}{b}", output_path="output.mp3")
|
|
28
|
+
@endcode
|
|
29
|
+
|
|
30
|
+
@author Accessible Math Reader Contributors
|
|
31
|
+
@version 0.1.0
|
|
32
|
+
@date 2025
|
|
33
|
+
@copyright MIT License
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from accessible_math_reader.core.parser import MathParser
|
|
37
|
+
from accessible_math_reader.core.semantic import SemanticNode, NodeType
|
|
38
|
+
from accessible_math_reader.core.renderer import MathRenderer
|
|
39
|
+
from accessible_math_reader.speech.engine import SpeechEngine
|
|
40
|
+
from accessible_math_reader.speech.rules import SpeechRuleSet, VerbosityLevel
|
|
41
|
+
from accessible_math_reader.braille.nemeth import NemethConverter
|
|
42
|
+
from accessible_math_reader.braille.ueb import UEBConverter
|
|
43
|
+
from accessible_math_reader.config import Config
|
|
44
|
+
from accessible_math_reader.reader import MathReader
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Main interface
|
|
49
|
+
"MathReader",
|
|
50
|
+
# Core components
|
|
51
|
+
"MathParser",
|
|
52
|
+
"SemanticNode",
|
|
53
|
+
"NodeType",
|
|
54
|
+
"MathRenderer",
|
|
55
|
+
# Speech
|
|
56
|
+
"SpeechEngine",
|
|
57
|
+
"SpeechRuleSet",
|
|
58
|
+
"VerbosityLevel",
|
|
59
|
+
# Braille
|
|
60
|
+
"NemethConverter",
|
|
61
|
+
"UEBConverter",
|
|
62
|
+
# Configuration
|
|
63
|
+
"Config",
|
|
64
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""!
|
|
2
|
+
@file api/__init__.py
|
|
3
|
+
@brief REST API subsystem for Accessible Math Reader.
|
|
4
|
+
|
|
5
|
+
@details
|
|
6
|
+
Provides a versioned REST API (``/api/v1/``) as a Flask Blueprint
|
|
7
|
+
that can be registered on any Flask application. Includes optional
|
|
8
|
+
authentication, rate limiting, and Prometheus metrics.
|
|
9
|
+
|
|
10
|
+
@author Accessible Math Reader Contributors
|
|
11
|
+
@version 0.2.0
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from accessible_math_reader.api.app import create_api_blueprint
|
|
15
|
+
|
|
16
|
+
__all__ = ["create_api_blueprint"]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""!
|
|
2
|
+
@file api/app.py
|
|
3
|
+
@brief REST API Blueprint for Accessible Math Reader.
|
|
4
|
+
|
|
5
|
+
@details
|
|
6
|
+
Exposes the full AMR pipeline as a versioned JSON REST API.
|
|
7
|
+
|
|
8
|
+
Endpoints (all under ``/api/v1/``):
|
|
9
|
+
POST /api/v1/speech — convert math to spoken English
|
|
10
|
+
POST /api/v1/braille — convert math to Braille
|
|
11
|
+
POST /api/v1/structure — return the semantic AST as JSON
|
|
12
|
+
POST /api/v1/audio — synthesise speech and return MP3 binary
|
|
13
|
+
POST /api/v1/validate — run accessibility validation
|
|
14
|
+
|
|
15
|
+
Infrastructure routes (mounted at root):
|
|
16
|
+
GET /health — liveness probe (Feature 6)
|
|
17
|
+
GET /readiness — readiness probe (Feature 6)
|
|
18
|
+
GET /metrics — Prometheus text exposition (Feature 7)
|
|
19
|
+
|
|
20
|
+
All conversion endpoints reuse ``MathReader`` from the core library —
|
|
21
|
+
no logic is duplicated.
|
|
22
|
+
|
|
23
|
+
@author Accessible Math Reader Contributors
|
|
24
|
+
@version 0.2.0
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import io
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import tempfile
|
|
33
|
+
import time
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from flask import Blueprint, Response, jsonify, request, send_file
|
|
37
|
+
|
|
38
|
+
from accessible_math_reader.api.errors import (
|
|
39
|
+
ErrorCode,
|
|
40
|
+
error_response,
|
|
41
|
+
register_error_handlers,
|
|
42
|
+
)
|
|
43
|
+
from accessible_math_reader.api.middleware import rate_limit, require_api_key
|
|
44
|
+
from accessible_math_reader.api.schemas import (
|
|
45
|
+
braille_response,
|
|
46
|
+
speech_response,
|
|
47
|
+
structure_response,
|
|
48
|
+
validate_math_request,
|
|
49
|
+
validation_response,
|
|
50
|
+
)
|
|
51
|
+
from accessible_math_reader.observability.metrics import MetricsCollector
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Module-level singletons (instantiated at import time)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_metrics = MetricsCollector()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Blueprint factory
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def create_api_blueprint() -> Blueprint:
|
|
67
|
+
"""!
|
|
68
|
+
@brief Create and return the REST API Blueprint.
|
|
69
|
+
|
|
70
|
+
@details
|
|
71
|
+
Register this blueprint on any Flask app:
|
|
72
|
+
|
|
73
|
+
@code{.py}
|
|
74
|
+
from accessible_math_reader.api import create_api_blueprint
|
|
75
|
+
|
|
76
|
+
app = Flask(__name__)
|
|
77
|
+
app.register_blueprint(create_api_blueprint())
|
|
78
|
+
@endcode
|
|
79
|
+
|
|
80
|
+
@return Configured Flask Blueprint
|
|
81
|
+
"""
|
|
82
|
+
bp = Blueprint("amr_api", __name__)
|
|
83
|
+
register_error_handlers(bp)
|
|
84
|
+
|
|
85
|
+
# ── Lazy reader accessor ──────────────────────────────────────
|
|
86
|
+
_reader_cache: dict[str, Any] = {}
|
|
87
|
+
|
|
88
|
+
def _get_reader() -> Any:
|
|
89
|
+
"""Get or create a shared MathReader instance."""
|
|
90
|
+
if "reader" not in _reader_cache:
|
|
91
|
+
from accessible_math_reader.reader import MathReader
|
|
92
|
+
_reader_cache["reader"] = MathReader()
|
|
93
|
+
return _reader_cache["reader"]
|
|
94
|
+
|
|
95
|
+
# ══════════════════════════════════════════════════════════════
|
|
96
|
+
# Conversion Endpoints (/api/v1/…)
|
|
97
|
+
# ══════════════════════════════════════════════════════════════
|
|
98
|
+
|
|
99
|
+
@bp.route("/api/v1/speech", methods=["POST"])
|
|
100
|
+
@require_api_key
|
|
101
|
+
@rate_limit
|
|
102
|
+
def api_speech() -> Any:
|
|
103
|
+
"""!
|
|
104
|
+
@brief Convert math input to spoken English text.
|
|
105
|
+
|
|
106
|
+
@details
|
|
107
|
+
Accepts JSON: ``{"input": "...", "format": "auto"}``
|
|
108
|
+
Returns JSON: ``{"speech": "...", "input": "..."}``
|
|
109
|
+
"""
|
|
110
|
+
_metrics.inc_request("speech")
|
|
111
|
+
data = request.get_json(silent=True)
|
|
112
|
+
err, params = validate_math_request(data)
|
|
113
|
+
if err:
|
|
114
|
+
return error_response(err, ErrorCode.VALIDATION_ERROR, 400)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
reader = _get_reader()
|
|
118
|
+
with _metrics.timer("speech"):
|
|
119
|
+
text = reader.to_speech(params["input"])
|
|
120
|
+
return jsonify(speech_response(text, params["input"]))
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.exception("Speech conversion failed")
|
|
123
|
+
_metrics.inc_error("speech_error")
|
|
124
|
+
return error_response(
|
|
125
|
+
str(exc), ErrorCode.PARSE_ERROR, 422
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@bp.route("/api/v1/braille", methods=["POST"])
|
|
129
|
+
@require_api_key
|
|
130
|
+
@rate_limit
|
|
131
|
+
def api_braille() -> Any:
|
|
132
|
+
"""!
|
|
133
|
+
@brief Convert math input to Braille notation.
|
|
134
|
+
|
|
135
|
+
@details
|
|
136
|
+
Accepts JSON: ``{"input": "...", "notation": "nemeth"}``
|
|
137
|
+
Returns JSON: ``{"braille": "...", "notation": "...", "input": "..."}``
|
|
138
|
+
"""
|
|
139
|
+
_metrics.inc_request("braille")
|
|
140
|
+
data = request.get_json(silent=True)
|
|
141
|
+
err, params = validate_math_request(data)
|
|
142
|
+
if err:
|
|
143
|
+
return error_response(err, ErrorCode.VALIDATION_ERROR, 400)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
reader = _get_reader()
|
|
147
|
+
with _metrics.timer("braille"):
|
|
148
|
+
text = reader.to_braille(
|
|
149
|
+
params["input"], notation=params["notation"]
|
|
150
|
+
)
|
|
151
|
+
return jsonify(
|
|
152
|
+
braille_response(text, params["notation"], params["input"])
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
logger.exception("Braille conversion failed")
|
|
156
|
+
_metrics.inc_error("braille_error")
|
|
157
|
+
return error_response(
|
|
158
|
+
str(exc), ErrorCode.PARSE_ERROR, 422
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@bp.route("/api/v1/structure", methods=["POST"])
|
|
162
|
+
@require_api_key
|
|
163
|
+
@rate_limit
|
|
164
|
+
def api_structure() -> Any:
|
|
165
|
+
"""!
|
|
166
|
+
@brief Return the semantic AST of a math expression as JSON.
|
|
167
|
+
|
|
168
|
+
@details
|
|
169
|
+
Accepts JSON: ``{"input": "..."}``
|
|
170
|
+
Returns JSON: ``{"structure": {...}, "input": "..."}``
|
|
171
|
+
"""
|
|
172
|
+
_metrics.inc_request("structure")
|
|
173
|
+
data = request.get_json(silent=True)
|
|
174
|
+
err, params = validate_math_request(data)
|
|
175
|
+
if err:
|
|
176
|
+
return error_response(err, ErrorCode.VALIDATION_ERROR, 400)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
reader = _get_reader()
|
|
180
|
+
with _metrics.timer("structure"):
|
|
181
|
+
struct = reader.get_structure(params["input"])
|
|
182
|
+
return jsonify(structure_response(struct, params["input"]))
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.exception("Structure extraction failed")
|
|
185
|
+
_metrics.inc_error("structure_error")
|
|
186
|
+
return error_response(
|
|
187
|
+
str(exc), ErrorCode.PARSE_ERROR, 422
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@bp.route("/api/v1/audio", methods=["POST"])
|
|
191
|
+
@require_api_key
|
|
192
|
+
@rate_limit
|
|
193
|
+
def api_audio() -> Any:
|
|
194
|
+
"""!
|
|
195
|
+
@brief Synthesise math speech and return an MP3 file.
|
|
196
|
+
|
|
197
|
+
@details
|
|
198
|
+
Accepts JSON: ``{"input": "..."}``
|
|
199
|
+
Returns: binary MP3 with ``Content-Type: audio/mpeg``
|
|
200
|
+
"""
|
|
201
|
+
_metrics.inc_request("audio")
|
|
202
|
+
data = request.get_json(silent=True)
|
|
203
|
+
err, params = validate_math_request(data)
|
|
204
|
+
if err:
|
|
205
|
+
return error_response(err, ErrorCode.VALIDATION_ERROR, 400)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
reader = _get_reader()
|
|
209
|
+
with _metrics.timer("audio"):
|
|
210
|
+
# Create a temp file for the audio
|
|
211
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
212
|
+
suffix=".mp3", delete=False
|
|
213
|
+
)
|
|
214
|
+
tmp_path = tmp.name
|
|
215
|
+
tmp.close()
|
|
216
|
+
reader.to_audio(params["input"], tmp_path)
|
|
217
|
+
|
|
218
|
+
return send_file(
|
|
219
|
+
tmp_path,
|
|
220
|
+
mimetype="audio/mpeg",
|
|
221
|
+
as_attachment=True,
|
|
222
|
+
download_name="speech.mp3",
|
|
223
|
+
)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.exception("Audio synthesis failed")
|
|
226
|
+
_metrics.inc_error("audio_error")
|
|
227
|
+
return error_response(
|
|
228
|
+
str(exc), ErrorCode.INTERNAL_ERROR, 500
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@bp.route("/api/v1/validate", methods=["POST"])
|
|
232
|
+
@require_api_key
|
|
233
|
+
@rate_limit
|
|
234
|
+
def api_validate() -> Any:
|
|
235
|
+
"""!
|
|
236
|
+
@brief Run accessibility validation on a math expression.
|
|
237
|
+
|
|
238
|
+
@details
|
|
239
|
+
Accepts JSON: ``{"input": "..."}``
|
|
240
|
+
Returns JSON:
|
|
241
|
+
@code{.json}
|
|
242
|
+
{
|
|
243
|
+
"wcag_violations": [],
|
|
244
|
+
"missing_semantic_structure": [],
|
|
245
|
+
"aria_warnings": [],
|
|
246
|
+
"valid": true
|
|
247
|
+
}
|
|
248
|
+
@endcode
|
|
249
|
+
"""
|
|
250
|
+
_metrics.inc_request("validate")
|
|
251
|
+
data = request.get_json(silent=True)
|
|
252
|
+
err, params = validate_math_request(data)
|
|
253
|
+
if err:
|
|
254
|
+
return error_response(err, ErrorCode.VALIDATION_ERROR, 400)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
from accessible_math_reader.validation.validator import (
|
|
258
|
+
MathValidator,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
with _metrics.timer("validate"):
|
|
262
|
+
validator = MathValidator()
|
|
263
|
+
results = validator.validate_expression(params["input"])
|
|
264
|
+
return jsonify(validation_response(results))
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.exception("Validation failed")
|
|
267
|
+
_metrics.inc_error("validate_error")
|
|
268
|
+
return error_response(
|
|
269
|
+
str(exc), ErrorCode.INTERNAL_ERROR, 500
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# ══════════════════════════════════════════════════════════════
|
|
273
|
+
# Infrastructure Endpoints (Feature 6 — Kubernetes)
|
|
274
|
+
# ══════════════════════════════════════════════════════════════
|
|
275
|
+
|
|
276
|
+
@bp.route("/health", methods=["GET"])
|
|
277
|
+
def health() -> Any:
|
|
278
|
+
"""!
|
|
279
|
+
@brief Liveness probe — returns 200 if the process is alive.
|
|
280
|
+
"""
|
|
281
|
+
return jsonify({"status": "healthy", "timestamp": time.time()})
|
|
282
|
+
|
|
283
|
+
@bp.route("/readiness", methods=["GET"])
|
|
284
|
+
def readiness() -> Any:
|
|
285
|
+
"""!
|
|
286
|
+
@brief Readiness probe — returns 200 if the service can accept requests.
|
|
287
|
+
|
|
288
|
+
@details
|
|
289
|
+
Checks that the core pipeline can be initialised. Returns 503
|
|
290
|
+
if something is wrong.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
_get_reader()
|
|
294
|
+
return jsonify({"status": "ready", "timestamp": time.time()})
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
return jsonify({
|
|
297
|
+
"status": "not ready",
|
|
298
|
+
"error": str(exc),
|
|
299
|
+
"timestamp": time.time(),
|
|
300
|
+
}), 503
|
|
301
|
+
|
|
302
|
+
@bp.route("/metrics", methods=["GET"])
|
|
303
|
+
def metrics() -> Any:
|
|
304
|
+
"""!
|
|
305
|
+
@brief Prometheus metrics endpoint.
|
|
306
|
+
|
|
307
|
+
@details
|
|
308
|
+
Returns metrics in Prometheus text exposition format.
|
|
309
|
+
"""
|
|
310
|
+
return Response(
|
|
311
|
+
_metrics.render(),
|
|
312
|
+
mimetype="text/plain; version=0.0.4; charset=utf-8",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return bp
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""!
|
|
2
|
+
@file api/errors.py
|
|
3
|
+
@brief Structured JSON error responses for the REST API.
|
|
4
|
+
|
|
5
|
+
@details
|
|
6
|
+
Provides standardised error response formatting and Flask error
|
|
7
|
+
handler registration so that all API errors return consistent JSON.
|
|
8
|
+
|
|
9
|
+
Response format:
|
|
10
|
+
@code{.json}
|
|
11
|
+
{
|
|
12
|
+
"error": "Human-readable message",
|
|
13
|
+
"code": "MACHINE_READABLE_CODE",
|
|
14
|
+
"details": {}
|
|
15
|
+
}
|
|
16
|
+
@endcode
|
|
17
|
+
|
|
18
|
+
@author Accessible Math Reader Contributors
|
|
19
|
+
@version 0.2.0
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
from flask import jsonify
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Error codes
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
class ErrorCode:
|
|
34
|
+
"""Machine-readable error codes returned in the ``code`` field."""
|
|
35
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
36
|
+
PARSE_ERROR = "PARSE_ERROR"
|
|
37
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
38
|
+
AUTH_REQUIRED = "AUTH_REQUIRED"
|
|
39
|
+
AUTH_INVALID = "AUTH_INVALID"
|
|
40
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
41
|
+
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
|
|
42
|
+
NOT_FOUND = "NOT_FOUND"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Response helpers
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def error_response(
|
|
50
|
+
message: str,
|
|
51
|
+
code: str,
|
|
52
|
+
status: int = 400,
|
|
53
|
+
details: Optional[dict[str, Any]] = None,
|
|
54
|
+
) -> tuple:
|
|
55
|
+
"""!
|
|
56
|
+
@brief Build a JSON error response tuple.
|
|
57
|
+
|
|
58
|
+
@param message Human-readable error description
|
|
59
|
+
@param code Machine-readable error code from ``ErrorCode``
|
|
60
|
+
@param status HTTP status code (default 400)
|
|
61
|
+
@param details Optional extra context
|
|
62
|
+
@return (response, status_code) tuple suitable for Flask
|
|
63
|
+
"""
|
|
64
|
+
body: dict[str, Any] = {
|
|
65
|
+
"error": message,
|
|
66
|
+
"code": code,
|
|
67
|
+
}
|
|
68
|
+
if details:
|
|
69
|
+
body["details"] = details
|
|
70
|
+
return jsonify(body), status
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Flask error handler registration
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def register_error_handlers(app_or_bp: Any) -> None:
|
|
78
|
+
"""!
|
|
79
|
+
@brief Register JSON error handlers on a Flask app or Blueprint.
|
|
80
|
+
|
|
81
|
+
@param app_or_bp Flask app or Blueprint instance
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@app_or_bp.errorhandler(400)
|
|
85
|
+
def bad_request(exc: Any) -> tuple:
|
|
86
|
+
return error_response(
|
|
87
|
+
str(exc), ErrorCode.VALIDATION_ERROR, 400
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@app_or_bp.errorhandler(404)
|
|
91
|
+
def not_found(exc: Any) -> tuple:
|
|
92
|
+
return error_response(
|
|
93
|
+
"Endpoint not found", ErrorCode.NOT_FOUND, 404
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@app_or_bp.errorhandler(413)
|
|
97
|
+
def payload_too_large(exc: Any) -> tuple:
|
|
98
|
+
return error_response(
|
|
99
|
+
"Request payload exceeds size limit",
|
|
100
|
+
ErrorCode.PAYLOAD_TOO_LARGE,
|
|
101
|
+
413,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@app_or_bp.errorhandler(429)
|
|
105
|
+
def rate_limited(exc: Any) -> tuple:
|
|
106
|
+
return error_response(
|
|
107
|
+
"Rate limit exceeded — please retry later",
|
|
108
|
+
ErrorCode.RATE_LIMITED,
|
|
109
|
+
429,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@app_or_bp.errorhandler(500)
|
|
113
|
+
def internal_error(exc: Any) -> tuple:
|
|
114
|
+
return error_response(
|
|
115
|
+
"Internal server error",
|
|
116
|
+
ErrorCode.INTERNAL_ERROR,
|
|
117
|
+
500,
|
|
118
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""!
|
|
2
|
+
@file api/middleware.py
|
|
3
|
+
@brief Optional authentication and rate-limiting middleware.
|
|
4
|
+
|
|
5
|
+
@details
|
|
6
|
+
Provides two Flask decorators that can be applied to API endpoints:
|
|
7
|
+
- ``require_api_key`` — checks ``X-API-Key`` header against a
|
|
8
|
+
configured list of valid keys.
|
|
9
|
+
- ``rate_limit`` — enforces a per-IP sliding-window request
|
|
10
|
+
limit using an in-memory store (no Redis required).
|
|
11
|
+
|
|
12
|
+
Both are **disabled by default** and controlled via environment
|
|
13
|
+
variables. Self-hosted users should leave ``AMR_ENABLE_AUTH`` and
|
|
14
|
+
``AMR_ENABLE_RATE_LIMIT`` as ``false`` for zero overhead.
|
|
15
|
+
|
|
16
|
+
Environment variables:
|
|
17
|
+
AMR_ENABLE_AUTH — "true" to require API keys (default: false)
|
|
18
|
+
AMR_API_KEYS — comma-separated list of valid keys
|
|
19
|
+
AMR_ENABLE_RATE_LIMIT — "true" to enable (default: false)
|
|
20
|
+
AMR_RATE_LIMIT — "<count>/<period>" e.g. "100/minute" (default)
|
|
21
|
+
|
|
22
|
+
@author Accessible Math Reader Contributors
|
|
23
|
+
@version 0.2.0
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import functools
|
|
29
|
+
import os
|
|
30
|
+
import time
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from threading import Lock
|
|
33
|
+
from typing import Any, Callable
|
|
34
|
+
|
|
35
|
+
from flask import request
|
|
36
|
+
|
|
37
|
+
from accessible_math_reader.api.errors import ErrorCode, error_response
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
# Authentication
|
|
42
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
def _auth_enabled() -> bool:
|
|
45
|
+
"""Check if API-key authentication is turned on."""
|
|
46
|
+
return os.environ.get("AMR_ENABLE_AUTH", "false").lower() == "true"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _valid_keys() -> set[str]:
|
|
50
|
+
"""Parse the comma-separated list of valid API keys."""
|
|
51
|
+
raw = os.environ.get("AMR_API_KEYS", "")
|
|
52
|
+
return {k.strip() for k in raw.split(",") if k.strip()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_api_key(fn: Callable) -> Callable:
|
|
56
|
+
"""!
|
|
57
|
+
@brief Decorator: reject requests missing a valid ``X-API-Key`` header.
|
|
58
|
+
|
|
59
|
+
@details
|
|
60
|
+
When ``AMR_ENABLE_AUTH=true``, every decorated endpoint requires
|
|
61
|
+
the header ``X-API-Key: <key>`` where ``<key>`` is one of the
|
|
62
|
+
values in the ``AMR_API_KEYS`` environment variable.
|
|
63
|
+
|
|
64
|
+
When authentication is disabled (the default), this decorator
|
|
65
|
+
is a transparent pass-through.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
@functools.wraps(fn)
|
|
69
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
70
|
+
if not _auth_enabled():
|
|
71
|
+
return fn(*args, **kwargs)
|
|
72
|
+
|
|
73
|
+
key = request.headers.get("X-API-Key", "")
|
|
74
|
+
keys = _valid_keys()
|
|
75
|
+
if not keys:
|
|
76
|
+
# No keys configured — treat as misconfiguration, deny all
|
|
77
|
+
return error_response(
|
|
78
|
+
"Authentication is enabled but no API keys are configured",
|
|
79
|
+
ErrorCode.INTERNAL_ERROR,
|
|
80
|
+
500,
|
|
81
|
+
)
|
|
82
|
+
if key not in keys:
|
|
83
|
+
return error_response(
|
|
84
|
+
"Missing or invalid API key",
|
|
85
|
+
ErrorCode.AUTH_INVALID,
|
|
86
|
+
401,
|
|
87
|
+
)
|
|
88
|
+
return fn(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
# Rate Limiting
|
|
95
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
96
|
+
|
|
97
|
+
# In-memory sliding window — per IP
|
|
98
|
+
_rate_lock = Lock()
|
|
99
|
+
_rate_store: dict[str, list[float]] = defaultdict(list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_rate_limit() -> tuple[int, float]:
|
|
103
|
+
"""!
|
|
104
|
+
@brief Parse ``AMR_RATE_LIMIT`` into (count, window_seconds).
|
|
105
|
+
|
|
106
|
+
@details
|
|
107
|
+
Accepted formats:
|
|
108
|
+
- "100/minute" → (100, 60)
|
|
109
|
+
- "1000/hour" → (1000, 3600)
|
|
110
|
+
- "10/second" → (10, 1)
|
|
111
|
+
|
|
112
|
+
Defaults to 100/minute.
|
|
113
|
+
|
|
114
|
+
@return Tuple of (max_requests, window_in_seconds)
|
|
115
|
+
"""
|
|
116
|
+
raw = os.environ.get("AMR_RATE_LIMIT", "100/minute")
|
|
117
|
+
try:
|
|
118
|
+
count_str, period = raw.strip().split("/")
|
|
119
|
+
count = int(count_str)
|
|
120
|
+
except (ValueError, IndexError):
|
|
121
|
+
return 100, 60.0
|
|
122
|
+
|
|
123
|
+
period_map = {
|
|
124
|
+
"second": 1.0,
|
|
125
|
+
"minute": 60.0,
|
|
126
|
+
"hour": 3600.0,
|
|
127
|
+
"day": 86400.0,
|
|
128
|
+
}
|
|
129
|
+
window = period_map.get(period.lower(), 60.0)
|
|
130
|
+
return count, window
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def rate_limit(fn: Callable) -> Callable:
|
|
134
|
+
"""!
|
|
135
|
+
@brief Decorator: enforce per-IP request rate limiting.
|
|
136
|
+
|
|
137
|
+
@details
|
|
138
|
+
Uses an in-memory sliding window. When ``AMR_ENABLE_RATE_LIMIT``
|
|
139
|
+
is ``false`` (the default), this decorator is a transparent
|
|
140
|
+
pass-through.
|
|
141
|
+
|
|
142
|
+
The limit is configured via ``AMR_RATE_LIMIT`` (e.g. "100/minute").
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
@functools.wraps(fn)
|
|
146
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
147
|
+
if os.environ.get(
|
|
148
|
+
"AMR_ENABLE_RATE_LIMIT", "false"
|
|
149
|
+
).lower() != "true":
|
|
150
|
+
return fn(*args, **kwargs)
|
|
151
|
+
|
|
152
|
+
ip = request.remote_addr or "unknown"
|
|
153
|
+
max_requests, window = _parse_rate_limit()
|
|
154
|
+
now = time.time()
|
|
155
|
+
cutoff = now - window
|
|
156
|
+
|
|
157
|
+
with _rate_lock:
|
|
158
|
+
# Prune old entries
|
|
159
|
+
_rate_store[ip] = [
|
|
160
|
+
t for t in _rate_store[ip] if t > cutoff
|
|
161
|
+
]
|
|
162
|
+
if len(_rate_store[ip]) >= max_requests:
|
|
163
|
+
return error_response(
|
|
164
|
+
"Rate limit exceeded — please retry later",
|
|
165
|
+
ErrorCode.RATE_LIMITED,
|
|
166
|
+
429,
|
|
167
|
+
)
|
|
168
|
+
_rate_store[ip].append(now)
|
|
169
|
+
|
|
170
|
+
return fn(*args, **kwargs)
|
|
171
|
+
|
|
172
|
+
return wrapper
|