estimating-tools 0.1.87__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 (156) hide show
  1. estimating_tools/__init__.py +7 -0
  2. estimating_tools/artifact_app/__init__.py +1 -0
  3. estimating_tools/artifact_app/app.html +195 -0
  4. estimating_tools/artifact_app/browser.py +194 -0
  5. estimating_tools/artifact_app/server.py +311 -0
  6. estimating_tools/artifact_common.py +56 -0
  7. estimating_tools/artifacts.py +28 -0
  8. estimating_tools/artifacts_html.py +391 -0
  9. estimating_tools/assemblies/__init__.py +27 -0
  10. estimating_tools/assemblies/artifact.py +420 -0
  11. estimating_tools/assemblies/cli.py +54 -0
  12. estimating_tools/assemblies/excel.py +293 -0
  13. estimating_tools/assemblies/load_cli.py +72 -0
  14. estimating_tools/assemblies/load_model.py +69 -0
  15. estimating_tools/assemblies/model.py +42 -0
  16. estimating_tools/assemblies/plan.py +396 -0
  17. estimating_tools/auth/__init__.py +0 -0
  18. estimating_tools/auth/cli.py +113 -0
  19. estimating_tools/auth/m2m.py +55 -0
  20. estimating_tools/calcbook/__init__.py +36 -0
  21. estimating_tools/calcbook/formula.py +57 -0
  22. estimating_tools/calcbook/recalc.py +45 -0
  23. estimating_tools/calcbook/writer.py +148 -0
  24. estimating_tools/costcodes/__init__.py +16 -0
  25. estimating_tools/costcodes/cli.py +71 -0
  26. estimating_tools/costcodes/excel.py +213 -0
  27. estimating_tools/costcodes/model.py +51 -0
  28. estimating_tools/costcodes/resolver.py +163 -0
  29. estimating_tools/costcodes/strategies.py +131 -0
  30. estimating_tools/directory/__init__.py +12 -0
  31. estimating_tools/directory/cli.py +52 -0
  32. estimating_tools/directory/diff.py +225 -0
  33. estimating_tools/directory/excel.py +141 -0
  34. estimating_tools/directory/model.py +63 -0
  35. estimating_tools/directory/sync_cli.py +57 -0
  36. estimating_tools/docgen/__init__.py +0 -0
  37. estimating_tools/docgen/docxtpl_engine.py +103 -0
  38. estimating_tools/docgen/letterhead_cli.py +111 -0
  39. estimating_tools/docgen/report_engine.py +445 -0
  40. estimating_tools/docgen/xlsx_populate.py +40 -0
  41. estimating_tools/docgen/yaml_tree.py +50 -0
  42. estimating_tools/estimate_spec/__init__.py +0 -0
  43. estimating_tools/estimate_spec/_shared.py +20 -0
  44. estimating_tools/estimate_spec/apply.py +286 -0
  45. estimating_tools/estimate_spec/artifact.py +878 -0
  46. estimating_tools/estimate_spec/build.py +709 -0
  47. estimating_tools/estimate_spec/cip.py +518 -0
  48. estimating_tools/estimate_spec/cli.py +496 -0
  49. estimating_tools/estimate_spec/compact.py +525 -0
  50. estimating_tools/estimate_spec/format_pack.py +359 -0
  51. estimating_tools/estimate_spec/packs/README.md +173 -0
  52. estimating_tools/estimate_spec/packs/demo/examples/demo-pipeline.estimate.yaml +91 -0
  53. estimating_tools/estimate_spec/packs/demo/format.yaml +23 -0
  54. estimating_tools/estimate_spec/packs/demo/recipes.yaml +61 -0
  55. estimating_tools/estimate_spec/packs/demo/seed.yaml +45 -0
  56. estimating_tools/estimate_spec/packs/demo/tokens.yaml +121 -0
  57. estimating_tools/estimate_spec/packs/demo/units.yaml +218 -0
  58. estimating_tools/estimate_spec/packs/demo/vocabulary.yaml +51 -0
  59. estimating_tools/estimate_spec/recipe.py +207 -0
  60. estimating_tools/estimate_spec/scaffold.py +506 -0
  61. estimating_tools/estimate_spec/scaffold_save.py +98 -0
  62. estimating_tools/estimate_spec/schema/skeleton.schema.json +208 -0
  63. estimating_tools/estimate_spec/summarize.py +433 -0
  64. estimating_tools/estimate_spec/sync.py +380 -0
  65. estimating_tools/estimate_spec/sync_executor.py +405 -0
  66. estimating_tools/estimate_spec/validate.py +75 -0
  67. estimating_tools/excel/__init__.py +0 -0
  68. estimating_tools/excel/excel_reader.py +119 -0
  69. estimating_tools/historicals/__init__.py +0 -0
  70. estimating_tools/historicals/cli.py +188 -0
  71. estimating_tools/historicals/estimates.py +86 -0
  72. estimating_tools/historicals/model.py +141 -0
  73. estimating_tools/historicals/suggest.py +151 -0
  74. estimating_tools/historicals/tabs.py +182 -0
  75. estimating_tools/importers/__init__.py +11 -0
  76. estimating_tools/importers/cli.py +51 -0
  77. estimating_tools/importers/hcss.py +347 -0
  78. estimating_tools/importers/mapping_artifact.py +184 -0
  79. estimating_tools/importers/model.py +49 -0
  80. estimating_tools/importers/pricing.py +102 -0
  81. estimating_tools/importers/push.py +137 -0
  82. estimating_tools/importers/registry.py +35 -0
  83. estimating_tools/importers/schema/canonical-estimate.schema.json +47 -0
  84. estimating_tools/importers/validate.py +20 -0
  85. estimating_tools/intake/__init__.py +4 -0
  86. estimating_tools/intake/bid_frame.py +124 -0
  87. estimating_tools/intake/briefing.py +87 -0
  88. estimating_tools/intake/cli.py +237 -0
  89. estimating_tools/intake/manifest.py +111 -0
  90. estimating_tools/pdf/__init__.py +0 -0
  91. estimating_tools/pdf/extract_text.py +157 -0
  92. estimating_tools/profiles/__init__.py +15 -0
  93. estimating_tools/profiles/build.py +109 -0
  94. estimating_tools/profiles/cli.py +73 -0
  95. estimating_tools/profiles/loader.py +94 -0
  96. estimating_tools/profiles/merge.py +25 -0
  97. estimating_tools/profiles/model.py +22 -0
  98. estimating_tools/profiles/scaffold.py +82 -0
  99. estimating_tools/profiles/schema/profile.schema.json +69 -0
  100. estimating_tools/profiles/trees/work-bid-layout.yaml +14 -0
  101. estimating_tools/profiles/trees/work-project-layout.yaml +9 -0
  102. estimating_tools/profiles/wires.py +57 -0
  103. estimating_tools/quotes/__init__.py +16 -0
  104. estimating_tools/quotes/model.py +32 -0
  105. estimating_tools/quotes/plan.py +121 -0
  106. estimating_tools/quotes/revision.py +58 -0
  107. estimating_tools/quotespace/__init__.py +0 -0
  108. estimating_tools/quotespace/cli.py +277 -0
  109. estimating_tools/quotespace/conditions.py +111 -0
  110. estimating_tools/quotespace/coverage.py +177 -0
  111. estimating_tools/quotespace/itb.py +64 -0
  112. estimating_tools/quotespace/model.py +143 -0
  113. estimating_tools/rates/__init__.py +13 -0
  114. estimating_tools/rates/cli.py +52 -0
  115. estimating_tools/rates/excel.py +151 -0
  116. estimating_tools/rates/model.py +56 -0
  117. estimating_tools/rebar/__init__.py +4 -0
  118. estimating_tools/rebar/cli.py +202 -0
  119. estimating_tools/rebar/detailing.py +68 -0
  120. estimating_tools/rebar/develop.py +54 -0
  121. estimating_tools/rebar/ifc.py +223 -0
  122. estimating_tools/rebar/model.py +69 -0
  123. estimating_tools/rebar/schedule.py +205 -0
  124. estimating_tools/reconcile/__init__.py +22 -0
  125. estimating_tools/reconcile/diff.py +229 -0
  126. estimating_tools/schedule/__init__.py +0 -0
  127. estimating_tools/schedule/cli.py +227 -0
  128. estimating_tools/schedule/costload.py +180 -0
  129. estimating_tools/schedule/gantt_to_ganttwarrior.py +327 -0
  130. estimating_tools/schedule/importers.py +13 -0
  131. estimating_tools/schedule/model.py +9 -0
  132. estimating_tools/schedule/render.py +73 -0
  133. estimating_tools/schedule/scope.py +225 -0
  134. estimating_tools/schedule/xer.py +371 -0
  135. estimating_tools/spreads/__init__.py +16 -0
  136. estimating_tools/spreads/award.py +184 -0
  137. estimating_tools/spreads/conditions.py +251 -0
  138. estimating_tools/spreads/item_quotes.py +96 -0
  139. estimating_tools/spreads/level.py +139 -0
  140. estimating_tools/spreads/model.py +83 -0
  141. estimating_tools/spreads/render.py +156 -0
  142. estimating_tools/takeoff/__init__.py +0 -0
  143. estimating_tools/takeoff/audit.py +556 -0
  144. estimating_tools/takeoff/audit_xlsx.py +127 -0
  145. estimating_tools/takeoff/cli.py +180 -0
  146. estimating_tools/takeoff/core.py +165 -0
  147. estimating_tools/takeoff/earthwork.py +159 -0
  148. estimating_tools/takeoff/model.py +79 -0
  149. estimating_tools/takeoff/push.py +409 -0
  150. estimating_tools/xlsx_determinism.py +38 -0
  151. estimating_tools-0.1.87.dist-info/METADATA +72 -0
  152. estimating_tools-0.1.87.dist-info/RECORD +156 -0
  153. estimating_tools-0.1.87.dist-info/WHEEL +5 -0
  154. estimating_tools-0.1.87.dist-info/entry_points.txt +24 -0
  155. estimating_tools-0.1.87.dist-info/licenses/LICENSE +202 -0
  156. estimating_tools-0.1.87.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """Deterministic construction estimating and engineering tools."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("estimating_tools")
6
+ except PackageNotFoundError: # running from source without an install
7
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1 @@
1
+ """MCP App surface: stdio server + static editor app (io.modelcontextprotocol/ui)."""
@@ -0,0 +1,195 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Estimating artifact editor</title>
7
+ <style>
8
+ body{font:14px/1.45 system-ui,"Segoe UI",sans-serif;margin:0;color:#1c2422;background:#fff}
9
+ main{max-width:1100px;margin:0 auto;padding:0 20px 60px}
10
+ header{position:sticky;top:0;background:#fff;border-bottom:1px solid #e2e6e4;padding:10px 20px;display:flex;gap:12px;align-items:center;z-index:2}
11
+ header h1{font-size:17px;margin:0;flex:1}
12
+ h2{font-size:15px;margin:18px 0 6px}
13
+ h3,h4,h5,h6{font-size:14px;margin:14px 0 4px}
14
+ table{border-collapse:collapse;margin:10px 0;width:100%}
15
+ th,td{border:1px solid #d8dedb;padding:4px 8px;text-align:left;font-variant-numeric:tabular-nums;font-size:13px}
16
+ th{background:#eef1ef;font-size:12px}
17
+ td[contenteditable]:focus{outline:2px solid #0e6e63;background:#f4faf9}
18
+ .ext{background:#f7f4ec;color:#6b5d33;font-style:italic}
19
+ th.ext span{display:block;font-size:9px;font-weight:400;text-transform:uppercase;letter-spacing:.05em}
20
+ td.ctl,th.ctl{border:none;background:none;width:26px;padding:0;text-align:center}
21
+ .del{border:none;background:none;color:#b04a3a;cursor:pointer;font-size:14px}
22
+ .add{margin:0 0 14px;border:1px dashed #9aa5a1;background:none;border-radius:4px;padding:2px 10px;cursor:pointer;color:#55605c;font-size:12px}
23
+ #copy{background:#0e6e63;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600}
24
+ #out{display:none;width:100%;height:150px;margin:10px 0;font:12px/1.4 Consolas,monospace}
25
+ blockquote{border-left:3px solid #0e6e63;margin:8px 0;padding:4px 10px;background:#f2f5f4}
26
+ .hint{color:#55605c;font-size:12px;font-style:italic}
27
+ tfoot td{font-weight:600}
28
+ ul{margin:6px 0;padding-left:22px}
29
+ #save{background:#0e6e63;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600}
30
+ #copy{background:none;color:#0e6e63;border:1px solid #0e6e63}
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <header><h1 id="title">Artifact editor</h1><span id="toolbar"></span></header>
35
+ <main>
36
+ <textarea id="out" readonly></textarea>
37
+ <div id="body"><p class="hint">Waiting for the artifact…</p></div>
38
+ </main>
39
+ <script>
40
+ /* ---- MCP Apps postMessage bridge (app <-> host) ---- */
41
+ let DOC = {lines: [], tables: []};
42
+ let nextId = 1;
43
+ const pending = new Map();
44
+ function send(msg) { window.parent.postMessage(msg, "*"); }
45
+ function request(method, params) {
46
+ return new Promise((resolve, reject) => {
47
+ const id = nextId++;
48
+ pending.set(id, {resolve, reject});
49
+ send({jsonrpc: "2.0", id, method, params});
50
+ });
51
+ }
52
+ function notify(method, params) { send({jsonrpc: "2.0", method, params}); }
53
+ window.addEventListener("message", ev => {
54
+ if (ev.source !== window.parent) return;
55
+ const m = ev.data;
56
+ if (!m || m.jsonrpc !== "2.0") return;
57
+ if (m.id !== undefined && m.method === undefined) { /* response */
58
+ const p = pending.get(m.id); if (!p) return;
59
+ pending.delete(m.id);
60
+ if (m.error) p.reject(m.error); else p.resolve(m.result);
61
+ return;
62
+ }
63
+ if (m.method === "ui/notifications/tool-input") return; /* render comes from tool-result */
64
+ if (m.method === "ui/notifications/tool-result") {
65
+ const sc = m.params && m.params.structuredContent;
66
+ if (sc) initDoc(sc);
67
+ return;
68
+ }
69
+ if (m.method === "ui/resource-teardown" && m.id !== undefined) {
70
+ send({jsonrpc: "2.0", id: m.id, result: {}});
71
+ }
72
+ });
73
+ request("ui/initialize", {
74
+ appInfo: {name: "estimating-artifact-editor", version: "1.0.0"},
75
+ appCapabilities: {},
76
+ protocolVersion: "2026-01-26",
77
+ }).then(res => {
78
+ const theme = res && res.hostContext && res.hostContext.theme;
79
+ if (theme) document.documentElement.dataset.theme = theme;
80
+ notify("ui/notifications/initialized", {});
81
+ }, () => {});
82
+ function initDoc(sc) {
83
+ DOC = {lines: sc.lines, tables: sc.tables};
84
+ document.title = sc.title || "Artifact";
85
+ document.getElementById("title").textContent = sc.title || "Artifact";
86
+ document.getElementById("body").innerHTML = sc.body; /* server-escaped fragment */
87
+ const tb = document.getElementById("toolbar");
88
+ tb.textContent = "";
89
+ if (sc.editable) {
90
+ const save = document.createElement("button");
91
+ save.id = "save"; save.textContent = "Save & send to chat";
92
+ const copy = document.createElement("button");
93
+ copy.id = "copy"; copy.textContent = "Copy edited artifact";
94
+ tb.append(save, copy);
95
+ }
96
+ recompute();
97
+ }
98
+ document.addEventListener("click", e => {
99
+ if (e.target.id === "save") {
100
+ const md = exportMarkdown();
101
+ e.target.disabled = true;
102
+ request("ui/message", {role: "user", content: [{type: "text", text: md}]}).then(
103
+ res => {
104
+ if (res && res.isError) { /* McpUiMessageResult soft failure */
105
+ e.target.disabled = false; copyOut(e.target, md); return;
106
+ }
107
+ e.target.textContent = "Sent ✓ — check the chat";
108
+ e.target.disabled = false; /* repeat Saves are supported */
109
+ },
110
+ () => { e.target.disabled = false; copyOut(e.target, md); });
111
+ }
112
+ });
113
+ /* GRID-CORE begin — byte-identical across surfaces (test-enforced) */
114
+ function dataCells(tr) {
115
+ return [...tr.querySelectorAll("td")].filter(
116
+ td => !td.classList.contains("ext") && !td.classList.contains("ctl"));
117
+ }
118
+ function cellText(td) { return td.innerText.replace(/\n/g, " ").trim(); }
119
+ function serializeRow(cells) {
120
+ return "| " + cells.map(c => c.replace(/\|/g, "\\|")).join(" | ") + " |";
121
+ }
122
+ function exportMarkdown() {
123
+ const lines = DOC.lines.slice();
124
+ [...DOC.tables].sort((a, b) => b.start - a.start).forEach(t => {
125
+ const body = [...document.querySelectorAll(
126
+ 'table[data-t="' + t.t + '"] tbody tr')].map(
127
+ tr => serializeRow(dataCells(tr).map(cellText)));
128
+ lines.splice(t.start + 2, t.end - t.start - 2, ...body);
129
+ });
130
+ return lines.join("\n");
131
+ }
132
+ function money(x) { return isFinite(x) ? x.toFixed(2) : ""; }
133
+ function recompute() {
134
+ DOC.tables.forEach(t => {
135
+ if (!t.computed) return;
136
+ let total = 0;
137
+ document.querySelectorAll('table[data-t="' + t.t + '"] tbody tr').forEach(tr => {
138
+ const cells = dataCells(tr);
139
+ const ext = tr.querySelector("td.ext");
140
+ const v = parseFloat(cellText(cells[t.computed[0]]).replace(/,/g, "")) *
141
+ parseFloat(cellText(cells[t.computed[1]]).replace(/,/g, ""));
142
+ if (isFinite(v)) { ext.textContent = money(v); total += v; }
143
+ else ext.textContent = "";
144
+ });
145
+ const tot = document.querySelector('table[data-t="' + t.t + '"] tfoot .total');
146
+ if (tot) tot.textContent = money(total);
147
+ });
148
+ }
149
+ function copyOut(btn, md) {
150
+ const out = document.getElementById("out");
151
+ out.value = md; out.style.display = "block";
152
+ const done = () => { btn.textContent = "Copied ✓ — paste into the chat"; };
153
+ const fallback = () => {
154
+ btn.textContent = "Copy the block below, then paste into the chat";
155
+ out.select();
156
+ };
157
+ if (navigator.clipboard && navigator.clipboard.writeText) {
158
+ navigator.clipboard.writeText(md).then(done, fallback);
159
+ } else { fallback(); }
160
+ }
161
+ document.addEventListener("input", recompute);
162
+ document.addEventListener("focusin", e => {
163
+ /* select the cell's contents on focus: typing replaces the value (so the
164
+ add-row "-" placeholder never concatenates), tabbing through loses nothing */
165
+ const td = e.target;
166
+ if (!td.isContentEditable || td.tagName !== "TD") return;
167
+ const sel = window.getSelection(), r = document.createRange();
168
+ r.selectNodeContents(td); sel.removeAllRanges(); sel.addRange(r);
169
+ });
170
+ document.addEventListener("click", e => {
171
+ if (e.target.classList.contains("del")) {
172
+ e.target.closest("tr").remove(); recompute(); return;
173
+ }
174
+ if (e.target.classList.contains("add")) {
175
+ const t = DOC.tables.find(x => String(x.t) === e.target.dataset.t);
176
+ const tr = document.createElement("tr");
177
+ for (let i = 0; i < t.width; i++) {
178
+ const td = document.createElement("td");
179
+ td.contentEditable = "true"; td.textContent = "-"; tr.appendChild(td);
180
+ }
181
+ if (t.computed) {
182
+ const td = document.createElement("td"); td.className = "ext"; tr.appendChild(td);
183
+ }
184
+ const ctl = document.createElement("td"); ctl.className = "ctl";
185
+ ctl.innerHTML = '<button class="del" title="delete row">×</button>';
186
+ tr.appendChild(ctl);
187
+ document.querySelector('table[data-t="' + t.t + '"] tbody').appendChild(tr);
188
+ recompute(); return;
189
+ }
190
+ if (e.target.id === "copy") { copyOut(e.target, exportMarkdown()); }
191
+ });
192
+ /* GRID-CORE end */
193
+ </script>
194
+ </body>
195
+ </html>
@@ -0,0 +1,194 @@
1
+ """Loopback browser surface for the artifact editor (non-Apps hosts).
2
+
3
+ One process-lifetime HTTP server on 127.0.0.1:<random port>. Each
4
+ ``open_session`` mints a token-guarded session and supersedes the previous one
5
+ (stale saves answer 409 so the page's copy fallback engages). Loopback bind +
6
+ random URL token + Host-header check keep other local processes, other tabs,
7
+ and DNS-rebinding pages out. Pure stdlib; the async MCP server awaits the
8
+ threading.Event via ``anyio.to_thread``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ import threading
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+
17
+ _MAX_SAVE_BYTES = 4 * 1024 * 1024
18
+
19
+
20
+ class EditSession:
21
+ def __init__(self, page_html: str) -> None:
22
+ self.token = secrets.token_urlsafe(16)
23
+ self.page_html = page_html
24
+ self.url = "" # set by open_session
25
+ self.saved_markdown: str | None = None
26
+ self._saved = threading.Event()
27
+ self._lock = threading.Lock()
28
+
29
+ def record_save(self, markdown: str) -> None:
30
+ with self._lock:
31
+ self.saved_markdown = markdown # repeat saves: latest wins
32
+ self._saved.set()
33
+
34
+ def wait_for_save(self, timeout: float) -> str | None:
35
+ if self._saved.wait(timeout):
36
+ with self._lock:
37
+ return self.saved_markdown
38
+ return None
39
+
40
+
41
+ _lock = threading.Lock()
42
+ _server: ThreadingHTTPServer | None = None
43
+ _session: EditSession | None = None
44
+ # Single-generation memory: only the immediately-previous session gets the
45
+ # tailored 409; older tabs fall through to 404 and the page's copy fallback.
46
+ _prev_token: str | None = None
47
+
48
+
49
+ class _Handler(BaseHTTPRequestHandler):
50
+ def log_message(self, *args) -> None: # stdio server: keep stderr quiet
51
+ pass
52
+
53
+ def _reply(self, code: int, body: str, ctype: str = "text/plain") -> None:
54
+ data = body.encode("utf-8")
55
+ self.send_response(code)
56
+ self.send_header("Content-Type", f"{ctype}; charset=utf-8")
57
+ self.send_header("Content-Length", str(len(data)))
58
+ self.send_header("Cache-Control", "no-store")
59
+ self.end_headers()
60
+ self.wfile.write(data)
61
+
62
+ def _drain_body(self) -> None:
63
+ """Read and discard the declared request body before an error reply.
64
+
65
+ Windows resets the connection if we close with unread data, so the
66
+ client would see ECONNABORTED instead of our status code. Bounded by
67
+ the declared Content-Length; chunked to avoid one giant allocation.
68
+ Capped at 2 * _MAX_SAVE_BYTES: legitimate clients (even a 413-bound
69
+ save just over the limit) stay under the cap and get clean replies;
70
+ a liar declaring more gets a fail-fast close instead of streaming
71
+ forever through the 403/404/409 paths.
72
+ """
73
+ try:
74
+ remaining = int(self.headers.get("Content-Length") or 0)
75
+ except ValueError:
76
+ self.close_connection = True # undrainable: unknown length
77
+ return
78
+ if remaining > 2 * _MAX_SAVE_BYTES:
79
+ self.close_connection = True
80
+ return
81
+ while remaining > 0:
82
+ chunk = self.rfile.read(min(remaining, 65536))
83
+ if not chunk:
84
+ break
85
+ remaining -= len(chunk)
86
+
87
+ def _gate(self) -> EditSession | None:
88
+ """Return the live session for this request path, else reply and None."""
89
+ host = (self.headers.get("Host") or "").rsplit(":", 1)[0]
90
+ if host not in ("127.0.0.1", "localhost"):
91
+ self._drain_body()
92
+ self._reply(403, "forbidden")
93
+ return None
94
+ with _lock:
95
+ session, prev = _session, _prev_token
96
+ token = self.path.split("/", 2)[1] if self.path.count("/") >= 2 else ""
97
+ # compare_digest: constant-time; bytes form tolerates non-ASCII paths.
98
+ token_b = token.encode("utf-8")
99
+ if session is not None and secrets.compare_digest(token_b, session.token.encode("utf-8")):
100
+ return session
101
+ self._drain_body()
102
+ if prev is not None and secrets.compare_digest(token_b, prev.encode("utf-8")):
103
+ self._reply(409, "session superseded — reopen the editor from the conversation")
104
+ else:
105
+ self._reply(404, "not found")
106
+ return None
107
+
108
+ def do_GET(self) -> None:
109
+ session = self._gate()
110
+ if session is None:
111
+ return
112
+ if self.path == f"/{session.token}/":
113
+ self._reply(200, session.page_html, ctype="text/html")
114
+ else:
115
+ self._drain_body()
116
+ self._reply(404, "not found")
117
+
118
+ def do_POST(self) -> None:
119
+ session = self._gate()
120
+ if session is None:
121
+ return
122
+ if self.path != f"/{session.token}/save":
123
+ self._drain_body()
124
+ self._reply(404, "not found")
125
+ return
126
+ try:
127
+ length = int(self.headers.get("Content-Length") or 0)
128
+ except ValueError:
129
+ # Unknown length: can't drain meaningfully — reply and hard-close.
130
+ self.close_connection = True
131
+ self._reply(400, "bad request")
132
+ return
133
+ if length < 0:
134
+ self.close_connection = True
135
+ self._reply(400, "bad request")
136
+ return
137
+ if length > _MAX_SAVE_BYTES:
138
+ self._drain_body() # drain before the reply (RST fix)
139
+ self._reply(413, "artifact too large")
140
+ return
141
+ try:
142
+ markdown = self.rfile.read(length).decode("utf-8")
143
+ except UnicodeDecodeError:
144
+ self._reply(400, "bad request") # body fully read — no drain needed
145
+ return
146
+ session.record_save(markdown)
147
+ self._reply(200, "saved")
148
+
149
+
150
+ def _start_server() -> ThreadingHTTPServer:
151
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
152
+ threading.Thread(target=server.serve_forever, daemon=True).start()
153
+ return server
154
+
155
+
156
+ def open_session(page_html: str) -> tuple[EditSession, str]:
157
+ """Serve ``page_html`` under a fresh token, superseding any prior session."""
158
+ global _server, _session, _prev_token
159
+ with _lock:
160
+ if _server is None:
161
+ _server = _start_server()
162
+ if _session is not None:
163
+ _prev_token = _session.token
164
+ session = EditSession(page_html)
165
+ port = _server.server_address[1]
166
+ session.url = f"http://127.0.0.1:{port}/{session.token}/"
167
+ _session = session
168
+ return session, session.url
169
+
170
+
171
+ def current_session() -> EditSession | None:
172
+ with _lock:
173
+ return _session
174
+
175
+
176
+ def await_save(session: EditSession, timeout: float) -> str | None:
177
+ return session.wait_for_save(timeout)
178
+
179
+
180
+ def open_browser(url: str) -> bool:
181
+ try:
182
+ return webbrowser.open(url)
183
+ except Exception:
184
+ return False
185
+
186
+
187
+ def shutdown() -> None:
188
+ """Tear down server + sessions (tests; harmless if never started)."""
189
+ global _server, _session, _prev_token
190
+ with _lock:
191
+ server, _server, _session, _prev_token = _server, None, None, None
192
+ if server is not None:
193
+ server.shutdown()
194
+ server.server_close()