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,256 @@
1
+ """Spec rule engine (server side). Mirrors frontend/src/runtime/rules.ts; both are tested
2
+ against the same vectors so the browser and the API always agree.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from datetime import date, datetime, timedelta
8
+ from typing import Any, Optional
9
+
10
+ REQUIRED_MESSAGE = "You can't leave this blank."
11
+
12
+
13
+ def is_empty(v: Any) -> bool:
14
+ return v is None or v == "" or v == [] or (isinstance(v, dict) and v.get("id") in (None, 0, ""))
15
+
16
+
17
+ def _scalar(v: Any, against: Any = None) -> Any:
18
+ """Lookup/user objects compare by title when the other side is text, by id when it is a number."""
19
+ if isinstance(v, dict):
20
+ if isinstance(against, (int, float)) and not isinstance(against, bool):
21
+ return v.get("id")
22
+ return v.get("title") if "title" in v else v.get("id")
23
+ return v
24
+
25
+
26
+ def _num_or_date(v: Any) -> Any:
27
+ if isinstance(v, bool):
28
+ return v
29
+ if isinstance(v, (int, float)):
30
+ return v
31
+ if isinstance(v, str):
32
+ try:
33
+ return float(v.replace(",", "").replace("$", "")) if re.fullmatch(r"\s*[$]?-?[\d,]+(\.\d+)?\s*", v) else v
34
+ except ValueError:
35
+ return v
36
+ return v
37
+
38
+
39
+ def _cmp(a: Any, op: str, b: Any) -> bool:
40
+ a, b = _num_or_date(_scalar(a, b)), _num_or_date(b)
41
+ if op == "eq":
42
+ return a == b
43
+ if op == "ne":
44
+ return a != b
45
+ if a is None or b is None or a == "":
46
+ return False
47
+ try:
48
+ if op == "gt":
49
+ return a > b
50
+ if op == "gte":
51
+ return a >= b
52
+ if op == "lt":
53
+ return a < b
54
+ if op == "lte":
55
+ return a <= b
56
+ except TypeError:
57
+ return False
58
+ raise ValueError(f"unknown operator {op}")
59
+
60
+
61
+ def get_value(values: dict, path: str) -> Any:
62
+ head, _, rest = path.partition(".")
63
+ v = values.get(head)
64
+ if rest and isinstance(v, dict):
65
+ return v.get(rest) if rest in v else v.get(rest.lower())
66
+ return v
67
+
68
+
69
+ def evaluate(cond: Optional[dict], ctx: dict) -> bool:
70
+ """ctx: {mode, values, original, user: {groups: [...]}}"""
71
+ if not cond:
72
+ return True
73
+ if "all" in cond:
74
+ return all(evaluate(c, ctx) for c in cond["all"])
75
+ if "any" in cond:
76
+ return any(evaluate(c, ctx) for c in cond["any"])
77
+ if "not" in cond:
78
+ return not evaluate(cond["not"], ctx)
79
+ if "role" in cond:
80
+ return cond["role"] in (ctx.get("user") or {}).get("groups", [])
81
+ if "mode" in cond:
82
+ modes = cond["mode"] if isinstance(cond["mode"], list) else [cond["mode"]]
83
+ return ctx.get("mode") in modes
84
+ if "field" in cond:
85
+ v = get_value(ctx.get("values") or {}, cond["field"])
86
+ result = True
87
+ for op, arg in cond.items():
88
+ if op == "field":
89
+ continue
90
+ if op == "empty":
91
+ ok = is_empty(v) == bool(arg)
92
+ elif op == "notEmpty":
93
+ ok = (not is_empty(v)) == bool(arg)
94
+ elif op == "changed":
95
+ before = get_value(ctx.get("original") or {}, cond["field"])
96
+ ok = (_scalar(v) != _scalar(before)) == bool(arg)
97
+ elif op == "in":
98
+ ok = _scalar(v, (arg or [None])[0]) in arg
99
+ elif op == "nin":
100
+ ok = _scalar(v, (arg or [None])[0]) not in arg
101
+ else:
102
+ ok = _cmp(v, op, arg)
103
+ result = result and ok
104
+ return result
105
+ raise ValueError(f"unknown condition {cond}")
106
+
107
+
108
+ def applies(rule: dict, mode: str) -> bool:
109
+ return not rule.get("modes") or mode in rule["modes"]
110
+
111
+
112
+ def rules_for(rules: list[dict], kind: str, field: Optional[str], mode: str) -> list[dict]:
113
+ return [r for r in rules if r["kind"] == kind and (field is None or r.get("field") == field) and applies(r, mode)]
114
+
115
+
116
+ def field_state(field: dict, rules: list[dict], ctx: dict) -> dict:
117
+ name, mode = field["name"], ctx["mode"]
118
+ # visible rules: those for this mode must all hold; a field whose visible rules are all scoped to
119
+ # other modes is hidden in this one (e.g. Status shown only on edit/display)
120
+ all_vis = [r for r in rules if r["kind"] == "visible" and r.get("field") == name]
121
+ vis_rules = [r for r in all_vis if applies(r, mode)]
122
+ visible = all(evaluate(r.get("when"), ctx) for r in vis_rules) if vis_rules else not all_vis or not all(r.get("modes") for r in all_vis)
123
+ if field.get("type") == "calculated" and mode != "display":
124
+ visible = False
125
+ readonly = mode == "display" or bool(field.get("read_only")) or any(evaluate(r.get("when"), ctx) for r in rules_for(rules, "readonly", name, mode))
126
+ required = bool(field.get("required")) and mode != "display" and not readonly
127
+ req_rule = next((r for r in rules_for(rules, "required", name, mode) if evaluate(r.get("when"), ctx)), None)
128
+ return {"visible": visible, "readonly": readonly, "required": required or (req_rule is not None and visible),
129
+ "required_rule": req_rule}
130
+
131
+
132
+ def resolve_token(token: Any, user: dict, today: Optional[date] = None) -> Any:
133
+ if token == "@me":
134
+ return {"id": user.get("id"), "login": user.get("login"), "title": user.get("title")}
135
+ if token == "@today":
136
+ return (today or date.today()).isoformat()
137
+ return token
138
+
139
+
140
+ def defaults(fields: list[dict], rules: list[dict], user: dict, today: Optional[date] = None) -> dict:
141
+ out = {}
142
+ for f in fields:
143
+ if "default" in f:
144
+ out[f["name"]] = resolve_token(f["default"], user, today)
145
+ for r in rules_for(rules, "default", None, "new"):
146
+ out[r["field"]] = resolve_token(r["params"]["value"], user, today)
147
+ return out
148
+
149
+
150
+ def fill_template(text: str, values: dict) -> str:
151
+ """'{Field}', '{Lookup.title}', '{Field|lower}', '{Field|upper}' -> values."""
152
+ def rep(m):
153
+ v = _scalar(get_value(values, m.group(1)))
154
+ if v is None:
155
+ return ""
156
+ if isinstance(v, float) and v.is_integer():
157
+ v = int(v)
158
+ out = str(v).lower() if isinstance(v, bool) else str(v)
159
+ return out.lower() if m.group(2) == "lower" else out.upper() if m.group(2) == "upper" else out
160
+ return re.sub(r"\{([\w.]+)(?:\|(lower|upper))?\}", rep, text or "")
161
+
162
+
163
+ def validate(fields: list[dict], rules: list[dict], ctx: dict) -> list[dict]:
164
+ """-> [{field, message, presentation, rule}] in display order, schema checks first per field."""
165
+ errors: list[dict] = []
166
+ values, mode = ctx.get("values") or {}, ctx["mode"]
167
+ by_name = {f["name"]: f for f in fields}
168
+ states = {f["name"]: field_state(f, rules, ctx) for f in fields}
169
+
170
+ for f in fields:
171
+ st, v = states[f["name"]], values.get(f["name"])
172
+ if not st["visible"] or st["readonly"]:
173
+ continue
174
+ if f.get("required") and is_empty(v):
175
+ errors.append({"field": f["name"], "message": REQUIRED_MESSAGE, "presentation": "inline", "rule": "schema"})
176
+ continue
177
+ if is_empty(v):
178
+ continue
179
+ if f.get("max_length") and isinstance(v, str) and len(v) > f["max_length"]:
180
+ errors.append({"field": f["name"], "message": f"The value of this field may not contain more than {f['max_length']} characters.",
181
+ "presentation": "inline", "rule": "schema"})
182
+ if f.get("type") in ("number", "currency"):
183
+ n = _num_or_date(v)
184
+ if not isinstance(n, (int, float)):
185
+ errors.append({"field": f["name"], "message": "The value of this field must be a number.", "presentation": "inline", "rule": "schema"})
186
+ else:
187
+ if "min" in f and n < f["min"]:
188
+ errors.append({"field": f["name"], "message": f"The value of this field must be greater than or equal to {f['min']:g}.", "presentation": "inline", "rule": "schema"})
189
+ if "max" in f and n > f["max"]:
190
+ errors.append({"field": f["name"], "message": f"The value of this field must be less than or equal to {f['max']:g}.", "presentation": "inline", "rule": "schema"})
191
+ if f.get("type") == "choice" and f.get("choices") and not f.get("fill_in") and v not in f["choices"]:
192
+ errors.append({"field": f["name"], "message": "Select a valid value.", "presentation": "inline", "rule": "schema"})
193
+
194
+ for r in rules:
195
+ if not applies(r, mode) or mode == "display":
196
+ continue
197
+ fname = r.get("field")
198
+ st = states.get(fname) if fname else None
199
+ if st and (not st["visible"] or st["readonly"]) and r["kind"] not in ("lock_item",):
200
+ continue
201
+ if r.get("when") and not evaluate(r["when"], ctx):
202
+ continue
203
+ k, v, p = r["kind"], values.get(fname) if fname else None, r.get("params") or {}
204
+ failed = False
205
+ if k == "required":
206
+ failed = is_empty(v)
207
+ elif k == "compare":
208
+ other = values.get(p["other"])
209
+ if is_empty(v) or is_empty(other):
210
+ failed = False
211
+ else:
212
+ failed = not _cmp(v, p["op"], _scalar(other))
213
+ elif k == "length":
214
+ failed = not (p.get("allow_empty") and is_empty(v)) and not _cmp(len(str(v or "")), p["op"], p["value"])
215
+ elif k == "range":
216
+ failed = not is_empty(v) and not _cmp(v, p["op"], p["value"])
217
+ elif k == "pattern":
218
+ failed = not is_empty(v) and not re.fullmatch(p["regex"], str(v))
219
+ if failed:
220
+ errors.append({"field": fname, "message": fill_template(r.get("message") or REQUIRED_MESSAGE, values),
221
+ "presentation": r.get("presentation", "inline"), "rule": r["id"]})
222
+ # one error per field (the first), like SharePoint shows one message under a control
223
+ seen, out = set(), []
224
+ for e in errors:
225
+ key = (e["field"], e["presentation"]) if e["field"] else (e["rule"],)
226
+ if key in seen:
227
+ continue
228
+ seen.add(key)
229
+ out.append(e)
230
+ return out
231
+
232
+
233
+ def lock(rules: list[dict], ctx: dict) -> Optional[dict]:
234
+ for r in rules_for(rules, "lock_item", None, ctx["mode"]):
235
+ if evaluate(r.get("when"), ctx):
236
+ return r
237
+ return None
238
+
239
+
240
+ def set_on_save(rules: list[dict], ctx: dict) -> dict:
241
+ return {r["field"]: resolve_token(r["params"]["value"], ctx.get("user") or {}) for r in rules_for(rules, "set_on_save", None, ctx["mode"])
242
+ if evaluate(r.get("when"), ctx)}
243
+
244
+
245
+ def banners(rules: list[dict], ctx: dict) -> list[dict]:
246
+ return [r for r in rules_for(rules, "banner", None, ctx["mode"]) if evaluate(r.get("when"), ctx)]
247
+
248
+
249
+ def clear_hidden(fields: list[dict], rules: list[dict], ctx: dict) -> dict:
250
+ """Fields hidden by a visible rule with params.clear lose their value (legacy: hide row + .val(""))."""
251
+ out = {}
252
+ for f in fields:
253
+ clears = [r for r in rules if r["kind"] == "visible" and r.get("field") == f["name"] and (r.get("params") or {}).get("clear")]
254
+ if clears and ctx["mode"] != "display" and not field_state(f, rules, ctx)["visible"] and not is_empty((ctx.get("values") or {}).get(f["name"])):
255
+ out[f["name"]] = None
256
+ return out
@@ -0,0 +1,159 @@
1
+ """Optional data source: keep the data in SharePoint and use this API as a backend-for-frontend.
2
+
3
+ Select with DATA_SOURCE=sharepoint. Same interface as SqliteRepo, so the rules engine and routes
4
+ do not change. Auth for the service account: SP_AUTH=ntlm|cookie|bearer with SP_USERNAME/SP_PASSWORD,
5
+ SP_COOKIE or SP_TOKEN. Writes use the request digest from /_api/contextinfo.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+ from typing import Any, Optional
12
+
13
+ import requests
14
+
15
+ from .store import Conflict, Specs
16
+
17
+
18
+ class SharePointRepo:
19
+ def __init__(self, specs: Specs):
20
+ self.specs = specs
21
+ self.site = os.environ["SP_SITE_URL"].rstrip("/")
22
+ self.s = requests.Session()
23
+ self.s.headers.update({"Accept": "application/json;odata=verbose"})
24
+ self.s.verify = os.environ.get("SP_VERIFY_SSL", "true").lower() not in ("0", "false", "no")
25
+ mode = os.environ.get("SP_AUTH", "ntlm")
26
+ if mode == "ntlm":
27
+ from requests_ntlm import HttpNtlmAuth # type: ignore
28
+ self.s.auth = HttpNtlmAuth(os.environ["SP_USERNAME"], os.environ["SP_PASSWORD"])
29
+ elif mode == "cookie":
30
+ self.s.headers["Cookie"] = os.environ["SP_COOKIE"]
31
+ elif mode == "bearer":
32
+ self.s.headers["Authorization"] = "Bearer " + os.environ["SP_TOKEN"]
33
+ self.users: list[dict] = []
34
+
35
+ # -------------------------------------------------------------- helpers
36
+ def _title(self, list_name: str) -> str:
37
+ return self.specs.lists[list_name]["title"].replace("'", "''")
38
+
39
+ def _url(self, list_name: str, suffix: str = "") -> str:
40
+ return f"{self.site}/_api/web/lists/getbytitle('{self._title(list_name)}')/items{suffix}"
41
+
42
+ def _digest(self) -> str:
43
+ r = self.s.post(f"{self.site}/_api/contextinfo")
44
+ r.raise_for_status()
45
+ return r.json()["d"]["GetContextWebInformation"]["FormDigestValue"]
46
+
47
+ def _fields(self, list_name: str) -> dict[str, dict]:
48
+ return {f["name"]: f for f in self.specs.lists[list_name]["fields"]}
49
+
50
+ def _query(self, list_name: str) -> dict:
51
+ sel, exp = ["*", "Author/Id", "Author/Title", "Author/Name", "Editor/Id", "Editor/Title", "Editor/Name", "AttachmentFiles"], ["Author", "Editor", "AttachmentFiles"]
52
+ for f in self._fields(list_name).values():
53
+ if f["type"] == "lookup":
54
+ sel += [f"{f['name']}/Id", f"{f['name']}/{f['lookup'].get('field', 'Title')}"]
55
+ exp.append(f["name"])
56
+ if f["type"] == "user":
57
+ sel += [f"{f['name']}/Id", f"{f['name']}/Title", f"{f['name']}/Name"]
58
+ exp.append(f["name"])
59
+ return {"$select": ",".join(sel), "$expand": ",".join(exp)}
60
+
61
+ @staticmethod
62
+ def _user(u: Any) -> Optional[dict]:
63
+ if not u or not isinstance(u, dict) or "Id" not in u:
64
+ return None
65
+ return {"id": u["Id"], "login": (u.get("Name") or "").split("|")[-1], "title": u.get("Title")}
66
+
67
+ def _to_item(self, list_name: str, d: dict) -> dict:
68
+ item: dict = {"ID": d["Id"]}
69
+ for name, f in self._fields(list_name).items():
70
+ v = d.get(name)
71
+ if f["type"] == "lookup":
72
+ col = f["lookup"].get("field", "Title")
73
+ conv = lambda x: {"id": x["Id"], "title": x.get(col)} if x and x.get("Id") else None
74
+ item[name] = [conv(x) for x in v.get("results", [])] if isinstance(v, dict) and "results" in v else conv(v)
75
+ elif f["type"] == "user":
76
+ item[name] = [self._user(x) for x in v.get("results", [])] if isinstance(v, dict) and "results" in v else self._user(v)
77
+ elif f["type"] == "date" and v:
78
+ item[name] = v[:10]
79
+ else:
80
+ item[name] = v
81
+ etag = d.get("__metadata", {}).get("etag", '"1"')
82
+ item.update({"Created": d.get("Created"), "Modified": d.get("Modified"), "Author": self._user(d.get("Author")),
83
+ "Editor": self._user(d.get("Editor")), "_etag": etag, "_version": etag.strip('"') + ".0",
84
+ "Attachments": [a["FileName"] for a in (d.get("AttachmentFiles") or {}).get("results", [])]})
85
+ return item
86
+
87
+ def _payload(self, list_name: str, values: dict) -> dict:
88
+ out: dict = {}
89
+ for name, v in values.items():
90
+ f = self._fields(list_name).get(name)
91
+ if not f:
92
+ continue
93
+ if f["type"] in ("lookup", "user"):
94
+ if isinstance(v, list):
95
+ out[f"{name}Id"] = {"results": [x.get("id") if isinstance(x, dict) else x for x in v]}
96
+ else:
97
+ out[f"{name}Id"] = v.get("id") if isinstance(v, dict) else v
98
+ else:
99
+ out[name] = v
100
+ return out
101
+
102
+ # ----------------------------------------------------------------- API
103
+ def user(self, uid):
104
+ return None
105
+
106
+ def user_by_login(self, login: str):
107
+ r = self.s.get(f"{self.site}/_api/web/siteusers", params={"$filter": f"substringof('{login}',LoginName)"})
108
+ r.raise_for_status()
109
+ res = r.json()["d"]["results"]
110
+ if not res:
111
+ return None
112
+ u = res[0]
113
+ g = self.s.get(f"{self.site}/_api/web/getuserbyid({u['Id']})/groups").json()["d"]["results"]
114
+ return {"id": u["Id"], "login": login, "title": u["Title"], "email": u.get("Email"), "groups": [x["Title"] for x in g]}
115
+
116
+ def all(self, list_name: str) -> list[dict]:
117
+ r = self.s.get(self._url(list_name), params={**self._query(list_name), "$top": "5000"})
118
+ r.raise_for_status()
119
+ return [self._to_item(list_name, d) for d in r.json()["d"]["results"]]
120
+
121
+ def get(self, list_name: str, iid: int) -> Optional[dict]:
122
+ r = self.s.get(self._url(list_name, f"({iid})"), params=self._query(list_name))
123
+ if r.status_code == 404:
124
+ return None
125
+ r.raise_for_status()
126
+ return self._to_item(list_name, r.json()["d"])
127
+
128
+ def create(self, list_name: str, values: dict, user: dict) -> dict:
129
+ body = {"__metadata": {"type": f"SP.Data.{self.specs.lists[list_name].get('entity', list_name + 'List')}Item"}, **self._payload(list_name, values)}
130
+ r = self.s.post(self._url(list_name), json=body, headers={"X-RequestDigest": self._digest(), "Content-Type": "application/json;odata=verbose"})
131
+ r.raise_for_status()
132
+ return self.get(list_name, r.json()["d"]["Id"])
133
+
134
+ def update(self, list_name: str, iid: int, values: dict, user: dict, etag: Optional[str] = None) -> dict:
135
+ body = {"__metadata": {"type": f"SP.Data.{self.specs.lists[list_name].get('entity', list_name + 'List')}Item"}, **self._payload(list_name, values)}
136
+ r = self.s.post(self._url(list_name, f"({iid})"), json=body, headers={
137
+ "X-RequestDigest": self._digest(), "Content-Type": "application/json;odata=verbose",
138
+ "X-HTTP-Method": "MERGE", "IF-MATCH": etag or "*"})
139
+ if r.status_code == 412:
140
+ raise Conflict()
141
+ r.raise_for_status()
142
+ return self.get(list_name, iid)
143
+
144
+ def delete(self, list_name: str, iid: int) -> None:
145
+ r = self.s.post(self._url(list_name, f"({iid})"), headers={"X-RequestDigest": self._digest(), "X-HTTP-Method": "DELETE", "IF-MATCH": "*"})
146
+ r.raise_for_status()
147
+
148
+ def add_attachment(self, list_name: str, iid: int, name: str, content_b64: str) -> None:
149
+ r = self.s.post(self._url(list_name, f"({iid})/AttachmentFiles/add(FileName='{name}')"), data=base64.b64decode(content_b64),
150
+ headers={"X-RequestDigest": self._digest()})
151
+ r.raise_for_status()
152
+
153
+ def attachment(self, list_name: str, iid: int, name: str) -> Optional[bytes]:
154
+ r = self.s.get(self._url(list_name, f"({iid})/AttachmentFiles('{name}')/$value"))
155
+ return r.content if r.ok else None
156
+
157
+ def delete_attachment(self, list_name: str, iid: int, name: str) -> None:
158
+ r = self.s.post(self._url(list_name, f"({iid})/AttachmentFiles('{name}')"), headers={"X-RequestDigest": self._digest(), "X-HTTP-Method": "DELETE"})
159
+ r.raise_for_status()