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,71 @@
|
|
|
1
|
+
"""The noun registry. Phase 1 tasks add `commands/<noun>.py`; they never edit this file.
|
|
2
|
+
|
|
3
|
+
A module that does not exist yet is skipped, so the CLI is usable at every point of the build.
|
|
4
|
+
A module that does exist must expose `app: typer.Typer` (kind "group") or `command: Callable`
|
|
5
|
+
(kind "command").
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("mgraphctl.commands")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Noun:
|
|
20
|
+
name: str
|
|
21
|
+
module: str
|
|
22
|
+
help: str
|
|
23
|
+
kind: Literal["group", "command"] = "group"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
NOUNS: list[Noun] = [
|
|
27
|
+
Noun("mail", "mgraphctl.commands.mail", "Outlook mail."),
|
|
28
|
+
Noun(
|
|
29
|
+
"mailbox",
|
|
30
|
+
"mgraphctl.commands.mailbox",
|
|
31
|
+
"Mailbox settings, automatic replies, focused inbox.",
|
|
32
|
+
),
|
|
33
|
+
Noun(
|
|
34
|
+
"calendar", "mgraphctl.commands.calendar", "Calendar events, availability, meeting times."
|
|
35
|
+
),
|
|
36
|
+
Noun("people", "mgraphctl.commands.people", "People, contacts and directory users."),
|
|
37
|
+
Noun("org", "mgraphctl.commands.org", "Manager, direct reports, management chain."),
|
|
38
|
+
Noun("groups", "mgraphctl.commands.groups", "Microsoft 365 groups."),
|
|
39
|
+
Noun("teams", "mgraphctl.commands.teams", "Teams and channels."),
|
|
40
|
+
Noun("chats", "mgraphctl.commands.chats", "Teams chats and direct messages."),
|
|
41
|
+
Noun("presence", "mgraphctl.commands.presence", "Teams presence."),
|
|
42
|
+
Noun("meetings", "mgraphctl.commands.meetings", "Online meetings, transcripts, AI insights."),
|
|
43
|
+
Noun("onedrive", "mgraphctl.commands.onedrive", "OneDrive files."),
|
|
44
|
+
Noun("sharepoint", "mgraphctl.commands.sharepoint", "SharePoint sites, drives and lists."),
|
|
45
|
+
Noun("onenote", "mgraphctl.commands.onenote", "OneNote notebooks and pages."),
|
|
46
|
+
Noun("planner", "mgraphctl.commands.planner", "Planner plans and tasks."),
|
|
47
|
+
Noun("todo", "mgraphctl.commands.todo", "Microsoft To Do."),
|
|
48
|
+
Noun(
|
|
49
|
+
"search",
|
|
50
|
+
"mgraphctl.commands.search",
|
|
51
|
+
"Unified search across Microsoft 365.",
|
|
52
|
+
kind="command",
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_all(root: typer.Typer) -> None:
|
|
58
|
+
"""Attach every noun that has a module. Missing modules are a debug line, not an error."""
|
|
59
|
+
for noun in NOUNS:
|
|
60
|
+
try:
|
|
61
|
+
module = importlib.import_module(noun.module)
|
|
62
|
+
except ModuleNotFoundError as exc:
|
|
63
|
+
# Only the noun's own module may be missing; a broken import inside it must surface.
|
|
64
|
+
if exc.name != noun.module:
|
|
65
|
+
raise
|
|
66
|
+
log.debug("noun %s not implemented yet", noun.name)
|
|
67
|
+
continue
|
|
68
|
+
if noun.kind == "command":
|
|
69
|
+
root.command(noun.name, help=noun.help)(module.command)
|
|
70
|
+
else:
|
|
71
|
+
root.add_typer(module.app, name=noun.name, help=noun.help)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The raw `api` escape hatch (spec §8.17). No scopes are declared; Graph decides."""
|
|
2
|
+
|
|
3
|
+
import json as jsonlib
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from mgraphctl import odata
|
|
11
|
+
from mgraphctl.cli import AllFlag, DryRunFlag, JsonFlag, graph_command
|
|
12
|
+
from mgraphctl.errors import UsageError
|
|
13
|
+
from mgraphctl.http import GraphClient, PlannedRequest
|
|
14
|
+
from mgraphctl.render import DryRunResult, FileResult, TextResult, WriteResult, fmt_size
|
|
15
|
+
|
|
16
|
+
JSON_CONTENT = "application/json"
|
|
17
|
+
PAGE_CAP = sys.maxsize
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _params(query: list[str]) -> dict[str, str]:
|
|
21
|
+
"""`--query k=v` pairs, split on the first `=`."""
|
|
22
|
+
out: dict[str, str] = dict()
|
|
23
|
+
for item in query:
|
|
24
|
+
key, sep, value = item.partition("=")
|
|
25
|
+
if not sep:
|
|
26
|
+
raise UsageError("USAGE", f"--query needs k=v, got {item!r}")
|
|
27
|
+
out[key] = value
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _headers(header: list[str]) -> dict[str, str]:
|
|
32
|
+
"""`--header k:v` pairs, split on the first `:`."""
|
|
33
|
+
out: dict[str, str] = dict()
|
|
34
|
+
for item in header:
|
|
35
|
+
key, sep, value = item.partition(":")
|
|
36
|
+
if not sep:
|
|
37
|
+
raise UsageError("USAGE", f"--header needs k:v, got {item!r}")
|
|
38
|
+
out[key.strip()] = value.strip()
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _body(body: str | None) -> Any:
|
|
43
|
+
"""JSON text, or `@FILE` naming a file that holds it."""
|
|
44
|
+
if body is None:
|
|
45
|
+
return None
|
|
46
|
+
text = Path(body[1:]).read_text() if body.startswith("@") else body
|
|
47
|
+
try:
|
|
48
|
+
return jsonlib.loads(text)
|
|
49
|
+
except jsonlib.JSONDecodeError as exc:
|
|
50
|
+
raise UsageError("USAGE", f"--body is not valid JSON: {exc}") from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_bytes(data: bytes, output: Path | None) -> FileResult | None:
|
|
54
|
+
if output is None:
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
sys.stdout.buffer.write(data)
|
|
57
|
+
sys.stdout.buffer.flush()
|
|
58
|
+
return None
|
|
59
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
output.write_bytes(data)
|
|
61
|
+
return FileResult(
|
|
62
|
+
path=output,
|
|
63
|
+
bytes=len(data),
|
|
64
|
+
meta=dict(),
|
|
65
|
+
message=f"Wrote {fmt_size(len(data))} to {output}",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _document(obj: Any) -> TextResult:
|
|
70
|
+
"""A JSON body, pretty-printed in text mode and passed through unchanged in JSON mode."""
|
|
71
|
+
return TextResult(text=jsonlib.dumps(obj, indent=2, ensure_ascii=False), json_obj=obj)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@graph_command(scopes=[])
|
|
75
|
+
def api(
|
|
76
|
+
client: GraphClient,
|
|
77
|
+
method: Annotated[str, typer.Argument(metavar="METHOD", help="GET, POST, PATCH, PUT, DELETE.")],
|
|
78
|
+
path: Annotated[
|
|
79
|
+
str, typer.Argument(metavar="PATH", help="Graph path such as /me, or an absolute URL.")
|
|
80
|
+
],
|
|
81
|
+
query: Annotated[
|
|
82
|
+
list[str] | None, typer.Option("--query", help="k=v query parameter (repeatable).")
|
|
83
|
+
] = None,
|
|
84
|
+
body: Annotated[
|
|
85
|
+
str | None, typer.Option("--body", help="JSON request body, or @FILE holding it.")
|
|
86
|
+
] = None,
|
|
87
|
+
header: Annotated[
|
|
88
|
+
list[str] | None, typer.Option("--header", help="k:v request header (repeatable).")
|
|
89
|
+
] = None,
|
|
90
|
+
beta: Annotated[
|
|
91
|
+
bool, typer.Option("--beta", help="Send this call to the /beta endpoint.")
|
|
92
|
+
] = False,
|
|
93
|
+
all_: AllFlag = False,
|
|
94
|
+
raw: Annotated[
|
|
95
|
+
bool, typer.Option("--raw", help="Treat the response as bytes, not JSON.")
|
|
96
|
+
] = False,
|
|
97
|
+
output: Annotated[
|
|
98
|
+
Path | None, typer.Option("--output", help="Write the --raw bytes to this file.")
|
|
99
|
+
] = None,
|
|
100
|
+
outlook_tz: Annotated[
|
|
101
|
+
bool,
|
|
102
|
+
typer.Option(
|
|
103
|
+
"--outlook-tz",
|
|
104
|
+
hidden=True,
|
|
105
|
+
help="Send Prefer: outlook.timezone for mail/calendar paths.",
|
|
106
|
+
),
|
|
107
|
+
] = False,
|
|
108
|
+
dry_run: DryRunFlag = False,
|
|
109
|
+
json_: JsonFlag = False,
|
|
110
|
+
):
|
|
111
|
+
"""Send one request to Microsoft Graph and print what comes back."""
|
|
112
|
+
params = _params(query or [])
|
|
113
|
+
headers = _headers(header or [])
|
|
114
|
+
payload = _body(body)
|
|
115
|
+
verb = method.upper()
|
|
116
|
+
use_beta = True if beta else None
|
|
117
|
+
if all_ and (raw or output is not None):
|
|
118
|
+
raise UsageError("USAGE", "--all cannot be combined with --raw or --output")
|
|
119
|
+
if output is not None and not raw:
|
|
120
|
+
raise UsageError("USAGE", "--output writes the bytes of a --raw response; add --raw")
|
|
121
|
+
if dry_run:
|
|
122
|
+
planned = dict(headers)
|
|
123
|
+
if payload is not None:
|
|
124
|
+
planned.setdefault("Content-Type", JSON_CONTENT)
|
|
125
|
+
url = client.url(odata.with_query(path, params), beta=use_beta)
|
|
126
|
+
return DryRunResult([PlannedRequest(verb, url, planned, payload)])
|
|
127
|
+
if all_:
|
|
128
|
+
page = client.paginate(
|
|
129
|
+
path,
|
|
130
|
+
params=params,
|
|
131
|
+
headers=headers or None,
|
|
132
|
+
beta=use_beta,
|
|
133
|
+
outlook_tz=outlook_tz,
|
|
134
|
+
limit=None,
|
|
135
|
+
all_=True,
|
|
136
|
+
cap=PAGE_CAP,
|
|
137
|
+
page_size=None,
|
|
138
|
+
)
|
|
139
|
+
return _document(dict(value=page.items))
|
|
140
|
+
call = dict(
|
|
141
|
+
params=params,
|
|
142
|
+
json=payload,
|
|
143
|
+
headers=headers or None,
|
|
144
|
+
beta=use_beta,
|
|
145
|
+
outlook_tz=outlook_tz,
|
|
146
|
+
)
|
|
147
|
+
if raw:
|
|
148
|
+
return _write_bytes(client.request(verb, path, expect="bytes", **call), output)
|
|
149
|
+
response = client.request(verb, path, expect="response", **call)
|
|
150
|
+
content_type = response.headers.get("content-type", "")
|
|
151
|
+
data = response.read()
|
|
152
|
+
response.close()
|
|
153
|
+
if not data:
|
|
154
|
+
return WriteResult(obj=None, message="OK")
|
|
155
|
+
if "json" in content_type:
|
|
156
|
+
return _document(jsonlib.loads(data))
|
|
157
|
+
text_body = data.decode("utf-8", errors="replace")
|
|
158
|
+
# A non-JSON body is still the answer: --json emits it as a JSON string, never null.
|
|
159
|
+
return TextResult(text=text_body, json_obj=text_body)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def register(root: typer.Typer) -> None:
|
|
163
|
+
"""Attach `api` to the root app."""
|
|
164
|
+
root.command("api")(api)
|