├── .env.example ├── .github ├── FUNDING.yml └── workflows │ ├── format.yml │ ├── publish.yml │ ├── smoke_test.yml │ └── test.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── TODO.md ├── dev-requirements.txt ├── docs ├── bump-version.sh ├── conf.py ├── gen-notes.sh ├── index.rst ├── introduction.rst └── serve.py ├── notion ├── __init__.py ├── block │ ├── __init__.py │ ├── basic.py │ ├── children.py │ ├── collection │ │ ├── __init__.py │ │ ├── basic.py │ │ ├── children.py │ │ ├── common.py │ │ ├── media.py │ │ ├── query.py │ │ └── view.py │ ├── database.py │ ├── embed.py │ ├── inline.py │ ├── media.py │ ├── types.py │ └── upload.py ├── client.py ├── converter.py ├── logger.py ├── maps.py ├── markdown.py ├── monitor.py ├── operations.py ├── record.py ├── renderer.py ├── settings.py ├── space.py ├── store.py ├── user.py └── utils.py ├── requirements.lock ├── requirements.txt ├── setup.py ├── smoke_tests ├── __init__.py ├── block │ ├── __init__.py │ ├── collection │ │ ├── __init__.py │ │ ├── test_basic.py │ │ └── test_media.py │ ├── test_basic.py │ ├── test_embed.py │ ├── test_media.py │ └── test_upload.py ├── conftest.py └── test_workflow.py └── tests ├── __init__.py ├── block ├── __init__.py └── test_block.py └── test_utils.py /.env.example: -------------------------------------------------------------------------------- 1 | # smoke tests config 2 | export NOTION_TOKEN_V2="" 3 | export NOTION_PAGE_URL="https://www.notion.so/org/tests-e77338782...45c26965" 4 | 5 | # notion config 6 | export NOTION_LOG_LEVEL="WARNING" 7 | export NOTION_DATA_DIR=".notion-py" 8 | 9 | # PyPI config 10 | TWINE_USERNAME="__token__" 11 | TWINE_PASSWORD="pypi-supersecrettoken" 12 | 13 | # create and load venv 14 | test -f .venv/bin/activate || python3 -m venv .venv 15 | test -f .venv/bin/activate && source .venv/bin/activate 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [arturtamborski] # sadly jamalex does not have a sponsorship button... 2 | ko_fi: arturtamborski 3 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Check Code Formatting 2 | 3 | on: 4 | push: 5 | paths: 6 | - notion/** 7 | - tests/** 8 | - smoke_tests/** 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v2 17 | with: { python-version: '3.x' } 18 | 19 | - run: make dev-install 20 | - run: make try-format 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: { python-version: '3.x' } 15 | 16 | - run: make dev-install 17 | - run: make install 18 | - run: make build 19 | - run: make publish 20 | env: 21 | TWINE_USERNAME: __token__ 22 | TWINE_PASSWORD: ${{ secrets.PYPI_SECRET_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/smoke_test.yml: -------------------------------------------------------------------------------- 1 | name: Run Smoke Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - notion/** 8 | - smoke_tests/** 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v2 17 | with: { python-version: '3.x' } 18 | 19 | - run: make dev-install 20 | - run: make install 21 | - run: make smoke-test 22 | env: 23 | NOTION_TOKEN_V2: ${{ secrets.NOTION_TOKEN_V2 }} 24 | NOTION_PAGE_URL: ${{ secrets.NOTION_PAGE_URL }} 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - notion/** 7 | - tests/** 8 | 9 | jobs: 10 | main: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: { python-version: '3.x' } 17 | 18 | - run: make dev-install 19 | - run: make install 20 | - run: make test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | __pycache__/ 4 | .pytest_cache/ 5 | 6 | dist/ 7 | build/ 8 | public/ 9 | docs/api/ 10 | .notion-py/ 11 | 12 | # make clean will remove files above this line 13 | # -------------------------------------------- 14 | 15 | .env 16 | .venv 17 | .idea/ 18 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | python: 7 | version: 3.8 8 | install: 9 | - requirements: dev-requirements.txt 10 | - requirements: requirements.txt 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 - 2020 Jamie Alexandre, Artur Tamborski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include requirements.txt 4 | 5 | global-exclude *.pyc 6 | global-exclude __pycache__ 7 | 8 | global-exclude tests/ 9 | global-exclude smoke_tests/ 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help clean install dev-install self-install 2 | .PHONY: build bump-% publish docs serve-docs lock 3 | .PHONY: format try-format test try-test smoke-test try-smoke-test 4 | 5 | 6 | tests = $(or $(word 2, $(MAKECMDGOALS)), tests/) 7 | smoke_tests = $(or $(word 2, $(MAKECMDGOALS)), smoke_tests/) 8 | temp_files = $(shell sed '/\# -/q' .gitignore | cut -d'\#' -f1) 9 | 10 | 11 | help: ## display this help 12 | @awk ' \ 13 | BEGIN { \ 14 | FS = ":.*##"; \ 15 | printf "Usage:\n\t make \033[36m"; \ 16 | printf "\033[0m\n\nTargets:\n"; \ 17 | } /^[\-a-z%]+:.*##/ { \ 18 | printf "\033[36m%17s\033[0m -%s\n", $$1, $$2; \ 19 | }' $(MAKEFILE_LIST) 20 | 21 | 22 | clean: ## clean all temp files 23 | rm -rf $(temp_files) 24 | find . -type f -name "*.pyc" -delete 25 | find . -type d -name "__pycache__" -delete 26 | 27 | 28 | install: ## install requirements 29 | python -m pip install -r requirements.lock 30 | 31 | 32 | dev-install: ## install dev requirements 33 | python -m pip install -r dev-requirements.txt 34 | 35 | 36 | self-install: ## install the package locally 37 | python setup.py install 38 | 39 | 40 | build: ## build wheel package 41 | python setup.py sdist bdist_wheel 42 | 43 | 44 | bump-%: ## bump version (major, minor, patch) 45 | @bash docs/bump-version.sh $(subst bump-,,$@) 46 | 47 | 48 | publish: ## publish the package on PyPI 49 | twine check dist/* 50 | twine upload --skip-existing dist/* 51 | 52 | 53 | docs: ## generate documentation in HTML 54 | sphinx-build -b dirhtml docs/ public/ 55 | 56 | 57 | serve-docs: ## generate and serve documentation 58 | python docs/serve.py 59 | 60 | 61 | lock: ## lock all dependency versions 62 | python -m pip freeze | xargs pip uninstall -y 63 | python -m pip install --upgrade -r requirements.txt 64 | python -m pip freeze > requirements.lock 65 | 66 | 67 | format: ## format code with black 68 | python -m black . 69 | 70 | 71 | try-format: ## try to format code with black 72 | python -m black --check . 73 | 74 | 75 | test: ## test code with unit tests 76 | python -m pytest --cache-clear -v $(tests) 77 | 78 | 79 | try-test: ## try test code with unit tests 80 | python -m pytest --cache-clear -v -x --pdb $(tests) 81 | 82 | 83 | smoke-test: ## test code with smoke tests 84 | python -m pytest --cache-clear -v $(smoke_tests) 85 | 86 | 87 | try-smoke-test: ## try to test code with smoke tests 88 | python -m pytest --cache-clear -v -x --pdb $(smoke_tests) 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

notion-py

5 |

(Fork of) Unofficial Python 3 client for Notion.so API v3.

