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
@@ -0,0 +1,279 @@
1
+ """Stage 6a: deterministic code generation (seconds, no model calls).
2
+
3
+ Writes the React + FastAPI app skeleton, the specs, seed data and generated tests. Agents only
4
+ write what is screen-specific (page components, custom handlers); everything here is fixed code.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Iterable, Optional
13
+
14
+ from ..analysis.inventory import Legacy, kebab
15
+ from ..spec.model import Spec
16
+
17
+ TEMPLATES = Path(__file__).parent / "templates"
18
+ AGENT_OWNED = re.compile(r"^(frontend/src/screens/[^/]+/(?!index).+|frontend/src/custom/(?!index\.ts|types\.ts).+|backend/app/custom/(?!__init__).+)$")
19
+
20
+
21
+ def ident(name: str) -> str:
22
+ s = re.sub(r"\W+", "_", name)
23
+ return ("_" + s) if s[0].isdigit() else s
24
+
25
+
26
+ def copy_templates(out: Path) -> list[str]:
27
+ written = []
28
+ for src in TEMPLATES.rglob("*"):
29
+ if src.is_dir() or "__pycache__" in src.parts:
30
+ continue
31
+ rel = src.relative_to(TEMPLATES).as_posix()
32
+ dst = out / rel
33
+ dst.parent.mkdir(parents=True, exist_ok=True)
34
+ shutil.copyfile(src, dst)
35
+ written.append(rel)
36
+ return written
37
+
38
+
39
+ # ----------------------------------------------------------------- seed data
40
+ def seed_rows(legacy: Legacy, list_key: str, fields: list[dict], export_dir: Optional[Path] = None) -> list[dict]:
41
+ """Full migrated rows when a data export exists, otherwise the sample captured during extraction."""
42
+ from ..migrate import read_export, transform
43
+ if export_dir and (export_dir / f"{list_key}.jsonl").exists():
44
+ return transform(read_export(export_dir, list_key), fields)
45
+ return transform(legacy.items(list_key), fields)
46
+
47
+
48
+ def users_json(legacy: Legacy) -> list[dict]:
49
+ f = legacy.root / "siteusers.json"
50
+ data = json.loads(f.read_text(encoding="utf-8")) if f.exists() else {"d": {"results": []}}
51
+ groups = legacy.site.get("groups", [])
52
+ out = []
53
+ for u in data.get("d", {}).get("results", []):
54
+ if u.get("PrincipalType", 1) != 1:
55
+ continue
56
+ login = re.sub(r"^i:0#\.\w\|", "", u.get("LoginName", ""))
57
+ out.append({"id": u["Id"], "login": login, "title": u.get("Title"), "email": u.get("Email"),
58
+ "groups": [g["name"] for g in groups if login.lower() in [m.lower() for m in g.get("members", [])]]})
59
+ return out
60
+
61
+
62
+ # ------------------------------------------------------------- generated src
63
+ def site_ts(legacy: Legacy, specs: list[Spec]) -> str:
64
+ prefix = legacy.prefix
65
+ routes: dict[str, str] = {}
66
+ for s in specs:
67
+ for u in s.screen.legacy_urls:
68
+ routes[u.lower()] = s.screen.route
69
+ if s.screen.kind == "list-form" and s.screen.list in legacy.lists:
70
+ l = legacy.lists[s.screen.list]
71
+ routes[f"{prefix}/{l['url']}".lower()] = s.screen.route
72
+ routes[f"{prefix}/{l['url']}/allitems.aspx".lower()] = s.screen.route
73
+ links = []
74
+ for q in legacy.site.get("quickLaunch", []):
75
+ url = re.sub(r"^https?://[^/]+", "", q.get("url") or "")
76
+ r = routes.get(url.lower().rstrip("/"))
77
+ links.append({"title": q.get("title"), "href": r or (legacy.site.get("url", "").split(prefix)[0] + url), "external": not r})
78
+ if not links:
79
+ links = [{"title": s.screen.title, "href": s.screen.route, "external": False} for s in specs]
80
+ return ("// Generated by sp2react from the legacy site's navigation. Links to screens not yet converted point at the old site.\n"
81
+ f"export const site = {json.dumps({'title': legacy.site.get('title') or 'Site', 'quickLaunch': links}, indent=2)};\n")
82
+
83
+
84
+ def app_tsx(specs: list[Spec], out: Path) -> str:
85
+ imports, entries, routes = [], [], []
86
+ for i, s in enumerate(specs):
87
+ var = f"spec{i}"
88
+ imports.append(f'import {var}Json from "./specs/{s.screen.id}.json";')
89
+ entries.append(f"{var}Json as unknown as Spec")
90
+ if s.screen.kind == "list-form":
91
+ routes.append(f' <Route path="{s.screen.route}/*" element={{<ListFormScreen spec={{specs[{i}]}} />}} />')
92
+ else:
93
+ page = out / "frontend" / "src" / "screens" / s.screen.id / "Page.tsx"
94
+ if page.exists():
95
+ comp = f"Page{i}"
96
+ imports.append(f'import {comp} from "./screens/{s.screen.id}/Page";')
97
+ else:
98
+ comp = "GenericPage"
99
+ routes.append(f' <Route path="{s.screen.route}" element={{<{comp} spec={{specs[{i}]}} />}} />')
100
+ home = next((s.screen.route for s in specs if s.screen.kind == "page"), specs[0].screen.route if specs else "/")
101
+ return "\n".join([
102
+ "// Generated by sp2react: one route per converted screen, plus redirects for the old SharePoint URLs.",
103
+ 'import { Navigate, Route, Routes } from "react-router-dom";',
104
+ 'import { GenericPage } from "./components/sp/GenericPage";',
105
+ 'import { LegacyRedirect } from "./screens/LegacyRedirect";',
106
+ 'import { ListFormScreen } from "./screens/ListFormScreen";',
107
+ 'import type { Spec } from "./runtime/spec";',
108
+ 'import { UserProvider } from "./runtime/user";',
109
+ *imports,
110
+ "",
111
+ f"export const specs: Spec[] = [{', '.join(entries)}];",
112
+ "",
113
+ "export default function App() {",
114
+ " return (",
115
+ " <UserProvider>",
116
+ " <Routes>",
117
+ f' <Route path="/" element={{<Navigate to="{home}" replace />}} />',
118
+ *routes,
119
+ ' <Route path="*" element={<LegacyRedirect specs={specs} />} />',
120
+ " </Routes>",
121
+ " </UserProvider>",
122
+ " );",
123
+ "}",
124
+ "",
125
+ ])
126
+
127
+
128
+ def custom_index(out: Path) -> str:
129
+ folder = out / "frontend" / "src" / "custom"
130
+ mods = sorted(p.stem for p in folder.glob("*.ts") if p.stem not in ("index", "types"))
131
+ lines = ["// Generated by sp2react: registry of agent-written handlers (rule kind \"custom\").",
132
+ 'import type { Handler } from "./types";']
133
+ lines += [f'import * as {ident(m)} from "./{m}";' for m in mods]
134
+ lines.append("")
135
+ body = ", ".join(f'"{m}": {ident(m)} as Handler' for m in mods)
136
+ lines.append(f"export const HANDLERS: Record<string, Handler> = {{{body}}};")
137
+ return "\n".join(lines) + "\n"
138
+
139
+
140
+ def legacy_css(legacy: Legacy, specs: list[Spec]) -> str:
141
+ seen, parts = set(), ["/* Site CSS carried over from the legacy SharePoint pages (order preserved). */"]
142
+ for s in specs:
143
+ for rel in s.screen.stylesheets:
144
+ if rel in seen:
145
+ continue
146
+ seen.add(rel)
147
+ txt = legacy.file_text(rel)
148
+ if txt:
149
+ parts.append(f"/* ---- {rel} ---- */\n{txt.strip()}")
150
+ return "\n\n".join(parts) + "\n"
151
+
152
+
153
+ def generated_tests(specs: list[Spec], seed_counts: dict[str, int], data_source: str = "sqlite") -> str:
154
+ """Deterministic API tests derived from the specs (schema + rules)."""
155
+ db = data_source == "database"
156
+ lines = [
157
+ '"""Generated by sp2react from the screen specs. Regenerated on every convert run; add your own tests in other files."""',
158
+ "import os",
159
+ "",
160
+ "import pytest",
161
+ "from fastapi.testclient import TestClient",
162
+ "",
163
+ "from app.rules import REQUIRED_MESSAGE",
164
+ "",
165
+ ]
166
+ if db:
167
+ lines += ['pytestmark = pytest.mark.skipif(not os.environ.get("DB_URL"), reason="set DB_URL (and MAPPING_FILE) to run the API tests against your database")', ""]
168
+ lines += [
169
+ "",
170
+ '@pytest.fixture(scope="module")',
171
+ "def client():",
172
+ " from app.main import create_app",
173
+ " return TestClient(create_app())",
174
+ "",
175
+ "",
176
+ "def test_screens_are_served(client):",
177
+ " ids = {s['id'] for s in client.get('/api/screens').json()}",
178
+ f" assert ids >= set({sorted(s.screen.id for s in specs)!r})",
179
+ "",
180
+ ]
181
+ done = set()
182
+ for s in specs:
183
+ for name, l in s.lists.items():
184
+ if name in done:
185
+ continue
186
+ done.add(name)
187
+ fn = ident(name).lower()
188
+ lines += ["", f"def test_{fn}_items_served(client):",
189
+ f" r = client.get('/api/lists/{name}/items', params={{'$top': 5}})",
190
+ " assert r.status_code == 200, r.text"]
191
+ if not db:
192
+ lines += [f" assert r.json()['count'] == {seed_counts.get(name, 0)}"]
193
+ if s.screen.kind == "list-form" and s.screen.list and "new" in s.screen.modes:
194
+ lst = s.lists[s.screen.list]
195
+ hidden_new = {r.field for r in s.rules if r.kind == "visible" and (not r.modes or "new" in r.modes)}
196
+ readonly = {r.field for r in s.rules if r.kind in ("readonly", "derive", "set_on_save") and (not r.modes or "new" in r.modes)}
197
+ defaults = {f["name"] for f in lst.fields if "default" in f} | {r.field for r in s.rules if r.kind == "default"}
198
+ req = [f["name"] for f in lst.fields if f.get("required") and f["name"] not in hidden_new | readonly | defaults]
199
+ fn = ident(s.screen.id).lower()
200
+ if req:
201
+ lines += ["", f"def test_{fn}_required_fields_enforced(client):",
202
+ f" r = client.post('/api/lists/{s.screen.list}/items', json={{'values': {{}}}})",
203
+ " assert r.status_code == 422",
204
+ " fields = {e['field'] for e in r.json()['errors'] if e['message'] == REQUIRED_MESSAGE}",
205
+ f" assert set({req!r}) <= fields"]
206
+ return "\n".join(lines) + "\n"
207
+
208
+
209
+ ENV_EXAMPLE = """# Copy to .env (or set in your service configuration). Never commit real values.
210
+ DATA_SOURCE=__SOURCE__
211
+ # database: your existing tables
212
+ DB_URL=mysql+pymysql://user:password@db-host:3306/sea
213
+ MAPPING_FILE=./mapping.json
214
+ # auth: dev (X-User header, local only) | header (identity header from your gateway)
215
+ AUTH_MODE=dev
216
+ IDENTITY_HEADER=X-MS-CLIENT-PRINCIPAL-NAME
217
+ CORS_ORIGINS=http://localhost:5173
218
+ """
219
+
220
+
221
+ def generate(out: Path, specs: list[Spec], legacy: Legacy, export_dir: Optional[Path] = None,
222
+ data_source: str = "sqlite", mapping_file: Optional[Path] = None) -> dict:
223
+ out.mkdir(parents=True, exist_ok=True)
224
+ written = copy_templates(out)
225
+ (out / "backend" / "specs").mkdir(parents=True, exist_ok=True)
226
+ (out / "frontend" / "src" / "specs").mkdir(parents=True, exist_ok=True)
227
+ for s in specs:
228
+ js = s.model_dump_json(indent=2, exclude_none=True)
229
+ (out / "backend" / "specs" / f"{s.screen.id}.json").write_text(js, encoding="utf-8")
230
+ (out / "frontend" / "src" / "specs" / f"{s.screen.id}.json").write_text(js, encoding="utf-8")
231
+
232
+ seed_dir = out / "backend" / "data" / "seed"
233
+ seed_dir.mkdir(parents=True, exist_ok=True)
234
+ counts, lists_done = {}, set()
235
+ for s in specs:
236
+ for name, l in s.lists.items():
237
+ if name in lists_done:
238
+ continue
239
+ lists_done.add(name)
240
+ rows = seed_rows(legacy, name, l.fields, export_dir)
241
+ counts[name] = len(rows)
242
+ (seed_dir / f"{name}.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
243
+ if export_dir and (export_dir / "attachments" / name).exists():
244
+ dst = out / "backend" / "data" / "attachments" / name
245
+ if dst.exists():
246
+ shutil.rmtree(dst)
247
+ shutil.copytree(export_dir / "attachments" / name, dst)
248
+ (out / "backend" / "data" / "users.json").write_text(json.dumps(users_json(legacy), indent=2), encoding="utf-8")
249
+
250
+ src = out / "frontend" / "src"
251
+ (src / "site.ts").write_text(site_ts(legacy, specs), encoding="utf-8")
252
+ (src / "App.tsx").write_text(app_tsx(specs, out), encoding="utf-8")
253
+ (src / "custom" / "index.ts").write_text(custom_index(out), encoding="utf-8")
254
+ (src / "styles" / "legacy.css").write_text(legacy_css(legacy, specs), encoding="utf-8")
255
+ html = (out / "frontend" / "index.html").read_text(encoding="utf-8").replace("__SITE_TITLE__", legacy.site.get("title") or "Site")
256
+ (out / "frontend" / "index.html").write_text(html, encoding="utf-8")
257
+ (out / "backend" / "tests" / "test_generated.py").write_text(generated_tests(specs, counts, data_source), encoding="utf-8")
258
+ main_py = out / "backend" / "app" / "main.py"
259
+ main_py.write_text(main_py.read_text(encoding="utf-8").replace("__DEFAULT_SOURCE__", data_source), encoding="utf-8")
260
+ if data_source == "database":
261
+ if mapping_file and mapping_file.exists():
262
+ shutil.copyfile(mapping_file, out / "backend" / "mapping.json")
263
+ roles = out / "backend" / "data" / "roles.json"
264
+ if not roles.exists(): # SharePoint group names; members are filled in by your admins (or come from the users table)
265
+ groups = sorted({r for sp in specs for r in sp.roles} | {g["name"] for g in legacy.site.get("groups", [])})
266
+ roles.write_text(json.dumps({g: [] for g in groups}, indent=2), encoding="utf-8")
267
+ (out / "backend" / ".env.example").write_text(ENV_EXAMPLE.replace("__SOURCE__", data_source), encoding="utf-8")
268
+ shared = TEMPLATES.parent / "rule_vectors.json"
269
+ if shared.exists():
270
+ (out / "shared").mkdir(exist_ok=True)
271
+ shutil.copyfile(shared, out / "shared" / "rule_vectors.json")
272
+ return {"files": len(written) + 8 + 2 * len(specs), "seed": counts}
273
+
274
+
275
+ def refresh_registries(out: Path, specs: list[Spec]) -> None:
276
+ """After agents add files: re-link App.tsx routes and the custom handler registry."""
277
+ src = out / "frontend" / "src"
278
+ (src / "App.tsx").write_text(app_tsx(specs, out), encoding="utf-8")
279
+ (src / "custom" / "index.ts").write_text(custom_index(out), encoding="utf-8")