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
@@ -150,10 +150,32 @@ def _s(x: Any) -> str:
150
150
  return x.strip() if isinstance(x, str) else ""
151
151
 
152
152
  def _clean_ws(s: str) -> str:
153
- s = s.replace("\x00", "")
153
+ """Single-line clean (titles, ids, etc.)."""
154
+ s = (s or "").strip()
154
155
  s = re.sub(r"\s+", " ", s).strip()
155
156
  return s
156
157
 
158
+
159
+ def _clean_text_block(s: str) -> str:
160
+ """
161
+ Preserve new lines for body text.
162
+ - Normalise line endings
163
+ - Collapse repeated spaces/tabs *within a line*
164
+ - Keep paragraph breaks
165
+ """
166
+ s = (s or "")
167
+ s = s.replace("\r\n", "\n").replace("\r", "\n")
168
+
169
+ lines = []
170
+ for ln in s.split("\n"):
171
+ ln = re.sub(r"[ \t]{2,}", " ", ln).rstrip()
172
+ lines.append(ln)
173
+
174
+ out = "\n".join(lines).strip()
175
+ out = re.sub(r"\n{3,}", "\n\n", out) # cap huge blank gaps
176
+ return out
177
+
178
+
157
179
  def _has_dangerous(s: str) -> bool:
158
180
  return bool(DANGEROUS_RE.search(s or ""))
159
181
 
