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.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|