Package: sinatranotion
notion-pyUnofficial Python 3 client for Notion.so API v3.Object-oriented interface (mapping database tables to Python classes/attributes)Automatic conversion between internal Notion formats and appropriate Python objectsLocal cache of data in a unified data store(Note: disk cache now disabled by default; to enable, addenable_caching=Truewhen initializingNotionClient)Real-time reactive two-way data binding (changing Python object -> live updating of Notion UI, and vice-versa)(Note: Notion->Python automatic updating is currently broken and hence disabled by default; callmy_block.refresh()to update, in the meantime, while monitoring is being fixed)Callback system for responding to changes in Notion (e.g. for triggering actions, updating another API, etc)Read more about Notion and Notion-py on Jamie's blogUsageQuickstartNote: the latest version ofnotion-pyrequires Python 3.5 or greater.pip install notionfromnotion.clientimportNotionClient# Obtain the `token_v2` value by inspecting your browser cookies on a logged-in (non-guest) session on Notion.soclient=NotionClient(token_v2="<token_v2>")# Replace this URL with the URL of the page you want to editpage=client.get_block("https://www.notion.so/myorg/Test-c0d20a71c0944985ae96e661ccc99821")print("The old title is:",page.title)# Note: You can use Markdown! We convert on-the-fly to Notion's internal formatted text data structure.page.title="The title has now changed, and has *live-updated* in the browser!"Concepts and notesWe map tables in the Notion database into Python classes (subclassingRecord), with each instance of a class representing a particular record. Some fields from the records (liketitlein the example above) have been mapped to model properties, allowing for easy, instantaneous read/write of the record. Other fields can be read with thegetmethod, and written with thesetmethod, but then you'll need to make sure to match the internal structures exactly.The tables we currently support areblock(viaBlockclass and its subclasses, corresponding to differenttypeof blocks),space(viaSpaceclass),collection(viaCollectionclass),collection_view(viaCollectionViewand subclasses), andnotion_user(viaUserclass).Data for all tables are stored in a centralRecordStore, with theRecordinstances not storing state internally, but always referring to the data in the centralRecordStore. Many API operations return updating versions of a large number of associated records, which we use to update the store, so the data inRecordinstances may sometimes update without being explicitly requested. You can also call therefreshmethod on aRecordto trigger an update, or passforce_updateto methods likeget.The API doesn't have strong validation of most data, so be careful to maintain the structures Notion is expecting. You can view the full internal structure of a record by callingmyrecord.get()with no arguments.When you callclient.get_block, you can pass in either an ID, or the URL of a page. Note that pages themselves are justblocks, as are all the chunks of content on the page. You can get the URL for a block within a page by clicking "Copy Link" in the context menu for the block, and pass that URL intoget_blockas well.Updating recordsWe keep a local cache of all data that passes through. When you reference an attribute on aRecord, we first look to that cache to retrieve the value. If it doesn't find it, it retrieves it from the server. You can also manually refresh the data for aRecordby calling therefreshmethod on it. By default (unless we instantiateNotionClientwithmonitor=False), we alsosubscribe to long-polling updatesfor any instantiatedRecord, so the local cache data for theseRecordsshould be automatically live-updated shortly after any data changes on the server. The long-polling happens in a background daemon thread.Example: Traversing the block treeforchildinpage.children:print(child.title)print("Parent of{}is{}".format(page.id,page.parent.id))Example: Adding a new nodefromnotion.blockimportTodoBlocknewchild=page.children.add_new(TodoBlock,title="Something to get done")newchild.checked=TrueExample: Deleting nodes# soft-deletepage.remove()# hard-deletepage.remove(permanently=True)Example: Create an embedded content type (iframe, video, etc)fromnotion.blockimportVideoBlockvideo=page.children.add_new(VideoBlock,width=200)# sets "property.source" to the URL, and "format.display_source" to the embedly-converted URLvideo.set_source_url("https://www.youtube.com/watch?v=oHg5SJYRHA0")Example: Create a new embedded collection view blockcollection=client.get_collection(COLLECTION_ID)# get an existing collectioncvb=page.children.add_new(CollectionViewBlock,collection=collection)view=cvb.views.add_new(view_type="table")# Before the view can be browsed in Notion,# the filters and format options on the view should be set as desired.## for example:#   view.set("query", ...)#   view.set("format.board_groups", ...)#   view.set("format.board_properties", ...)Example: Moving blocks around# move my block to after the videomy_block.move_to(video,"after")# move my block to the end of otherblock's childrenmy_block.move_to(otherblock,"last-child")# (you can also use "before" and "first-child")Example: Subscribing to updates(Note: Notion->Python automatic updating is currently broken and hence disabled by default; callmy_block.refresh()to update, in the meantime, while monitoring is being fixed)We can "watch" aRecordso that we get acallbackwhenever it changes. Combined with the live-updating of records based on long-polling, this allows for a "reactive" design, where actions in our local application can be triggered in response to interactions with the Notion interface.# define a callback (note: all arguments are optional, just include the ones you care about)defmy_callback(record,difference):print("The record's title is now:"record.title)print("Here's what was changed:")print(difference)# move my block to after the videomy_block.add_callback(my_callback)Example: Working with databases, aka "collections" (tables, boards, etc)Here's how things fit together:Main container block:CollectionViewBlock(inline) /CollectionViewPageBlock(full-page)Collection(holds the schema, and is parent to the database rows themselves)CollectionRowBlockCollectionRowBlock... (more database records)CollectionView(holds filters/sort/etc about each specific view)Note: For convenience, we automatically map the database "columns" (aka properties), based on the schema defined in theCollection, into getter/setter attributes on theCollectionRowBlockinstances. The attribute name is a "slugified" version of the name of the column. So if you have a column named "Estimated value", you can read and write it viamyrowblock.estimated_value. Some basic validation may be conducted, and it will be converted into the appropriate internal format. For columns of type "Person", we expect aUserinstance, or a list of them, and for a "Relation" we expect a singular/list of instances of a subclass ofBlock.# Access a database using the URL of the database page or the inline blockcv=client.get_collection_view("https://www.notion.so/myorg/8511b9fc522249f79b90768b832599cc?v=8dee2a54f6b64cb296c83328adba78e1")# List all the records with "Bob" in themforrowincv.collection.get_rows(search="Bob"):print("We estimate the value of '{}' at{}".format(row.name,row.estimated_value))# Add a new recordrow=cv.collection.add_row()row.name="Just some data"row.is_confirmed=Truerow.estimated_value=399row.files=["https://www.birdlife.org/sites/default/files/styles/1600/public/slide.jpg"]row.person=client.current_userrow.tags=["A","C"]row.where_to="https://learningequality.org"# Run a filtered/sorted query using a view's default parametersresult=cv.default_query().execute()forrowinresult:print(row)# Run an "aggregation" queryaggregations=[{"property":"estimated_value","aggregator":"sum","id":"total_value",}]result=cv.build_query(aggregate=aggregate_params).execute()print("Total estimated value:",result.get_aggregate("total_value"))# Run a "filtered" query (inspect network tab in browser for examples, on queryCollection calls)filter_params={"filters":[{"filter":{"value":{"type":"exact","value":{"table":"notion_user","id":client.current_user.id}},"operator":"person_contains"},"property":"assigned_to"}],"operator":"and"}result=cv.build_query(filter=filter_params).execute()print("Things assigned to me:",result)# Run a "sorted" querysort_params=[{"direction":"descending","property":"estimated_value",}]result=cv.build_query(sort=sort_params).execute()print("Sorted results, showing most valuable first:",result)Note: You can combinefilter,aggregate, andsort. See more examples of queries by setting up complex views in Notion, and then inspecting the full query:cv.get("query2").You can also seemore examples in action in the smoke test runner. Run it using:pythonrun_smoke_test.py--page[YOUR_NOTION_PAGE_URL]--token[YOUR_NOTION_TOKEN_V2]Example: Lock/Unlock A Pagefromnotion.clientimportNotionClient# Obtain the `token_v2` value by inspecting your browser cookies on a logged-in session on Notion.soclient=NotionClient(token_v2="<token_v2>")# Replace this URL with the URL of the page or database you want to editpage=client.get_block("https://www.notion.so/myorg/Test-c0d20a71c0944985ae96e661ccc99821")# The "locked" property is available on PageBlock and CollectionViewBlock objects# Set it to True to lock the page/databasepage.locked=True# and False to unlock it againpage.locked=FalseExample: Set the current user for multi-account userfromnotion.clientimportNotionClientclient=NotionClient(token_v2="<token_v2>")# The initial current_user of a multi-account user may be an unwanted userprint(client.current_user.email)# → not_the_desired@email.co.jp# Set current_user to the desired userclient.set_user_by_email('desired@email.com')print(client.current_user.email)# → desired@email.com# You can also set the current_user by uid.client.set_user_by_uid('<uid>')print(client.current_user.email)# → desired@email.comQuick plug: Learning Equality needs your support!If you'd like to support notion-py development, please considerdonating to my open-source nonprofit, Learning Equality, since when I'm not working on notion-py, it probably means I'm heads-down fundraising for our global education work (bringing resources like Khan Academy to communities with no Internet). COVID has further amplified needs, with over a billion kids stuck at home, and over half of them without the connectivity they need for distance learning. You can now alsosupport our work via GitHub Sponsors!Related Projectsmd2notion: import Markdown files to Notionnotion-export-ics: Export Notion Databases to ICS calendar filesnotion-tqdm: Progress Bar displayed in Notion like tqdmTODOCloning pages hierarchicallyDebounce cache-saving?Support inline "user" and "page" links, and reminders, in markdown conversionUtilities to support updating/creating collection schemasUtilities to support updating/creating collection_view queriesSupport for easily managing page permissionsWebsocket support for live block cache updating"Render full page to markdown" mode"Import page from html" mode