focus-data-toolkit 0.11.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 (116) hide show
  1. focus_data_toolkit/__init__.py +69 -0
  2. focus_data_toolkit/__main__.py +6 -0
  3. focus_data_toolkit/_version.py +8 -0
  4. focus_data_toolkit/cli.py +968 -0
  5. focus_data_toolkit/context/__init__.py +88 -0
  6. focus_data_toolkit/context/billing.py +54 -0
  7. focus_data_toolkit/context/provider.py +90 -0
  8. focus_data_toolkit/convert/__init__.py +708 -0
  9. focus_data_toolkit/convert/billing_period.py +65 -0
  10. focus_data_toolkit/convert/contract_applied.py +235 -0
  11. focus_data_toolkit/convert/contract_commitment.py +182 -0
  12. focus_data_toolkit/convert/cost_and_usage.py +179 -0
  13. focus_data_toolkit/convert/detect.py +39 -0
  14. focus_data_toolkit/convert/invoice_detail.py +199 -0
  15. focus_data_toolkit/convert/streaming.py +1030 -0
  16. focus_data_toolkit/errors.py +145 -0
  17. focus_data_toolkit/focus_json.py +68 -0
  18. focus_data_toolkit/generators/__init__.py +61 -0
  19. focus_data_toolkit/generators/_shim.py +43 -0
  20. focus_data_toolkit/generators/engine/__init__.py +14 -0
  21. focus_data_toolkit/generators/engine/context.py +12 -0
  22. focus_data_toolkit/generators/engine/determinism.py +117 -0
  23. focus_data_toolkit/generators/engine/json_focus.py +63 -0
  24. focus_data_toolkit/generators/engine/ladder.py +71 -0
  25. focus_data_toolkit/generators/engine/scenarios_core.py +380 -0
  26. focus_data_toolkit/generators/engine/serialize.py +151 -0
  27. focus_data_toolkit/generators/generate_aws_focus_1_2.py +19 -0
  28. focus_data_toolkit/generators/generate_aws_focus_1_3.py +20 -0
  29. focus_data_toolkit/generators/generate_azure_focus_1_2.py +17 -0
  30. focus_data_toolkit/generators/generate_azure_focus_1_3.py +17 -0
  31. focus_data_toolkit/generators/generate_gcp_focus_1_2.py +17 -0
  32. focus_data_toolkit/generators/generate_gcp_focus_1_3.py +17 -0
  33. focus_data_toolkit/generators/providers/__init__.py +29 -0
  34. focus_data_toolkit/generators/providers/aws.py +186 -0
  35. focus_data_toolkit/generators/providers/azure.py +191 -0
  36. focus_data_toolkit/generators/providers/gcp.py +194 -0
  37. focus_data_toolkit/generators/providers/profile.py +123 -0
  38. focus_data_toolkit/generators/scenarios.py +178 -0
  39. focus_data_toolkit/generators/versions/__init__.py +17 -0
  40. focus_data_toolkit/generators/versions/adapter.py +41 -0
  41. focus_data_toolkit/generators/versions/v1_2.py +111 -0
  42. focus_data_toolkit/generators/versions/v1_3.py +154 -0
  43. focus_data_toolkit/io/__init__.py +1 -0
  44. focus_data_toolkit/io/atomic_writer.py +462 -0
  45. focus_data_toolkit/io/csv_io.py +128 -0
  46. focus_data_toolkit/io/parquet_io.py +528 -0
  47. focus_data_toolkit/io/records.py +92 -0
  48. focus_data_toolkit/io/row_source.py +117 -0
  49. focus_data_toolkit/lifecycle.py +342 -0
  50. focus_data_toolkit/manifest.py +114 -0
  51. focus_data_toolkit/model/__init__.py +43 -0
  52. focus_data_toolkit/model/capabilities.py +66 -0
  53. focus_data_toolkit/model/focus_1_4_decimal_scale.json +10 -0
  54. focus_data_toolkit/model/focus_1_4_model.json +1913 -0
  55. focus_data_toolkit/model/focus_1_4_servicesubcategory.json +84 -0
  56. focus_data_toolkit/model/focus_json_keys.py +112 -0
  57. focus_data_toolkit/model/iso_4217_currencies.json +23 -0
  58. focus_data_toolkit/model/json_schema_check.py +205 -0
  59. focus_data_toolkit/model/json_schemas/allocatedmethoddetailsobjectschema.json +82 -0
  60. focus_data_toolkit/model/json_schemas/commitmentprogrameligibilitydetailsobjectschema.json +41 -0
  61. focus_data_toolkit/model/json_schemas/contractappliedobjectschema.json +104 -0
  62. focus_data_toolkit/model/json_schemas/contractcommitmentapplicabilityobjectschema.json +290 -0
  63. focus_data_toolkit/model/json_schemas/json_schemas_provenance.json +38 -0
  64. focus_data_toolkit/model/model_provenance.json +58 -0
  65. focus_data_toolkit/model/validator.py +498 -0
  66. focus_data_toolkit/modes.py +18 -0
  67. focus_data_toolkit/official_validator.py +61 -0
  68. focus_data_toolkit/progress.py +89 -0
  69. focus_data_toolkit/provenance.py +106 -0
  70. focus_data_toolkit/py.typed +1 -0
  71. focus_data_toolkit/runtime.py +243 -0
  72. focus_data_toolkit/schema/__init__.py +17 -0
  73. focus_data_toolkit/schema/detection.py +274 -0
  74. focus_data_toolkit/schema/registry.py +127 -0
  75. focus_data_toolkit/storage/__init__.py +1 -0
  76. focus_data_toolkit/storage/external_index.py +99 -0
  77. focus_data_toolkit/storage/spill.py +150 -0
  78. focus_data_toolkit/studio/__init__.py +19 -0
  79. focus_data_toolkit/studio/app.py +467 -0
  80. focus_data_toolkit/studio/config.py +42 -0
  81. focus_data_toolkit/studio/frontend/app.js +214 -0
  82. focus_data_toolkit/studio/frontend/index.html +101 -0
  83. focus_data_toolkit/studio/frontend/style.css +60 -0
  84. focus_data_toolkit/studio/jobs.py +142 -0
  85. focus_data_toolkit/studio/preview.py +32 -0
  86. focus_data_toolkit/studio/security.py +125 -0
  87. focus_data_toolkit/studio/server.py +71 -0
  88. focus_data_toolkit/supplement/__init__.py +50 -0
  89. focus_data_toolkit/supplement/adapters/__init__.py +21 -0
  90. focus_data_toolkit/supplement/adapters/adapters_provenance.json +39 -0
  91. focus_data_toolkit/supplement/adapters/aws_invoice_summary.json +24 -0
  92. focus_data_toolkit/supplement/adapters/aws_savings_plans.json +31 -0
  93. focus_data_toolkit/supplement/adapters/azure_invoice.json +25 -0
  94. focus_data_toolkit/supplement/adapters/gcp_compute_commitments.json +28 -0
  95. focus_data_toolkit/supplement/adapters/registry.py +215 -0
  96. focus_data_toolkit/supplement/apply.py +318 -0
  97. focus_data_toolkit/supplement/gaps.py +219 -0
  98. focus_data_toolkit/supplement/kinds.py +118 -0
  99. focus_data_toolkit/supplement/loader.py +409 -0
  100. focus_data_toolkit/supplement/spec.py +74 -0
  101. focus_data_toolkit/supplement/validate.py +215 -0
  102. focus_data_toolkit/validate/__init__.py +15 -0
  103. focus_data_toolkit/validate/allocation.py +333 -0
  104. focus_data_toolkit/validate/bundle.py +254 -0
  105. focus_data_toolkit/validate/codes.py +93 -0
  106. focus_data_toolkit/validate/corrections.py +245 -0
  107. focus_data_toolkit/validate/reconciliation.py +98 -0
  108. focus_data_toolkit/validate/referential.py +289 -0
  109. focus_data_toolkit-0.11.0.dist-info/METADATA +519 -0
  110. focus_data_toolkit-0.11.0.dist-info/RECORD +116 -0
  111. focus_data_toolkit-0.11.0.dist-info/WHEEL +5 -0
  112. focus_data_toolkit-0.11.0.dist-info/entry_points.txt +2 -0
  113. focus_data_toolkit-0.11.0.dist-info/licenses/LICENSE +21 -0
  114. focus_data_toolkit-0.11.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
  115. focus_data_toolkit-0.11.0.dist-info/licenses/NOTICE +60 -0
  116. focus_data_toolkit-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ // Focus Data Toolkit Studio — minimal no-build client. The access token comes from the URL the
