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
@@ -0,0 +1,85 @@
1
+ """Serve the ``static/`` tree.
2
+
3
+ The front end is a directory of ES modules and CSS partials rather than
4
+ two flat files, so this resolves an arbitrary relative path — but only
5
+ inside STATIC_DIR, and only for an allow-listed extension. A path that
6
+ resolves outside the tree (``../``, an absolute path, a symlink out) is
7
+ a 404, not a read.
8
+
9
+ Every response carries ``Cache-Control: no-store`` (set by the request
10
+ loop), and index.html gets the tree's newest mtime stamped onto its
11
+ entry-point URLs, so the browser always pulls current code after an
12
+ edit even if a proxy ignores the header.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from .responses import Response, not_found
21
+
22
+ STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
23
+
24
+ INDEX = "index.html"
25
+
26
+ #: Extension -> Content-Type. An extension absent from this map is not
27
+ #: servable, which is the allow-list.
28
+ CONTENT_TYPES = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".js": "application/javascript; charset=utf-8",
31
+ ".mjs": "application/javascript; charset=utf-8",
32
+ ".css": "text/css; charset=utf-8",
33
+ ".json": "application/json; charset=utf-8",
34
+ ".svg": "image/svg+xml",
35
+ ".png": "image/png",
36
+ ".ico": "image/x-icon",
37
+ ".woff": "font/woff",
38
+ ".woff2": "font/woff2",
39
+ }
40
+
41
+ #: URLs in index.html that get a ?v= cache-buster stamped on.
42
+ _VERSIONED = re.compile(r'((?:href|src)=")(/static/[^"?]+\.(?:css|js))(")')
43
+
44
+
45
+ def _tree_version() -> int:
46
+ """Newest mtime across the served tree. One number for the whole
47
+ front end: a change to any module busts the entry points, which is
48
+ what the browser actually re-requests."""
49
+ newest = 0
50
+ for path in STATIC_DIR.rglob("*"):
51
+ if path.suffix in CONTENT_TYPES and path.is_file():
52
+ newest = max(newest, int(path.stat().st_mtime))
53
+ return newest
54
+
55
+
56
+ def _resolve(rel: str) -> Path | None:
57
+ """Map a URL path to a file inside STATIC_DIR, or None if it escapes
58
+ the tree, has a non-servable extension, or doesn't exist."""
59
+ rel = rel.lstrip("/")
60
+ if not rel:
61
+ rel = INDEX
62
+ candidate = (STATIC_DIR / rel).resolve()
63
+ try:
64
+ candidate.relative_to(STATIC_DIR.resolve())
65
+ except ValueError:
66
+ return None # traversal attempt — outside the tree
67
+ if candidate.suffix not in CONTENT_TYPES or not candidate.is_file():
68
+ return None
69
+ return candidate
70
+
71
+
72
+ def serve(rel_path: str) -> Response:
73
+ """Read one file out of ``static/`` and wrap it in a Response."""
74
+ path = _resolve(rel_path)
75
+ if path is None:
76
+ return not_found()
77
+ body = path.read_bytes()
78
+ if path.name == INDEX:
79
+ version = _tree_version()
80
+ text = _VERSIONED.sub(rf"\g<1>\g<2>?v={version}\g<3>", body.decode("utf-8"))
81
+ body = text.encode("utf-8")
82
+ return Response(status=200, body=body, content_type=CONTENT_TYPES[path.suffix])
83
+
84
+
85
+ __all__ = ["CONTENT_TYPES", "INDEX", "STATIC_DIR", "serve"]
@@ -0,0 +1,66 @@
1
+ """Override mutations exposed by the web UI's drawer.
2
+
3
+ Three concerns, three modules:
4
+
5
+ rows the drawer's override table, the recommender's
6
+ one-click fixes, and the staged add/remove forms
7
+ airports list / add / remove international airport codes
8
+ break_time the floating mid-shift break length
9
+ filters write-through of the extraction-filter list
10
+ config_yaml the shared config.yml handle + write lock
11
+
12
+ ``airports``, ``break_time`` and ``filters`` edit ``configs/config.yml`` so the
13
+ engine picks the change up on the next run with no manual edit step.
14
+ The edits are targeted line splices rather than a YAML round-trip, so
15
+ the file's commentary survives.
16
+
17
+ Import from here — the sub-module split is an implementation detail.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .airports import (
23
+ add_intl_airport_code,
24
+ list_intl_airports,
25
+ preview_intl_airport_yaml_diff,
26
+ remove_intl_airport_code,
27
+ validate_intl_airport,
28
+ )
29
+ from .break_time import get_break_length, set_break_length
30
+ from .config_yaml import CONFIG_YML, config_write_lock
31
+ from .filters import (
32
+ add_extraction_filter,
33
+ list_extraction_filters,
34
+ remove_extraction_filter,
35
+ update_extraction_filter,
36
+ )
37
+ from .rows import (
38
+ add_override,
39
+ apply_phase_r_override,
40
+ clear_all_overrides,
41
+ delete_override,
42
+ update_override,
43
+ write_staged_override,
44
+ )
45
+
46
+ __all__ = [
47
+ "CONFIG_YML",
48
+ "add_extraction_filter",
49
+ "add_intl_airport_code",
50
+ "add_override",
51
+ "apply_phase_r_override",
52
+ "clear_all_overrides",
53
+ "config_write_lock",
54
+ "delete_override",
55
+ "get_break_length",
56
+ "list_extraction_filters",
57
+ "list_intl_airports",
58
+ "preview_intl_airport_yaml_diff",
59
+ "remove_extraction_filter",
60
+ "remove_intl_airport_code",
61
+ "set_break_length",
62
+ "update_extraction_filter",
63
+ "update_override",
64
+ "validate_intl_airport",
65
+ "write_staged_override",
66
+ ]
@@ -0,0 +1,261 @@
1
+ """The Setup sidebar's international airport codes: list, add, remove.
2
+
3
+ Edits ``io.sv_portal.international_airport_codes`` in
4
+ ``configs/config.yml`` so the engine picks the change up on the next run
5
+ with no manual edit step. Validation for an add is split out from the
6
+ write so the caller can preview the diff before anything touches the
7
+ file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ import yaml
15
+
16
+ from .config_yaml import CONFIG_YML, config_write_lock
17
+
18
+ _INTL_CODE_RE = re.compile(r"^[A-Z]{3}$")
19
+ _INTL_HEADER_RE = re.compile(r"^\s*international_airport_codes\s*:\s*(#.*)?$")
20
+ _INTL_ITEM_RE = re.compile(r"^(\s*-\s+)([A-Z]{3})\s*(?:#\s*(.*))?$")
21
+
22
+
23
+ def _find_intl_block(lines: list[str]) -> tuple[int, list[int]]:
24
+ """Locate the ``international_airport_codes`` list in config.yml.
25
+
26
+ Returns ``(header_idx, item_idxs)``: the header line and every
27
+ ``- XXX`` item line under it, in file order. ``header_idx`` is -1
28
+ when the section is missing.
29
+ """
30
+ header_idx = -1
31
+ item_idxs: list[int] = []
32
+ for i, ln in enumerate(lines):
33
+ if header_idx < 0:
34
+ if _INTL_HEADER_RE.match(ln):
35
+ header_idx = i
36
+ continue
37
+ if _INTL_ITEM_RE.match(ln):
38
+ item_idxs.append(i)
39
+ continue
40
+ # Stop scanning once we leave the list block: a line at
41
+ # less-or-equal indent than the header, or a new YAML key at
42
+ # the io.sv_portal level.
43
+ if ln.strip() and not ln.startswith(" " * 6):
44
+ break
45
+ return header_idx, item_idxs
46
+
47
+
48
+ def _read_existing_intl_codes() -> list[str]:
49
+ raw = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8"))
50
+ return list(
51
+ raw.get("io", {})
52
+ .get("sv_portal", {})
53
+ .get("international_airport_codes", [])
54
+ )
55
+
56
+
57
+ def _read_routing_drop_dep_codes() -> list[str]:
58
+ """Read ``io.sv_portal.routing_drop_dep_codes`` — Gulf-3 airports
59
+ whose departing flights are dropped at Step 1 extraction (Phase 3,
60
+ 2026-05-14, Change 1). Validation rejects adding these to the
61
+ international list (they're a different concept — dropped flights
62
+ can't be allocated as INTL).
63
+
64
+ Empty list when the key is missing; caller treats that as "no
65
+ exclusions configured."
66
+ """
67
+ raw = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8"))
68
+ return list(
69
+ raw.get("io", {})
70
+ .get("sv_portal", {})
71
+ .get("routing_drop_dep_codes", [])
72
+ )
73
+
74
+
75
+ def validate_intl_airport(
76
+ code: str, name: str | None = None,
77
+ ) -> tuple[str, str]:
78
+ """Sanitize + validate a proposed international-airport addition.
79
+
80
+ Returns ``(sanitized_code, sanitized_name)``. Raises ``ValueError``
81
+ with a user-facing message on any failure. The three checks are:
82
+
83
+ 1. Code matches the IATA 3-uppercase-letter convention.
84
+ 2. Code is not already in ``international_airport_codes``.
85
+ 3. Code is not in ``routing_drop_dep_codes`` (Gulf-3 — AUH/DOH/DXB
86
+ by default). Flights departing from those airports are dropped
87
+ at Step 1 (Phase 3 / Change 1, 2026-05-14), so classifying them
88
+ as INTL would never take effect.
89
+
90
+ Pure function: does NOT touch disk. Callers compose it with
91
+ ``_apply_intl_airport_to_yaml`` for the actual mutation, either
92
+ directly (the existing operator route) or via the approval chassis
93
+ (the new agent tool).
94
+ """
95
+ if not isinstance(code, str):
96
+ raise ValueError("code must be a string")
97
+ raw = code.strip()
98
+ # Strict check BEFORE upper-casing: Phase 2 spec rejects lowercase
99
+ # outright (it's an API contract violation — the UI uppercases on
100
+ # input so a lowercase request can only come from a script). This
101
+ # changed 2026-05-13; previous behavior silently upper-cased.
102
+ if not _INTL_CODE_RE.match(raw):
103
+ raise ValueError(
104
+ f"code must be exactly 3 uppercase letters (got {code!r})"
105
+ )
106
+ code = raw # already validated as uppercase
107
+ existing = {c.upper() for c in _read_existing_intl_codes()}
108
+ if code in existing:
109
+ raise ValueError(f"{code} is already in the international list")
110
+ drop_codes = {c.upper() for c in _read_routing_drop_dep_codes()}
111
+ if code in drop_codes:
112
+ raise ValueError(
113
+ f"{code} is in routing_drop_dep_codes (the Gulf-3 "
114
+ "AUH/DOH/DXB list). Flights departing from those airports "
115
+ "are dropped at Step 1, so adding the code to the INTL list "
116
+ "would have no effect. Use a different code or update "
117
+ "routing_drop_dep_codes directly."
118
+ )
119
+ cleaned_name = ""
120
+ if name:
121
+ cleaned_name = re.sub(r"[^\w\s\-/().,]", "", str(name)).strip()
122
+ return code, cleaned_name
123
+
124
+
125
+ def _apply_intl_airport_to_yaml(code: str, name: str = "") -> None:
126
+ """Insert ``code`` (with optional ``name`` as a trailing comment)
127
+ under ``international_airport_codes`` in ``configs/config.yml``.
128
+
129
+ Pure mutation: assumes the caller has already validated. The file
130
+ edit is a targeted line-insertion (not a YAML round-trip) so the
131
+ file's commentary survives. Locates the last existing entry under
132
+ the list header and inserts after it, matching that entry's
133
+ indentation.
134
+
135
+ Raises ``ValueError`` if the section can't be found at all — that's
136
+ a structural problem with the config, not a validation error, and
137
+ callers should bubble it up.
138
+ """
139
+ with config_write_lock:
140
+ text = CONFIG_YML.read_text(encoding="utf-8")
141
+ lines = text.splitlines(keepends=False)
142
+ header_idx, item_idxs = _find_intl_block(lines)
143
+ if header_idx < 0:
144
+ raise ValueError(
145
+ "could not find international_airport_codes section in config.yml"
146
+ )
147
+ item_prefix = " - " # fallback if no item present yet
148
+ last_item_idx = -1
149
+ if item_idxs:
150
+ last_item_idx = item_idxs[-1]
151
+ item_prefix = _INTL_ITEM_RE.match(lines[last_item_idx]).group(1)
152
+ insert_at = (last_item_idx if last_item_idx >= 0 else header_idx) + 1
153
+ new_line = f"{item_prefix}{code}"
154
+ if name:
155
+ new_line = f"{new_line} # {name}"
156
+ lines.insert(insert_at, new_line)
157
+ CONFIG_YML.write_text("\n".join(lines) + "\n", encoding="utf-8")
158
+
159
+
160
+ def preview_intl_airport_yaml_diff(code: str, name: str = "") -> str:
161
+ """Return a unified-diff-ish string of what ``_apply_intl_airport_to_yaml``
162
+ would do. Used by the agent approval preview.
163
+
164
+ Cheap: just rebuilds the would-be insert line and returns a small
165
+ diff string. Does NOT touch disk; safe to call inside the preview
166
+ builder.
167
+ """
168
+ item_prefix = " - "
169
+ new_line = f"{item_prefix}{code}"
170
+ if name:
171
+ new_line = f"{new_line} # {name}"
172
+ return (
173
+ f"--- configs/config.yml (before)\n"
174
+ f"+++ configs/config.yml (after)\n"
175
+ f"@@ io.sv_portal.international_airport_codes @@\n"
176
+ f"+{new_line}\n"
177
+ )
178
+
179
+
180
+ def add_intl_airport_code(code: str, name: str | None = None) -> dict[str, str]:
181
+ """Validate + mutate in one shot. Existing direct-write entry point
182
+ (kept for the override drawer's airport form / direct API route).
183
+
184
+ The agent tool (``src.agent.tools.add_intl_airport``) uses
185
+ the two underlying functions (``validate_intl_airport`` and
186
+ ``_apply_intl_airport_to_yaml``) separately so the approval chassis
187
+ can preview the diff before any write happens.
188
+ """
189
+ sanitized_code, sanitized_name = validate_intl_airport(code, name)
190
+ _apply_intl_airport_to_yaml(sanitized_code, sanitized_name)
191
+ return {"code": sanitized_code, "name": sanitized_name}
192
+
193
+
194
+ def list_intl_airports() -> list[dict[str, str]]:
195
+ """Every configured international airport, in file order, as
196
+ ``{"code", "name"}``. ``name`` is the trailing ``# comment`` the add
197
+ form writes, or "" when there is none.
198
+
199
+ The codes come from the parsed YAML — exactly what the engine will
200
+ read — and the names from the raw lines, since YAML drops comments.
201
+ """
202
+ text = CONFIG_YML.read_text(encoding="utf-8")
203
+ lines = text.splitlines(keepends=False)
204
+ _, item_idxs = _find_intl_block(lines)
205
+ names: dict[str, str] = {}
206
+ for i in item_idxs:
207
+ m = _INTL_ITEM_RE.match(lines[i])
208
+ names.setdefault(m.group(2), (m.group(3) or "").strip())
209
+ codes = (
210
+ (yaml.safe_load(text) or {})
211
+ .get("io", {})
212
+ .get("sv_portal", {})
213
+ .get("international_airport_codes")
214
+ ) or []
215
+ out: list[dict[str, str]] = []
216
+ seen: set[str] = set()
217
+ for c in codes:
218
+ code = str(c).strip().upper()
219
+ if not code or code in seen:
220
+ continue
221
+ seen.add(code)
222
+ out.append({"code": code, "name": names.get(code, "")})
223
+ return out
224
+
225
+
226
+ def remove_intl_airport_code(code: str) -> dict[str, str]:
227
+ """Delete ``code`` from ``international_airport_codes`` in
228
+ ``configs/config.yml``. Returns the removed ``{"code", "name"}``.
229
+
230
+ Same targeted line splice as the add path, so the file's commentary
231
+ survives. Raises ``ValueError`` with a user-facing message when the
232
+ code isn't in the list, or when it is the last one left — an empty
233
+ list would leave a bare ``international_airport_codes:`` key that
234
+ YAML reads as null.
235
+ """
236
+ if not isinstance(code, str) or not _INTL_CODE_RE.match(code.strip()):
237
+ raise ValueError(f"code must be exactly 3 uppercase letters (got {code!r})")
238
+ code = code.strip()
239
+ with config_write_lock:
240
+ text = CONFIG_YML.read_text(encoding="utf-8")
241
+ lines = text.splitlines(keepends=False)
242
+ header_idx, item_idxs = _find_intl_block(lines)
243
+ if header_idx < 0:
244
+ raise ValueError(
245
+ "could not find international_airport_codes section in config.yml"
246
+ )
247
+ matches = [
248
+ i for i in item_idxs if _INTL_ITEM_RE.match(lines[i]).group(2) == code
249
+ ]
250
+ if not matches:
251
+ raise ValueError(f"{code} is not in the international list")
252
+ if len(matches) == len(item_idxs):
253
+ raise ValueError(
254
+ f"{code} is the last international airport code. Add "
255
+ "another code before removing it."
256
+ )
257
+ name = (_INTL_ITEM_RE.match(lines[matches[0]]).group(3) or "").strip()
258
+ for i in reversed(matches):
259
+ del lines[i]
260
+ CONFIG_YML.write_text("\n".join(lines) + "\n", encoding="utf-8")
261
+ return {"code": code, "name": name}
@@ -0,0 +1,83 @@
1
+ """The Setup sidebar's shift break length.
2
+
3
+ Stored as ``break_pass.length_minutes`` in ``configs/config.yml`` and
4
+ read by the floating-break post-pass (``allocator/postpass_break.py``)
5
+ on every Allocate run. The write is a targeted line splice so the
6
+ file's commentary survives; a config written before the setting
7
+ existed gets the section appended.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from ...config import (
15
+ BREAK_MINUTES_HIGHEST,
16
+ BREAK_MINUTES_LOWEST,
17
+ load_config,
18
+ )
19
+ from .config_yaml import CONFIG_YML, config_write_lock
20
+
21
+ _SECTION_RE = re.compile(r"^break_pass\s*:\s*(#.*)?$")
22
+ _LENGTH_RE = re.compile(r"^(\s+length_minutes\s*:\s*)\d+(\s*(?:#.*)?)$")
23
+
24
+
25
+ def get_break_length() -> int:
26
+ """The break length the next Allocate run will use."""
27
+ return load_config(CONFIG_YML).break_pass.length_minutes
28
+
29
+
30
+ def validate_break_length(value: object) -> int:
31
+ """Whole minutes within the allowed band, or ``ValueError`` with a
32
+ user-facing message."""
33
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
34
+ raise ValueError("break length must be a whole number of minutes")
35
+ try:
36
+ minutes = int(str(value).strip())
37
+ except ValueError:
38
+ raise ValueError(
39
+ "break length must be a whole number of minutes"
40
+ ) from None
41
+ if not BREAK_MINUTES_LOWEST <= minutes <= BREAK_MINUTES_HIGHEST:
42
+ raise ValueError(
43
+ f"break length must be between {BREAK_MINUTES_LOWEST} and "
44
+ f"{BREAK_MINUTES_HIGHEST} minutes (got {minutes})"
45
+ )
46
+ return minutes
47
+
48
+
49
+ def set_break_length(value: object) -> int:
50
+ """Validate ``value`` and write it to config.yml. Returns the saved
51
+ minutes."""
52
+ minutes = validate_break_length(value)
53
+ with config_write_lock:
54
+ lines = CONFIG_YML.read_text(encoding="utf-8").splitlines(keepends=False)
55
+ header_idx = next(
56
+ (i for i, ln in enumerate(lines) if _SECTION_RE.match(ln)), -1,
57
+ )
58
+ if header_idx < 0:
59
+ lines += [
60
+ "",
61
+ "# Floating mid-shift break (allocator/postpass_break.py).",
62
+ "# Editable from the Setup sidebar.",
63
+ "break_pass:",
64
+ f" length_minutes: {minutes}",
65
+ ]
66
+ else:
67
+ length_idx = -1
68
+ for i in range(header_idx + 1, len(lines)):
69
+ ln = lines[i]
70
+ if ln.strip() and not ln[0].isspace():
71
+ break # next top-level key: left the section
72
+ if _LENGTH_RE.match(ln):
73
+ length_idx = i
74
+ break
75
+ if length_idx >= 0:
76
+ lines[length_idx] = _LENGTH_RE.sub(
77
+ lambda m: f"{m.group(1)}{minutes}{m.group(2)}",
78
+ lines[length_idx],
79
+ )
80
+ else:
81
+ lines.insert(header_idx + 1, f" length_minutes: {minutes}")
82
+ CONFIG_YML.write_text("\n".join(lines) + "\n", encoding="utf-8")
83
+ return minutes
@@ -0,0 +1,21 @@
1
+ """Shared handle on ``configs/config.yml`` for the drawer's write-through
2
+ edits.
3
+
4
+ The edits here are targeted line splices rather than a YAML round-trip,
5
+ so the file's commentary survives a save. Every writer takes
6
+ ``config_write_lock`` first — two drawer forms submitted back to back
7
+ land on different request threads.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from pathlib import Path
14
+
15
+ #: Absolute path, so a write works whatever CWD the server was launched from.
16
+ CONFIG_YML = Path(__file__).resolve().parents[3] / "configs" / "config.yml"
17
+
18
+ #: Serialises every read-modify-write against CONFIG_YML.
19
+ config_write_lock = threading.Lock()
20
+
21
+ __all__ = ["CONFIG_YML", "config_write_lock"]