sp2react 0.1.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 (79) hide show
  1. sp2react/__init__.py +18 -0
  2. sp2react/agents/__init__.py +0 -0
  3. sp2react/agents/core.py +146 -0
  4. sp2react/agents/prompts/dbmap.md +28 -0
  5. sp2react/agents/prompts/fix.md +11 -0
  6. sp2react/agents/prompts/handler.md +22 -0
  7. sp2react/agents/prompts/page.md +40 -0
  8. sp2react/agents/prompts/review.md +21 -0
  9. sp2react/agents/prompts/spec.md +79 -0
  10. sp2react/agents/tasks.py +374 -0
  11. sp2react/analysis/__init__.py +0 -0
  12. sp2react/analysis/aspx.py +254 -0
  13. sp2react/analysis/behaviors.py +357 -0
  14. sp2react/analysis/fields.py +152 -0
  15. sp2react/analysis/inventory.py +314 -0
  16. sp2react/analysis/jsscan.py +330 -0
  17. sp2react/checks.py +105 -0
  18. sp2react/cli.py +188 -0
  19. sp2react/config.py +172 -0
  20. sp2react/dbmap.py +245 -0
  21. sp2react/llm/__init__.py +0 -0
  22. sp2react/llm/providers.py +219 -0
  23. sp2react/migrate.py +255 -0
  24. sp2react/net.py +60 -0
  25. sp2react/pipeline.py +583 -0
  26. sp2react/scaffold/__init__.py +0 -0
  27. sp2react/scaffold/generate.py +279 -0
  28. sp2react/scaffold/rule_vectors.json +3826 -0
  29. sp2react/scaffold/templates/backend/app/__init__.py +0 -0
  30. sp2react/scaffold/templates/backend/app/api.py +293 -0
  31. sp2react/scaffold/templates/backend/app/auth.py +33 -0
  32. sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
  33. sp2react/scaffold/templates/backend/app/main.py +47 -0
  34. sp2react/scaffold/templates/backend/app/odata.py +185 -0
  35. sp2react/scaffold/templates/backend/app/rules.py +256 -0
  36. sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
  37. sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
  38. sp2react/scaffold/templates/backend/app/store.py +224 -0
  39. sp2react/scaffold/templates/backend/requirements.txt +8 -0
  40. sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
  41. sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
  42. sp2react/scaffold/templates/frontend/index.html +12 -0
  43. sp2react/scaffold/templates/frontend/package.json +24 -0
  44. sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
  45. sp2react/scaffold/templates/frontend/src/api.ts +88 -0
  46. sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
  47. sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
  48. sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
  49. sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
  50. sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
  51. sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
  52. sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
  53. sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
  54. sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
  55. sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
  56. sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
  57. sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
  58. sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
  59. sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
  60. sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
  61. sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
  62. sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
  63. sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
  64. sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
  65. sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
  66. sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
  67. sp2react/sharepoint/__init__.py +0 -0
  68. sp2react/sharepoint/client.py +210 -0
  69. sp2react/sharepoint/extract.py +217 -0
  70. sp2react/spec/__init__.py +0 -0
  71. sp2react/spec/base.py +80 -0
  72. sp2react/spec/coverage.py +191 -0
  73. sp2react/spec/model.py +133 -0
  74. sp2react-0.1.0.dist-info/METADATA +238 -0
  75. sp2react-0.1.0.dist-info/RECORD +79 -0
  76. sp2react-0.1.0.dist-info/WHEEL +5 -0
  77. sp2react-0.1.0.dist-info/entry_points.txt +2 -0
  78. sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. sp2react-0.1.0.dist-info/top_level.txt +1 -0