3
+ // user opened (?token=…) and is sent on every API call; the full file is never loaded here.
4
+
5
+ const TOKEN = new URLSearchParams(location.search).get("token") || "";
6
+ const $ = (id) => document.getElementById(id);
7
+ const state = { source: null, jobId: null, cwd: "", config: null };
8
+
9
+ function withToken(url) {
10
+ return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
11
+ }
12
+ async function api(path, { method = "GET", json, form } = {}) {
13
+ const opts = { method, headers: { "X-FDT-Token": TOKEN } };
14
+ if (json !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(json); }
15
+ if (form !== undefined) { opts.body = form; }
16
+ const res = await fetch(path, opts);
17
+ const data = res.headers.get("content-type")?.includes("application/json") ? await res.json() : await res.text();
18
+ if (!res.ok) throw new Error((data && data.error) || ("HTTP " + res.status));
19
+ return data;
20
+ }
21
+
22
+ function setSource(source, label) {
23
+ state.source = source;
24
+ const el = $("sourceLabel");
25
+ el.textContent = "source: " + label;
26
+ el.classList.remove("hidden");
27
+ $("detectBtn").disabled = false;
28
+ $("convertBtn").disabled = false;
29
+ }
30
+
31
+ // --- tabs ---
32
+ $("tabs").addEventListener("click", (e) => {
33
+ const btn = e.target.closest("button[data-tab]");
34
+ if (!btn) return;
35
+ document.querySelectorAll("#tabs button").forEach((b) => b.classList.toggle("active", b === btn));
36
+ ["server", "upload", "generate"].forEach((t) => $("tab-" + t).classList.toggle("hidden", t !== btn.dataset.tab));
37
+ });
38
+
39
+ // --- server file browser ---
40
+ async function browse(subpath) {
41
+ const data = await api("/api/files?subpath=" + encodeURIComponent(subpath || ""));
42
+ state.cwd = data.path || "";
43
+ const crumbs = $("crumbs");
44
+ const parts = state.cwd ? state.cwd.split("/") : [];
45
+ crumbs.innerHTML = `<a data-p="">root</a>` + parts.map((p, i) =>
46
+ ` / <a data-p="${parts.slice(0, i + 1).join("/")}">${escapeHtml(p)}</a>`).join("");
47
+ crumbs.querySelectorAll("a").forEach((a) => a.onclick = () => browse(a.dataset.p));
48
+ const ul = $("browser");
49
+ ul.innerHTML = "";
50
+ for (const entry of data.entries) {
51
+ const li = document.createElement("li");
52
+ const rel = (state.cwd ? state.cwd + "/" : "") + entry.name;
53
+ li.className = entry.is_dir ? "dir" : "file";
54
+ li.innerHTML = `<span>${entry.is_dir ? "📁 " : "📄 "}${escapeHtml(entry.name)}</span>` +
55
+ `<span class="muted">${entry.is_dir ? "" : fmtSize(entry.size)}</span>`;
56
+ li.onclick = () => entry.is_dir ? browse(rel) : setSource({ path: rel }, rel);
57
+ ul.appendChild(li);
58
+ }
59
+ }
60
+
61
+ // --- upload ---
62
+ $("uploadBtn").onclick = async () => {
63
+ const f = $("uploadFile").files[0];
64
+ if (!f) return;
65
+ const form = new FormData();
66
+ form.append("file", f);
67
+ try {
68
+ const r = await api("/api/upload", { method: "POST", form });
69
+ setSource({ source_id: r.source_id, source_name: r.source_name }, r.source_name + " (uploaded)");
70
+ } catch (e) { alert(e.message); }
71
+ };
72
+
73
+ // --- generate ---
74
+ $("genBtn").onclick = async () => {
75
+ try {
76
+ const r = await api("/api/generate", { method: "POST", json: {
77
+ provider: $("genProvider").value, focus_version: $("genVersion").value,
78
+ rows: Number($("genRows").value), seed: Number($("genSeed").value),
79
+ }});
80
+ setSource({ source_id: r.source_id, source_name: r.source_name },
81
+ r.source_name + ` (generated, ${r.rows} rows)`);
82
+ } catch (e) { alert(e.message); }
83
+ };
84
+
85
+ // --- detect ---
86
+ $("detectBtn").onclick = async () => {
87
+ try {
88
+ const r = await api("/api/detect", { method: "POST", json: state.source });
89
+ const out = $("detectOut");
90
+ out.textContent = `${r.dataset || "?"} · FOCUS ${r.detected_version || "?"} · confidence ${r.confidence} (score ${r.score})`
91
+ + (r.missing_columns?.length ? `\nmissing: ${r.missing_columns.join(", ")}` : "")
92
+ + (r.unknown_columns?.length ? `\nunknown: ${r.unknown_columns.join(", ")}` : "");
93
+ out.classList.remove("hidden");
94
+ } catch (e) { alert(e.message); }
95
+ };
96
+
97
+ // --- convert + progress (SSE) ---
98
+ $("convertBtn").onclick = async () => {
99
+ try {
100
+ const body = Object.assign({}, state.source, {
101
+ mode: $("mode").value, output_format: $("format").value, on_exists: $("onExists").value,
102
+ });
103
+ const r = await api("/api/jobs", { method: "POST", json: body });
104
+ state.jobId = r.job_id;
105
+ $("resultCard").classList.add("hidden");
106
+ $("progressCard").classList.remove("hidden");
107
+ $("barFill").style.width = "0";
108
+ $("progressText").textContent = "starting…";
109
+ streamProgress(r.job_id);
110
+ } catch (e) { alert(e.message); }
111
+ };
112
+
113
+ function streamProgress(jobId) {
114
+ const es = new EventSource(withToken(`/api/jobs/${jobId}/events`));
115
+ es.onmessage = (ev) => {
116
+ const p = JSON.parse(ev.data);
117
+ const pct = p.fraction != null ? Math.round(p.fraction * 100) : null;
118
+ $("barFill").style.width = (pct != null ? pct : 8) + "%";
119
+ const total = p.total != null ? "/" + p.total.toLocaleString() : "";
120
+ $("progressText").textContent =
121
+ `${p.phase} · ${p.completed.toLocaleString()}${total} ${p.unit}` +
122
+ (pct != null ? ` (${pct}%)` : "") + (p.message ? ` — ${p.message}` : "");
123
+ };
124
+ es.addEventListener("done", (ev) => {
125
+ es.close();
126
+ $("barFill").style.width = "100%";
127
+ loadResult(jobId);
128
+ });
129
+ es.onerror = () => { es.close(); loadResult(jobId); };
130
+ }
131
+
132
+ $("cancelBtn").onclick = async () => {
133
+ if (state.jobId) { try { await api(`/api/jobs/${state.jobId}/cancel`, { method: "POST" }); } catch (e) {} }
134
+ };
135
+
136
+ // --- results ---
137
+ async function loadResult(jobId) {
138
+ const r = await api(`/api/jobs/${jobId}/result`);
139
+ $("progressCard").classList.add("hidden");
140
+ $("resultCard").classList.remove("hidden");
141
+ const cls = r.status === "succeeded" ? "status-ok" : r.status === "cancelled" ? "status-warn" : "status-err";
142
+ $("resultStatus").innerHTML = `<span class="${cls}">${r.status}</span>` +
143
+ (r.error ? ` — ${escapeHtml(r.error)}` : "") +
144
+ (r.assumptions_present ? ` · <span class="status-warn">contains ASSUMED (synthetic) values</span>` : "");
145
+
146
+ const dt = $("datasets");
147
+ if (r.datasets) {
148
+ dt.innerHTML = "<tr><th>Dataset</th><th>Status</th><th>Conformance</th><th>Rows</th></tr>" +
149
+ Object.entries(r.datasets).map(([n, e]) =>
150
+ `<tr><td>${escapeHtml(n)}</td><td>${escapeHtml(e.status)}</td><td>${escapeHtml(e.conformance || "")}</td><td>${e.row_count ?? ""}</td></tr>`).join("");
151
+ } else { dt.innerHTML = ""; }
152
+
153
+ const dl = $("downloads");
154
+ dl.innerHTML = "";
155
+ const links = [];
156
+ const dataFiles = (r.files || []).filter((f) => !f.is_dir);
157
+ if (dataFiles.some((f) => f.name === "focus_1_4_manifest.json")) links.push(["manifest", `/api/jobs/${jobId}/manifest`]);
158
+ if (dataFiles.some((f) => f.name === "SHA256SUMS")) links.push(["checksums", `/api/jobs/${jobId}/checksums`]);
159
+ links.push(["diagnostics.json", `/api/jobs/${jobId}/diagnostics?format=json`]);
160
+ links.push(["diagnostics.csv", `/api/jobs/${jobId}/diagnostics?format=csv`]);
161
+ links.push(["summary.html", `/api/jobs/${jobId}/summary.html`]);
162
+ for (const f of dataFiles) links.push([f.name, `/api/jobs/${jobId}/download?file=${encodeURIComponent(f.name)}`]);
163
+ for (const [label, url] of links) {
164
+ const a = document.createElement("a");
165
+ a.href = withToken(url); a.textContent = "⬇ " + label; a.target = "_blank"; a.rel = "noopener";
166
+ dl.appendChild(a);
167
+ }
168
+
169
+ const sel = $("previewFile");
170
+ sel.innerHTML = "";
171
+ for (const f of (r.files || [])) {
172
+ if (/\.(csv|parquet)$/.test(f.name) || f.is_dir) {
173
+ const o = document.createElement("option"); o.value = f.name; o.textContent = f.name; sel.appendChild(o);
174
+ }
175
+ }
176
+ }
177
+
178
+ $("previewBtn").onclick = async () => {
179
+ const file = $("previewFile").value;
180
+ if (!state.jobId || !file) return;
181
+ try {
182
+ const p = await api(`/api/jobs/${state.jobId}/preview?file=${encodeURIComponent(file)}&limit=50`);
183
+ const cols = p.columns || [];
184
+ const head = "<tr>" + cols.map((c) => `<th>${escapeHtml(c)}</th>`).join("") + "</tr>";
185
+ const rows = (p.rows || []).map((row) =>
186
+ "<tr>" + cols.map((c) => `<td>${escapeHtml(String(row[c] ?? ""))}</td>`).join("") + "</tr>").join("");
187
+ $("previewTable").innerHTML = `<table>${head}${rows}</table>` +
188
+ `<p class="muted">rows ${p.offset}–${p.offset + (p.rows || []).length} (sampled)</p>`;
189
+ } catch (e) { alert(e.message); }
190
+ };
191
+
192
+ // --- helpers ---
193
+ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); }
194
+ function fmtSize(n) { if (n == null) return ""; const u = ["B", "KB", "MB", "GB"]; let i = 0; while (n >= 1000 && i < u.length - 1) { n /= 1000; i++; } return n.toFixed(i ? 1 : 0) + " " + u[i]; }
195
+
196
+ // --- init ---
197
+ (async function init() {
198
+ if (!TOKEN) { $("tokenWarn").classList.remove("hidden"); return; }
199
+ try {
200
+ const cfg = await api("/api/config");
201
+ state.config = cfg;
202
+ $("rootLabel").textContent = cfg.root;
203
+ $("uploadCap").textContent = fmtSize(cfg.max_upload_bytes);
204
+ $("genNote").textContent = `max ${cfg.max_generate_rows.toLocaleString()} rows in the Studio; use the CLI/Runner for larger sets.`;
205
+ fill($("genProvider"), cfg.providers);
206
+ fill($("genVersion"), cfg.focus_versions);
207
+ fill($("format"), cfg.output_formats);
208
+ await browse("");
209
+ } catch (e) {
210
+ $("tokenWarn").classList.remove("hidden");
211
+ $("tokenWarn").innerHTML = "<strong>Cannot reach the Studio API.</strong> " + escapeHtml(e.message);
212
+ }
213
+ })();
214
+ function fill(sel, values) { sel.innerHTML = ""; for (const v of values) { const o = document.createElement("option"); o.value = v; o.textContent = v; sel.appendChild(o); } }
@@ -0,0 +1,101 @@
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>Focus Data Toolkit Studio</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>Focus Data Toolkit <span class="badge">Studio</span></h1>
12
+ <p id="meta" class="muted">local · your data never leaves this machine</p>
13
+ </header>
14
+
15
+ <main>
16
+ <section id="tokenWarn" class="card warn hidden">
17
+ <strong>Missing access token.</strong> Open the URL printed by
18
+ <code>focus-toolkit ui</code> — it contains <code>?token=…</code>.
19
+ </section>
20
+
21
+ <section class="card">
22
+ <h2>1 · Source</h2>
23
+ <div class="tabs" id="tabs">
24
+ <button data-tab="server" class="active">Server file</button>
25
+ <button data-tab="upload">Upload</button>
26
+ <button data-tab="generate">Generate test data</button>
27
+ </div>
28
+
29
+ <div id="tab-server" class="tab">
30
+ <p class="muted">Files under the allowed root (<code id="rootLabel"></code>). Best for large files.</p>
31
+ <div id="crumbs" class="crumbs"></div>
32
+ <ul id="browser" class="browser"></ul>
33
+ </div>
34
+
35
+ <div id="tab-upload" class="tab hidden">
36
+ <p class="muted">For small/medium files only (capped, <span id="uploadCap"></span>).</p>
37
+ <input type="file" id="uploadFile">
38
+ <button id="uploadBtn">Upload</button>
39
+ </div>
40
+
41
+ <div id="tab-generate" class="tab hidden">
42
+ <div class="row">
43
+ <label>Provider <select id="genProvider"></select></label>
44
+ <label>FOCUS <select id="genVersion"></select></label>
45
+ <label>Rows <input type="number" id="genRows" value="1000" min="1"></label>
46
+ <label>Seed <input type="number" id="genSeed" value="1202"></label>
47
+ </div>
48
+ <button id="genBtn">Generate</button>
49
+ <p class="muted" id="genNote"></p>
50
+ </div>
51
+
52
+ <p id="sourceLabel" class="pill hidden"></p>
53
+ <button id="detectBtn" disabled>Detect</button>
54
+ <pre id="detectOut" class="hidden"></pre>
55
+ </section>
56
+
57
+ <section class="card">
58
+ <h2>2 · Convert</h2>
59
+ <div class="row">
60
+ <label>Mode
61
+ <select id="mode">
62
+ <option value="strict">strict</option>
63
+ <option value="synthetic">synthetic</option>
64
+ </select>
65
+ </label>
66
+ <label>Output <select id="format"></select></label>
67
+ <label>On exists
68
+ <select id="onExists">
69
+ <option value="refuse">refuse</option>
70
+ <option value="replace">replace</option>
71
+ <option value="version">version</option>
72
+ </select>
73
+ </label>
74
+ </div>
75
+ <button id="convertBtn" disabled>Convert</button>
76
+ </section>
77
+
78
+ <section class="card hidden" id="progressCard">
79
+ <h2>3 · Progress</h2>
80
+ <div class="bar"><div id="barFill"></div></div>
81
+ <p id="progressText" class="muted">starting…</p>
82
+ <button id="cancelBtn">Cancel</button>
83
+ </section>
84
+
85
+ <section class="card hidden" id="resultCard">
86
+ <h2>4 · Results</h2>
87
+ <p id="resultStatus"></p>
88
+ <table id="datasets" class="grid"></table>
89
+ <div id="downloads" class="downloads"></div>
90
+ <h3>Preview <span class="muted">(sampled — the full file is never loaded)</span></h3>
91
+ <div class="row">
92
+ <label>File <select id="previewFile"></select></label>
93
+ <button id="previewBtn">Preview</button>
94
+ </div>
95
+ <div id="previewTable" class="scroll"></div>
96
+ </section>
97
+ </main>
98
+
99
+ <script src="/static/app.js"></script>
100
+ </body>
101
+ </html>
@@ -0,0 +1,60 @@
1
+ :root {
2
+ --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --card: #f6f7f9; --border: #dcdfe4;
3
+ --accent: #2b6cb0; --accent-fg: #fff; --warn: #b7791f; --ok: #2f855a; --err: #c53030;
4
+ }
5
+ @media (prefers-color-scheme: dark) {
6
+ :root {
7
+ --bg: #14171c; --fg: #e6e8eb; --muted: #9aa2ad; --card: #1c2027; --border: #2c323b;
8
+ --accent: #4a97e0; --accent-fg: #0b0e12; --warn: #d69e2e; --ok: #68d391; --err: #fc8181;
9
+ }
10
+ }
11
+ * { box-sizing: border-box; }
12
+ body {
13
+ margin: 0; font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
14
+ background: var(--bg); color: var(--fg);
15
+ }
16
+ header { padding: 1.2rem 1.5rem; border-bottom: 1px solid var(--border); }
17
+ h1 { margin: 0; font-size: 1.4rem; }
18
+ h2 { margin: 0 0 .8rem; font-size: 1.1rem; }
19
+ h3 { margin: 1rem 0 .5rem; font-size: 1rem; }
20
+ .badge { background: var(--accent); color: var(--accent-fg); border-radius: 6px; padding: 0 .4rem; font-size: .8rem; }
21
+ .muted { color: var(--muted); }
22
+ main { max-width: 900px; margin: 0 auto; padding: 1.2rem 1.5rem; display: grid; gap: 1.2rem; }
23
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem 1.2rem; }
24
+ .card.warn { border-color: var(--warn); }
25
+ .hidden { display: none !important; }
26
+ .row { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; margin-bottom: .8rem; }
27
+ label { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; color: var(--muted); }
28
+ select, input[type=number], input[type=file] {
29
+ font: inherit; padding: .4rem .5rem; border: 1px solid var(--border); border-radius: 6px;
30
+ background: var(--bg); color: var(--fg);
31
+ }
32
+ button {
33
+ font: inherit; padding: .45rem .9rem; border: 1px solid var(--accent); border-radius: 6px;
34
+ background: var(--accent); color: var(--accent-fg); cursor: pointer;
35
+ }
36
+ button:disabled { opacity: .5; cursor: not-allowed; }
37
+ button.ghost { background: transparent; color: var(--accent); }
38
+ .tabs { display: flex; gap: .3rem; margin-bottom: .8rem; }
39
+ .tabs button { background: transparent; color: var(--fg); border-color: var(--border); }
40
+ .tabs button.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
41
+ .browser { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; border: 1px solid var(--border); border-radius: 6px; }
42
+ .browser li { padding: .35rem .6rem; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; justify-content: space-between; }
43
+ .browser li:hover { background: var(--bg); }
44
+ .browser li.dir { font-weight: 600; }
45
+ .crumbs { margin-bottom: .5rem; font-size: .85rem; }
46
+ .crumbs a { color: var(--accent); cursor: pointer; }
47
+ .pill { display: inline-block; background: var(--bg); border: 1px solid var(--border); border-radius: 999px; padding: .2rem .7rem; }
48
+ pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: .7rem; overflow: auto; }
49
+ .bar { height: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
50
+ #barFill { height: 100%; width: 0; background: var(--accent); transition: width .2s; }
51
+ table.grid, table.grid th, table.grid td { border: 1px solid var(--border); border-collapse: collapse; }
52
+ table.grid { width: 100%; margin: .5rem 0; }
53
+ table.grid th, table.grid td { padding: .35rem .5rem; text-align: left; font-size: .85rem; }
54
+ .downloads { display: flex; flex-wrap: wrap; gap: .5rem; margin: .6rem 0; }
55
+ .downloads a { color: var(--accent); text-decoration: none; border: 1px solid var(--border); border-radius: 6px; padding: .3rem .6rem; }
56
+ .scroll { overflow-x: auto; }
57
+ .scroll table { border-collapse: collapse; font-size: .8rem; }
58
+ .scroll th, .scroll td { border: 1px solid var(--border); padding: .25rem .45rem; white-space: nowrap; }
59
+ .status-ok { color: var(--ok); } .status-err { color: var(--err); } .status-warn { color: var(--warn); }
60
+ code { background: var(--bg); padding: 0 .3rem; border-radius: 4px; }
@@ -0,0 +1,142 @@
1
+ """Background conversion jobs + managed sources for the Studio.
2
+
3
+ By default a single worker runs one conversion at a time (extra submissions queue), so two large
4
+ conversions cannot exhaust the machine. Each job/source gets its own directory under the work
5
+ root; a TTL sweep (and a startup sweep) removes old ones and recovers any interrupted atomic
6
+ publishes. Conversions run the Core streaming engine with the progress/cancel hooks, so the
7
+ Studio's outputs are identical to a CLI run and cancellation never leaves partial output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import queue
14
+ import shutil
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from focus_data_toolkit.io.atomic_writer import clean_leftovers
23
+ from focus_data_toolkit.studio.security import resolve_within_root
24
+
25
+
26
+ @dataclass
27
+ class Job:
28
+ """State of one conversion job (mutated by the worker thread; read by the API)."""
29
+
30
+ id: str
31
+ out_dir: Path
32
+ status: str = "queued" # queued | running | succeeded | failed | cancelled
33
+ events: list[dict] = field(default_factory=list)
34
+ error: str | None = None
35
+ error_code: str | None = None
36
+ created: float = field(default_factory=time.monotonic)
37
+ cancel: threading.Event = field(default_factory=threading.Event)
38
+ finished: threading.Event = field(default_factory=threading.Event)
39
+
40
+ @property
41
+ def done(self) -> bool:
42
+ return self.status in ("succeeded", "failed", "cancelled")
43
+
44
+ def summary(self) -> dict:
45
+ last = self.events[-1] if self.events else None
46
+ return {
47
+ "id": self.id,
48
+ "status": self.status,
49
+ "error": self.error,
50
+ "error_code": self.error_code,
51
+ "last_event": last,
52
+ "event_count": len(self.events),
53
+ }
54
+
55
+
56
+ class JobManager:
57
+ """Owns the work root, a bounded worker pool, and the job/source registries."""
58
+
59
+ def __init__(self, work_dir: Path, *, max_concurrency: int = 1, ttl_seconds: int = 86400):
60
+ self._work = Path(work_dir)
61
+ self._jobs_dir = self._work / "jobs"
62
+ self._sources_dir = self._work / "sources"
63
+ self._jobs_dir.mkdir(parents=True, exist_ok=True)
64
+ self._sources_dir.mkdir(parents=True, exist_ok=True)
65
+ self._ttl = ttl_seconds
66
+ self._jobs: dict[str, Job] = {}
67
+ self._lock = threading.Lock()
68
+ self._queue: queue.Queue = queue.Queue()
69
+ self._workers = [
70
+ threading.Thread(target=self._worker, name=f"fdt-studio-{i}", daemon=True)
71
+ for i in range(max(1, max_concurrency))
72
+ ]
73
+ for worker in self._workers:
74
+ worker.start()
75
+ self._sweep_disk_startup()
76
+
77
+ # --- managed sources (uploads / generated files) ------------------------------------
78
+ def new_source_dir(self) -> tuple[str, Path]:
79
+ source_id = uuid.uuid4().hex
80
+ path = self._sources_dir / source_id
81
+ path.mkdir(parents=True, exist_ok=True)
82
+ return source_id, path
83
+
84
+ def source_file(self, source_id: str, name: str) -> Path:
85
+ # Resolve the (user-supplied) id + name in one pass against the server-owned sources dir,
86
+ # so the confinement root is never itself derived from user input.
87
+ return resolve_within_root(os.path.join(source_id, name), self._sources_dir)
88
+
89
+ # --- jobs ---------------------------------------------------------------------------
90
+ def get(self, job_id: str) -> Job | None:
91
+ with self._lock:
92
+ return self._jobs.get(job_id)
93
+
94
+ def job_file(self, job_id: str, name: str) -> Path:
95
+ job = self.get(job_id)
96
+ if job is None:
97
+ raise KeyError(job_id)
98
+ return resolve_within_root(name, job.out_dir)
99
+
100
+ def submit_convert(self, run: Callable[[Job], None]) -> Job:
101
+ """Register a job and enqueue ``run(job)`` (executed on a worker thread)."""
102
+ self._sweep_ttl()
103
+ job_id = uuid.uuid4().hex
104
+ job_root = self._jobs_dir / job_id
105
+ job_root.mkdir(parents=True, exist_ok=True)
106
+ job = Job(id=job_id, out_dir=job_root / "result")
107
+ with self._lock:
108
+ self._jobs[job_id] = job
109
+ self._queue.put((job, run))
110
+ return job
111
+
112
+ def _worker(self) -> None:
113
+ while True:
114
+ job, run = self._queue.get()
115
+ try:
116
+ if job.cancel.is_set():
117
+ job.status = "cancelled"
118
+ else:
119
+ job.status = "running"
120
+ run(job)
121
+ except Exception as exc: # backstop: a job callable must never kill the worker
122
+ job.status = "failed"
123
+ job.error = f"{type(exc).__name__}: {exc}"
124
+ finally:
125
+ job.finished.set()
126
+ self._queue.task_done()
127
+
128
+ # --- cleanup ------------------------------------------------------------------------
129
+ def _sweep_ttl(self) -> None:
130
+ cutoff = time.monotonic() - self._ttl
131
+ stale = [j for j in list(self._jobs.values()) if j.done and j.created < cutoff]
132
+ for job in stale:
133
+ with self._lock:
134
+ self._jobs.pop(job.id, None)
135
+ shutil.rmtree(self._jobs_dir / job.id, ignore_errors=True)
136
+
137
+ def _sweep_disk_startup(self) -> None:
138
+ # Recover any interrupted atomic publish and drop leftover staging under the work root.
139
+ try:
140
+ clean_leftovers(self._work)
141
+ except OSError:
142
+ pass # best-effort startup hygiene
@@ -0,0 +1,32 @@
1
+ """Bounded, paginated preview of a produced file — never materialises the whole dataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from pathlib import Path
7
+
8
+ from focus_data_toolkit.io.row_source import open_row_source
9
+
10
+
11
+ def sampled_page(
12
+ path: str | Path, *, dataset: str | None = None, offset: int = 0, limit: int = 50
13
+ ) -> dict:
14
+ """Return ``limit`` rows starting at ``offset`` from ``path`` (CSV/Parquet, incl. partitioned).
15
+
16
+ Reads at most ``offset + limit`` rows and stops — memory is bounded regardless of file size,
17
+ so a multi-GB result previews the same way a tiny one does. The full file is never loaded into
18
+ the backend or sent to the browser.
19
+ """
20
+ offset = max(0, offset)
21
+ limit = max(1, limit)
22
+ columns: list[str] = []
23
+ rows: list[dict[str, str]] = []
24
+ with contextlib.closing(open_row_source(path, dataset=dataset)) as reader:
25
+ columns = list(reader.source_columns)
26
+ for index, record in enumerate(reader):
27
+ if index < offset:
28
+ continue
29
+ if len(rows) >= limit:
30
+ break
31
+ rows.append(dict(record.values))
32
+ return {"columns": columns, "rows": rows, "offset": offset, "limit": limit}