6 | 7 | [Documentation][documentation-url] 8 | | [Package on PyPI][package-url] 9 |
10 |
11 | ![check formatting][check-formatting-url] 12 | ![run unit tests][run-unit-tests-url] 13 | ![upload-python-package][upload-python-package-url] 14 | ![run-smoke-tests][run-smoke-tests-url] 15 | ![documentation-status][documentation-status-url] 16 | ![code-style][code-style-url] 17 | ![license][license-url] 18 | ![code-size][code-size-url] 19 | ![downloads-rate][downloads-rate-url] 20 |
21 |
22 | 23 | 24 | 25 | 26 | --- 27 | 28 | > **_NOTE:_** This is a fork of the 29 | [original repository](https://github.com/jamalex/notion-py) 30 | created by [Jamie Alexandre](https://github.com/jamalex). 31 | 32 | You can try out this package - it's called 33 | [notion-py](https://pypi.org/project/notion-py/) 34 | on PyPI. The original package created by Jamie is still online 35 | under the name [notion](https://pypi.org/project/notion/) on PyPI, 36 | so please watch out for any confusion. 37 | 38 | imports are still working as before, the `-py` in 39 | name is there only to differentiate between these two. 40 | 41 | --- 42 | 43 | These libraries as of now are _not_ fully compatible. 44 | (I'm working on sending PRs to the upstream) 45 | 46 | List of major differences: 47 | - imports were changed, especially for blocks and collections. 48 | General rule is: 49 | - `notion.block.py -> notion.block.*.py` 50 | - `notion.collection.py -> notion.block.collection.*.py` 51 | - some block names were changed to align them with notion.so 52 | One of such examples is `TodoBlock -> ToDoBlock` (because it's type is `to_do`) 53 | - some function definitions also changed 54 | I did that to simplify the API and make it more uniform. 55 | 56 |
57 |
58 | 59 | 60 | 61 | ## Features 62 | - **Automatic conversion between Notion blocks and Python objects** 63 | we covered pretty much every block type there is! 64 | 65 | - **Callback system for responding to changes in Notion** 66 | useful for triggering actions, updating another block, etc. 67 | 68 | - **Object-oriented interface** 69 | seamless mapping of API response parameters to Python classes/attributes. 70 | 71 | - **Local cache of data in a unified data store** 72 | note: this is disabled by default; add `enable_caching=True` when initializing `NotionClient` to change it. 73 | 74 | - **Real-time reactive two-way data binding** 75 | fancy way of saying that changing Python object will update the Notion UI, and vice-versa. 76 | 77 | --- 78 | 79 | ![data binding example][data-binding-url] 80 | *(Example of the two-way data binding in action)* 81 |
82 | 83 | 84 | [Read more about Notion and the original notion-py package on Jamie's blog][introduction-url]. 85 | 86 | 87 | ## Usage 88 | 89 | ### Quickstart 90 | 91 | 92 | > **_NOTE:_** The latest version of **notion-py** requires Python 3.6 or greater. 93 | 94 | 95 | `pip install notion-py` 96 | 97 | ```Python 98 | from notion.client import NotionClient 99 | 100 | # Obtain the `token_v2` value by inspecting your browser 101 | # cookies on a logged-in (non-guest) session on Notion.so 102 | client = NotionClient(token_v2="123123...") 103 | 104 | # Replace this URL with the URL of the page you want to edit 105 | page = client.get_block("https://www.notion.so/myorg/Test-c0d20a71c0944985ae96e661ccc99821") 106 | 107 | print("The old title is:", page.title) 108 | 109 | # You can use Markdown! We convert on-the-fly 110 | # to Notion's internal formatted text data structure. 111 | page.title = "The title has now changed, and has *live-updated* in the browser!" 112 | ``` 113 | 114 | ## Getting the token_v2 115 | 116 | 1. Open [notion.so](https://notion.so) in your browser and log in. 117 | 2. Open up developer console ([quick tutorial the most common browsers][dev-tools-url]). 118 | 3. Find a list of cookies (Firefox: `Storage` -> `Cookies`, Chrome: `Application` -> `Cookies`). 119 | 4. Find the one named `token_v2` and copy its value (lengthy, 160ish characters hex string). 120 | 5. Save it somewhere safe and use it with notion-py! 121 | 122 | > **_NOTE:_** Keep the token in secure place and out of your repository! 123 | > This token when leaked can let anyone do anything on your notion account! 124 | 125 | 126 | ## Updating records 127 | 128 | We keep a local cache of all data that passes through. 129 | When you reference an attribute on a `Record` (basically 130 | any `Block`) we first look to that cache to retrieve the value. 131 | If it doesn't find it, it retrieves it from the server. 132 | You can also manually refresh the data for a `Record` 133 | by calling the `refresh()` method on it. 134 | 135 | By default (unless we instantiate `NotionClient` 136 | with `monitor=False`), we also subscribe to long-polling 137 | updates for any instantiated `Record`, so the local cache 138 | data for these `Records` should be automatically 139 | live-updated shortly after any data changes on the server. 140 | The long-polling happens in a background daemon thread. 141 | 142 | 143 | ## Concepts and notes 144 | 145 | - **The tables we currently support are `block`, `space`, 146 | `collection`, `collection_view`, and `notion_user`.** 147 | 148 | - **We map tables in the Notion database into Python classes** 149 | by subclassing `Record`, with each instance of a class 150 | representing a particular record. Some fields from the 151 | records (like `title` in the example above) have been 152 | mapped to model properties, allowing for easy, 153 | instantaneous read/write of the record. 154 | Other fields can be read with the `get` method, 155 | and written with the `set` method, but then you'll 156 | need to make sure to match the internal structures exactly. 157 | 158 | - **Data for all tables are stored in a central RecordStore** 159 | with the `Record` instances not storing state internally, 160 | but always referring to the data in the 161 | central `RecordStore`. 162 | Many API operations return updating versions of a large 163 | number of associated records, which we use to update 164 | the store, so the data in `Record` instances may sometimes 165 | update without being explicitly requested. 166 | You can also call the `refresh()` method on a `Record` 167 | to trigger an update, or pass `force_update=True` to 168 | methods like `get()`. 169 | 170 | - **The API doesn't have strong validation of most data** 171 | so be careful to maintain the structures Notion is expecting. 172 | You can view the full internal structure of a record by 173 | calling `myrecord.get()` with no arguments. 174 | 175 | - **When you call `client.get_block()`, you can pass in 176 | block ID, or the URL of a block** 177 | Note that pages themselves are just `blocks`, as are all 178 | the chunks of content on the page. You can get the URL 179 | for a block within a page by clicking "Copy Link" in the 180 | context menu for the block, and pass that URL 181 | into `get_block()` as well. 182 | 183 | 184 | 185 | ## Working on a Pull Request 186 | 187 | You'll need `git` and `python3` with `venv` module. 188 | 189 | 190 | Best way to start is to clone the repo and prepare the `.env` file. 191 | This step is optional but nice to have to create healthy python venv. 192 | 193 | ```bash 194 | git https://github.com/arturtamborski/notion-py 195 | 196 | cd notion-py 197 | 198 | cp .env.example .env 199 | vim .env 200 | ``` 201 | 202 | You should modify the variables as following: 203 | ```bash 204 | # see above for info on how to get it 205 | NOTION_TOKEN_V2="insert your token_v2 here" 206 | 207 | # used in smoke tests 208 | NOTION_PAGE_URL="insert URL from some notion page here" 209 | 210 | # set it to any level from python logging library 211 | NOTION_LOG_LEVEL="DEBUG" 212 | 213 | # the location for cache, defaults to current directory 214 | NOTION_DATA_DIR=".notion-py" 215 | ``` 216 | 217 | And then load that file (which will also create local venv): 218 | ```bash 219 | source .env 220 | ``` 221 | 222 | On top of that there's a handy toolbox provided to you via `Makefile`. 223 | Everything related to the development of the project relies heavily on 224 | the interface it provides. 225 | 226 | You can display all commands by running 227 | ```bash 228 | make help 229 | ``` 230 | 231 | Which should print a nice list of commands avaiable to you. 232 | These are compatible with the Github Actions (CI system), 233 | in fact the actions are using Makefile directly for formatting 234 | and other steps so everything that Github might show you 235 | under your Pull Request can be reproduced locally via Makefile. 236 | 237 | 238 | Also, there's one very handy shortcut that I'm using all the 239 | time when testing the library with smoke tests. 240 | 241 | This command will run a single test unit that you point at 242 | by passing an argument to `make try-smoke-test` like so: 243 | 244 | ```bash 245 | make try-smoke-test smoke_tests/test_workflow.py::test_workflow_1 246 | ``` 247 | 248 | That's super handy when you run some smoke tests and see the failed output: 249 | ``` 250 | ============================= short test summary info ============================= 251 | ERROR smoke_tests/block/test_basic.py::test_block - KeyboardInterrupt 252 | !!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!! 253 | !!!!!!!!!!!!!!!!!!!! _pytest.outcomes.Exit: Quitting debugger !!!!!!!!!!!!!!!!!!!!! 254 | ================================ 1 error in 32.90s ================================ 255 | make: *** [Makefile:84: try-smoke-test] Error 2 256 | ``` 257 | 258 | Notice that `ERROR smoke_tests/...test_basic.py::test_block` - just copy it over 259 | as a command argument and run it again - you'll run this and only this one test! 260 | 261 | ```bash 262 | make try-smoke-test smoke_tests/block/test_basic.py::test_block 263 | ``` 264 | 265 | 266 | 267 | 268 | ## Examples 269 | 270 |
271 | Click here to show or hide 272 | 273 | 274 | ### Example: Traversing the block tree 275 | 276 | ```Python 277 | for child in page.children: 278 | print(child.title) 279 | 280 | print(f"Parent of {page.id} is {page.parent.id}") 281 | ``` 282 | 283 | 284 | ### Example: Adding a new node 285 | 286 | ```Python 287 | from notion.block.basic import ToDoBlock 288 | 289 | todo = page.children.add_new(ToDoBlock, title="Something to get done") 290 | todo.checked = True 291 | ``` 292 | 293 | 294 | ### Example: Deleting nodes 295 | 296 | ```Python 297 | # soft-delete 298 | page.remove() 299 | 300 | # hard-delete 301 | page.remove(permanently=True) 302 | ``` 303 | 304 | 305 | ### Example: Create an embedded content type (iframe, video, etc) 306 | 307 | ```Python 308 | from notion.block.upload import VideoBlock 309 | 310 | video = page.children.add_new(VideoBlock, width=200) 311 | 312 | # sets "property.source" to the URL 313 | # and "format.display_source" to the embedly-converted URL 314 | video.set_source_url("https://www.youtube.com/watch?v=oHg5SJYRHA0") 315 | ``` 316 | 317 | 318 | ### Example: Create a new embedded collection view block 319 | 320 | ```Python 321 | from notion.block.collection.basic import CollectionViewBlock 322 | 323 | collection = client.get_collection("") # get an existing collection 324 | cvb = page.children.add_new(CollectionViewBlock, collection=collection) 325 | view = cvb.views.add_new(view_type="table") 326 | 327 | # Before the view can be browsed in Notion, 328 | # the filters and format options on the view should be set as desired. 329 | # 330 | # for example: 331 | # view.set("query", ...) 332 | # view.set("format.board_groups", ...) 333 | # view.set("format.board_properties", ...) 334 | ``` 335 | 336 | 337 | ### Example: Moving blocks around 338 | 339 | ```Python 340 | # move my block to after the video 341 | my_block.move_to(video, "after") 342 | 343 | # move my block to the end of otherblock's children 344 | my_block.move_to(otherblock, "last-child") 345 | 346 | # Note: you can also use "before" and "first-child" :) 347 | ``` 348 | 349 | 350 | ### Example: Subscribing to updates 351 | 352 | > **_NOTE:_** Notion -> Python automatic updating is 353 | > currently broken and hence disabled by default. 354 | > call `my_block.refresh()` to update, in the meantime, 355 | > while monitoring is being fixed. 356 | 357 | We can "watch" a `Record` so that we get a callback whenever 358 | it changes. Combined with the live-updating of records based 359 | on long-polling, this allows for a "reactive" design, where 360 | actions in our local application can be triggered in response 361 | to interactions with the Notion interface. 362 | 363 | ```Python 364 | # define a callback (all arguments are optional, just include the ones you care about) 365 | def my_callback(record, difference): 366 | print("The record's title is now:", record.title) 367 | print("Here's what was changed:\n", difference) 368 | 369 | # move my block to after the video 370 | my_block.add_callback(my_callback) 371 | ``` 372 | 373 | 374 | ### Example: Working with databases, aka "collections" (tables, boards, etc) 375 | 376 | Here's how things fit together: 377 | - Main container block: `CollectionViewBlock` (inline) / `CollectionViewPageBlock` (full-page) 378 | - `Collection` (holds the schema, and is parent to the database rows themselves) 379 | - `CollectionBlock` 380 | - `CollectionBlock` 381 | - ... (more database records) 382 | - `CollectionView` (holds filters/sort/etc about each specific view) 383 | 384 | For convenience, we automatically map the database 385 | "columns" (aka properties), based on the schema defined 386 | in the `Collection`, into getter/setter attributes 387 | on the `CollectionBlock` instances. 388 | 389 | The attribute name is a "slugified" version of the name of 390 | the column. So if you have a column named "Estimated value", 391 | you can read and write it via `myrowblock.estimated_value`. 392 | 393 | Some basic validation may be conducted, and it will be 394 | converted into the appropriate internal format. 395 | 396 | For columns of type "Person", we expect a `NotionUser` instance, 397 | or a list of them, and for a "Relation" we expect a singular/list 398 | of instances of a subclass of `Block`. 399 | 400 | ```Python 401 | # Access a database using the URL of the database page or the inline block 402 | cv = client.get_collection_view("https://www.notion.so/myorg/b9076...8b832?v=8de...8e1") 403 | 404 | # List all the records with "Bob" in them 405 | for row in cv.collection.get_rows(search="Bob"): 406 | print("We estimate the value of '{}' at {}".format(row.name, row.estimated_value)) 407 | 408 | # Add a new record 409 | row = cv.collection.add_row() 410 | row.name = "Just some data" 411 | row.is_confirmed = True 412 | row.estimated_value = 399 413 | row.files = ["https://www.birdlife.org/sites/default/files/styles/1600/public/slide.jpg"] 414 | row.person = client.current_user 415 | row.tags = ["A", "C"] 416 | row.where_to = "https://learningequality.org" 417 | 418 | # Run a filtered/sorted query using a view's default parameters 419 | result = cv.default_query().execute() 420 | for row in result: 421 | print(row) 422 | 423 | # Run an "aggregation" query 424 | aggregations = [{ 425 | "property": "estimated_value", 426 | "aggregator": "sum", 427 | "id": "total_value", 428 | }] 429 | result = cv.build_query(aggregate=aggregations).execute() 430 | print("Total estimated value:", result.get_aggregate("total_value")) 431 | 432 | # Run a "filtered" query (inspect network tab in browser for examples, on queryCollection calls) 433 | filters = { 434 | "filters": [{ 435 | "filter": { 436 | "value": { 437 | "type": "exact", 438 | "value": {"table": "notion_user", "id": client.current_user.id} 439 | }, 440 | "operator": "person_contains" 441 | }, 442 | "property": "assigned_to" 443 | }], 444 | "operator": "and" 445 | } 446 | result = cv.build_query(filter=filters).execute() 447 | print("Things assigned to me:", result) 448 | 449 | # Run a "sorted" query 450 | sorters = [{ 451 | "direction": "descending", 452 | "property": "estimated_value", 453 | }] 454 | result = cv.build_query(sort=sorters).execute() 455 | print("Sorted results, showing most valuable first:", result) 456 | ``` 457 | 458 | > **_NOTE:_**: You can combine `filter`, `aggregate`, and `sort`. 459 | > See more examples of queries by setting up complex views in Notion, 460 | > and then inspecting `cv.get("query")`. 461 | 462 | 463 | ### Example: Lock/Unlock A Page 464 | 465 | ```python 466 | from notion.client import NotionClient 467 | 468 | client = NotionClient(token_v2="123123...") 469 | 470 | # Replace this URL with the URL of the page you want to edit 471 | page = client.get_block("https://www.notion.so/myorg/Test-c0d20a71c0944985ae96e661ccc99821") 472 | 473 | # change_lock is a method accessible to every Block/Page in notion. 474 | # Pass True to lock a page and False to unlock it. 475 | page.change_lock(True) 476 | page.change_lock(False) 477 | ``` 478 | 479 | 480 |
481 |
482 | 483 | 484 | [documentation-url]: https://notion-py.readthedocs.io 485 | [package-url]: https://pypi.org/project/notion-py/ 486 | [check-formatting-url]: https://github.com/arturtamborski/notion-py/workflows/Check%20Code%20Formatting/badge.svg 487 | [run-unit-tests-url]: https://github.com/arturtamborski/notion-py/workflows/Run%20Unit%20Tests/badge.svg 488 | [upload-python-package-url]: https://github.com/arturtamborski/notion-py/workflows/Upload%20Python%20Package/badge.svg 489 | [run-smoke-tests-url]: https://github.com/arturtamborski/notion-py/workflows/Run%20Smoke%20Tests/badge.svg 490 | [code-style-url]: https://img.shields.io/badge/code%20style-black-000000 491 | [documentation-status-url]: https://readthedocs.org/projects/notion-py/badge/?version=latest 492 | [license-url]: https://img.shields.io/github/license/arturtamborski/notion-py 493 | [code-size-url]: https://img.shields.io/github/languages/code-size/arturtamborski/notion-py 494 | [downloads-rate-url]: https://img.shields.io/pypi/dm/notion-py.svg 495 | 496 | [introduction-url]: https://medium.com/@jamiealexandre/introducing-notion-py-an-unofficial-python-api-wrapper-for-notion-so-603700f92369 497 | [data-binding-url]: https://raw.githubusercontent.com/jamalex/notion-py/master/ezgif-3-a935fdcb7415.gif 498 | [dev-tools-url]: https://support.airtable.com/hc/en-us/articles/232313848-How-to-open-the-developer-console 499 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * Type hints are borken af 2 | * Most of the methods seem unused or not needed 3 | * Split exposed api from exposed props from notion 4 | * Move less important stuff to meta classes 5 | * Cloning pages hierarchically 6 | * Debounce cache-saving? 7 | * Support inline "user" and "page" links, and reminders, in markdown conversion 8 | * Utilities to support updating/creating collection schemas 9 | * Utilities to support updating/creating `collection_view` queries 10 | * Support for easily managing page permissions 11 | * Websocket support for live block cache updating 12 | * "Render full page to markdown" mode 13 | * "Import page from html" mode 14 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools 2 | wheel 3 | twine 4 | 5 | black 6 | pytest 7 | 8 | sphinx 9 | sphinx-rtd-theme 10 | sphinxcontrib-apidoc 11 | livereload 12 | 13 | pdbpp 14 | ipython 15 | -------------------------------------------------------------------------------- /docs/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # disclaimer: I know it's ugly but it gets the job done. 4 | # I could've used dev packages for bumping, but they 5 | # aren't as simple as this script and they need some 6 | # funny files to work with whereas I want to just 7 | # increment one digit in one file and that's it. 8 | # Same goes for rest of this script, the release 9 | # is a simple thing to do, but it's cumbersome 10 | # that's the only reason for this script to exist. 11 | # 12 | # you can assume that you've never seen this file, 13 | # it's private (as in for my own personal use) but it 14 | # happened to be shared publicly for your amusement :) 15 | 16 | set -Eeuo pipefail 17 | 18 | REPOSITORY="arturtamborski/notion-py" 19 | VERSION_FILE="notion/__init__.py" 20 | 21 | [[ "$1" != @(major|minor|patch) ]] && { 22 | echo "./$0 " 23 | exit 1 24 | } 25 | [[ "master" != "$(git branch | grep '\* ' | cut -c3-)" ]] && { 26 | echo "git says that you are not on master branch" 27 | echo "please checkout and run this script again :)" 28 | exit 1 29 | } 30 | git status | grep "not staged for commit" > /dev/null && { 31 | echo "git says that there are uncommitted changes" 32 | echo "please fix them and run this script again :)" 33 | exit 1 34 | } 35 | 36 | old_tag=$(git tag | tail -1) 37 | old_num=$(echo "$old_tag" | cut -c2-) 38 | 39 | major=$(echo "$old_num" | cut -d. -f1) 40 | minor=$(echo "$old_num" | cut -d. -f2) 41 | patch=$(echo "$old_num" | cut -d. -f3) 42 | 43 | [[ "$1" == "major" ]] && major=$((major + 1)) 44 | [[ "$1" == "minor" ]] && minor=$((minor + 1)) 45 | [[ "$1" == "patch" ]] && patch=$((patch + 1)) 46 | 47 | new_num="$major.$minor.$patch" 48 | new_tag="v$new_num" 49 | 50 | echo "bumping $old_num to $new_num" 51 | sed --in-place "s/$old_num/$new_num/" "$VERSION_FILE" 52 | 53 | echo "done" 54 | echo "committing and pushing to origin" 55 | git add "$VERSION_FILE" 56 | git commit -m "'Release: $new_tag'" 57 | git tag "$new_tag" 58 | 59 | git push origin master 60 | git push origin "$new_tag" 61 | 62 | echo "done" 63 | echo "creating release notes file" 64 | bash ./docs/gen-notes.sh > "'$new_tag.md'" 65 | 66 | echo "done" 67 | echo "publishing release notes on github" 68 | new_tag_sha=$(git show-ref --tags -s | tail -1) 69 | 70 | echo gh release create \ 71 | --repo "$REPOSITORY" \ 72 | --title "'Release: $new_tag'" \ 73 | --notes-file "'$new_tag.md'" \ 74 | --target "$new_tag_sha" \ 75 | --draft 76 | 77 | rm "'$new_tag.md'" 78 | echo "all done, have a nice day :)" 79 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import date 4 | 5 | # for import below 6 | sys.path.insert(0, os.path.abspath("..")) 7 | from notion import __name__, __author__, __version__ 8 | 9 | project = __name__ 10 | author = __author__ 11 | release = __version__ 12 | copyright = f"{date.today().year}, {__author__}" 13 | 14 | html_baseurl = "notion-py.readthedocs.io" 15 | html_theme = "sphinx_rtd_theme" 16 | master_doc = "index" 17 | 18 | extensions = [ 19 | "sphinx.ext.viewcode", 20 | "sphinx.ext.napoleon", 21 | "sphinx.ext.autodoc", 22 | "sphinx_rtd_theme", 23 | "sphinxcontrib.apidoc", 24 | ] 25 | 26 | napoleon_include_init_with_doc = True 27 | napoleon_google_docstring = False 28 | napoleon_use_param = True 29 | napoleon_use_rtype = True 30 | 31 | apidoc_output_dir = "api" 32 | apidoc_module_dir = "../notion" 33 | apidoc_extra_args = ["--force"] 34 | -------------------------------------------------------------------------------- /docs/gen-notes.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | refs=$(echo $(git show-ref --tags -s | tail -2) | sed 's/ /../') 6 | diff=$(git log --oneline "$refs") 7 | 8 | python3 < 12 | List of modules 13 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | TODO: write :) 5 | -------------------------------------------------------------------------------- /docs/serve.py: -------------------------------------------------------------------------------- 1 | from livereload import Server, shell 2 | 3 | server = Server() 4 | 5 | server.watch("docs/*.rst", shell("make docs")) 6 | server.serve(root="public/") 7 | -------------------------------------------------------------------------------- /notion/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.10" 2 | 3 | __name__ = "notion-py" 4 | __author__ = "Artur Tamborski" 5 | __author_email__ = "tamborskiartur@gmail.com" 6 | __description__ = "(Fork of) Unofficial Python API client for Notion.so" 7 | __url__ = "https://github.com/arturtamborski/notion-py" 8 | -------------------------------------------------------------------------------- /notion/block/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/notion/block/__init__.py -------------------------------------------------------------------------------- /notion/block/basic.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from notion.maps import ( 4 | property_map, 5 | plaintext_property_map, 6 | field_map, 7 | prefixed_field_map, 8 | nested_field_map, 9 | boolean_property_map, 10 | Mapper, 11 | ) 12 | from notion.record import Record 13 | from notion.settings import BASE_URL 14 | from notion.utils import get_by_path 15 | 16 | 17 | class Block(Record): 18 | """ 19 | Base class for every kind of notion block object. 20 | 21 | Most data in Notion is stored as a "block". That includes pages 22 | and all the individual elements within a page. These blocks have 23 | different types, and in some cases we create subclasses of this 24 | class to represent those types. 25 | 26 | Attributes on the `Block` are mapped to useful attributes of the 27 | server-side data structure, as properties, so you can get and set 28 | values on the API just by reading/writing attributes on these classes. 29 | 30 | We store a shared local cache on the `NotionClient` object 31 | of all block data, and reference that as needed from here. 32 | Data can be refreshed from the server using the `refresh` method. 33 | """ 34 | 35 | _table = "block" 36 | _type = "block" 37 | _str_fields = "type" 38 | 39 | # we'll mark it as an alias if we load the Block 40 | # as a child of a page that is not its parent 41 | _alias_parent = None 42 | _child_list_key = "content" 43 | 44 | type = field_map("type") 45 | alive = field_map("alive") 46 | 47 | def _convert_diff_to_changelist(self, difference, old_val, new_val): 48 | # TODO: cached property? 49 | mappers = {} 50 | for name in dir(self.__class__): 51 | field = getattr(self.__class__, name) 52 | if isinstance(field, Mapper): 53 | mappers[name] = field 54 | 55 | changed_fields = set() 56 | changes = [] 57 | remaining = [] 58 | content_changed = False 59 | 60 | for d in deepcopy(difference): 61 | operation, path, values = d 62 | 63 | # normalize path 64 | path = path if path else [] 65 | path = path.split(".") if isinstance(path, str) else path 66 | if operation in ["add", "remove"]: 67 | path.append(values[0][0]) 68 | while isinstance(path[-1], int): 69 | path.pop() 70 | path = ".".join(map(str, path)) 71 | 72 | # check whether it was content that changed 73 | if path == "content": 74 | content_changed = True 75 | continue 76 | 77 | # check whether the value changed matches 78 | # one of our mapped fields/properties 79 | fields = [ 80 | (name, field) 81 | for name, field in mappers.items() 82 | if path.startswith(field.path) 83 | ] 84 | if fields: 85 | changed_fields.add(fields[0]) 86 | continue 87 | 88 | remaining.append(d) 89 | 90 | if content_changed: 91 | old = deepcopy(old_val.get("content", [])) 92 | new = deepcopy(new_val.get("content", [])) 93 | 94 | # track what's been added and removed 95 | removed = set(old) - set(new) 96 | added = set(new) - set(old) 97 | for i in removed: 98 | changes.append(("content_removed", "content", i)) 99 | for i in added: 100 | changes.append(("content_added", "content", i)) 101 | 102 | # ignore the added/removed items, and see whether order has changed 103 | for i in removed: 104 | old.remove(i) 105 | for i in added: 106 | new.remove(i) 107 | if old != new: 108 | changes.append(("content_reordered", "content", (old, new))) 109 | 110 | for name, field in changed_fields: 111 | old = field.api_to_python(get_by_path(field.path, old_val)) 112 | new = field.api_to_python(get_by_path(field.path, new_val)) 113 | changes.append(("changed_field", name, (old, new))) 114 | 115 | return changes + super()._convert_diff_to_changelist( 116 | remaining, old_val, new_val 117 | ) 118 | 119 | def get_browseable_url(self) -> str: 120 | """ 121 | Return direct URL to given Block. 122 | 123 | 124 | Returns 125 | ------- 126 | str 127 | valid URL 128 | """ 129 | short_id = self.id.replace("-", "") 130 | 131 | if "page" in self._type: 132 | return BASE_URL + short_id 133 | else: 134 | return self.parent.get_browseable_url() + "#" + short_id 135 | 136 | def remove(self, permanently: bool = False): 137 | """ 138 | Remove the node from its parent, and mark it as inactive. 139 | 140 | This corresponds to what happens in the Notion UI when you 141 | delete a block. Note that it doesn't *actually* delete it, 142 | just orphan it, unless `permanently` is set to True, 143 | in which case we make an extra call to hard-delete. 144 | 145 | 146 | Arguments 147 | --------- 148 | permanently : bool, optional 149 | Whether or not to hard-delete the block. 150 | Defaults to False. 151 | """ 152 | if self.is_alias: 153 | # only remove it from the alias parent's content list 154 | return self._client.build_and_submit_transaction( 155 | record_id=self._alias_parent, 156 | path="content", 157 | args={"id": self.id}, 158 | command="listRemove", 159 | ) 160 | 161 | with self._client.as_atomic_transaction(): 162 | # Mark the block as inactive 163 | self._client.build_and_submit_transaction( 164 | record_id=self.id, path="", args={"alive": False}, command="update" 165 | ) 166 | 167 | # Remove the block's ID from a list on its parent, if needed 168 | if self.parent._child_list_key: 169 | self._client.build_and_submit_transaction( 170 | record_id=self.parent.id, 171 | path=self.parent._child_list_key, 172 | args={"id": self.id}, 173 | command="listRemove", 174 | table=self.parent._table, 175 | ) 176 | 177 | if permanently: 178 | data = {"blockIds": [self.id], "permanentlyDelete": True} 179 | self._client.post("deleteBlocks", data=data) 180 | del self._client._store._values["block"][self.id] 181 | 182 | def move_to(self, target_block: "Block", position="last-child"): 183 | if position not in ["first-child", "last-child", "before", "after"]: 184 | raise ValueError("Provided value for position is not valid.") 185 | 186 | if "child" in position: 187 | new_parent_id = target_block.id 188 | new_parent_table = "block" 189 | else: 190 | new_parent_id = target_block.get("parent_id") 191 | new_parent_table = target_block.get("parent_table") 192 | 193 | if position in ["first-child", "before"]: 194 | list_command = "listBefore" 195 | else: 196 | list_command = "listAfter" 197 | 198 | args = {"id": self.id} 199 | if position in ["before", "after"]: 200 | args[position] = target_block.id 201 | 202 | with self._client.as_atomic_transaction(): 203 | # First, remove the node, before we re-insert 204 | # and re-activate it at the target location 205 | self.remove() 206 | 207 | if not self.is_alias: 208 | # Set the parent_id of the moving block to the new parent, 209 | # and mark it as active again 210 | self._client.build_and_submit_transaction( 211 | record_id=self.id, 212 | path="", 213 | args={ 214 | "alive": True, 215 | "parent_id": new_parent_id, 216 | "parent_table": new_parent_table, 217 | }, 218 | command="update", 219 | ) 220 | else: 221 | self._alias_parent = new_parent_id 222 | 223 | # Add the moving block's ID to the "content" list of the new parent 224 | self._client.build_and_submit_transaction( 225 | record_id=new_parent_id, 226 | path="content", 227 | args=args, 228 | command=list_command, 229 | ) 230 | 231 | # update the local block cache to reflect the updates 232 | self._client.refresh_records( 233 | block=[ 234 | self.id, 235 | self.get("parent_id"), 236 | target_block.id, 237 | target_block.get("parent_id"), 238 | ] 239 | ) 240 | 241 | def change_lock(self, locked: bool): 242 | """ 243 | Set or free the lock according to the value passed in `locked`. 244 | 245 | 246 | Arguments 247 | --------- 248 | locked : bool 249 | Whether or not to lock the block. 250 | """ 251 | user_id = self._client.current_user.id 252 | args = {"block_locked": locked, "block_locked_by": user_id} 253 | 254 | with self._client.as_atomic_transaction(): 255 | self._client.build_and_submit_transaction( 256 | record_id=self.id, 257 | path="format", 258 | args=args, 259 | command="update", 260 | ) 261 | 262 | # update the local block cache to reflect the updates 263 | self._client.refresh_records(block=[self.id]) 264 | 265 | @property 266 | def children(self): 267 | """ 268 | Get block children. 269 | 270 | 271 | Returns 272 | ------- 273 | Children 274 | Children of this block. 275 | """ 276 | if not self._children: 277 | children_ids = self.get("content", []) 278 | self._client.refresh_records(block=children_ids) 279 | # TODO: can we do something about that without breaking 280 | # the current code layout? 281 | from notion.block.children import Children 282 | 283 | self._children = Children(parent=self) 284 | return self._children 285 | 286 | @property 287 | def is_alias(self): 288 | return self._alias_parent is not None 289 | 290 | @property 291 | def parent(self): 292 | parent_id = self._alias_parent 293 | parent_table = "block" 294 | 295 | if not self.is_alias: 296 | parent_id = self.get("parent_id") 297 | parent_table = self.get("parent_table") 298 | 299 | getter = getattr(self._client, f"get_{parent_table}") 300 | if getter: 301 | return getter(parent_id) 302 | 303 | return None 304 | 305 | 306 | class BasicBlock(Block): 307 | 308 | _type = "block" 309 | _str_fields = "title" 310 | 311 | title = property_map("title") 312 | title_plaintext = plaintext_property_map("title") 313 | color = field_map("format.block_color") 314 | 315 | 316 | class DividerBlock(Block): 317 | 318 | _type = "divider" 319 | 320 | 321 | class ColumnBlock(Block): 322 | """ 323 | Should be added as children of a ColumnListBlock. 324 | """ 325 | 326 | _type = "column" 327 | 328 | column_ratio = field_map("format.column_ratio") 329 | 330 | 331 | class ColumnListBlock(Block): 332 | """ 333 | Must contain only ColumnBlocks as children. 334 | """ 335 | 336 | _type = "column_list" 337 | 338 | def evenly_space_columns(self): 339 | with self._client.as_atomic_transaction(): 340 | for child in self.children: 341 | child.column_ratio = 1 / len(self.children) 342 | 343 | 344 | class PageBlock(BasicBlock): 345 | 346 | _type = "page" 347 | 348 | icon = prefixed_field_map("format.page_icon") 349 | cover = prefixed_field_map("format.page_cover") 350 | 351 | 352 | class TextBlock(BasicBlock): 353 | 354 | _type = "text" 355 | 356 | 357 | class CalloutBlock(BasicBlock): 358 | 359 | _type = "callout" 360 | 361 | icon = field_map("format.page_icon") 362 | 363 | 364 | class CodeBlock(BasicBlock): 365 | 366 | _type = "code" 367 | 368 | language = property_map("language") 369 | wrap = field_map("format.code_wrap") 370 | 371 | 372 | class LinkToPageBlock(BasicBlock): 373 | 374 | _type = "link_to_page" 375 | 376 | 377 | class EquationBlock(BasicBlock): 378 | 379 | _type = "equation" 380 | 381 | latex = nested_field_map("properties.title") 382 | 383 | 384 | class QuoteBlock(BasicBlock): 385 | 386 | _type = "quote" 387 | 388 | 389 | class ToDoBlock(BasicBlock): 390 | 391 | _type = "to_do" 392 | _str_fields = "checked" 393 | 394 | checked = boolean_property_map("checked") 395 | 396 | 397 | class ToggleBlock(BasicBlock): 398 | 399 | _type = "toggle" 400 | 401 | 402 | class HeaderBlock(BasicBlock): 403 | 404 | _type = "header" 405 | 406 | 407 | class SubHeaderBlock(BasicBlock): 408 | 409 | _type = "sub_header" 410 | 411 | 412 | class SubSubHeaderBlock(BasicBlock): 413 | 414 | _type = "sub_sub_header" 415 | 416 | 417 | class BulletedListBlock(BasicBlock): 418 | 419 | _type = "bulleted_list" 420 | 421 | 422 | class NumberedListBlock(BasicBlock): 423 | 424 | _type = "numbered_list" 425 | 426 | 427 | class FactoryBlock(BasicBlock): 428 | """ 429 | Also known as a "Template Button" 430 | 431 | The title is the button text, 432 | and the children are the templates to clone. 433 | """ 434 | 435 | _type = "factory" 436 | -------------------------------------------------------------------------------- /notion/block/children.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Union, Optional, List 3 | 4 | from notion.block.basic import Block 5 | from notion.logger import logger 6 | from notion.utils import extract_id 7 | 8 | 9 | class Children: 10 | 11 | _child_list_key = "content" 12 | 13 | def __init__(self, parent): 14 | self._parent = parent 15 | self._client = parent._client 16 | 17 | def __repr__(self): 18 | children = "\n" if len(self) else "" 19 | for child in self: 20 | children += f" {repr(child)},\n" 21 | 22 | return f"<{self.__class__.__name__} [{children}]>" 23 | 24 | def __len__(self): 25 | return len(self._content_list()) 26 | 27 | def __getitem__(self, key) -> Union[Optional[Block], List[Optional[Block]]]: 28 | result = self._content_list()[key] 29 | if not isinstance(result, list): 30 | return self._get_block(result) 31 | 32 | return [self._get_block(block_id) for block_id in result] 33 | 34 | def __delitem__(self, key): 35 | self._get_block(self._content_list()[key]).remove() 36 | 37 | def __iter__(self): 38 | return iter(self._get_block(bid) for bid in self._content_list()) 39 | 40 | def __reversed__(self): 41 | return reversed(list(self)) 42 | 43 | def __contains__(self, other: Union[Block, str]): 44 | return extract_id(other) in self._content_list() 45 | 46 | def _content_list(self) -> list: 47 | return self._parent.get(self._child_list_key) or [] 48 | 49 | def _get_block(self, url_or_id: str) -> Optional[Block]: 50 | # NOTE: this is needed because there seems to be a server-side 51 | # race condition with setting and getting data 52 | # (sometimes the data previously sent hasn't yet 53 | # propagated to all DB nodes, perhaps? it fails to load here) 54 | for i in range(20): 55 | block = self._client.get_block(url_or_id) 56 | if block: 57 | break 58 | time.sleep(0.1) 59 | else: 60 | return None 61 | 62 | if block.get("parent_id") != self._parent.id: 63 | block._alias_parent = self._parent.id 64 | 65 | return block 66 | 67 | def add_new( 68 | self, block: Block, child_list_key: str = None, **kwargs 69 | ) -> Optional[Block]: 70 | """ 71 | Create a new block, add it as the last child of this 72 | parent block, and return the corresponding Block instance. 73 | 74 | 75 | Arguments 76 | --------- 77 | block : Block 78 | Class of block to use. 79 | 80 | child_list_key : str, optional 81 | Defaults to None. 82 | 83 | 84 | Returns 85 | ------- 86 | Block 87 | Instance of added block. 88 | """ 89 | # determine the block type string from the Block class, if provided 90 | valid = isinstance(block, type) 91 | valid = valid and issubclass(block, Block) 92 | valid = valid and hasattr(block, "_type") 93 | if not valid: 94 | raise ValueError( 95 | "block argument must be a Block subclass with a _type attribute" 96 | ) 97 | 98 | block_id = self._client.create_record( 99 | table="block", 100 | parent=self._parent, 101 | type=block._type, 102 | child_list_key=child_list_key, 103 | ) 104 | 105 | block = self._get_block(block_id) 106 | 107 | if kwargs: 108 | with self._client.as_atomic_transaction(): 109 | for key, val in kwargs.items(): 110 | if hasattr(block, key): 111 | setattr(block, key, val) 112 | else: 113 | logger.warning( 114 | f"{block} does not have attribute '{key}'; skipping." 115 | ) 116 | 117 | return block 118 | 119 | def add_alias(self, block: Block) -> Optional[Block]: 120 | """ 121 | Adds an alias to the provided `block`, i.e. adds 122 | the block's ID to the parent's content list, 123 | but doesn't change the block's parent_id. 124 | 125 | 126 | Arguments 127 | --------- 128 | block : Block 129 | Instance of block to alias. 130 | 131 | 132 | Returns 133 | ------- 134 | Block 135 | Aliased block. 136 | """ 137 | 138 | self._client.build_and_submit_transaction( 139 | record_id=self._parent.id, 140 | path=self._child_list_key, 141 | args={"id": block.id}, 142 | command="listAfter", 143 | ) 144 | 145 | return self._get_block(block.id) 146 | 147 | def filter(self, block_type: Union[Block, str]) -> list: 148 | """ 149 | Get list of children of particular type. 150 | 151 | 152 | Arguments 153 | --------- 154 | block_type : Block or str 155 | Block type to filter on. 156 | Either a Block or block type as str. 157 | 158 | 159 | Returns 160 | ------- 161 | list 162 | List of blocks. 163 | """ 164 | if not isinstance(block_type, str): 165 | block_type = block_type._type 166 | 167 | return [kid for kid in self if kid._type == block_type] 168 | 169 | 170 | class Templates(Children): 171 | """ 172 | Templates 173 | 174 | TODO: what? what does that even mean to user? 175 | """ 176 | 177 | _child_list_key = "template_pages" 178 | 179 | def add_new(self, **kwargs) -> Optional[Block]: 180 | kwargs["block_type"] = "page" 181 | kwargs["child_list_key"] = self._child_list_key 182 | kwargs["is_template"] = True 183 | 184 | return super().add_new(**kwargs) 185 | -------------------------------------------------------------------------------- /notion/block/collection/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/notion/block/collection/__init__.py -------------------------------------------------------------------------------- /notion/block/collection/basic.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from notion.block.basic import PageBlock, Block 4 | from notion.block.children import Templates 5 | from notion.block.collection.media import CollectionViewBlock 6 | from notion.block.collection.query import CollectionQuery 7 | from notion.block.collection.view import CalendarView 8 | from notion.converter import PythonToNotionConverter, NotionToPythonConverter 9 | from notion.maps import markdown_field_map, field_map 10 | from notion.utils import ( 11 | slugify, 12 | ) 13 | 14 | 15 | class CollectionBlock(Block): 16 | """ 17 | Collection Block. 18 | """ 19 | 20 | _type = "collection" 21 | _table = "collection" 22 | _str_fields = "name" 23 | 24 | cover = field_map("cover") 25 | name = markdown_field_map("name") 26 | description = markdown_field_map("description") 27 | 28 | def __init__(self, *args, **kwargs): 29 | super().__init__(*args, **kwargs) 30 | self._templates = None 31 | 32 | def _convert_diff_to_changelist(self, difference, old_val, new_val): 33 | changes = [] 34 | remaining = [] 35 | 36 | for operation, path, values in difference: 37 | if path == "rows": 38 | changes.append((operation, path, values)) 39 | else: 40 | remaining.append((operation, path, values)) 41 | 42 | return changes + super()._convert_diff_to_changelist( 43 | remaining, old_val, new_val 44 | ) 45 | 46 | def _get_a_collection_view(self): 47 | """ 48 | Get an arbitrary collection view for this collection, to allow querying. 49 | """ 50 | parent = self.parent 51 | assert isinstance(parent, CollectionViewBlock) 52 | assert len(parent.views) > 0 53 | return parent.views[0] 54 | 55 | def get_schema_properties(self) -> list: 56 | """ 57 | Fetch a flattened list of all properties in the collection's schema. 58 | 59 | 60 | Returns 61 | ------- 62 | list 63 | All properties. 64 | """ 65 | properties = [] 66 | 67 | for block_id, item in self.get("schema").items(): 68 | slug = slugify(item["name"]) 69 | properties.append({"id": block_id, "slug": slug, **item}) 70 | 71 | return properties 72 | 73 | def get_schema_property(self, identifier: str) -> Optional[dict]: 74 | """ 75 | Look up a property in the collection's schema 76 | by "property id" (generally a 4-char string), 77 | or name (human-readable -- there may be duplicates 78 | so we pick the first match we find). 79 | 80 | 81 | Attributes 82 | ---------- 83 | identifier : str 84 | Value used for searching the prop. 85 | Can be set to ID, slug or title (if property type is also title). 86 | 87 | 88 | Returns 89 | ------- 90 | dict, optional 91 | Schema of the property if found, or None. 92 | """ 93 | for prop in self.get_schema_properties(): 94 | if identifier == prop["id"] or slugify(identifier) == prop["slug"]: 95 | return prop 96 | if identifier == "title" and prop["type"] == "title": 97 | return prop 98 | return None 99 | 100 | def add_row(self, update_views=True, **kwargs) -> "CollectionRowBlock": 101 | """ 102 | Create a new empty CollectionRowBlock 103 | under this collection, and return the instance. 104 | 105 | 106 | Arguments 107 | --------- 108 | update_views : bool, optional 109 | Whether or not to update the views after 110 | adding the row to Collection. 111 | Defaults to True. 112 | 113 | kwargs : dict, optional 114 | Additional pairs of keys and values set in 115 | newly created CollectionRowBlock. 116 | Defaults to empty dict() 117 | 118 | 119 | Returns 120 | ------- 121 | CollectionRowBlock 122 | Added row. 123 | """ 124 | 125 | row_id = self._client.create_record("block", self, type="page") 126 | row = CollectionRowBlock(self._client, row_id) 127 | 128 | with self._client.as_atomic_transaction(): 129 | for key, val in kwargs.items(): 130 | setattr(row, key, val) 131 | if update_views: 132 | # make sure the new record is inserted at the end of each view 133 | for view in self.parent.views: 134 | # TODO: why we skip CalendarView? can we remove that 'if'? 135 | if not view or isinstance(view, CalendarView): 136 | continue 137 | view.set("page_sort", view.get("page_sort", []) + [row_id]) 138 | 139 | return row 140 | 141 | def query(self, **kwargs): 142 | """ 143 | Run a query inline and return the results. 144 | 145 | 146 | Returns 147 | ------- 148 | CollectionQueryResult 149 | Result of passed query. 150 | """ 151 | return CollectionQuery(self, self._get_a_collection_view(), **kwargs).execute() 152 | 153 | def get_rows(self, **kwargs): 154 | """ 155 | Get all rows from a collection. 156 | 157 | 158 | Returns 159 | ------- 160 | CollectionQueryResult 161 | All rows. 162 | """ 163 | return self.query(**kwargs) 164 | 165 | @property 166 | def templates(self) -> Templates: 167 | if not self._templates: 168 | template_pages = self.get("template_pages", []) 169 | self._client.refresh_records(block=template_pages) 170 | self._templates = Templates(parent=self) 171 | 172 | return self._templates 173 | 174 | @property 175 | def parent(self): 176 | """ 177 | Get parent block. 178 | 179 | 180 | Returns 181 | ------- 182 | Block 183 | Parent block. 184 | """ 185 | assert self.get("parent_table") == "block" 186 | return self._client.get_block(self.get("parent_id")) 187 | 188 | 189 | class CollectionRowBlock(PageBlock): 190 | """ 191 | Collection Row Block. 192 | """ 193 | 194 | def __getattr__(self, attname): 195 | return self.get_property(attname) 196 | 197 | def __setattr__(self, name, value): 198 | if name.startswith("_"): 199 | # we only allow setting of new non-property 200 | # attributes that start with "_" 201 | super().__setattr__(name, value) 202 | return 203 | 204 | slugs = self._get_property_slugs() 205 | if name in slugs: 206 | self.set_property(name, value) 207 | return 208 | 209 | slugged_name = slugify(name) 210 | if slugged_name in slugs: 211 | self.set_property(slugged_name, value) 212 | return 213 | 214 | if hasattr(self, name): 215 | super().__setattr__(name, value) 216 | return 217 | 218 | raise AttributeError(f"Unknown property: '{name}'") 219 | 220 | def __dir__(self): 221 | return self._get_property_slugs() + dir(super()) 222 | 223 | def _get_property_slugs(self): 224 | slugs = [prop["slug"] for prop in self.schema] 225 | if "title" not in slugs: 226 | slugs.append("title") 227 | return slugs 228 | 229 | def _get_property(self, name): 230 | prop = self.collection.get_schema_property(name) 231 | if prop is None: 232 | raise AttributeError(f"Object does not have property '{name}'") 233 | 234 | prop_id = prop["id"] 235 | value = self.get(f"properties.{prop_id}") 236 | return value, prop 237 | 238 | def _convert_diff_to_changelist(self, difference, old_val, new_val): 239 | changed_props = set() 240 | changes = [] 241 | remaining = [] 242 | 243 | for d in difference: 244 | operation, path, values = d 245 | path = path.split(".") if isinstance(path, str) else path 246 | if path and path[0] == "properties": 247 | if len(path): 248 | changed_props.add(path[1]) 249 | else: 250 | for item in values: 251 | changed_props.add(item[0]) 252 | else: 253 | remaining.append(d) 254 | 255 | for prop_id in changed_props: 256 | prop = self.collection.get_schema_property(prop_id) 257 | old = self._convert_notion_to_python( 258 | old_val.get("properties", {}).get(prop_id), prop 259 | ) 260 | new = self._convert_notion_to_python( 261 | new_val.get("properties", {}).get(prop_id), prop 262 | ) 263 | changes.append(("prop_changed", prop["slug"], (old, new))) 264 | 265 | return changes + super()._convert_diff_to_changelist( 266 | remaining, old_val, new_val 267 | ) 268 | 269 | def _convert_mentioned_pages_to_python(self, value, prop): 270 | if not prop["type"] in ["title", "text"]: 271 | raise TypeError( 272 | "The property must be an title or text to convert mentioned pages to Python." 273 | ) 274 | 275 | pages = [] 276 | for i, part in enumerate(value): 277 | if len(part) == 2: 278 | for format in part[1]: 279 | if "p" in format: 280 | pages.append(self._client.get_block(format[1])) 281 | 282 | return pages 283 | 284 | def _convert_notion_to_python(self, val, prop): 285 | _, value = NotionToPythonConverter.convert( 286 | name="", value=val, prop=prop, block=self 287 | ) 288 | return value 289 | 290 | def _convert_python_to_notion(self, val, prop, name=""): 291 | return PythonToNotionConverter.convert( 292 | name=name, value=val, prop=prop, block=self 293 | ) 294 | 295 | def get_property(self, name): 296 | return self._convert_notion_to_python(*self._get_property(name)) 297 | 298 | def get_all_properties(self): 299 | props = {} 300 | for prop in self.schema: 301 | prop_id = slugify(prop["name"]) 302 | props[prop_id] = self.get_property(prop_id) 303 | 304 | return props 305 | 306 | def set_property(self, name, value): 307 | _, prop = self._get_property(name) 308 | self.set(*self._convert_python_to_notion(value, prop, name)) 309 | 310 | def get_mentioned_pages_on_property(self, name): 311 | return self._convert_mentioned_pages_to_python(*self._get_property(name)) 312 | 313 | @property 314 | def is_template(self): 315 | return self.get("is_template") 316 | 317 | @property 318 | def collection(self): 319 | return self._client.get_collection(self.get("parent_id")) 320 | 321 | @property 322 | def schema(self): 323 | props = self.collection.get_schema_properties() 324 | return [p for p in props if p["type"] not in ["formula", "rollup"]] 325 | 326 | 327 | class TemplateBlock(CollectionRowBlock): 328 | """ 329 | Template block. 330 | """ 331 | 332 | _type = "template" 333 | 334 | @property 335 | def is_template(self): 336 | return self.get("is_template") 337 | 338 | @is_template.setter 339 | def is_template(self, val): 340 | if not val: 341 | raise ValueError("TemplateBlock must have 'is_template' set to True.") 342 | 343 | self.set("is_template", True) 344 | -------------------------------------------------------------------------------- /notion/block/collection/children.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from notion.block.children import Children 4 | 5 | 6 | class CollectionViewBlockViews(Children): 7 | """ 8 | Collection View Block Views. 9 | """ 10 | 11 | _child_list_key = "view_ids" 12 | 13 | def _get_block(self, view_id): 14 | view = self._client.get_collection_view( 15 | view_id, collection=self._parent.collection 16 | ) 17 | 18 | i = 0 19 | while view is None: 20 | i += 1 21 | if i > 20: 22 | return None 23 | time.sleep(0.1) 24 | view = self._client.get_collection_view( 25 | view_id, collection=self._parent.collection 26 | ) 27 | 28 | return view 29 | 30 | # TODO: why this is not aligned? 31 | def add_new(self, view_type="table"): 32 | if not self._parent.collection: 33 | raise Exception( 34 | "Collection view block does not have an " 35 | f"associated collection: {self._parent}" 36 | ) 37 | 38 | record_id = self._client.create_record( 39 | table="collection_view", parent=self._parent, type=view_type 40 | ) 41 | view = self._client.get_collection_view( 42 | record_id, collection=self._parent.collection 43 | ) 44 | view.set("collection_id", self._parent.collection.id) 45 | views = self._parent.get(CollectionViewBlockViews._child_list_key, []) 46 | views.append(view.id) 47 | self._parent.set(CollectionViewBlockViews._child_list_key, views) 48 | 49 | return view 50 | -------------------------------------------------------------------------------- /notion/block/collection/common.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from datetime import datetime 3 | 4 | from tzlocal import get_localzone 5 | 6 | 7 | def _normalize_prop_name(prop_name, collection): 8 | if not prop_name: 9 | return "" 10 | 11 | return collection.get_schema_property(prop_name).get("id", "") 12 | 13 | 14 | def _normalize_query_data(data, collection, recursive=False): 15 | if not recursive: 16 | data = deepcopy(data) 17 | 18 | if isinstance(data, list): 19 | return [ 20 | _normalize_query_data(item, collection, recursive=True) for item in data 21 | ] 22 | 23 | if isinstance(data, dict): 24 | # convert slugs to property ids 25 | if "property" in data: 26 | data["property"] = _normalize_prop_name(data["property"], collection) 27 | 28 | # convert any instantiated objects into their ids 29 | if "value" in data and hasattr(data["value"], "id"): 30 | data["value"] = data["value"].id 31 | 32 | for key in data: 33 | data[key] = _normalize_query_data(data[key], collection, recursive=True) 34 | 35 | return data 36 | 37 | 38 | class NotionDate: 39 | 40 | start = None 41 | end = None 42 | timezone = None 43 | reminder = None 44 | 45 | def __init__(self, start, end=None, timezone=None, reminder=None): 46 | self.start = start 47 | self.end = end 48 | self.timezone = timezone 49 | self.reminder = reminder 50 | 51 | @classmethod 52 | def _parse_datetime(cls, date_str, time_str): 53 | if not date_str: 54 | return None 55 | if time_str: 56 | return datetime.strptime(date_str + " " + time_str, "%Y-%m-%d %H:%M") 57 | else: 58 | return datetime.strptime(date_str, "%Y-%m-%d").date() 59 | 60 | def _format_datetime(self, date_or_datetime): 61 | if not date_or_datetime: 62 | return None, None 63 | if isinstance(date_or_datetime, datetime): 64 | return ( 65 | date_or_datetime.strftime("%Y-%m-%d"), 66 | date_or_datetime.strftime("%H:%M"), 67 | ) 68 | else: 69 | return date_or_datetime.strftime("%Y-%m-%d"), None 70 | 71 | def type(self): 72 | name = "date" 73 | if isinstance(self.start, datetime): 74 | name += "time" 75 | if self.end: 76 | name += "range" 77 | return name 78 | 79 | @classmethod 80 | def from_notion(cls, obj): 81 | if isinstance(obj, dict): 82 | data = obj 83 | elif isinstance(obj, list): 84 | data = obj[0][1][0][1] 85 | else: 86 | return None 87 | start = cls._parse_datetime(data.get("start_date"), data.get("start_time")) 88 | end = cls._parse_datetime(data.get("end_date"), data.get("end_time")) 89 | timezone = data.get("timezone") 90 | reminder = data.get("reminder") 91 | return cls(start, end=end, timezone=timezone, reminder=reminder) 92 | 93 | def to_notion(self): 94 | 95 | if self.end: 96 | self.start, self.end = sorted([self.start, self.end]) 97 | 98 | start_date, start_time = self._format_datetime(self.start) 99 | end_date, end_time = self._format_datetime(self.end) 100 | 101 | if not start_date: 102 | return [] 103 | 104 | data = {"type": self.type(), "start_date": start_date} 105 | 106 | if end_date: 107 | data["end_date"] = end_date 108 | 109 | if "time" in data["type"]: 110 | data["time_zone"] = str(self.timezone or get_localzone()) 111 | data["start_time"] = start_time or "00:00" 112 | if end_date: 113 | data["end_time"] = end_time or "00:00" 114 | 115 | if self.reminder: 116 | data["reminder"] = self.reminder 117 | 118 | return [["‣", [["d", data]]]] 119 | -------------------------------------------------------------------------------- /notion/block/collection/media.py: -------------------------------------------------------------------------------- 1 | from notion.block.collection.children import CollectionViewBlockViews 2 | from notion.block.media import MediaBlock 3 | from notion.maps import prefixed_field_map 4 | 5 | 6 | class CollectionViewBlock(MediaBlock): 7 | """ 8 | Collection View Block. 9 | """ 10 | 11 | _type = "collection_view" 12 | _str_fields = "title", "collection" 13 | 14 | @property 15 | def views(self): 16 | if not hasattr(self, "_views"): 17 | self._views = CollectionViewBlockViews(parent=self) 18 | 19 | return self._views 20 | 21 | @property 22 | def collection(self): 23 | collection_id = self.get("collection_id") 24 | 25 | if not collection_id: 26 | return None 27 | 28 | if not hasattr(self, "_collection"): 29 | self._collection = self._client.get_collection(collection_id) 30 | 31 | return self._collection 32 | 33 | @collection.setter 34 | def collection(self, val): 35 | if hasattr(self, "_collection"): 36 | del self._collection 37 | 38 | self.set("collection_id", val.id) 39 | 40 | @property 41 | def title(self): 42 | if not hasattr(self, "_collection"): 43 | return "" 44 | 45 | return self.collection.name 46 | 47 | @title.setter 48 | def title(self, val): 49 | self.collection.name = val 50 | 51 | @property 52 | def description(self): 53 | if not hasattr(self, "_collection"): 54 | return "" 55 | 56 | return self.collection.description 57 | 58 | @description.setter 59 | def description(self, val): 60 | self.collection.description = val 61 | 62 | 63 | class CollectionViewPageBlock(CollectionViewBlock): 64 | """ 65 | Full Page Collection View Block. 66 | """ 67 | 68 | _type = "collection_view_page" 69 | 70 | icon = prefixed_field_map("format.page_icon") 71 | cover = prefixed_field_map("format.page_cover") 72 | 73 | 74 | class LinkToCollectionBlock(MediaBlock): 75 | """ 76 | Link To Collection. 77 | """ 78 | 79 | _type = "link_to_collection" 80 | # TODO: add custom fields 81 | -------------------------------------------------------------------------------- /notion/block/collection/query.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from notion.block.basic import Block 4 | from notion.block.collection.common import _normalize_query_data, _normalize_prop_name 5 | from notion.utils import extract_id 6 | from notion.block.types import get_collection_query_result_type 7 | 8 | 9 | class CollectionQuery: 10 | """ 11 | Collection Query. 12 | """ 13 | 14 | def __init__( 15 | self, 16 | collection, 17 | collection_view, 18 | search="", 19 | type="table", 20 | aggregate=[], 21 | aggregations=[], 22 | filter=[], 23 | sort=[], 24 | calendar_by="", 25 | group_by="", 26 | ): 27 | # TODO: replace all these arguments with something sane 28 | if aggregate and aggregations: 29 | raise ValueError( 30 | "Use only one of `aggregate` or `aggregations` (old vs new format)" 31 | ) 32 | 33 | self.collection = collection 34 | self.collection_view = collection_view 35 | self.search = search 36 | self.type = type 37 | self.aggregate = _normalize_query_data(aggregate, collection) 38 | self.aggregations = _normalize_query_data(aggregations, collection) 39 | self.filter = _normalize_query_data(filter, collection) 40 | self.sort = _normalize_query_data(sort, collection) 41 | self.calendar_by = _normalize_prop_name(calendar_by, collection) 42 | self.group_by = _normalize_prop_name(group_by, collection) 43 | self._client = collection._client 44 | 45 | def execute(self) -> "CollectionQueryResult": 46 | """ 47 | Execute the query. 48 | 49 | 50 | Returns 51 | ------- 52 | CollectionQueryResult 53 | Result of the query. 54 | """ 55 | 56 | klass = get_collection_query_result_type(self.type) 57 | 58 | return klass( 59 | self.collection, 60 | self._client._store.call_query_collection( 61 | collection_id=self.collection.id, 62 | collection_view_id=self.collection_view.id, 63 | search=self.search, 64 | type=self.type, 65 | aggregate=self.aggregate, 66 | aggregations=self.aggregations, 67 | filter=self.filter, 68 | sort=self.sort, 69 | calendar_by=self.calendar_by, 70 | group_by=self.group_by, 71 | ), 72 | self, 73 | ) 74 | 75 | 76 | class CollectionQueryResult: 77 | """ 78 | Collection Query Result. 79 | """ 80 | 81 | _type = "" 82 | 83 | def __init__(self, collection, result, query: CollectionQuery): 84 | self._block_ids = self._get_block_ids(result) 85 | self.collection = collection 86 | self.query = query 87 | self.aggregates = result.get("aggregationResults", []) 88 | self.aggregate_ids = [ 89 | agg.get("id") for agg in (query.aggregate or query.aggregations) 90 | ] 91 | 92 | def __repr__(self) -> str: 93 | children = "\n" if len(self) else "" 94 | for child in self: 95 | children += f" {repr(child)},\n" 96 | 97 | return f"<{self.__class__.__name__} [\n{children}]>" 98 | 99 | def __len__(self) -> int: 100 | return len(self._block_ids) 101 | 102 | def __getitem__(self, key): 103 | return list(iter(self))[key] 104 | 105 | def __iter__(self): 106 | return iter(self._get_block(bid) for bid in self._block_ids) 107 | 108 | def __reversed__(self): 109 | return reversed(list(self)) 110 | 111 | def __contains__(self, other: Union[Block, str]) -> bool: 112 | return extract_id(other) in self._block_ids 113 | 114 | def _get_block_ids(self, result: dict) -> list: 115 | return result["blockIds"] 116 | 117 | def _get_block(self, block_id: str): 118 | from notion.block.collection.basic import CollectionRowBlock 119 | 120 | block = CollectionRowBlock(self.collection._client, block_id) 121 | # TODO: wtf? pass it as argument? 122 | block.__dict__["collection"] = self.collection 123 | return block 124 | 125 | def get_aggregate(self, block_id: str): 126 | for agg_id, agg in zip(self.aggregate_ids, self.aggregates): 127 | if block_id == agg_id: 128 | return agg["value"] 129 | return None 130 | 131 | 132 | class CalendarQueryResult(CollectionQueryResult): 133 | 134 | _type = "calendar" 135 | 136 | def _get_block_ids(self, result: dict) -> list: 137 | return [w["items"] for w in result["weeks"]] 138 | 139 | 140 | class TableQueryResult(CollectionQueryResult): 141 | 142 | _type = "table" 143 | 144 | 145 | class BoardQueryResult(CollectionQueryResult): 146 | 147 | _type = "board" 148 | 149 | 150 | class ListQueryResult(CollectionQueryResult): 151 | 152 | _type = "list" 153 | 154 | 155 | class GalleryQueryResult(CollectionQueryResult): 156 | 157 | _type = "gallery" 158 | -------------------------------------------------------------------------------- /notion/block/collection/view.py: -------------------------------------------------------------------------------- 1 | from notion.block.collection.query import CollectionQuery 2 | from notion.maps import field_map 3 | from notion.record import Record 4 | 5 | 6 | class CollectionView(Record): 7 | """ 8 | A "view" is a particular visualization of a collection, 9 | with a "type" (board, table, list, etc) and filters, sort, etc. 10 | """ 11 | 12 | _type = "collection_view" 13 | _table = "collection_view" 14 | 15 | name = field_map("name") 16 | type = field_map("type") 17 | 18 | def __init__(self, *args, collection, **kwargs): 19 | super().__init__(*args, **kwargs) 20 | self.collection = collection 21 | 22 | def build_query(self, **kwargs) -> CollectionQuery: 23 | return CollectionQuery( 24 | collection=self.collection, collection_view=self, **kwargs 25 | ) 26 | 27 | def default_query(self) -> CollectionQuery: 28 | """ 29 | Return default query. 30 | """ 31 | return self.build_query(**self.get("query", {})) 32 | 33 | @property 34 | def parent(self): 35 | return self._client.get_block(self.get("parent_id")) 36 | 37 | 38 | class CalendarView(CollectionView): 39 | 40 | _type = "calendar" 41 | 42 | def build_query(self, **kwargs): 43 | data = self._client.get_record_data("collection_view", self._id) 44 | calendar_by = data["query2"]["calendar_by"] 45 | return super().build_query(calendar_by=calendar_by, **kwargs) 46 | 47 | 48 | class BoardView(CollectionView): 49 | 50 | _type = "board" 51 | 52 | group_by = field_map("query.group_by") 53 | 54 | 55 | class TableView(CollectionView): 56 | 57 | _type = "table" 58 | 59 | 60 | class ListView(CollectionView): 61 | 62 | _type = "list" 63 | 64 | 65 | class GalleryView(CollectionView): 66 | 67 | _type = "gallery" 68 | -------------------------------------------------------------------------------- /notion/block/database.py: -------------------------------------------------------------------------------- 1 | # from notion.block.common import Block 2 | # 3 | # 4 | # class LinkedDatabaseBlock(Block): 5 | # pass 6 | # 7 | # 8 | # class BoardDatabaseBlock(Block): 9 | # pass 10 | # 11 | # 12 | # class BoardInlineDatabaseBlock(Block): 13 | # pass 14 | # 15 | # 16 | # class CalendarDatabaseBlock(Block): 17 | # pass 18 | # 19 | # 20 | # class CalendarInlineDatabaseBlock(Block): 21 | # pass 22 | # 23 | # 24 | # class GalleryDatabaseBlock(Block): 25 | # pass 26 | # 27 | # 28 | # class GalleryInlineDatabaseBlock(Block): 29 | # pass 30 | # 31 | # 32 | # class ListDatabaseBlock(Block): 33 | # pass 34 | # 35 | # 36 | # class ListInlineDatabaseBlock(Block): 37 | # pass 38 | # 39 | # 40 | # class TableDatabaseBlock(Block): 41 | # pass 42 | # 43 | # 44 | # class TableInlineDatabaseBlock(Block): 45 | # pass 46 | # 47 | -------------------------------------------------------------------------------- /notion/block/embed.py: -------------------------------------------------------------------------------- 1 | from notion.block.media import MediaBlock 2 | from notion.maps import ( 3 | field_map, 4 | prefixed_property_map, 5 | prefixed_field_map, 6 | property_map, 7 | ) 8 | from notion.utils import get_embed_link, remove_signed_prefix_as_needed 9 | 10 | 11 | class EmbedBlock(MediaBlock): 12 | """ 13 | Embed Block. 14 | """ 15 | 16 | _type = "embed" 17 | _str_fields = "source" 18 | 19 | display_source = prefixed_field_map("format.display_source") 20 | source = prefixed_property_map("source") 21 | height = field_map("format.block_height") 22 | width = field_map("format.block_width") 23 | full_width = field_map("format.block_full_width") 24 | page_width = field_map("format.block_page_width") 25 | 26 | def set_source_url(self, url: str): 27 | self.source = remove_signed_prefix_as_needed(url) 28 | self.display_source = get_embed_link(self.source, self._client) 29 | 30 | 31 | class BookmarkBlock(EmbedBlock): 32 | """ 33 | Bookmark Block. 34 | """ 35 | 36 | _type = "bookmark" 37 | _str_fields = "source", "title" 38 | 39 | bookmark_cover = field_map("format.bookmark_cover") 40 | bookmark_icon = field_map("format.bookmark_icon") 41 | description = property_map("description") 42 | link = property_map("link") 43 | title = property_map("title") 44 | 45 | def set_new_link(self, link: str): 46 | data = {"blockId": self.id, "url": link} 47 | self._client.post("setBookmarkMetadata", data) 48 | self.refresh() 49 | 50 | 51 | class AbstractBlock(EmbedBlock): 52 | """ 53 | Abstract Block for abstract.com 54 | """ 55 | 56 | _type = "abstract" 57 | 58 | 59 | class FramerBlock(EmbedBlock): 60 | """ 61 | Framer Block for framer.com 62 | """ 63 | 64 | _type = "framer" 65 | 66 | 67 | class TweetBlock(EmbedBlock): 68 | """ 69 | Tweet Block for twitter.com 70 | """ 71 | 72 | _type = "tweet" 73 | 74 | 75 | class GistBlock(EmbedBlock): 76 | """ 77 | Gist Block for gist.github.com 78 | """ 79 | 80 | _type = "gist" 81 | 82 | 83 | class DriveBlock(EmbedBlock): 84 | """ 85 | Drive Block for drive.google.com 86 | """ 87 | 88 | _type = "drive" 89 | 90 | 91 | class FigmaBlock(EmbedBlock): 92 | """ 93 | Figma Block for figma.io 94 | """ 95 | 96 | _type = "figma" 97 | 98 | 99 | class LoomBlock(EmbedBlock): 100 | """ 101 | Loom Block for loom.com 102 | """ 103 | 104 | _type = "loom" 105 | 106 | 107 | class MiroBlock(EmbedBlock): 108 | """ 109 | Miro Block for miro.com 110 | """ 111 | 112 | _type = "miro" 113 | 114 | 115 | class TypeformBlock(EmbedBlock): 116 | """ 117 | Typeform Block for typeform.com 118 | """ 119 | 120 | _type = "typeform" 121 | 122 | 123 | class CodepenBlock(EmbedBlock): 124 | """ 125 | Codepen Block for codepen.io 126 | """ 127 | 128 | _type = "codepen" 129 | 130 | 131 | class MapsBlock(EmbedBlock): 132 | """ 133 | Maps Block for maps.google.com 134 | """ 135 | 136 | _type = "maps" 137 | 138 | 139 | class InvisionBlock(EmbedBlock): 140 | """ 141 | Invision Block for invisionapp.com 142 | """ 143 | 144 | _type = "invision" 145 | 146 | 147 | class WhimsicalBlock(EmbedBlock): 148 | """ 149 | Whimsical Block for whimsical.com 150 | """ 151 | 152 | _type = "whimsical" 153 | -------------------------------------------------------------------------------- /notion/block/inline.py: -------------------------------------------------------------------------------- 1 | # class PersonMentionInlineBlock: 2 | # pass 3 | # 4 | # 5 | # class PageMentionInlineBlock: 6 | # pass 7 | # 8 | # 9 | # class DateInlineBlock: 10 | # pass 11 | # 12 | # 13 | # class EmojiInlineBlock: 14 | # pass 15 | # 16 | # 17 | # class EquationInlineBlock: 18 | # pass 19 | # 20 | # 21 | ## TODO: smelly? why here? why so much code? 22 | # class NotionDateBlock: 23 | # 24 | # start = None 25 | # end = None 26 | # timezone = None 27 | # reminder = None 28 | # 29 | # def __init__(self, start, end=None, timezone=None, reminder=None): 30 | # self.start = start 31 | # self.end = end 32 | # self.timezone = timezone 33 | # self.reminder = reminder 34 | # 35 | # @classmethod 36 | # def from_notion(cls, obj): 37 | # if isinstance(obj, dict): 38 | # data = obj 39 | # elif isinstance(obj, list): 40 | # data = obj[0][1][0][1] 41 | # else: 42 | # return None 43 | # start = cls._parse_datetime(data.get("start_date"), data.get("start_time")) 44 | # end = cls._parse_datetime(data.get("end_date"), data.get("end_time")) 45 | # timezone = data.get("timezone") 46 | # reminder = data.get("reminder") 47 | # return cls(start, end=end, timezone=timezone, reminder=reminder) 48 | # 49 | # @classmethod 50 | # def _parse_datetime(cls, date_str, time_str): 51 | # if not date_str: 52 | # return None 53 | # if time_str: 54 | # return datetime.strptime(date_str + " " + time_str, "%Y-%m-%d %H:%M") 55 | # else: 56 | # return datetime.strptime(date_str, "%Y-%m-%d").date() 57 | # 58 | # def _format_datetime(self, date_or_datetime): 59 | # if not date_or_datetime: 60 | # return None, None 61 | # if isinstance(date_or_datetime, datetime): 62 | # return ( 63 | # date_or_datetime.strftime("%Y-%m-%d"), 64 | # date_or_datetime.strftime("%H:%M"), 65 | # ) 66 | # else: 67 | # return date_or_datetime.strftime("%Y-%m-%d"), None 68 | # 69 | # def type(self): 70 | # name = "date" 71 | # if isinstance(self.start, datetime): 72 | # name += "time" 73 | # if self.end: 74 | # name += "range" 75 | # return name 76 | # 77 | # def to_notion(self): 78 | # 79 | # if self.end: 80 | # self.start, self.end = sorted([self.start, self.end]) 81 | # 82 | # start_date, start_time = self._format_datetime(self.start) 83 | # end_date, end_time = self._format_datetime(self.end) 84 | # 85 | # if not start_date: 86 | # return [] 87 | # 88 | # data = {"type": self.type(), "start_date": start_date} 89 | # 90 | # if end_date: 91 | # data["end_date"] = end_date 92 | # 93 | # if "time" in data["type"]: 94 | # data["time_zone"] = str(self.timezone or get_localzone()) 95 | # data["start_time"] = start_time or "00:00" 96 | # if end_date: 97 | # data["end_time"] = end_time or "00:00" 98 | # 99 | # if self.reminder: 100 | # data["reminder"] = self.reminder 101 | # 102 | # return [["‣", [["d", data]]]] 103 | # 104 | -------------------------------------------------------------------------------- /notion/block/media.py: -------------------------------------------------------------------------------- 1 | from notion.block.basic import Block 2 | from notion.maps import property_map 3 | 4 | 5 | class MediaBlock(Block): 6 | """ 7 | Media block. 8 | """ 9 | 10 | _type = "media" 11 | _str_fields = "caption" 12 | 13 | caption = property_map("caption") 14 | 15 | 16 | class BreadcrumbBlock(MediaBlock): 17 | """ 18 | Breadcrumb block. 19 | """ 20 | 21 | _type = "breadcrumb" 22 | -------------------------------------------------------------------------------- /notion/block/types.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | 4 | def _get_blocks(file_name: str, suffix: str = "Block") -> dict: 5 | """ 6 | Get a mapping of types and classes 7 | that end with `suffix` found in `file_name`. 8 | 9 | 10 | This function caches the results using `file_name` as a key. 11 | 12 | 13 | Arguments 14 | --------- 15 | file_name : str 16 | File name to the file in `notion.block` module. 17 | Pass it without extension (.py). 18 | 19 | suffix : str, optional 20 | Class suffix to used to filter the objects. 21 | Defaults to "Block". 22 | 23 | 24 | Returns 25 | ------- 26 | dict 27 | Mapping of types to their classes. 28 | """ 29 | cache = getattr(_get_blocks, "_cache", {}) 30 | 31 | if cache.get(file_name): 32 | return cache[file_name] 33 | 34 | module = import_module(f"notion.block.{file_name}") 35 | blocks = {} 36 | 37 | for name in dir(module): 38 | if name.endswith(suffix): 39 | klass = getattr(module, name) 40 | blocks[klass._type] = klass 41 | 42 | cache[file_name] = blocks 43 | setattr(_get_blocks, "_cache", cache) 44 | 45 | return blocks 46 | 47 | 48 | def get_all_block_types() -> dict: 49 | return { 50 | **_get_blocks("basic"), 51 | **_get_blocks("database"), 52 | **_get_blocks("embed"), 53 | **_get_blocks("inline"), 54 | **_get_blocks("media"), 55 | **_get_blocks("upload"), 56 | **_get_blocks("collection.basic"), 57 | **_get_blocks("collection.media"), 58 | } 59 | 60 | 61 | def get_block_type(block_type: str = "", default="block"): 62 | blocks = get_all_block_types() 63 | return blocks.get(block_type, None) or blocks[default] 64 | 65 | 66 | def get_collection_view_type(view_type: str, default="collection_view"): 67 | blocks = _get_blocks("collection.view", "View") 68 | return blocks.get(view_type, None) or blocks[default] 69 | 70 | 71 | def get_collection_query_result_type(query_result_type: str, default="collection"): 72 | blocks = _get_blocks("collection.query", "QueryResult") 73 | return blocks.get(query_result_type, None) or blocks[default] 74 | -------------------------------------------------------------------------------- /notion/block/upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | from mimetypes import guess_type 3 | from urllib.parse import urlencode, urlparse, quote 4 | 5 | from notion.block.embed import EmbedBlock 6 | from notion.maps import field_map, property_map 7 | from notion.settings import SIGNED_URL_PREFIX 8 | from notion.utils import human_size, from_list 9 | 10 | 11 | class UploadBlock(EmbedBlock): 12 | """ 13 | Upload Block. 14 | """ 15 | 16 | file_id = field_map("file_ids.0") 17 | 18 | def upload_file(self, path: str): 19 | """ 20 | Upload a file and embed it in Notion. 21 | 22 | 23 | Arguments 24 | --------- 25 | path : str 26 | Valid path to a file. 27 | 28 | 29 | Raises 30 | ------ 31 | HTTPError 32 | On API error. 33 | """ 34 | 35 | content_type = guess_type(path)[0] or "text/plain" 36 | file_name = os.path.split(path)[-1] 37 | 38 | data = {"bucket": "secure", "name": file_name, "contentType": content_type} 39 | resp = self._client.post("getUploadFileUrl", data) 40 | resp.raise_for_status() 41 | resp_data = resp.json() 42 | url = resp_data["url"] 43 | signed_url = resp_data["signedPutUrl"] 44 | 45 | with open(path, mode="rb") as f: 46 | headers = {"Content-Type": content_type} 47 | resp = self._client.put(signed_url, data=f, headers=headers) 48 | resp.raise_for_status() 49 | 50 | query = urlencode( 51 | { 52 | "cache": "v2", 53 | "name": file_name, 54 | "id": self._id, 55 | "table": self._table, 56 | "userId": self._client.current_user.id, 57 | } 58 | ) 59 | query_url = f"{url}?{query}" 60 | 61 | self.source = query_url 62 | self.display_source = query_url 63 | self.file_id = urlparse(url).path.split("/")[2] 64 | 65 | def download_file(self, path: str): 66 | """ 67 | Download a file. 68 | 69 | 70 | Arguments 71 | --------- 72 | path : str 73 | Path for saving file. 74 | 75 | 76 | Raises 77 | ------ 78 | HTTPError 79 | On API error. 80 | """ 81 | 82 | record_data = self._get_record_data() 83 | source = record_data["properties"]["source"] 84 | s3_url = from_list(source) 85 | file_name = s3_url.split("/")[-1] 86 | 87 | params = { 88 | "cache": "v2", 89 | "name": file_name, 90 | "id": self._id, 91 | "table": self._table, 92 | "userId": self._client.current_user.id, 93 | "download": True, 94 | } 95 | 96 | url = SIGNED_URL_PREFIX + quote(s3_url, safe="") 97 | resp = self._client.session.get(url, params=params, stream=True) 98 | resp.raise_for_status() 99 | 100 | with open(path, "wb") as f: 101 | for chunk in resp.iter_content(chunk_size=4096): 102 | f.write(chunk) 103 | 104 | 105 | class FileBlock(UploadBlock): 106 | """ 107 | File Block. 108 | """ 109 | 110 | _type = "file" 111 | 112 | size = property_map("size") 113 | title = property_map("title") 114 | 115 | def upload_file(self, path: str): 116 | super().upload_file(path) 117 | self.size = human_size(path) 118 | 119 | 120 | class PdfBlock(UploadBlock): 121 | """ 122 | PDF Block. 123 | """ 124 | 125 | _type = "pdf" 126 | 127 | 128 | class VideoBlock(UploadBlock): 129 | """ 130 | Video Block. 131 | """ 132 | 133 | _type = "video" 134 | 135 | 136 | class AudioBlock(UploadBlock): 137 | """ 138 | Audio Block. 139 | """ 140 | 141 | _type = "audio" 142 | 143 | 144 | class ImageBlock(UploadBlock): 145 | """ 146 | Image Block. 147 | """ 148 | 149 | _type = "image" 150 | -------------------------------------------------------------------------------- /notion/converter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | from random import choice 3 | from typing import Any, Callable 4 | from uuid import uuid1 5 | 6 | from notion.block.collection.common import NotionDate 7 | from notion.markdown import markdown_to_notion, notion_to_markdown 8 | from notion.utils import ( 9 | remove_signed_prefix_as_needed, 10 | add_signed_prefix_as_needed, 11 | to_list, 12 | extract_id, 13 | ) 14 | 15 | 16 | class BaseConverter: 17 | """ 18 | Base Converter. 19 | """ 20 | 21 | _converters = None 22 | 23 | @classmethod 24 | def _ensure_type(cls, name: str, value, types): 25 | types = to_list(types) 26 | 27 | for t in types: 28 | if isinstance(value, t): 29 | break 30 | else: 31 | types = [t.__name__ for t in types] 32 | msg = f"Value type passed to prop '{name}' must be one of {types}." 33 | raise TypeError(msg) 34 | 35 | @classmethod 36 | def _get_converter_for_type(cls, type_: str) -> Callable: 37 | if not cls._converters: 38 | cls._converters = [m for m in dir(cls) if m.startswith("convert_")] 39 | 40 | method_name = f"convert_{type_}" 41 | if method_name in cls._converters: 42 | return getattr(cls, method_name) 43 | 44 | @classmethod 45 | def convert(cls, name: str, value: Any, prop: dict, block) -> (str, Any): 46 | """ 47 | Convert `value` from attribute `name`. 48 | 49 | 50 | Attributes 51 | ---------- 52 | name : str 53 | Property name. 54 | 55 | value : Any 56 | Value to convert. 57 | 58 | prop : dict 59 | More information about the block property. 60 | 61 | block : Block 62 | instance of the block itself. 63 | 64 | 65 | Returns 66 | ------- 67 | (str, Any) 68 | Tuple containing property path and converted value. 69 | 70 | """ 71 | prop_id = prop["id"] 72 | prop_type = prop["type"] 73 | callback = cls._get_converter_for_type(prop_type) 74 | 75 | if not callback: 76 | raise ValueError( 77 | f"Prop '{name}' with type '{prop_type}'" 78 | " does not have a converter method" 79 | ) 80 | 81 | value = callback(name=name, value=value, prop=prop, block=block) 82 | return f"properties.{prop_id}", value 83 | 84 | 85 | class PythonToNotionConverter(BaseConverter): 86 | @classmethod 87 | def convert_title(cls, name, value, **_): 88 | cls._ensure_type(name, value, str) 89 | return markdown_to_notion(value) 90 | 91 | @classmethod 92 | def convert_text(cls, **kwargs): 93 | return cls.convert_title(**kwargs) 94 | 95 | @classmethod 96 | def convert_number(cls, name, value, **_): 97 | if value is None: 98 | return None 99 | 100 | cls._ensure_type(name, value, [int, float]) 101 | return [[str(value)]] 102 | 103 | @classmethod 104 | def convert_select(cls, value, prop, block, **_): 105 | value = to_list(value) 106 | if value == [None]: 107 | return value 108 | 109 | options = prop["options"] = prop.get("options", []) 110 | valid_options = [[p["value"].lower() for p in options]] 111 | colors = [ 112 | "default", 113 | "gray", 114 | "brown", 115 | "orange", 116 | "yellow", 117 | "green", 118 | "blue", 119 | "purple", 120 | "pink", 121 | "red", 122 | ] 123 | 124 | schema_needs_update = False 125 | for i, v in enumerate(value): 126 | value[i] = v = v.replace(",", "") 127 | v_key = v.lower() 128 | 129 | if v_key not in valid_options: 130 | schema_needs_update = True 131 | valid_options.append(v_key) 132 | options.append( 133 | {"id": str(uuid1()), "value": v, "color": choice(colors)} 134 | ) 135 | 136 | value = [[",".join(value)]] 137 | 138 | if schema_needs_update: 139 | schema = block.collection.get("schema") 140 | schema[prop["id"]] = prop 141 | block.collection.set("schema", schema) 142 | 143 | return value 144 | 145 | @classmethod 146 | def convert_multi_select(cls, **kwargs): 147 | return cls.convert_select(**kwargs) 148 | 149 | @classmethod 150 | def convert_email(cls, value, **_): 151 | return [[value, [["a", value]]]] 152 | 153 | @classmethod 154 | def convert_phone_number(cls, **kwargs): 155 | return cls.convert_email(**kwargs) 156 | 157 | @classmethod 158 | def convert_url(cls, **kwargs): 159 | return cls.convert_email(**kwargs) 160 | 161 | @classmethod 162 | def convert_date(cls, name, value, **_): 163 | cls._ensure_type(name, value, [date, datetime, NotionDate]) 164 | 165 | if isinstance(value, NotionDate): 166 | return value.to_notion() 167 | 168 | return NotionDate(value) 169 | 170 | @classmethod 171 | def convert_checkbox(cls, name, value, **_): 172 | cls._ensure_type(name, value, bool) 173 | return [["Yes" if value else "No"]] 174 | 175 | @classmethod 176 | def convert_person(cls, value, **_): 177 | users = [] 178 | 179 | for user in to_list(value): 180 | users += [["‣", [["u", extract_id(user)]]], [","]] 181 | 182 | return users[:-1] 183 | 184 | @classmethod 185 | def convert_file(cls, value, **_): 186 | files = [] 187 | 188 | for url in to_list(value): 189 | url = remove_signed_prefix_as_needed(url) 190 | name = url.split("/")[-1] 191 | files += [[name, [["a", url]]], [","]] 192 | 193 | return files[:-1] 194 | 195 | @classmethod 196 | def convert_relation(cls, value, block, **_): 197 | pages = [] 198 | 199 | for page in to_list(value): 200 | if isinstance(page, str): 201 | page = block._client.get_block(page) 202 | pages += [["‣", [["p", page.id]]], [","]] 203 | 204 | return pages[:-1] 205 | 206 | @classmethod 207 | def convert_created_time(cls, value, **_): 208 | return int(value.timestamp() * 1000) 209 | 210 | @classmethod 211 | def convert_last_edited_time(cls, **kwargs): 212 | return cls.convert_created_time(**kwargs) 213 | 214 | @classmethod 215 | def convert_created_by(cls, value, **_): 216 | return extract_id(value) 217 | 218 | @classmethod 219 | def convert_last_edited_by(cls, **kwargs): 220 | return cls.convert_created_by(**kwargs) 221 | 222 | 223 | class NotionToPythonConverter(BaseConverter): 224 | @classmethod 225 | def convert_title(cls, value, block, **_): 226 | for i, part in enumerate(value): 227 | if len(part) == 2: 228 | for fmt in part[1]: 229 | if "p" in fmt: 230 | page = block._client.get_block(fmt[1]) 231 | title = f"{page.icon} {page.title}" 232 | address = page.get_browseable_url() 233 | value[i] = f"[{title}]({address})" 234 | 235 | return notion_to_markdown(value) if value else "" 236 | 237 | @classmethod 238 | def convert_text(cls, **kwargs): 239 | return cls.convert_title(**kwargs) 240 | 241 | @classmethod 242 | def convert_number(cls, value, **_): 243 | if value is None: 244 | return None 245 | 246 | value = value[0][0].replace(",", "") 247 | if "." in value: 248 | return float(value) 249 | 250 | return int(value) 251 | 252 | @classmethod 253 | def convert_select(cls, value, **_): 254 | return value[0][0] if value else None 255 | 256 | @classmethod 257 | def convert_multi_select(cls, value, **_): 258 | if not value: 259 | return [] 260 | 261 | return [v.strip() for v in value[0][0].split(",")] 262 | 263 | @classmethod 264 | def convert_email(cls, **kwargs): 265 | return cls.convert_select(**kwargs) 266 | 267 | @classmethod 268 | def convert_phone_number(cls, **kwargs): 269 | return cls.convert_select(**kwargs) 270 | 271 | @classmethod 272 | def convert_url(cls, **kwargs): 273 | return cls.convert_select(**kwargs) 274 | 275 | @classmethod 276 | def convert_date(cls, value, **_): 277 | return NotionDate.from_notion(value) 278 | 279 | @classmethod 280 | def convert_checkbox(cls, value, **_): 281 | return value[0][0] == "Yes" if value else False 282 | 283 | @classmethod 284 | def convert_person(cls, value, block, **_): 285 | if not value: 286 | return [] 287 | 288 | items = [i[1][0][1] for i in value if i[0] == "‣"] 289 | return [block._client.get_user(i) for i in items] 290 | 291 | @classmethod 292 | def convert_file(cls, value, block, **_): 293 | if not value: 294 | return [] 295 | 296 | client = block._client 297 | items = [i[1][0][1] for i in value if i[0] != ","] 298 | return [add_signed_prefix_as_needed(i, client=client) for i in items] 299 | 300 | @classmethod 301 | def convert_relation(cls, value, block, **_): 302 | if not value: 303 | return [] 304 | 305 | items = [i[1][0][1] for i in value if i[0] != "‣"] 306 | return [block._client.get_block(i) for i in items] 307 | 308 | @classmethod 309 | def convert_created_time(cls, block, prop, **_): 310 | value = block.get(prop["type"]) 311 | value = datetime.utcfromtimestamp(value / 1000) 312 | return int(value.timestamp() * 1000) 313 | 314 | @classmethod 315 | def convert_last_edited_time(cls, **kwargs): 316 | return cls.convert_created_time(**kwargs) 317 | 318 | @classmethod 319 | def convert_created_by(cls, block, prop, **_): 320 | value = block.get(prop["type"] + "_id") 321 | return block._client.get_user(value) 322 | 323 | @classmethod 324 | def convert_last_edited_by(cls, **kwargs): 325 | return cls.convert_created_by(**kwargs) 326 | -------------------------------------------------------------------------------- /notion/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from notion.settings import NOTION_LOG_FILE, NOTION_LOG_LEVEL 4 | 5 | logger = logging.getLogger("notion") 6 | 7 | 8 | if NOTION_LOG_LEVEL == "DISABLED": 9 | handler = logging.NullHandler() 10 | else: 11 | handler = logging.FileHandler(NOTION_LOG_FILE) 12 | formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 13 | handler.setFormatter(formatter) 14 | logger.setLevel(NOTION_LOG_LEVEL) 15 | handler.setLevel(NOTION_LOG_LEVEL) 16 | 17 | logger.addHandler(handler) 18 | -------------------------------------------------------------------------------- /notion/maps.py: -------------------------------------------------------------------------------- 1 | from inspect import signature 2 | from typing import Callable 3 | 4 | from notion.markdown import ( 5 | markdown_to_notion, 6 | notion_to_markdown, 7 | plaintext_to_notion, 8 | notion_to_plaintext, 9 | ) 10 | from notion.utils import ( 11 | add_signed_prefix_as_needed, 12 | remove_signed_prefix_as_needed, 13 | ) 14 | 15 | 16 | class Mapper(property): 17 | """ 18 | Mapper for converting to/from notion and Python. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | path: str, 24 | python_to_api: Callable, 25 | api_to_python: Callable, 26 | *args, 27 | **kwargs, 28 | ): 29 | """ 30 | Create mapper object and fill its fields. 31 | 32 | 33 | Arguments 34 | --------- 35 | path : str 36 | Path can either be a top-level field-name or a 37 | dot-delimited string representing the key names to traverse. 38 | 39 | python_to_api : Callable 40 | Function that converts values as given in the Python layer 41 | into the internal API representation. 42 | 43 | api_to_python : Callable 44 | Function that converts what is received from the API 45 | into an internal representation to be returned to the Python layer. 46 | """ 47 | self.path = path 48 | self.python_to_api = python_to_api 49 | self.api_to_python = api_to_python 50 | super().__init__(*args, **kwargs) 51 | 52 | 53 | def field_map( 54 | path: str, 55 | python_to_api: Callable = lambda x: x, 56 | api_to_python: Callable = lambda x: x, 57 | ) -> Mapper: 58 | """ 59 | Return a property that maps a Block attribute 60 | onto a field in the API data structures. 61 | 62 | 63 | Arguments 64 | --------- 65 | path : str 66 | Path can either be a top-level field-name or a 67 | dot-delimited string representing the key names to traverse. 68 | 69 | python_to_api : Callable, optional 70 | Function that converts values as given in the Python layer into 71 | the internal API representation. 72 | Defaults to proxy lambda x: x. 73 | 74 | api_to_python : Callable, optional 75 | Function that converts what is received from the API into 76 | an internal representation to be returned to the Python layer. 77 | Defaults to proxy lambda x: x. 78 | 79 | 80 | Returns 81 | ------- 82 | Mapper 83 | Property map. 84 | 85 | 86 | See Also 87 | -------- 88 | property_map 89 | """ 90 | 91 | def fget(self): 92 | kwargs = {} 93 | if "client" in signature(api_to_python).parameters: 94 | kwargs["client"] = self._client 95 | 96 | return api_to_python(self.get(path), **kwargs) 97 | 98 | def fset(self, value): 99 | kwargs = {} 100 | if "client" in signature(python_to_api).parameters: 101 | kwargs["client"] = self._client 102 | 103 | self.set(path, python_to_api(value, **kwargs)) 104 | 105 | return Mapper( 106 | path=path, 107 | python_to_api=python_to_api, 108 | api_to_python=api_to_python, 109 | fget=fget, 110 | fset=fset, 111 | ) 112 | 113 | 114 | def prefixed_field_map(name: str) -> Mapper: 115 | """ 116 | Arguments 117 | --------- 118 | name : str 119 | Name of the property. 120 | 121 | 122 | Returns 123 | ------- 124 | Mapper 125 | Field map. 126 | 127 | 128 | See Also 129 | -------- 130 | field_map 131 | """ 132 | return field_map( 133 | name, 134 | api_to_python=add_signed_prefix_as_needed, 135 | python_to_api=remove_signed_prefix_as_needed, 136 | ) 137 | 138 | 139 | def nested_field_map(name: str) -> Mapper: 140 | """ 141 | Arguments 142 | --------- 143 | name : str 144 | Name of the property. 145 | 146 | 147 | Returns 148 | ------- 149 | Mapper 150 | Field map. 151 | 152 | 153 | See Also 154 | -------- 155 | field_map 156 | """ 157 | return field_map( 158 | name, 159 | python_to_api=lambda x: [[x]], 160 | api_to_python=lambda x: x[0][0], 161 | ) 162 | 163 | 164 | def markdown_field_map(name: str) -> Mapper: 165 | """ 166 | Arguments 167 | --------- 168 | name : str 169 | Name of the property. 170 | 171 | 172 | Returns 173 | ------- 174 | Mapper 175 | Field map. 176 | 177 | 178 | See Also 179 | -------- 180 | field_map 181 | """ 182 | return field_map( 183 | name, api_to_python=notion_to_markdown, python_to_api=markdown_to_notion 184 | ) 185 | 186 | 187 | def property_map( 188 | name: str, 189 | python_to_api: Callable = lambda x: x, 190 | api_to_python: Callable = lambda x: x, 191 | markdown: bool = True, 192 | ) -> Mapper: 193 | """ 194 | Similar to `field_map`, except it works specifically with 195 | the data under the "properties" field in the API block table, 196 | and just takes a single name to specify which subkey to reference. 197 | 198 | Also, these properties all seem to use a special "embedded list" 199 | format that breaks the text up into a sequence of chunks and associated 200 | format metadata. 201 | 202 | 203 | Arguments 204 | --------- 205 | name : str 206 | Name of the property. 207 | 208 | python_to_api : Callable, optional 209 | Function that converts values as given in the Python layer into 210 | the internal API representation. 211 | Defaults to proxy lambda x: x. 212 | 213 | api_to_python : Callable, optional 214 | Function that converts what is received from the API into 215 | an internal representation to be returned to the Python layer. 216 | Defaults to proxy lambda x: x. 217 | 218 | markdown : bool, optional 219 | Whether or not to convert the representation into commonmark-compatible 220 | markdown text upon reading from API and again when saving. 221 | Defaults to True. 222 | 223 | 224 | Returns 225 | ------- 226 | Mapper 227 | Property map. 228 | 229 | 230 | See Also 231 | -------- 232 | field_map 233 | """ 234 | 235 | def py2api(x, client=None): 236 | kwargs = {} 237 | if "client" in signature(python_to_api).parameters: 238 | kwargs["client"] = client 239 | 240 | x = python_to_api(x, **kwargs) 241 | if markdown: 242 | x = markdown_to_notion(x) 243 | 244 | return x 245 | 246 | def api2py(x, client=None): 247 | x = x or [[""]] 248 | 249 | if markdown: 250 | x = notion_to_markdown(x) 251 | 252 | kwargs = {} 253 | if "client" in signature(api_to_python).parameters: 254 | kwargs["client"] = client 255 | 256 | return api_to_python(x, **kwargs) 257 | 258 | path = f"properties.{name}" 259 | return field_map(path, python_to_api=py2api, api_to_python=api2py) 260 | 261 | 262 | def prefixed_property_map(name: str) -> Mapper: 263 | """ 264 | Arguments 265 | --------- 266 | name : str 267 | Name of the property. 268 | 269 | 270 | Returns 271 | ------- 272 | Mapper 273 | Property map. 274 | 275 | 276 | See Also 277 | -------- 278 | property_map 279 | """ 280 | return property_map( 281 | name, 282 | api_to_python=add_signed_prefix_as_needed, 283 | python_to_api=remove_signed_prefix_as_needed, 284 | ) 285 | 286 | 287 | def plaintext_property_map(name: str) -> Mapper: 288 | """ 289 | Arguments 290 | --------- 291 | name : str 292 | Name of the property. 293 | 294 | 295 | Returns 296 | ------- 297 | Mapper 298 | Property map. 299 | 300 | 301 | See Also 302 | -------- 303 | property_map 304 | """ 305 | return property_map( 306 | name, 307 | python_to_api=plaintext_to_notion, 308 | api_to_python=notion_to_plaintext, 309 | markdown=False, 310 | ) 311 | 312 | 313 | def boolean_property_map(name: str) -> Mapper: 314 | """ 315 | Arguments 316 | --------- 317 | name : str 318 | Name of the property. 319 | 320 | 321 | Returns 322 | ------- 323 | Mapper 324 | Property map. 325 | 326 | 327 | See Also 328 | -------- 329 | property_map 330 | """ 331 | return property_map( 332 | name, 333 | python_to_api=lambda x: "Yes" if x else "No", 334 | api_to_python=lambda x: x == "Yes", 335 | ) 336 | -------------------------------------------------------------------------------- /notion/markdown.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from commonmark import Parser 4 | from commonmark.dump import prepare 5 | 6 | delimiters = { 7 | "!", 8 | '"', 9 | "#", 10 | "$", 11 | "%", 12 | "&", 13 | "'", 14 | "(", 15 | ")", 16 | "*", 17 | "+", 18 | ",", 19 | "-", 20 | ".", 21 | "/", 22 | ":", 23 | ";", 24 | "<", 25 | "=", 26 | ">", 27 | "?", 28 | "@", 29 | "[", 30 | "\\", 31 | "]", 32 | "^", 33 | "_", 34 | "`", 35 | "{", 36 | "|", 37 | "}", 38 | "~", 39 | "☃", 40 | " ", 41 | "\t", 42 | "\n", 43 | "\x0b", 44 | "\x0c", 45 | "\r", 46 | "\x1c", 47 | "\x1d", 48 | "\x1e", 49 | "\x1f", 50 | "\x85", 51 | "\xa0", 52 | "\u1680", 53 | "\u2000", 54 | "\u2001", 55 | "\u2002", 56 | "\u2003", 57 | "\u2004", 58 | "\u2005", 59 | "\u2006", 60 | "\u2007", 61 | "\u2008", 62 | "\u2009", 63 | "\u200a", 64 | "\u2028", 65 | "\u2029", 66 | "\u202f", 67 | "\u205f", 68 | "\u3000", 69 | } 70 | 71 | _NOTION_TO_MARKDOWN_MAPPER = {"i": "☃", "b": "☃☃", "s": "~~", "c": "`"} 72 | 73 | FORMAT_PRECEDENCE = ["s", "b", "i", "a", "c"] 74 | 75 | 76 | def _extract_text_and_format_from_ast(item: dict): 77 | literal = item.get("literal", "") 78 | item_type = item["type"] 79 | 80 | if item_type == "html_inline": 81 | if literal == "": 82 | return "", ("s",) 83 | 84 | if item_type == "strong": 85 | return literal, ("b",) 86 | 87 | if item_type == "emph": 88 | return literal, ("i",) 89 | 90 | if item_type == "link": 91 | return literal, ("a", item.get("destination", "")) 92 | 93 | if item_type == "code": 94 | return literal, ("c",) 95 | 96 | return literal, () 97 | 98 | 99 | def _get_format(notion_segment, as_set=False): 100 | if len(notion_segment) == 1: 101 | if as_set: 102 | return set() 103 | else: 104 | return [] 105 | else: 106 | if as_set: 107 | return set([tuple(f) for f in notion_segment[1]]) 108 | else: 109 | return notion_segment[1] 110 | 111 | 112 | def _cleanup_dashes(thing): 113 | regex_pattern = re.compile("⸻|%E2%B8%BB") 114 | if type(thing) is list: 115 | for counter, value in enumerate(thing): 116 | thing[counter] = _cleanup_dashes(value) 117 | elif type(thing) is str: 118 | return regex_pattern.sub("-", thing) 119 | 120 | return thing 121 | 122 | 123 | def markdown_to_notion(markdown: str) -> list: 124 | """ 125 | Convert Markdown formatted string to Notion. 126 | 127 | 128 | Arguments 129 | --------- 130 | markdown : str 131 | Text to convert. 132 | 133 | 134 | Returns 135 | ------- 136 | list of Block 137 | Blocks converted from input. 138 | """ 139 | 140 | # commonmark doesn't support strikethrough, 141 | # so we need to handle it ourselves 142 | while markdown.count("~~") >= 2: 143 | markdown = markdown.replace("~~", "", 1) 144 | markdown = markdown.replace("~~", "", 1) 145 | 146 | # we don't want to touch dashes, so temporarily replace them here 147 | markdown = markdown.replace("-", "⸻") 148 | 149 | parser = Parser() 150 | ast = prepare(parser.parse(markdown)) 151 | 152 | format = set() 153 | 154 | notion = [] 155 | 156 | for section in ast: 157 | 158 | _, ended_format = _extract_text_and_format_from_ast(section) 159 | if ended_format and ended_format in format: 160 | format.remove(ended_format) 161 | 162 | if section["type"] == "paragraph": 163 | notion.append(["\n\n"]) 164 | 165 | for item in section.get("children", []): 166 | 167 | literal, new_format = _extract_text_and_format_from_ast(item) 168 | 169 | if new_format: 170 | format.add(new_format) 171 | 172 | if item["type"] == "html_inline" and literal == "": 173 | format.remove(("s",)) 174 | literal = "" 175 | 176 | if item["type"] == "softbreak": 177 | literal = "\n" 178 | 179 | if literal: 180 | notion.append( 181 | [literal, [list(f) for f in sorted(format)]] 182 | if format 183 | else [literal] 184 | ) 185 | 186 | # in the ast format, code blocks are meant 187 | # to be immediately self-closing 188 | if ("c",) in format: 189 | format.remove(("c",)) 190 | 191 | # remove any trailing newlines from automatic closing paragraph markers 192 | if notion: 193 | notion[-1][0] = notion[-1][0].rstrip("\n") 194 | 195 | # consolidate any adjacent text blocks with identical styles 196 | consolidated = [] 197 | for item in notion: 198 | if consolidated and _get_format(consolidated[-1], as_set=True) == _get_format( 199 | item, as_set=True 200 | ): 201 | consolidated[-1][0] += item[0] 202 | elif item[0]: 203 | consolidated.append(item) 204 | 205 | return _cleanup_dashes(consolidated) 206 | 207 | 208 | # TODO: Rewrite this function, it has to be shorter! 209 | def notion_to_markdown(notion: list) -> str: 210 | """ 211 | Convert list of notion blocks to markdown text. 212 | 213 | 214 | Arguments 215 | --------- 216 | notion : list 217 | List of Notion Blocks 218 | TODO: is it true? 219 | 220 | 221 | Raises 222 | ------ 223 | Exception 224 | When it's unable to extract text. 225 | 226 | 227 | Returns 228 | ------- 229 | str 230 | Converted Markdown text. 231 | """ 232 | pattern = re.compile(r"^(?P\s*)(?P(\s|.)*?)(?P\s*)$") 233 | markdown_chunks = [] 234 | 235 | for item in notion or []: 236 | 237 | markdown = "" 238 | 239 | text = item[0] 240 | format = item[1] if len(item) == 2 else [] 241 | 242 | match = pattern.match(text) 243 | 244 | if not match: 245 | raise Exception("Unable to extract text from: %r" % text) 246 | 247 | leading_whitespace = match.groupdict()["leading"] 248 | stripped = match.groupdict()["stripped"] 249 | trailing_whitespace = match.groupdict()["trailing"] 250 | 251 | markdown += leading_whitespace 252 | 253 | sorted_format = sorted( 254 | format, 255 | key=lambda x: FORMAT_PRECEDENCE.index(x[0]) 256 | if x[0] in FORMAT_PRECEDENCE 257 | else -1, 258 | ) 259 | 260 | for f in sorted_format: 261 | if f[0] in _NOTION_TO_MARKDOWN_MAPPER: 262 | if stripped: 263 | markdown += _NOTION_TO_MARKDOWN_MAPPER[f[0]] 264 | if f[0] == "a": 265 | markdown += "[" 266 | 267 | markdown += stripped 268 | 269 | for f in reversed(sorted_format): 270 | if f[0] in _NOTION_TO_MARKDOWN_MAPPER: 271 | if stripped: 272 | markdown += _NOTION_TO_MARKDOWN_MAPPER[f[0]] 273 | if f[0] == "a": 274 | markdown += "]({})".format(f[1]) 275 | 276 | markdown += trailing_whitespace 277 | 278 | # to make it parseable, add a space after if it combines code/links and emphasis formatting 279 | format_types = [f[0] for f in format] 280 | if ( 281 | ("c" in format_types or "a" in format_types) 282 | and ("b" in format_types or "i" in format_types) 283 | and not trailing_whitespace 284 | ): 285 | markdown += " " 286 | 287 | markdown_chunks.append(markdown) 288 | 289 | # use underscores as needed to separate adjacent chunks to avoid ambiguous runs of asterisks 290 | full_markdown = "" 291 | last_used_underscores = False 292 | for i in range(len(markdown_chunks)): 293 | prev = markdown_chunks[i - 1] if i > 0 else "" 294 | curr = markdown_chunks[i] 295 | next = markdown_chunks[i + 1] if i < len(markdown_chunks) - 1 else "" 296 | prev_ended_in_delimiter = not prev or prev[-1] in delimiters 297 | next_starts_with_delimiter = not next or next[0] in delimiters 298 | if ( 299 | prev_ended_in_delimiter 300 | and next_starts_with_delimiter 301 | and not last_used_underscores 302 | and curr.startswith("☃") 303 | and curr.endswith("☃") 304 | ): 305 | if curr[1] == "☃": 306 | count = 2 307 | else: 308 | count = 1 309 | curr = "_" * count + curr[count:-count] + "_" * count 310 | last_used_underscores = True 311 | else: 312 | last_used_underscores = False 313 | 314 | final_markdown = curr.replace("☃", "*") 315 | 316 | # to make it parseable, convert emphasis/strong combinations to use a mix of _ and * 317 | if "***" in final_markdown: 318 | final_markdown = final_markdown.replace("***", "**_", 1) 319 | final_markdown = final_markdown.replace("***", "_**", 1) 320 | 321 | full_markdown += final_markdown 322 | 323 | return full_markdown 324 | 325 | 326 | def notion_to_plaintext(notion: list, client=None) -> str: 327 | """ 328 | Convert list of notion blocks to plain text. 329 | 330 | 331 | Arguments 332 | --------- 333 | notion : list 334 | Text in a Notion specific API format 335 | i.e. [["some text"]] 336 | 337 | client : NotionClient, optional 338 | Used for getting blocks, if passed. 339 | Defaults to None. 340 | 341 | 342 | Returns 343 | ------- 344 | str 345 | Converted text. 346 | """ 347 | plaintext = "" 348 | 349 | for item in notion or []: 350 | 351 | text = item[0] 352 | formats = item[1] if len(item) == 2 else [] 353 | 354 | if text == "‣": 355 | 356 | for f in formats: 357 | if f[0] == "p": # page link 358 | if client is None: 359 | plaintext += "page:" + f[1] 360 | else: 361 | plaintext += client.get_block(f[1]).title_plaintext 362 | elif f[0] == "u": # user link 363 | if client is None: 364 | plaintext += "user:" + f[1] 365 | else: 366 | plaintext += client.get_user(f[1]).full_name 367 | 368 | continue 369 | 370 | plaintext += text 371 | 372 | return plaintext 373 | 374 | 375 | def plaintext_to_notion(plaintext: str) -> list: 376 | """ 377 | Convert plain text to list of notion blocks. 378 | 379 | 380 | Arguments 381 | --------- 382 | plaintext : str 383 | Text to be converted. 384 | 385 | 386 | Returns 387 | ------- 388 | list 389 | List with the converted plaintext. 390 | """ 391 | return [[plaintext]] 392 | -------------------------------------------------------------------------------- /notion/monitor.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import threading 4 | import time 5 | import uuid 6 | from collections import defaultdict 7 | from typing import Set 8 | from urllib.parse import urlencode 9 | 10 | from requests import HTTPError 11 | 12 | from notion.block.collection.basic import CollectionBlock 13 | from notion.logger import logger 14 | from notion.record import Record 15 | from notion.settings import MESSAGE_STORE_URL 16 | 17 | 18 | class Monitor: 19 | """ 20 | Monitor class for automatic data polling of records. 21 | """ 22 | 23 | thread = None 24 | 25 | def __init__(self, client, root_url: str = MESSAGE_STORE_URL): 26 | """ 27 | Create Monitor object. 28 | 29 | 30 | Arguments 31 | --------- 32 | client : NotionClient 33 | Client to use. 34 | 35 | root_url : str, optional 36 | Root URL for polling message stats. 37 | Defaults to valid notion message store URL. 38 | """ 39 | self.sid = None 40 | self.client = client 41 | self.root_url = root_url 42 | self.session_id = str(uuid.uuid4()) 43 | self._subscriptions = set() 44 | self.initialize() 45 | 46 | @staticmethod 47 | def _encode_numbered_json_thing(data: list) -> bytes: 48 | results = "" 49 | for obj in data: 50 | msg = str(len(obj)) + json.dumps(obj, separators=(",", ":")) 51 | msg = f"{len(msg)}:{msg}" 52 | results += msg 53 | 54 | return results.encode() 55 | 56 | def _decode_numbered_json_thing(self, thing: bytes) -> list: 57 | thing = thing.decode().strip() 58 | 59 | for ping in re.findall(r'\d+:\d+"primus::ping::\d+"', thing): 60 | logger.debug(f"Received ping: {ping}") 61 | self.post_data(ping.replace("::ping::", "::pong::")) 62 | 63 | results = [] 64 | for blob in re.findall(r"\d+:\d+({.+})(?=\d|$)", thing): 65 | results.append(json.loads(blob)) 66 | 67 | if thing and not results and "::ping::" not in thing: 68 | logger.debug(f"Could not parse monitoring response: {thing}") 69 | 70 | return results 71 | 72 | def _refresh_updated_records(self, events: list): 73 | records_to_refresh = defaultdict(list) 74 | versions_pattern = re.compile(r"versions/([^:]+):(.+)") 75 | collection_pattern = re.compile(r"collection/(.+)") 76 | 77 | events = filter(lambda e: isinstance(e, dict), events) 78 | events = filter(lambda e: e.get("type", "") == "notification", events) 79 | 80 | for event in events: 81 | logger.debug(f"Received the following event from notion: {event}") 82 | key = event.get("key") 83 | 84 | # TODO: rewrite below if cases to something simpler 85 | if key.startswith("versions/"): 86 | match = versions_pattern.match(key) 87 | if not match: 88 | continue 89 | 90 | record_id, record_table = match.groups() 91 | name = f"{record_table}/{record_id}" 92 | new = event["value"] 93 | old = self.client._store.get_current_version( 94 | table=record_table, record_id=record_id 95 | ) 96 | 97 | if new > old: 98 | logger.debug( 99 | ( 100 | f"Record {name} has changed; refreshing to update" 101 | f"from version {old} to version {new}" 102 | ) 103 | ) 104 | records_to_refresh[record_table].append(record_id) 105 | else: 106 | logger.debug( 107 | ( 108 | f"Record {name} already at version {old}" 109 | f"not trying to update to version {new}" 110 | ) 111 | ) 112 | 113 | if key.startswith("collection/"): 114 | 115 | match = collection_pattern.match(key) 116 | if not match: 117 | continue 118 | 119 | collection_id = match.groups()[0] 120 | 121 | self.client.refresh_collection_rows(collection_id) 122 | row_ids = self.client._store.get_collection_rows(collection_id) 123 | 124 | logger.debug( 125 | ( 126 | f"Something inside collection '{collection_id}' " 127 | f"has changed. Refreshing all {row_ids} rows inside it" 128 | ) 129 | ) 130 | 131 | records_to_refresh["block"] += row_ids 132 | 133 | self.client.refresh_records(**records_to_refresh) 134 | 135 | def url(self, **kwargs) -> str: 136 | kwargs["b64"] = 1 137 | kwargs["transport"] = kwargs.get("transport", "polling") 138 | kwargs["sessionId"] = kwargs.get("sessionId", self.session_id) 139 | return f"{self.root_url}?{urlencode(kwargs)}" 140 | 141 | def initialize(self): 142 | """ 143 | Initialize the monitoring session. 144 | """ 145 | logger.debug("Initializing new monitoring session.") 146 | 147 | content = self.client.session.get(self.url(EIO=3)).content 148 | self.sid = self._decode_numbered_json_thing(content)[0]["sid"] 149 | 150 | logger.debug(f"New monitoring session ID is: {self.sid}") 151 | 152 | # resubscribe to any existing subscriptions if we're reconnecting 153 | old_subscriptions = self._subscriptions 154 | self._subscriptions = set() 155 | self.subscribe(old_subscriptions) 156 | 157 | def subscribe(self, records: Set[Record]): 158 | """ 159 | Subscribe to changes of passed records. 160 | 161 | 162 | Arguments 163 | --------- 164 | records : set of Record 165 | Set of `Record` objects to subscribe to. 166 | """ 167 | if isinstance(records, list): 168 | records = set(records) 169 | 170 | # TODO: how to describe that you can also pass 171 | # record explicitly or should we block it? 172 | if not isinstance(records, set): 173 | records = {records} 174 | 175 | sub_data = [] 176 | 177 | for record in records.difference(self._subscriptions): 178 | key = f"{record.id}:{record._table}" 179 | logger.debug(f"Subscribing new record: {key}") 180 | 181 | # save it in case we're disconnected 182 | self._subscriptions.add(record) 183 | 184 | # TODO: hide that dict generation in Record class 185 | sub_data.append( 186 | { 187 | "type": "/api/v1/registerSubscription", 188 | "requestId": str(uuid.uuid4()), 189 | "key": f"versions/{key}", 190 | "version": record.get("version", -1), 191 | } 192 | ) 193 | 194 | # if it's a collection, subscribe to changes to its children too 195 | if isinstance(record, CollectionBlock): 196 | sub_data.append( 197 | { 198 | "type": "/api/v1/registerSubscription", 199 | "requestId": str(uuid.uuid4()), 200 | "key": f"collection/{record.id}", 201 | "version": -1, 202 | } 203 | ) 204 | 205 | self.post_data(self._encode_numbered_json_thing(sub_data)) 206 | 207 | def post_data(self, data: bytes): 208 | """ 209 | Send monitoring requests to Notion. 210 | 211 | 212 | Arguments 213 | --------- 214 | data : bytes 215 | Form encoded request data. 216 | """ 217 | if not data: 218 | return 219 | 220 | logger.debug(f"Posting monitoring data: {data}") 221 | self.client.session.post(self.url(sid=self.sid), data=data) 222 | 223 | def poll(self, retries: int = 10): 224 | """ 225 | Poll for changes. 226 | 227 | 228 | Arguments 229 | --------- 230 | retries : int, optional 231 | Number of times to retry request if it fails. 232 | Should be bigger than 5. 233 | Defaults to 10. 234 | 235 | 236 | Raises 237 | ------ 238 | HTTPError 239 | When GET request fails for `retries` times. 240 | """ 241 | logger.debug("Starting new long-poll request") 242 | url = self.url(EIO=3, sid=self.sid) 243 | response = None 244 | 245 | while retries: 246 | try: 247 | retries -= 1 248 | response = self.client.session.get(url) 249 | response.raise_for_status() 250 | 251 | except HTTPError as e: 252 | try: 253 | message = f"{response.content} / {e}" 254 | except AttributeError: 255 | message = str(e) 256 | 257 | logger.warn( 258 | "Problem with submitting poll request: " 259 | f"{message} (will retry {retries} more times)" 260 | ) 261 | 262 | time.sleep(0.1) 263 | if retries <= 0: 264 | raise 265 | 266 | if retries <= 5: 267 | logger.error( 268 | "Persistent error submitting poll request: " 269 | f"{message} (will retry {retries} more times)" 270 | ) 271 | 272 | if retries == 3: 273 | # if we're close to giving up, try to restart the session 274 | self.initialize() 275 | 276 | self._refresh_updated_records( 277 | self._decode_numbered_json_thing(response.content) 278 | ) 279 | 280 | def poll_async(self): 281 | if self.thread: 282 | # Already polling async; no need to have two threads 283 | return 284 | 285 | logger.debug("Starting new thread for async polling") 286 | self.thread = threading.Thread(target=self.poll_forever, daemon=True) 287 | self.thread.start() 288 | 289 | def poll_forever(self): 290 | """ 291 | Call `poll()` in never-ending loop with small time intervals in-between. 292 | 293 | This function is blocking, it never returns! 294 | """ 295 | while True: 296 | try: 297 | self.poll() 298 | except Exception as e: 299 | logger.error("Encountered error during polling!") 300 | logger.error(e, exc_info=True) 301 | time.sleep(1) 302 | -------------------------------------------------------------------------------- /notion/operations.py: -------------------------------------------------------------------------------- 1 | from notion.utils import now 2 | 3 | 4 | def build_operations( 5 | record_id: str, 6 | path: str, 7 | args: dict, 8 | command: str, 9 | table: str = "block", 10 | ) -> dict: 11 | """ 12 | Build sequence of operations for submitTransaction endpoint. 13 | 14 | 15 | Arguments 16 | --------- 17 | record_id : str 18 | ID of the object. 19 | 20 | path : str 21 | Key for the object. 22 | 23 | args : dict 24 | Arguments. 25 | 26 | command : str 27 | Command to execute. 28 | 29 | table : str, optional 30 | Table argument for endpoint. 31 | Defaults to "block". 32 | 33 | 34 | Returns 35 | ------- 36 | dict 37 | Valid dict for the endpoint. 38 | """ 39 | 40 | def maybe_to_int(value): 41 | try: 42 | return int(value) 43 | except ValueError: 44 | return value 45 | 46 | path = list(map(maybe_to_int, path.split("."))) 47 | path = [] if path == [""] else path 48 | 49 | return { 50 | "id": record_id, 51 | "path": path, 52 | "args": args, 53 | "command": command, 54 | "table": table, 55 | } 56 | 57 | 58 | def operation_update_last_edited(user_id, record_id) -> dict: 59 | """ 60 | Convenience function for constructing "last edited" operation. 61 | 62 | When transactions are submitted from the web UIit also 63 | includes an operation to update the "last edited" fields, 64 | so we want to send those too, for consistency. 65 | 66 | 67 | Arguments 68 | --------- 69 | user_id : str 70 | User ID 71 | 72 | record_id : str 73 | ID of the object. 74 | 75 | 76 | Returns 77 | ------- 78 | dict 79 | Constructed dict with last edited operation included. 80 | """ 81 | return build_operations( 82 | record_id=record_id, 83 | path="", 84 | args={"last_edited_by": user_id, "last_edited_time": now()}, 85 | command="update", 86 | ) 87 | -------------------------------------------------------------------------------- /notion/record.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Callable, Union, Iterable, Any 3 | 4 | from notion.settings import BASE_URL 5 | from notion.store import Callback 6 | from notion.utils import extract_id, get_by_path 7 | 8 | 9 | class Record: 10 | """ 11 | Basic collection of information about a notion-like block. 12 | """ 13 | 14 | _type = "" 15 | _table = "" 16 | _str_fields = "id" 17 | _child_list_key = None 18 | 19 | def __init__(self, client, block_id: str, *args, **kwargs): 20 | """ 21 | Create record object and fill its fields. 22 | """ 23 | self._children = None 24 | self._callbacks = [] 25 | self._client = client 26 | self._id = extract_id(block_id) 27 | 28 | if self._client._monitor is not None: 29 | self._client._monitor.subscribe(self) 30 | 31 | def __repr__(self) -> str: 32 | """ 33 | Return string representation of the object. 34 | 35 | 36 | Returns 37 | ------- 38 | str 39 | String with details about the object. 40 | """ 41 | fields = {} 42 | klass_chain = self.__class__.__mro__[:-1] 43 | 44 | for klass in reversed(klass_chain): 45 | for f in self._get_str_fields(klass): 46 | v = getattr(self, f) 47 | if v: 48 | fields[f] = f"{f}={repr(v)}" 49 | 50 | # skip printing type if its something else than just a Block 51 | if getattr(klass_chain[0], "_type", "") != "block": 52 | fields.pop("type", None) 53 | 54 | joined_fields = ", ".join(fields.values()) 55 | return f"<{self.__class__.__name__} ({joined_fields})>" 56 | 57 | def __hash__(self) -> int: 58 | """ 59 | Unique value computed based on the ID. 60 | 61 | 62 | Returns 63 | ------- 64 | int 65 | Computed hash value. 66 | """ 67 | return hash(self.id) 68 | 69 | def __eq__(self, other) -> bool: 70 | """ 71 | Compare the objects by their ID. 72 | 73 | 74 | Arguments 75 | --------- 76 | other : Record 77 | Other record to compare. 78 | 79 | 80 | Returns 81 | ------- 82 | bool 83 | Whether or not the objects are the same. 84 | """ 85 | return self.id == other.id 86 | 87 | def __ne__(self, other): 88 | """ 89 | Compare the objects by their ID. 90 | 91 | 92 | Arguments 93 | --------- 94 | other : Record 95 | Other record to compare. 96 | 97 | 98 | Returns 99 | ------- 100 | bool 101 | Whether or not the objects are different. 102 | """ 103 | return self.id != other.id 104 | 105 | @staticmethod 106 | def _get_str_fields(klass) -> list: 107 | """ 108 | Get list of fields that should be used for printing the Record. 109 | 110 | Returns 111 | ------- 112 | list 113 | List of strings. 114 | """ 115 | str_fields = getattr(klass, "_str_fields", []) 116 | 117 | if isinstance(str_fields, str): 118 | return [str_fields] 119 | 120 | elif isinstance(str_fields, Iterable): 121 | return list(str_fields) 122 | 123 | else: 124 | raise ValueError( 125 | f"{klass.__name__}._str_fields is not an iterable or a str" 126 | ) 127 | 128 | def _convert_diff_to_changelist(self, difference: list, old_val, new_val) -> list: 129 | """ 130 | Convert difference between field values into a changelist. 131 | 132 | 133 | Arguments 134 | --------- 135 | difference : list 136 | List of changes needed to consider. 137 | 138 | old_val 139 | Previous value. 140 | 141 | new_val 142 | New value. 143 | 144 | 145 | Returns 146 | ------- 147 | list 148 | Changelist converted from different values. 149 | """ 150 | changed_values = set() 151 | for operation, path, values in deepcopy(difference): 152 | path = path.split(".") if isinstance(path, str) else path 153 | if operation in ["add", "remove"]: 154 | path.append(values[0][0]) 155 | while isinstance(path[-1], int): 156 | path.pop() 157 | changed_values.add(".".join(map(str, path))) 158 | 159 | return [ 160 | ( 161 | "changed_value", 162 | path, 163 | (get_by_path(path, old_val), get_by_path(path, new_val)), 164 | ) 165 | for path in changed_values 166 | ] 167 | 168 | def _get_record_data(self, force_refresh: bool = False) -> dict: 169 | """ 170 | Get record data. 171 | 172 | 173 | Arguments 174 | --------- 175 | force_refresh : bool, optional 176 | Whether or not to force object refresh. 177 | Defaults to False. 178 | 179 | 180 | Returns 181 | ------- 182 | dict 183 | Record data. 184 | """ 185 | return self._client.get_record_data( 186 | self._table, self.id, force_refresh=force_refresh 187 | ) 188 | 189 | @property 190 | def space_info(self): 191 | data = {"blockId": self.id} 192 | return self._client.post("getPublicPageData", data=data).json() 193 | 194 | @property 195 | def url(self) -> str: 196 | """ 197 | Get the URL. 198 | 199 | 200 | Returns 201 | ------- 202 | str 203 | URL ro Record. 204 | """ 205 | return f'{BASE_URL}{self.id.replace("-", "")}' 206 | 207 | @property 208 | def id(self) -> str: 209 | """ 210 | Get the Record ID. 211 | 212 | 213 | Returns 214 | ------- 215 | str 216 | Record ID 217 | """ 218 | return self._id 219 | 220 | @property 221 | def role(self) -> str: 222 | """ 223 | Get the Record role. 224 | 225 | 226 | Returns 227 | ------- 228 | str 229 | Record role 230 | """ 231 | return self._client._store.get_role(self._table, self._id) 232 | 233 | def add_callback( 234 | self, cb: Callable, cb_id: str = "", **extra_kwargs: dict 235 | ) -> Callback: 236 | """ 237 | Add callback function to listeners. 238 | 239 | 240 | Arguments 241 | --------- 242 | cb : Callable 243 | Function that should be called. 244 | 245 | cb_id : str, optional 246 | Identification key for the callback. 247 | Defaults to random UUID string. 248 | 249 | extra_kwargs : dict, optional 250 | Additional information that should be passed 251 | to callback when executed. 252 | Defaults to empty dict. 253 | 254 | 255 | Returns 256 | ------- 257 | Callback 258 | Callback object. 259 | """ 260 | cb = self._client._store.add_callback( 261 | self, cb, callback_id=cb_id, extra_kwargs=extra_kwargs 262 | ) 263 | self._callbacks.append(cb) 264 | return cb 265 | 266 | def remove_callbacks(self, cb_or_cb_id_prefix: Union[Callback, str] = None): 267 | """ 268 | Remove one or more callbacks based on their ID prefix. 269 | 270 | 271 | Arguments 272 | --------- 273 | cb_or_cb_id_prefix: Callback or str, optional 274 | Callback to remove or prefix of callback IDs to remove. 275 | """ 276 | if cb_or_cb_id_prefix is None: 277 | for callback_obj in list(self._callbacks): 278 | self._client._store.remove_callbacks( 279 | table=self._table, 280 | record_id=self.id, 281 | cb_or_cb_id_prefix=callback_obj, 282 | ) 283 | self._callbacks = [] 284 | 285 | else: 286 | self._client._store.remove_callbacks( 287 | table=self._table, 288 | record_id=self.id, 289 | cb_or_cb_id_prefix=cb_or_cb_id_prefix, 290 | ) 291 | if cb_or_cb_id_prefix in self._callbacks: 292 | self._callbacks.remove(cb_or_cb_id_prefix) 293 | 294 | def get( 295 | self, 296 | path: str = "", 297 | default: Any = None, 298 | force_refresh: bool = False, 299 | ) -> Union[dict, str]: 300 | """ 301 | Retrieve cached data for this record. 302 | 303 | 304 | Arguments 305 | --------- 306 | path : str, optional 307 | Specifies the field to retrieve the value for. 308 | If no path is supplied, return the entire cached 309 | data structure for this record. 310 | Defaults to empty string. 311 | 312 | default : Any, optional 313 | Default value to return if no value was found 314 | under provided path. 315 | Defaults to None. 316 | 317 | force_refresh : bool, optional 318 | If set to True, force refresh the data cache 319 | from the server before reading the values. 320 | Defaults to False. 321 | 322 | 323 | Returns 324 | ------- 325 | Union[dict, str] 326 | Cached data. 327 | """ 328 | obj = self._get_record_data(force_refresh=force_refresh) 329 | return get_by_path(path=path, obj=obj, default=default) 330 | 331 | def set(self, path: str, value: Any): 332 | """ 333 | Set a specific `value` under the specific `path` 334 | on the record's data structure on the server. 335 | 336 | 337 | Arguments 338 | --------- 339 | path : str 340 | Specifies the field to which set the value. 341 | 342 | value : Any 343 | Value to set under provided path. 344 | """ 345 | self._client.build_and_submit_transaction( 346 | record_id=self.id, 347 | path=path, 348 | args=value, 349 | command="set", 350 | table=self._table, 351 | ) 352 | 353 | def refresh(self): 354 | """ 355 | Update the cached data for this record from the server. 356 | 357 | Data for other records may be updated as a side effect. 358 | """ 359 | self._get_record_data(force_refresh=True) 360 | -------------------------------------------------------------------------------- /notion/renderer.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | import mistletoe 4 | from dominate.tags import * 5 | from dominate.util import raw 6 | from mistletoe import block_token, span_token 7 | from mistletoe.html_renderer import HTMLRenderer as MistletoeHTMLRenderer 8 | 9 | from notion.block.basic import Block 10 | from notion.block.collection.basic import CollectionBlock 11 | 12 | # This is the minimal css stylesheet to apply to get decent looking output. 13 | # It won't make it look exactly like Notion.so but will have the same structure 14 | from notion.settings import CHART_API_URL, TWITTER_API_URL 15 | 16 | HTMLRendererStyles = """ 17 | 92 | """ 93 | 94 | 95 | class MistletoeHTMLRendererSpanTokens(MistletoeHTMLRenderer): 96 | """ 97 | Renders Markdown to HTML without any MD block tokens (like blockquote) 98 | except for the paragraph block token, because you need at least one. 99 | """ 100 | 101 | def __enter__(self): 102 | ret = super().__enter__() 103 | for klass_name in block_token.__all__[:-1]: # All but Paragraph token 104 | block_token.remove_token(getattr(block_token, klass_name)) 105 | 106 | # don't auto-link urls in markdown 107 | span_token.remove_token(span_token.AutoLink) 108 | return ret 109 | 110 | def render_paragraph(self, token): 111 | # Only used for span tokens, so don't render out anything 112 | return self.render_inner(token) 113 | 114 | 115 | def md(content: str): 116 | """ 117 | Render the markdown string to HTML, wrapped with dominate "raw" so Dominate 118 | renders it straight to HTML. 119 | """ 120 | # NOTE: [:-1] because it adds a newline for some reason 121 | # TODO: Follow up on this and make it more robust 122 | # https://github.com/miyuchina/mistletoe/blob/master/mistletoe/block_token.py#L138-L152 123 | return raw(mistletoe.markdown(content, MistletoeHTMLRendererSpanTokens)[:-1]) 124 | 125 | 126 | def handles_children_rendering(func): 127 | setattr(func, "handles_children_rendering", True) 128 | return func 129 | 130 | 131 | class BaseHTMLRenderer: 132 | """ 133 | BaseRenderer for HTML output. 134 | 135 | Uses [Dominate](https://github.com/Knio/dominate) internally for generating HTML output. 136 | Each token rendering method should create a dominate tag and it automatically 137 | gets added to the parent context (because of the with statement). If you return 138 | a given tag, it will be used as the parent container for all rendered children 139 | """ 140 | 141 | def __init__( 142 | self, 143 | start_block: Block, 144 | exclude_ids: list = None, 145 | render_sub_pages: bool = True, 146 | render_with_styles: bool = False, 147 | render_linked_pages: bool = False, 148 | render_table_pages_after_table: bool = False, 149 | render_sub_pages_links: bool = True, 150 | ): 151 | """ 152 | Attributes 153 | ---------- 154 | start_block : Block 155 | The root block to render from. 156 | 157 | exclude_ids : list of str, optional 158 | Optional list of Block IDs to skip when rendering. 159 | Defaults to None. 160 | 161 | render_sub_pages : bool, optional 162 | Whether to render sub pages. 163 | Defaults to True. 164 | 165 | render_sub_pages_links : bool, optional 166 | Whether to render sub pages as a link at the bottom, if render_sub_pages = False 167 | Defaults to False. 168 | 169 | render_with_styles : bool, optional 170 | Whether to include CSS styles inside rendered HTML. 171 | Defaults to False. 172 | 173 | render_linked_pages : bool, optional 174 | Whether to render linked pages as well. 175 | Defaults to False. 176 | 177 | # TODO: what? 178 | render_table_pages_after_table : bool, optional 179 | Whether to render linked pages after table. 180 | Defaults to False. 181 | """ 182 | self._render_stack = [] 183 | self.start_block = start_block 184 | self.exclude_ids = exclude_ids or [] 185 | self.render_sub_pages = render_sub_pages 186 | self.render_with_styles = render_with_styles 187 | self.render_linked_pages = render_linked_pages 188 | self.render_table_pages_after_table = render_table_pages_after_table 189 | self.render_sub_pages_links = render_sub_pages_links 190 | 191 | def _get_previous_sibling_el(self): 192 | """ 193 | Gets the previous sibling element in the rendered HTML tree 194 | """ 195 | if not self._render_stack: 196 | return None 197 | 198 | parent_el = self._render_stack[-1] 199 | 200 | if not parent_el or not parent_el.children: 201 | return None 202 | 203 | return parent_el.children[-1] 204 | 205 | def _render_blocks_into(self, blocks: Iterable[Block], container_el=None): 206 | if container_el is None: 207 | container_el = div(_class="children-list") 208 | 209 | self._render_stack.append(container_el) 210 | 211 | for block in blocks: 212 | container_el.add(self.render_block(block)) 213 | 214 | self._render_stack.pop() 215 | return [container_el] 216 | 217 | def render_block(self, block: Block) -> list: 218 | if block.id in self.exclude_ids: 219 | return [] 220 | 221 | renderer = getattr(self, "render_default", None) 222 | renderer = getattr(self, f"render_{block._type}", renderer) 223 | 224 | if not renderer: 225 | raise ValueError(f"No handler for block type '{block._type}'.") 226 | 227 | elements = renderer(block=block) 228 | 229 | # TODO: find a better way of marking that information 230 | # If the block has no children, or the called function handles 231 | # the child rendering itself, don't render the children 232 | class_function = getattr(self.__class__, renderer.__name__) 233 | renders_children = hasattr(class_function, "handles_children_rendering") 234 | 235 | if not block.children or renders_children: 236 | return elements 237 | 238 | return elements + self._render_blocks_into(block.children, None) 239 | 240 | # == Conversions for rendering notion-py block types to elements == 241 | # Each function should return a list containing dominate tags or a string of HTML 242 | # Marking a function with handles_children_rendering means it handles rendering 243 | # it's own `.children` and doesn't need to perform the default rendering 244 | 245 | def render_default(self, block): 246 | return [p(md(block.title))] 247 | 248 | def render_divider(self, **_): 249 | return [hr()] 250 | 251 | @handles_children_rendering 252 | def render_column_list(self, block): 253 | return self._render_blocks_into( 254 | block.children, div(style="display: flex;", _class="column-list") 255 | ) 256 | 257 | @handles_children_rendering 258 | def render_column(self, block): 259 | return self._render_blocks_into(block.children, div(_class="column")) 260 | 261 | def render_to_do(self, block): 262 | block_id = f"chk_{block.id}" 263 | return [ 264 | div( 265 | [ 266 | input_( 267 | label(_for=block_id), 268 | type="checkbox", 269 | id=block_id, 270 | checked=block.checked, 271 | title=block.title, 272 | ), 273 | span(block.title), 274 | ], 275 | _class="checked" if block.checked else "unchecked", 276 | ) 277 | ] 278 | 279 | def render_code(self, block): 280 | return [pre(code(block.title), _class="code")] 281 | 282 | def render_factory(self, **_): 283 | # TODO: implement this? 284 | return [] 285 | 286 | def render_header(self, block): 287 | return [h2(md(block.title))] 288 | 289 | def render_sub_header(self, block): 290 | return [h3(md(block.title))] 291 | 292 | def render_sub_sub_header(self, block): 293 | return [h4(md(block.title))] 294 | 295 | @handles_children_rendering 296 | def render_page(self, block): 297 | inner_blocks = self._render_blocks_into(block.children) 298 | 299 | # If it's a child of a collection (CollectionBlock) 300 | if isinstance(block.parent, CollectionBlock): 301 | if not self.render_table_pages_after_table: 302 | return [] 303 | 304 | return [h3(md(block.title))] + inner_blocks 305 | 306 | if block.parent.id != block.get("parent_id"): 307 | # A link is a PageBlock where the parent id 308 | # doesn't equal the _actual_ parent id of the block 309 | if not self.render_linked_pages: 310 | # Render only the link, none of the content in the link 311 | return [a(h4(md(block.title)), href=block.url)] 312 | 313 | if not self.render_sub_pages and self._render_stack: 314 | if self.render_sub_pages_links: 315 | # non-direct subpage rendering, use a simple header 316 | return [a(h4(md(block.title), _class="subpage"), href=block.url)] 317 | else: 318 | # do not render subpages as links 319 | return [] 320 | 321 | # render a page normally in it's entirety 322 | # TODO: This should probably not use a "children-list" 323 | # but we need to refactor the _render_stack to make that work 324 | return [h1(md(block.title))] + inner_blocks 325 | 326 | @handles_children_rendering 327 | def render_bulleted_list(self, block): 328 | prev_el = self._get_previous_sibling_el() 329 | if prev_el and isinstance(prev_el, ul): 330 | container_el = prev_el 331 | else: 332 | container_el = ul() 333 | 334 | container_el.add(li(md(block.title))) 335 | self._render_blocks_into(block.children, container_el) 336 | 337 | # Only return if it's not in the rendered output yet 338 | if container_el.parent: 339 | return [] 340 | 341 | return [container_el] 342 | 343 | @handles_children_rendering 344 | def render_numbered_list(self, block): 345 | prev_el = self._get_previous_sibling_el() 346 | if prev_el and isinstance(prev_el, ol): 347 | container_el = prev_el 348 | else: 349 | container_el = ol() 350 | 351 | container_el.add(li(md(block.title))) 352 | self._render_blocks_into(block.children, container_el) 353 | 354 | # Only return if it's not in the rendered output yet 355 | if container_el.parent: 356 | return [] 357 | 358 | return [container_el] 359 | 360 | @handles_children_rendering 361 | def render_toggle(self, block): 362 | return [ 363 | details( 364 | [ 365 | summary(md(block.title)), 366 | self._render_blocks_into(block.children, None), 367 | ] 368 | ) 369 | ] 370 | 371 | def render_quote(self, block): 372 | return [blockquote(md(block.title))] 373 | 374 | def render_text(self, block): 375 | return self.render_default(block) 376 | 377 | def render_equation(self, block): 378 | return [p(img(src=CHART_API_URL + block.latex))] 379 | 380 | def render_embed(self, block): 381 | src = block.display_source or block.source 382 | sandbox = "allow-scripts allow-popups allow-forms allow-same-origin" 383 | el = iframe(src=src, sandbox=sandbox, frameborder=0, allowfullscreen="") 384 | return [el] 385 | 386 | def render_file(self, block): 387 | return self.render_embed(block) 388 | 389 | def render_pdf(self, block): 390 | return self.render_embed(block) 391 | 392 | def render_video(self, block): 393 | # TODO: this won't work if there's no file extension 394 | # we might have to query and get the MIME type 395 | src = block.display_source or block.source 396 | ext = "video/" + src.split(".")[-1] 397 | return [video(source(src=src, type=ext), controls=True)] 398 | 399 | def render_audio(self, block): 400 | return [audio(src=block.display_source or block.source, controls=True)] 401 | 402 | def render_image(self, block): 403 | attrs = {"alt": block.caption} if block.caption else {} 404 | src = block.display_source or block.source 405 | path, query = (src.split("?") + [""])[:2] 406 | if query == "": 407 | query = "table=block&id=" + block.id 408 | src = path + "?" + query 409 | return [img(src=src, **attrs)] 410 | 411 | def render_bookmark(self, **_): 412 | # return bookmark_template.format(link=, title=block.title, description=block.description, icon=block.bookmark_icon, cover=block.bookmark_cover) 413 | # TODO: It's just a social share card for the website we're bookmarking 414 | return [a(href="block.link")] 415 | 416 | def render_link_to_collection(self, block): 417 | return [a(md(block.title), href=block.url)] 418 | 419 | def render_breadcrumb(self, block): 420 | return [p(md(block.title))] 421 | 422 | def render_collection_view_page(self, block): 423 | return self.render_link_to_collection(block) 424 | 425 | def render_framer(self, block): 426 | return self.render_embed(block) 427 | 428 | def render_tweet(self, block): 429 | url = TWITTER_API_URL + block.source 430 | return block._client.get(url).json()["html"] 431 | 432 | def render_gist(self, block): 433 | return self.render_embed(block) 434 | 435 | def render_drive(self, block): 436 | return self.render_embed(block) 437 | 438 | def render_figma(self, block): 439 | return self.render_embed(block) 440 | 441 | def render_loom(self, block): 442 | return self.render_embed(block) 443 | 444 | def render_typeform(self, block): 445 | return self.render_embed(block) 446 | 447 | def render_codepen(self, block): 448 | return self.render_embed(block) 449 | 450 | def render_maps(self, block): 451 | return self.render_embed(block) 452 | 453 | def render_invision(self, block): 454 | return self.render_embed(block) 455 | 456 | def render_callout(self, block): 457 | icon = div(block.icon, _class="icon") 458 | title = div(md(block.title), _class="text") 459 | return [div([icon, title], _class="callout")] 460 | 461 | def render_collection_view(self, block): 462 | # TODO: render out the table itself 463 | 464 | # Render out all the embedded PageBlocks 465 | if not self.render_table_pages_after_table: 466 | return [] 467 | 468 | collection_divs = self._render_blocks_into(block.collection.get_rows()) 469 | return [h2(block.title)] + collection_divs 470 | 471 | def render( 472 | self, indent: str = " ", pretty: bool = True, xhtml: bool = False 473 | ) -> str: 474 | """ 475 | Renders the HTML, kwargs takes kwargs for render() function 476 | https://github.com/Knio/dominate#rendering 477 | 478 | 479 | Attributes 480 | ---------- 481 | indent : str, optional 482 | String used for indenting the rendered text. 483 | Defaults to str consisting of two spaces. 484 | 485 | pretty : bool, optional 486 | Whether or not to render the HTML in a human-readable way. 487 | Defaults to True. 488 | 489 | xhtml : bool, False 490 | Whether or not to use XHTML instead of HTML. 491 | Example:
instead of
492 | Defaults to False. 493 | 494 | 495 | Returns 496 | ------- 497 | str 498 | Rendered blocks. 499 | """ 500 | 501 | def _render_el(e): 502 | if isinstance(e, dom_tag): 503 | return e.render(indent=indent, pretty=pretty, xhtml=xhtml) 504 | 505 | return e 506 | 507 | styles = HTMLRendererStyles if self.render_with_styles else "" 508 | blocks = self.render_block(self.start_block) 509 | rendered = "".join(_render_el(e) for e in blocks) 510 | 511 | return styles + rendered 512 | -------------------------------------------------------------------------------- /notion/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | BASE_URL = "https://www.notion.so/" 5 | API_BASE_URL = BASE_URL + "api/v3/" 6 | 7 | SIGNED_URL_PREFIX = "https://www.notion.so/signed/" 8 | MESSAGE_STORE_URL = "https://msgstore.www.notion.so/primus/" 9 | S3_URL_PREFIX = "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/" 10 | 11 | # for rendering 12 | EMBED_API_URL = "https://api.embed.ly/1/oembed?key=421626497c5d4fc2ae6b075189d602a2" 13 | CHART_API_URL = "https://chart.googleapis.com/chart?cht=tx&chl=" 14 | TWITTER_API_URL = "https://publish.twitter.com/oembed?url=" 15 | 16 | NOTION_DATA_DIR = Path(os.environ.get("XDG_DATA_HOME") or "~") 17 | NOTION_DATA_DIR = Path(os.environ.get("NOTION_DATA_DIR") or NOTION_DATA_DIR) 18 | NOTION_DATA_DIR = Path(NOTION_DATA_DIR / ".notion-py") 19 | NOTION_DATA_DIR = os.path.expanduser(str(NOTION_DATA_DIR)) 20 | os.makedirs(NOTION_DATA_DIR, exist_ok=True) 21 | 22 | NOTION_LOG_FILE = str(Path(NOTION_DATA_DIR) / "notion.log") 23 | NOTION_CACHE_DIR = str(Path(NOTION_DATA_DIR) / "cache") 24 | os.makedirs(NOTION_CACHE_DIR, exist_ok=True) 25 | 26 | NOTION_LOG_LEVEL = os.environ.get("NOTION_LOG_LEVEL", "WARNING").upper() 27 | -------------------------------------------------------------------------------- /notion/space.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from notion.block.basic import PageBlock 4 | from notion.block.collection.media import CollectionViewPageBlock 5 | from notion.maps import field_map 6 | from notion.record import Record 7 | 8 | 9 | class NotionSpace(Record): 10 | """ 11 | Class representing notion's Space - user workplace. 12 | """ 13 | 14 | _type = "space" 15 | _table = "space" 16 | _str_fields = "name", "domain" 17 | _child_list_key = "pages" 18 | 19 | name = field_map("name") 20 | domain = field_map("domain") 21 | icon = field_map("icon") 22 | 23 | @property 24 | def pages(self) -> list: 25 | # The page list includes pages the current user 26 | # might not have permissions on, so it's slow to query. 27 | # Instead, we just filter for pages with the space as the parent. 28 | return self._client.search_pages_with_parent(self.id) 29 | 30 | @property 31 | def users(self) -> list: 32 | ids = [p["user_id"] for p in self.get("permissions")] 33 | self._client.refresh_records(notion_user=ids) 34 | return [self._client.get_user(uid) for uid in ids] 35 | 36 | def add_page( 37 | self, title, type: str = "page", shared: bool = False 38 | ) -> Union[PageBlock, CollectionViewPageBlock]: 39 | """ 40 | Create new page. 41 | 42 | 43 | Arguments 44 | --------- 45 | title : str 46 | Title for the newly created page. 47 | 48 | type : str, optional 49 | Type of the page. Must be one of "page" or "collection_view_page". 50 | Defaults to "page". 51 | 52 | shared : bool, optional 53 | Whether or not the page should be shared (public). 54 | TODO: is it true? 55 | Defaults to False. 56 | """ 57 | perms = [ 58 | { 59 | "role": "editor", 60 | "type": "user_permission", 61 | "user_id": self._client.current_user.id, 62 | } 63 | ] 64 | 65 | if shared: 66 | perms = [{"role": "editor", "type": "space_permission"}] 67 | 68 | page_id = self._client.create_record( 69 | "block", self, type=type, permissions=perms 70 | ) 71 | page = self._client.get_block(page_id) 72 | page.title = title 73 | return page 74 | -------------------------------------------------------------------------------- /notion/store.py: -------------------------------------------------------------------------------- 1 | import json 2 | from threading import Thread 3 | import uuid 4 | from collections import defaultdict 5 | from typing import Callable 6 | from copy import deepcopy 7 | from inspect import signature 8 | from pathlib import Path 9 | from threading import Lock 10 | from typing import Union 11 | 12 | from dictdiffer import diff 13 | from tzlocal import get_localzone 14 | 15 | from notion.logger import logger 16 | from notion.settings import NOTION_CACHE_DIR 17 | from notion.utils import extract_id, to_list 18 | 19 | 20 | class MissingClass: 21 | def __bool__(self): 22 | return False 23 | 24 | 25 | Missing = MissingClass() 26 | 27 | 28 | class Callback: 29 | def __init__(self, callback: Callable, record, callback_id: str = None, **kwargs): 30 | self.callback = callback 31 | self.record = record 32 | self.callback_id = callback_id or str(uuid.uuid4()) 33 | self.extra_kwargs = kwargs 34 | 35 | def __call__(self, difference, old_val, new_val): 36 | kwargs = {} 37 | kwargs.update(self.extra_kwargs) 38 | kwargs["record"] = self.record 39 | kwargs["callback_id"] = self.callback_id 40 | kwargs["difference"] = difference 41 | kwargs["changes"] = self.record._convert_diff_to_changelist( 42 | difference, old_val, new_val 43 | ) 44 | 45 | logger.debug(f"Firing callback {self.callback} with kwargs: {kwargs}") 46 | 47 | # trim down the passed parameters 48 | # to include only those the callback will accept 49 | params = signature(self.callback).parameters 50 | if not any(["**" in str(p) for p in params.values()]): 51 | # there's no "**kwargs" in the callback signature, 52 | # so remove any unaccepted params 53 | for arg in kwargs.keys(): 54 | if arg not in params: 55 | del kwargs[arg] 56 | 57 | # perform the callback, gracefully handling any exceptions 58 | try: 59 | # trigger the callback within its own thread, 60 | # so it won't block others if it's long-running 61 | Thread(target=self.callback, kwargs=kwargs, daemon=True).start() 62 | except Exception as e: 63 | logger.error( 64 | f"Error while processing callback for {repr(self.record)}: {repr(e)}" 65 | ) 66 | 67 | def __eq__(self, value: Union["Callback", str]) -> bool: 68 | if isinstance(value, str): 69 | return self.callback_id.startswith(value) 70 | 71 | if isinstance(value, Callback): 72 | return self.callback_id == value.callback_id 73 | 74 | return False 75 | 76 | 77 | class RecordStore: 78 | """ 79 | Central Record Store. 80 | """ 81 | 82 | def __init__(self, client, cache_key=None): 83 | self._mutex = Lock() 84 | self._client = client 85 | self._cache_key = cache_key 86 | self._values = defaultdict(lambda: defaultdict(dict)) 87 | self._role = defaultdict(lambda: defaultdict(str)) 88 | self._collection_row_ids = {} 89 | self._callbacks = defaultdict(lambda: defaultdict(list)) 90 | self._records_to_refresh = {} 91 | self._pages_to_refresh = [] 92 | with self._mutex: 93 | self._load_cache() 94 | 95 | def _get(self, table: str, record_id: str): 96 | return self._values[table].get(record_id, Missing) 97 | 98 | def _get_cache_path(self, attribute): 99 | file = f"{self._cache_key}{attribute}.json" 100 | return str(Path(NOTION_CACHE_DIR) / file) 101 | 102 | def _load_cache(self, attributes=("_values", "_role", "_collection_row_ids")): 103 | if not self._cache_key: 104 | return 105 | 106 | for attr in attributes: 107 | try: 108 | with open(self._get_cache_path(attr)) as f: 109 | if attr == "_collection_row_ids": 110 | self._collection_row_ids.update(json.load(f)) 111 | 112 | else: 113 | for k, v in json.load(f).items(): 114 | getattr(self, attr)[k].update(v) 115 | 116 | except (FileNotFoundError, ValueError): 117 | pass 118 | 119 | def _save_cache(self, attribute): 120 | if not self._cache_key: 121 | return 122 | 123 | with open(self._get_cache_path(attribute), "w") as f: 124 | json.dump(getattr(self, attribute), f) 125 | 126 | def _trigger_callbacks(self, table, record_id, difference, old_val, new_val): 127 | for callback_obj in self._callbacks[table][record_id]: 128 | callback_obj(difference, old_val, new_val) 129 | 130 | def set_collection_rows(self, collection_id: str, row_ids): 131 | if collection_id in self._collection_row_ids: 132 | old_ids = set(self.get_collection_rows(collection_id)) 133 | new_ids = set(row_ids) 134 | args = { 135 | "table": "collection", 136 | "record_id": collection_id, 137 | "old_val": old_ids, 138 | "new_val": new_ids, 139 | } 140 | 141 | for i in new_ids - old_ids: 142 | args["difference"] = [("row_added", "rows", i)] 143 | self._trigger_callbacks(**args) 144 | 145 | for i in old_ids - new_ids: 146 | args["difference"] = [("row_removed", "rows", i)] 147 | self._trigger_callbacks(**args) 148 | 149 | self._collection_row_ids[collection_id] = row_ids 150 | self._save_cache("_collection_row_ids") 151 | 152 | def get_collection_rows(self, collection_id): 153 | return self._collection_row_ids.get(collection_id, []) 154 | 155 | def get_role(self, table, record_id, force_refresh=False): 156 | self.get(table, record_id, force_refresh=force_refresh) 157 | return self._role[table].get(id, None) 158 | 159 | def get(self, table, url_or_id, force_refresh=False): 160 | rid = extract_id(url_or_id) 161 | # look up the record in the current local dataset 162 | result = self._get(table, rid) 163 | # if it's not found, try refreshing the record from the server 164 | if result is Missing or force_refresh: 165 | if table == "block": 166 | self.call_load_page_chunk(rid) 167 | else: 168 | self.call_get_record_values(**{table: rid}) 169 | result = self._get(table, rid) 170 | return result if result is not Missing else None 171 | 172 | def add_callback( 173 | self, record, callback: Callable, callback_id=None, **extra_kwargs 174 | ): 175 | if not callable(callback): 176 | raise ValueError(f"The callback {callback} must be a callable.") 177 | 178 | self.remove_callbacks(record._table, record.id, callback_id) 179 | callback_obj = Callback( 180 | callback, record, callback_id=callback_id, extra_kwargs=extra_kwargs 181 | ) 182 | self._callbacks[record._table][record.id].append(callback_obj) 183 | return callback_obj 184 | 185 | def remove_callbacks(self, table, record_id: str, cb_or_cb_id_prefix=""): 186 | """ 187 | Remove all callbacks for the record specified 188 | by `table` and `id` that have a callback_id 189 | starting with the string `cb_or_cb_id_prefix`, 190 | or are equal to the provided callback. 191 | """ 192 | if cb_or_cb_id_prefix is None: 193 | return 194 | 195 | callbacks = self._callbacks[table][record_id] 196 | while cb_or_cb_id_prefix in callbacks: 197 | callbacks.remove(cb_or_cb_id_prefix) 198 | 199 | def _update_record(self, table, record_id, value=None, role=None): 200 | callback_queue = [] 201 | 202 | with self._mutex: 203 | if role: 204 | logger.debug(f"Updating 'role' for '{table}/{record_id}' to '{role}'") 205 | self._role[table][record_id] = role 206 | self._save_cache("_role") 207 | if value: 208 | p_value = json.dumps(value, indent=2) 209 | logger.debug( 210 | f"Updating 'value' for '{table}/{record_id}' to \n{p_value}" 211 | ) 212 | old_val = self._values[table][record_id] 213 | difference = list( 214 | diff( 215 | old_val, 216 | value, 217 | ignore=["version", "last_edited_time", "last_edited_by"], 218 | expand=True, 219 | ) 220 | ) 221 | self._values[table][record_id] = value 222 | self._save_cache("_values") 223 | if old_val and difference: 224 | p_difference = json.dumps(value, indent=2) 225 | logger.debug(f"Value changed! Difference:\n{p_difference}") 226 | callback = (table, record_id, difference, old_val, value) 227 | callback_queue.append(callback) 228 | 229 | # run callbacks outside the mutex to avoid lockups 230 | for cb in callback_queue: 231 | self._trigger_callbacks(*cb) 232 | 233 | def call_get_record_values(self, **kwargs): 234 | """ 235 | Call the server's getRecordValues endpoint 236 | to update the local record store. 237 | The keyword arguments map table names into lists 238 | of (or singular) record IDs to load for that table. 239 | Use True to refresh all known records for that table. 240 | """ 241 | requests = [] 242 | 243 | for table, ids in kwargs.items(): 244 | # TODO: ids can be `True` and if it is then we take every 245 | # key from collection_view into consideration, is it OK? 246 | if ids is True: 247 | ids = self._values.get(table, {}).keys() 248 | ids = to_list(ids) 249 | 250 | # if we're in a transaction, add the requested IDs 251 | # to a queue to refresh when the transaction completes 252 | if self._client.in_transaction(): 253 | records = self._records_to_refresh.get(table, []) + ids 254 | self._records_to_refresh[table] = list(set(records)) 255 | continue 256 | 257 | requests += [{"table": table, "id": extract_id(i)} for i in ids] 258 | 259 | if requests: 260 | logger.debug(f"Calling 'getRecordValues' endpoint for requests: {requests}") 261 | data = {"requests": requests} 262 | data = self._client.post("getRecordValues", data).json() 263 | results = data["results"] 264 | 265 | for request, result in zip(requests, results): 266 | self._update_record( 267 | table=request["table"], 268 | record_id=request["id"], 269 | value=result.get("value"), 270 | role=result.get("role"), 271 | ) 272 | 273 | def get_current_version(self, table, record_id): 274 | values = self._get(table, record_id) 275 | if values and "version" in values: 276 | return values["version"] 277 | 278 | return -1 279 | 280 | def call_load_page_chunk(self, page_id): 281 | if self._client.in_transaction(): 282 | self._pages_to_refresh.append(page_id) 283 | return 284 | 285 | data = { 286 | "pageId": page_id, 287 | "limit": 100000, 288 | "cursor": {"stack": []}, 289 | "chunkNumber": 0, 290 | "verticalColumns": False, 291 | } 292 | data = self._client.post("loadPageChunk", data).json() 293 | self.store_record_map(data) 294 | 295 | def store_record_map(self, data: dict) -> dict: 296 | data = data["recordMap"] 297 | for table, records in data.items(): 298 | for record_id, record in records.items(): 299 | self._update_record( 300 | table=table, 301 | record_id=record_id, 302 | value=record.get("value"), 303 | role=record.get("role"), 304 | ) 305 | return data 306 | 307 | def call_query_collection( 308 | self, 309 | collection_id: str, 310 | collection_view_id: str, 311 | search: str = "", 312 | type: str = "table", 313 | aggregate: list = None, 314 | aggregations: list = None, 315 | filter: dict = None, 316 | filter_operator: str = "and", 317 | sort: list = [], 318 | calendar_by: str = "", 319 | group_by: str = "", 320 | ): 321 | # TODO: No idea what this is. 322 | 323 | if aggregate and aggregations: 324 | raise ValueError( 325 | "Use either `aggregate` or `aggregations` (old vs new format)" 326 | ) 327 | 328 | aggregate = to_list(aggregate or []) 329 | aggregations = aggregations or [] 330 | filter = to_list(filter or {}) 331 | sort = to_list(sort or []) 332 | 333 | data = { 334 | "collectionId": collection_id, 335 | "collectionViewId": collection_view_id, 336 | "loader": { 337 | "limit": 10000, 338 | "loadContentCover": True, 339 | "searchQuery": search, 340 | "userLocale": "en", 341 | "userTimeZone": str(get_localzone()), 342 | "type": type, 343 | }, 344 | "query": { 345 | "aggregate": aggregate, 346 | "aggregations": aggregations, 347 | "filter": { 348 | "filters": filter, 349 | "filter_operator": filter_operator, 350 | }, 351 | "sort": sort, 352 | }, 353 | } 354 | data = self._client.post("queryCollection", data).json() 355 | self.store_record_map(data) 356 | 357 | return data["result"] 358 | 359 | def handle_post_transaction_refreshing(self): 360 | for block_id in self._pages_to_refresh: 361 | self.call_load_page_chunk(block_id) 362 | self._pages_to_refresh = [] 363 | 364 | self.call_get_record_values(**self._records_to_refresh) 365 | self._records_to_refresh = {} 366 | 367 | def run_local_operation(self, table, record_id, path, command, args): 368 | with self._mutex: 369 | path = deepcopy(path) 370 | new_val = deepcopy(self._values[table][record_id]) 371 | 372 | ref = new_val 373 | 374 | # loop and descend down the path until it's consumed, 375 | # or if we're doing a "set", there's one key left 376 | while (len(path) > 1) or (path and command != "set"): 377 | comp = path.pop(0) 378 | if comp not in ref: 379 | ref[comp] = [] if "list" in command else {} 380 | ref = ref[comp] 381 | 382 | if not isinstance(ref, dict) and not isinstance(ref, list): 383 | raise ValueError("IDK ev what") 384 | 385 | if command == "update": 386 | ref.update(args) 387 | 388 | if command == "set": 389 | if path: 390 | ref[path[0]] = args 391 | else: 392 | # case for "setting the top level" (i.e. creating a record) 393 | ref.clear() 394 | ref.update(args) 395 | 396 | if command == "listAfter": 397 | if "after" in args: 398 | ref.insert(ref.index(args["after"]) + 1, args["id"]) 399 | else: 400 | ref.append(args["id"]) 401 | 402 | if command == "listBefore": 403 | if "before" in args: 404 | ref.insert(ref.index(args["before"]), args["id"]) 405 | else: 406 | ref.insert(0, args["id"]) 407 | 408 | if command == "listRemove": 409 | try: 410 | ref.remove(args["id"]) 411 | except ValueError: 412 | pass 413 | 414 | self._update_record(table, record_id, value=new_val) 415 | -------------------------------------------------------------------------------- /notion/user.py: -------------------------------------------------------------------------------- 1 | from notion.maps import field_map 2 | from notion.record import Record 3 | 4 | 5 | class NotionUser(Record): 6 | """ 7 | Representation of a Notion user. 8 | """ 9 | 10 | _table = "notion_user" 11 | _str_fields = "email", "full_name" 12 | 13 | user_id = field_map("user_id") 14 | given_name = field_map("given_name") 15 | family_name = field_map("family_name") 16 | email = field_map("email") 17 | locale = field_map("locale") 18 | time_zone = field_map("time_zone") 19 | 20 | @property 21 | def full_name(self) -> str: 22 | """ 23 | Get full user name. 24 | 25 | 26 | Returns 27 | ------- 28 | str 29 | User name. 30 | """ 31 | given = self.given_name or "" 32 | family = self.family_name or "" 33 | return f"{given} {family}".strip() 34 | -------------------------------------------------------------------------------- /notion/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from datetime import datetime 4 | from typing import Any, Optional, Iterator 5 | from urllib.parse import urlparse, parse_qs, quote_plus, unquote_plus 6 | 7 | from slugify import slugify as _dash_slugify 8 | 9 | from notion.settings import ( 10 | BASE_URL, 11 | SIGNED_URL_PREFIX, 12 | S3_URL_PREFIX, 13 | EMBED_API_URL, 14 | ) 15 | 16 | 17 | def to_list(value) -> list: 18 | """ 19 | Wrap value in list if it's not already in a list. 20 | 21 | 22 | Arguments 23 | --------- 24 | value : Any 25 | Value to wrap in list. 26 | 27 | 28 | Returns 29 | ------- 30 | list 31 | List with value inside. 32 | """ 33 | return value if isinstance(value, list) else [value] 34 | 35 | 36 | def from_list(value) -> Any: 37 | """ 38 | Unwrap value from nested list. 39 | 40 | 41 | Arguments 42 | --------- 43 | value : List 44 | Nested list with target value. 45 | 46 | 47 | Returns 48 | ------- 49 | Any 50 | Value from nested list. 51 | """ 52 | if "__iter__" in dir(value) and not isinstance(value, str): 53 | return from_list(next(iter(value), None)) 54 | 55 | return value 56 | 57 | 58 | def now() -> int: 59 | """ 60 | Get UNIX-style time since epoch in seconds. 61 | 62 | 63 | Returns 64 | ------- 65 | int 66 | Time since epoch in seconds. 67 | """ 68 | return int(datetime.now().timestamp() * 1000) 69 | 70 | 71 | def human_size(path: str, divider: int = 1024) -> str: 72 | """ 73 | Get human readable file size. 74 | 75 | 76 | Arguments 77 | --------- 78 | path : str 79 | Path to the file. 80 | 81 | divider : int, optional 82 | Divider used for calculations, use 1000 or 1024. 83 | Defaults to 1024. 84 | 85 | 86 | Returns 87 | ------- 88 | str 89 | Converted size. 90 | """ 91 | size, divider = os.path.getsize(path), float(divider) 92 | size = size / divider if size < divider else size 93 | 94 | for unit in ("KB", "KB", "MB", "GB", "TB"): 95 | if abs(size) < divider: 96 | return f"{size:.1f}{unit}" 97 | size /= divider 98 | 99 | return str(size) 100 | 101 | 102 | def extract_id(source) -> Optional[str]: 103 | """ 104 | Extract the record ID from a block or Notion.so URL. 105 | 106 | If it's a bare page URL, it will be the ID of the page. 107 | If there's a hash with a block ID in it (from clicking "Copy Link") 108 | on a block in a page), it will instead be the ID of that block. 109 | If it's already in ID format, it will be passed right through. 110 | If it's a Block, it will be the ID of a block. 111 | 112 | 113 | Arguments 114 | --------- 115 | source : Block or str 116 | Block or Link to block or its ID. 117 | 118 | 119 | Returns 120 | ------- 121 | str 122 | ID of the block or None. 123 | """ 124 | if not isinstance(source, str): 125 | return source.get("id") 126 | 127 | if source.startswith(BASE_URL): 128 | source = ( 129 | source.split("#")[-1] 130 | .split("/")[-1] 131 | .split("&p=")[-1] 132 | .split("?")[0] 133 | .split("-")[-1] 134 | ) 135 | 136 | try: 137 | return str(uuid.UUID(source)) 138 | except ValueError: 139 | return None 140 | 141 | 142 | def get_embed_link(source_url: str, client) -> str: 143 | """ 144 | Get embed link. 145 | 146 | 147 | Arguments 148 | --------- 149 | source_url : str 150 | Source URL from which the embedded link will be extracted. 151 | 152 | client : NotionClient 153 | Client used for sending the actual request. 154 | 155 | 156 | Returns 157 | ------- 158 | str 159 | Extracted link. 160 | """ 161 | data = client.get(f"{EMBED_API_URL}&url={source_url}").json() 162 | 163 | if "html" not in data: 164 | return source_url 165 | 166 | url = data["html"].split('src="')[1].split('"')[0] 167 | return parse_qs(urlparse(url).query)["src"][0] 168 | 169 | 170 | def add_signed_prefix_as_needed(url: str, client=None) -> str: 171 | """ 172 | Utility function for adding signed prefix to URL. 173 | 174 | 175 | Arguments 176 | --------- 177 | url : str 178 | URL to operate on. 179 | 180 | client : NotionClient, optional 181 | It's used for making wrapped requests via 182 | initialized requests.Session object. 183 | Defaults to None. 184 | 185 | 186 | Returns 187 | ------- 188 | str 189 | Prefixed URL. 190 | """ 191 | if not url: 192 | return "" 193 | 194 | if url.startswith(S3_URL_PREFIX): 195 | path, query = (url.split("?") + [""])[:2] 196 | url = f"{SIGNED_URL_PREFIX}{quote_plus(path)}?{query}" 197 | 198 | if client: 199 | url = client.session.head(url).headers.get("Location", url) 200 | 201 | return url 202 | 203 | 204 | def remove_signed_prefix_as_needed(url: str) -> str: 205 | """ 206 | Utility function for removing signed prefix from URL. 207 | 208 | 209 | Arguments 210 | --------- 211 | url : str 212 | URL to operate on. 213 | 214 | 215 | Returns 216 | ------- 217 | str 218 | Non-prefixed URL. 219 | """ 220 | if url.startswith(SIGNED_URL_PREFIX): 221 | url = unquote_plus(url[len(S3_URL_PREFIX) :]) 222 | 223 | return url or "" 224 | 225 | 226 | def slugify(text: str) -> str: 227 | """ 228 | Convert text to computer-friendly simplified form. 229 | 230 | 231 | Arguments 232 | --------- 233 | text : str 234 | String to operate on. 235 | 236 | 237 | Returns 238 | ------- 239 | str 240 | Converted string. 241 | """ 242 | return _dash_slugify(text).replace("-", "_") 243 | 244 | 245 | def split_on_dot(path: str) -> Iterator[str]: 246 | """ 247 | Convert path (i.e "path.to.0.some.key") to an iterator of keys 248 | worth trying out when traversing some data structure in depth. 249 | 250 | 251 | Arguments 252 | --------- 253 | path : str 254 | Path in string form. 255 | 256 | 257 | Returns 258 | ------- 259 | Iterator[str] 260 | Iterator with all possible keys. 261 | """ 262 | pos = 0 263 | 264 | while True: 265 | pos = path.find(".", pos) 266 | if pos == -1: 267 | break 268 | 269 | yield path[: pos + 0] 270 | yield path[: pos + 1] 271 | pos += 2 272 | 273 | yield path 274 | 275 | 276 | def get_by_path(path: str, obj: Any, default: Any = None) -> Any: 277 | """ 278 | Get value from object's key by dotted path (i.e. "path.to.0.some.key"). 279 | 280 | 281 | Arguments 282 | --------- 283 | path : str 284 | Path in string form. 285 | 286 | obj : Any 287 | Object to traverse. 288 | 289 | default: Any, optional 290 | Default value if key was invalid. 291 | Defaults to None. 292 | 293 | 294 | Returns 295 | ------- 296 | Any 297 | Value stored under specified key or default value. 298 | """ 299 | offset = 0 300 | 301 | for key in split_on_dot(path): 302 | key = key[offset:] 303 | key_len = len(key) + 1 # the 1 accounts for the dot 304 | 305 | if key: 306 | if isinstance(obj, dict): 307 | if key in obj: 308 | obj = obj[key] 309 | offset += key_len 310 | 311 | elif isinstance(obj, list): 312 | try: 313 | idx = int(key) 314 | except ValueError: 315 | return default 316 | 317 | if idx >= len(obj): 318 | return default 319 | 320 | obj = obj[idx] 321 | offset += key_len 322 | 323 | # we don't support other types 324 | else: 325 | return default 326 | 327 | # in case path was not fully traversed 328 | if len(path) != offset - 1: 329 | return default 330 | 331 | return obj 332 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | certifi==2020.6.20 2 | chardet==3.0.4 3 | commonmark==0.9.1 4 | dictdiffer==0.8.1 5 | dominate==2.5.2 6 | idna==2.10 7 | mistletoe==0.7.2 8 | python-slugify==4.0.1 9 | pytz==2020.1 10 | requests==2.24.0 11 | text-unidecode==1.3 12 | tzlocal==2.1 13 | urllib3==1.25.10 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.24.0 2 | commonmark==0.9.1 3 | tzlocal==2.1 4 | python-slugify==4.0.1 5 | dictdiffer==0.8.1 6 | mistletoe==0.7.2 7 | dominate==2.5.2 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | from notion import ( 4 | __name__, 5 | __version__, 6 | __author__, 7 | __author_email__, 8 | __description__, 9 | __url__, 10 | ) 11 | 12 | with open("README.md") as file: 13 | long_description = file.read() 14 | 15 | with open("requirements.txt") as file: 16 | r = file.read().split("\n") 17 | r = map(lambda l: l.strip(), filter(len, r)) 18 | r = filter(lambda l: not l.startswith("-"), r) 19 | r = filter(lambda l: not l.startswith("#"), r) 20 | install_requires = list(r) 21 | packages = find_packages(include=["notion*"]) 22 | 23 | setup( 24 | url=__url__, 25 | name=__name__, 26 | version=__version__, 27 | author=__author__, 28 | author_email=__author_email__, 29 | maintainer=__author__, 30 | maintainer_email=__author_email__, 31 | description=__description__, 32 | long_description=long_description, 33 | long_description_content_type="text/markdown", 34 | install_requires=install_requires, 35 | include_package_data=True, 36 | packages=packages, 37 | python_requires=">=3.6", 38 | keywords=["python3", "notion", "api-client"], 39 | classifiers=[ 40 | "License :: OSI Approved :: MIT License", 41 | "Programming Language :: Python :: 3", 42 | "Operating System :: OS Independent", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /smoke_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/smoke_tests/__init__.py -------------------------------------------------------------------------------- /smoke_tests/block/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/smoke_tests/block/__init__.py -------------------------------------------------------------------------------- /smoke_tests/block/collection/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/smoke_tests/block/collection/__init__.py -------------------------------------------------------------------------------- /smoke_tests/block/collection/test_basic.py: -------------------------------------------------------------------------------- 1 | from notion.block.collection.basic import ( 2 | CollectionBlock, 3 | CollectionRowBlock, 4 | TemplateBlock, 5 | ) 6 | from smoke_tests.conftest import assert_block_is_okay 7 | 8 | 9 | def test_collection_block(notion): 10 | # block = notion.root_page.children.add_new(CollectionBlock) 11 | # assert_block_is_okay(**locals(), type="collection") 12 | # TODO: fix this, should it even work at all? 13 | pass 14 | 15 | 16 | def test_collection_row_block(notion): 17 | block = notion.root_page.children.add_new(CollectionRowBlock) 18 | assert_block_is_okay(**locals(), type="page") 19 | 20 | 21 | def test_template_block(notion): 22 | # block = notion.root_page.children.add_new(TemplateBlock) 23 | # assert_block_is_okay(**locals(), type="template") 24 | pass 25 | -------------------------------------------------------------------------------- /smoke_tests/block/collection/test_media.py: -------------------------------------------------------------------------------- 1 | from notion.block.collection.media import CollectionViewPageBlock, CollectionViewBlock 2 | from smoke_tests.conftest import assert_block_attributes, assert_block_is_okay 3 | 4 | 5 | def test_collection_view_block(notion): 6 | block = notion.root_page.children.add_new(CollectionViewBlock) 7 | assert_block_is_okay(**locals(), type="collection_view") 8 | 9 | 10 | def test_collection_view_page_block(notion): 11 | block = notion.root_page.children.add_new(CollectionViewPageBlock) 12 | assert_block_is_okay(**locals(), type="collection_view_page") 13 | assert_block_attributes(block, icon="✔️", cover="cover") 14 | -------------------------------------------------------------------------------- /smoke_tests/block/test_basic.py: -------------------------------------------------------------------------------- 1 | from notion.block.basic import ( 2 | Block, 3 | BulletedListBlock, 4 | CalloutBlock, 5 | CodeBlock, 6 | DividerBlock, 7 | EquationBlock, 8 | HeaderBlock, 9 | NumberedListBlock, 10 | PageBlock, 11 | LinkToPageBlock, 12 | QuoteBlock, 13 | SubHeaderBlock, 14 | SubSubHeaderBlock, 15 | TextBlock, 16 | ToDoBlock, 17 | ToggleBlock, 18 | ColumnBlock, 19 | ColumnListBlock, 20 | FactoryBlock, 21 | ) 22 | from notion.block.collection.media import LinkToCollectionBlock 23 | from smoke_tests.conftest import assert_block_is_okay, assert_block_attributes 24 | 25 | 26 | def test_block(notion): 27 | # create basic block from existing page 28 | block = Block(notion.client, notion.root_page.id) 29 | parent = notion.root_page.parent 30 | assert_block_is_okay(**locals(), type="page") 31 | 32 | assert len(block.children) == 0 33 | assert len(block.parent.id) == 36 34 | assert block.id == notion.root_page.id 35 | 36 | 37 | def test_bulleted_list_block(notion): 38 | block = notion.root_page.children.add_new(BulletedListBlock) 39 | assert_block_is_okay(**locals(), type="bulleted_list") 40 | assert_block_attributes(block, title="bulleted_list") 41 | 42 | 43 | def test_callout_block(notion): 44 | block = notion.root_page.children.add_new(CalloutBlock) 45 | assert_block_is_okay(**locals(), type="callout") 46 | assert_block_attributes(block, icon="✔️", color="blue", title="callout") 47 | 48 | 49 | def test_code_block(notion): 50 | block = notion.root_page.children.add_new(CodeBlock) 51 | assert_block_is_okay(**locals(), type="code") 52 | assert_block_attributes(block, color="blue", language="Go", title="code") 53 | 54 | 55 | def test_column_block(notion): 56 | block = notion.root_page.children.add_new(ColumnBlock) 57 | assert_block_is_okay(**locals(), type="column") 58 | 59 | assert block.column_ratio is None 60 | assert len(block.children) == 0 61 | 62 | block.column_ratio = 1 / 2 63 | block.refresh() 64 | 65 | assert block.column_ratio == 1 / 2 66 | 67 | 68 | def test_column_list_block(notion): 69 | block = notion.root_page.children.add_new(ColumnListBlock) 70 | assert_block_is_okay(**locals(), type="column_list") 71 | 72 | assert len(block.children) == 0 73 | 74 | block.children.add_new(ColumnBlock) 75 | block.children.add_new(ColumnBlock) 76 | block.evenly_space_columns() 77 | block.refresh() 78 | 79 | assert len(block.children) == 2 80 | assert block.children[0].column_ratio == 1 / 2 81 | 82 | 83 | def test_divider_block(notion): 84 | block = notion.root_page.children.add_new(DividerBlock) 85 | assert_block_is_okay(**locals(), type="divider") 86 | 87 | 88 | def test_equation_block(notion): 89 | block = notion.root_page.children.add_new(EquationBlock) 90 | assert_block_is_okay(**locals(), type="equation") 91 | assert_block_attributes(block, title="E=mc^{2}", color="blue") 92 | 93 | 94 | def test_factory_block(notion): 95 | block = notion.root_page.children.add_new(FactoryBlock) 96 | assert_block_is_okay(**locals(), type="factory") 97 | assert_block_attributes(block, title="factory", color="blue") 98 | 99 | 100 | def test_link_to_collection_block(notion): 101 | block = notion.root_page.children.add_new(LinkToCollectionBlock) 102 | assert_block_is_okay(**locals(), type="link_to_collection") 103 | 104 | 105 | def test_numbered_list_block(notion): 106 | block = notion.root_page.children.add_new(NumberedListBlock) 107 | assert_block_is_okay(**locals(), type="numbered_list") 108 | assert_block_attributes(block, title="numbered_list") 109 | 110 | 111 | def test_page_block(notion): 112 | block = notion.root_page.children.add_new(PageBlock) 113 | assert_block_is_okay(**locals(), type="page") 114 | cover = "/images/page-cover/woodcuts_3.jpg" 115 | assert_block_attributes( 116 | block, title="numbered_list", cover=cover, color="blue", icon="✔️" 117 | ) 118 | 119 | 120 | def test_link_to_page_block(notion): 121 | block = notion.root_page.children.add_new(LinkToPageBlock) 122 | assert_block_is_okay(**locals(), type="link_to_page") 123 | assert_block_attributes(block, title="") 124 | 125 | 126 | def test_quote_block(notion): 127 | block = notion.root_page.children.add_new(QuoteBlock) 128 | assert_block_is_okay(**locals(), type="quote") 129 | assert_block_attributes(block, title="quote", color="blue") 130 | 131 | 132 | def test_header_block(notion): 133 | block = notion.root_page.children.add_new(HeaderBlock) 134 | assert_block_is_okay(**locals(), type="header") 135 | assert_block_attributes(block, title="header", color="blue") 136 | 137 | 138 | def test_sub_header_block(notion): 139 | block = notion.root_page.children.add_new(SubHeaderBlock) 140 | assert_block_is_okay(**locals(), type="sub_header") 141 | assert_block_attributes(block, title="subheader", color="blue") 142 | 143 | 144 | def test_sub_sub_header_block(notion): 145 | block = notion.root_page.children.add_new(SubSubHeaderBlock) 146 | assert_block_is_okay(**locals(), type="sub_sub_header") 147 | assert_block_attributes(block, title="subsubheader", color="blue") 148 | 149 | 150 | def test_text_block(notion): 151 | block = notion.root_page.children.add_new(TextBlock) 152 | assert_block_is_okay(**locals(), type="text") 153 | assert_block_attributes(block, title="text", color="blue") 154 | 155 | 156 | def test_to_do_block(notion): 157 | block = notion.root_page.children.add_new(ToDoBlock) 158 | assert_block_is_okay(**locals(), type="to_do") 159 | assert_block_attributes(block, title="text", color="blue", checked=True) 160 | 161 | 162 | def test_toggle_block(notion): 163 | block = notion.root_page.children.add_new(ToggleBlock) 164 | assert_block_is_okay(**locals(), type="toggle") 165 | assert_block_attributes(block, title="text", color="blue") 166 | -------------------------------------------------------------------------------- /smoke_tests/block/test_embed.py: -------------------------------------------------------------------------------- 1 | from smoke_tests.conftest import assert_block_is_okay, assert_block_attributes 2 | from notion.block.embed import ( 3 | GistBlock, 4 | FramerBlock, 5 | FigmaBlock, 6 | InvisionBlock, 7 | LoomBlock, 8 | MapsBlock, 9 | TweetBlock, 10 | TypeformBlock, 11 | BookmarkBlock, 12 | CodepenBlock, 13 | DriveBlock, 14 | EmbedBlock, 15 | ) 16 | 17 | 18 | def test_embed_block(notion): 19 | block = notion.root_page.children.add_new(EmbedBlock) 20 | assert_block_is_okay(**locals(), type="embed") 21 | 22 | assert block.source == "" 23 | assert block.caption == "" 24 | 25 | caption = "block embed caption" 26 | block.caption = caption 27 | block.refresh() 28 | 29 | assert block.caption == caption 30 | 31 | 32 | def test_bookmark_block(notion): 33 | block = notion.root_page.children.add_new(BookmarkBlock) 34 | assert_block_is_okay(**locals(), type="bookmark") 35 | 36 | assert block.link == "" 37 | assert block.title == "" 38 | assert block.source == "" 39 | assert block.description == "" 40 | assert block.bookmark_icon is None 41 | assert block.bookmark_cover is None 42 | 43 | link = "github.com/arturtamborski/notion-py/" 44 | block.set_new_link(link) 45 | block.refresh() 46 | 47 | assert block.link == link 48 | assert block.title == "arturtamborski/notion-py" 49 | assert "This is a fork of the" in block.description 50 | assert "https://" in block.bookmark_icon 51 | assert "https://" in block.bookmark_cover 52 | 53 | block.set_source_url(link) 54 | block.refresh() 55 | 56 | assert block.source == link 57 | assert block.display_source == link 58 | 59 | 60 | def test_codepen_block(notion): 61 | block = notion.root_page.children.add_new(CodepenBlock) 62 | assert_block_is_okay(**locals(), type="codepen") 63 | source = "https://codepen.io/MrWeb123/pen/QWyeQwp" 64 | assert_block_attributes(block, source=source, caption="caption") 65 | 66 | 67 | def test_drive_block(notion): 68 | block = notion.root_page.children.add_new(DriveBlock) 69 | assert_block_is_okay(**locals(), type="drive") 70 | source = "https://drive.google.com/file/" 71 | source = source + "d/15kESeWR9wCWT7GW9VvChakTGin68iZsw/view" 72 | assert_block_attributes(block, source=source, caption="drive") 73 | 74 | 75 | def test_figma_block(notion): 76 | block = notion.root_page.children.add_new(FigmaBlock) 77 | assert_block_is_okay(**locals(), type="figma") 78 | 79 | 80 | def test_framer_block(notion): 81 | block = notion.root_page.children.add_new(FramerBlock) 82 | assert_block_is_okay(**locals(), type="framer") 83 | 84 | 85 | def test_gist_block(notion): 86 | block = notion.root_page.children.add_new(GistBlock) 87 | assert_block_is_okay(**locals(), type="gist") 88 | source = "https://gist.github.com/arturtamborski/" 89 | source = source + "539a335fcd71f88bb8c05f316f54ba31" 90 | assert_block_attributes(block, source=source, caption="caption") 91 | 92 | 93 | def test_invision_block(notion): 94 | block = notion.root_page.children.add_new(InvisionBlock) 95 | assert_block_is_okay(**locals(), type="invision") 96 | 97 | 98 | def test_loom_block(notion): 99 | block = notion.root_page.children.add_new(LoomBlock) 100 | assert_block_is_okay(**locals(), type="loom") 101 | 102 | 103 | def test_maps_block(notion): 104 | block = notion.root_page.children.add_new(MapsBlock) 105 | assert_block_is_okay(**locals(), type="maps") 106 | source = "https://goo.gl/maps/MrLSwJ3YqdkqekuGA" 107 | assert_block_attributes(block, source=source, caption="caption") 108 | 109 | 110 | def test_tweet_block(notion): 111 | block = notion.root_page.children.add_new(TweetBlock) 112 | assert_block_is_okay(**locals(), type="tweet") 113 | source = "https://twitter.com/arturtamborski/status/1289293818609704961" 114 | assert_block_attributes(block, source=source, caption="caption") 115 | 116 | 117 | def test_typeform_block(notion): 118 | block = notion.root_page.children.add_new(TypeformBlock) 119 | assert_block_is_okay(**locals(), type="typeform") 120 | source = "https://linklocal.typeform.com/to/I3lVBn" 121 | assert_block_attributes(block, source=source, caption="caption") 122 | -------------------------------------------------------------------------------- /smoke_tests/block/test_media.py: -------------------------------------------------------------------------------- 1 | from notion.block.media import BreadcrumbBlock 2 | from smoke_tests.conftest import assert_block_is_okay 3 | 4 | 5 | def test_media_block(notion): 6 | pass 7 | # TODO: fix 8 | # block = notion.root_page.children.add_new(MediaBlock) 9 | # assert_block_is_okay(**locals(), type='media') 10 | 11 | 12 | def test_breadcrumb_block(notion): 13 | block = notion.root_page.children.add_new(BreadcrumbBlock) 14 | assert_block_is_okay(**locals(), type="breadcrumb") 15 | -------------------------------------------------------------------------------- /smoke_tests/block/test_upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from notion.block.upload import PdfBlock, ImageBlock, VideoBlock, FileBlock 4 | from smoke_tests.conftest import assert_block_is_okay, assert_block_attributes 5 | 6 | 7 | def test_file_block(notion): 8 | block = notion.root_page.children.add_new(FileBlock) 9 | assert_block_is_okay(**locals(), type="file") 10 | 11 | assert block.title == "" 12 | assert block.source == "" 13 | assert block.file_id is None 14 | 15 | title = "requirements.txt" 16 | block.upload_file(title) 17 | block.title = title 18 | block.refresh() 19 | 20 | assert block.title == title 21 | assert "secure.notion-static.com" in block.source 22 | assert len(block.file_id) == 36 23 | 24 | title_bak = f"{title}.bak" 25 | block.download_file(title_bak) 26 | 27 | assert os.path.isfile(title_bak) 28 | 29 | os.remove(title_bak) 30 | 31 | 32 | def test_image_block(notion): 33 | block = notion.root_page.children.add_new(ImageBlock) 34 | assert_block_is_okay(**locals(), type="image") 35 | 36 | source = "https://raw.githubusercontent.com/jamalex/" 37 | source = source + "notion-py/master/ezgif-3-a935fdcb7415.gif" 38 | 39 | assert_block_attributes(block, source=source, caption="caption") 40 | 41 | 42 | def test_video_block(notion): 43 | block = notion.root_page.children.add_new(VideoBlock) 44 | assert_block_is_okay(**locals(), type="video") 45 | 46 | source = "https://streamable.com/8ud2kh" 47 | 48 | assert_block_attributes(block, source=source, caption="caption") 49 | 50 | 51 | def test_pdf_block(notion): 52 | block = notion.root_page.children.add_new(PdfBlock) 53 | assert_block_is_okay(**locals(), type="pdf") 54 | -------------------------------------------------------------------------------- /smoke_tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | from notion.block.basic import Block 7 | from notion.client import NotionClient 8 | 9 | 10 | class AttrDict(dict): 11 | __getattr__ = dict.__getitem__ 12 | __setattr__ = dict.__setitem__ 13 | 14 | 15 | @dataclass 16 | class NotionTestContext: 17 | client: NotionClient 18 | root_page: Block 19 | store: AttrDict 20 | 21 | 22 | @pytest.fixture(scope="session", autouse=True) 23 | def notion(_cache=[]): 24 | if _cache: 25 | return _cache[0] 26 | 27 | token_v2 = os.environ["NOTION_TOKEN_V2"].strip() 28 | page_url = os.environ["NOTION_PAGE_URL"].strip() 29 | 30 | client = NotionClient(token_v2=token_v2) 31 | page = client.get_block(page_url) 32 | store = AttrDict() 33 | 34 | if page is None: 35 | raise ValueError(f"No such page under url: {page_url}") 36 | 37 | notion = NotionTestContext(client, page, store) 38 | _cache.append(notion) 39 | 40 | yield notion 41 | 42 | clean_root_page(page) 43 | 44 | 45 | def clean_root_page(page): 46 | for child in page.children: 47 | child.remove(permanently=True) 48 | 49 | page.refresh() 50 | 51 | 52 | def assert_block_is_okay(notion, block, type: str, parent=None): 53 | parent = parent or notion.root_page 54 | 55 | assert block.id 56 | assert block.type == type 57 | assert block.alive is True 58 | assert block.is_alias is False 59 | assert block.parent == parent 60 | 61 | 62 | def assert_block_attributes(block, **kwargs): 63 | for attr, value in kwargs.items(): 64 | assert hasattr(block, attr) 65 | setattr(block, attr, value) 66 | 67 | block.refresh() 68 | 69 | for attr, value in kwargs.items(): 70 | assert getattr(block, attr) == value 71 | -------------------------------------------------------------------------------- /smoke_tests/test_workflow.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | from datetime import datetime 4 | 5 | from notion.block.basic import ( 6 | TextBlock, 7 | ToDoBlock, 8 | HeaderBlock, 9 | SubHeaderBlock, 10 | PageBlock, 11 | QuoteBlock, 12 | BulletedListBlock, 13 | CalloutBlock, 14 | ColumnBlock, 15 | ColumnListBlock, 16 | ) 17 | from notion.block.collection.media import CollectionViewBlock 18 | from notion.block.upload import VideoBlock 19 | from smoke_tests.conftest import clean_root_page 20 | 21 | 22 | def test_workflow_1_markdown(notion): 23 | clean_root_page(notion.root_page) 24 | 25 | now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 26 | page = notion.root_page.children.add_new( 27 | PageBlock, 28 | title=f"Smoke test at {now}", 29 | ) 30 | 31 | title = "Some formatting: *italic*, **bold**, ***both***!" 32 | col_list = page.children.add_new(ColumnListBlock) 33 | col1 = col_list.children.add_new(ColumnBlock) 34 | col1kid = col1.children.add_new(TextBlock, title=title) 35 | 36 | assert col_list in page.children 37 | assert col1kid.title.replace("_", "*") == title 38 | assert col1kid.title_plaintext == "Some formatting: italic, bold, both!" 39 | 40 | notion.store.page = page 41 | notion.store.col_list = col_list 42 | 43 | 44 | def test_workflow_2_checkbox(notion): 45 | col_list = notion.store.col_list 46 | 47 | col2 = col_list.children.add_new(ColumnBlock) 48 | col2.children.add_new(ToDoBlock, title="I should be unchecked") 49 | col2.children.add_new(ToDoBlock, title="I should be checked", checked=True) 50 | 51 | assert col2.children[0].checked is False 52 | assert col2.children[1].checked is True 53 | 54 | 55 | def test_workflow_2_media(notion): 56 | page = notion.store.page 57 | col_list = notion.store.col_list 58 | 59 | page.children.add_new(HeaderBlock, title="The finest music:") 60 | video = page.children.add_new(VideoBlock, width=100) 61 | video.set_source_url("https://www.youtube.com/watch?v=oHg5SJYRHA0") 62 | 63 | assert video in page.children.filter(VideoBlock) 64 | assert col_list not in page.children.filter(VideoBlock) 65 | 66 | 67 | def test_workflow_2_alias(notion): 68 | page = notion.store.page 69 | 70 | page.children.add_new(SubHeaderBlock, title="A link back to where I came from:") 71 | alias = page.children.add_alias(notion.root_page) 72 | 73 | assert alias.is_alias 74 | assert not page.is_alias 75 | 76 | url = page.parent.get_browseable_url() 77 | page.children.add_new( 78 | QuoteBlock, title=f"Clicking [here]({url}) should take you to the same place..." 79 | ) 80 | 81 | # ensure __repr__ methods are not breaking 82 | repr(page) 83 | repr(page.children) 84 | for child in page.children: 85 | repr(child) 86 | 87 | 88 | def test_workflow_2_order(notion): 89 | page = notion.store.page 90 | 91 | page.children.add_new(CalloutBlock, title="I am a callout", icon="🤞") 92 | page.children.add_new( 93 | SubHeaderBlock, title="The order of the following should be alphabetical:" 94 | ) 95 | 96 | b = page.children.add_new(BulletedListBlock, title="B") 97 | d = page.children.add_new(BulletedListBlock, title="D") 98 | c2 = page.children.add_new(BulletedListBlock, title="C2") 99 | c1 = page.children.add_new(BulletedListBlock, title="C1") 100 | c = page.children.add_new(BulletedListBlock, title="C") 101 | a = page.children.add_new(BulletedListBlock, title="A") 102 | 103 | d.move_to(c, "after") 104 | a.move_to(b, "before") 105 | c2.move_to(c) 106 | c1.move_to(c, "first-child") 107 | 108 | 109 | def test_workflow_2_collection_view(notion): 110 | page = notion.store.page 111 | 112 | cvb = page.children.add_new(CollectionViewBlock) 113 | cvb.collection = notion.client.get_collection( 114 | notion.client.create_record( 115 | "collection", parent=cvb, schema=get_collection_schema() 116 | ) 117 | ) 118 | cvb.title = "My data!" 119 | view = cvb.views.add_new(view_type="table") 120 | 121 | assert notion.client.get_collection_view(view.id, view.collection) 122 | 123 | notion.store.cvb = cvb 124 | notion.store.view = view 125 | 126 | 127 | def test_workflow_3_collection_row_1(notion): 128 | cvb = notion.store.cvb 129 | 130 | # add a row 131 | row1 = cvb.collection.add_row() 132 | 133 | assert row1.person == [] 134 | 135 | special_code = uuid.uuid4().hex[:8] 136 | row1.name = "Just some data" 137 | row1.title = "Can reference 'title' field too! " + special_code 138 | 139 | assert row1.name == row1.title 140 | 141 | row1.check_yo_self = True 142 | row1.estimated_value = None 143 | row1.estimated_value = 42 144 | row1.files = [ 145 | "https://www.birdlife.org/sites/default/files/styles/1600/public/slide.jpg" 146 | ] 147 | row1.tags = None 148 | row1.tags = [] 149 | row1.tags = ["A", "C"] 150 | row1.where_to = "https://learningequality.org" 151 | row1.category = "A" 152 | row1.category = "" 153 | row1.category = None 154 | row1.category = "B" 155 | 156 | notion.store.row1 = row1 157 | notion.store.special_code = special_code 158 | 159 | 160 | def test_workflow_3_collection_row_2(notion): 161 | cvb = notion.store.cvb 162 | 163 | # add another row 164 | row2 = cvb.collection.add_row( 165 | person=notion.client.current_user, title="Metallic penguins" 166 | ) 167 | 168 | assert row2.person == [notion.client.current_user] 169 | assert row2.name == "Metallic penguins" 170 | 171 | row2.check_yo_self = False 172 | row2.estimated_value = 22 173 | row2.files = [ 174 | "https://www.picclickimg.com/d/l400/pict/223603662103_/Vintage-Small-Monet-and-Jones-JNY-Enamel-Metallic.jpg" 175 | ] 176 | row2.tags = ["A", "B"] 177 | row2.where_to = "https://learningequality.org" 178 | row2.category = "C" 179 | 180 | notion.store.row2 = row2 181 | 182 | 183 | def test_workflow_4_default_query(notion): 184 | row1, row2 = notion.store.row1, notion.store.row2 185 | view = notion.store.view 186 | 187 | result = view.default_query().execute() 188 | 189 | assert row1 == result[0] 190 | assert row2 == result[1] 191 | assert len(result) == 2 192 | 193 | 194 | def test_workflow_4_direct_query(notion): 195 | row1, row2 = notion.store.row1, notion.store.row2 196 | cvb, special_code = notion.store.cvb, notion.store.special_code 197 | 198 | # query the collection directly 199 | assert row1 in cvb.collection.get_rows(search=special_code) 200 | assert row2 not in cvb.collection.get_rows(search=special_code) 201 | assert row1 not in cvb.collection.get_rows(search="penguins") 202 | assert row2 in cvb.collection.get_rows(search="penguins") 203 | 204 | 205 | def test_workflow_4_space_query(notion): 206 | row1, row2 = notion.store.row1, notion.store.row2 207 | cvb, special_code = notion.store.cvb, notion.store.special_code 208 | 209 | # search the entire space 210 | assert row1 in notion.client.search_blocks(search=special_code) 211 | assert row1 not in notion.client.search_blocks(search="penguins") 212 | assert row2 not in notion.client.search_blocks(search=special_code) 213 | assert row2 in notion.client.search_blocks(search="penguins") 214 | 215 | 216 | def test_workflow_4_aggregation_query(notion): 217 | view = notion.store.view 218 | 219 | aggregations = [ 220 | {"property": "estimated_value", "aggregator": "sum", "id": "total_value"} 221 | ] 222 | result = view.build_query(aggregations=aggregations).execute() 223 | 224 | assert result.get_aggregate("total_value") == 64 225 | 226 | 227 | def test_workflow_4_filtered_query(notion): 228 | row1, row2 = notion.store.row1, notion.store.row2 229 | view = notion.store.view 230 | 231 | filter_params = { 232 | "filters": [ 233 | { 234 | "filter": { 235 | "value": { 236 | "type": "exact", 237 | "value": { 238 | "table": "notion_user", 239 | "id": notion.client.current_user.id, 240 | }, 241 | }, 242 | "operator": "person_does_not_contain", 243 | }, 244 | "property": "person", 245 | } 246 | ], 247 | "operator": "and", 248 | } 249 | result = view.build_query(filter=filter_params).execute() 250 | 251 | assert row1 in result 252 | assert row2 not in result 253 | 254 | 255 | def test_workflow_4_sorted_query(notion): 256 | row1, row2 = notion.store.row1, notion.store.row2 257 | view = notion.store.view 258 | 259 | # Run a "sorted" query 260 | sort_params = [{"direction": "ascending", "property": "estimated_value"}] 261 | result = view.build_query(sort=sort_params).execute() 262 | 263 | assert row1 == result[1] 264 | assert row2 == result[0] 265 | 266 | 267 | def test_workflow_5_remove(notion): 268 | page = notion.store.page 269 | 270 | assert page.get("alive") is True 271 | assert page in page.parent.children 272 | 273 | page.remove() 274 | 275 | assert page.get("alive") is False 276 | assert page not in page.parent.children 277 | assert page.space_info, f"Page {page.id} was fully deleted prematurely" 278 | 279 | page.remove(permanently=True) 280 | time.sleep(1) 281 | 282 | assert not page.space_info, f"Page {page.id} was not fully deleted" 283 | 284 | 285 | def get_collection_schema(): 286 | return { 287 | "%9:q": {"name": "Check Yo'self", "type": "checkbox"}, 288 | "=d{|": { 289 | "name": "Tags", 290 | "type": "multi_select", 291 | "options": [ 292 | { 293 | "color": "orange", 294 | "id": "79560dab-c776-43d1-9420-27f4011fcaec", 295 | "value": "A", 296 | }, 297 | { 298 | "color": "default", 299 | "id": "002c7016-ac57-413a-90a6-64afadfb0c44", 300 | "value": "B", 301 | }, 302 | { 303 | "color": "blue", 304 | "id": "77f431ab-aeb2-48c2-9e40-3a630fb86a5b", 305 | "value": "C", 306 | }, 307 | ], 308 | }, 309 | "=d{q": { 310 | "name": "Category", 311 | "type": "select", 312 | "options": [ 313 | { 314 | "color": "orange", 315 | "id": "59560dab-c776-43d1-9420-27f4011fcaec", 316 | "value": "A", 317 | }, 318 | { 319 | "color": "default", 320 | "id": "502c7016-ac57-413a-90a6-64afadfb0c44", 321 | "value": "B", 322 | }, 323 | { 324 | "color": "blue", 325 | "id": "57f431ab-aeb2-48c2-9e40-3a630fb86a5b", 326 | "value": "C", 327 | }, 328 | ], 329 | }, 330 | "LL[(": {"name": "Person", "type": "person"}, 331 | "4Jv$": {"name": "Estimated value", "type": "number"}, 332 | "OBcJ": {"name": "Where to?", "type": "url"}, 333 | "dV$q": {"name": "Files", "type": "file"}, 334 | "title": {"name": "Name", "type": "title"}, 335 | } 336 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/tests/__init__.py -------------------------------------------------------------------------------- /tests/block/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturtamborski/notion-py/f282ad2e0971302f6b6e14e2f029b90987228adf/tests/block/__init__.py -------------------------------------------------------------------------------- /tests/block/test_block.py: -------------------------------------------------------------------------------- 1 | def test_block(): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from notion.utils import * 2 | 3 | 4 | def test_split_on_dot(): 5 | assert list(split_on_dot("schema.?m.J.name")) == [ 6 | "schema", 7 | "schema.", 8 | "schema.?m", 9 | "schema.?m.", 10 | "schema.?m.J", 11 | "schema.?m.J.", 12 | "schema.?m.J.name", 13 | ] 14 | 15 | assert list(split_on_dot("schema.=f)..name")) == [ 16 | "schema", 17 | "schema.", 18 | "schema.=f)", 19 | "schema.=f).", 20 | "schema.=f)..name", 21 | ] 22 | 23 | 24 | def test_get_by_path(): 25 | obj = { 26 | "schema": { 27 | ":vHS": {"name": "Column 13", "type": "text"}, 28 | ":{:U": {"name": "Column 20", "type": "text"}, 29 | ";wKV": {"name": "Column 1", "type": "text"}, 30 | "D": {"name": "Column 11", "type": "text"}, 31 | "?m.J": {"name": "asd", "type": "text"}, 32 | ".yZV": {"name": "Column 14", "type": "text"}, 33 | "AC..": {"name": "Column 19", "type": "text"}, 34 | "=f).": {"name": "Column 20", "type": "text"}, 35 | }, 36 | "something": [ 37 | {"idx1": "neat"}, 38 | {"idx2": "cool"}, 39 | {"idx3": "bruh"}, 40 | ], 41 | } 42 | 43 | # basic checks 44 | assert get_by_path("schema.:vHS.name", obj, False) == "Column 13" 45 | assert get_by_path("schema.:{:U.name", obj, False) == "Column 20" 46 | assert get_by_path("schema.;wKV.type", obj, False) == "text" 47 | assert get_by_path("schema.D", obj, False).__class__.__name__ == "dict" 48 | assert get_by_path("schema.?m.J.name", obj, False) == "asd" 49 | 50 | # weird dot patterns 51 | assert get_by_path("schema..yZV.name", obj, False) == "Column 14" 52 | assert get_by_path("schema.AC...name", obj, False) == "Column 19" 53 | assert get_by_path("schema.=f)..name", obj, False) == "Column 20" 54 | 55 | # invalid keys 56 | assert get_by_path("schema..yZV.nam", obj, False) is False 57 | assert get_by_path("schema.AD...name", obj, False) is False 58 | 59 | # array indexing 60 | assert get_by_path("something.0.idx1", obj, False) == "neat" 61 | assert get_by_path("something.1.idx2", obj, False) == "cool" 62 | assert get_by_path("something.3.idx3", obj, False) is False 63 | assert get_by_path("something.3", obj, False) is False 64 | --------------------------------------------------------------------------------