@@ -284,7 +306,7 @@ def normalise_layout(
284
306
 
285
307
  # Strings
286
308
  s2["title"] = _clean_ws(_s(s2.get("title")))
287
- s2["text"] = _clean_ws(_s(s2.get("text")))
309
+ s2["text"] = _clean_text_block(_s(s2.get("text")))
288
310
 
289
311
  # Items
290
312
  items = s2.get("items")
@@ -298,7 +320,7 @@ def normalise_layout(
298
320
  it2["id"] = _safe_id(_s(it2.get("id"))) or f"item_{sid}_{j+1}"
299
321
  it2["type"] = _safe_id(_s(it2.get("type"))) or "card"
300
322
  it2["title"] = _clean_ws(_s(it2.get("title")))
301
- it2["text"] = _clean_ws(_s(it2.get("text")))
323
+ it2["text"] = _clean_text_block(_s(it2.get("text")))
302
324
  if "imageUrl" in it2:
303
325
  it2["imageUrl"] = _clean_ws(_s(it2.get("imageUrl")))
304
326
  items2.append(it2)
@@ -4,6 +4,76 @@ from typing import Any, Dict, Tuple, List, Optional
4
4
  from bs4 import BeautifulSoup
5
5
 
6
6
 
7
+ _RICH_DROP_TAGS = {"script", "style", "iframe", "object", "embed", "link", "meta", "base"}
8
+
9
+ def _safe_text_align(v: str) -> str:
10
+ v = (v or "").strip().lower()
11
+ if v == "centre":
12
+ v = "center"
13
+ return v if v in ("left", "center", "right", "justify") else ""
14
+
15
+ def _sanitize_rich_fragment(html: str) -> str:
16
+ """
17
+ Best-effort sanitiser for rich text coming from the page editor.
18
+ Removes scripts/events and javascript/data URLs.
19
+ """
20
+ html = (html or "").strip()
21
+ if not html:
22
+ return ""
23
+
24
+ frag = BeautifulSoup(html, "html.parser")
25
+
26
+ # Drop dangerous container tags entirely
27
+ for t in frag.find_all(list(_RICH_DROP_TAGS)):
28
+ t.decompose()
29
+
30
+ # Scrub attributes
31
+ for tag in frag.find_all(True):
32
+ # remove inline on* handlers
33
+ for k in list(tag.attrs.keys()):
34
+ if k.lower().startswith("on"):
35
+ del tag.attrs[k]
36
+
37
+ # block javascript:/data: URLs
38
+ for url_attr in ("href", "src"):
39
+ if url_attr in tag.attrs:
40
+ v = str(tag.attrs.get(url_attr) or "").strip().lower()
41
+ if v.startswith("javascript:") or v.startswith("data:"):
42
+ del tag.attrs[url_attr]
43
+
44
+ # scrub risky style content
45
+ if "style" in tag.attrs:
46
+ st = str(tag.attrs.get("style") or "")
47
+ low = st.lower()
48
+ if "expression(" in low or "javascript:" in low:
49
+ del tag.attrs["style"]
50
+ else:
51
+ # remove url(...) to avoid surprises
52
+ st = re.sub(r"url\s*\([^)]*\)", "", st, flags=re.IGNORECASE)
53
+ tag.attrs["style"] = st
54
+
55
+ return "".join(str(c) for c in frag.contents).strip()
56
+
57
+ def _set_inner_html(tag, html: str):
58
+ tag.clear()
59
+ frag = BeautifulSoup(html or "", "html.parser")
60
+ for child in list(frag.contents):
61
+ tag.append(child)
62
+
63
+ def _set_text_with_breaks(soup, tag, text: str):
64
+ """
65
+ Put plain text into an element but keep user newlines visible.
66
+ """
67
+ tag.clear()
68
+ text = text or ""
69
+ lines = text.splitlines()
70
+ for i, ln in enumerate(lines):
71
+ if i > 0:
72
+ tag.append(soup.new_tag("br"))
73
+ if ln:
74
+ tag.append(ln)
75
+
76
+
7
77
  _SECTION_BY_ID_RE = r'(<section\b[^>]*\bid="{sid}"[^>]*>)(.*?)(</section>)'
8
78
  _SECTION_OPEN_RE = re.compile(r"<section\b[^>]*\bid=['\"]([^'\"]+)['\"][^>]*>", re.IGNORECASE)
9
79
 
@@ -309,10 +379,24 @@ def _default_card_node(soup, it: Dict[str, Any]):
309
379
  row.append(h3)
310
380
  card.append(row)
311
381
 
312
- p = soup.new_tag("p")
313
- p["style"] = "margin-top:8px;"
314
- p.string = it_text
315
- card.append(p)
382
+ it_text_html = (it.get("textHtml") or "").strip()
383
+
384
+ # Body: prefer rich HTML if provided, otherwise plain text
385
+ if it_text_html:
386
+ body = soup.new_tag("div")
387
+ body["class"] = ["smx-rich"]
388
+ body["data-smx"] = "card-body"
389
+ body["style"] = "margin-top:8px;"
390
+ safe = _sanitize_rich_fragment(it_text_html)
391
+ if safe:
392
+ _set_inner_html(body, safe)
393
+ card.append(body)
394
+ else:
395
+ p = soup.new_tag("p")
396
+ p["data-smx"] = "card-text"
397
+ p["style"] = "margin-top:8px;"
398
+ _set_text_with_breaks(soup, p, it_text)
399
+ card.append(p)
316
400
 
317
401
  return card
318
402
 
@@ -363,11 +447,39 @@ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> b
363
447
  a.clear(); a.append(label); changed = True
364
448
  if a.get("href") != href:
365
449
  a["href"] = href; changed = True
450
+
451
+ # Open exported HTML results in a new tab (client docs assets)
452
+ href = str(it_href or "").strip()
453
+ auto_newtab = href.startswith("/docs/") and href.lower().endswith(".html")
454
+
455
+ open_new = bool(
456
+ auto_newtab
457
+ or it.get("openInNewTab")
458
+ or it.get("newTab")
459
+ or (str(it.get("target") or "").strip().lower() == "_blank")
460
+ )
461
+
462
+ if open_new:
463
+ if a.get("target") != "_blank":
464
+ a["target"] = "_blank"; changed = True
465
+ # keep rel consistent
466
+ if a.get("rel") != "noopener":
467
+ a["rel"] = "noopener"; changed = True
468
+ else:
469
+ if a.has_attr("target"):
470
+ del a["target"]; changed = True
471
+ if a.has_attr("rel"):
472
+ del a["rel"]; changed = True
473
+
366
474
  continue
367
475
 
368
- # normal card patch (existing behaviour)
476
+ # normal card patch (existing behaviour)
369
477
  it_title = (it.get("title") or "").strip()
370
478
  it_text = (it.get("text") or "").strip()
479
+ it_text_html = (it.get("textHtml") or "").strip()
480
+ title_align = _safe_text_align(it.get("titleAlign") or it.get("title_align") or "")
481
+ body_align = _safe_text_align(it.get("bodyAlign") or it.get("body_align") or "")
482
+
371
483
  it_img = (it.get("imageUrl") or "").strip()
372
484
  it_href = _safe_href(it.get("href") or "")
373
485
  cta_lbl = (it.get("ctaLabel") or "Read more").strip() or "Read more"
@@ -391,15 +503,65 @@ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> b
391
503
 
392
504
  # title
393
505
  h = node.find(["h3", "h4"]) or node.find(["h2", "h3", "h4"])
394
- if h and it_title and h.get_text(strip=True) != it_title:
395
- h.clear(); h.append(it_title); changed = True
506
+ if h and it_title:
507
+ cur_title = h.get_text("\n", strip=False).strip()
508
+ if cur_title != it_title:
509
+ _set_text_with_breaks(soup, h, it_title)
510
+ changed = True
511
+ if title_align:
512
+ h["style"] = _merge_inline_style(h.get("style") or "", {"text-align": title_align})
513
+
514
+ # body (prefer rich)
515
+ body = (
516
+ node.find(attrs={"data-smx": "card-body"})
517
+ or node.find("div", class_=lambda c: c and "smx-rich" in str(c))
518
+ or node.find("p")
519
+ )
520
+
521
+ if it_text_html:
522
+ safe = _sanitize_rich_fragment(it_text_html)
523
+
524
+ # ensure we have a div container for rich HTML
525
+ if body is None or body.name != "div":
526
+ new_body = soup.new_tag("div")
527
+ new_body["class"] = ["smx-rich"]
528
+ new_body["data-smx"] = "card-body"
529
+ new_body["style"] = "margin-top:8px;"
530
+ if body is not None:
531
+ body.replace_with(new_body)
532
+ else:
533
+ node.append(new_body)
534
+ body = new_body
535
+ changed = True
536
+
537
+ cur_html = "".join(str(c) for c in body.contents).strip()
538
+ if safe and cur_html != safe:
539
+ _set_inner_html(body, safe)
540
+ changed = True
541
+
542
+ else:
543
+ # plain text path (keep newlines visible)
544
+ if body is None or body.name != "p":
545
+ new_p = soup.new_tag("p")
546
+ new_p["data-smx"] = "card-text"
547
+ new_p["style"] = "margin-top:8px;"
548
+ if body is not None:
549
+ body.replace_with(new_p)
550
+ else:
551
+ node.append(new_p)
552
+ body = new_p
553
+ changed = True
554
+
555
+ cur_text = body.get_text("\n", strip=False).strip()
556
+ if it_text and cur_text != it_text:
557
+ _set_text_with_breaks(soup, body, it_text)
558
+ changed = True
396
559
 
397
- # text
398
- p = node.find("p") or node.select_one(".mut")
399
- if p and it_text and p.get_text(" ", strip=True) != it_text:
400
- p.clear(); p.append(it_text); changed = True
560
+ # body alignment
561
+ if body_align and body is not None:
562
+ body["style"] = _merge_inline_style(body.get("style") or "", {"text-align": body_align})
401
563
 
402
- # card CTA link (Read more) + alignment
564
+ # card CTA link (Read more) + alignment
403
565
  align = (it.get("ctaAlign") or it.get("cta_align") or "").strip().lower() or "left"
404
566
  if align == "centre":
405
567
  align = "center"
@@ -729,6 +891,104 @@ def _patch_testimonials(sec, soup, items: List[Dict[str, Any]], cols: int) -> bo
729
891
  grid["style"] = f"grid-template-columns:repeat({max(1, min(cols, 3))}, minmax(0,1fr));"
730
892
  return changed
731
893
 
894
+ def _patch_gallery_items(sec, soup, items: List[Dict[str, Any]]) -> bool:
895
+ """
896
+ Patch a gallery section so image items render as true gallery tiles (figure.gimg)
897
+ compatible with compile_layout_to_html/gallery_js.
898
+ """
899
+ grid = sec.select_one(".grid")
900
+ if grid is None:
901
+ wrap = sec.select_one(".wrap") or sec
902
+ grid = soup.new_tag("div")
903
+ grid["class"] = ["grid"]
904
+ wrap.append(grid)
905
+
906
+ changed = False
907
+
908
+ # Only manage direct children of grid
909
+ children = [c for c in grid.find_all(True, recursive=False)]
910
+
911
+ def make_node(it: Dict[str, Any]):
912
+ it = it if isinstance(it, dict) else {}
913
+ it_type = (it.get("type") or "card").strip().lower()
914
+
915
+ title_plain = (it.get("title") or "").strip()
916
+ text_plain = (it.get("text") or "").strip()
917
+
918
+ img_full = (it.get("imageUrl") or "").strip()
919
+ img_thumb = (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
920
+ src = img_thumb or img_full
921
+
922
+ # TRUE gallery image tile
923
+ if it_type == "image" and src:
924
+ fig = soup.new_tag("figure")
925
+ fig["class"] = ["gimg", "reveal"]
926
+ fig["data-item-type"] = "image"
927
+ fig["data-full"] = img_full or src
928
+ fig["data-title"] = title_plain
929
+ fig["data-caption"] = text_plain
930
+
931
+ im = soup.new_tag("img")
932
+ im["loading"] = "lazy"
933
+ im["decoding"] = "async"
934
+ im["src"] = src
935
+ im["data-full"] = img_full or src
936
+ im["alt"] = title_plain or "image"
937
+ fig.append(im)
938
+
939
+ if title_plain:
940
+ cap = soup.new_tag("figcaption")
941
+ cap.string = title_plain
942
+ fig.append(cap)
943
+
944
+ return fig
945
+
946
+ # Otherwise render as a normal card (content card inside rail)
947
+ node = _default_card_node(soup, it)
948
+ node["data-item-type"] = it_type or "card"
949
+ return node
950
+
951
+ n = len(items)
952
+
953
+ # Sync child count
954
+ if len(children) < n:
955
+ for i in range(len(children), n):
956
+ grid.append(make_node(items[i]))
957
+ changed = True
958
+ children = [c for c in grid.find_all(True, recursive=False)]
959
+
960
+ if len(children) > n:
961
+ for extra in children[n:]:
962
+ extra.decompose()
963
+ changed = True
964
+ children = [c for c in grid.find_all(True, recursive=False)]
965
+
966
+ # Patch / replace nodes if type mismatches
967
+ for i in range(n):
968
+ it = items[i] if isinstance(items[i], dict) else {}
969
+ want_type = (it.get("type") or "card").strip().lower()
970
+
971
+ node = children[i]
972
+ is_fig = (node.name == "figure" and "gimg" in (node.get("class") or []))
973
+ want_fig = (want_type == "image" and bool((it.get("thumbUrl") or it.get("thumb_url") or it.get("imageUrl") or "").strip()))
974
+
975
+ if want_fig and not is_fig:
976
+ node.replace_with(make_node(it))
977
+ changed = True
978
+ continue
979
+
980
+ if (not want_fig) and is_fig:
981
+ node.replace_with(make_node(it))
982
+ changed = True
983
+ continue
984
+
985
+ # For simplicity and correctness, refresh the node content by replacement
986
+ # (keeps behaviour consistent with compile_layout_to_html)
987
+ node.replace_with(make_node(it))
988
+ changed = True
989
+
990
+ return changed
991
+
732
992
 
733
993
  def _patch_items_in_section_by_id(
734
994
  html: str,
@@ -758,11 +1018,14 @@ def _patch_items_in_section_by_id(
758
1018
  changed = _patch_testimonials(sec, soup, items, cols=cols)
759
1019
  return str(soup), changed
760
1020
 
1021
+ if st == "gallery":
1022
+ changed = _patch_gallery_items(sec, soup, items)
1023
+ return str(soup), changed
1024
+
761
1025
  # default cards grid: features/gallery/cta/richtext/anything else that uses cards
762
1026
  changed = _patch_default_cards(sec, soup, items, cols=cols)
763
1027
  return str(soup), changed
764
1028
 
765
-
766
1029
  def _patch_hero(html: str, hero_section: Dict[str, Any]) -> Tuple[str, bool]:
767
1030
  """
768
1031
  Patch hero bg image + <h1> + lead paragraph inside the hero section.
@@ -1350,6 +1613,84 @@ def _patch_section_styles(existing_html: str, layout: dict) -> tuple[str, bool]:
1350
1613
  out = str(soup)
1351
1614
  return out, changed
1352
1615
 
1616
+ def _ensure_gallery_assets(layout: dict) -> tuple[dict, bool]:
1617
+ """
1618
+ Normalise gallery sections so that image additions behave like true gallery tiles.
1619
+
1620
+ Rule:
1621
+ - In a section with type == "gallery", any item that has an image (imageUrl/thumbUrl/imgQuery)
1622
+ but is typed as "card" (or missing type) is converted to type="image" by default.
1623
+ - We preserve title/text, but keep them short (gallery tiles should be image-first).
1624
+ """
1625
+ changed = False
1626
+ if not isinstance(layout, dict):
1627
+ return layout, changed
1628
+
1629
+ secs = layout.get("sections")
1630
+ if not isinstance(secs, list):
1631
+ return layout, changed
1632
+
1633
+ def _has_img(it: dict) -> bool:
1634
+ return bool(
1635
+ (it.get("imageUrl") or "").strip()
1636
+ or (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
1637
+ or (it.get("imgQuery") or "").strip()
1638
+ or bool(it.get("needsImage"))
1639
+ )
1640
+
1641
+ def _meaningful_text(s: str) -> bool:
1642
+ s = (s or "").strip()
1643
+ return len(s) >= 20 # heuristic: longer text suggests a content card
1644
+
1645
+ for sec in secs:
1646
+ if not isinstance(sec, dict):
1647
+ continue
1648
+ if str(sec.get("type") or "").strip().lower() != "gallery":
1649
+ continue
1650
+
1651
+ items = sec.get("items")
1652
+ if not isinstance(items, list):
1653
+ continue
1654
+
1655
+ for it in items:
1656
+ if not isinstance(it, dict):
1657
+ continue
1658
+
1659
+ it_type = str(it.get("type") or "").strip().lower()
1660
+ if it_type == "image":
1661
+ continue
1662
+
1663
+ if not _has_img(it):
1664
+ continue
1665
+
1666
+ # If it looks like a content card (long text), leave it as card.
1667
+ # Otherwise treat it as a gallery image tile.
1668
+ title = (it.get("title") or "").strip()
1669
+ text = (it.get("text") or "").strip()
1670
+
1671
+ if _meaningful_text(text):
1672
+ # keep as card: someone likely wants a case-study style card inside the gallery rail
1673
+ if not it_type:
1674
+ it["type"] = "card"
1675
+ changed = True
1676
+ continue
1677
+
1678
+ it["type"] = "image"
1679
+ changed = True
1680
+
1681
+ # Optional: keep gallery captions concise (avoid feature-card paragraphs)
1682
+ if len(text) > 110:
1683
+ it["text"] = text[:107].rstrip() + "…"
1684
+
1685
+ if len(title) > 60:
1686
+ it["title"] = title[:57].rstrip() + "…"
1687
+
1688
+ # Ensure fields exist (helps downstream)
1689
+ it.setdefault("thumbUrl", it.get("thumb_url", "") or "")
1690
+ it.setdefault("imageUrl", it.get("imageUrl", "") or "")
1691
+
1692
+ return layout, changed
1693
+
1353
1694
 
1354
1695
  def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Optional[str] = None) -> Tuple[str, Dict[str, int]]:
1355
1696
  """
@@ -1360,6 +1701,11 @@ def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Op
1360
1701
  stats = {"hero": 0, "sections": 0, "skipped": 0}
1361
1702
 
1362
1703
  if not existing_html or not isinstance(layout, dict):
1704
+ # Inject Gallery rail + lightbox behaviour ONLY if this page has a gallery section
1705
+ existing_html, gallery_injected = _ensure_gallery_assets(existing_html)
1706
+ if gallery_injected:
1707
+ stats["gallery_assets_injected"] = 1
1708
+
1363
1709
  return existing_html, stats
1364
1710
 
1365
1711
  sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
@@ -1470,8 +1816,15 @@ def _remove_deleted_sections(existing_html: str, layout: Dict[str, Any], *, page
1470
1816
  if not sid:
1471
1817
  continue
1472
1818
 
1473
- # Only touch builder sections.
1474
- is_builder_section = sid.startswith("sec_") or sec.has_attr("data-section-type")
1819
+ cls = [c for c in (sec.get("class") or []) if isinstance(c, str)]
1820
+
1821
+ # Treat generator sections as builder-owned even if the ID is custom.
1822
+ is_builder_section = (
1823
+ sid.startswith("sec_")
1824
+ or sec.has_attr("data-section-type")
1825
+ or ("sec" in cls)
1826
+ or ("hero" in cls)
1827
+ )
1475
1828
  if not is_builder_section:
1476
1829
  continue
1477
1830
 
File without changes
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+
10
+ def _safe_json_loads(raw: str, *, default: Any) -> Any:
11
+ try:
12
+ return json.loads(raw)
13
+ except Exception:
14
+ return default
15
+
16
+
17
+ @dataclass
18
+ class PluginSpec:
19
+ """A single plugin to load.
20
+
21
+ module: python module path (e.g. 'syntaxmatrix_premium_cloud_db')
22
+ name: entitlement key (e.g. 'cloud_db')
23
+ """
24
+
25
+ name: str
26
+ module: str
27
+
28
+
29
+ class PluginManager:
30
+ """Loads optional plugins (typically premium) in a controlled, safe way."""
31
+
32
+ ENV_PLUGINS = "SMX_PREMIUM_PLUGINS" # JSON list of {name,module}
33
+ DB_PLUGINS_KEY = "premium.plugins" # JSON list of {name,module}
34
+
35
+ def __init__(self, smx: object, *, gate: Optional[object] = None, db: Optional[object] = None):
36
+ self._smx = smx
37
+ self._gate = gate
38
+ self._db = db
39
+ self.loaded: Dict[str, str] = {} # name -> module
40
+ self.errors: List[str] = []
41
+
42
+ def _specs_from_env(self) -> List[PluginSpec]:
43
+ raw = os.environ.get(self.ENV_PLUGINS, "").strip()
44
+ if not raw:
45
+ return []
46
+ data = _safe_json_loads(raw, default=[])
47
+ return self._coerce_specs(data)
48
+
49
+ def _specs_from_db(self) -> List[PluginSpec]:
50
+ if not self._db:
51
+ return []
52
+ get_setting = getattr(self._db, "get_setting", None)
53
+ if not callable(get_setting):
54
+ return []
55
+ raw = get_setting(self.DB_PLUGINS_KEY, "[]")
56
+ data = _safe_json_loads(str(raw or "[]"), default=[])
57
+ return self._coerce_specs(data)
58
+
59
+ def _coerce_specs(self, data: Any) -> List[PluginSpec]:
60
+ out: List[PluginSpec] = []
61
+ if isinstance(data, list):
62
+ for row in data:
63
+ if not isinstance(row, dict):
64
+ continue
65
+ name = str(row.get("name") or "").strip()
66
+ module = str(row.get("module") or "").strip()
67
+ if name and module:
68
+ out.append(PluginSpec(name=name, module=module))
69
+ return out
70
+
71
+ def _entitled(self, name: str) -> bool:
72
+ if not self._gate:
73
+ return True # if no gate configured, don't block
74
+ enabled = getattr(self._gate, "enabled", None)
75
+ if not callable(enabled):
76
+ return True
77
+ try:
78
+ return bool(enabled(name))
79
+ except Exception:
80
+ return False
81
+
82
+ def load_all(self) -> Tuple[Dict[str, str], List[str]]:
83
+ """Load all configured plugins. Returns (loaded, errors)."""
84
+ specs = self._specs_from_env()
85
+ if not specs:
86
+ specs = self._specs_from_db()
87
+
88
+ for spec in specs:
89
+ if not self._entitled(spec.name):
90
+ continue
91
+ if spec.name in self.loaded:
92
+ continue
93
+ self._load_one(spec)
94
+
95
+ return self.loaded, self.errors
96
+
97
+ def _load_one(self, spec: PluginSpec) -> None:
98
+ try:
99
+ mod = importlib.import_module(spec.module)
100
+ except Exception as e:
101
+ self.errors.append(f"{spec.name}: import failed: {e}")
102
+ return
103
+
104
+ # Plugin contract: module exposes register(smx) -> None
105
+ reg = getattr(mod, "register", None)
106
+ if not callable(reg):
107
+ self.errors.append(f"{spec.name}: missing register(smx) function")
108
+ return
109
+
110
+ try:
111
+ reg(self._smx)
112
+ self.loaded[spec.name] = spec.module
113
+ except Exception as e:
114
+ self.errors.append(f"{spec.name}: register() failed: {e}")
@@ -0,0 +1,18 @@
1
+ """Premium support.
2
+
3
+ This package contains runtime plumbing for premium features (entitlements +
4
+ plugin loading).
5
+
6
+ Key idea:
7
+ - FeatureGate reads a single entitlement dict from env/db/licence.json
8
+ - ensure_premium_state() resolves trial + plan entitlements and writes them
9
+ into app_settings so the rest of the app can safely gate routes + navbar.
10
+
11
+ Premium implementations (e.g. non-SQLite DB backends) should live in separate,
12
+ private distributions.
13
+ """
14
+
15
+ from .gate import FeatureGate
16
+ from .state import ensure_premium_state, PremiumState
17
+
18
+ __all__ = ["FeatureGate", "ensure_premium_state", "PremiumState"]