Microsoft Graph in Practice #3: First Calls with the Python SDK — Reading Users and Org Data
With app registration and permissions ready from part 2, it’s time for code. This series uses Python as its reference language — the realistic standard for workplace automation, and a direct continuation for readers of the Python track (the SDKs for JavaScript/TypeScript, C#, Java, and Go share the same structure, so this part’s skeleton translates directly). The goal here is the calling skeleton we’ll reuse throughout parts 4–7.
Installation — two packages #
$ pip install azure-identity msgraph-sdkThe division of labor is clean: azure-identity handles authentication (token acquisition), msgraph-sdk handles the Graph calls. The auth flows from part 2 each map to one credential class in azure-identity — device code flow is DeviceCodeCredential, client credentials flow is ClientSecretCredential — a one-to-one correspondence between concept and code.
The application client — the skeleton for unattended automation #
First, the basic form for server automation: application (client credentials). The ingredients are part 2’s tenant ID, client ID, and secret, with the secret read from the environment.
import os
from azure.identity.aio import ClientSecretCredential
from msgraph import GraphServiceClient
def build_app_client() -> GraphServiceClient:
credential = ClientSecretCredential(
tenant_id=os.environ["AZURE_TENANT_ID"],
client_id=os.environ["AZURE_CLIENT_ID"],
client_secret=os.environ["AZURE_CLIENT_SECRET"],
)
# Application permissions use .default instead of individual scopes:
# "everything granted (consented) to this app registration."
scopes = ["https://graph.microsoft.com/.default"]
return GraphServiceClient(credential, scopes)Two things may look unfamiliar. First, the azure.identity.aio path: the Python Graph SDK is async-based, so we use the asynchronous credential variants (more below). Second, the single .default scope: rather than listing scopes as delegated flows do, it’s a fixed string meaning “all application permissions an admin has consented to for this app.” Part 2’s point — application permissions are decided at the app registration, not in code — shows up in code exactly like this.
The delegated client — the skeleton for CLI tools #
If a human runs the tool, it’s delegated. For terminal tools, the most practical sign-in is the device code flow: the code prints a URL and a one-time code, the user signs in via browser, and the token is issued.
import os
from azure.identity.aio import DeviceCodeCredential
from msgraph import GraphServiceClient
def build_user_client() -> GraphServiceClient:
credential = DeviceCodeCredential(
tenant_id=os.environ["AZURE_TENANT_ID"],
client_id=os.environ["AZURE_CLIENT_ID"],
# Note: no secret — the user proves identity by signing in
)
# Delegated flows list their scopes explicitly
scopes = ["User.Read", "Mail.Read"]
return GraphServiceClient(credential, scopes)Running it prints “open https://microsoft.com/devicelogin and enter code XXXXXXX”; once sign-in completes, the call proceeds. Delegated scopes are listed explicitly, and the effective rights are the intersection with what the signed-in user holds — part 2’s rules, unchanged.
First calls — getting used to the async pattern #
Skeleton in hand, we make the first call. Every call in the Python SDK is an await.
import asyncio
from graph_app import build_app_client
async def main():
client = build_app_client()
# List users in the org (application: requires User.Read.All)
result = await client.users.get()
for user in result.value:
print(user.display_name, "|", user.mail, "|", user.id)
asyncio.run(main())Commit the URL-to-SDK mapping rules to memory and you can write code without the docs: each URL segment becomes a property or method, and {id} slots become by_xxx_id().
# GET /users → client.users.get()
# GET /users/{id} → client.users.by_user_id(uid).get()
# GET /users/{id}/messages → client.users.by_user_id(uid).messages.get()
# GET /me → client.me.get() (delegated only)
# POST /users/{id}/sendMail → client.users.by_user_id(uid).send_mail.post(...)Note that me only means something in delegated contexts. An application token has no “me,” so application code always names its target via users.by_user_id(...). Hitting 403s or 400s over this difference is the standard beginner’s rite of passage.
OData in the SDK — query parameter objects #
Part 1’s OData grammar appears in the SDK as query parameter objects. The shape is verbose, but there’s only one pattern — build the template once and keep copying it.
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
async def engineering_users(client):
params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=["displayName", "mail", "department"],
filter="department eq 'Engineering'",
top=25,
orderby=["displayName"],
)
config = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(
query_parameters=params,
)
return await client.users.get(request_configuration=config)Part 1’s advice to make $select a habit applies unchanged. And when a filter/orderby combination returns 400 (unsupported query combination), reproduce the same query in Graph Explorer first rather than wrestling in code — finish there, then port. That workflow bears repeating.
If async is new — the three minimal rules #
For readers meeting async for the first time because of this SDK (the full story is in Modern Python Intermediate #7):
- Declare functions that call Graph with
async def, and putawaiton the calls. - Wrap the program’s entry point once with
asyncio.run(main()). - Forget an
awaitand you get “coroutine was never awaited” — and nothing happens. See that warning, hunt the missing await.
For batch scripts, these three suffice. Async’s real payoff (running calls concurrently) returns in part 7.
When stuck — open the token #
To part 2’s error patterns (401 = credentials, 403 = permissions), add one more tool. If you get 403 but the permission setup looks right, check whether the permission is actually in the token: get the token string via the credential’s get_token() and paste it into jwt.ms (Microsoft’s token decoder). Application tokens list permissions in the roles claim, delegated tokens in scp. If the expected permission is missing, the problem is the app registration (missing consent) or your scopes, not your code; if present, narrow toward the target resource. Just don’t carry tokens around for anything beyond debugging.
Summary #
- Two packages: azure-identity (auth) and msgraph-sdk (calls); part 2’s flows map 1:1 to credential classes (unattended = ClientSecretCredential, CLI = DeviceCodeCredential).
- Application uses the single
.defaultscope (real reach decided by consent at the registration); delegated lists scopes explicitly.meis delegated-only. - SDK paths mirror REST URLs (
{id}→by_xxx_id()); OData becomes query parameter objects; complex queries get reproduced in Explorer first. - Every call is an await. async def, await, asyncio.run — three rules cover batch scripts.
- The last resort for 403s is opening the token and comparing roles/scp against expectations.
The skeleton is done. From the next part we stack real scenarios on top — starting with the most in-demand scenario: mail and calendar automation.