sp2react/migrate.py ADDED
@@ -0,0 +1,255 @@
1
+ """Data migration and reconciliation: SharePoint list items -> the generated app's database, then proof
2
+ that what the app serves matches what SharePoint holds.
3
+
4
+ export every item of every list the converted screens use (paged, original IDs, attachments)
5
+ transform lookups/people -> ids, date-only values -> calendar dates, multi-values -> lists, system fields kept
6
+ load written as the app's seed + attachment files, then loaded through the app's own repository
7
+ reconcile read back THROUGH THE APP's repository and compare with the export: counts, ids, every field,
8
+ orphaned lookups, attachments (sha256), and a per-list fingerprint
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+ from typing import Callable, Iterable, Optional
20
+
21
+ SYSTEM = ("Created", "Modified", "Author", "Editor")
22
+
23
+
24
+ # ------------------------------------------------------------------ export
25
+ def export_lists(sp, legacy_site: dict, lists: Iterable[str], out: Path, page_size: int = 1000,
26
+ progress: Callable[[str], None] = lambda s: None) -> dict:
27
+ """Writes <out>/<List>.jsonl (raw SharePoint items) and <out>/attachments/<List>/<ID>/<file>."""
28
+ out.mkdir(parents=True, exist_ok=True)
29
+ by_key = {l["url"].rstrip("/").split("/")[-1]: l for l in legacy_site["lists"]}
30
+ summary = {}
31
+ for key in lists:
32
+ l = by_key[key]
33
+ n, files = 0, 0
34
+ with (out / f"{key}.jsonl").open("w", encoding="utf-8") as fh:
35
+ for it in sp.all_items(l["id"], page_size):
36
+ fh.write(json.dumps(it, ensure_ascii=False) + "\n")
37
+ n += 1
38
+ for a in ((it.get("AttachmentFiles") or {}).get("results") or []):
39
+ data = sp.file_bytes(a["ServerRelativeUrl"])
40
+ p = out / "attachments" / key / str(it.get("ID") or it.get("Id")) / a["FileName"]
41
+ p.parent.mkdir(parents=True, exist_ok=True)
42
+ p.write_bytes(data)
43
+ files += 1
44
+ summary[key] = {"items": n, "attachments": files}
45
+ progress(f"[migrate] exported {key}: {n} items, {files} attachments")
46
+ (out / "export.json").write_text(json.dumps({"at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "lists": summary, "source": legacy_site.get("url")}, indent=2))
47
+ return summary
48
+
49
+
50
+ def read_export(out: Path, key: str) -> list[dict]:
51
+ f = out / f"{key}.jsonl"
52
+ return [json.loads(line) for line in f.read_text(encoding="utf-8").splitlines() if line.strip()] if f.exists() else []
53
+
54
+
55
+ # --------------------------------------------------------------- transform
56
+ def date_only(v: Optional[str]) -> Optional[str]:
57
+ """SharePoint stores date-only values as local midnight in UTC; +12h lands on the right calendar day either side of UTC."""
58
+ if not v:
59
+ return None
60
+ try:
61
+ return (datetime.strptime(v[:19], "%Y-%m-%dT%H:%M:%S") + timedelta(hours=12)).date().isoformat()
62
+ except ValueError:
63
+ return v[:10]
64
+
65
+
66
+ def _results(v):
67
+ return v.get("results", []) if isinstance(v, dict) else (v or [])
68
+
69
+
70
+ def transform(items: list[dict], fields: list[dict]) -> list[dict]:
71
+ rows = []
72
+ for it in items:
73
+ row: dict = {"ID": it.get("ID") or it.get("Id")}
74
+ for f in fields:
75
+ n, t = f["name"], f["type"]
76
+ if t in ("lookup", "user"):
77
+ raw = it.get(f"{n}Id", it.get(n))
78
+ row[n] = sorted(_results(raw)) if f.get("multi") else (raw if isinstance(raw, int) else None)
79
+ elif t == "multichoice":
80
+ row[n] = list(_results(it.get(n)))
81
+ elif t == "date":
82
+ row[n] = date_only(it.get(n))
83
+ elif t == "url":
84
+ v = it.get(n)
85
+ row[n] = v.get("Url") if isinstance(v, dict) else v
86
+ elif t in ("number", "currency"):
87
+ v = it.get(n)
88
+ row[n] = float(v) if v not in (None, "") else None
89
+ else:
90
+ row[n] = it.get(n)
91
+ ver = str(it.get("_UIVersionString") or it.get("OData__UIVersionString") or "1.0").split(".")[0]
92
+ row["_meta"] = {"version": int(ver) if ver.isdigit() else 1, "created": it.get("Created"), "modified": it.get("Modified"),
93
+ "author": it.get("AuthorId"), "editor": it.get("EditorId")}
94
+ rows.append(row)
95
+ return rows
96
+
97
+
98
+ # -------------------------------------------------------------------- load
99
+ def write_seed(app: Path, key: str, rows: list[dict], attachments_dir: Optional[Path]) -> None:
100
+ seed = app / "backend" / "data" / "seed"
101
+ seed.mkdir(parents=True, exist_ok=True)
102
+ (seed / f"{key}.json").write_text(json.dumps(rows, indent=1), encoding="utf-8")
103
+ if attachments_dir and (attachments_dir / key).exists():
104
+ import shutil
105
+ dst = app / "backend" / "data" / "attachments" / key
106
+ if dst.exists():
107
+ shutil.rmtree(dst)
108
+ shutil.copytree(attachments_dir / key, dst)
109
+
110
+
111
+ LOADER = r"""
112
+ import json, os, sys, hashlib
113
+ from pathlib import Path
114
+ sys.path.insert(0, os.getcwd())
115
+ if sys.argv[2] == "load":
116
+ db = Path(os.environ["DB_PATH"])
117
+ if db.exists():
118
+ db.unlink()
119
+ from app.main import create_app
120
+ repo = create_app().state.repo # the same repository the API uses (sqlite seed, or your database via mapping.json)
121
+ out = {}
122
+ for name in repo.specs.lists:
123
+ try:
124
+ items = repo.all(name)
125
+ except KeyError: # list not mapped to a table
126
+ continue
127
+ for it in items:
128
+ it["_attachment_sha256"] = {n: hashlib.sha256(repo.attachment(name, it["ID"], n) or b"").hexdigest() for n in it.get("Attachments", [])}
129
+ out[name] = items
130
+ print(json.dumps(out, default=str))
131
+ """
132
+
133
+
134
+ def read_through_app(app: Path, db_path: Path, load: bool, env: Optional[dict] = None) -> dict:
135
+ """Run the generated backend's own repository (same code the API uses) and return every item it serves."""
136
+ r = subprocess.run([sys.executable, "-c", LOADER, str(db_path), "load" if load else "read"], cwd=app / "backend",
137
+ capture_output=True, text=True, timeout=900,
138
+ env={**os.environ, "DB_PATH": str(db_path), "DATA_SOURCE": "sqlite", **(env or {})})
139
+ if r.returncode != 0:
140
+ raise RuntimeError(f"could not read the app database: {r.stderr[-2000:]}")
141
+ return json.loads(r.stdout)
142
+
143
+
144
+ # --------------------------------------------------------------- reconcile
145
+ def _norm_source(f: dict, it: dict):
146
+ n, t = f["name"], f["type"]
147
+ if t in ("lookup", "user"):
148
+ raw = it.get(f"{n}Id", it.get(n))
149
+ return sorted(_results(raw)) if f.get("multi") else (raw if isinstance(raw, int) else None)
150
+ v = it.get(n)
151
+ if t == "multichoice":
152
+ return sorted(_results(v))
153
+ if t == "date":
154
+ return date_only(v)
155
+ if t == "url":
156
+ return v.get("Url") if isinstance(v, dict) else v
157
+ if t in ("number", "currency"):
158
+ return float(v) if v not in (None, "") else None
159
+ if t == "boolean":
160
+ return bool(v) if v is not None else None
161
+ return None if v == "" else v
162
+
163
+
164
+ def _norm_app(f: dict, v):
165
+ t = f["type"]
166
+ if t in ("lookup", "user"):
167
+ if isinstance(v, list):
168
+ return sorted(x.get("id") for x in v if x)
169
+ return v.get("id") if isinstance(v, dict) else v
170
+ if t == "multichoice":
171
+ return sorted(v or [])
172
+ if t in ("number", "currency"):
173
+ return float(v) if v not in (None, "") else None
174
+ if t == "boolean":
175
+ return bool(v) if v is not None else None
176
+ return None if v == "" else v
177
+
178
+
179
+ def _sec(v: Optional[str]) -> Optional[str]:
180
+ return v[:19] if isinstance(v, str) else v
181
+
182
+
183
+ def reconcile(export_dir: Path, app_items: dict, specs_lists: dict, max_rows: int = 200) -> dict:
184
+ report: dict = {"ok": True, "lists": {}}
185
+ for key, l in specs_lists.items():
186
+ fields = l["fields"]
187
+ src = {int(it.get("ID") or it.get("Id")): it for it in read_export(export_dir, key)}
188
+ dst = {int(it["ID"]): it for it in app_items.get(key, [])}
189
+ lr = {"source_items": len(src), "app_items": len(dst), "missing_in_app": sorted(set(src) - set(dst)),
190
+ "extra_in_app": sorted(set(dst) - set(src)), "field_mismatches": [], "orphan_lookups": [], "attachment_mismatches": [],
191
+ "fields_checked": len(fields) + len(SYSTEM)}
192
+ canon_src, canon_dst = [], []
193
+ for iid in sorted(set(src) & set(dst)):
194
+ s, d = src[iid], dst[iid]
195
+ rs, rd = {"ID": iid}, {"ID": iid}
196
+ for f in fields:
197
+ a, b = _norm_source(f, s), _norm_app(f, d.get(f["name"]))
198
+ rs[f["name"]], rd[f["name"]] = a, b
199
+ if a != b and len(lr["field_mismatches"]) < max_rows:
200
+ lr["field_mismatches"].append({"id": iid, "field": f["name"], "sharepoint": a, "app": b})
201
+ if f["type"] == "lookup" and isinstance(d.get(f["name"]), dict) and d[f["name"]].get("title") is None and b is not None:
202
+ lr["orphan_lookups"].append({"id": iid, "field": f["name"], "points_to": b, "list": f["lookup"]["list"]})
203
+ for name, sk, dk in (("Created", "Created", "Created"), ("Modified", "Modified", "Modified")):
204
+ a, b = _sec(s.get(sk)), _sec(d.get(dk))
205
+ rs[name], rd[name] = a, b
206
+ if a != b and len(lr["field_mismatches"]) < max_rows:
207
+ lr["field_mismatches"].append({"id": iid, "field": name, "sharepoint": a, "app": b})
208
+ for name in ("Author", "Editor"):
209
+ a, b = s.get(f"{name}Id"), (d.get(name) or {}).get("id")
210
+ rs[name], rd[name] = a, b
211
+ if a != b and len(lr["field_mismatches"]) < max_rows:
212
+ lr["field_mismatches"].append({"id": iid, "field": name, "sharepoint": a, "app": b})
213
+ sp_files = {a["FileName"] for a in ((s.get("AttachmentFiles") or {}).get("results") or [])}
214
+ app_files = set(d.get("Attachments") or [])
215
+ for n in sorted(sp_files | app_files):
216
+ p = export_dir / "attachments" / key / str(iid) / n
217
+ sha = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None
218
+ got = (d.get("_attachment_sha256") or {}).get(n)
219
+ if sha != got:
220
+ lr["attachment_mismatches"].append({"id": iid, "file": n, "sharepoint_sha256": sha, "app_sha256": got})
221
+ canon_src.append(rs)
222
+ canon_dst.append(rd)
223
+ fp = lambda rows: hashlib.sha256(json.dumps(rows, sort_keys=True, default=str).encode()).hexdigest()[:16]
224
+ lr["fingerprint_sharepoint"], lr["fingerprint_app"] = fp(canon_src), fp(canon_dst)
225
+ lr["ok"] = not (lr["missing_in_app"] or lr["extra_in_app"] or lr["field_mismatches"] or lr["orphan_lookups"] or lr["attachment_mismatches"])
226
+ report["lists"][key] = lr
227
+ report["ok"] = report["ok"] and lr["ok"]
228
+ return report
229
+
230
+
231
+ def report_markdown(rep: dict, export_meta: dict) -> str:
232
+ lines = ["# Data reconciliation: SharePoint lists vs the converted app", "",
233
+ f"Export taken {export_meta.get('at', '?')} from {export_meta.get('source', '?')}. Values were read back through the app's own repository.", "",
234
+ f"**Result: {'all lists match' if rep['ok'] else 'MISMATCHES FOUND'}**", "",
235
+ "| List | SharePoint items | App items | Missing | Extra | Field mismatches | Orphan lookups | Attachments | Fingerprint (SP / app) |",
236
+ "|---|---:|---:|---:|---:|---:|---:|---|---|"]
237
+ for key, l in rep["lists"].items():
238
+ att = "ok" if not l["attachment_mismatches"] else f"{len(l['attachment_mismatches'])} differ"
239
+ lines.append(f"| {key} | {l['source_items']} | {l['app_items']} | {len(l['missing_in_app'])} | {len(l['extra_in_app'])} | "
240
+ f"{len(l['field_mismatches'])} | {len(l['orphan_lookups'])} | {att} | `{l['fingerprint_sharepoint']}` / `{l['fingerprint_app']}` |")
241
+ for key, l in rep["lists"].items():
242
+ if l["ok"]:
243
+ continue
244
+ lines += ["", f"## {key}", ""]
245
+ if l["missing_in_app"]:
246
+ lines.append(f"- Missing in the app: IDs {l['missing_in_app'][:50]}")
247
+ if l["extra_in_app"]:
248
+ lines.append(f"- In the app but not in SharePoint: IDs {l['extra_in_app'][:50]}")
249
+ for m in l["field_mismatches"][:100]:
250
+ lines.append(f"- ID {m['id']} `{m['field']}`: SharePoint `{m['sharepoint']!r}` vs app `{m['app']!r}`")
251
+ for o in l["orphan_lookups"][:50]:
252
+ lines.append(f"- ID {o['id']} `{o['field']}` points to {o['list']} item {o['points_to']}, which does not exist")
253
+ for a in l["attachment_mismatches"][:50]:
254
+ lines.append(f"- ID {a['id']} attachment `{a['file']}` differs (SharePoint {a['sharepoint_sha256']}, app {a['app_sha256']})")
255
+ return "\n".join(lines) + "\n"
sp2react/net.py ADDED
@@ -0,0 +1,60 @@
1
+ """Outbound network policy.
2
+
3
+ sp2react makes HTTP requests to exactly two kinds of host, both named in your config:
4
+ * your SharePoint site (page source, scripts, list schemas; list items only when allowed),
5
+ * your model endpoint (Bridger, or the provider you configured).
6
+ Every request (and every redirect hop) is checked against that allowlist before it is sent.
7
+ There is no telemetry, no update check and no third-party call.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Iterable
12
+ from urllib.parse import urlsplit
13
+
14
+ import requests
15
+
16
+
17
+ class NetworkPolicyError(RuntimeError):
18
+ pass
19
+
20
+
21
+ class NetworkPolicy:
22
+ def __init__(self, hosts: Iterable[str]):
23
+ self.hosts = {h.lower() for h in hosts if h}
24
+
25
+ @staticmethod
26
+ def host_of(url: str) -> str:
27
+ return (urlsplit(url).hostname or "").lower()
28
+
29
+ def check(self, url: str) -> None:
30
+ host = self.host_of(url)
31
+ if host not in self.hosts:
32
+ raise NetworkPolicyError(
33
+ f"Blocked request to '{host or url}': sp2react only contacts hosts named in your config "
34
+ f"({', '.join(sorted(self.hosts)) or 'none'}). Add it to security.allowed_hosts if it is intended.")
35
+
36
+ def guard(self, session: requests.Session) -> requests.Session:
37
+ """Check every prepared request, including each redirect hop, before it leaves the machine."""
38
+ send = session.send
39
+ policy = self
40
+
41
+ def guarded_send(request, **kwargs):
42
+ policy.check(request.url)
43
+ return send(request, **kwargs)
44
+
45
+ session.send = guarded_send # type: ignore[method-assign]
46
+ return session
47
+
48
+
49
+ def policy_for(cfg) -> NetworkPolicy:
50
+ """Allowed hosts = SharePoint site + model endpoint + anything listed in security.allowed_hosts."""
51
+ hosts = [NetworkPolicy.host_of(cfg.sharepoint.site_url)]
52
+ llm = cfg.llm
53
+ if llm.base_url:
54
+ hosts.append(NetworkPolicy.host_of(llm.base_url))
55
+ elif llm.provider == "anthropic":
56
+ hosts.append("api.anthropic.com")
57
+ elif llm.provider == "openai":
58
+ hosts.append("api.openai.com")
59
+ hosts += list(cfg.security.allowed_hosts)
60
+ return NetworkPolicy(hosts)