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_sessionsneedsUser.RevokeSessions.Alland nothing else works app-only.User.ReadWrite.Allcovers almost every other user call in this module but not that one.get_user_direct_group_membershipsneedsDirectory.Read.Allapp-only.User.Read.Allis not enough, unlike every other read here.reset_ms_passwordneeds a directory role, not just a scope. The app must hold at least User Administrator. Without it you get403 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)
| File | Size | Uploaded | |
|---|---|---|---|
| microsoft_graph_helpers-0.4.0.tar.gz | 28.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|