mgraphctl 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.

Potentially problematic release.


This version of mgraphctl might be problematic. Click here for more details.

Files changed (57) hide show
  1. mgraphctl/__init__.py +3 -0
  2. mgraphctl/__main__.py +3 -0
  3. mgraphctl/auth.py +395 -0
  4. mgraphctl/cli.py +259 -0
  5. mgraphctl/commands/__init__.py +71 -0
  6. mgraphctl/commands/api.py +164 -0
  7. mgraphctl/commands/calendar.py +491 -0
  8. mgraphctl/commands/chats.py +329 -0
  9. mgraphctl/commands/config_cmd.py +140 -0
  10. mgraphctl/commands/groups.py +65 -0
  11. mgraphctl/commands/mail.py +705 -0
  12. mgraphctl/commands/mailbox.py +176 -0
  13. mgraphctl/commands/meetings.py +294 -0
  14. mgraphctl/commands/onedrive.py +351 -0
  15. mgraphctl/commands/onenote.py +196 -0
  16. mgraphctl/commands/org.py +102 -0
  17. mgraphctl/commands/people.py +201 -0
  18. mgraphctl/commands/planner.py +268 -0
  19. mgraphctl/commands/presence.py +102 -0
  20. mgraphctl/commands/search.py +75 -0
  21. mgraphctl/commands/sharepoint.py +304 -0
  22. mgraphctl/commands/teams.py +235 -0
  23. mgraphctl/commands/todo.py +253 -0
  24. mgraphctl/commands/top.py +284 -0
  25. mgraphctl/config.py +385 -0
  26. mgraphctl/errors.py +276 -0
  27. mgraphctl/fixtures.py +149 -0
  28. mgraphctl/graph/__init__.py +1 -0
  29. mgraphctl/graph/calendar.py +313 -0
  30. mgraphctl/graph/chats.py +447 -0
  31. mgraphctl/graph/files.py +269 -0
  32. mgraphctl/graph/groups.py +55 -0
  33. mgraphctl/graph/mail.py +663 -0
  34. mgraphctl/graph/mailbox.py +89 -0
  35. mgraphctl/graph/meetings.py +269 -0
  36. mgraphctl/graph/onedrive.py +35 -0
  37. mgraphctl/graph/onenote.py +138 -0
  38. mgraphctl/graph/org.py +86 -0
  39. mgraphctl/graph/people.py +83 -0
  40. mgraphctl/graph/planner.py +415 -0
  41. mgraphctl/graph/presence.py +89 -0
  42. mgraphctl/graph/search.py +178 -0
  43. mgraphctl/graph/sharepoint.py +294 -0
  44. mgraphctl/graph/teams.py +240 -0
  45. mgraphctl/graph/todo.py +199 -0
  46. mgraphctl/graph/users.py +85 -0
  47. mgraphctl/html.py +127 -0
  48. mgraphctl/http.py +843 -0
  49. mgraphctl/odata.py +72 -0
  50. mgraphctl/render.py +627 -0
  51. mgraphctl/resolve.py +111 -0
  52. mgraphctl/token_store.py +196 -0
  53. mgraphctl-0.1.0.dist-info/METADATA +226 -0
  54. mgraphctl-0.1.0.dist-info/RECORD +57 -0
  55. mgraphctl-0.1.0.dist-info/WHEEL +4 -0
  56. mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
  57. mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,178 @@
