flight-alloc 0.0.1__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 (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. src/zc_store.py +221 -0
src/web/api/runs.py ADDED
@@ -0,0 +1,102 @@
1
+ """Starting a Plan / Allocate / Reset, and polling what's in flight.
2
+
3
+ A run is kicked off on a worker thread and the UI polls
4
+ ``/api/run/status`` — the POST returns as soon as the run is accepted,
5
+ so a slow solve never holds a request open.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from datetime import date as date_t
12
+
13
+ from ...state import STATE
14
+ from .. import runner
15
+ from ..core.responses import Response, bad_request, conflict, json_response
16
+ from ..core.router import Request, Router
17
+
18
+ logger = logging.getLogger("src.web")
19
+
20
+ #: Sub-commands ``/api/run`` will accept.
21
+ RUN_STEPS = {"all", "plan", "step1", "step2", "step3", "reset"}
22
+
23
+
24
+ def parse_date(raw: str | None) -> date_t | None:
25
+ if not raw:
26
+ return None
27
+ try:
28
+ return date_t.fromisoformat(raw)
29
+ except ValueError:
30
+ return None
31
+
32
+
33
+ def register(router: Router) -> None:
34
+
35
+ @router.get("/api/run/status")
36
+ def status(_req: Request) -> Response:
37
+ return json_response(runner.status())
38
+
39
+ @router.post("/api/run")
40
+ def run(req: Request) -> Response:
41
+ body = req.json()
42
+ step = body.get("step") if isinstance(body.get("step"), str) else "all"
43
+ if step not in RUN_STEPS:
44
+ return bad_request(f"step={step!r} not allowed")
45
+ if step == "reset":
46
+ return _reset()
47
+
48
+ d_day = parse_date(body.get("date"))
49
+ if d_day is None:
50
+ return bad_request("body.date required (YYYY-MM-DD)")
51
+
52
+ missing = STATE.missing_inputs()
53
+ if missing:
54
+ from ...state import INPUT_LABELS
55
+ names = ", ".join(INPUT_LABELS[k] for k in missing)
56
+ return conflict({
57
+ "error": f"upload the missing input file(s) first: {names}",
58
+ "missing": missing,
59
+ })
60
+
61
+ # Belt-and-suspenders: the dashboard is expected to grey out
62
+ # Allocate until P2F handlers are sorted (see plan.js /
63
+ # handlers.js), but a direct API call — or a stale tab — could
64
+ # still reach here. Only blocks when Plan has already run for
65
+ # this date (same date requested) with a real gap; never blocks
66
+ # the "all" step's own Plan phase when nothing's been planned
67
+ # yet, since that will surface the same gap in the Warnings tab
68
+ # after the solve.
69
+ if step in ("all", "step3") and d_day == STATE.run_date:
70
+ from .. import readback
71
+ handlers_state = readback.read_handlers(STATE)
72
+ if handlers_state.get("plan_has_run") and not handlers_state.get("ready"):
73
+ missing_shifts = handlers_state.get("missing_p2f_shifts", [])
74
+ return conflict({
75
+ "error": (
76
+ "P2F handler nomination missing or invalid for "
77
+ f"shift(s): {', '.join(missing_shifts)}. Fix it in "
78
+ "the Override drawer, then Plan again before "
79
+ "Allocate."
80
+ ),
81
+ "missing_p2f_shifts": missing_shifts,
82
+ "issues": handlers_state.get("issues", []),
83
+ })
84
+
85
+ STATE.run_date = d_day
86
+ if not runner.trigger(STATE, d_day, step):
87
+ return conflict({"error": "another run is in progress"})
88
+ return json_response({"started": True, "step": step})
89
+
90
+
91
+ def _reset() -> Response:
92
+ runner.cancel()
93
+ cleared = STATE.reset_results()
94
+ # The drawer promises "defaults restore on Reset" for the
95
+ # per-(shift, role) bands, so clear those too.
96
+ from ...allocator import caps as _caps
97
+ _caps.reset_overrides()
98
+ logger.info("[reset] cleared %s", cleared)
99
+ return json_response({"reset": True, "cleared": cleared})
100
+
101
+
102
+ __all__ = ["RUN_STEPS", "parse_date", "register"]
@@ -0,0 +1,201 @@
1
+ """Drawer settings that write through past the session.
2
+
3
+ Shift limits are per-iteration and cleared by Reset. The airport-code,
4
+ break-length and extraction-filter edits go into ``configs/config.yml``, and
5
+ the per-staff flight caps into ``configs/shift_limits.json``, so they
6
+ outlive the process and the engine sees them on the next run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .. import overrides as override_ops
12
+ from .. import runner
13
+ from ..core.responses import (
14
+ Response,
15
+ bad_request,
16
+ conflict,
17
+ json_response,
18
+ server_error,
19
+ )
20
+ from ..core.router import Request, Router
21
+
22
+ #: Shift codes and roles a band override may name.
23
+ BAND_SHIFTS = ("M", "A", "N", "M1", "A1")
24
+ BAND_ROLES = ("STAFF", "ZC")
25
+
26
+
27
+ def register(router: Router) -> None:
28
+
29
+ @router.post("/api/shift_limits")
30
+ def shift_limits(req: Request) -> Response:
31
+ """Drawer "Edit shift limits": a per-iteration band override for
32
+ one (shift, role) bucket. Cleared by Reset."""
33
+ body = req.json()
34
+ shift = body.get("shift")
35
+ role = body.get("role")
36
+ try:
37
+ min_v = int(body.get("min"))
38
+ target_v = int(body.get("target"))
39
+ max_v = int(body.get("max"))
40
+ except (TypeError, ValueError):
41
+ return bad_request("min, target, max must be integers")
42
+ if shift not in BAND_SHIFTS:
43
+ allowed = "/".join(BAND_SHIFTS)
44
+ return bad_request(f"shift must be {allowed}, got {shift!r}")
45
+ if role not in BAND_ROLES:
46
+ allowed = " or ".join(BAND_ROLES)
47
+ return bad_request(f"role must be {allowed}, got {role!r}")
48
+ from ...allocator import caps as _caps
49
+ from ...schemas import Role as _Role
50
+ try:
51
+ saved = _caps.set_iteration_band(
52
+ shift, _Role.STAFF if role == "STAFF" else _Role.ZC,
53
+ min_v=min_v, target_v=target_v, max_v=max_v,
54
+ )
55
+ except ValueError as exc:
56
+ return bad_request(str(exc))
57
+ return json_response({"saved": saved})
58
+
59
+ def _break_payload(minutes: int) -> dict[str, int]:
60
+ from ...config import BREAK_MINUTES_HIGHEST, BREAK_MINUTES_LOWEST
61
+ return {
62
+ "length_minutes": minutes,
63
+ "min": BREAK_MINUTES_LOWEST,
64
+ "max": BREAK_MINUTES_HIGHEST,
65
+ }
66
+
67
+ @router.get("/api/break_length")
68
+ def get_break_length(_req: Request) -> Response:
69
+ """Setup sidebar "Shift break length" — the value the next
70
+ Allocate run will use, plus the allowed range."""
71
+ try:
72
+ minutes = override_ops.get_break_length()
73
+ except Exception as exc: # noqa: BLE001
74
+ return server_error(exc, context="get_break_length failed")
75
+ return json_response(_break_payload(minutes))
76
+
77
+ @router.post("/api/break_length")
78
+ def set_break_length(req: Request) -> Response:
79
+ """Writes ``break_pass.length_minutes`` to configs/config.yml so
80
+ the next Allocate run uses it."""
81
+ try:
82
+ minutes = override_ops.set_break_length(
83
+ req.json().get("length_minutes"),
84
+ )
85
+ except ValueError as exc:
86
+ return bad_request(str(exc))
87
+ return json_response(_break_payload(minutes))
88
+
89
+ def _caps_payload() -> dict[str, object]:
90
+ from ...allocator import caps as _caps
91
+ overrides = _caps.snapshot_iteration_overrides()["shift_role"]
92
+ return {
93
+ "caps": _caps.default_caps(),
94
+ "overrides": {key: band["max"] for key, band in overrides.items()},
95
+ }
96
+
97
+ @router.get("/api/hard_caps")
98
+ def get_hard_caps(_req: Request) -> Response:
99
+ """Setup sidebar "Max flights per staff" table — the saved cap
100
+ per shift and role, plus the cap of any per-run shift-limits
101
+ override that shadows one until Reset."""
102
+ return json_response(_caps_payload())
103
+
104
+ @router.post("/api/hard_caps")
105
+ def set_hard_caps(req: Request) -> Response:
106
+ """Writes the caps to configs/shift_limits.json. Refused while a
107
+ run is in flight, since the engine reads the caps all the way
108
+ through one."""
109
+ caps = req.json().get("caps")
110
+ if not isinstance(caps, dict):
111
+ return bad_request("body.caps must be an object of {shift: {role: cap}}")
112
+ if runner.is_running():
113
+ return conflict({"error": "a run is in progress — wait for it to finish"})
114
+ from ...allocator import caps as _caps
115
+ try:
116
+ notes = _caps.set_default_caps(caps)
117
+ except ValueError as exc:
118
+ return bad_request(str(exc))
119
+ return json_response({**_caps_payload(), "notes": notes})
120
+
121
+ @router.get("/api/extraction_filters")
122
+ def list_filters(_req: Request) -> Response:
123
+ try:
124
+ filters = override_ops.list_extraction_filters()
125
+ except Exception as exc: # noqa: BLE001
126
+ return server_error(exc, context="list_extraction_filters failed")
127
+ return json_response({"filters": filters})
128
+
129
+ @router.get("/api/intl_airports")
130
+ def list_airports(_req: Request) -> Response:
131
+ """Setup sidebar's international airport list, as configured in
132
+ configs/config.yml."""
133
+ try:
134
+ airports = override_ops.list_intl_airports()
135
+ except Exception as exc: # noqa: BLE001
136
+ return server_error(exc, context="list_intl_airports failed")
137
+ return json_response({"airports": airports})
138
+
139
+ @router.post("/api/intl_airports")
140
+ def add_airport(req: Request) -> Response:
141
+ """Setup sidebar "Add international airport code" — writes through
142
+ to configs/config.yml so the engine sees it on the next run."""
143
+ body = req.json()
144
+ code = body.get("code")
145
+ name = body.get("name")
146
+ if not isinstance(code, str):
147
+ return bad_request("body.code must be a string")
148
+ try:
149
+ payload = override_ops.add_intl_airport_code(
150
+ code, name if isinstance(name, str) else None,
151
+ )
152
+ except ValueError as exc:
153
+ return bad_request(str(exc))
154
+ return json_response({
155
+ "added": payload, "airports": override_ops.list_intl_airports(),
156
+ })
157
+
158
+ @router.post("/api/intl_airports/remove")
159
+ def remove_airport(req: Request) -> Response:
160
+ """Setup sidebar's remove button on an airport chip — deletes the
161
+ code from configs/config.yml; the next run treats flights from
162
+ that airport as domestic."""
163
+ code = req.json().get("code")
164
+ if not isinstance(code, str):
165
+ return bad_request("body.code must be a string")
166
+ try:
167
+ removed = override_ops.remove_intl_airport_code(code)
168
+ except ValueError as exc:
169
+ return bad_request(str(exc))
170
+ return json_response({
171
+ "removed": removed, "airports": override_ops.list_intl_airports(),
172
+ })
173
+
174
+ @router.post("/api/extraction_filters/add")
175
+ def add_filter(req: Request) -> Response:
176
+ try:
177
+ norm = override_ops.add_extraction_filter(req.json())
178
+ except ValueError as exc:
179
+ return bad_request(str(exc))
180
+ except Exception as exc: # noqa: BLE001
181
+ return server_error(exc, context="add_extraction_filter failed")
182
+ return json_response({
183
+ "filter": norm, "filters": override_ops.list_extraction_filters(),
184
+ })
185
+
186
+ @router.post("/api/extraction_filters/remove")
187
+ def remove_filter(req: Request) -> Response:
188
+ try:
189
+ idx = int(req.json().get("index", 0))
190
+ except (TypeError, ValueError):
191
+ return bad_request("body.index must be an integer (1-based)")
192
+ try:
193
+ removed = override_ops.remove_extraction_filter(idx)
194
+ except IndexError as exc:
195
+ return bad_request(str(exc))
196
+ return json_response({
197
+ "removed": removed, "filters": override_ops.list_extraction_filters(),
198
+ })
199
+
200
+
201
+ __all__ = ["BAND_ROLES", "BAND_SHIFTS", "register"]
src/web/api/zc.py ADDED
@@ -0,0 +1,117 @@
1
+ """Zone Controller list: read, add, remove — one list per date.
2
+
3
+ The list lives in ``zc_store`` (saved on disk, carried over from the
4
+ previous day until edited). Every change is applied to the working data
5
+ straight away, so the dashboard reflects it before Allocate is clicked;
6
+ the next Plan re-applies it from the store.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import date as date_t
13
+
14
+ from ... import zc_store
15
+ from ...state import STATE
16
+ from .. import runner
17
+ from ..core.responses import Response, bad_request, conflict, json_response
18
+ from ..core.router import Request, Router
19
+ from .runs import parse_date
20
+
21
+ logger = logging.getLogger("src.web")
22
+
23
+
24
+ def _payload(d_day: date_t, **extra: object) -> dict[str, object]:
25
+ names, source, carried_from = zc_store.effective(d_day)
26
+ return {
27
+ "date": d_day.isoformat(),
28
+ "names": names,
29
+ # saved = this date has its own list
30
+ # carried = inherited from an earlier date until edited
31
+ # none = nothing saved on or before this date
32
+ "source": source,
33
+ "carried_from": carried_from.isoformat() if carried_from else None,
34
+ **extra,
35
+ }
36
+
37
+
38
+ def _rebuild(d_day: date_t) -> dict[str, object]:
39
+ """Re-derive today's availability from the rosters and re-apply the
40
+ staged rows plus the ZC list.
41
+
42
+ A rebuild (rather than patching roles in place) is what makes
43
+ *removing* a ZC work: the roster row is the only place that still
44
+ knows whether that person is STAFF or AM. Only done when the date
45
+ being edited is the one that has been planned; otherwise the list is
46
+ simply saved and applied by the next Plan for that date.
47
+ """
48
+ with STATE.lock:
49
+ planned = bool(STATE.availability) and STATE.run_date == d_day
50
+ if not planned:
51
+ return {"applied": False, "reason": "not planned yet"}
52
+
53
+ from ...step2_extract_roster import run as run_step2
54
+
55
+ # step2 replaces state.warnings; the ones on screen belong to the
56
+ # last Plan / Allocate, so put them back afterwards.
57
+ with STATE.lock:
58
+ saved_warnings = list(STATE.warnings)
59
+ try:
60
+ counts = run_step2(STATE, d_day, runner.CONFIG_PATH)
61
+ except Exception as exc: # noqa: BLE001 — list is saved; say why it didn't apply
62
+ logger.warning("[zc] roster re-read failed: %s", exc)
63
+ return {"applied": False, "reason": f"{type(exc).__name__}: {exc}"}
64
+ finally:
65
+ with STATE.lock:
66
+ STATE.warnings = saved_warnings
67
+ if counts.get("ABORTED"):
68
+ return {"applied": False, "reason": "roster check failed — see warnings"}
69
+ return {"applied": True}
70
+
71
+
72
+ def _date_or_error(raw: str | None) -> tuple[date_t | None, Response | None]:
73
+ d_day = parse_date(raw)
74
+ if d_day is None:
75
+ return None, bad_request("date required (YYYY-MM-DD)")
76
+ return d_day, None
77
+
78
+
79
+ def register(router: Router) -> None:
80
+
81
+ @router.get("/api/zc")
82
+ def get_zc(req: Request) -> Response:
83
+ d_day, err = _date_or_error((req.query.get("date") or [""])[0])
84
+ if err:
85
+ return err
86
+ return json_response(_payload(d_day))
87
+
88
+ @router.post("/api/zc/add")
89
+ def add_zc(req: Request) -> Response:
90
+ body = req.json()
91
+ d_day, err = _date_or_error(body.get("date"))
92
+ if err:
93
+ return err
94
+ name = str(body.get("name") or "").strip()
95
+ if not name:
96
+ return bad_request("name required")
97
+ if runner.is_running():
98
+ return conflict({"error": "a run is in progress — wait for it to finish"})
99
+ zc_store.add(d_day, name)
100
+ return json_response(_payload(d_day, **_rebuild(d_day)))
101
+
102
+ @router.post("/api/zc/remove")
103
+ def remove_zc(req: Request) -> Response:
104
+ body = req.json()
105
+ d_day, err = _date_or_error(body.get("date"))
106
+ if err:
107
+ return err
108
+ name = str(body.get("name") or "").strip()
109
+ if not name:
110
+ return bad_request("name required")
111
+ if runner.is_running():
112
+ return conflict({"error": "a run is in progress — wait for it to finish"})
113
+ zc_store.remove(d_day, name)
114
+ return json_response(_payload(d_day, **_rebuild(d_day)))
115
+
116
+
117
+ __all__ = ["register"]
@@ -0,0 +1,5 @@
1
+ """HTTP plumbing shared by every API module.
2
+
3
+ Deliberately knows nothing about flights, rosters or the solver — it is
4
+ the transport layer the ``api`` package plugs handlers into.
5
+ """
@@ -0,0 +1,91 @@
1
+ """What a handler hands back.
2
+
3
+ A handler returns a ``Response``; the request loop is what knows how to
4
+ put it on the wire. Keeping the two apart means a handler is a plain
5
+ function of ``Request -> Response``, testable without a socket.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import traceback
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ JSON_CONTENT_TYPE = "application/json"
16
+ XLSX_CONTENT_TYPE = (
17
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
18
+ )
19
+ XML_CONTENT_TYPE = "application/xml"
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class Response:
24
+ """One HTTP response. ``headers`` carries anything beyond the
25
+ Content-Type / Content-Length / Cache-Control the sender always
26
+ sets — a Content-Disposition on a download, for instance."""
27
+
28
+ status: int = 200
29
+ body: bytes = b""
30
+ content_type: str = JSON_CONTENT_TYPE
31
+ headers: dict[str, str] = field(default_factory=dict)
32
+
33
+
34
+ def json_response(payload: Any, status: int = 200) -> Response:
35
+ return Response(
36
+ status=status,
37
+ body=json.dumps(payload, default=str).encode("utf-8"),
38
+ content_type=JSON_CONTENT_TYPE,
39
+ )
40
+
41
+
42
+ def bad_request(message: str) -> Response:
43
+ return json_response({"error": message}, status=400)
44
+
45
+
46
+ def not_found(message: str = "not found") -> Response:
47
+ return json_response({"error": message}, status=404)
48
+
49
+
50
+ def conflict(payload: dict[str, Any]) -> Response:
51
+ return json_response(payload, status=409)
52
+
53
+
54
+ def server_error(exc: BaseException, *, context: str = "") -> Response:
55
+ """500 with the exception text and traceback in the body.
56
+
57
+ Sending it as JSON rather than letting the exception bubble matters:
58
+ an escaped exception closes the connection mid-response and the
59
+ browser shows a bare ERR_EMPTY_RESPONSE with nothing to act on.
60
+ """
61
+ label = f"{context}: " if context else ""
62
+ return json_response(
63
+ {
64
+ "error": f"{label}{type(exc).__name__}: {exc}",
65
+ "traceback": traceback.format_exc(),
66
+ },
67
+ status=500,
68
+ )
69
+
70
+
71
+ def attachment(body: bytes, content_type: str, filename: str) -> Response:
72
+ return Response(
73
+ status=200,
74
+ body=body,
75
+ content_type=content_type,
76
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
77
+ )
78
+
79
+
80
+ __all__ = [
81
+ "JSON_CONTENT_TYPE",
82
+ "Response",
83
+ "XLSX_CONTENT_TYPE",
84
+ "XML_CONTENT_TYPE",
85
+ "attachment",
86
+ "bad_request",
87
+ "conflict",
88
+ "json_response",
89
+ "not_found",
90
+ "server_error",
91
+ ]
src/web/core/router.py ADDED
@@ -0,0 +1,167 @@
1
+ """A pattern -> handler table, so adding an endpoint is a registration
2
+ rather than another branch in a growing if/elif chain.
3
+
4
+ Patterns are literal paths with optional ``{name}`` segments:
5
+
6
+ @router.get("/api/dashboard")
7
+ def dashboard(req): ...
8
+
9
+ @router.post("/api/inputs/{kind}")
10
+ def upload(req): ... # req.params["kind"]
11
+
12
+ @router.get("/static/{path*}")
13
+ def asset(req): ... # trailing * spans "/" too
14
+
15
+ Literal routes are matched by dict lookup; only the handful of
16
+ parameterised ones fall through to the regex list, so dispatch cost
17
+ doesn't grow with the number of plain endpoints.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import urllib.parse
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Callable
27
+
28
+ from .responses import Response, not_found, server_error
29
+
30
+ #: A route handler. Takes the request, returns the response.
31
+ Handler = Callable[["Request"], Response]
32
+
33
+ #: ``{name}`` matches one path segment; ``{name*}`` matches the rest of
34
+ #: the path, slashes included.
35
+ _PARAM_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)(\*?)\}")
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Request:
40
+ """One inbound request, already parsed enough for a handler to use.
41
+
42
+ ``body`` is read by the request loop before dispatch so a handler
43
+ never touches the socket — it is the raw bytes for an upload and
44
+ the JSON source for everything else.
45
+ """
46
+
47
+ method: str
48
+ path: str
49
+ headers: Any = None
50
+ body: bytes = b""
51
+ params: dict[str, str] = field(default_factory=dict)
52
+ query: dict[str, list[str]] = field(default_factory=dict)
53
+
54
+ def json(self) -> dict[str, Any]:
55
+ """The body as a dict. A malformed or non-object body reads as
56
+ ``{}`` — handlers validate the fields they need and return a
57
+ 400 naming the missing one, which is a better error than a
58
+ generic parse failure."""
59
+ if not self.body:
60
+ return {}
61
+ try:
62
+ data = json.loads(self.body.decode("utf-8"))
63
+ except (UnicodeDecodeError, json.JSONDecodeError):
64
+ return {}
65
+ return data if isinstance(data, dict) else {}
66
+
67
+ def header(self, name: str, default: str = "") -> str:
68
+ if self.headers is None:
69
+ return default
70
+ return self.headers.get(name, default) or default
71
+
72
+
73
+ def _compile(pattern: str) -> re.Pattern[str]:
74
+ parts: list[str] = []
75
+ last = 0
76
+ for m in _PARAM_RE.finditer(pattern):
77
+ parts.append(re.escape(pattern[last:m.start()]))
78
+ body = ".+" if m.group(2) else "[^/]+"
79
+ parts.append(f"(?P<{m.group(1)}>{body})")
80
+ last = m.end()
81
+ parts.append(re.escape(pattern[last:]))
82
+ return re.compile("^" + "".join(parts) + "$")
83
+
84
+
85
+ class Router:
86
+ """Route table. One instance per server; built once at start-up."""
87
+
88
+ def __init__(self) -> None:
89
+ self._literal: dict[tuple[str, str], Handler] = {}
90
+ self._patterned: list[tuple[str, re.Pattern[str], Handler]] = []
91
+
92
+ # -- registration ------------------------------------------------
93
+
94
+ def add(self, method: str, pattern: str, handler: Handler) -> None:
95
+ method = method.upper()
96
+ if _PARAM_RE.search(pattern):
97
+ self._patterned.append((method, _compile(pattern), handler))
98
+ else:
99
+ self._literal[(method, pattern)] = handler
100
+
101
+ def route(self, method: str, pattern: str) -> Callable[[Handler], Handler]:
102
+ def decorator(fn: Handler) -> Handler:
103
+ self.add(method, pattern, fn)
104
+ return fn
105
+ return decorator
106
+
107
+ def get(self, pattern: str) -> Callable[[Handler], Handler]:
108
+ return self.route("GET", pattern)
109
+
110
+ def post(self, pattern: str) -> Callable[[Handler], Handler]:
111
+ return self.route("POST", pattern)
112
+
113
+ def put(self, pattern: str) -> Callable[[Handler], Handler]:
114
+ return self.route("PUT", pattern)
115
+
116
+ def delete(self, pattern: str) -> Callable[[Handler], Handler]:
117
+ return self.route("DELETE", pattern)
118
+
119
+ # -- dispatch ----------------------------------------------------
120
+
121
+ def resolve(self, method: str, path: str) -> tuple[Handler, dict[str, str]] | None:
122
+ handler = self._literal.get((method, path))
123
+ if handler is not None:
124
+ return handler, {}
125
+ for route_method, regex, fn in self._patterned:
126
+ if route_method != method:
127
+ continue
128
+ m = regex.match(path)
129
+ if m:
130
+ return fn, m.groupdict()
131
+ return None
132
+
133
+ def dispatch(self, method: str, raw_path: str, headers: Any, body: bytes) -> Response:
134
+ """Find the handler for this request and run it.
135
+
136
+ Any exception a handler lets escape becomes a 500 carrying the
137
+ traceback, so one broken endpoint can't take the console down.
138
+ """
139
+ parsed = urllib.parse.urlparse(raw_path)
140
+ match = self.resolve(method, parsed.path)
141
+ if match is None:
142
+ return not_found()
143
+ handler, params = match
144
+ request = Request(
145
+ method=method,
146
+ path=parsed.path,
147
+ headers=headers,
148
+ body=body,
149
+ params=params,
150
+ query=urllib.parse.parse_qs(parsed.query),
151
+ )
152
+ try:
153
+ return handler(request)
154
+ except Exception as exc: # noqa: BLE001 — see docstring
155
+ return server_error(exc, context=f"{method} {parsed.path}")
156
+
157
+ @property
158
+ def routes(self) -> list[str]:
159
+ """``"GET /api/dashboard"`` for each registered route — handy in
160
+ a log line at start-up and when checking a new blueprint
161
+ actually registered."""
162
+ literal = [f"{m} {p}" for (m, p) in self._literal]
163
+ patterned = [f"{m} {r.pattern}" for (m, r, _) in self._patterned]
164
+ return sorted(literal + patterned)
165
+
166
+
167
+ __all__ = ["Handler", "Request", "Router"]