pyo365 - Microsoft Graph and Office 365 API made easy
This project aims is to make it easy to interact with Microsoft Graph and Office 365 Email, Contacts, Calendar, OneDrive, etc.
This project is inspired on the super work done by Toben Archer Python-O365.
The oauth part is based on the work done by Royce Melborn which is now integrated with the original project.
I just want to make this project different in almost every sense, and make it also more pythonic.
So I ended up rewriting the whole project from scratch.
The result is a package that provides a lot of the Microsoft Graph and Office 365 API capabilities.
This is for example how you send a message:
from pyo365 import Account
credentials = ('client_id', 'client_secret')
account = Account(credentials)
m = account.new_message()
m.to.add('to_example@example.com')
m.subject = 'Testing!'
m.body = "George Best quote: I've stopped drinking, but only while I'm asleep."
m.send()
Python 3.4 is the minimum required... I was very tempted to just go for 3.6 and use f-strings. Those are fantastic!
This project was also a learning resource for me. This is a list of not so common python characteristics used in this project:
- New unpacking technics:
def method(argument, *, with_name=None, **other_params):
- Enums:
from enum import Enum
- Factory paradigm
- Package organization
- Timezone conversion and timezone aware datetimes
- Etc. (see the code!)
This project is in early development. Changes that can break your code may be commited. If you want to help please feel free to fork and make pull requests.
What follows is kind of a wiki... but you will get more insights by looking at the code.
Table of contents
Install
pyo365 is available on pypi.org. Simply run pip install pyo365
to install it.
Project dependencies installed by pip:
- requests
- requests-oauthlib
- beatifulsoup4
- stringcase
- python-dateutil
- tzlocal
- pytz
The first step to be able to work with this library is to register an application and retrieve the auth token. See Authentication.
Protocols
Protocols handles the aspects of comunications between different APIs.
This project uses by default either the Office 365 APIs or Microsoft Graph APIs.
But, you can use many other Microsoft APIs as long as you implement the protocol needed.
You can use one or the other:
Both protocols are similar but the Graph one has access to more resources (for example OneDrive). It also depends on the api version used.
The default protocol used by the Account
Class is MSGraphProtocol
.
You can implement your own protocols by inheriting from Protocol
to communicate with other Microsoft APIs.
You can instantiate protocols like this:
from pyo365 import MSGraphProtocol
protocol = MSGraphProtocol(api_version='beta')
Resources:
Each API endpoint requires a resource. This usually defines the owner of the data.
Every protocol defaults to resource 'ME'. 'ME' is the user which has given consent, but you can change this behaviour by providing a different default resource to the protocol constructor.
For example when accesing a shared mailbox:
account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com')
This can be done however at any point. For example at the protocol level:
my_protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com')
account = Account(credentials=my_credentials, protocol=my_protocol)
shared_mailbox_messages = account.mailbox().get_messages()
Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows:
account = Account(credentials=my_credentials)
mailbox = account.mailbox('shared_mailbox@example.com')
message = Message(parent=account, main_resource='shared_mailbox@example.com')
Usually you will work with the default 'ME' resuorce, but you can also use one of the following:
- 'me': the user which has given consent. the default for every protocol.
- 'user:user@domain.com': a shared mailbox or a user account for which you have permissions. If you don't provide 'user:' will be infered anyways.
- 'sharepoint:sharepoint-site-id': a sharepoint site id.
- 'group:group-site-id': a office365 group id.
Authentication
You can only authenticate using oauth athentication as Microsoft deprecated basic oauth on November 1st 2018.
- Oauth authentication: using an authentication token provided after user consent.
The Connection
Class handles the authentication.
Oauth Authentication
This section is explained using Microsoft Graph Protocol, almost the same applies to the Office 365 REST API.
Permissions and Scopes:
When using oauth you create an application and allow some resources to be accesed and used by it's users.
Then the user can request access to one or more of this resources by providing scopes to the oauth provider.
For example your application can have Calendar.Read, Mail.ReadWrite and Mail.Send permissions, but the application can request access only to the Mail.ReadWrite and Mail.Send permission.
This is done by providing scopes to the connection object like so:
from pyo365 import Connection
credentials = ('client_id', 'client_secret')
scopes = ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send']
con = Connection(credentials, scopes=scopes)
Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed:
You can get the same scopes as before using protocols like this:
protocol_graph = MSGraphProtocol()
scopes_graph = protocol.get_scopes_for('message all')
protocol_office = MSOffice365Protocol()
scopes_office = protocol.get_scopes_for('message all')
con = Connection(credentials, scopes=scopes_graph)
Authentication Flow
-
To work with oauth you first need to register your application at Microsoft Application Registration Portal.
- Login at Microsoft Application Registration Portal
- Create an app, note your app id (client_id)
- Generate a new password (client_secret) under "Application Secrets" section
- Under the "Platform" section, add a new Web platform and set "https://outlook.office365.com/owa/" as the redirect URL
- Under "Microsoft Graph Permissions" section, add the delegated permissions you want (see scopes), as an example, to read and send emails use:
- Mail.ReadWrite
- Mail.Send
- User.Read
-
Then you need to login for the first time to get the access token by consenting the application to access the resources it needs.
-
First get the authorization url.
url = account.connection.get_authorization_url()
-
The user must visit this url and give consent to the application. When consent is given, the page will rediret to: "https://outlook.office365.com/owa/".
Then the user must copy the resulting page url and give it to the connection object:
result_url = input('Paste the result url here...')
account.connection.request_token(result_url)
Take care, the access token must remain protected from unauthorized users.
-
At this point you will have an access token that will provide valid credentials when using the api. If you change the scope requested, then the current token won't work, and you will need the user to give consent again on the application to gain access to the new scopes requested.
The access token only lasts 60 minutes, but the app will automatically request new tokens through the refresh tokens, but note that a refresh token only lasts for 90 days. So you must use it before or you will need to request a new access token again (no new consent needed by the user, just a login).
If your application needs to work for more than 90 days without user interaction and without interacting with the API, then you must implement a periodic call to Connection.refresh_token
before the 90 days have passed.
Using pyo365 to authenticate
You can manually authenticate by using a single Connection
instance as described before or use the helper methods provided by the library.
-
account.authenticate
:
This is the preferred way for performing authentication.
Create an Account
instance and authenticate using the authenticate
method:
from pyo365 import Account
account = Account(credentials=('client_id', 'client_secret'))
result = account.authenticate(scopes=['basic', 'message_all'])
-
oauth_authentication_flow
:
from pyo365 import oauth_authentication_flow
result = oauth_authentication_flow('client_id', 'client_secret', ['scopes_required'])
Account Class and Modularity
Usually you will only need to work with the Account
Class. This is a wrapper around all functionality.
But you can also work only with the pieces you want.
For example, instead of:
from pyo365 import Account
account = Account(('client_id', 'client_secret'))
message = account.new_message()
mailbox = account.mailbox()
You can work only with the required pieces:
from pyo365 import Connection, MSGraphProtocol, Message, MailBox
my_protocol = MSGraphProtocol()
con = Connection(('client_id', 'client_secret'))
message = Message(con=con, protocol=my_protocol)
mailbox = MailBox(con=con, protocol=my_protocol)
message2 = Message(parent=mailbox)
It's also easy to implement a custom Class.
Just Inherit from ApiComponent
, define the endpoints, and use the connection to make requests. If needed also inherit from Protocol to handle different comunications aspects with the API server.
from pyo365.utils import ApiComponent
class CustomClass(ApiComponent):
_endpoints = {'my_url_key': '/customendpoint'}
def __init__(self, *, parent=None, con=None, **kwargs):
super().__init__(parent=parent, con=con, **kwargs)
def do_some_stuff(self):
url = self.build_url(self._endpoints.get('my_url_key'))
my_params = {'param1': 'param1'}
response = self.con.get(url, params=my_params)
MailBox
Mailbox groups the funcionality of both the messages and the email folders.
mailbox = account.mailbox()
inbox = mailbox.inbox_folder()
for message in inbox.get_messages():
print(message)
sent_folder = mailbox.sent_folder()
for message in sent_folder.get_messages():
print(message)
m = mailbox.new_message()
m.to.add('to_example@example.com')
m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.'
m.save_draft()
Email Folder
Represents a Folder
within your email mailbox.
You can get any folder in your mailbox by requesting child folders or filtering by name.
mailbox = account.mailbox()
archive = mailbox.get_folder(folder_name='archive')
child_folders = archive.get_folders(25)
for folder in child_folders:
print(folder.name, folder.parent_id)
new_folder = archive.create_child_folder('George Best Quotes')
Message
An email object with all it's data and methods.
Creating a draft message is as easy as this:
message = mailbox.new_message()
message.to.add(['example1@example.com', 'example2@example.com'])
message.sender.address = 'my_shared_account@example.com'
message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous'
message.attachments.add('george_best_quotes.txt')
message.save_draft()
Working with saved emails is also easy:
query = mailbox.new_query().on_attribute('subject').contains('george best')
messages = mailbox.get_messages(limit=25, query=query)
message = messages[0]
message.mark_as_read()
reply_msg = message.reply()
if 'example@example.com' in reply_msg.to:
reply_msg.body = 'George Best quote: I spent a lot of money on booze, birds and fast cars. The rest I just squandered.'
else:
reply_msg.body = 'George Best quote: I used to go missing a lot... Miss Canada, Miss United Kingdom, Miss World.'
reply_msg.send()
AddressBook
AddressBook groups the funcionality of both the Contact Folders and Contacts. Outlook Distribution Groups are not supported (By the Microsoft API's).
Contact Folders
Represents a Folder within your Contacts Section in Office 365.
AddressBook class represents the parent folder (it's a folder itself).
You can get any folder in your address book by requesting child folders or filtering by name.
address_book = account.address_book()
contacts = address_book.get_contacts(limit=None)
work_contacts_folder = address_book.get_folder(folder_name='Work Contacts')
message_to_all_contats_in_folder = work_contacts_folder.new_message()
message_to_all_contats_in_folder.subject = 'Hallo!'
message_to_all_contats_in_folder.body = """
George Best quote:
If you'd given me the choice of going out and beating four men and smashing a goal in
from thirty yards against Liverpool or going to bed with Miss World,
it would have been a difficult choice. Luckily, I had both.
"""
message_to_all_contats_in_folder.send()
child_folders = address_book.get_folders(25)
for folder in child_folders:
print(folder.name, folder.parent_id)
address_book.create_child_folder('new folder')
The Global Address List
Office 365 API (Nor MS Graph API) has no concept such as the Outlook Global Address List.
However you can use the Users API to access all the users within your organization.
Without admin consent you can only access a few properties of each user such as name and email and litte more.
You can search by name or retrieve a contact specifying the complete email.
- Basic Permision needed is Users.ReadBasic.All (limit info)
- Full Permision is Users.Read.All but needs admin consent.
To search the Global Address List (Users API):
global_address_list = account.address_book(address_book='gal')
q = global_address_list.new_query('display_name')
q.startswith('George Best')
print(global_address_list.get_contacts(query=q))
To retrieve a contact by it's email:
contact = global_address_list.get_contact_by_email('example@example.com')
Contacts
Everything returned from an AddressBook
instance is a Contact
instance.
Contacts have all the information stored as attributes
Creating a contact from an AddressBook
:
new_contact = address_book.new_contact()
new_contact.name = 'George Best'
new_contact.job_title = 'football player'
new_contact.emails.add('george@best.com')
new_contact.save()
message = new_contact.new_message()
new_contact.delete()
Calendar
The calendar and events functionality is group in a Schedule
object.
A Schedule
instance can list and create calendars. It can also list or create events on the default user calendar.
To use other calendars use a Calendar
instance.
Working with the Schedule
instance:
import datetime as dt
schedule = account.schedule()
new_event = schedule.new_event()
new_event.subject = 'Recruit George Best!'
new_event.location = 'England'
new_event.start = dt.datetime(2018, 9, 5, 19, 45)
new_event.recurrence.set_daily(1, end=dt.datetime(2018, 9, 10))
new_event.remind_before_minutes = 45
new_event.save()
Working with Calendar
instances:
calendar = schedule.get_calendar(calendar_name='Birthdays')
calendar.name = 'Football players birthdays'
calendar.update()
q = calendar.new_query('start').ge(dt.datetime(2018, 5, 20)).chain('and').on_attribute('end').le(dt.datetime(2018, 5, 24))
birthdays = calendar.get_events(query=q)
for event in birthdays:
if event.subject == 'George Best Birthday':
event.accept("I'll attend!")
else:
event.decline("No way I'm comming, I'll be in Spain", send_response=False)
OneDrive
The Storage
class handles all functionality around One Drive and Document Library Storage in Sharepoint.
The Storage
instance allows to retrieve Drive
instances which handles all the Files and Folders from within the selected Storage
.
Usually you will only need to work with the default drive. But the Storage
instances can handle multiple drives.
A Drive
will allow you to work with Folders and Files.
account = Account(credentials=my_credentials)
storage = account.storage()
drives = storage.get_drives()
my_drive = storage.get_default_drive()
root_folder = my_drive.get_root_folder()
attachments_folder = my_drive.get_special_folder('attachments')
for item in root_folder.get_items(limit=25):
if item.is_folder:
print(item.get_items(2))
elif item.is_file:
if item.is_photo:
print(item.camera_model)
elif item.is_image:
print(item.dimensione)
else:
print(item.mime_type)
Both Files and Folders are DriveItems. Both Image and Photo are Files, but Photo is also an Image. All have some different methods and properties.
Take care when using 'is_xxxx'.
When coping a DriveItem the api can return a direct copy of the item or a pointer to a resource that will inform on the progress of the copy operation.
documents_folder = drive.get_special_folder('documents')
files = drive.search('george best quotes', limit=1)
if files:
george_best_quotes = files[0]
operation = george_best_quotes.copy(target=documents_folder)
for status, progress in operation.check_status():
print('{} - {}'.format(status, progress))
copied_item = operation.get_item()
if copied_item:
copied_item.delete()
You can also work with share permissions:
current_permisions = file.get_permissions()
permission = file.share_with_link(share_type='edit')
if permission:
print(permission.share_link)
permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit')
if permission:
print(permission.granted_to)
You can also:
file.download(to_path='/quotes/')
uploaded_file = folder.upload_file(item='path_to_my_local_file')
versiones = file.get_versions()
for version in versions:
if version.name == '2.0':
version.restore()
Sharepoint
Work in progress
Utils
When using certain methods, it is possible that you request more items than the api can return in a single api call.
In this case the Api, returns a "next link" url where you can pull more data.
When this is the case, the methods in this library will return a Pagination
object which abstracts all this into a single iterator.
The pagination object will request "next links" as soon as they are needed.
For example:
maibox = account.mailbox()
messages = mailbox.get_messages(limit=1500)
for message in messages:
print(message.subject)
When using certain methods you will have the option to specify not only a limit option (the number of items to be returned) but a batch option.
This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed.
This is usefull when you want to optimize memory or network latency.
For example:
messages = mailbox.get_messages(limit=100, batch=25)
for message in messages:
print(message.subject)
The Query helper
When using the Office 365 API you can filter some fields.
This filtering is tedious as is using Open Data Protocol (OData).
Every ApiComponent
(such as MailBox
) implements a new_query method that will return a Query
instance.
This Query
instance can handle the filtering (and sorting and selecting) very easily.
For example:
query = mailbox.new_query()
query = query.on_attribute('subject').contains('george best').chain('or').startswith('quotes')
query = query.chain('and').on_attribute('created_date_time').greater(datetime(2018, 3, 21))
print(query)
filtered_messages = mailbox.get_messages(query=query)
You can also specify specific data to be retrieved with "select":
query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time')
messages_with_selected_properties = mailbox.get_messages(query=query)
Request Error Handling and Custom Errors
Whenever a Request error raises, the connection object will raise an exception.
Then the exception will be captured and logged it to the stdout with it's message, an return Falsy (None, False, [], etc...)
HttpErrors 4xx (Bad Request) and 5xx (Internal Server Error) are considered exceptions and raised also by the connection (you can configure this on the connection).