diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a008855 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## v1.3.0 + +- [#27](https://github.com/lastorel/pytion/issues/27): Switched from `2022-02-22` to `2022-06-28` version of Notion API +- `Request()` (internal) method argument added +- [#27](https://github.com/lastorel/pytion/issues/27): Fix of parent object hierarchy +- [#27](https://github.com/lastorel/pytion/issues/27): `models.Block` now has non-empty `parent` attr +- `models.Database`: `is_inline` attr added +- `Notion()`: new optional arg `version` added to customize API interaction +- [#27](https://github.com/lastorel/pytion/issues/27): You must retrieve Page properties manually. `.get_page_properties` method added +- [#27](https://github.com/lastorel/pytion/issues/27): add support of `relation` type `Property` +- [#27](https://github.com/lastorel/pytion/issues/27): updates for `relation` type `PropertyValue` +- [#16](https://github.com/lastorel/pytion/issues/17): tests of Property model + +### Breaking changes + +- `Request()` method now looks for positional argument `api` for getting version (internal method) +- Page has title=`unknown` until you retrieve its properties +- `PropertyValue` with `relation` type now represents by list of `LinkTo` object instead of list of IDs \ No newline at end of file diff --git a/README.md b/README.md index 2168ad4..497e43d 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ Client is built with its own object model based on API So if you are using **notion.so** and want to automate some stuff with the original API, you're welcome! You can read any available data, create basic models, and even work with databases. -Current Notion API version = **"2022-02-22"** +Current Notion API version = **"2022-06-28"** _*does not use notion-sdk-py client_ +See [Change Log](./CHANGELOG.md) + # Contents 1. [Quick Start](#quick-start) @@ -146,6 +148,8 @@ There is a list of available methods for communicate with **api.notion.com**. Th `.get_page_property(property_id, id_, limit)` - Retrieve a page property item. +`.get_page_properties(title_only, obj)` - Retrieve the title or all properties of current Page or Page `obj` + `.db_query(id_, limit, filter_, sorts)` - Query Database. `.db_filter(...see desc...)` - Query Database. @@ -191,6 +195,7 @@ There are classes **based on API** structures: - use `.db_filter()` to get database content with filtering and/or sorting - `Page` based on [Page object](https://developers.notion.com/reference/page) - You can create object `Page.create(...)` and/or use `.page_create(...)` API method + - use `.get_page_properties()` to retrieve page title and other `PropertyValue`-s - use `.page_update()` method to modify attributes or delete the page - use `.get_block_children()` to get page content (without nested blocks) (it will be `BlockArray`) - use `.get_block_children_recursive()` to get page content with nested blocks @@ -206,10 +211,10 @@ There are classes **based on API** structures: - You can retrieve more data about a User by his ID using `.get()` - `Property` based on [Property object](https://developers.notion.com/reference/property-object) - You can create object `Property.create(...)` while creating or editing database: `.db_create()` or `.db_update()` - - `formula`, `relation`, `rollup` type properties configuration is not supported + - `formula`, `rollup` type properties configuration is not supported - `PropertyValue` based on [Property values](https://developers.notion.com/reference/property-value-object) - You can create object `PropertyValue.create(...)` to set or edit page properties by `.page_create()` or `.page_update()` - - `files`, `relation`, `formula`, `rollup` type properties are not editable + - `files`, `formula`, `rollup` type properties are not editable There are also useful **internal** classes: @@ -283,6 +288,13 @@ Extension attributes are listed below in support matrix: > API converts **toggle heading** Block to simple heading Block. +### Supported Property types + +| Property type | Property type | Property Schema | Property Values | Property Item | Config attrs | +| --- | --- | --- | --- | --- | --- | +| `title` | rw | rw | rw | + | | +| in_progress... | rw | rw | rw | + | | + ### Block creating examples Create `paragraph` block object and add it to Notion: diff --git a/pytion/api.py b/pytion/api.py index ab34ede..6726974 100644 --- a/pytion/api.py +++ b/pytion/api.py @@ -15,9 +15,15 @@ class Notion(object): - def __init__(self, token: Optional[str] = None): - self.version = envs.NOTION_VERSION - self.session = Request(token=token) + def __init__(self, token: Optional[str] = None, version: Optional[str] = None): + """ + Creates main API object. + + :param token: provide your integration API token. If None - find the file `token` + :param version: provide non hardcoded API version + """ + self.version = version if version else envs.NOTION_VERSION + self.session = Request(api=self, token=token) logger.debug(f"API object created. Version {envs.NOTION_VERSION}") def __len__(self): @@ -212,13 +218,38 @@ def get_page_property(self, property_id: str, id_: Optional[str] = None, limit: return None if isinstance(id_, str) and "-" in id_: id_ = id_.replace("-", "") - if self.obj: + if self.obj and not id_: id_ = self.obj.id property_obj = self.api.session.method( method="get", path=self.name, id_=id_, after_path="properties/"+property_id, limit=limit ) return Element(api=self.api, name=f"pages/{id_}/properties", obj=PropertyValue(property_obj, property_id)) + def get_page_properties(self, title_only: bool = False, obj: Optional[Page] = None) -> None: + """ + Page properties must be retrieved using the page properties endpoint. (c) + after retrieving a Page object you can retrieve its properties + + obj or self.obj must be a Page + :return: + """ + if not obj: + obj = self.obj + if obj and isinstance(obj, Page): + for prop in obj.properties: + # Skip already retrieved properties + if isinstance(obj.properties[prop], PropertyValue): + continue + prop_id = obj.properties[prop].id + if title_only and prop_id != "title": + continue + result = self.get_page_property(prop_id, id_=obj.id) + obj.properties[prop] = result.obj + if prop_id == "title": + obj.title = result.obj.value if result.obj.value else "" + return + logger.warning("You must provide a Page to retrieve properties") + def db_query( self, id_: Optional[str] = None, @@ -240,7 +271,10 @@ def db_query( ) if r["object"] != "list": return None - return Element(api=self.api, name="pages", obj=PageArray(r["results"])) + pa = Element(api=self.api, name="pages", obj=PageArray(r["results"])) + for p in pa.obj: + pa.get_page_properties(title_only=True, obj=p) + return pa def db_filter(self, title: str = None, **kwargs) -> Optional[Element]: """ diff --git a/pytion/envs.py b/pytion/envs.py index 3d2fc37..f0e720a 100644 --- a/pytion/envs.py +++ b/pytion/envs.py @@ -14,7 +14,7 @@ NOTION_SECRET = None # Current API Version (mandatory) -NOTION_VERSION = "2022-02-22" +NOTION_VERSION = "2022-06-28" # Logging settings (mandatory) LOGGING_BASE_LEVEL = logging.WARNING diff --git a/pytion/models.py b/pytion/models.py index 3d3b617..f0fbea9 100644 --- a/pytion/models.py +++ b/pytion/models.py @@ -214,12 +214,23 @@ def format_iso_time(cls, time: str) -> Optional[datetime]: class Property(object): - def __init__(self, data: Dict[str, str]): - self.to_delete = True if data.get("type") is None else False + def __init__(self, data: Dict[str, Any]): + self.to_delete = True if data.get("type", False) is None else False self.id: str = data.get("id") self.type: str = data.get("type", "") self.name: str = data.get("name") self.raw = data + self.subtype = None + + if self.type == "relation": + if isinstance(data[self.type], dict): + self.subtype = data[self.type].get("type") + self.relation = LinkTo.create(database_id=data[self.type].get("database_id")) + if self.subtype == "single_property": + pass + elif self.subtype == "dual_property": + self.relation_property_id = data[self.type][self.subtype].get("synced_property_id") + self.relation_property_name = data[self.type][self.subtype].get("synced_property_name") def __str__(self): return self.name if self.name else self.type @@ -237,7 +248,11 @@ def get(self) -> Optional[Dict[str, Dict]]: data["name"] = self.name # property retyping while patch if self.type: - data[self.type] = {} + # create relation type property with configuration + if self.type == "relation": + data[self.type] = {self.subtype: {}, "database_id": self.relation.id} + else: + data[self.type] = {} return data @classmethod @@ -248,7 +263,15 @@ def create(cls, type_: Optional[str] = "", **kwargs): + addons: set type_ = `None` to delete this Property set param `name` to rename this Property + + + relation type: + set param `single_property` with `database_id` value OR + set param `dual_property` with `database_id` value + Property.create(type_="relation", dual_property="v111c132c12c1242341c41c") """ + if type_ == "relation": + subtype = next(kwarg for kwarg in kwargs if kwarg in ("single_property", "dual_property")) + kwargs["relation"] = {"type": subtype, subtype: {}, "database_id": kwargs[subtype]} return cls({"type": type_, **kwargs}) @@ -331,7 +354,10 @@ def __init__(self, data: Dict, name: str, **kwargs): self.value = [user if isinstance(user, User) else User(**user) for user in data[self.type]] if self.type == "relation": - self.value: List[str] = [item.get("id") for item in data["relation"]] + self.value: List[LinkTo] = [ + LinkTo.create(page_id=item.get("id")) if not isinstance(item, LinkTo) else item + for item in data[self.type] + ] if self.type == "rollup": rollup_type = data["rollup"]["type"] @@ -420,8 +446,12 @@ def get(self): if self.type == "people": return {self.type: [user.get() for user in self.value]} + # relation type + if self.type == "relation": + return {self.type: [{"id": lt.id} for lt in self.value]} + # unsupported types: - if self.type in ["files", "relation"]: + if self.type in ["files"]: return {self.type: []} if self.type in ["created_time", "last_edited_by", "last_edited_time", "created_by"]: return None @@ -450,6 +480,7 @@ def __init__(self, **kwargs) -> None: :param properties: :param parent: :param url: + :param is_inline: """ super().__init__(**kwargs) self.cover: Optional[Dict] = kwargs.get("cover") @@ -464,6 +495,7 @@ def __init__(self, **kwargs) -> None: } self.parent = kwargs["parent"] if isinstance(kwargs.get("parent"), LinkTo) else LinkTo(**kwargs["parent"]) self.url: str = kwargs.get("url") + self.is_inline: bool = kwargs.get("is_inline") def __str__(self): return str(self.title) @@ -507,15 +539,10 @@ def __init__(self, **kwargs) -> None: self.url: str = kwargs.get("url") self.children = kwargs["children"] if "children" in kwargs else LinkTo(block=self) self.properties = { - name: (PropertyValue(data, name) if not isinstance(data, PropertyValue) else data) + name: (Property(data) if not isinstance(data, PropertyValue) else data) for name, data in kwargs["properties"].items() } - for p in self.properties.values(): - if "title" in p.type: - self.title = p.value - break - else: - self.title = None + self.title = "unknown" def __str__(self): return str(self.title) @@ -579,6 +606,7 @@ def __init__(self, **kwargs): if isinstance(self.caption, str): self.caption = RichTextArray.create(self.caption) return + self.parent = kwargs["parent"] if isinstance(kwargs.get("parent"), LinkTo) else LinkTo(**kwargs["parent"]) if self.type == "paragraph": self.text = RichTextArray(kwargs[self.type].get("rich_text")) @@ -956,11 +984,11 @@ def __init__( self.id = "" self.after_path = "" if self.type == "page_id": - self.uri = "blocks" + self.uri = "pages" elif self.type == "database_id": self.uri = "databases" # when type is set manually - elif self.type == "page": + elif self.type == "page": # deprecated. self.uri = "pages" elif self.type == "block_id": self.uri = "blocks" diff --git a/pytion/query.py b/pytion/query.py index 3dcc270..1ce85ab 100644 --- a/pytion/query.py +++ b/pytion/query.py @@ -139,6 +139,7 @@ def __repr__(self): class Request(object): def __init__( self, + api: object, # Notion object method: Optional[str] = None, path: Optional[str] = None, id_: str = "", @@ -156,7 +157,7 @@ def __init__( self._token = token if token else envs.NOTION_SECRET if not self._token: logger.error("Token is not provided or file `token` is not found!") - self.version = envs.NOTION_VERSION + self.version = getattr(api, "version") self.auth = {"Authorization": "Bearer " + self._token} self.session.headers.update({"Notion-Version": self.version, **self.auth}) self.result = None diff --git a/setup.py b/setup.py index b61bcfb..ee23096 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="pytion", - version="1.2.3", + version="1.3.0", author="Yegor Gomzin", author_email="slezycmex@mail.ru", description="Unofficial Python client for official Notion API", diff --git a/tests/fixtures.py b/tests/fixtures.py index 89dc6af..07d8f38 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -28,4 +28,6 @@ def page_for_pages(no): @pytest.fixture(scope="session") def page_for_updates(no): - return no.pages.get("36223246a20e42df8f9b354ed1f11d75") + page = no.pages.get("36223246a20e42df8f9b354ed1f11d75") + page.get_page_properties() + return page diff --git a/tests/test_api.py b/tests/test_api.py index 196fb3c..ffb428a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,7 +8,7 @@ def test_notion(no): - assert no.version == "2022-02-22" + assert no.version == "2022-06-28" assert no.session.base == "https://api.notion.com/v1/" @@ -17,6 +17,7 @@ def test_get__page(self, root_page): page = root_page assert isinstance(page.obj, Page), "get of .pages. must return Page object" assert page.obj.id == "878d628488d94894ab14f9b872cd6870" + page.get_page_properties() assert str(page.obj.title) == "Pytion Tests" def test_get__block(self, no): @@ -60,20 +61,28 @@ def test_get__after_path_block(self, no): assert isinstance(blocks.obj[0], Block) def test_get_parent__block(self, no): - something = no.blocks.get_parent("9b2026c3a0cb45fc8cee330142d60f3a") # I'm fine! - assert something is None, "Blocks have not any parent" + parent_block = no.blocks.get_parent("9b2026c3a0cb45fc8cee330142d60f3a") # I'm fine! + assert isinstance(parent_block.obj, Block) + assert parent_block.obj.id == "8a920ba7dc1d4961811e5c82b28028ed" + assert str(parent_block.obj) == "Hello! How are you?" + + def test_get_parent__block2(self, no): + parent_block = no.blocks.get_parent("8a920ba7dc1d4961811e5c82b28028ed") # Hello! How are you? + assert isinstance(parent_block.obj, Page) + assert parent_block.obj.id == "82ee5677402f44819a5da3302273400a" + parent_block.get_page_properties() + assert str(parent_block.obj) == "Page with some texts" def test_get_parent__page(self, no): parent_page_block = no.pages.get_parent("82ee5677402f44819a5da3302273400a") # Page with some texts assert isinstance(parent_page_block.obj, Block) - assert parent_page_block.obj.id == "878d628488d94894ab14f9b872cd6870" - assert parent_page_block.obj.text == "Pytion Tests" + assert parent_page_block.obj.id == "6001eb5621f2428392d532bf08bc8ecd" + assert "Page with some texts" in str(parent_page_block.obj) - def test_get_parent__database(self, no): - parent_page_block = no.pages.get_parent("82ee5677402f44819a5da3302273400a") # Little Database - assert isinstance(parent_page_block.obj, Block) - assert parent_page_block.obj.id == "878d628488d94894ab14f9b872cd6870" - assert parent_page_block.obj.text == "Pytion Tests" + def test_get_parent__database(self, no, root_page): + parent_page_block = no.databases.get_parent("0e9539099cff456d89e44684d6b6c701") # Little Database + assert isinstance(parent_page_block.obj, Page) + assert parent_page_block.obj.id == root_page.obj.id def test_get_parent__user(self, no): something = no.users.get_parent("01c67faf3aba45ffaa022407f87c86a5") @@ -81,7 +90,11 @@ def test_get_parent__user(self, no): def test_get_parent__block_obj(self, no): block = no.blocks.get("8a920ba7dc1d4961811e5c82b28028ed") # Hello! How are you? - assert block.get_parent() is None, "Blocks have not any parent" + parent_block = block.get_parent() + assert isinstance(parent_block.obj, Page) + assert parent_block.obj.id == "82ee5677402f44819a5da3302273400a" + parent_block.get_page_properties() + assert str(parent_block.obj) == "Page with some texts" def test_get_parent__page_obj(self, no): # Database is the parent of this page page = no.pages.get("b85877eaf7bf4245a8c5218055eeb81f") # Parent testing page @@ -93,15 +106,17 @@ def test_get_parent__page_obj(self, no): # Database is the parent of this page def test_get_parent__database_obj(self, little_database): database = little_database # Little Database parent_page_block = database.get_parent() - assert isinstance(parent_page_block.obj, Block) + assert isinstance(parent_page_block.obj, Page) assert parent_page_block.obj.id == "878d628488d94894ab14f9b872cd6870" - assert parent_page_block.obj.text == "Pytion Tests" + parent_page_block.get_page_properties() + assert str(parent_page_block.obj) == "Pytion Tests" def test_get_parent__child_page(self, no): child_page = no.blocks.get("878d628488d94894ab14f9b872cd6870") # root page page = child_page.get_parent() assert isinstance(page.obj, Page) assert page.obj.id == "878d628488d94894ab14f9b872cd6870" + page.get_page_properties() assert str(page.obj.title) == "Pytion Tests" def test_get_parent__child_database(self, no): @@ -234,6 +249,26 @@ def test_get_page_property__bad_page(self, no): with pytest.raises(ObjectNotFound): no.pages.get_page_property("%7Dma%3F", "b85877eaf7bf4245a8c5218055eeb81a") + def test_get_page_properties(self, no): + page = no.pages.get("b85877eaf7bf4245a8c5218055eeb81f") # Parent testing page + assert isinstance(page.obj, Page) + assert str(page.obj) == "unknown" + assert isinstance(page.obj.properties["Done"], Property) + page.get_page_properties() + assert isinstance(page.obj.properties["Done"], PropertyValue) + assert page.obj.properties["Digit"].value == 2 + assert "tag1" in page.obj.properties["Tags"].value + + def test_get_page_properties__title(self, no): + page = no.pages.get("b85877eaf7bf4245a8c5218055eeb81f") # Parent testing page + assert isinstance(page.obj, Page) + assert str(page.obj) == "unknown" + assert isinstance(page.obj.properties["Done"], Property) + page.get_page_properties(title_only=True) + assert hasattr(page.obj.properties["by"], "value") is False + assert isinstance(page.obj.properties["Done"], Property) + assert str(page.obj.title) == "Parent testing page" + def test_db_query__id(self, no): pages = no.databases.db_query("0e9539099cff456d89e44684d6b6c701") # Little Database assert isinstance(pages.obj, PageArray) @@ -312,6 +347,7 @@ def test_db_filter__no_tags(self, little_database): def test_db_filter__tag_property_obj(self, no, little_database): page = no.pages.get("c2fc6b3dc3d244e9be2a3d28b26082bf") # Untitled + page.get_page_properties() my_prop = page.obj.properties["Tags"] pages = little_database.db_filter(property_obj=my_prop) assert isinstance(pages.obj, PageArray) @@ -412,6 +448,7 @@ def test_page_create__into_page(self, no, page_for_pages): parent = LinkTo(from_object=page_for_pages.obj) page = no.pages.page_create(parent=parent, title="Page 1") assert isinstance(page.obj, Page) + page.get_page_properties(title_only=True) assert str(page.obj.title) == "Page 1" # delete section delete_page = page.page_update(archived=True) @@ -421,6 +458,7 @@ def test_page_create__into_database(self, no): parent = LinkTo.create(database_id="35f50aa293964b0d93e09338bc980e2e") # Database for creating pages page = no.pages.page_create(parent=parent, title="Page 2") assert isinstance(page.obj, Page) + page.get_page_properties(title_only=True) assert str(page.obj.title) == "Page 2" # delete section delete_page = page.page_update(archived=True) @@ -435,6 +473,7 @@ def test_page_create__into_database_props(self, no): } page = no.pages.page_create(parent=parent, properties=props, title="Page 3") assert isinstance(page.obj, Page) + page.get_page_properties(title_only=True) assert str(page.obj.title) == "Page 3" # delete section delete_page = page.page_update(archived=True) @@ -445,6 +484,7 @@ def test_page_create__with_children(self, no, page_for_pages): child = Block.create("Hello, World!") page = no.pages.page_create(parent=parent, title="Page 4", children=[child]) assert isinstance(page.obj, Page) + page.get_page_properties(title_only=True) assert str(page.obj.title) == "Page 4" blocks = page.get_block_children() assert isinstance(blocks.obj, BlockArray) @@ -460,6 +500,7 @@ def test_page_create__from_obj(self, no, page_for_pages): page_obj = Page.create(parent=parent, title="Page 5") page = no.pages.page_create(page_obj=page_obj) assert isinstance(page.obj, Page) + page.get_page_properties(title_only=True) assert str(page.obj.title) == "Page 5" # delete section delete_page = page.page_update(archived=True) @@ -470,12 +511,15 @@ def test_page_update__rename(self, page_for_updates): new_name = "Updating for page" page = page_for_updates.page_update(title=new_name) assert isinstance(page.obj, Page) + page.get_page_properties(title_only=True) assert str(page.obj.title) == new_name old_page = page.page_update(title=RichTextArray.create(old_name)) + old_page.get_page_properties(title_only=True) assert str(old_page.obj.title) == old_name def test_page_update__change_props(self, page_for_updates): + page_for_updates.get_page_properties() old_props = page_for_updates.obj.properties new_props = { "Tags": PropertyValue.create("multi_select", ["tag2"]), @@ -484,10 +528,12 @@ def test_page_update__change_props(self, page_for_updates): } page = page_for_updates.page_update(properties=new_props) assert isinstance(page.obj, Page) + page.get_page_properties() assert "tag2" in page.obj.properties["Tags"].value assert page.obj.properties["done"].value is False old_page = page.page_update(properties=old_props) + old_page.get_page_properties() assert "tag1" in old_page.obj.properties["Tags"].value assert old_page.obj.properties["done"].value is True @@ -570,9 +616,10 @@ def test_block_append__many(self, no): def test_from_linkto__base(self, no): link = LinkTo.create(page_id="878d628488d94894ab14f9b872cd6870") page = no.pages.from_linkto(link) - assert isinstance(page.obj, Block) + assert isinstance(page.obj, Page) assert page.obj.id == "878d628488d94894ab14f9b872cd6870" - assert str(page.obj.text) == "Pytion Tests" + page.get_page_properties(title_only=True) + assert str(page.obj) == "Pytion Tests" def test_from_linkto__child(self, page_some_texts): child = page_some_texts.from_linkto(page_some_texts.obj.children) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2da8e6f --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,45 @@ +from pytion.models import * + + +class TestProperty: + def test_create(self): + p = Property.create(type_="multi_select", name="multiselected") + p.get() + assert p.id is None + assert p.type == "multi_select" + assert p.to_delete is False + + def test_create__to_rename(self): + p = Property.create(name="renamed") + p.get() + assert p.id is None + assert p.type == "" + assert p.name == "renamed" + assert p.to_delete is False + + def test_create__to_delete(self): + p = Property.create(type_=None) + p.get() + assert p.id is None + assert p.type is None + assert p.to_delete is True + + def test_create__relation_single(self): + p = Property.create("relation", single_property="878d628488d94894ab14f9b872cd6870") + p.get() + assert p.id is None + assert p.type == "relation" + assert p.to_delete is False + assert isinstance(p.relation, LinkTo) + assert p.relation.uri == "databases" + assert p.subtype == "single_property" + + def test_create__relation_dual(self): + p = Property.create("relation", dual_property="878d628488d94894ab14f9b872cd6870") + p.get() + assert p.id is None + assert p.type == "relation" + assert p.to_delete is False + assert isinstance(p.relation, LinkTo) + assert p.relation.uri == "databases" + assert p.subtype == "dual_property"