Skip to content

Commit b09ab2a

Browse files
committed
Initial commit
0 parents  commit b09ab2a

File tree

9 files changed

+398
-0
lines changed

9 files changed

+398
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.env
2+
3+
.venv
4+
__pycache__
5+
*.pyc
6+
*egg-info

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Florian Schäfer
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Pythonic Notion API
2+
3+
A pythonic way to interact with Notion without passing JSON around.

docs/usage.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Usage
2+
3+
## Client
4+
5+
### Load the Notion Client
6+
7+
```python
8+
from notion import NotionClient
9+
10+
notion = NotionClient("secret_token")
11+
```
12+
13+
## Pages
14+
15+
### Load a Page
16+
17+
```python
18+
page = client.get_page("some-page-id")
19+
print(page.title)
20+
```
21+
22+
### Edit some property of a Page
23+
24+
```python
25+
page.title = "pythonic-notion-playground-test"
26+
print(page.title)
27+
```

notion/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from notion.client import NotionClient

notion/client.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from typing import Optional
2+
3+
import requests
4+
5+
from notion.model import Page
6+
7+
API_BASE_URL = "https://api.notion.com/v1/"
8+
API_VERSION = "2022-02-22"
9+
10+
11+
class NotionClient:
12+
def __init__(self, token: str):
13+
self.token = token
14+
15+
def _make_request(self, request_type: str, entity, payload=None) -> dict:
16+
url = f"{API_BASE_URL}{entity}/"
17+
18+
headers = {
19+
"Accept": "application/json",
20+
"Notion-Version": API_VERSION,
21+
"Content-Type": "application/json",
22+
"Authorization": self.token,
23+
}
24+
25+
assert request_type in ("get", "post", "patch", "delete")
26+
requests_func = getattr(requests, request_type)
27+
28+
response = requests_func(
29+
url,
30+
headers=headers,
31+
json=payload,
32+
)
33+
if response.status_code != 200:
34+
raise ValueError(response.text)
35+
36+
return response.json()
37+
38+
# ---------------------------------------------------------------------------
39+
# Pages
40+
# ---------------------------------------------------------------------------
41+
42+
def get_pages(self, database_id, filter_: Optional[dict] = None):
43+
results = []
44+
start_cursor = {}
45+
while True: # FIXME: Find a better way than while True?!
46+
result_set = self._make_request(
47+
"post", f"databases/{database_id}/query", {**filter_, **start_cursor}
48+
)
49+
results.extend(result_set["results"])
50+
start_cursor = {"start_cursor": result_set.get("next_cursor")}
51+
if not result_set.get("has_more"):
52+
break
53+
return results
54+
55+
def get_page(self, page_id):
56+
data = self._make_request("get", f"pages/{page_id}")
57+
return Page(client=self, data=data)
58+
59+
def create_page(self, page) -> Page:
60+
response = self._make_request("post", "pages", page)
61+
return response
62+
63+
def update_page(self, page_id, payload: dict):
64+
return self._make_request("patch", f"pages/{page_id}", payload)
65+
66+
def delete_page(self, page_id):
67+
"""Deletes the Notion Page with the given ID.
68+
69+
The Notion API does not offer a DELETE method but insteads works by setting the `archived` field.
70+
"""
71+
return self.update_page(page_id, {"archived": True})
72+
73+
# ---------------------------------------------------------------------------
74+
# Blocks
75+
# ---------------------------------------------------------------------------
76+
77+
def update_block(self, block_id, payload: dict):
78+
return self._make_request("patch", f"blocks/{block_id}", payload)
79+
80+
def retrieve_block_children(self, block_id: str, limit: Optional[int] = None):
81+
return self._make_request("get", f"blocks/{block_id}/children")
82+
83+
def append_block_children(self, block_id: str, children: str):
84+
return self._make_request(
85+
"patch", f"blocks/{block_id}/children", {"children": children}
86+
)
87+
88+
def delete_block(self, block_id: str):
89+
"""Deletes the Notion Block with the given ID.
90+
91+
The Notion API does not offer a DELETE method but insteads works by setting the `archived` field.
92+
"""
93+
return self.update_block(block_id, {"archived": True})

