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,201 @@
|
|
|
1
|
+
"""People, contacts and directory users (spec §8.5)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from mgraphctl import auth
|
|
9
|
+
from mgraphctl.cli import (
|
|
10
|
+
AllFlag,
|
|
11
|
+
JsonFlag,
|
|
12
|
+
LimitOpt,
|
|
13
|
+
gate,
|
|
14
|
+
graph_command,
|
|
15
|
+
make_noun_app,
|
|
16
|
+
page_bounds,
|
|
17
|
+
)
|
|
18
|
+
from mgraphctl.errors import AuthError
|
|
19
|
+
from mgraphctl.graph import people
|
|
20
|
+
from mgraphctl.graph import users as graph_users
|
|
21
|
+
from mgraphctl.http import GraphClient
|
|
22
|
+
from mgraphctl.render import Column, FileResult, ListResult, ObjectResult, fmt_size
|
|
23
|
+
|
|
24
|
+
app = make_noun_app("People, contacts and directory users.")
|
|
25
|
+
|
|
26
|
+
CONTACT_FIELDS = [
|
|
27
|
+
("Name", "displayName"),
|
|
28
|
+
("Email", lambda c: _first_email(c)),
|
|
29
|
+
("Mobile", "mobilePhone"),
|
|
30
|
+
("Business phones", lambda c: ", ".join(c.get("businessPhones") or [])),
|
|
31
|
+
("Title", "jobTitle"),
|
|
32
|
+
("Company", "companyName"),
|
|
33
|
+
("Contact id", "id"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
USER_FIELDS = [
|
|
37
|
+
("Name", "displayName"),
|
|
38
|
+
("UPN", "userPrincipalName"),
|
|
39
|
+
("Mail", "mail"),
|
|
40
|
+
("Title", "jobTitle"),
|
|
41
|
+
("Dept", "department"),
|
|
42
|
+
("Office", "officeLocation"),
|
|
43
|
+
("Phones", lambda u: ", ".join(u.get("businessPhones") or [])),
|
|
44
|
+
("Mobile", "mobilePhone"),
|
|
45
|
+
("User id", "id"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _first_scored_email(p: dict) -> str:
|
|
50
|
+
emails = p.get("scoredEmailAddresses") or []
|
|
51
|
+
return emails[0].get("address", "") if emails else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _first_email(c: dict) -> str:
|
|
55
|
+
emails = c.get("emailAddresses") or []
|
|
56
|
+
return emails[0].get("address", "") if emails else ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _first_phone(c: dict) -> str:
|
|
60
|
+
phones = c.get("businessPhones") or []
|
|
61
|
+
return c.get("mobilePhone") or (phones[0] if phones else "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _has_scope(scopes: list[str]) -> bool:
|
|
65
|
+
"""Whether the current token already carries `scopes`, without failing the command."""
|
|
66
|
+
try:
|
|
67
|
+
gate(scopes)
|
|
68
|
+
except AuthError:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("search")
|
|
74
|
+
@graph_command(scopes=["People.Read"])
|
|
75
|
+
def search(
|
|
76
|
+
client: GraphClient,
|
|
77
|
+
q: Annotated[str, typer.Argument(metavar="Q")],
|
|
78
|
+
limit: LimitOpt = None,
|
|
79
|
+
all_: AllFlag = False,
|
|
80
|
+
json_: JsonFlag = False,
|
|
81
|
+
):
|
|
82
|
+
"""Search people relevant to the signed-in user (`/me/people`)."""
|
|
83
|
+
limit, all_ = page_bounds(limit, all_, default=20)
|
|
84
|
+
page = people.search_people(client, q, limit=limit, all_=all_)
|
|
85
|
+
return ListResult(
|
|
86
|
+
items=page.items,
|
|
87
|
+
page=page,
|
|
88
|
+
hit_cap=people.CAP if all_ else None,
|
|
89
|
+
columns=[
|
|
90
|
+
Column("id", "id"),
|
|
91
|
+
Column("name", "displayName"),
|
|
92
|
+
Column("email", _first_scored_email),
|
|
93
|
+
Column("title", "jobTitle"),
|
|
94
|
+
Column("department", "department"),
|
|
95
|
+
Column("type", lambda p: (p.get("personType") or {}).get("class", "")),
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("contacts")
|
|
101
|
+
@graph_command(scopes=["Contacts.Read"])
|
|
102
|
+
def contacts(
|
|
103
|
+
client: GraphClient,
|
|
104
|
+
search: Annotated[str | None, typer.Option("--search", help="Filter by name or email.")] = None,
|
|
105
|
+
limit: LimitOpt = None,
|
|
106
|
+
all_: AllFlag = False,
|
|
107
|
+
json_: JsonFlag = False,
|
|
108
|
+
):
|
|
109
|
+
"""List the signed-in user's contacts."""
|
|
110
|
+
limit, all_ = page_bounds(limit, all_, default=20)
|
|
111
|
+
page = people.list_contacts(client, search=search, limit=limit, all_=all_)
|
|
112
|
+
return ListResult(
|
|
113
|
+
items=page.items,
|
|
114
|
+
page=page,
|
|
115
|
+
hit_cap=people.CAP if all_ else None,
|
|
116
|
+
columns=[
|
|
117
|
+
Column("id", "id"),
|
|
118
|
+
Column("name", "displayName"),
|
|
119
|
+
Column("email", _first_email),
|
|
120
|
+
Column("phone", _first_phone),
|
|
121
|
+
Column("title", "jobTitle"),
|
|
122
|
+
Column("company", "companyName"),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("contact")
|
|
128
|
+
@graph_command(scopes=["Contacts.Read"])
|
|
129
|
+
def contact(
|
|
130
|
+
client: GraphClient,
|
|
131
|
+
contact_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
132
|
+
json_: JsonFlag = False,
|
|
133
|
+
):
|
|
134
|
+
"""Show one contact."""
|
|
135
|
+
return ObjectResult(obj=people.get_contact(client, contact_id), fields=list(CONTACT_FIELDS))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command("users")
|
|
139
|
+
@graph_command(scopes=["User.ReadBasic.All"])
|
|
140
|
+
def users(
|
|
141
|
+
client: GraphClient,
|
|
142
|
+
q: Annotated[str, typer.Argument(metavar="Q")],
|
|
143
|
+
limit: LimitOpt = None,
|
|
144
|
+
all_: AllFlag = False,
|
|
145
|
+
json_: JsonFlag = False,
|
|
146
|
+
):
|
|
147
|
+
"""Search the directory for users (`/users`, tokenised `$search`)."""
|
|
148
|
+
limit, all_ = page_bounds(limit, all_, default=20)
|
|
149
|
+
page = graph_users.search_users(client, q, limit=limit, all_=all_)
|
|
150
|
+
return ListResult(
|
|
151
|
+
items=page.items,
|
|
152
|
+
page=page,
|
|
153
|
+
hit_cap=graph_users.CAP if all_ else None,
|
|
154
|
+
columns=[
|
|
155
|
+
Column("id", "id"),
|
|
156
|
+
Column("name", "displayName"),
|
|
157
|
+
Column("upn", "userPrincipalName"),
|
|
158
|
+
Column("mail", "mail"),
|
|
159
|
+
Column("title", "jobTitle"),
|
|
160
|
+
Column("department", "department"),
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command("user")
|
|
166
|
+
@graph_command(scopes=["User.Read"])
|
|
167
|
+
def user(
|
|
168
|
+
client: GraphClient,
|
|
169
|
+
ref: Annotated[str, typer.Argument(metavar="UPN|ID")],
|
|
170
|
+
json_: JsonFlag = False,
|
|
171
|
+
):
|
|
172
|
+
"""Look up one org user, by UPN, id, `me`, or (with User.ReadBasic.All) display name."""
|
|
173
|
+
can_search = _has_scope(["User.ReadBasic.All"])
|
|
174
|
+
obj = graph_users.resolve_user(client, ref, can_search=can_search)
|
|
175
|
+
return ObjectResult(obj=obj, fields=list(USER_FIELDS))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command("photo")
|
|
179
|
+
@graph_command(scopes=["User.Read"])
|
|
180
|
+
def photo(
|
|
181
|
+
client: GraphClient,
|
|
182
|
+
upn: Annotated[str | None, typer.Argument(metavar="UPN")] = None,
|
|
183
|
+
output: Annotated[Path | None, typer.Option("--output", help="Destination file.")] = None,
|
|
184
|
+
size: Annotated[str, typer.Option("--size", help="Photo size (others only).")] = "96x96",
|
|
185
|
+
json_: JsonFlag = False,
|
|
186
|
+
):
|
|
187
|
+
"""Download a profile photo (self by default)."""
|
|
188
|
+
if upn is not None:
|
|
189
|
+
gate(["User.ReadBasic.All"])
|
|
190
|
+
stem = upn
|
|
191
|
+
else:
|
|
192
|
+
claims = auth.decode_jwt(auth.get_access_token(False))
|
|
193
|
+
stem = claims.get("upn") or "me"
|
|
194
|
+
dest = output or Path(f"{stem}.jpg")
|
|
195
|
+
got = people.download_photo(client, upn, size, dest)
|
|
196
|
+
return FileResult(
|
|
197
|
+
path=got.path,
|
|
198
|
+
bytes=got.bytes,
|
|
199
|
+
meta=dict(contentType=got.content_type),
|
|
200
|
+
message=f"Downloaded photo ({fmt_size(got.bytes)}) to {got.path}",
|
|
201
|
+
)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Planner commands: plans, buckets, tasks (spec §8.14)."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from mgraphctl.cli import DryRunFlag, JsonFlag, LimitOpt, graph_command, make_noun_app
|
|
8
|
+
from mgraphctl.errors import UsageError
|
|
9
|
+
from mgraphctl.graph import planner, users
|
|
10
|
+
from mgraphctl.http import GraphClient
|
|
11
|
+
from mgraphctl.render import (
|
|
12
|
+
Column,
|
|
13
|
+
DryRunResult,
|
|
14
|
+
ListResult,
|
|
15
|
+
ObjectResult,
|
|
16
|
+
WriteResult,
|
|
17
|
+
fmt_dt,
|
|
18
|
+
note,
|
|
19
|
+
parse_dt,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = make_noun_app("Planner plans and tasks.")
|
|
23
|
+
|
|
24
|
+
SCOPES = ["Tasks.ReadWrite"]
|
|
25
|
+
SCOPES_PLANS = ["Tasks.ReadWrite", "Group.Read.All"]
|
|
26
|
+
DEFAULT_LIMIT = 50
|
|
27
|
+
|
|
28
|
+
PLAN_COLUMNS = [
|
|
29
|
+
Column("id", "id"),
|
|
30
|
+
Column("title", "title"),
|
|
31
|
+
Column("group", lambda p: p.get("_groupName") or ""),
|
|
32
|
+
]
|
|
33
|
+
BUCKET_COLUMNS = [Column("id", "id"), Column("name", "name")]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _task_columns(tz: str, *, my: bool) -> list[Column]:
|
|
37
|
+
columns = [
|
|
38
|
+
Column("id", "id"),
|
|
39
|
+
Column("%", lambda t: t.get("percentComplete", "")),
|
|
40
|
+
Column("priority", lambda t: t.get("priority", "")),
|
|
41
|
+
Column("due", lambda t: fmt_dt(t.get("dueDateTime"), tz)),
|
|
42
|
+
]
|
|
43
|
+
if my:
|
|
44
|
+
columns.append(Column("plan", lambda t: t.get("_planTitle") or ""))
|
|
45
|
+
else:
|
|
46
|
+
columns.append(Column("bucket", lambda t: t.get("_bucketName") or ""))
|
|
47
|
+
columns.append(Column("title", "title"))
|
|
48
|
+
return columns
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _assignee_oids(client: GraphClient, upns: list[str]) -> list[str]:
|
|
52
|
+
return [users.get_user(client, upn, select="id")["id"] for upn in upns]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("plans")
|
|
56
|
+
@graph_command(scopes=SCOPES_PLANS)
|
|
57
|
+
def plans(
|
|
58
|
+
client: GraphClient,
|
|
59
|
+
limit: LimitOpt = None,
|
|
60
|
+
json_: JsonFlag = False,
|
|
61
|
+
):
|
|
62
|
+
"""List plans you own plus every plan of a Microsoft 365 group you belong to."""
|
|
63
|
+
eff_limit = limit if limit is not None else DEFAULT_LIMIT
|
|
64
|
+
page = planner.list_plans(client, limit=eff_limit, on_skip=note)
|
|
65
|
+
return ListResult(items=page.items, columns=PLAN_COLUMNS, page=page, supports_all=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("plan")
|
|
69
|
+
@graph_command(scopes=SCOPES)
|
|
70
|
+
def plan(
|
|
71
|
+
client: GraphClient,
|
|
72
|
+
plan_ref: Annotated[str, typer.Argument(metavar="PLAN")],
|
|
73
|
+
json_: JsonFlag = False,
|
|
74
|
+
):
|
|
75
|
+
"""Show one plan, with its details."""
|
|
76
|
+
resolved = planner.resolve_plan(client, plan_ref)
|
|
77
|
+
obj = planner.get_plan(client, resolved["id"])
|
|
78
|
+
fields = [
|
|
79
|
+
("Title", "title"),
|
|
80
|
+
("Plan id", "id"),
|
|
81
|
+
("Created", lambda o: fmt_dt(o.get("createdDateTime"), client.tz)),
|
|
82
|
+
("Description", lambda o: (o.get("details") or {}).get("description") or ""),
|
|
83
|
+
]
|
|
84
|
+
return ObjectResult(obj=obj, fields=fields)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("buckets")
|
|
88
|
+
@graph_command(scopes=SCOPES)
|
|
89
|
+
def buckets(
|
|
90
|
+
client: GraphClient,
|
|
91
|
+
plan_ref: Annotated[str, typer.Argument(metavar="PLAN")],
|
|
92
|
+
json_: JsonFlag = False,
|
|
93
|
+
):
|
|
94
|
+
"""List the buckets of a plan."""
|
|
95
|
+
resolved = planner.resolve_plan(client, plan_ref)
|
|
96
|
+
items = planner.list_buckets(client, resolved["id"])
|
|
97
|
+
return ListResult(items=items, columns=BUCKET_COLUMNS)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("tasks")
|
|
101
|
+
@graph_command(scopes=SCOPES)
|
|
102
|
+
def tasks(
|
|
103
|
+
client: GraphClient,
|
|
104
|
+
plan_ref: Annotated[str | None, typer.Argument(metavar="PLAN")] = None,
|
|
105
|
+
my: Annotated[bool, typer.Option("--my", help="Your tasks across every plan.")] = False,
|
|
106
|
+
bucket: Annotated[str | None, typer.Option("--bucket", help="Bucket name or id.")] = None,
|
|
107
|
+
include_completed: Annotated[bool, typer.Option("--include-completed")] = False,
|
|
108
|
+
limit: LimitOpt = None,
|
|
109
|
+
json_: JsonFlag = False,
|
|
110
|
+
):
|
|
111
|
+
"""List tasks in a plan, or your own tasks across every plan (--my)."""
|
|
112
|
+
eff_limit = limit if limit is not None else DEFAULT_LIMIT
|
|
113
|
+
if my:
|
|
114
|
+
page = planner.my_tasks(
|
|
115
|
+
client, include_completed=include_completed, limit=eff_limit, on_skip=note
|
|
116
|
+
)
|
|
117
|
+
return ListResult(
|
|
118
|
+
items=page.items,
|
|
119
|
+
columns=_task_columns(client.tz, my=True),
|
|
120
|
+
page=page,
|
|
121
|
+
supports_all=False,
|
|
122
|
+
)
|
|
123
|
+
if plan_ref is None:
|
|
124
|
+
raise UsageError("USAGE", "PLAN is required unless --my is given")
|
|
125
|
+
resolved = planner.resolve_plan(client, plan_ref)
|
|
126
|
+
bucket_id = None
|
|
127
|
+
if bucket is not None:
|
|
128
|
+
bucket_id = planner.resolve_bucket(client, resolved["id"], bucket)["id"]
|
|
129
|
+
page = planner.list_tasks(
|
|
130
|
+
client,
|
|
131
|
+
resolved["id"],
|
|
132
|
+
bucket_id=bucket_id,
|
|
133
|
+
include_completed=include_completed,
|
|
134
|
+
limit=eff_limit,
|
|
135
|
+
)
|
|
136
|
+
return ListResult(
|
|
137
|
+
items=page.items,
|
|
138
|
+
columns=_task_columns(client.tz, my=False),
|
|
139
|
+
page=page,
|
|
140
|
+
supports_all=False,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command("task")
|
|
145
|
+
@graph_command(scopes=SCOPES)
|
|
146
|
+
def task(
|
|
147
|
+
client: GraphClient,
|
|
148
|
+
task_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
149
|
+
json_: JsonFlag = False,
|
|
150
|
+
):
|
|
151
|
+
"""Show one task, with its details."""
|
|
152
|
+
obj = planner.get_task(client, task_id)
|
|
153
|
+
fields = [
|
|
154
|
+
("Title", "title"),
|
|
155
|
+
("Plan id", "planId"),
|
|
156
|
+
("Bucket id", "bucketId"),
|
|
157
|
+
("%", "percentComplete"),
|
|
158
|
+
("Priority", "priority"),
|
|
159
|
+
("Due", lambda o: fmt_dt(o.get("dueDateTime"), client.tz)),
|
|
160
|
+
("Description", lambda o: (o.get("details") or {}).get("description") or ""),
|
|
161
|
+
]
|
|
162
|
+
return ObjectResult(obj=obj, fields=fields)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command("create")
|
|
166
|
+
@graph_command(scopes=SCOPES)
|
|
167
|
+
def create(
|
|
168
|
+
client: GraphClient,
|
|
169
|
+
plan_opt: Annotated[str, typer.Option("--plan", help="Plan name or id.")],
|
|
170
|
+
title: Annotated[str, typer.Option("--title")],
|
|
171
|
+
bucket: Annotated[str | None, typer.Option("--bucket", help="Bucket name or id.")] = None,
|
|
172
|
+
due: Annotated[str | None, typer.Option("--due")] = None,
|
|
173
|
+
assign: Annotated[
|
|
174
|
+
list[str] | None, typer.Option("--assign", help="Assignee UPN (repeatable).")
|
|
175
|
+
] = None,
|
|
176
|
+
priority: Annotated[int | None, typer.Option("--priority", min=0, max=10)] = None,
|
|
177
|
+
description: Annotated[str | None, typer.Option("--description")] = None,
|
|
178
|
+
dry_run: DryRunFlag = False,
|
|
179
|
+
json_: JsonFlag = False,
|
|
180
|
+
):
|
|
181
|
+
"""Create a task."""
|
|
182
|
+
resolved_plan = planner.resolve_plan(client, plan_opt)
|
|
183
|
+
bucket_id = None
|
|
184
|
+
if bucket is not None:
|
|
185
|
+
bucket_id = planner.resolve_bucket(client, resolved_plan["id"], bucket)["id"]
|
|
186
|
+
due_dt = parse_dt(due, client.tz) if due is not None else None
|
|
187
|
+
oids = _assignee_oids(client, assign or [])
|
|
188
|
+
kwargs = dict(
|
|
189
|
+
plan_id=resolved_plan["id"],
|
|
190
|
+
bucket_id=bucket_id,
|
|
191
|
+
title=title,
|
|
192
|
+
due=due_dt,
|
|
193
|
+
assignee_oids=oids,
|
|
194
|
+
priority=priority,
|
|
195
|
+
description=description,
|
|
196
|
+
)
|
|
197
|
+
if dry_run:
|
|
198
|
+
return DryRunResult(planner.plan_create_task(client, **kwargs))
|
|
199
|
+
obj = planner.run_create_task(client, **kwargs)
|
|
200
|
+
return WriteResult(obj=obj, message=f"Created task {obj.get('id')}.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command("update")
|
|
204
|
+
@graph_command(scopes=SCOPES)
|
|
205
|
+
def update(
|
|
206
|
+
client: GraphClient,
|
|
207
|
+
task_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
208
|
+
title: Annotated[str | None, typer.Option("--title")] = None,
|
|
209
|
+
due: Annotated[str | None, typer.Option("--due")] = None,
|
|
210
|
+
percent: Annotated[
|
|
211
|
+
Literal[0, 50, 100] | None,
|
|
212
|
+
typer.Option("--percent", help="Completion; Graph accepts only these three values."),
|
|
213
|
+
] = None,
|
|
214
|
+
bucket: Annotated[str | None, typer.Option("--bucket", help="Bucket name or id.")] = None,
|
|
215
|
+
priority: Annotated[int | None, typer.Option("--priority", min=0, max=10)] = None,
|
|
216
|
+
assign: Annotated[list[str] | None, typer.Option("--assign")] = None,
|
|
217
|
+
unassign: Annotated[list[str] | None, typer.Option("--unassign")] = None,
|
|
218
|
+
description: Annotated[str | None, typer.Option("--description")] = None,
|
|
219
|
+
dry_run: DryRunFlag = False,
|
|
220
|
+
json_: JsonFlag = False,
|
|
221
|
+
):
|
|
222
|
+
"""Update a task."""
|
|
223
|
+
due_dt = parse_dt(due, client.tz) if due is not None else None
|
|
224
|
+
bucket_id = planner.resolve_bucket_for_task(client, task_id, bucket)["id"] if bucket else None
|
|
225
|
+
kwargs = dict(
|
|
226
|
+
title=title,
|
|
227
|
+
due=due_dt,
|
|
228
|
+
percent=percent,
|
|
229
|
+
bucket_id=bucket_id,
|
|
230
|
+
priority=priority,
|
|
231
|
+
assign_oids=_assignee_oids(client, assign or []),
|
|
232
|
+
unassign_oids=_assignee_oids(client, unassign or []),
|
|
233
|
+
description=description,
|
|
234
|
+
)
|
|
235
|
+
if dry_run:
|
|
236
|
+
return DryRunResult(planner.plan_update_task(client, task_id, **kwargs))
|
|
237
|
+
obj = planner.run_update_task(client, task_id, **kwargs)
|
|
238
|
+
return WriteResult(obj=obj, message=f"Updated task {task_id}.")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command("complete")
|
|
242
|
+
@graph_command(scopes=SCOPES)
|
|
243
|
+
def complete(
|
|
244
|
+
client: GraphClient,
|
|
245
|
+
task_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
246
|
+
dry_run: DryRunFlag = False,
|
|
247
|
+
json_: JsonFlag = False,
|
|
248
|
+
):
|
|
249
|
+
"""Mark a task complete (percent 100)."""
|
|
250
|
+
if dry_run:
|
|
251
|
+
return DryRunResult(planner.plan_update_task(client, task_id, percent=100))
|
|
252
|
+
obj = planner.run_update_task(client, task_id, percent=100)
|
|
253
|
+
return WriteResult(obj=obj, message=f"Completed task {task_id}.")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@app.command("delete")
|
|
257
|
+
@graph_command(scopes=SCOPES)
|
|
258
|
+
def delete(
|
|
259
|
+
client: GraphClient,
|
|
260
|
+
task_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
261
|
+
dry_run: DryRunFlag = False,
|
|
262
|
+
json_: JsonFlag = False,
|
|
263
|
+
):
|
|
264
|
+
"""Delete a task."""
|
|
265
|
+
if dry_run:
|
|
266
|
+
return DryRunResult(planner.plan_delete_task(client, task_id))
|
|
267
|
+
planner.run_delete_task(client, task_id)
|
|
268
|
+
return WriteResult(obj={"status": "deleted", "id": task_id}, message=f"Deleted task {task_id}.")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Teams presence commands (spec §8.9)."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from mgraphctl import auth
|
|
8
|
+
from mgraphctl.cli import DryRunFlag, JsonFlag, gate, graph_command, make_noun_app
|
|
9
|
+
from mgraphctl.graph import presence as presence_api
|
|
10
|
+
from mgraphctl.graph import users
|
|
11
|
+
from mgraphctl.http import GraphClient
|
|
12
|
+
from mgraphctl.render import (
|
|
13
|
+
Column,
|
|
14
|
+
DryRunResult,
|
|
15
|
+
ListResult,
|
|
16
|
+
ObjectResult,
|
|
17
|
+
WriteResult,
|
|
18
|
+
dig,
|
|
19
|
+
parse_duration,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
OTHERS_SCOPES = ["Presence.Read.All"]
|
|
23
|
+
STATES = ", ".join(presence_api.PRESENCE_PAIRS)
|
|
24
|
+
|
|
25
|
+
app = make_noun_app("Teams presence.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("get")
|
|
29
|
+
@graph_command(scopes=["Presence.Read"])
|
|
30
|
+
def get(
|
|
31
|
+
client: GraphClient,
|
|
32
|
+
user: Annotated[
|
|
33
|
+
list[str] | None,
|
|
34
|
+
typer.Argument(metavar="[USER...]", help="UPNs or user ids; omit for yourself."),
|
|
35
|
+
] = None,
|
|
36
|
+
json_: JsonFlag = False,
|
|
37
|
+
):
|
|
38
|
+
"""Show your presence, or that of the given users."""
|
|
39
|
+
if not user:
|
|
40
|
+
return ObjectResult(
|
|
41
|
+
obj=presence_api.get_presence(client),
|
|
42
|
+
fields=[
|
|
43
|
+
("Availability", "availability"),
|
|
44
|
+
("Activity", "activity"),
|
|
45
|
+
("Status", lambda p: dig(p, "statusMessage.message.content")),
|
|
46
|
+
("User id", "id"),
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
gate(OTHERS_SCOPES) # Reading anyone else needs the on-demand scope (spec §4.4).
|
|
50
|
+
people = [users.get_user(client, ref, select="id,displayName") for ref in user]
|
|
51
|
+
names = {p["id"]: p.get("displayName") for p in people}
|
|
52
|
+
return ListResult(
|
|
53
|
+
items=presence_api.get_presences(client, [p["id"] for p in people]),
|
|
54
|
+
empty_text="No presence information.",
|
|
55
|
+
columns=[
|
|
56
|
+
Column("id", "id"),
|
|
57
|
+
Column("name", lambda p: names.get(p.get("id"), "")),
|
|
58
|
+
Column("availability", "availability"),
|
|
59
|
+
Column("activity", "activity"),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("set")
|
|
65
|
+
@graph_command(scopes=["Presence.ReadWrite"])
|
|
66
|
+
def set_(
|
|
67
|
+
client: GraphClient,
|
|
68
|
+
state: Annotated[str, typer.Argument(metavar="STATE", help=f"One of {STATES}.")],
|
|
69
|
+
expiration: Annotated[
|
|
70
|
+
str, typer.Option("--expiration", help="How long it lasts (30m, 2h, PT1H).")
|
|
71
|
+
] = "1h",
|
|
72
|
+
message: Annotated[
|
|
73
|
+
str | None, typer.Option("--message", help="Status message to show alongside it.")
|
|
74
|
+
] = None,
|
|
75
|
+
dry_run: DryRunFlag = False,
|
|
76
|
+
json_: JsonFlag = False,
|
|
77
|
+
):
|
|
78
|
+
"""Set your preferred presence."""
|
|
79
|
+
availability, _ = presence_api.resolve_pair(state)
|
|
80
|
+
plan = presence_api.plan_set(
|
|
81
|
+
client,
|
|
82
|
+
auth.my_oid(),
|
|
83
|
+
state,
|
|
84
|
+
expiration=parse_duration(expiration),
|
|
85
|
+
message=message,
|
|
86
|
+
)
|
|
87
|
+
if dry_run:
|
|
88
|
+
return DryRunResult(plan)
|
|
89
|
+
for step in plan:
|
|
90
|
+
client.execute(step)
|
|
91
|
+
return WriteResult(obj=None, message=f"Presence set to {availability}.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("clear")
|
|
95
|
+
@graph_command(scopes=["Presence.ReadWrite"])
|
|
96
|
+
def clear(client: GraphClient, dry_run: DryRunFlag = False, json_: JsonFlag = False):
|
|
97
|
+
"""Clear your preferred presence, handing it back to Teams."""
|
|
98
|
+
plan = presence_api.plan_clear(client, auth.my_oid())
|
|
99
|
+
if dry_run:
|
|
100
|
+
return DryRunResult(plan)
|
|
101
|
+
client.execute(plan[0])
|
|
102
|
+
return WriteResult(obj=None, message="Preferred presence cleared.")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Unified search across Microsoft 365 (spec §8.17). Registered as a top-level command."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from mgraphctl.cli import AllFlag, JsonFlag, LimitOpt, gate, graph_command, page_bounds
|
|
8
|
+
from mgraphctl.graph import search as search_ops
|
|
9
|
+
from mgraphctl.http import GraphClient
|
|
10
|
+
from mgraphctl.render import (
|
|
11
|
+
ListResult,
|
|
12
|
+
Window,
|
|
13
|
+
parse_dt,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _split_fields(value: str | None) -> list[str] | None:
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
fields = [f.strip() for f in value.split(",") if f.strip()]
|
|
21
|
+
return fields or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@graph_command(scopes=[]) # the scope depends on --type; gated at runtime below (spec §8.17)
|
|
25
|
+
def command(
|
|
26
|
+
client: GraphClient,
|
|
27
|
+
q: Annotated[str, typer.Argument(metavar="Q", help="What to search for.")],
|
|
28
|
+
type_: Annotated[
|
|
29
|
+
str,
|
|
30
|
+
typer.Option(
|
|
31
|
+
"--type", help="message, event, driveItem, site, list, chatMessage, or person."
|
|
32
|
+
),
|
|
33
|
+
] = "message",
|
|
34
|
+
after: Annotated[
|
|
35
|
+
str | None, typer.Option("--after", metavar="DT", help="Only hits after this time.")
|
|
36
|
+
] = None,
|
|
37
|
+
before: Annotated[
|
|
38
|
+
str | None, typer.Option("--before", metavar="DT", help="Only hits before this time.")
|
|
39
|
+
] = None,
|
|
40
|
+
limit: LimitOpt = None,
|
|
41
|
+
all_: AllFlag = False,
|
|
42
|
+
fields: Annotated[
|
|
43
|
+
str | None, typer.Option("--fields", help="Comma-separated Graph fields to fetch.")
|
|
44
|
+
] = None,
|
|
45
|
+
json_: JsonFlag = False,
|
|
46
|
+
):
|
|
47
|
+
"""Search mail, calendar, files, sites, chats or people with the Microsoft Search API."""
|
|
48
|
+
entity_type = search_ops.normalise_entity_type(type_)
|
|
49
|
+
gate(search_ops.SCOPES_BY_TYPE[entity_type])
|
|
50
|
+
limit, all_ = page_bounds(limit, all_, default=search_ops.DEFAULT_LIMIT)
|
|
51
|
+
tz = client.tz
|
|
52
|
+
after_dt = parse_dt(after, tz) if after else None
|
|
53
|
+
before_dt = parse_dt(before, tz, end_of_day=True) if before else None
|
|
54
|
+
found = search_ops.search(
|
|
55
|
+
client,
|
|
56
|
+
entity_type=entity_type,
|
|
57
|
+
q=q,
|
|
58
|
+
after=after_dt,
|
|
59
|
+
before=before_dt,
|
|
60
|
+
limit=limit,
|
|
61
|
+
all_=all_,
|
|
62
|
+
fields=_split_fields(fields),
|
|
63
|
+
tz=tz,
|
|
64
|
+
)
|
|
65
|
+
return ListResult(
|
|
66
|
+
items=[search_ops.shape_hit(entity_type, hit) for hit in found.hits],
|
|
67
|
+
truncated=found.more,
|
|
68
|
+
fetched=found.fetched_count,
|
|
69
|
+
cap=found.cap,
|
|
70
|
+
query=found.query,
|
|
71
|
+
window=Window(after=after_dt, before=before_dt),
|
|
72
|
+
hit_cap=search_ops.CAP_SEARCH if all_ else None,
|
|
73
|
+
empty_text="No results.",
|
|
74
|
+
columns=search_ops.columns_for(entity_type, tz),
|
|
75
|
+
)
|