syntaxmatrix 2.6.4.3__py3-none-any.whl → 2.6.4.4__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.
- syntaxmatrix/core.py +18 -2
- syntaxmatrix/db.py +77 -0
- syntaxmatrix/db_backends/__init__.py +1 -0
- syntaxmatrix/db_backends/postgres_backend.py +14 -0
- syntaxmatrix/db_backends/sqlite_backend.py +258 -0
- syntaxmatrix/db_contract.py +71 -0
- syntaxmatrix/plugin_manager.py +114 -0
- syntaxmatrix/premium/__init__.py +10 -0
- syntaxmatrix/premium/gate.py +107 -0
- syntaxmatrix/routes.py +2 -2
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-2.6.4.4.dist-info}/METADATA +1 -1
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-2.6.4.4.dist-info}/RECORD +15 -8
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-2.6.4.4.dist-info}/WHEEL +0 -0
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-2.6.4.4.dist-info}/licenses/LICENSE.txt +0 -0
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-2.6.4.4.dist-info}/top_level.txt +0 -0
syntaxmatrix/core.py
CHANGED
|
@@ -34,6 +34,10 @@ from syntaxmatrix.settings.prompts import(
|
|
|
34
34
|
)
|
|
35
35
|
from syntaxmatrix.settings.client_items import read_client_file, getenv_api_key
|
|
36
36
|
|
|
37
|
+
from .premium import FeatureGate
|
|
38
|
+
from .plugin_manager import PluginManager
|
|
39
|
+
|
|
40
|
+
|
|
37
41
|
# ──────── framework‐local storage paths ────────
|
|
38
42
|
# this ensures the key & data always live under the package dir,
|
|
39
43
|
_CLIENT_DIR = detect_project_root()
|
|
@@ -127,6 +131,18 @@ class SyntaxMUI:
|
|
|
127
131
|
self.is_streaming = False
|
|
128
132
|
self.stream_args = {}
|
|
129
133
|
self._apply_feature_flags_from_db()
|
|
134
|
+
# Premium (entitlements + plugins). Safe no-op unless configured.
|
|
135
|
+
try:
|
|
136
|
+
self.feature_gate = FeatureGate(client_dir=_CLIENT_DIR, db=db)
|
|
137
|
+
except Exception:
|
|
138
|
+
self.feature_gate = FeatureGate(client_dir=_CLIENT_DIR)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
self.plugins = PluginManager(self, gate=self.feature_gate, db=db)
|
|
142
|
+
self.plugins.load_all()
|
|
143
|
+
except Exception:
|
|
144
|
+
# Never break app boot because of premium plumbing
|
|
145
|
+
self.plugins = PluginManager(self)
|
|
130
146
|
|
|
131
147
|
self._recent_visual_summaries = []
|
|
132
148
|
|
|
@@ -345,8 +361,8 @@ class SyntaxMUI:
|
|
|
345
361
|
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
|
|
346
362
|
|
|
347
363
|
try:
|
|
348
|
-
stream_v = db.get_setting("feature.stream_mode", "
|
|
349
|
-
user_files_v = db.get_setting("feature.user_files", "
|
|
364
|
+
stream_v = db.get_setting("feature.stream_mode", "1")
|
|
365
|
+
user_files_v = db.get_setting("feature.user_files", "1")
|
|
350
366
|
|
|
351
367
|
self.is_streaming = _truthy(stream_v)
|
|
352
368
|
self.user_files_enabled = _truthy(user_files_v)
|
syntaxmatrix/db.py
CHANGED
|
@@ -6,6 +6,8 @@ from werkzeug.utils import secure_filename
|
|
|
6
6
|
from syntaxmatrix.project_root import detect_project_root
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
_SMX_DB_PROVIDER = (os.environ.get("SMX_DB_PROVIDER") or "sqlite").strip().lower()
|
|
10
|
+
|
|
9
11
|
_CLIENT_DIR = detect_project_root()
|
|
10
12
|
DB_PATH = os.path.join(_CLIENT_DIR, "data", "syntaxmatrix.db")
|
|
11
13
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
@@ -624,3 +626,78 @@ def get_setting(key: str, default: str | None = None) -> str | None:
|
|
|
624
626
|
return row[0] if row else default
|
|
625
627
|
finally:
|
|
626
628
|
conn.close()
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ============================================================================
|
|
632
|
+
# Optional DB backend override (Premium hook)
|
|
633
|
+
#
|
|
634
|
+
# Default behaviour (no env vars): SQLite stays in use exactly as before.
|
|
635
|
+
#
|
|
636
|
+
# To enable a premium backend (e.g. Postgres), set:
|
|
637
|
+
# SMX_DB_PROVIDER=postgres
|
|
638
|
+
#
|
|
639
|
+
# Optional:
|
|
640
|
+
# SMX_DB_BACKEND_MODULE=syntaxmatrix_premium.db_backends.postgres_backend
|
|
641
|
+
#
|
|
642
|
+
# The backend module should either expose:
|
|
643
|
+
# - install(target_globals: dict) -> None (preferred)
|
|
644
|
+
# OR export a compatible surface (functions/constants) that will be copied
|
|
645
|
+
# into this module namespace.
|
|
646
|
+
# ============================================================================
|
|
647
|
+
|
|
648
|
+
def _smx_apply_optional_backend_override() -> None:
|
|
649
|
+
import os
|
|
650
|
+
import importlib
|
|
651
|
+
|
|
652
|
+
provider = (os.getenv("SMX_DB_PROVIDER") or "sqlite").strip().lower()
|
|
653
|
+
if provider in ("", "sqlite", "sqlite3"):
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
# Pick backend module (allow override for custom installations)
|
|
657
|
+
mod_name = (os.getenv("SMX_DB_BACKEND_MODULE") or "").strip()
|
|
658
|
+
if not mod_name:
|
|
659
|
+
if provider in ("postgres", "postgresql", "pg"):
|
|
660
|
+
mod_name = "syntaxmatrix.db_backends.postgres_backend"
|
|
661
|
+
else:
|
|
662
|
+
# Convention: syntaxmatrix.db_backends.<provider>_backend
|
|
663
|
+
mod_name = f"syntaxmatrix.db_backends.{provider}_backend"
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
mod = importlib.import_module(mod_name)
|
|
667
|
+
except Exception as e:
|
|
668
|
+
raise RuntimeError(
|
|
669
|
+
f"SMX_DB_PROVIDER='{provider}' requested, but backend module '{mod_name}' "
|
|
670
|
+
f"could not be imported. Install the premium backend package (or set "
|
|
671
|
+
f"SMX_DB_BACKEND_MODULE) and try again. Underlying error: {e}"
|
|
672
|
+
) from e
|
|
673
|
+
|
|
674
|
+
installer = getattr(mod, "install", None)
|
|
675
|
+
if callable(installer):
|
|
676
|
+
installer(globals())
|
|
677
|
+
else:
|
|
678
|
+
names = getattr(mod, "__all__", None)
|
|
679
|
+
if not names:
|
|
680
|
+
names = [n for n in dir(mod) if not n.startswith("_")]
|
|
681
|
+
for n in names:
|
|
682
|
+
globals()[n] = getattr(mod, n)
|
|
683
|
+
|
|
684
|
+
# Helpful introspection
|
|
685
|
+
globals()["_SMX_DB_PROVIDER"] = provider
|
|
686
|
+
globals()["_SMX_DB_BACKEND_MODULE"] = mod_name
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# Apply on import
|
|
690
|
+
_smx_apply_optional_backend_override()
|
|
691
|
+
|
|
692
|
+
# If a non-SQLite backend was requested, validate the required API surface now.
|
|
693
|
+
try:
|
|
694
|
+
import os as _os
|
|
695
|
+
_provider = (globals().get("_SMX_DB_PROVIDER") or _os.getenv("SMX_DB_PROVIDER") or "sqlite").strip().lower()
|
|
696
|
+
|
|
697
|
+
if _provider not in ("", "sqlite", "sqlite3"):
|
|
698
|
+
from .db_contract import assert_backend_implements_core_api as _assert_backend_implements_core_api
|
|
699
|
+
_assert_backend_implements_core_api(globals(), provider=_provider)
|
|
700
|
+
except Exception:
|
|
701
|
+
# Fail fast with a clear error; this is intentional for premium backends.
|
|
702
|
+
raise
|
|
703
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# DB backends package
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# syntaxmatrix/db_backends/postgres_backend.py
|
|
2
|
+
"""
|
|
3
|
+
PostgreSQL backend
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
def install(ns: dict) -> None:
|
|
7
|
+
raise RuntimeError(
|
|
8
|
+
"Postgres backend is not available in the free tier.\n\n"
|
|
9
|
+
"You set SMX_DB_PROVIDER=postgres, but the premium Postgres backend package "
|
|
10
|
+
"is not installed.\n\n"
|
|
11
|
+
"Fix:\n"
|
|
12
|
+
"- Install the SyntaxMatrix premium Postgres backend package, then restart.\n"
|
|
13
|
+
"- Or set SMX_DB_PROVIDER=sqlite to use the built-in SQLite backend.\n"
|
|
14
|
+
)
|
|
@@ -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
|
+
)
|
|
@@ -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,10 @@
|
|
|
1
|
+
"""Premium support.
|
|
2
|
+
|
|
3
|
+
This package contains runtime plumbing for premium features (entitlements +
|
|
4
|
+
plugin loading). The actual premium implementations should live in separate,
|
|
5
|
+
private distributions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .gate import FeatureGate
|
|
9
|
+
|
|
10
|
+
__all__ = ["FeatureGate"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _safe_json_loads(raw: str, *, default: Any) -> Any:
|
|
10
|
+
try:
|
|
11
|
+
return json.loads(raw)
|
|
12
|
+
except Exception:
|
|
13
|
+
return default
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class GateSources:
|
|
18
|
+
"""Where the gate reads entitlements from.
|
|
19
|
+
|
|
20
|
+
Precedence (highest first):
|
|
21
|
+
1) env_json
|
|
22
|
+
2) db_setting_key
|
|
23
|
+
3) licence_file
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
env_json: str = "SMX_PREMIUM_ENTITLEMENTS"
|
|
27
|
+
db_setting_key: str = "premium.entitlements"
|
|
28
|
+
licence_file_relpath: str = os.path.join("premium", "licence.json")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FeatureGate:
|
|
32
|
+
"""Runtime entitlement checks for premium features.
|
|
33
|
+
|
|
34
|
+
This is intentionally small, dependency-free, and safe:
|
|
35
|
+
- If anything fails, it returns 'not entitled' rather than crashing the app.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
client_dir: str,
|
|
42
|
+
db: Optional[object] = None,
|
|
43
|
+
sources: Optional[GateSources] = None,
|
|
44
|
+
):
|
|
45
|
+
self._client_dir = client_dir
|
|
46
|
+
self._db = db
|
|
47
|
+
self._sources = sources or GateSources()
|
|
48
|
+
self._cache: Optional[Dict[str, Any]] = None
|
|
49
|
+
|
|
50
|
+
def _load_from_env(self) -> Optional[Dict[str, Any]]:
|
|
51
|
+
raw = os.environ.get(self._sources.env_json)
|
|
52
|
+
if not raw:
|
|
53
|
+
return None
|
|
54
|
+
data = _safe_json_loads(raw, default=None)
|
|
55
|
+
return data if isinstance(data, dict) else None
|
|
56
|
+
|
|
57
|
+
def _load_from_db(self) -> Optional[Dict[str, Any]]:
|
|
58
|
+
if not self._db:
|
|
59
|
+
return None
|
|
60
|
+
get_setting = getattr(self._db, "get_setting", None)
|
|
61
|
+
if not callable(get_setting):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
raw = get_setting(self._sources.db_setting_key, "{}")
|
|
65
|
+
data = _safe_json_loads(str(raw or "{}"), default={})
|
|
66
|
+
return data if isinstance(data, dict) else None
|
|
67
|
+
|
|
68
|
+
def _load_from_file(self) -> Optional[Dict[str, Any]]:
|
|
69
|
+
p = os.path.join(self._client_dir, self._sources.licence_file_relpath)
|
|
70
|
+
if not os.path.exists(p):
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
74
|
+
data = json.load(f)
|
|
75
|
+
return data if isinstance(data, dict) else None
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def entitlements(self, *, refresh: bool = False) -> Dict[str, Any]:
|
|
80
|
+
"""Returns entitlement dict (possibly empty)."""
|
|
81
|
+
if self._cache is not None and not refresh:
|
|
82
|
+
return self._cache
|
|
83
|
+
|
|
84
|
+
ent = self._load_from_env()
|
|
85
|
+
if ent is None:
|
|
86
|
+
ent = self._load_from_db()
|
|
87
|
+
if ent is None:
|
|
88
|
+
ent = self._load_from_file()
|
|
89
|
+
if ent is None:
|
|
90
|
+
ent = {}
|
|
91
|
+
|
|
92
|
+
self._cache = ent
|
|
93
|
+
return ent
|
|
94
|
+
|
|
95
|
+
def enabled(self, key: str) -> bool:
|
|
96
|
+
"""True if entitlement exists and is truthy."""
|
|
97
|
+
key = (key or "").strip()
|
|
98
|
+
if not key:
|
|
99
|
+
return False
|
|
100
|
+
ent = self.entitlements()
|
|
101
|
+
return bool(ent.get(key))
|
|
102
|
+
|
|
103
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
104
|
+
key = (key or "").strip()
|
|
105
|
+
if not key:
|
|
106
|
+
return default
|
|
107
|
+
return self.entitlements().get(key, default)
|
syntaxmatrix/routes.py
CHANGED
|
@@ -5715,8 +5715,8 @@ def setup_routes(smx):
|
|
|
5715
5715
|
flash("Settings updated ✓")
|
|
5716
5716
|
return redirect(url_for("admin_features"))
|
|
5717
5717
|
|
|
5718
|
-
stream_mode = _truthy(db.get_setting("feature.stream_mode", "
|
|
5719
|
-
user_files = _truthy(db.get_setting("feature.user_files", "
|
|
5718
|
+
stream_mode = _truthy(db.get_setting("feature.stream_mode", "1"))
|
|
5719
|
+
user_files = _truthy(db.get_setting("feature.user_files", "1"))
|
|
5720
5720
|
|
|
5721
5721
|
return render_template(
|
|
5722
5722
|
"admin_features.html",
|
|
@@ -2,9 +2,10 @@ syntaxmatrix/__init__.py,sha256=_LnTrYAW2tbYA37Y233Vv4OMOk8NUnoJi-1yzFyHxEI,2573
|
|
|
2
2
|
syntaxmatrix/auth.py,sha256=SCD6uWojXjj9yjUTKzgV5kBYe6ZkXASEG2VopLFkEtM,18140
|
|
3
3
|
syntaxmatrix/bootstrap.py,sha256=Y7ZNg-Z3ecrr1iYem5EMzPmGstXnEKmO9kqKVoOoljo,817
|
|
4
4
|
syntaxmatrix/commentary.py,sha256=3c8qBAKJI2IcYd9PBZrFEwmv-c4_tfa3ebEoPa5vW7U,12428
|
|
5
|
-
syntaxmatrix/core.py,sha256=
|
|
5
|
+
syntaxmatrix/core.py,sha256=IKcvPJMbTMwv0eqkeLx4se3AU_Qx7LNNbS5sz-VWVf4,67485
|
|
6
6
|
syntaxmatrix/dataset_preprocessing.py,sha256=wtV4MWzkyfOsBHTsS0H1gqHho77ZQHGDI9skJryyZWA,8732
|
|
7
|
-
syntaxmatrix/db.py,sha256=
|
|
7
|
+
syntaxmatrix/db.py,sha256=Q3b_Dr8LifWpGQyBsA-MLc8pBQjFJbc0ox0t_sbdECE,23722
|
|
8
|
+
syntaxmatrix/db_contract.py,sha256=N7WvdTgRH87XX7K5cPSDXvNfHNsSZ4xxZO5DL5vrpVA,2141
|
|
8
9
|
syntaxmatrix/display_html.py,sha256=tBeeHcRbmAOKqRTXY0hUehThFspCDsvjW4myi2zj0iU,3568
|
|
9
10
|
syntaxmatrix/emailer.py,sha256=KazaSY0ieE5kC5nTVmh-O2N3gjfeG_oBnl4pl_UHEws,949
|
|
10
11
|
syntaxmatrix/file_processor.py,sha256=9-TT20qfhZ7Q0eWCJpdxDA54jWM9g56A6VJNFme2azY,2863
|
|
@@ -18,10 +19,11 @@ syntaxmatrix/page_builder_generation.py,sha256=EuRVsHLxYDo0SQUo9apMY0ML0-OnaZ9kD
|
|
|
18
19
|
syntaxmatrix/page_layout_contract.py,sha256=4i18ireN2wigWD3mGIA5A6RMw-0DCxnEXpKuu3UrHog,25175
|
|
19
20
|
syntaxmatrix/page_patch_publish.py,sha256=pakIo8tA2y_2zulrU7aBqbqXkmgJLc2-YwghDvYKbgs,50473
|
|
20
21
|
syntaxmatrix/plottings.py,sha256=MjHQ9T1_oC5oyr4_wkM2GJDrpjp0sbvudbs2lGaMyzk,6103
|
|
22
|
+
syntaxmatrix/plugin_manager.py,sha256=sRpm6xGU8AQSDU_bnh1aHHARwl4uqNYgYXgKlFGHYm4,3747
|
|
21
23
|
syntaxmatrix/preface.py,sha256=tCm0C0BhY_SOntQT5I7cOJr6TB5mVDAeL9i8UmHLu5g,21237
|
|
22
24
|
syntaxmatrix/profiles.py,sha256=hPg27IQjl8-Tpo3BanQQsByeAgcizqIA2I_IKKNZ0TI,2900
|
|
23
25
|
syntaxmatrix/project_root.py,sha256=1ckvbFVV1szHtHsfSCoGcImHkRwbfszmPG1kGh9ZZlE,2227
|
|
24
|
-
syntaxmatrix/routes.py,sha256=
|
|
26
|
+
syntaxmatrix/routes.py,sha256=CMBWRB7b1ju1odxWT6K7b8njkjz__f8XlX0biKSozKA,356892
|
|
25
27
|
syntaxmatrix/selftest_page_templates.py,sha256=JY1i2xu7FBkN0TIPiAXhEk_iIjdOBmfc1g9aX98iqhw,14833
|
|
26
28
|
syntaxmatrix/session.py,sha256=v0qgxnVM_LEaNvZQJSa-13Q2eiwc3RDnjd2SahNnHQk,599
|
|
27
29
|
syntaxmatrix/smiv.py,sha256=1lSN3UYpXvYoVNd6VrkY5iZuF_nDxD6xxvLnTn9wcbQ,1405
|
|
@@ -38,8 +40,13 @@ syntaxmatrix/agentic/agents.py,sha256=IVqF2VLl-80nx75FW0-RK2-2nfhbQjVwyGgplgVOea
|
|
|
38
40
|
syntaxmatrix/agentic/agents_orchestrer.py,sha256=NMC0Mr1zRxxWBr-KRZxu1iLMBJowqesNIkuNwY1AlQA,14681
|
|
39
41
|
syntaxmatrix/agentic/code_tools_registry.py,sha256=rV0sA1qf_a9A4mmJXGuLnPD6qzAtTBjVgViYpwykfRU,1489
|
|
40
42
|
syntaxmatrix/agentic/model_templates.py,sha256=A3ROE3BHkvnU9cxqSGjlCBIw9U15zRaTKgK-WxcZtUI,76033
|
|
43
|
+
syntaxmatrix/db_backends/__init__.py,sha256=ocEqHP5enZCCpurdITAucrrWbw2peihBC6AM3Ck9AAI,23
|
|
44
|
+
syntaxmatrix/db_backends/postgres_backend.py,sha256=vveUAz8Ss2KSFQRpKVxjBPpgqabw-Z5VVKHHdKxsuSU,519
|
|
45
|
+
syntaxmatrix/db_backends/sqlite_backend.py,sha256=DJ70rB7NCRpcYZ6heGnrunt9lxxFgAaidLcio-8y8U0,8384
|
|
41
46
|
syntaxmatrix/media/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
47
|
syntaxmatrix/media/media_pixabay.py,sha256=_gkntpbK7HzyLOtzoyWLjXXWtCXUFknD4hFHn_qHfRY,8195
|
|
48
|
+
syntaxmatrix/premium/__init__.py,sha256=ab4z0PWcMSbnIIqyRBHWTqoE1NyhhW5kzIHNcdhZ4J4,269
|
|
49
|
+
syntaxmatrix/premium/gate.py,sha256=-ko1x4iNZZxe6DYTZdW9Cni38NAbfdZoGJ5Co_813l0,3273
|
|
43
50
|
syntaxmatrix/settings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
51
|
syntaxmatrix/settings/client_items.py,sha256=jtJ2k5r_Roq42Pumor_VEsYVTIdbFVP2l61MOeMAPUc,868
|
|
45
52
|
syntaxmatrix/settings/default.yaml,sha256=BznvF1D06VMPbT6UX3MQ4zUkXxTXLnAA53aUu8G4O38,569
|
|
@@ -78,8 +85,8 @@ syntaxmatrix/vectordb/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
|
|
|
78
85
|
syntaxmatrix/vectordb/adapters/milvus_adapter.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
79
86
|
syntaxmatrix/vectordb/adapters/pgvector_adapter.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
80
87
|
syntaxmatrix/vectordb/adapters/sqlite_adapter.py,sha256=L8M2qHfwZRAFVxWeurUVdHaJXz6F5xTUSWh3uy6TSUs,6035
|
|
81
|
-
syntaxmatrix-2.6.4.
|
|
82
|
-
syntaxmatrix-2.6.4.
|
|
83
|
-
syntaxmatrix-2.6.4.
|
|
84
|
-
syntaxmatrix-2.6.4.
|
|
85
|
-
syntaxmatrix-2.6.4.
|
|
88
|
+
syntaxmatrix-2.6.4.4.dist-info/licenses/LICENSE.txt,sha256=j1P8naTdy1JMxTC80XYQjbyAQnuOlpDusCUhncrvpy8,1083
|
|
89
|
+
syntaxmatrix-2.6.4.4.dist-info/METADATA,sha256=UZK3_MnFpYk0wb3AUfEJx100W6v4ra2oHM_9Wt54DyE,18201
|
|
90
|
+
syntaxmatrix-2.6.4.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
91
|
+
syntaxmatrix-2.6.4.4.dist-info/top_level.txt,sha256=HKP_zkl4V_nt7osC15DlacoBZktHrbZYOqf_pPkF3T8,13
|
|
92
|
+
syntaxmatrix-2.6.4.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|