Skip to main content

Microsoft Graph Helpers

A Python module for calling various Microsoft Graph endpoints. Not nearly as comprehensive as Microsoft's official modules, but far lighter weight and with a minimal amount of dependencies.

Not comprehensive, only contains calls that I've needed to user in my other applets.

Return conventions

Every function returns one of three things, and which one depends on what the function is for.

Fetching a value returns the value. get_bearer_token gives a token, get_group_guid a guid, get_upn_from_email a UPN, get_user_location a country code. False means it could not answer.

Fetching an object or a collection returns the parsed JSON. get_user, list_users, create_user, restore_user, list_deleted_users. False means the request failed.

Doing something returns True or False. update_user, delete_user, disable_user, revoke_ms_sessions, set_user_location. There is no useful answer beyond whether Graph accepted it.

Nothing raises on a failed Graph call. False comes back and the reason goes to the logging module, at warning for a rejected request and error for one that never completed. These are helpers for other applications to call, so a failure is a value to branch on, not an exception to catch. If a call returns False and you want to know why, read the log.

Testing the result

result = list_users(token, filter="accountEnabled eq false")
if not result:
    return                        # the request failed
for user in result["value"]:      # legitimately empty if nothing matched
    ...

A zero-result search is a success. {"value": []} is a non-empty dict, so if not result never mistakes "nothing matched" for "the request failed". result tells you whether Graph answered; result["value"] tells you what it found.

Never test with == True or is True. A function that returns a value returns a string, and "US" == True is False, so a check written that way treats every success as a failure, silently, with nothing in the log because nothing went wrong.

loc = get_user_location(token, upn)   # returns "US", None, or False
if loc == True:    # never fires. "US" is not True
if loc is not False:   # lets None through
if not loc:        # correct first cut, catches None and False

The one function with three outcomes

get_user_location is the only helper where False is not the only non-answer:

return meaning what to do
"US" location set proceed
None account exists, no location set call set_user_location
False could not answer stop

None never means the user was not found; that is False like everywhere else. Test with is None and is False, because a bare if not loc collapses the two cases that call for opposite responses.

Permissions

Every function's docstring names the least privileged application permission that works, verified against the Graph reference rather than assumed. To see them all at once:

