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.
Files changed (43) hide show
  1. accessible_math_reader/__init__.py +64 -0
  2. accessible_math_reader/api/__init__.py +16 -0
  3. accessible_math_reader/api/app.py +315 -0
  4. accessible_math_reader/api/errors.py +118 -0
  5. accessible_math_reader/api/middleware.py +172 -0
  6. accessible_math_reader/api/schemas.py +112 -0
  7. accessible_math_reader/braille/__init__.py +4 -0
  8. accessible_math_reader/braille/nemeth.py +426 -0
  9. accessible_math_reader/braille/ueb.py +253 -0
  10. accessible_math_reader/cli.py +391 -0
  11. accessible_math_reader/config.py +306 -0
  12. accessible_math_reader/core/__init__.py +4 -0
  13. accessible_math_reader/core/accessibility_contract.py +312 -0
  14. accessible_math_reader/core/aria_navigator.py +540 -0
  15. accessible_math_reader/core/aria_renderer.py +263 -0
  16. accessible_math_reader/core/parser.py +1186 -0
  17. accessible_math_reader/core/renderer.py +176 -0
  18. accessible_math_reader/core/semantic.py +462 -0
  19. accessible_math_reader/grpc_service/__init__.py +11 -0
  20. accessible_math_reader/grpc_service/proto/math_service.proto +89 -0
  21. accessible_math_reader/grpc_service/server.py +126 -0
  22. accessible_math_reader/jupyter/__init__.py +154 -0
  23. accessible_math_reader/observability/__init__.py +23 -0
  24. accessible_math_reader/observability/logging.py +141 -0
  25. accessible_math_reader/observability/metrics.py +243 -0
  26. accessible_math_reader/observability/tracing.py +133 -0
  27. accessible_math_reader/plugins/__init__.py +4 -0
  28. accessible_math_reader/plugins/base.py +399 -0
  29. accessible_math_reader/reader.py +195 -0
  30. accessible_math_reader/server.py +130 -0
  31. accessible_math_reader/speech/__init__.py +4 -0
  32. accessible_math_reader/speech/coqui_backend.py +105 -0
  33. accessible_math_reader/speech/engine.py +217 -0
  34. accessible_math_reader/speech/espeak_backend.py +124 -0
  35. accessible_math_reader/speech/pyttsx3_backend.py +107 -0
  36. accessible_math_reader/speech/rules.py +402 -0
  37. accessible_math_reader/validation/__init__.py +15 -0
  38. accessible_math_reader/validation/validator.py +233 -0
  39. accessible_math_reader-0.1.0.dist-info/METADATA +666 -0
  40. accessible_math_reader-0.1.0.dist-info/RECORD +43 -0
  41. accessible_math_reader-0.1.0.dist-info/WHEEL +4 -0
  42. accessible_math_reader-0.1.0.dist-info/entry_points.txt +2 -0
  43. 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