syntaxmatrix 2.6.4.3__py3-none-any.whl → 3.0.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 (45) hide show
  1. syntaxmatrix/__init__.py +6 -4
  2. syntaxmatrix/agentic/agents.py +195 -15
  3. syntaxmatrix/agentic/agents_orchestrer.py +16 -10
  4. syntaxmatrix/client_docs.py +237 -0
  5. syntaxmatrix/commentary.py +96 -25
  6. syntaxmatrix/core.py +156 -54
  7. syntaxmatrix/dataset_preprocessing.py +2 -2
  8. syntaxmatrix/db.py +60 -0
  9. syntaxmatrix/db_backends/__init__.py +1 -0
  10. syntaxmatrix/db_backends/postgres_backend.py +14 -0
  11. syntaxmatrix/db_backends/sqlite_backend.py +258 -0
  12. syntaxmatrix/db_contract.py +71 -0
  13. syntaxmatrix/kernel_manager.py +174 -150
  14. syntaxmatrix/page_builder_generation.py +654 -50
  15. syntaxmatrix/page_layout_contract.py +25 -3
  16. syntaxmatrix/page_patch_publish.py +368 -15
  17. syntaxmatrix/plugins/__init__.py +0 -0
  18. syntaxmatrix/plugins/plugin_manager.py +114 -0
  19. syntaxmatrix/premium/__init__.py +18 -0
  20. syntaxmatrix/premium/catalogue/__init__.py +121 -0
  21. syntaxmatrix/premium/gate.py +119 -0
  22. syntaxmatrix/premium/state.py +507 -0
  23. syntaxmatrix/premium/verify.py +222 -0
  24. syntaxmatrix/profiles.py +1 -1
  25. syntaxmatrix/routes.py +9782 -8004
  26. syntaxmatrix/settings/model_map.py +50 -65
  27. syntaxmatrix/settings/prompts.py +1435 -380
  28. syntaxmatrix/settings/string_navbar.py +4 -4
  29. syntaxmatrix/static/icons/bot_icon.png +0 -0
  30. syntaxmatrix/static/icons/bot_icon2.png +0 -0
  31. syntaxmatrix/templates/admin_billing.html +408 -0
  32. syntaxmatrix/templates/admin_branding.html +65 -2
  33. syntaxmatrix/templates/admin_features.html +54 -0
  34. syntaxmatrix/templates/dashboard.html +285 -8
  35. syntaxmatrix/templates/edit_page.html +199 -18
  36. syntaxmatrix/themes.py +17 -17
  37. syntaxmatrix/workspace_db.py +0 -23
  38. syntaxmatrix-3.0.0.dist-info/METADATA +219 -0
  39. {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/RECORD +42 -30
  40. {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/WHEEL +1 -1
  41. syntaxmatrix/settings/default.yaml +0 -13
  42. syntaxmatrix-2.6.4.3.dist-info/METADATA +0 -539
  43. syntaxmatrix-2.6.4.3.dist-info/licenses/LICENSE.txt +0 -21
  44. /syntaxmatrix/static/icons/{logo3.png → logo2.png} +0 -0
  45. {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+ import sqlite3
3
+ import os
4
+ import json
5
+ from werkzeug.utils import secure_filename
6
+ from syntaxmatrix.project_root import detect_project_root
7
+
8
+
9
+ _CLIENT_DIR = detect_project_root()
10
+ DB_PATH = os.path.join(_CLIENT_DIR, "data", "syntaxmatrix.db")
11
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
12
+
13
+ TEMPLATES_DIR = os.path.join(_CLIENT_DIR, "templates")
14
+ os.makedirs(TEMPLATES_DIR, exist_ok=True)
15
+
16
+
17
+ # ------------ Utils ------------
18
+ def connect_db():
19
+ conn = sqlite3.connect(DB_PATH)
20
+ conn.row_factory = sqlite3.Row
21
+ return conn
22
+
23
+ def _col_exists(conn, table: str, col: str) -> bool:
24
+ cur = conn.execute(f"PRAGMA table_info({table})")
25
+ cols = [r["name"] for r in cur.fetchall()]
26
+ return col in cols
27
+
28
+ def _ensure_column(conn, table: str, col: str, col_sql: str):
29
+ if not _col_exists(conn, table, col):
30
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_sql}")
31
+
32
+ def _ensure_index(conn, idx_sql: str):
33
+ try:
34
+ conn.execute(idx_sql)
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ # ------------ Schema init ------------
40
+ def init_db():
41
+ conn = connect_db()
42
+ conn.execute("""
43
+ CREATE TABLE IF NOT EXISTS pages (
44
+ name TEXT PRIMARY KEY,
45
+ content TEXT NOT NULL,
46
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
47
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
48
+ )
49
+ """)
50
+ conn.execute("""
51
+ CREATE TABLE IF NOT EXISTS page_layouts (
52
+ name TEXT PRIMARY KEY,
53
+ layout_json TEXT NOT NULL,
54
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
55
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
56
+ )
57
+ """)
58
+ conn.execute("""
59
+ CREATE TABLE IF NOT EXISTS settings (
60
+ key TEXT PRIMARY KEY,
61
+ value TEXT NOT NULL,
62
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
63
+ )
64
+ """)
65
+ conn.execute("""
66
+ CREATE TABLE IF NOT EXISTS audit_log (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ action TEXT NOT NULL,
69
+ subject TEXT,
70
+ meta TEXT,
71
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
72
+ )
73
+ """)
74
+
75
+ _init_media_assets_table(conn)
76
+
77
+ conn.commit()
78
+ conn.close()
79
+
80
+
81
+ # ------------ Pages ------------
82
+ def get_pages():
83
+ conn = connect_db()
84
+ cur = conn.execute("SELECT name FROM pages ORDER BY name")
85
+ rows = [r["name"] for r in cur.fetchall()]
86
+ conn.close()
87
+ return rows
88
+
89
+ def get_page(name: str):
90
+ conn = connect_db()
91
+ cur = conn.execute("SELECT name, content FROM pages WHERE name = ?", (name,))
92
+ row = cur.fetchone()
93
+ conn.close()
94
+ return dict(row) if row else None
95
+
96
+ def add_page(name: str, content: str):
97
+ conn = connect_db()
98
+ conn.execute("INSERT OR REPLACE INTO pages (name, content) VALUES (?, ?)", (name, content))
99
+ conn.commit()
100
+ conn.close()
101
+
102
+ def update_page(name: str, content: str):
103
+ conn = connect_db()
104
+ conn.execute("UPDATE pages SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", (content, name))
105
+ conn.commit()
106
+ conn.close()
107
+
108
+ def delete_page(name: str):
109
+ conn = connect_db()
110
+ conn.execute("DELETE FROM pages WHERE name = ?", (name,))
111
+ conn.execute("DELETE FROM page_layouts WHERE name = ?", (name,))
112
+ conn.commit()
113
+ conn.close()
114
+
115
+ def rename_page(old_name: str, new_name: str):
116
+ conn = connect_db()
117
+ conn.execute("UPDATE pages SET name = ? WHERE name = ?", (new_name, old_name))
118
+ conn.execute("UPDATE page_layouts SET name = ? WHERE name = ?", (new_name, old_name))
119
+ conn.commit()
120
+ conn.close()
121
+
122
+
123
+ # ------------ Layouts ------------
124
+ def upsert_page_layout(name: str, layout_json: str):
125
+ conn = connect_db()
126
+ conn.execute(
127
+ "INSERT OR REPLACE INTO page_layouts (name, layout_json) VALUES (?, ?)",
128
+ (name, layout_json)
129
+ )
130
+ conn.commit()
131
+ conn.close()
132
+
133
+ def get_page_layout(name: str):
134
+ conn = connect_db()
135
+ cur = conn.execute("SELECT name, layout_json FROM page_layouts WHERE name = ?", (name,))
136
+ row = cur.fetchone()
137
+ conn.close()
138
+ return dict(row) if row else None
139
+
140
+ def get_all_page_layouts():
141
+ conn = connect_db()
142
+ cur = conn.execute("SELECT name, layout_json FROM page_layouts ORDER BY name")
143
+ rows = [dict(r) for r in cur.fetchall()]
144
+ conn.close()
145
+ return rows
146
+
147
+
148
+ # ------------ Settings ------------
149
+ def get_setting(key: str, default=None):
150
+ conn = connect_db()
151
+ cur = conn.execute("SELECT value FROM settings WHERE key = ?", (key,))
152
+ row = cur.fetchone()
153
+ conn.close()
154
+ if not row:
155
+ return default
156
+ try:
157
+ return json.loads(row["value"])
158
+ except Exception:
159
+ return row["value"]
160
+
161
+ def set_setting(key: str, value):
162
+ conn = connect_db()
163
+ conn.execute(
164
+ "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
165
+ (key, json.dumps(value))
166
+ )
167
+ conn.commit()
168
+ conn.close()
169
+
170
+ def delete_setting(key: str):
171
+ conn = connect_db()
172
+ conn.execute("DELETE FROM settings WHERE key = ?", (key,))
173
+ conn.commit()
174
+ conn.close()
175
+
176
+
177
+ # ------------ Audit log ------------
178
+ def audit(action: str, subject: str = "", meta: dict | None = None):
179
+ conn = connect_db()
180
+ conn.execute(
181
+ "INSERT INTO audit_log (action, subject, meta) VALUES (?, ?, ?)",
182
+ (action, subject, json.dumps(meta or {}))
183
+ )
184
+ conn.commit()
185
+ conn.close()
186
+
187
+
188
+ # ------------ Media assets (dedupe + metadata) ------------
189
+ MEDIA_DIR = os.path.join(_CLIENT_DIR, "uploads", "media")
190
+ os.makedirs(MEDIA_DIR, exist_ok=True)
191
+
192
+ MEDIA_IMAGES_DIR = os.path.join(MEDIA_DIR, "images")
193
+ os.makedirs(MEDIA_IMAGES_DIR, exist_ok=True)
194
+
195
+ MEDIA_THUMBS_DIR = os.path.join(MEDIA_DIR, "thumbs")
196
+ os.makedirs(MEDIA_THUMBS_DIR, exist_ok=True)
197
+
198
+ def _init_media_assets_table(conn):
199
+ conn.execute("""
200
+ CREATE TABLE IF NOT EXISTS media_assets (
201
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
202
+ kind TEXT NOT NULL DEFAULT 'image',
203
+ rel_path TEXT NOT NULL UNIQUE,
204
+ thumb_path TEXT,
205
+ sha256 TEXT,
206
+ dhash TEXT,
207
+ width INTEGER,
208
+ height INTEGER,
209
+ bytes INTEGER,
210
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
211
+ )
212
+ """)
213
+ _ensure_index(conn, "CREATE INDEX IF NOT EXISTS idx_media_assets_kind ON media_assets(kind)")
214
+ _ensure_index(conn, "CREATE INDEX IF NOT EXISTS idx_media_assets_sha256 ON media_assets(sha256)")
215
+ _ensure_index(conn, "CREATE INDEX IF NOT EXISTS idx_media_assets_dhash ON media_assets(dhash)")
216
+
217
+ def upsert_media_asset(kind: str, rel_path: str, thumb_path: str | None = None,
218
+ sha256: str | None = None, dhash: str | None = None,
219
+ width: int | None = None, height: int | None = None, bytes_: int | None = None):
220
+ conn = connect_db()
221
+ _init_media_assets_table(conn)
222
+ conn.execute(
223
+ """
224
+ INSERT INTO media_assets (kind, rel_path, thumb_path, sha256, dhash, width, height, bytes)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
226
+ ON CONFLICT(rel_path) DO UPDATE SET
227
+ kind=excluded.kind,
228
+ thumb_path=excluded.thumb_path,
229
+ sha256=excluded.sha256,
230
+ dhash=excluded.dhash,
231
+ width=excluded.width,
232
+ height=excluded.height,
233
+ bytes=excluded.bytes
234
+ """,
235
+ (kind, rel_path, thumb_path, sha256, dhash, width, height, bytes_)
236
+ )
237
+ conn.commit()
238
+ conn.close()
239
+
240
+ def get_media_asset_by_rel_path(rel_path: str):
241
+ conn = connect_db()
242
+ cur = conn.execute("SELECT * FROM media_assets WHERE rel_path = ?", (rel_path,))
243
+ row = cur.fetchone()
244
+ conn.close()
245
+ return dict(row) if row else None
246
+
247
+ def list_media_assets(kind: str = "image"):
248
+ conn = connect_db()
249
+ cur = conn.execute("SELECT * FROM media_assets WHERE kind = ? ORDER BY id DESC", (kind,))
250
+ rows = [dict(r) for r in cur.fetchall()]
251
+ conn.close()
252
+ return rows
253
+
254
+ def normalise_media_filename(filename: str) -> str:
255
+ filename = secure_filename(filename or "file")
256
+ if not filename:
257
+ filename = "file"
258
+ return filename
@@ -0,0 +1,71 @@
1
+ # syntaxmatrix/db_contract.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict, Iterable, Optional
5
+
6
+
7
+ # Keep this list tight: only functions that the framework truly depends on.
8
+ # If we add more later, we do it deliberately.
9
+ CORE_REQUIRED_FUNCTIONS = (
10
+ # Pages
11
+ "get_pages",
12
+ "get_page_html",
13
+ "add_page",
14
+ "update_page",
15
+ "delete_page",
16
+
17
+ # Secrets
18
+ "get_secrets",
19
+ "set_secret",
20
+ "delete_secret",
21
+
22
+ # Nav
23
+ "get_nav_links",
24
+ "set_nav_links",
25
+
26
+ # Page layouts (builder)
27
+ "get_page_layout",
28
+ "upsert_page_layout",
29
+ "delete_page_layout",
30
+
31
+ # Media library
32
+ "add_media_file",
33
+ "list_media_files",
34
+ "delete_media_file",
35
+
36
+ # Generic settings (used by branding + profiles + other admin toggles)
37
+ "get_setting",
38
+ "set_setting",
39
+
40
+ # Optional: some backends may want init, but we do not force it here.
41
+ )
42
+
43
+
44
+ def assert_backend_implements_core_api(ns: Dict[str, object], *, provider: str = "") -> None:
45
+ """
46
+ Validate that the loaded backend provides the minimum required surface.
47
+
48
+ `ns` is usually `globals()` from syntaxmatrix.db (the facade module).
49
+
50
+ We keep this strict for non-SQLite providers, because:
51
+ - SQLite is the built-in reference backend.
52
+ - Premium/Cloud backends must be complete, or we fail fast with a clear error.
53
+ """
54
+ missing = []
55
+ for fn in CORE_REQUIRED_FUNCTIONS:
56
+ obj = ns.get(fn)
57
+ if not callable(obj):
58
+ missing.append(fn)
59
+
60
+ if missing:
61
+ prov = provider or "unknown"
62
+ raise RuntimeError(
63
+ "SyntaxMatrix DB backend validation failed.\n"
64
+ f"Provider: {prov}\n"
65
+ "Missing required functions:\n"
66
+ f" - " + "\n - ".join(missing) + "\n\n"
67
+ "Fix:\n"
68
+ "- If you are using the premium Postgres backend, ensure the premium package is installed\n"
69
+ " and that your backend module's install(ns) correctly injects these functions.\n"
70
+ "- If you are writing your own backend, implement the missing functions.\n"
71
+ )
@@ -44,135 +44,46 @@ class SyntaxMatrixKernelManager:
44
44
  def cleanup_all(cls):
45
45
  for sid in list(cls._kernels):
46
46
  cls.shutdown_kernel(sid)
47
+
48
+ def _prefer_display_over_to_html(code: str) -> str:
49
+ """
50
+ Convert common 'print(df.to_html())' patterns into IPython display(df).
51
+ Best-effort only; avoids breaking code.
52
+ """
53
+ if not code or "to_html" not in code:
54
+ return code
55
+
56
+ # Replace print(X.to_html()) -> display(X)
57
+ code2 = _re.sub(
58
+ r"print\(\s*([A-Za-z_][A-Za-z0-9_\.]*)\s*\.to_html\(\s*\)\s*\)",
59
+ r"display(\1)",
60
+ code,
61
+ flags=_re.IGNORECASE,
62
+ )
47
63
 
64
+ # Replace print(pd.DataFrame(...).to_html()) -> display(pd.DataFrame(...))
65
+ code2 = _re.sub(
66
+ r"print\(\s*(pd\.DataFrame\([^\)]*\))\s*\.to_html\(\s*\)\s*\)",
67
+ r"display(\1)",
68
+ code2,
69
+ flags=_re.IGNORECASE,
70
+ )
48
71
 
49
- _df_cache = None
50
-
51
- def execute_code_in_kernel(kc, code, timeout=120):
52
-
53
- _local_stdout = ""
54
- _local_stderr = ""
72
+ # If we introduced display(), ensure import exists
73
+ if "display(" in code2 and "from IPython.display import display" not in code2:
74
+ code2 = "from IPython.display import display\n" + code2
55
75
 
56
- global _df_cache
57
- exec_namespace = {}
76
+ return code2
58
77
 
59
- if not hasattr(_pd.core.generic.NDFrame, "_patch_safe_reduce"):
60
- _AGG_FUNCS = (
61
- "sum", "mean", "median", "std", "var",
62
- "min", "max", "prod",
63
- )
64
78
 
65
- def _make_wrapper(name):
66
- orig = getattr(_pd.core.generic.NDFrame, name)
67
-
68
- @wraps(orig)
69
- def _wrapper(self, *args, **kwargs):
70
- try:
71
- return orig(self, *args, **kwargs)
72
-
73
- except TypeError as exc:
74
- msg = str(exc)
75
- if (
76
- "can only concatenate str" not in msg
77
- and "could not convert" not in msg
78
- ):
79
- raise # - not the error we’re guarding against
80
-
81
- # Caller already supplied numeric_only (positional or kw) → re-raise
82
- if len(args) >= 3 or "numeric_only" in kwargs:
83
- raise
84
-
85
- kwargs = dict(kwargs)
86
- kwargs["numeric_only"] = True
87
- return orig(self, *args, **kwargs)
88
-
89
- return _wrapper
90
-
91
- for _fn in _AGG_FUNCS:
92
- setattr(
93
- _pd.core.generic.NDFrame,
94
- f"_orig_{_fn}",
95
- getattr(_pd.core.generic.NDFrame, _fn),
96
- )
97
- setattr(
98
- _pd.core.generic.NDFrame,
99
- _fn,
100
- _make_wrapper(_fn),
101
- )
102
-
103
- # marker so we don’t patch twice in the same kernel
104
- _pd.core.generic.NDFrame._patch_safe_reduce = True
105
-
106
- if "_pandas_sum_patched" not in exec_namespace:
107
-
108
- if not hasattr(_pd.core.generic.NDFrame, "_orig_sum"):
109
- _pd.core.generic.NDFrame._orig_sum = _pd.core.generic.NDFrame.sum
110
- _pd.core.generic.NDFrame._orig_mean = _pd.core.generic.NDFrame.mean # ← NEW
111
-
112
- def _safe_agg(orig_func):
113
- def wrapper(self, *args, **kwargs):
114
- try:
115
- return orig_func(self, *args, **kwargs)
116
-
117
- except TypeError as exc:
118
- # Only rescue the classic mixed-dtype failure
119
- if ("can only concatenate" not in str(exc) and
120
- "could not convert" not in str(exc)):
121
- raise
122
-
123
- # Caller already gave numeric_only → we must not override
124
- if "numeric_only" in kwargs or len(args) >= 3:
125
- raise
126
-
127
- kwargs = dict(kwargs)
128
- kwargs["numeric_only"] = True
129
- return orig_func(self, *args, **kwargs)
130
- return wrapper
131
-
132
- _pd.core.generic.NDFrame.sum = _safe_agg(_pd.core.generic.NDFrame._orig_sum)
133
- _pd.core.generic.NDFrame.mean = _safe_agg(_pd.core.generic.NDFrame._orig_mean)
134
-
135
- exec_namespace["_pandas_sum_patched"] = True
136
-
137
- # inject cached df if we have one
138
- if _df_cache is not None:
139
- exec_namespace["df"] = _df_cache
140
-
141
- try:
142
- # Prevent any print()/stdout/stderr from hitting your server console
143
- _buf_out, _buf_err = io.StringIO(), io.StringIO()
144
- with contextlib.redirect_stdout(_buf_out), contextlib.redirect_stderr(_buf_err):
145
- exec(code, exec_namespace, exec_namespace)
146
-
147
- _local_stdout = _buf_out.getvalue()
148
- _local_stderr = _buf_err.getvalue()
149
-
150
- # ── show a friendly “missing package” hint ────
151
- except (ModuleNotFoundError, ImportError) as e:
152
- missing = getattr(e, "name", None) or str(e).split("'")[1]
153
- hint = (
154
-
155
- f"<div style='color:red; font-weight:bold;'>"
156
- f"Missing package: <code>{missing}</code><br>"
157
- f"Activate this virtual-env and run:<br>"
158
- f"<code>pip install {missing}</code><br>"
159
- f"then re-run your query.</div>"
160
- )
161
- return [hint], []
162
-
163
- except Exception:
164
- pass
79
+ _df_cache = None
165
80
 
166
- # cache df for next call
167
- if "df" in exec_namespace:
168
- _df_cache = exec_namespace["df"]
81
+ def execute_code_in_kernel(kc, code, timeout=120):
169
82
 
170
- # Auto-import display if needed (original code) :contentReference[oaicite:0]{index=0}
83
+ code = SyntaxMatrixKernelManager._prefer_display_over_to_html(code)
171
84
  if "display(" in code and "from IPython.display import display" not in code:
172
85
  code = "from IPython.display import display\n" + code
173
86
 
174
- # ------------------------------------------------------------------
175
- # everything below is the original while-loop message collector
176
87
  msg_id = kc.execute(
177
88
  code,
178
89
  user_expressions={"_last": "(_)",},
@@ -211,18 +122,6 @@ def execute_code_in_kernel(kc, code, timeout=120):
211
122
  if txt:
212
123
  output_blocks.append(f"<pre>{_html.escape(txt)}</pre>")
213
124
 
214
- # elif mtype in ("execute_result", "display_data"):
215
- # data = content["data"]
216
- # if "text/html" in data:
217
- # output_blocks.append(data["text/html"])
218
- # elif "image/png" in data:
219
- # output_blocks.append(
220
- # f"<img src='data:image/png;base64,{data['image/png']}' "
221
- # f"style='max-width:100%;'/>"
222
- # )
223
- # else:
224
- # output_blocks.append(f"<pre>{data.get('text/plain','')}</pre>")
225
-
226
125
  elif mtype in ("execute_result", "display_data"):
227
126
  data = content.get("data", {})
228
127
  if "text/html" in data:
@@ -246,38 +145,163 @@ def execute_code_in_kernel(kc, code, timeout=120):
246
145
  # keep the traceback html-friendly
247
146
  traceback_html = "<br>".join(content["traceback"])
248
147
  errors.append(f"<pre style='color:red;'>{traceback_html}</pre>")
249
- # --- surface the locally captured commentary (stdout/stderr) back to the UI ---
250
- if _local_stdout.strip():
251
- # Put commentary first so the user sees it above plots/tables
252
- output_blocks.insert(0, f"<pre>{_html.escape(_local_stdout)}</pre>")
253
- if _local_stderr.strip():
254
- errors.insert(0, f"<pre style='color:#b00;'>{_html.escape(_local_stderr)}</pre>")
255
148
 
256
149
  def _smx_strip_display_reprs(text: str) -> str:
257
- if not text:
258
- return text
259
- # remove tokens like "<IPython.core.display.HTML object>"
260
- text = _re.sub(r"<IPython\.core\.display\.[A-Za-z]+\s+object>", "", text)
261
- # if these were printed as lists, remove leftover brackets/commas
262
- text = _re.sub(r"[\[\],]", " ", text)
263
- # collapse whitespace
264
- text = _re.sub(r"\s+", " ", text).strip()
150
+ """Remove noisy Jupyter display reprs while keeping line breaks readable."""
151
+ if text is None:
152
+ return ""
153
+ # Strip common display wrappers that sometimes leak into stdout
154
+ text = text.replace("<IPython.core.display.HTML object>", "")
155
+ text = text.replace("<IPython.core.display.Markdown object>", "")
156
+ text = text.replace("<IPython.core.display.Image object>", "")
157
+
158
+ # Normalise line endings but KEEP newlines
159
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
160
+
161
+ # Collapse runs of spaces/tabs per line (do not flatten newlines)
162
+ lines = []
163
+ for ln in text.split("\n"):
164
+ ln = _re.sub(r"[\t ]{2,}", " ", ln).rstrip()
165
+ lines.append(ln)
166
+
167
+ text = "\n".join(lines).strip()
168
+
169
+ # Avoid huge blank sections
170
+ text = _re.sub(r"\n{3,}", "\n\n", text)
265
171
  return text
266
172
 
173
+ def _sanitize_table_html(table_html: str) -> str:
174
+ """Best-effort sanitiser for DataFrame-style HTML tables (blocks scripts/events)."""
175
+ if not table_html:
176
+ return ""
177
+ # Remove scripts and styles outright
178
+ table_html = _re.sub(
179
+ r"<\s*script[^>]*>.*?<\s*/\s*script\s*>",
180
+ "",
181
+ table_html,
182
+ flags=_re.IGNORECASE | _re.DOTALL,
183
+ )
184
+ table_html = _re.sub(
185
+ r"<\s*style[^>]*>.*?<\s*/\s*style\s*>",
186
+ "",
187
+ table_html,
188
+ flags=_re.IGNORECASE | _re.DOTALL,
189
+ )
190
+
191
+ # Drop inline event handlers (onload=, onclick=, etc.)
192
+ table_html = _re.sub(r"\son\w+\s*=\s*\"[^\"]*\"", "", table_html, flags=_re.IGNORECASE)
193
+ table_html = _re.sub(r"\son\w+\s*=\s*\'[^\']*\'", "", table_html, flags=_re.IGNORECASE)
194
+
195
+ # Block javascript: URLs
196
+ table_html = _re.sub(r"javascript\s*:", "", table_html, flags=_re.IGNORECASE)
197
+ return table_html.strip()
198
+
199
+
200
+ def _smx_ensure_table_class(table_html: str) -> str:
201
+ """Ensure DataFrame tables pick up the dashboard's .smx-table styling."""
202
+ if not table_html:
203
+ return ""
204
+
205
+ def _inject_class(m):
206
+ tag = m.group(0)
207
+
208
+ # If there's already a class="", append smx-table
209
+ if _re.search(r"\bclass\s*=", tag, flags=_re.IGNORECASE):
210
+ tag = _re.sub(
211
+ r'(class\s*=\s*["\'])([^"\']*)(["\'])',
212
+ lambda mm: f"{mm.group(1)}{mm.group(2)} smx-table{mm.group(3)}",
213
+ tag,
214
+ count=1,
215
+ flags=_re.IGNORECASE,
216
+ )
217
+ else:
218
+ # add class attr
219
+ tag = tag[:-1] + ' class="smx-table">'
220
+
221
+ # Remove noisy border attr if present (optional)
222
+ tag = _re.sub(r'\sborder\s*=\s*["\']?\d+["\']?', "", tag, flags=_re.IGNORECASE)
223
+ return tag
224
+
225
+ return _re.sub(r"<table\b[^>]*>", _inject_class, table_html, count=1, flags=_re.IGNORECASE)
226
+
227
+ def _smx_wrap_df_table(table_html: str, max_h: int = 460) -> str:
228
+ """
229
+ Wrap table in a scroll box and apply the same table class the dashboard already styles.
230
+ """
231
+ table_html = _smx_ensure_table_class(table_html)
232
+ if not table_html:
233
+ return ""
234
+ return (
235
+ f"<div class='smx-scroll' style='overflow:auto; max-height:{int(max_h)}px;'>"
236
+ f"{table_html}"
237
+ f"</div>"
238
+ )
239
+
240
+ def _split_text_and_tables(text: str):
241
+ """Yield (kind, chunk) where kind is 'text' or 'table'."""
242
+ if not text:
243
+ return [("text", "")]
244
+ out = []
245
+ last = 0
246
+ for m in _TABLE_RE.finditer(text):
247
+ if m.start() > last:
248
+ out.append(("text", text[last:m.start()]))
249
+ out.append(("table", m.group(1)))
250
+ last = m.end()
251
+ if last < len(text):
252
+ out.append(("text", text[last:]))
253
+ return out
254
+
255
+ _TABLE_RE = _re.compile(r"(<table\b.*?</table>)", flags=_re.IGNORECASE | _re.DOTALL)
256
+
267
257
  _cleaned_blocks = []
268
258
  for blk in output_blocks:
269
259
  # pre-wrapped plaintext
270
260
  if blk.startswith("<pre>") and blk.endswith("</pre>"):
271
261
  inner = blk[5:-6]
272
- inner = _smx_strip_display_reprs(_html.unescape(inner))
273
- if inner:
274
- _cleaned_blocks.append(f"<pre>{_html.escape(inner)}</pre>")
275
- # if empty after cleaning, drop it
262
+ inner = _html.unescape(inner)
263
+
264
+ # If stdout contains HTML tables (e.g., DataFrame.to_html()), render them as tables
265
+ # rather than showing the raw <table> markup inside a <pre> block.
266
+ low = inner.lower()
267
+ if "<table" in low and "</table>" in low:
268
+ parts = _split_text_and_tables(inner)
269
+ else:
270
+ parts = [("text", inner)]
271
+
272
+ for kind, chunk in parts:
273
+ if not chunk:
274
+ continue
275
+ if kind == "table":
276
+ safe_tbl = _sanitize_table_html(chunk)
277
+ if safe_tbl:
278
+ _cleaned_blocks.append(_smx_wrap_df_table(safe_tbl))
279
+ else:
280
+ txt = _smx_strip_display_reprs(chunk)
281
+ if txt:
282
+ _cleaned_blocks.append(f"<pre>{_html.escape(txt)}</pre>")
283
+
276
284
  continue
277
285
 
278
286
  # html/img payloads: just remove stray repr tokens if they slipped in
279
287
  cleaned = _re.sub(r"<IPython\.core\.display\.[A-Za-z]+\s+object>", "", blk)
280
- _cleaned_blocks.append(cleaned)
288
+ low = cleaned.lower()
289
+ if "<table" in low and "</table>" in low:
290
+ parts = _split_text_and_tables(cleaned)
291
+ for kind, chunk in parts:
292
+ if not chunk:
293
+ continue
294
+ if kind == "table":
295
+ safe_tbl = _sanitize_table_html(chunk)
296
+ wrapped = _smx_wrap_df_table(safe_tbl)
297
+ if wrapped:
298
+ _cleaned_blocks.append(wrapped)
299
+ else:
300
+ # keep non-table html as-is
301
+ if chunk.strip():
302
+ _cleaned_blocks.append(chunk)
303
+ else:
304
+ _cleaned_blocks.append(cleaned)
281
305
  output_blocks = _cleaned_blocks
282
306
 
283
307
  return output_blocks, errors