grep -A1 "Needs " microsoft_graph_helpers/*.py

Three are worth knowing before you grant anything, because they are the ones people guess wrong:

  • revoke_ms_sessions needs User.RevokeSessions.All and nothing else works app-only. User.ReadWrite.All covers almost every other user call in this module but not that one.
  • get_user_direct_group_memberships needs Directory.Read.All app-only. User.Read.All is not enough, unlike every other read here.
  • reset_ms_password needs a directory role, not just a scope. The app must hold at least User Administrator. Without it you get 403 Authorization_RequestDenied, which reads like a missing permission.

core.py and get_bearer_token need no Graph permission. They are transport and token plumbing; the permission that matters is whichever one the calling function documents.

Users

get_upn_from_email turns whatever address you were handed into the account's UPN. Alerts and ticket queues often carry the address a person sends mail from rather than the name they sign in with, and the rest of the user calls (revoke_ms_sessions, reset_ms_password, get_user_direct_group_memberships) only take a UPN.

upn = get_upn_from_email(token, "lucas@example.edu")
if upn:
    revoke_ms_sessions(token, upn)

It runs two lookups on the one token you pass in. First userPrincipalName, then mail, stopping at the first hit. An address that is already a UPN answers on the first query; a mail address costs both. Needs User.Read.All.

UPN is checked first on purpose. Entra enforces uniqueness on UPN but not on mail, so one person's UPN can also be sitting in another person's mail field. Entra accepts that without complaint. Checking mail first meant an address that was a real sign-in name resolved to the other account, which for a caller that revokes sessions or resets passwords means acting on the wrong person. An exact UPN match now always wins.

A duplicate mail value makes the lookup fail rather than guess. Entra does not enforce uniqueness on mail the way it does on UPN, so two accounts can carry the same value: most often accounts synced from on-prem AD, where nothing blocks it, and accounts with no mailbox, where Exchange is not there to object. When a lookup matches more than one user, it logs an error naming every match and returns False instead of acting on whichever came back first. Callers use this to reset passwords and kill sessions, so an ambiguous answer is treated as no answer. A mail collision still falls through to the UPN lookup, and a hit there is definitive.

Only mail and userPrincipalName are searched. Aliases in proxyAddresses and recovery addresses in otherMails are not. An address that exists only as an alias comes back False.

Addresses are escaped before they go into the $filter, so an apostrophe in o'brien@example.edu does not break the query.

Creating, changing and removing accounts

create_user takes the five properties Graph insists on as named arguments (userPrincipalName, displayName, mailNickname, a password, and accountEnabled) rather than a dict, because omitting one returns a 400 that does not tell you which one is missing. Anything else writable goes in additional_properties. It returns the created object so you get the new id without a second lookup.

user = create_user(
    token,
    user_principal_name="avance@example.edu",
    display_name="Adele Vance",
    mail_nickname="avance",
    password=generated_password,
    usage_location="US",
    additional_properties={"givenName": "Adele", "surname": "Vance"},
)
if user:
    print(user["id"])

Two things that bite: the domain in the UPN has to be a verified domain on the tenant, and usage_location is not required to create the account but license assignment fails without it. Needs User.Create or User.ReadWrite.All.

update_user patches writable properties and takes a plain dict, since the user resource has far more fields than are worth spelling out as arguments. Property names are not validated here; a name Graph does not recognize comes back as a 400 and gets logged. Needs User.ReadWrite.All.

disable_user and enable_user flip accountEnabled. They are thin wrappers over update_user, named because that property has its own least-privilege scope, User.EnableDisableAccount.All plus User.Read.All. An app that only switches accounts off during offboarding does not need User.ReadWrite.All.

Disabling does not end sessions that are already running. It blocks new sign-ins, but an access token issued before the change keeps working until it expires, usually up to an hour, so a disabled account can still be reading mail after the call returns True. Pair it with revoke_ms_sessions, which invalidates the refresh tokens so nothing can be renewed:

disable_user(token, upn)        # block new sign-ins first
revoke_ms_sessions(token, upn)  # then kill what is already running

That order matters. Revoking first leaves a gap where the account can sign straight back in. Disabling also does not free the license: the account keeps its seat until the license is removed or the account is deleted.

delete_user is a soft delete. The account, its mailbox and its license assignments move to a holding area for 30 days, and restore_user puts all of it back, group memberships included. After 30 days Entra purges the account on its own and the licenses are freed. Needs User.ReadWrite.All, and note that an app token with that permission still cannot delete a user holding a privileged admin role unless the app holds an equal or higher role.

restore_user takes an object id, not a UPN, because a deleted user is out of /users and the UPN no longer resolves. list_deleted_users is there to find that id. Pass new_user_principal_name when the old UPN has since been handed to someone else, and auto_reconcile_proxy_conflict=True when an active account has picked up one of the deleted user's proxy addresses, which otherwise fails the whole restore. Both need User.DeleteRestore.All.

import re

for u in list_deleted_users(token).get("value", []):
    # Entra prefixes the object id, dashes stripped, onto the UPN when it soft
    # deletes, so a startswith test on the original address never matches.
    upn = re.sub(r"^[0-9a-f]{32}", "", u["userPrincipalName"])
    if upn.startswith("avance@"):
        restore_user(token, u["id"])

permanently_delete_user empties the recycle bin for one account. There is no undo. The mailbox is gone, the object id never comes back, and restore_user has nothing left to work with. Every other call here can be walked back, including delete_user.

It takes an object id and nothing else, never a UPN. Two safeguards fall out of that, both intentional: a mistyped name cannot resolve to a live account because names are not accepted, and the endpoint only reaches objects already in the recycle bin, so an id belonging to a live user comes back not-found rather than destroyed. Delete first, then purge. Needs User.DeleteRestore.All.

list_users lists users, with filter, select, order_by and top.

# everyone who will fail a license assignment
list_users(token, filter="usageLocation eq null", select="userPrincipalName")

That query fails if you send it yourself. eq null, ne, not, endsWith and $search all need Graph's advanced query capabilities: a ConsistencyLevel: eventual header and $count=true. list_users runs the query plainly, and retries once with those headers if Graph answers Request_UnsupportedQuery, so callers never have to know the rule.

Worth absorbing because Graph's errors point the wrong way. A failed usageLocation eq null reports the filter clause is unsupported for that property, which reads as "you cannot filter this", and a failed ne reports the operator is unsupported. Both work with the headers.

top is a cap on results, not a page size. Graph's $top is a page size, and since requests here follow @odata.nextLink automatically, passing it through would page the whole directory a few at a time and return everything.

Build filter yourself and run interpolated values through escape_odata_value. Needs User.Read.All.

get_user reads a user and returns the response. select limits which properties come back, as a comma separated string or a list.

user = get_user(token, upn, select=["displayName", "accountEnabled", "usageLocation"])

Leave select off and you get Graph's default property set, which is small. It does not include usageLocation, accountEnabled, assignedLicenses, licenseAssignmentStates, employeeId, or most of what you probably came for. A property missing from the response usually means it was not asked for, not that it is empty on the account. That distinction is worth remembering before you go debugging a value that looks unset. Needs User.Read.All.

set_user_location and get_user_location handle usageLocation, the property whose absence fails a later call rather than the one you just made.

Setting it takes a two letter ISO 3166-1 alpha-2 code, normalized to uppercase. There is no list of valid codes here. Graph keeps its own list and enforces it, so a local copy would need maintaining every time ISO 3166 changes and would eventually reject a code Graph accepts.

Only the shape is checked locally, because anything that is not two letters cannot be a country code and is not worth a round trip. Everything else goes to Graph, and its rejection is translated on the way back. Graph's own message for a bad code is:

Property usageLocation is invalid.

That is the whole error. It does not say what a valid value looks like, and it does not say what to use instead, so the log says it for you:

Graph rejected 'UK' as a usageLocation for adele@example.edu. It must be
a two letter ISO 3166-1 alpha-2 code such as US, GB or JP. The United
Kingdom is GB, not UK. Graph said: Property usageLocation is invalid.

The GB hint appears only for UK; every other rejected code gets the general message. Graph's own words are kept on the end in case the 400 was really about something else. Nothing is ever silently substituted, because quietly changing a country code on a compliance field is worse than an error.

Reading it needs its own function because usageLocation does not come back from a plain user GET. It has to be requested with $select, and without that an account with a location set looks identical to one without.

get_user_location returns three things, and the difference matters:

return meaning what to do
"US" account has a location proceed
None account exists, no location set call set_user_location
False could not answer: not found, denied, network stop

None never means the user was not found. A missing user, a denied request and a dead connection all come back False. None only happens when Graph answered, the account is there, and the field is empty.

It returns three rather than two because create_user does not set usageLocation, so every account starts in the None state. That is the normal path, not an edge case. Folding it into False would make "this account needs a location" look identical to "Graph is unreachable", and those want opposite responses.

None logs at warning, not error. Nothing failed and the caller can carry on, but a license assignment will fail later until a location is set, so it is worth seeing. Test with is None and is False; a bare if not location collapses the two cases you most need to tell apart.

Release files for microsoft-graph-helpers 0.4.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 microsoft-graph-helpers 0.4.0
File Size Uploaded
microsoft_graph_helpers-0.4.0.tar.gz 28.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for microsoft-graph-helpers 0.4.0
File Interpreter ABI Platform
microsoft_graph_helpers-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 56.6 kB

Release files / microsoft_graph_helpers-0.4.0.tar.gz

Download URL microsoft_graph_helpers-0.4.0.tar.gz
Size 28.8 kB
Tags Source
SHA-256 checksum
How to use checksums
dce23aa2f6371152d95dac06b32532348ce8ac176d2d457b1bb8b9f03a26219a
BLAKE2b-256 checksum
How to use checksums
e4fd3cdb0cf586105c8de0febe503ec8a1371bee51f7d0c328dea68a96432ceb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.12

Release files / microsoft_graph_helpers-0.4.0-py3-none-any.whl

Download URL microsoft_graph_helpers-0.4.0-py3-none-any.whl
Size 27.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
96744a6b34db288d2f45ee3fbad3f70cd6514a2bce4651e3760f5a7a27b19705
BLAKE2b-256 checksum
How to use checksums
53f95589b5bcdbe43d2b855f388ae9e90c5f111ee61b181c20985e5ac8905673
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.12

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

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