1
+ """Graph operations for the unified `search` command (spec §8.17).
2
+
3
+ Pure: client and parameters in, an `http.SearchResult` out. The Search API
4
+ (`POST /search/query`) is entered through `GraphClient.search`, which already handles
5
+ paging on `moreResultsAvailable`; this module only shapes the query and the results.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+
12
+ from mgraphctl.errors import UsageError
13
+ from mgraphctl.graph.chats import shape_chat_hit
14
+ from mgraphctl.http import GraphClient, SearchResult
15
+ from mgraphctl.render import (
16
+ Column,
17
+ dig,
18
+ fmt_dt,
19
+ fmt_dtz,
20
+ fmt_person,
21
+ kql_date,
22
+ parse_graph_dt,
23
+ truncate,
24
+ )
25
+
26
+ ENTITY_TYPES = ("message", "event", "driveItem", "site", "list", "chatMessage", "person")
27
+
28
+ # Scopes per §8.17: declared statically as [] on the command and gated at runtime with
29
+ # `gate(SCOPES_BY_TYPE[type])`, because the scope needed depends on `--type`.
30
+ SCOPES_BY_TYPE: dict[str, list[str]] = {
31
+ "message": ["Mail.Read"],
32
+ "event": ["Calendars.Read"],
33
+ "driveItem": ["Sites.Read.All"],
34
+ "site": ["Sites.Read.All"],
35
+ "list": ["Sites.Read.All"],
36
+ "chatMessage": ["Chat.Read"],
37
+ "person": ["People.Read"],
38
+ }
39
+
40
+ SEARCH_SIZE, DEFAULT_LIMIT, CAP_SEARCH = 25, 25, 200
41
+
42
+ # The client-side date-window field for every type but `message` (server-side KQL) and
43
+ # `person` (no date field on a person hit) — spec §8.17.
44
+ _DATE_PATH: dict[str, str] = {
45
+ "event": "resource.start.dateTime",
46
+ "driveItem": "resource.lastModifiedDateTime",
47
+ "site": "resource.lastModifiedDateTime",
48
+ "list": "resource.lastModifiedDateTime",
49
+ "chatMessage": "resource.createdDateTime",
50
+ }
51
+
52
+
53
+ def normalise_entity_type(value: str) -> str:
54
+ """Fold `--type` onto the Graph spelling; anything else is a usage error."""
55
+ for known in ENTITY_TYPES:
56
+ if known.casefold() == value.casefold():
57
+ return known
58
+ raise UsageError("USAGE", f"unknown --type {value!r}; use one of {', '.join(ENTITY_TYPES)}")
59
+
60
+
61
+ def _within(hit: dict, entity_type: str, after: datetime | None, before: datetime | None) -> bool:
62
+ if after is None and before is None:
63
+ return True
64
+ path = _DATE_PATH.get(entity_type)
65
+ if path is None: # message (filtered server-side) and person (no date field)
66
+ return True
67
+ when = parse_graph_dt(dig(hit, path))
68
+ if when is None:
69
+ return False
70
+ if after is not None and when < after:
71
+ return False
72
+ return before is None or when <= before
73
+
74
+
75
+ def _author(hit: dict) -> str:
76
+ """The display name of whoever last touched a document hit, or its creator."""
77
+ for key in ("lastModifiedBy", "createdBy"):
78
+ name = dig(hit, f"resource.{key}.user.displayName")
79
+ if name:
80
+ return name
81
+ return ""
82
+
83
+
84
+ def _person_email(hit: dict) -> str:
85
+ resource = hit.get("resource") or {}
86
+ scored = resource.get("scoredEmailAddresses") or []
87
+ if scored and scored[0].get("address"):
88
+ return scored[0]["address"]
89
+ emails = resource.get("emailAddresses") or []
90
+ return emails[0].get("address", "") if emails else ""
91
+
92
+
93
+ def columns_for(entity_type: str, tz: str) -> list[Column]:
94
+ """The per-type list columns: id, date, title/subject, from/author, webUrl (spec §8.17)."""
95
+ if entity_type == "message":
96
+ return [
97
+ Column("id", lambda h: dig(h, "resource.id")),
98
+ Column("received", lambda h: fmt_dt(dig(h, "resource.receivedDateTime"), tz)),
99
+ Column("subject", lambda h: truncate(dig(h, "resource.subject"))),
100
+ Column("from", lambda h: fmt_person(dig(h, "resource.from"))),
101
+ Column("webUrl", lambda h: dig(h, "resource.webUrl")),
102
+ ]
103
+ if entity_type == "event":
104
+ return [
105
+ Column("id", lambda h: dig(h, "resource.id")),
106
+ Column("start", lambda h: fmt_dtz(dig(h, "resource.start"), tz)),
107
+ Column("subject", lambda h: truncate(dig(h, "resource.subject"))),
108
+ Column("organizer", lambda h: fmt_person(dig(h, "resource.organizer"))),
109
+ Column("webUrl", lambda h: dig(h, "resource.webUrl")),
110
+ ]
111
+ if entity_type in ("driveItem", "site", "list"):
112
+ return [
113
+ Column("id", lambda h: dig(h, "resource.id")),
114
+ Column("modified", lambda h: fmt_dt(dig(h, "resource.lastModifiedDateTime"), tz)),
115
+ Column("name", lambda h: dig(h, "resource.name") or dig(h, "resource.displayName")),
116
+ Column("author", _author),
117
+ Column("webUrl", lambda h: dig(h, "resource.webUrl")),
118
+ ]
119
+ if entity_type == "chatMessage":
120
+ # Already flattened by `shape_hit` (`shape_chat_hit`): id, created, from, where, summary.
121
+ return [
122
+ Column("id", "id"),
123
+ Column("created", lambda h: fmt_dt(h.get("created"), tz)),
124
+ Column("from", "from"),
125
+ Column("where", "where"),
126
+ Column("summary", lambda h: truncate(h.get("summary"))),
127
+ ]
128
+ if entity_type == "person":
129
+ return [
130
+ Column("id", lambda h: dig(h, "resource.id")),
131
+ Column("name", lambda h: dig(h, "resource.displayName")),
132
+ Column("email", _person_email),
133
+ Column("title", lambda h: dig(h, "resource.jobTitle")),
134
+ ]
135
+ raise UsageError("USAGE", f"unknown --type {entity_type!r}") # pragma: no cover - CLI validates
136
+
137
+
138
+ def shape_hit(entity_type: str, hit: dict) -> dict:
139
+ """Flatten a `chatMessage` hit the same way `chats search` does (quirk 16); every other
140
+ type is returned as Graph sent it (kept under `resource`, `hitId`, `rank`, `summary`)."""
141
+ if entity_type == "chatMessage":
142
+ return shape_chat_hit(hit)
143
+ return hit
144
+
145
+
146
+ def search(
147
+ client: GraphClient,
148
+ *,
149
+ entity_type: str,
150
+ q: str,
151
+ after: datetime | None,
152
+ before: datetime | None,
153
+ limit: int,
154
+ all_: bool,
155
+ fields: list[str] | None,
156
+ tz: str,
157
+ ) -> SearchResult:
158
+ """Run one `/search/query` request (paged by `GraphClient.search`) and filter it."""
159
+ terms = [q]
160
+ if entity_type == "message":
161
+ if after is not None:
162
+ terms.append(f"received>={kql_date(after, tz)}")
163
+ if before is not None:
164
+ terms.append(f"received<={kql_date(before, tz)}")
165
+ query_string = " ".join(terms)
166
+ bound = CAP_SEARCH if all_ else limit
167
+ found = client.search([entity_type], query_string, size=SEARCH_SIZE, limit=bound, fields=fields)
168
+ hits = found.hits
169
+ if entity_type != "message":
170
+ hits = [h for h in hits if _within(h, entity_type, after, before)]
171
+ return SearchResult(
172
+ hits=hits,
173
+ total=found.total,
174
+ more=found.more,
175
+ fetched=len(found.hits),
176
+ cap=bound,
177
+ query={"entityTypes": entity_type, "query": query_string},
178
+ )
@@ -0,0 +1,294 @@
1
+ """Graph operations for SharePoint sites, drives and lists (spec §8.12, §6.6).
2
+
3
+ Pure: client + params in, Graph dicts / PageResult / Plan out. Drive browsing, download and
4
+ upload reuse `graph.files` with a site-drive `base` (`/sites/{id}/drive` or `/drives/{id}`);
5
+ this module owns site/drive/list resolution and the SharePoint-only `url` and `search` verbs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from urllib.parse import quote, unquote, urlsplit
14
+
15
+ from mgraphctl import odata, resolve
16
+ from mgraphctl.errors import AuthError, GraphError, NotFoundError
17
+ from mgraphctl.graph import files
18
+ from mgraphctl.http import DownloadResult, GraphClient, PageResult, SearchResult
19
+
20
+ SITE_SELECT = "id,displayName,name,webUrl,description"
21
+ SITES_FOLLOWED_SELECT = "id,displayName,webUrl"
22
+ DRIVE_SELECT = "id,name,webUrl,driveType"
23
+ LIST_SELECT = "id,displayName,webUrl,list"
24
+
25
+ SEARCH_SIZE, SEARCH_CAP = 25, 200
26
+ ITEMS_PAGE, ITEMS_CAP = 200, 500
27
+ PREFER_NONINDEXED = "HonorNonIndexedQueriesWarningMayFailRandomly"
28
+
29
+ _SITE_KINDS = ("sites", "teams", "personal")
30
+ _VIEWER_SEGMENT_RE = re.compile(r"^:[A-Za-z]:$")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Resolution:
35
+ site_id: str | None
36
+ drive_id: str | None
37
+ path: str
38
+ item: dict
39
+
40
+
41
+ def _site_id_segment(site_id: str) -> str:
42
+ """A site id in a URL path: commas kept literal, like `odata.site_ref`'s composite form."""
43
+ return quote(site_id, safe=",")
44
+
45
+
46
+ def _site_path(site_id: str, *rest: str) -> str:
47
+ tail = "/".join(quote(s, safe="") for s in rest)
48
+ base = f"/sites/{_site_id_segment(site_id)}"
49
+ return f"{base}/{tail}" if tail else base
50
+
51
+
52
+ # --------------------------------------------------------------------------- sites
53
+
54
+
55
+ def list_sites(client: GraphClient, *, search: str | None, limit: int) -> PageResult:
56
+ """Followed sites by default; `--search` or an empty followed list falls back to `/sites`."""
57
+ if not search:
58
+ payload = client.get("/me/followedSites", params={"$select": SITES_FOLLOWED_SELECT}) or {}
59
+ items = payload.get("value") or []
60
+ if items:
61
+ return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
62
+ search = "*"
63
+ payload = client.get("/sites", params={"search": search, "$top": limit}) or {}
64
+ items = payload.get("value") or []
65
+ return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
66
+
67
+
68
+ def resolve_site(client: GraphClient, value: str) -> dict:
69
+ """A URL / `host:/a/b` / composite id / `id:`-forced id via `odata.site_ref`, else a name."""
70
+ bare, _ = resolve.split_id_prefix(value)
71
+ if resolve.looks_like_id(value, "site"):
72
+ return client.get(odata.site_ref(bare), params={"$select": SITE_SELECT})
73
+ payload = client.get("/sites", params={"search": value}) or {}
74
+ items = payload.get("value") or []
75
+ return resolve.pick_unique(items, "displayName", value, what="site")
76
+
77
+
78
+ def resolve_site_id(client: GraphClient, value: str) -> str:
79
+ """The site's id, offline when `value` already IS one: `id:`-forced, or the composite
80
+ `host,guid,guid` form Graph returns from `site` (no lookup needed to know its own id).
81
+ A URL / `host:/a/b` reference still needs a live fetch to learn the real id; so does a name.
82
+ """
83
+ bare, forced = resolve.split_id_prefix(value)
84
+ if forced or "," in bare:
85
+ return bare
86
+ return resolve_site(client, value)["id"]
87
+
88
+
89
+ # --------------------------------------------------------------------------- drives
90
+
91
+
92
+ def list_drives(client: GraphClient, site_id: str) -> list[dict]:
93
+ payload = client.get(_site_path(site_id, "drives"), params={"$select": DRIVE_SELECT}) or {}
94
+ return payload.get("value") or []
95
+
96
+
97
+ def resolve_drive(client: GraphClient, site_id: str, value: str) -> str:
98
+ """A `b!` drive id, else a `name` among the site's drives."""
99
+ if resolve.looks_like_id(value, "drive"):
100
+ bare, _ = resolve.split_id_prefix(value)
101
+ return bare
102
+ drives = list_drives(client, site_id)
103
+ return resolve.pick_unique(drives, "name", value, what="drive")["id"]
104
+
105
+
106
+ def drive_base(site_id: str, drive_id: str | None = None) -> str:
107
+ """`/sites/{id}/drive` (the site's default drive) or `/drives/{id}` (a named drive)."""
108
+ if drive_id is None:
109
+ return _site_path(site_id, "drive")
110
+ return odata.p("drives", drive_id)
111
+
112
+
113
+ # --------------------------------------------------------------------------- search
114
+
115
+
116
+ def search_site_drive(
117
+ client: GraphClient, site_id: str, q: str, *, limit: int, all_: bool = False
118
+ ) -> PageResult:
119
+ base = drive_base(site_id)
120
+ path = f"{base}/root/search(q='{odata.odata_str(q)}')"
121
+ return client.paginate(
122
+ path,
123
+ params={"$select": files.ITEM_SELECT},
124
+ limit=limit,
125
+ all_=all_,
126
+ cap=SEARCH_CAP,
127
+ page_size=SEARCH_SIZE,
128
+ )
129
+
130
+
131
+ def search_all(client: GraphClient, q: str, *, limit: int, all_: bool = False) -> SearchResult:
132
+ bound = SEARCH_CAP if all_ else min(limit, SEARCH_CAP)
133
+ return client.search(["driveItem"], q, size=SEARCH_SIZE, limit=bound)
134
+
135
+
136
+ # --------------------------------------------------------------------------- url resolution
137
+
138
+
139
+ def _is_personal_url(url: str) -> bool:
140
+ return "/personal/" in urlsplit(url).path
141
+
142
+
143
+ def _url_segments(url: str) -> tuple[str, list[str]]:
144
+ """The host and decoded path segments, with a leading `/:x:/r/` viewer prefix skipped."""
145
+ parsed = urlsplit(url)
146
+ segments = [unquote(s) for s in parsed.path.split("/") if s]
147
+ while segments and (_VIEWER_SEGMENT_RE.match(segments[0]) or segments[0].lower() == "r"):
148
+ segments.pop(0)
149
+ return parsed.netloc, segments
150
+
151
+
152
+ def _rel_path(item: dict) -> str:
153
+ """The item's drive-relative path, from `parentReference.path` plus its name."""
154
+ raw = (item.get("parentReference") or {}).get("path") or ""
155
+ _, _, after_colon = raw.partition(":")
156
+ name = item.get("name") or ""
157
+ return f"{after_colon}/{name}" if after_colon else f"/{name}"
158
+
159
+
160
+ def _pick_drive(drives: list[dict], segments: list[str]) -> tuple[dict | None, list[str]]:
161
+ """The drive whose `webUrl` is the deepest prefix of `segments`, and the remaining path."""
162
+ best: dict | None = None
163
+ best_len = -1
164
+ for d in drives:
165
+ web = d.get("webUrl") or ""
166
+ web_segments = [unquote(s) for s in urlsplit(web).path.split("/") if s]
167
+ n = len(web_segments)
168
+ if 0 < n <= len(segments) and n > best_len:
169
+ folded = [s.casefold() for s in segments[:n]]
170
+ if folded == [s.casefold() for s in web_segments]:
171
+ best, best_len = d, n
172
+ if best is None:
173
+ return None, segments[2:]
174
+ return best, segments[best_len:]
175
+
176
+
177
+ def _resolve_via_site(client: GraphClient, url: str) -> tuple[Resolution, str]:
178
+ """Node's fallback algorithm: parse the site from the URL, then locate the item."""
179
+ host, segments = _url_segments(url)
180
+ if len(segments) < 2 or segments[0] not in _SITE_KINDS:
181
+ raise NotFoundError("NOT_FOUND", f"cannot parse a SharePoint site from {url!r}")
182
+ kind, name = segments[0], segments[1]
183
+ site_path = f"/sites/{quote(host, safe='')}:/{quote(kind, safe='')}/{quote(name, safe='')}"
184
+ site = client.get(site_path, params={"$select": SITE_SELECT})
185
+ site_id = site["id"]
186
+ drive, drive_rel = _pick_drive(list_drives(client, site_id), segments)
187
+ rest = segments[2:]
188
+ last_exc: GraphError | None = None
189
+ if drive is not None:
190
+ rel = "/".join(quote(s, safe="") for s in drive_rel)
191
+ path = f"/drives/{drive['id']}/root:/{rel}" if rel else f"/drives/{drive['id']}/root"
192
+ try:
193
+ item = client.get(path, params={"$select": files.ITEM_SELECT})
194
+ resolution = Resolution(site_id, drive["id"], "/" + "/".join(drive_rel), item)
195
+ content = f"{path}:/content" if rel else f"{path}/content"
196
+ return resolution, content
197
+ except GraphError as exc:
198
+ if exc.status != 404:
199
+ raise
200
+ last_exc = exc
201
+ base = _site_path(site_id, "drive")
202
+ for candidate in (rest, rest[1:]):
203
+ if not candidate:
204
+ continue
205
+ rel = "/".join(quote(s, safe="") for s in candidate)
206
+ path = f"{base}/root:/{rel}"
207
+ try:
208
+ item = client.get(path, params={"$select": files.ITEM_SELECT})
209
+ resolution = Resolution(site_id, None, "/" + "/".join(candidate), item)
210
+ return resolution, f"{path}:/content"
211
+ except GraphError as exc:
212
+ last_exc = exc
213
+ if exc.status != 404:
214
+ raise
215
+ continue
216
+ raise last_exc or NotFoundError("NOT_FOUND", f"could not resolve {url!r} to a SharePoint item")
217
+
218
+
219
+ def _resolve(client: GraphClient, url: str) -> tuple[Resolution, str]:
220
+ share_path = f"/shares/{odata.share_id(url)}/driveItem"
221
+ try:
222
+ item = client.get(share_path, params={"$select": files.ITEM_SELECT})
223
+ except GraphError as exc:
224
+ if not (400 <= exc.status < 500):
225
+ raise
226
+ if exc.status == 403 and _is_personal_url(url):
227
+ raise AuthError(
228
+ "FORBIDDEN",
229
+ f"cannot resolve {url!r}: this is a personal OneDrive link",
230
+ hint="ask the owner to share the file and use that share link instead",
231
+ ) from exc
232
+ return _resolve_via_site(client, url)
233
+ pr = item.get("parentReference") or {}
234
+ resolution = Resolution(pr.get("siteId"), pr.get("driveId"), _rel_path(item), item)
235
+ return resolution, f"{share_path}/content"
236
+
237
+
238
+ def resolve_url(client: GraphClient, url: str) -> Resolution:
239
+ resolution, _content_path = _resolve(client, url)
240
+ return resolution
241
+
242
+
243
+ def download_url(
244
+ client: GraphClient, url: str, dest: Path | None
245
+ ) -> tuple[Resolution, DownloadResult]:
246
+ resolution, content_path = _resolve(client, url)
247
+ name = resolution.item.get("name") or url.rsplit("/", 1)[-1].split("?", 1)[0]
248
+ result = client.download(content_path, dest or Path(Path(name).name or resolution.item["id"]))
249
+ return resolution, result
250
+
251
+
252
+ # --------------------------------------------------------------------------- lists / items
253
+
254
+
255
+ def list_lists(client: GraphClient, site_id: str) -> list[dict]:
256
+ """Every list, with hidden system lists dropped (`list.hidden`); document libraries kept."""
257
+ payload = client.get(_site_path(site_id, "lists"), params={"$select": LIST_SELECT}) or {}
258
+ items = payload.get("value") or []
259
+ return [i for i in items if not (i.get("list") or {}).get("hidden")]
260
+
261
+
262
+ def resolve_list(client: GraphClient, site_id: str, value: str) -> dict:
263
+ if resolve.looks_like_id(value, "guid"):
264
+ bare, _ = resolve.split_id_prefix(value)
265
+ return client.get(_site_path(site_id, "lists", bare), params={"$select": LIST_SELECT})
266
+ return resolve.pick_unique(list_lists(client, site_id), "displayName", value, what="list")
267
+
268
+
269
+ def list_items(
270
+ client: GraphClient,
271
+ site_id: str,
272
+ list_id: str,
273
+ *,
274
+ fields: list[str] | None,
275
+ filter_: str | None,
276
+ limit: int | None,
277
+ all_: bool,
278
+ ) -> PageResult:
279
+ expand = f"fields($select={','.join(fields)})" if fields else "fields"
280
+ params: dict[str, object] = {"$expand": expand}
281
+ headers = None
282
+ if filter_:
283
+ params["$filter"] = filter_
284
+ headers = {"Prefer": PREFER_NONINDEXED}
285
+ path = _site_path(site_id, "lists", list_id, "items")
286
+ return client.paginate(
287
+ path,
288
+ params=params,
289
+ headers=headers,
290
+ limit=limit,
291
+ all_=all_,
292
+ cap=ITEMS_CAP,
293
+ page_size=ITEMS_PAGE,
294
+ )