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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- sp2react-0.1.0.dist-info/top_level.txt +1 -0
sp2react/checks.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Stage 8 (test against the original): build and test the generated app. Plain processes, no model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Check:
|
|
15
|
+
name: str
|
|
16
|
+
ok: bool
|
|
17
|
+
output: str
|
|
18
|
+
skipped: bool = False
|
|
19
|
+
|
|
20
|
+
def tail(self, n: int = 6000) -> str:
|
|
21
|
+
return self.output[-n:]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(cmd: list[str], cwd: Path, timeout: int, env: dict | None = None) -> tuple[int, str]:
|
|
25
|
+
try:
|
|
26
|
+
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, env={**os.environ, **(env or {})})
|
|
27
|
+
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
|
28
|
+
except subprocess.TimeoutExpired as e:
|
|
29
|
+
return 124, f"timed out after {timeout}s: {' '.join(cmd)}\n{(e.stdout or '')}"
|
|
30
|
+
except FileNotFoundError as e:
|
|
31
|
+
return 127, str(e)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def backend_tests(out: Path, timeout: int = 900, env: dict | None = None) -> Check:
|
|
35
|
+
be = out / "backend"
|
|
36
|
+
db = be / "data" / "test.db"
|
|
37
|
+
if db.exists():
|
|
38
|
+
db.unlink()
|
|
39
|
+
code, txt = _run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "tests"], be, timeout,
|
|
40
|
+
{"PYTHONPATH": str(be), "DB_PATH": str(db), **(env or {})})
|
|
41
|
+
return Check("backend tests (generated API tests + rule vectors)", code == 0, txt)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def frontend_install(out: Path, timeout: int = 1200) -> Check:
|
|
45
|
+
fe = out / "frontend"
|
|
46
|
+
if (fe / "node_modules" / ".bin" / "tsc").exists():
|
|
47
|
+
return Check("npm install", True, "node_modules present", skipped=True)
|
|
48
|
+
npm = shutil.which("npm")
|
|
49
|
+
if not npm:
|
|
50
|
+
return Check("npm install", False, "npm not found on PATH; install Node.js 20+ or set pipeline.run_builds: false", skipped=True)
|
|
51
|
+
code, txt = _run([npm, "install", "--no-audit", "--no-fund", "--loglevel=error"], fe, timeout)
|
|
52
|
+
return Check("npm install", code == 0, txt)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def frontend_build(out: Path, timeout: int = 900) -> Check:
|
|
56
|
+
fe = out / "frontend"
|
|
57
|
+
npm = shutil.which("npm")
|
|
58
|
+
if not npm or not (fe / "node_modules").exists():
|
|
59
|
+
return Check("frontend build (tsc + vite)", False, "dependencies not installed", skipped=True)
|
|
60
|
+
code, txt = _run([npm, "run", "build", "--silent"], fe, timeout)
|
|
61
|
+
return Check("frontend build (tsc + vite)", code == 0, txt)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def frontend_rule_vectors(out: Path, timeout: int = 120) -> Check:
|
|
65
|
+
node = shutil.which("node")
|
|
66
|
+
fe = out / "frontend"
|
|
67
|
+
if not node:
|
|
68
|
+
return Check("rule vectors (TypeScript engine)", False, "node not found", skipped=True)
|
|
69
|
+
code, txt = _run([node, "scripts/check-rules.ts"], fe, timeout)
|
|
70
|
+
if code != 0 and ("Unknown file extension" in txt or "ERR_UNKNOWN_FILE_EXTENSION" in txt):
|
|
71
|
+
code, txt = _run([node, "--experimental-strip-types", "scripts/check-rules.ts"], fe, timeout)
|
|
72
|
+
if code != 0 and "bad option" in txt:
|
|
73
|
+
return Check("rule vectors (TypeScript engine)", False, "Node.js >= 22.6 is needed to run the TypeScript vectors directly", skipped=True)
|
|
74
|
+
return Check("rule vectors (TypeScript engine)", code == 0, txt)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_all(out: Path, run_builds: bool = True, env: dict | None = None) -> list[Check]:
|
|
78
|
+
checks = [backend_tests(out, env=env)]
|
|
79
|
+
if run_builds:
|
|
80
|
+
inst = frontend_install(out)
|
|
81
|
+
checks.append(inst)
|
|
82
|
+
if inst.ok:
|
|
83
|
+
checks.append(frontend_build(out))
|
|
84
|
+
checks.append(frontend_rule_vectors(out))
|
|
85
|
+
return checks
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def failures_by_screen(checks: list[Check], screen_ids: list[str], handlers: dict[str, str]) -> dict[str, str]:
|
|
89
|
+
"""Route failure output to the screen whose files are named in it; '' key = infrastructure."""
|
|
90
|
+
out: dict[str, list[str]] = {}
|
|
91
|
+
for c in checks:
|
|
92
|
+
if c.ok or c.skipped:
|
|
93
|
+
continue
|
|
94
|
+
hit = False
|
|
95
|
+
for sid in screen_ids:
|
|
96
|
+
if re.search(rf"screens/{re.escape(sid)}/", c.output) or re.search(rf"test_{re.escape(sid.lower().replace('-', '_'))}", c.output):
|
|
97
|
+
out.setdefault(sid, []).append(f"## {c.name}\n{c.tail()}")
|
|
98
|
+
hit = True
|
|
99
|
+
for handler, sid in handlers.items():
|
|
100
|
+
if re.search(rf"custom/{re.escape(handler)}\.", c.output):
|
|
101
|
+
out.setdefault(sid, []).append(f"## {c.name}\n{c.tail()}")
|
|
102
|
+
hit = True
|
|
103
|
+
if not hit:
|
|
104
|
+
out.setdefault("", []).append(f"## {c.name}\n{c.tail()}")
|
|
105
|
+
return {k: "\n\n".join(v) for k, v in out.items()}
|
sp2react/cli.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""sp2react command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import TEMPLATE, ConfigError, load_config
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG = "sp2react.yaml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _pipeline(args):
|
|
17
|
+
from .pipeline import Pipeline
|
|
18
|
+
cfg = load_config(args.config)
|
|
19
|
+
if getattr(args, "auto_approve", False):
|
|
20
|
+
cfg.pipeline.auto_approve = True
|
|
21
|
+
if getattr(args, "no_builds", False):
|
|
22
|
+
cfg.pipeline.run_builds = False
|
|
23
|
+
return Pipeline(cfg, progress=lambda m: print(m, flush=True))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_init(args) -> int:
|
|
27
|
+
p = Path(args.config)
|
|
28
|
+
if p.exists() and not args.force:
|
|
29
|
+
print(f"{p} already exists (use --force to overwrite)")
|
|
30
|
+
return 1
|
|
31
|
+
p.write_text(TEMPLATE, encoding="utf-8")
|
|
32
|
+
print(f"Wrote {p}. Fill in sharepoint.site_url and the llm section, set the secret environment variables, then run: sp2react doctor")
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_doctor(args) -> int:
|
|
37
|
+
from .llm.providers import make_llm
|
|
38
|
+
from .sharepoint.client import SharePointClient
|
|
39
|
+
cfg = load_config(args.config)
|
|
40
|
+
ok = True
|
|
41
|
+
try:
|
|
42
|
+
web = SharePointClient(cfg.sharepoint).web()
|
|
43
|
+
print(f"SharePoint: OK - '{web.get('Title')}' ({web.get('ServerRelativeUrl')})")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
ok = False
|
|
46
|
+
print(f"SharePoint: FAILED - {e}")
|
|
47
|
+
try:
|
|
48
|
+
llm = make_llm(cfg.llm)
|
|
49
|
+
try:
|
|
50
|
+
models = llm.models()
|
|
51
|
+
claude = [m for m in models if "claude" in (m or "").lower()]
|
|
52
|
+
print(f"Model provider ({cfg.llm.provider}): OK - {len(models)} model(s){'; Claude: ' + ', '.join(claude) if claude else ''}")
|
|
53
|
+
if cfg.llm.model and models and cfg.llm.model not in models:
|
|
54
|
+
print(f" warning: configured model '{cfg.llm.model}' is not in the provider's list")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"Model provider ({cfg.llm.provider}): could not list models ({e}); trying a one-line completion")
|
|
57
|
+
c = llm.complete("Reply with the single word: ready", [{"role": "user", "content": "ping"}], max_tokens=10)
|
|
58
|
+
print(f"Model call: OK - {cfg.llm.model or cfg.llm.deployment} replied '{c.text.strip()[:40]}'")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
ok = False
|
|
61
|
+
print(f"Model provider: FAILED - {e}")
|
|
62
|
+
return 0 if ok else 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_run(args) -> int:
|
|
66
|
+
p = _pipeline(args)
|
|
67
|
+
p.run(pages=args.page or None, offline=args.offline, until=args.until)
|
|
68
|
+
print(f"\nReport: {p.ws / 'report.md'}\nApp: {p.out}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_stage(args) -> int:
|
|
73
|
+
from .pipeline import write_report
|
|
74
|
+
p = _pipeline(args)
|
|
75
|
+
scrs = p.screens(args.page or None)
|
|
76
|
+
sids = [s["id"] for s in scrs] if not args.screen else args.screen
|
|
77
|
+
if args.stage == "extract":
|
|
78
|
+
p.stage_inputs(False)
|
|
79
|
+
p.stage_extract(args.page or None, False)
|
|
80
|
+
elif args.stage == "inventory":
|
|
81
|
+
p.stage_inventory(args.page or None)
|
|
82
|
+
elif args.stage == "spec":
|
|
83
|
+
p.stage_spec(sids)
|
|
84
|
+
elif args.stage == "convert":
|
|
85
|
+
p.stage_convert(sids)
|
|
86
|
+
elif args.stage == "review":
|
|
87
|
+
p.stage_review(sids)
|
|
88
|
+
elif args.stage == "test":
|
|
89
|
+
p.stage_test_and_fix(sids)
|
|
90
|
+
elif args.stage == "migrate":
|
|
91
|
+
specs = [p.load_spec(s) for s in p.approved_sids()]
|
|
92
|
+
p._offline = False
|
|
93
|
+
from .scaffold.generate import generate
|
|
94
|
+
generate(p.out, specs, p.legacy, p.stage_migrate(specs))
|
|
95
|
+
elif args.stage == "map-db":
|
|
96
|
+
specs = [p.load_spec(s) for s in p.approved_sids()] or [p.load_spec(sc["id"]) for sc in p.screens() if (p.ws / "specs" / f"{sc['id']}.json").exists()]
|
|
97
|
+
ok = p.stage_map_db(specs)
|
|
98
|
+
print(f"{'Mapping ready' if ok else 'Mapping needs attention'}: {p.cfg.database.mapping} and {p.ws / 'db-mapping.md'}")
|
|
99
|
+
elif args.stage == "reconcile":
|
|
100
|
+
rep = p.stage_reconcile([p.load_spec(s) for s in p.approved_sids()])
|
|
101
|
+
print(f"{'All lists match' if rep['ok'] else 'Mismatches found'} - see {p.ws / 'reconciliation.md'}")
|
|
102
|
+
write_report(p)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_approve(args) -> int:
|
|
107
|
+
from .pipeline import write_report
|
|
108
|
+
p = _pipeline(args)
|
|
109
|
+
gate = "screen" if args.screen_gate else "spec"
|
|
110
|
+
if gate == "spec" and p.status(args.screen_id, "spec") != "done":
|
|
111
|
+
print(f"The spec for {args.screen_id} is not done yet")
|
|
112
|
+
return 1
|
|
113
|
+
if gate == "screen" and p.status(args.screen_id, "test") != "done":
|
|
114
|
+
print(f"{args.screen_id} has not passed testing yet")
|
|
115
|
+
return 1
|
|
116
|
+
p.approve(args.screen_id, gate, args.by or "")
|
|
117
|
+
write_report(p)
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_status(args) -> int:
|
|
122
|
+
from .pipeline import STAGES
|
|
123
|
+
p = _pipeline(args)
|
|
124
|
+
if args.json:
|
|
125
|
+
print(json.dumps(p.state, indent=2))
|
|
126
|
+
return 0
|
|
127
|
+
for k in ("inputs", "extract"):
|
|
128
|
+
s = p.state["global"].get(k)
|
|
129
|
+
if s:
|
|
130
|
+
print(f"{k:<10} {s['status']:<9} {s.get('detail', '')}")
|
|
131
|
+
for sid, st in p.state["screens"].items():
|
|
132
|
+
print(f"\n{sid}")
|
|
133
|
+
for s in STAGES[2:]:
|
|
134
|
+
if st.get(s):
|
|
135
|
+
print(f" {s:<13} {st[s]['status']:<9} {st[s].get('detail', '')[:110]}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(argv=None) -> int:
|
|
140
|
+
ap = argparse.ArgumentParser(prog="sp2react", description="Convert classic SharePoint ASPX/jQuery forms and pages to React + FastAPI with AI agents.")
|
|
141
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
142
|
+
ap.add_argument("-c", "--config", default=DEFAULT_CONFIG)
|
|
143
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
144
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
145
|
+
|
|
146
|
+
s = sub.add_parser("init", help="write a starter sp2react.yaml")
|
|
147
|
+
s.add_argument("--force", action="store_true")
|
|
148
|
+
s.set_defaults(fn=cmd_init)
|
|
149
|
+
|
|
150
|
+
s = sub.add_parser("doctor", help="check the SharePoint connection and the model provider (lists Bridger's Claude models)")
|
|
151
|
+
s.set_defaults(fn=cmd_doctor)
|
|
152
|
+
|
|
153
|
+
s = sub.add_parser("run", help="run every stage up to the next approval gate")
|
|
154
|
+
s.add_argument("--page", action="append", help="site-relative or server-relative page URL (repeatable); default: all list forms + SitePages")
|
|
155
|
+
s.add_argument("--offline", action="store_true", help="reuse the extraction already in the workspace")
|
|
156
|
+
s.add_argument("--until", default="approve", choices=["inputs", "extract", "inventory", "spec", "approve_spec", "convert", "review", "test", "fix", "approve"])
|
|
157
|
+
s.add_argument("--auto-approve", action="store_true", help="skip the human gates (CI / demos)")
|
|
158
|
+
s.add_argument("--no-builds", action="store_true", help="skip npm install/build")
|
|
159
|
+
s.set_defaults(fn=cmd_run)
|
|
160
|
+
|
|
161
|
+
for name in ("extract", "inventory", "spec", "map-db", "convert", "review", "test", "migrate", "reconcile"):
|
|
162
|
+
s = sub.add_parser(name, help=f"run only the {name} stage")
|
|
163
|
+
s.add_argument("--page", action="append")
|
|
164
|
+
s.add_argument("--screen", action="append")
|
|
165
|
+
s.add_argument("--no-builds", action="store_true")
|
|
166
|
+
s.set_defaults(fn=cmd_stage, stage=name)
|
|
167
|
+
|
|
168
|
+
s = sub.add_parser("approve", help="approve a screen's spec (default) or the finished screen (--screen)")
|
|
169
|
+
s.add_argument("screen_id")
|
|
170
|
+
s.add_argument("--screen", dest="screen_gate", action="store_true")
|
|
171
|
+
s.add_argument("--by", help="name to record with the approval")
|
|
172
|
+
s.set_defaults(fn=cmd_approve)
|
|
173
|
+
|
|
174
|
+
s = sub.add_parser("status", help="show every screen's stage status")
|
|
175
|
+
s.add_argument("--json", action="store_true")
|
|
176
|
+
s.set_defaults(fn=cmd_status)
|
|
177
|
+
|
|
178
|
+
args = ap.parse_args(argv)
|
|
179
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
|
|
180
|
+
try:
|
|
181
|
+
return args.fn(args)
|
|
182
|
+
except ConfigError as e:
|
|
183
|
+
print(f"Configuration problem: {e}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|
sp2react/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Configuration: one YAML file, secrets only through environment variables."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SharePointConfig(BaseModel):
|
|
17
|
+
site_url: str = Field(..., description="Full URL of the site, e.g. https://sp.example.com/sites/SEA")
|
|
18
|
+
auth: Literal["ntlm", "cookie", "bearer", "none"] = "ntlm"
|
|
19
|
+
username_env: str = "SP_USERNAME" # DOMAIN\\user for NTLM
|
|
20
|
+
password_env: str = "SP_PASSWORD"
|
|
21
|
+
cookie_env: str = "SP_COOKIE" # e.g. "FedAuth=...; rtFa=..." copied from a signed-in browser
|
|
22
|
+
token_env: str = "SP_TOKEN" # bearer token (SharePoint Online / Entra)
|
|
23
|
+
verify_ssl: Union[bool, str] = True # True, False, or path to a corporate CA bundle
|
|
24
|
+
timeout: int = 60
|
|
25
|
+
sample_items: int = 200 # rows exported per list (lookup values, seed data)
|
|
26
|
+
extra_headers: dict[str, str] = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
@field_validator("site_url")
|
|
29
|
+
@classmethod
|
|
30
|
+
def _strip(cls, v: str) -> str:
|
|
31
|
+
return v.rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LLMConfig(BaseModel):
|
|
35
|
+
provider: Literal["bridger", "openai", "azure_openai", "anthropic", "scripted"] = "bridger"
|
|
36
|
+
api_format: Literal["anthropic", "openai"] = "anthropic" # bridger only: which wire format Bridger serves Claude on
|
|
37
|
+
base_url: Optional[str] = None # bridger: http://host:port ; azure: https://<res>.openai.azure.com
|
|
38
|
+
model: Optional[str] = None # model id as the provider lists it (run `sp2react doctor`); azure uses `deployment`
|
|
39
|
+
api_key_env: Optional[str] = None # env var holding the key/token; omit if Bridger needs none
|
|
40
|
+
deployment: Optional[str] = None # azure_openai only
|
|
41
|
+
api_version: str = "2024-10-21" # azure_openai only
|
|
42
|
+
temperature: float = 0.0
|
|
43
|
+
max_tokens: int = 16000
|
|
44
|
+
timeout: int = 600 # seconds per model call
|
|
45
|
+
json_mode: bool = True # send response_format=json_object where supported
|
|
46
|
+
extra_headers: dict[str, str] = Field(default_factory=dict)
|
|
47
|
+
verify_ssl: Union[bool, str] = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PipelineConfig(BaseModel):
|
|
51
|
+
workspace: str = "./sp2react-work" # extraction, inventory, spec, agent transcripts, state
|
|
52
|
+
output: str = "./converted-app" # generated React + FastAPI app
|
|
53
|
+
max_concurrency: int = 4 # parallel agent calls
|
|
54
|
+
agent_retries: int = 2 # re-asks per agent task when its output fails validation
|
|
55
|
+
agent_timeout: int = 900 # hard limit per agent task (seconds)
|
|
56
|
+
max_fix_iterations: int = 5
|
|
57
|
+
auto_approve: bool = False # skip the human gates (spec + screen approval)
|
|
58
|
+
run_builds: bool = True # npm install/build + pytest on the generated app
|
|
59
|
+
data_source: Literal["database", "sqlite", "sharepoint"] = "database"
|
|
60
|
+
# database = your existing database (MySQL / SQL Server / PostgreSQL) through a list->table mapping;
|
|
61
|
+
# no list items are ever read from SharePoint
|
|
62
|
+
# sqlite = sp2react migrates the list items into a SQLite copy (needs sharepoint item reads)
|
|
63
|
+
# sharepoint = the generated API reads and writes the live lists
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DatabaseConfig(BaseModel):
|
|
67
|
+
url_env: str = "SP2REACT_DB_URL" # env var with a SQLAlchemy URL, e.g. mysql+pymysql://user:pass@host:3306/sea
|
|
68
|
+
schema_name: Optional[str] = None # database schema, if not the default
|
|
69
|
+
mapping: str = "./db-mapping.json" # list -> table / field -> column mapping (sp2react map-db drafts it)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SecurityConfig(BaseModel):
|
|
73
|
+
allowed_hosts: list[str] = Field(default_factory=list) # extra hosts beyond the SharePoint site and model endpoint
|
|
74
|
+
sharepoint_item_reads: Optional[bool] = None # None: allowed only when data_source is sqlite
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Config(BaseModel):
|
|
78
|
+
sharepoint: SharePointConfig
|
|
79
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
80
|
+
pipeline: PipelineConfig = Field(default_factory=PipelineConfig)
|
|
81
|
+
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
|
82
|
+
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def item_reads_allowed(self) -> bool:
|
|
86
|
+
"""Whether sp2react may read list ITEMS (business data) from SharePoint over HTTP."""
|
|
87
|
+
if self.security.sharepoint_item_reads is not None:
|
|
88
|
+
return self.security.sharepoint_item_reads
|
|
89
|
+
return self.pipeline.data_source == "sqlite"
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def workspace(self) -> Path:
|
|
93
|
+
return Path(self.pipeline.workspace).resolve()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def output(self) -> Path:
|
|
97
|
+
return Path(self.pipeline.output).resolve()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def secret(env_name: Optional[str], what: str, required: bool = True) -> Optional[str]:
|
|
101
|
+
if not env_name:
|
|
102
|
+
if required:
|
|
103
|
+
raise ConfigError(f"No environment variable configured for {what}.")
|
|
104
|
+
return None
|
|
105
|
+
value = os.environ.get(env_name)
|
|
106
|
+
if not value and required:
|
|
107
|
+
raise ConfigError(f"Set the environment variable {env_name} ({what}).")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_config(path: Union[str, Path]) -> Config:
|
|
112
|
+
p = Path(path)
|
|
113
|
+
if not p.exists():
|
|
114
|
+
raise ConfigError(f"Config file not found: {p}. Run `sp2react init` to create one.")
|
|
115
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
116
|
+
try:
|
|
117
|
+
cfg = Config.model_validate(data)
|
|
118
|
+
except Exception as e: # pydantic ValidationError -> readable message
|
|
119
|
+
raise ConfigError(f"Invalid config {p}:\n{e}") from e
|
|
120
|
+
# Relative workspace/output paths are relative to the config file, not the cwd
|
|
121
|
+
base = p.resolve().parent
|
|
122
|
+
for attr in ("workspace", "output"):
|
|
123
|
+
val = getattr(cfg.pipeline, attr)
|
|
124
|
+
if not Path(val).is_absolute():
|
|
125
|
+
setattr(cfg.pipeline, attr, str((base / val).resolve()))
|
|
126
|
+
if not Path(cfg.database.mapping).is_absolute():
|
|
127
|
+
cfg.database.mapping = str((base / cfg.database.mapping).resolve())
|
|
128
|
+
return cfg
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
TEMPLATE = """\
|
|
132
|
+
# sp2react configuration. Secrets are read from environment variables, never stored here.
|
|
133
|
+
sharepoint:
|
|
134
|
+
site_url: https://sharepoint.example.com/sites/SEA
|
|
135
|
+
auth: ntlm # ntlm | cookie | bearer | none
|
|
136
|
+
username_env: SP_USERNAME # DOMAIN\\\\user (ntlm)
|
|
137
|
+
password_env: SP_PASSWORD # (ntlm)
|
|
138
|
+
cookie_env: SP_COOKIE # "FedAuth=...; rtFa=..." from a signed-in browser (cookie)
|
|
139
|
+
token_env: SP_TOKEN # bearer token (bearer)
|
|
140
|
+
verify_ssl: true # or a path to your corporate CA bundle
|
|
141
|
+
sample_items: 200
|
|
142
|
+
|
|
143
|
+
llm:
|
|
144
|
+
provider: bridger # bridger | anthropic | openai | azure_openai
|
|
145
|
+
api_format: anthropic # how Bridger serves Claude: anthropic (/v1/messages) or openai (/v1/chat/completions)
|
|
146
|
+
base_url: http://localhost:4141
|
|
147
|
+
model: claude-sonnet-4 # exact Claude model id as Bridger lists it: run `sp2react doctor`
|
|
148
|
+
api_key_env: BRIDGER_TOKEN # omit if Bridger needs no token
|
|
149
|
+
# azure_openai: base_url: https://<resource>.openai.azure.com, deployment: <name>, api_key_env: AZURE_OPENAI_API_KEY
|
|
150
|
+
# anthropic: model: claude-sonnet-5-5, api_key_env: ANTHROPIC_API_KEY
|
|
151
|
+
# openai: base_url: https://api.openai.com/v1, model: <model>, api_key_env: OPENAI_API_KEY
|
|
152
|
+
|
|
153
|
+
pipeline:
|
|
154
|
+
workspace: ./sp2react-work
|
|
155
|
+
output: ./converted-app
|
|
156
|
+
max_concurrency: 4
|
|
157
|
+
agent_retries: 2
|
|
158
|
+
agent_timeout: 900
|
|
159
|
+
max_fix_iterations: 5
|
|
160
|
+
auto_approve: false
|
|
161
|
+
run_builds: true
|
|
162
|
+
data_source: database # database: your existing MySQL/SQL Server tables (no list items read over HTTP)
|
|
163
|
+
# sqlite: sp2react migrates the items itself | sharepoint: API uses the live lists
|
|
164
|
+
|
|
165
|
+
database:
|
|
166
|
+
url_env: SP2REACT_DB_URL # e.g. mysql+pymysql://user:password@db-host:3306/sea (read from the environment)
|
|
167
|
+
mapping: ./db-mapping.json # drafted by `sp2react map-db`, reviewed by you
|
|
168
|
+
|
|
169
|
+
security:
|
|
170
|
+
allowed_hosts: [] # every request is refused unless it goes to the SharePoint site or the model endpoint
|
|
171
|
+
# sharepoint_item_reads: false # default: false unless data_source is sqlite
|
|
172
|
+
"""
|