msgraphkit 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.
- graphkit/__init__.py +17 -0
- graphkit/auth.py +89 -0
- graphkit/client.py +88 -0
- msgraphkit-0.1.0.dist-info/METADATA +161 -0
- msgraphkit-0.1.0.dist-info/RECORD +8 -0
- msgraphkit-0.1.0.dist-info/WHEEL +5 -0
- msgraphkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- msgraphkit-0.1.0.dist-info/top_level.txt +1 -0
graphkit/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
graphkit -- a small, dependency-light toolkit for authenticating to the
|
|
3
|
+
Microsoft Graph API (app-only / client credentials flow) and making
|
|
4
|
+
simple REST calls against it.
|
|
5
|
+
|
|
6
|
+
from graphkit import get_token, GraphClient
|
|
7
|
+
|
|
8
|
+
token = get_token()
|
|
9
|
+
graph = GraphClient(token)
|
|
10
|
+
me = graph.get("/users/someone@example.com")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .auth import get_token, GraphAuthError
|
|
14
|
+
from .client import GraphClient
|
|
15
|
+
|
|
16
|
+
__all__ = ["get_token", "GraphAuthError", "GraphClient"]
|
|
17
|
+
__version__ = "0.1.0"
|
graphkit/auth.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
graphkit.auth
|
|
3
|
+
=============
|
|
4
|
+
Authenticates to the Microsoft Graph API using the OAuth2 "client
|
|
5
|
+
credentials" flow -- also called app-only auth. This is the flow you
|
|
6
|
+
want when a script needs to act on a mailbox, Planner plan, SharePoint
|
|
7
|
+
site, etc. *without* a human signing in interactively (e.g. a scheduled
|
|
8
|
+
job, a background automation, a shared-mailbox integration).
|
|
9
|
+
|
|
10
|
+
No secrets live in this file or anywhere else in this package. All
|
|
11
|
+
three required values are read from environment variables at runtime:
|
|
12
|
+
|
|
13
|
+
GRAPH_TENANT_ID
|
|
14
|
+
GRAPH_CLIENT_ID
|
|
15
|
+
GRAPH_CLIENT_SECRET
|
|
16
|
+
|
|
17
|
+
See the README for how to create these by registering an app in
|
|
18
|
+
Azure AD (Entra ID), and .env.example for a template you can copy to
|
|
19
|
+
a real .env file (which you should never commit to version control).
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
from graphkit import get_token
|
|
24
|
+
|
|
25
|
+
token = get_token()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
import msal
|
|
32
|
+
|
|
33
|
+
GRAPH_SCOPE = ["https://graph.microsoft.com/.default"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GraphAuthError(RuntimeError):
|
|
37
|
+
"""Raised when a Graph API access token could not be acquired."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_env(name: str) -> str:
|
|
41
|
+
value = os.environ.get(name)
|
|
42
|
+
if not value:
|
|
43
|
+
raise GraphAuthError(
|
|
44
|
+
f"Missing required environment variable: {name}\n"
|
|
45
|
+
"Set it in your shell, or add it to a .env file in your "
|
|
46
|
+
"project folder (see .env.example) and load it with "
|
|
47
|
+
"python-dotenv before calling get_token()."
|
|
48
|
+
)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_token(
|
|
53
|
+
tenant_id: Optional[str] = None,
|
|
54
|
+
client_id: Optional[str] = None,
|
|
55
|
+
client_secret: Optional[str] = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Acquire an app-only Microsoft Graph access token.
|
|
59
|
+
|
|
60
|
+
If tenant_id / client_id / client_secret are not passed in
|
|
61
|
+
directly, they are read from the GRAPH_TENANT_ID, GRAPH_CLIENT_ID,
|
|
62
|
+
and GRAPH_CLIENT_SECRET environment variables.
|
|
63
|
+
|
|
64
|
+
MSAL keeps its own in-memory token cache for the lifetime of the
|
|
65
|
+
process and will reuse a still-valid token instead of calling
|
|
66
|
+
Azure again, so it's fine to call this once per script, or before
|
|
67
|
+
every API call, without worrying about hammering the token
|
|
68
|
+
endpoint.
|
|
69
|
+
|
|
70
|
+
Raises GraphAuthError if credentials are missing or invalid.
|
|
71
|
+
"""
|
|
72
|
+
tenant_id = tenant_id or _require_env("GRAPH_TENANT_ID")
|
|
73
|
+
client_id = client_id or _require_env("GRAPH_CLIENT_ID")
|
|
74
|
+
client_secret = client_secret or _require_env("GRAPH_CLIENT_SECRET")
|
|
75
|
+
|
|
76
|
+
app = msal.ConfidentialClientApplication(
|
|
77
|
+
client_id,
|
|
78
|
+
authority=f"https://login.microsoftonline.com/{tenant_id}",
|
|
79
|
+
client_credential=client_secret,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
result = app.acquire_token_for_client(scopes=GRAPH_SCOPE)
|
|
83
|
+
|
|
84
|
+
if "access_token" not in result:
|
|
85
|
+
raise GraphAuthError(
|
|
86
|
+
result.get("error_description", str(result))
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return result["access_token"]
|
graphkit/client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
graphkit.client
|
|
3
|
+
================
|
|
4
|
+
A deliberately thin wrapper around the Microsoft Graph REST API
|
|
5
|
+
(v1.0). It knows nothing about Outlook, Planner, SharePoint, or any
|
|
6
|
+
other specific Graph resource -- it just handles the repetitive parts
|
|
7
|
+
(base URL, auth header, JSON) so you can call any Graph endpoint with
|
|
8
|
+
one line and build your own helper functions on top.
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
-----
|
|
12
|
+
from graphkit import get_token, GraphClient
|
|
13
|
+
|
|
14
|
+
token = get_token()
|
|
15
|
+
graph = GraphClient(token)
|
|
16
|
+
|
|
17
|
+
me = graph.get("/users/someone@example.com")
|
|
18
|
+
|
|
19
|
+
graph.post("/users/someone@example.com/messages", {
|
|
20
|
+
"subject": "Hello",
|
|
21
|
+
"body": {"contentType": "Text", "content": "Hi there"},
|
|
22
|
+
"toRecipients": [{"emailAddress": {"address": "other@example.com"}}],
|
|
23
|
+
})
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GraphClient:
|
|
34
|
+
"""
|
|
35
|
+
Thin HTTP client for the Microsoft Graph API.
|
|
36
|
+
|
|
37
|
+
Pass in a bearer token from graphkit.auth.get_token(). One
|
|
38
|
+
GraphClient per token -- if a token expires mid-run, create a new
|
|
39
|
+
client with a freshly acquired token.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, token: str, base_url: str = GRAPH_BASE_URL):
|
|
43
|
+
self.token = token
|
|
44
|
+
self.base_url = base_url
|
|
45
|
+
|
|
46
|
+
def _headers(self, extra: Optional[dict] = None) -> dict:
|
|
47
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
48
|
+
if extra:
|
|
49
|
+
headers.update(extra)
|
|
50
|
+
return headers
|
|
51
|
+
|
|
52
|
+
def get(self, path: str) -> dict:
|
|
53
|
+
"""GET a Graph path (e.g. '/me' or '/users/x@y.com/messages') and
|
|
54
|
+
return the parsed JSON body. Raises on non-2xx responses."""
|
|
55
|
+
r = requests.get(f"{self.base_url}{path}", headers=self._headers())
|
|
56
|
+
r.raise_for_status()
|
|
57
|
+
return r.json()
|
|
58
|
+
|
|
59
|
+
def post(self, path: str, body: dict) -> requests.Response:
|
|
60
|
+
"""POST a JSON body to a Graph path. Returns the raw response --
|
|
61
|
+
check response.status_code (Graph typically returns 201 for a
|
|
62
|
+
successful create) rather than assuming success."""
|
|
63
|
+
return requests.post(
|
|
64
|
+
f"{self.base_url}{path}",
|
|
65
|
+
headers=self._headers({"Content-Type": "application/json"}),
|
|
66
|
+
json=body,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def patch(self, path: str, body: dict) -> requests.Response:
|
|
70
|
+
"""PATCH (partial update) a JSON body to a Graph path."""
|
|
71
|
+
return requests.patch(
|
|
72
|
+
f"{self.base_url}{path}",
|
|
73
|
+
headers=self._headers({"Content-Type": "application/json"}),
|
|
74
|
+
json=body,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def delete(self, path: str) -> requests.Response:
|
|
78
|
+
"""DELETE a Graph resource."""
|
|
79
|
+
return requests.delete(f"{self.base_url}{path}", headers=self._headers())
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def error_message(response: requests.Response) -> str:
|
|
83
|
+
"""Best-effort extraction of a human-readable error message from
|
|
84
|
+
a failed Graph response, for logging/printing."""
|
|
85
|
+
try:
|
|
86
|
+
return response.json().get("error", {}).get("message", response.text[:300])
|
|
87
|
+
except ValueError:
|
|
88
|
+
return response.text[:300]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msgraphkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small, dependency-light toolkit for authenticating to the Microsoft Graph API (app-only / client credentials flow) and making simple REST calls against it.
|
|
5
|
+
Author: Duckboard
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Duckboard/graphkit
|
|
8
|
+
Project-URL: Buy me a coffee, https://ko-fi.com/duckboard
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: msal>=1.24
|
|
19
|
+
Requires-Dist: requests>=2.31
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# graphkit
|
|
23
|
+
|
|
24
|
+
A small, dependency-light Python toolkit for authenticating to the
|
|
25
|
+
Microsoft Graph API using the **client credentials flow** (app-only
|
|
26
|
+
auth -- no user sign-in required), plus a thin HTTP client for making
|
|
27
|
+
calls against it.
|
|
28
|
+
|
|
29
|
+
This is useful for background scripts, scheduled jobs, or shared-mailbox
|
|
30
|
+
automations that need to act on Outlook mail, Planner tasks, SharePoint,
|
|
31
|
+
or any other Graph resource without a human logging in each time.
|
|
32
|
+
|
|
33
|
+
If this saves you time, consider [buying me a coffee](https://ko-fi.com/duckboard).
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- OAuth2 client credentials flow via [MSAL](https://github.com/AzureAD/microsoft-authentication-library-for-python)
|
|
38
|
+
- No secrets in code -- credentials are read from environment variables
|
|
39
|
+
- A minimal `GraphClient` for GET/POST/PATCH/DELETE against any Graph endpoint
|
|
40
|
+
- Two worked examples: drafting an email, creating a Planner task
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -r requirements.txt
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
or, to install as an editable package:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Azure app registration
|
|
55
|
+
|
|
56
|
+
You need an Azure AD (Entra ID) app registration before this will work.
|
|
57
|
+
If you don't already have one:
|
|
58
|
+
|
|
59
|
+
1. Go to [portal.azure.com](https://portal.azure.com) -> **Azure Active
|
|
60
|
+
Directory** (or **Entra ID**) -> **App registrations** -> **New
|
|
61
|
+
registration**.
|
|
62
|
+
2. Give it a name, leave the default options, and click **Register**.
|
|
63
|
+
3. Note down the **Application (client) ID** and **Directory (tenant)
|
|
64
|
+
ID** from the app's Overview page.
|
|
65
|
+
4. Go to **Certificates & secrets** -> **New client secret**. Copy the
|
|
66
|
+
secret **value** immediately -- it's only shown once.
|
|
67
|
+
5. Go to **API permissions** -> **Add a permission** -> **Microsoft
|
|
68
|
+
Graph** -> **Application permissions**, and add whichever
|
|
69
|
+
permissions your use case needs, for example:
|
|
70
|
+
- `Mail.ReadWrite` (draft/read/send mail as any mailbox)
|
|
71
|
+
- `Tasks.ReadWrite` (Planner tasks)
|
|
72
|
+
- `Group.Read.All` (look up Microsoft 365 Groups by name)
|
|
73
|
+
- `User.Read.All` (look up users by name/UPN)
|
|
74
|
+
6. Click **Grant admin consent** for your organisation (this step
|
|
75
|
+
requires a Global Administrator or Application Administrator role).
|
|
76
|
+
|
|
77
|
+
Application permissions act on the whole tenant, scoped only by
|
|
78
|
+
whichever specific mailbox/site/group you address in your API calls --
|
|
79
|
+
there's no per-user consent screen for this flow. Only grant the
|
|
80
|
+
permissions you actually need.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Copy `.env.example` to `.env` and fill in your values:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cp .env.example .env
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
GRAPH_TENANT_ID=your-tenant-id
|
|
92
|
+
GRAPH_CLIENT_ID=your-client-id
|
|
93
|
+
GRAPH_CLIENT_SECRET=your-client-secret
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`.env` is already in `.gitignore` -- never commit real credentials.
|
|
97
|
+
|
|
98
|
+
To load `.env` automatically in your own scripts, install
|
|
99
|
+
`python-dotenv` and add this before importing graphkit:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from dotenv import load_dotenv
|
|
103
|
+
load_dotenv()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Alternatively, just set the three variables directly in your shell or
|
|
107
|
+
process environment -- graphkit doesn't require python-dotenv, it only
|
|
108
|
+
reads `os.environ`.
|
|
109
|
+
|
|
110
|
+
## Usage
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from graphkit import get_token, GraphClient
|
|
114
|
+
|
|
115
|
+
token = get_token() # reads GRAPH_TENANT_ID / GRAPH_CLIENT_ID / GRAPH_CLIENT_SECRET
|
|
116
|
+
graph = GraphClient(token)
|
|
117
|
+
|
|
118
|
+
me = graph.get("/users/someone@yourdomain.com")
|
|
119
|
+
print(me["displayName"])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`GraphClient` is intentionally minimal -- `.get()`, `.post()`,
|
|
123
|
+
`.patch()`, `.delete()` -- so it works with any Graph endpoint, not
|
|
124
|
+
just the ones this package happens to have examples for.
|
|
125
|
+
|
|
126
|
+
### Examples
|
|
127
|
+
|
|
128
|
+
- `examples/send_email_draft.py` -- create a draft email in a mailbox's
|
|
129
|
+
Drafts folder
|
|
130
|
+
- `examples/create_planner_task.py` -- look up a Microsoft 365 Group,
|
|
131
|
+
Planner plan, and bucket by name, then create a task in it
|
|
132
|
+
|
|
133
|
+
Run either directly once you've set up your `.env`:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python examples/send_email_draft.py
|
|
137
|
+
python examples/create_planner_task.py
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install pytest
|
|
144
|
+
pytest
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Tests mock MSAL, so they run offline without real Azure credentials.
|
|
148
|
+
|
|
149
|
+
## Why client credentials and not device code / interactive login?
|
|
150
|
+
|
|
151
|
+
Client credentials (app-only) auth is the right choice when a script
|
|
152
|
+
runs unattended -- no browser, no one available to click "allow" on a
|
|
153
|
+
sign-in prompt. If you need a script to act *as a specific signed-in
|
|
154
|
+
user* instead of a shared app identity, you'd want MSAL's device code
|
|
155
|
+
or interactive flows instead, which this package doesn't cover.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT -- see [LICENSE](LICENSE). Do whatever you like with this; a
|
|
160
|
+
credit or a [Ko-fi tip](https://ko-fi.com/duckboard) is
|
|
161
|
+
appreciated but never required.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
graphkit/__init__.py,sha256=tQs-JF0ey5Hm5umro1EoDRLEE8xupr8l85xDK1zD-wE,489
|
|
2
|
+
graphkit/auth.py,sha256=SFEBs0mLMwLHvslzbREywntRWDMs-i0fGDdwAsyYcAU,2827
|
|
3
|
+
graphkit/client.py,sha256=B5Q7tcUlUxdROb96nGZk721LN2iDUut-3AY5kKaeVMg,3068
|
|
4
|
+
msgraphkit-0.1.0.dist-info/licenses/LICENSE,sha256=ChymOtwCDSjsL0VwJuqUn7dw8gqHr0Rt4jHcFsJa8Ec,1066
|
|
5
|
+
msgraphkit-0.1.0.dist-info/METADATA,sha256=OGDxzIqD_PSNxr_3vMi4m21Iqx99gUPgUow3ji2Ek_w,5475
|
|
6
|
+
msgraphkit-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
msgraphkit-0.1.0.dist-info/top_level.txt,sha256=xSiy0YBLQPHfTXAI0ihN8QO69oBv2zR-zqkQf9z2kvY,9
|
|
8
|
+
msgraphkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duckboard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
graphkit
|