Skip to main content

graphplug

Plug-and-play Microsoft Graph for Python. Sending a mail is one call. Booking a Teams meeting is one call. Authentication, retries, throttling, paging, batching and large file transfers happen underneath.

import asyncio
from graphplug import GraphClient, Scopes

async def main():
    graph = await GraphClient.device_code(TENANT, CLIENT, Scopes.MAIL_SEND)

    async with graph:
        await graph.mail.send(
            to="alice@contoso.com",
            subject="Quarterly report",
            body="<p>Attached.</p>", html=True,
            attachments=["report.pdf"],
        )

asyncio.run(main())

Start here: USAGE.md is the full walkthrough. When something fails, see docs/troubleshooting.md. For working code, see samples/. ARCHITECTURE.md explains why the rules are what they are.


Install

pip install graphplug

Two dependencies, both Microsoft's own — azure-identity for credentials, msgraph-core for the supported middleware pipeline. Pure Python, so it installs anywhere.

msgraph-sdk is deliberately not used: its dependency tree does not resolve in practice, hanging pip and uv indefinitely. msgraph-core resolves in a few seconds and carries the parts that matter.


What you get

A resource layer, over five areas. Graph's sendMail payload is roughly twenty lines of nested JSON — recipients as objects inside objects, a body with a content type, attachments base64-encoded with an @odata.type discriminator. A Teams meeting needs isOnlineMeeting and onlineMeetingProvider. A drive item is /me/drive/root:/reports/q3.xlsx: — with a closing colon everybody forgets. A user search returns a bare 400 without a ConsistencyLevel header. All of that is built for you.

await graph.mail.send(to=..., subject=..., body=..., attachments=[...])
async for message in graph.mail.inbox(unread_only=True): ...

event = await graph.calendar.schedule(subject=..., start=..., end=..., online=True)
print(event["onlineMeeting"]["joinUrl"])

await graph.files.upload("q3.xlsx", to="/reports/2026/q3.xlsx")
url = await graph.files.share_link("/reports/2026/q3.xlsx", kind="edit")

channel = await graph.teams.channel_by_name(team_id, "deploys")
await graph.teams.post(team_id, channel["id"], f"Report is up: {url}")

async for person in graph.users.find("smith"): ...
boss = await graph.users.manager()

Everything generic, too. get, post, patch, delete, paged, batch, download, upload — every v1.0 and beta endpoint reachable without waiting for a typed wrapper.

Speed that does not need orchestrating. Batching is the lever, not asyncio:

500 user lookups
One at a time 500 round-trips
graph.batch(...) 25 round-trips
…dispatched concurrently ~5 round-trip times

batch, get_many and send_many chunk at Graph's limit of 20 and dispatch under a bounded semaphore. You never write asyncio.gather, and you do not get throttled for going too wide.

Adding a resource is one subclass. list, get, create, update, delete and get_many come from a shared base; a new resource sets a path and adds whatever is specific to it. The five that ship are each about 150 lines and are worth reading as worked examples.


The two access models

Choosing wrong is how a script ends up with far more reach than intended.

Application-level Delegated
Acting as The application itself A signed-in person
Reach The whole tenant Only what that person can already do
graph.mail / graph.calendar No — there is no user Yes
Human needed No Yes, at first sign-in
Constructor app_only, from_env device_code, interactive

Mail.Read as an application permission reads every mailbox in the tenant. The same name as a delegated permission reads only the signed-in person's mail.

Supported sign-ins: client secret, device code, and authorization code with PKCE. Certificate, managed identity and on-behalf-of go through GraphClient.from_credential(...), which takes any azure-identity credential — sync or async — and is the reason those flows need no support here.


Security properties

Deliberate, and tested rather than documented and hoped for.

  • Delegated scopes are never defaulted. .default on a delegated flow silently requests every scope ever consented for that client. Omitting scopes is an error naming the field.
  • A caller-supplied Authorization header is rejected before the request leaves.
  • Response headers pass an allowlist, never a denylist. A denylist fails open on whatever header Microsoft adds tomorrow.
  • The PKCE verifier never leaves the process, and state is validated internally so the CSRF check cannot be skipped.
  • The bearer token is withheld from any host but graph.microsoft.com. Pre-authenticated download URLs still work; they simply travel unauthenticated.
  • Nothing is written to disk. The token cache is in memory for the life of the client.

Errors

One exception type carrying data, rather than a hierarchy.

except GraphError as e:
    e.status        # HTTP status, or 0 when there was no response at all
    e.code          # "itemNotFound", or a core code such as "consentRequired"
    e.message
    e.request_id    # quote this to Microsoft support
    e.retry_after
    e.inner         # Graph's own inner error, verbatim

Throttling is handled for you — the pipeline honours Retry-After. Catching activityLimitReached means the retry budget ran out, and retry_after tells you how long to wait. Every code and its fix is in docs/troubleshooting.md.


Logging

Off unless asked. GRAPHPLUG_LOG_LEVEL=info or =error; one JSON object per line on stderr.

{"level":"info","event":"request","method":"GET","url":"https://graph.microsoft.com/v1.0/users","status":200,"ms":214,"requestId":"a1b2c3d4","errorCode":null}

URLs are logged without their query string, because an OData $filter routinely carries email addresses. Headers, bodies and credential material are never logged.


Development

pip install -e .
python -m unittest discover -s tests     # 188 tests
python -m build --wheel

No container, no compiler, no platform-specific build.


Status

The package is complete and tested. 188 tests at 94% line coverage, covering the middleware contract, request construction, paging, batching, file round-trips, the exact paths and payloads all five resources build, the error taxonomy, concurrency bounds, the sign-in orchestration, the loopback redirect listener, the drive addressing rules and the logger.

What still needs a tenant

Everything above is verified without one. These cannot be:

  • Whether each sign-in flow completes against real Entra.
  • Whether the permissions each resource declares are sufficient in practice.

Set AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET and the live checks become available.

History

An earlier version of this package was a C# core compiled to a native library and reached through a C ABI. It has been removed. Most of the rules here — the header allowlist, the error taxonomy, the upload thresholds, the batch chunking, the two access models — were worked out there and survived the rewrite unchanged, which is decent evidence they were about Graph rather than about C#. ARCHITECTURE.md documents the current design and records the reasoning.


Licence

MIT. azure-identity and msgraph-core are MIT too, so nothing here carries an obligation you did not choose.

Release files for graphplug 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for graphplug 0.2.0
File Size Uploaded
graphplug-0.2.0.tar.gz 61.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for graphplug 0.2.0
File Interpreter ABI Platform
graphplug-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 106.2 kB

Release files / graphplug-0.2.0.tar.gz

Download URL graphplug-0.2.0.tar.gz
Size 61.4 kB
Tags Source
SHA-256 checksum
How to use checksums
c938790584c2c61a484a4724446d21afd3ab7bb6fde271982f12fe74e382062e
BLAKE2b-256 checksum
How to use checksums
297361c6bbb2ad7a7404a4051e82899aed35be012961f79531f00f51e1575d23
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release files / graphplug-0.2.0-py3-none-any.whl

Download URL graphplug-0.2.0-py3-none-any.whl
Size 44.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c3d2b13e2765d26a712d1a96de9c18e7c95b0497ede7c2febdcbfee185d35cb5
BLAKE2b-256 checksum
How to use checksums
1d23aef536710bc8fbef0d9cdb7cfad9573b545c0ae175571d6086f4ddae7900
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release history Release notifications | RSS feed

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page