notion/model/__init__.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from typing import List, Union
2+
3+
4+
def class_from_type_name(type_name: str):
5+
return {
6+
"child_page": ChildPage,
7+
"paragraph": Paragraph,
8+
"heading_1": Heading,
9+
"heading_2": SubHeading,
10+
"heading_3": SubSubHeading,
11+
"quote": Quote,
12+
}[type_name]
13+
14+
15+
class ChildrenMixin:
16+
@property
17+
def children(self) -> list:
18+
return [
19+
class_from_type_name(data["type"])(client=self._client, data=data)
20+
for data in self._client.retrieve_block_children(self.id)["results"]
21+
]
22+
23+
def append_children(self, children: Union[dict, List[dict]]):
24+
"""Append blocks or pages to a parent.
25+
26+
TODO: Instead of appending items one by one, batch blocks to be more efficient.
27+
"""
28+
if not isinstance(children, list):
29+
children = [children]
30+
children = [c._data for c in children]
31+
32+
res = []
33+
for c in children:
34+
object_name = c["object"]
35+
if object_name == "block":
36+
res.extend(self._client.append_block_children(self.id, [c]))
37+
elif object_name == "page":
38+
# TODO Also support database_id
39+
c["parent"] = {"type": "page_id", "page_id": self.id}
40+
res.append(self._client.create_page(c))
41+
else:
42+
raise TypeError(
43+
f"Appending objects of type {object_name} is not supported."
44+
)
45+
46+
return res
47+
48+
49+
class TitleMixin:
50+
@property
51+
def title(self) -> str:
52+
return self._data["properties"]["title"]["title"][0]["plain_text"]
53+
54+
@title.setter
55+
def title(self, new_title: str):
56+
new_data = self._client.update_page(
57+
self.id,
58+
{"properties": {"title": {"title": [{"text": {"content": new_title}}]}}},
59+
)
60+
self._data = new_data
61+
62+
63+
class Block:
64+
def __init__(self, client=None, data=None):
65+
self._client = client
66+
self._data = data
67+
68+
@property
69+
def id(self) -> str:
70+
return self._data["id"].replace("-", "")
71+
72+
@property
73+
def type(self) -> str:
74+
return self._data["type"]
75+
76+
def delete(self):
77+
self._client.delete_block(self.id)
78+
79+
80+
# TODO: Pages are technically not Blocks. So model this in inheritance as well.
81+
class Page(Block, ChildrenMixin, TitleMixin):
82+
def __init__(self, title: str = None, data=None, client=None):
83+
if title:
84+
data = {
85+
"object": "page",
86+
"properties": {
87+
"title": {"title": [{"text": {"content": title}}]},
88+
},
89+
}
90+
super().__init__(client, data)
91+
92+
def delete(self):
93+
self._client.delete_page(self.id)
94+
95+
96+
class ChildPage(Block):
97+
"""A page contained in another page.
98+
99+
From the Notion docs (https://developers.notion.com/docs/working-with-page-content#modeling-content-as-blocks):
100+
When a child page appears inside another page, it's represented as a `child_page` block, which does not have children.
101+
You should think of this as a reference to the page block.
102+
"""
103+
104+
@property
105+
def title(self) -> str:
106+
return self._data["child_page"]["title"]
107+
108+
def delete(self):
109+
"""Delete the ChildPage.
110+
111+
Needs to be overwritten to use the `delete_page` endpoint instead of `delete_block`.
112+
"""
113+
self._client.delete_page(self.id)
114+
115+
116+
class RichText(Block):
117+
def __init__(
118+
self, class_name: str = None, text: str = None, data=None, client=None
119+
) -> None:
120+
if class_name and text:
121+
data = {
122+
"object": "block",
123+
"type": class_name,
124+
class_name: {
125+
"rich_text": [{"type": "text", "text": {"content": text}}]
126+
},
127+
}
128+
129+
super().__init__(client, data)
130+
131+
@property
132+
def text(self) -> str:
133+
return self._data[self.type]["rich_text"][0]["text"]["content"]
134+
135+
@text.setter
136+
def text(self, new_text: str):
137+
new_data = self._client.update_block(
138+
self.id, {self.type: {"rich_text": [{"text": {"content": new_text}}]}}
139+
)
140+
self._data = new_data
141+
142+
143+
class Paragraph(RichText):
144+
def __init__(self, text: str = None, data=None, client=None) -> None:
145+
super().__init__("paragraph", text, data, client)
146+
147+
148+
class Heading(RichText):
149+
def __init__(self, text: str = None, data=None, client=None) -> None:
150+
super().__init__("heading_1", text, data, client)
151+
152+
153+
class SubHeading(RichText):
154+
def __init__(self, text: str = None, data=None, client=None) -> None:
155+
super().__init__("heading_2", text, data, client)
156+
157+
158+
class SubSubHeading(RichText):
159+
def __init__(self, text: str = None, data=None, client=None) -> None:
160+
super().__init__("heading_3", text, data, client)
161+
162+
163+
class Quote(RichText):
164+
def __init__(self, text: str = None, data=None, client=None) -> None:
165+
super().__init__("quote", text, data, client)

setup.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from setuptools import setup
2+
3+
4+
setup(
5+
name="pythonic-notion-api",
6+
version="0.0.1",
7+
description="A pythonic way to interact with Notion without passing JSON around.",
8+
author="Florian Schaefer",
9+
author_email="florian.schaefer@gmail.com",
10+
license="MIT",
11+
packages=["notion"],
12+
install_requires=["requests==2.28.0"],
13+
)

0 commit comments

Comments
 (0)