diff --git a/.github/workflows/chore.yml b/.github/workflows/chore.yml index c145c7f..4d19ac4 100644 --- a/.github/workflows/chore.yml +++ b/.github/workflows/chore.yml @@ -16,26 +16,31 @@ jobs: runs-on: ubuntu-latest steps: + - name: Get Gitflow App token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.GITFLOW_APP_ID }} + private-key: ${{ secrets.GITFLOW_APP_KEY }} + - name: Checkout Repository uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} - name: Merge Source Branch into Develop run: | git config --global user.name "bot" git config --global user.email "bot@colorifix.com" - git fetch - git checkout develop + git fetch origin + git switch develop git merge --no-ff origin/${{ github.event.pull_request.head.ref }} -m "Merge ${{ github.event.pull_request.head.ref }} into develop" git push origin develop - - name: Build docs - if: startsWith(github.event.pull_request.head.ref, 'docs/') - run: foo - publish-docs: - name: Publish Documentation + name: Publish documentation runs-on: ubuntu-latest - if: startsWith(github.event.pull_request.head.ref, 'docs/') + if: startsWith(github.event.pull_request.head.ref, 'docs/') || startsWith(github.event.pull_request.head.ref, 'release/') permissions: id-token: write pages: write @@ -66,3 +71,6 @@ jobs: - name: Deploy pages uses: actions/deploy-pages@v4 + +permissions: + contents: write diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 9665c9f..29f73bf 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - 'develop' + - 'main' jobs: run-tests: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f020333..3cfdd7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,14 @@ jobs: (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/')) steps: + + - name: Get Gitflow App token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.GITFLOW_APP_ID }} + private-key: ${{ secrets.GITFLOW_APP_KEY }} + - name: Gitflow action id: gitflow-action uses: hoangvvo/gitflow-workflow-action@0.3.7 @@ -35,7 +43,7 @@ jobs: version_increment: ${{ contains(github.head_ref, 'hotfix/') && 'patch' || '' }} dry_run: ${{ inputs.dry_run }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - name: Checkout code uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d26b2..7fc595d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## Unreleased +## [2.0.0] - 2026/01/07 + +- Updating for Notion API version 2025-09-03 + ## [1.0.0] - 2025/01/31 - First public release diff --git a/conftest.py b/conftest.py index 53a78c6..0ebedac 100644 --- a/conftest.py +++ b/conftest.py @@ -22,9 +22,24 @@ def cover_url() -> str: @fixture def example_page_id() -> str: - return "e439b7a7296d45b98805f24c9cfc2115" + return "2cef2075b1dc803a8ec3d142ac105817" + + +@fixture(scope="session") +def database_id() -> str: + return "2cef2075b1dc8023b0ece0dbf9b278f7" + + +@fixture(scope="session") +def data_source_id1() -> str: + return "2cef2075b1dc80bab834000b42b068d7" + + +@fixture +def data_source_id2() -> str: + return "2cef2075b1dc80048d8d000b1b75df8f" @fixture -def example_page_id_2() -> str: - return "d5bce0a0fe6248d0a120c6c693d9b597" +def block_id() -> str: + return "2cef2075b1dc80a59d6ad9ed97846ff8" diff --git a/docs/get_started/data_sources.md b/docs/get_started/data_sources.md new file mode 100644 index 0000000..b654e0e --- /dev/null +++ b/docs/get_started/data_sources.md @@ -0,0 +1,154 @@ +## Retrieve a data source + +=== "Async" + + ```python + async def main(): + async_api = AsyncNotionAPI(access_token='') + data_source = await async_api.get_data_source(data_source_id='') + ``` + +=== "Sync" + + ```python + api = NotionAPI(access_token='') + data_source = api.get_data_source(data_source_id='') + ``` + +## Query + +=== "Async" + + ```python + async def main(): + async_api = AsyncNotionAPI(access_token='') + data_source = await async_api.get_data_source(data_source_id='') + + async for page in data_source.query(): + ... + ``` + +=== "Sync" + + ```python + api = NotionAPI(access_token='') + data_source = api.get_data_source(data_source_id='') + + for page in data_source.query(): + ... + ``` + +### Filters + +You can use filter classes in `python_notion_api.models.filters` to create property filters and pass them to the query. + +=== "Async" + + ```python + from python_notion_api.models.filters import SelectFilter + + async def main(): + async_api = AsyncNotionAPI(access_token='') + data_source = await async_api.get_data_source(data_source_id='') + + await for page in data_source.query( + filters=SelectFilter(property='', equals='') + ): + ... + ``` + +=== "Sync" + + ```python + from python_notion_api.models.filters import SelectFilter + + api = NotionAPI(access_token='') + data_source = api.get_data_source(data_source_id='') + + for page in data_source.query( + filters=SelectFilter(property='', equals='') + ): + ... + ``` + +'and' and 'or' filters are supported: + +=== "Async" + + ```python + from python_notion_api.models.filters import SelectFilter, or_filter, and_filter + + async def main(): + async_api = AsyncNotionAPI(access_token='') + data_source = await async_api.get_data_source(data_source_id='') + + await for page in data_source.query( + filters=or_filter([ + SelectFilter(property="Select", equals="xxx"), + and_filter([ + NumberFilter(property="Number", greater_than=10), + CheckboxFilter(property="Checkbox", equals=True) + ]) + ]) + ): + ... + ``` + +=== "Sync" + + ```python + from python_notion_api.models.filters import SelectFilter, or_filter, and_filter + + api = NotionAPI(access_token='') + data_source = api.get_data_source(data_source_id='') + + for page in data_source.query( + filters=or_filter([ + SelectFilter(property="Select", equals="xxx"), + and_filter([ + NumberFilter(property="Number", greater_than=10), + CheckboxFilter(property="Checkbox", equals=True) + ]) + ]) + ) + ... + ``` + +You can read more on filters [here](https://developers.notion.com/reference/post-database-query-filter){:target="_blank"} + +### Sorts + +You can use `python_notion_api.models.sorts.Sort` class to create sorts and pass them to the query. + +=== "Async" + + ```python + from python_notion_api.models.sorts import Sort + + async def main(): + async_api = AsyncNotionAPI(access_token='') + data_source = await async_api.get_data_source(data_source_id='') + + await for page in data_source.query( + sorts=[ + Sort(property="Title"), + Sort(property="Date", descending=True) + ] + ): + ``` + +=== "Sync" + + ```python + from python_notion_api.models.sorts import Sort + + api = NotionAPI(access_token='') + data_source = api.get_data_source(data_source_id='') + + for page in data_source.query( + sorts=[ + Sort(property="Title"), + Sort(property="Date", descending=True) + ] + ) + ``` diff --git a/docs/get_started/databases.md b/docs/get_started/databases.md index 1e8f6eb..4c8b38a 100644 --- a/docs/get_started/databases.md +++ b/docs/get_started/databases.md @@ -14,141 +14,3 @@ api = NotionAPI(access_token='') database = api.get_database(database_id='') ``` - -## Query - -=== "Async" - - ```python - async def main(): - async_api = AsyncNotionAPI(access_token='') - database = await async_api.get_database(database_id='') - - async for page in database.query(): - ... - ``` - -=== "Sync" - - ```python - api = NotionAPI(access_token='') - database = api.get_database(database_id='') - - for page in database.query(): - ... - ``` - -### Filters - -You can use filter classes in `python_notion_api.models.filters` to create property filters and pass them to the query. - -=== "Async" - - ```python - from python_notion_api.models.filters import SelectFilter - - async def main(): - async_api = AsyncNotionAPI(access_token='') - database = await async_api.get_database(database_id='') - - await for page in database.query( - filters=SelectFilter(property='', equals='') - ): - ... - ``` - -=== "Sync" - - ```python - from python_notion_api.models.filters import SelectFilter - - api = NotionAPI(access_token='') - database = api.get_database(database_id='') - - for page in database.query( - filters=SelectFilter(property='', equals='') - ): - ... - ``` - -'and' and 'or' filters are supported: - -=== "Async" - - ```python - from python_notion_api.models.filters import SelectFilter, or_filter, and_filter - - async def main(): - async_api = AsyncNotionAPI(access_token='') - database = await async_api.get_database(database_id='') - - await for page in database.query( - filters=or_filter([ - SelectFilter(property="Select", equals="xxx"), - and_filter([ - NumberFilter(property="Number", greater_than=10), - CheckboxFilter(property="Checkbox", equals=True) - ]) - ]) - ): - ... - ``` - -=== "Sync" - - ```python - from python_notion_api.models.filters import SelectFilter, or_filter, and_filter - - api = NotionAPI(access_token='') - database = api.get_database(database_id='') - - for page in database.query( - filters=or_filter([ - SelectFilter(property="Select", equals="xxx"), - and_filter([ - NumberFilter(property="Number", greater_than=10), - CheckboxFilter(property="Checkbox", equals=True) - ]) - ]) - ) - ... - ``` - -You can read more on filters [here](https://developers.notion.com/reference/post-database-query-filter){:target="_blank"} - -### Sorts - -You can use `python_notion_api.models.sorts.Sort` class to create sorts and pass them to the query. - -=== "Async" - - ```python - from python_notion_api.models.sorts import Sort - - async def main(): - async_api = AsyncNotionAPI(access_token='') - database = await async_api.get_database(database_id='') - - await for page in database.query( - sorts=[ - Sort(property="Title"), - Sort(property="Date", descending=True) - ] - ): - ``` - -=== "Sync" - - ```python - from python_notion_api.models.sorts import Sort - - api = NotionAPI(access_token='') - database = api.get_database(database_id='') - - for page in database.query( - sorts=[ - Sort(property="Title"), - Sort(property="Date", descending=True) - ] - ) - ``` diff --git a/docs/get_started/pages.md b/docs/get_started/pages.md index 25dde12..87773eb 100644 --- a/docs/get_started/pages.md +++ b/docs/get_started/pages.md @@ -22,9 +22,9 @@ ```python async def main(): async_api = AsyncNotionAPI(access_token='') - database = await async_api.get_database(database_id='') + data_source = await async_api.get_data_source(data_source_id='') - await database.create_page(properties={ + await data_source.create_page(properties={ 'Number_property': 234, 'Select_property': 'select1', 'Checkbox_property': True, @@ -36,9 +36,9 @@ ```python api = NotionAPI(access_token='') - database = api.get_database(database_id='') + data_source = api.get_data_source(data_source_id='') - database.create_page(properties={ + data_source.create_page(properties={ 'Number_property': 234, 'Select_property': 'select1', 'Checkbox_property': True, @@ -130,8 +130,8 @@ In particular, the values of rollups and formulas may be incorrect when retrieve To use custom page properties, create a subclass of NotionPage. Define a function to get each custom property (these must return a `PropertyValue`) and define the mapping from Notion property names to the function names. ```python -from python_notion_api.api import NotionPage -from python_notion_api.models import RichTextObject +from python_notion_api.sync_api.api import NotionPage +from python_notion_api.models import RichTextObject, RichTextPropertyItem from python_notion_api.models.values import RichTextPropertyValue class MyPage(NotionPage): @@ -159,14 +159,14 @@ class MyPage(NotionPage): ``` -This page class can be passed when querying a database or getting a page. +This page class can be passed when querying a data source or getting a page. ```python page = api.get_page(page_id='', cast_cls=MyPage) -for page in database.query(cast_cls=MyPage, filters=NumberFilter(property='Value', equals=1)): +for page in data_source.query(cast_cls=MyPage, filters=NumberFilter(property='Value', equals=1)): print('Custom processing:', page.get('Value').value) print('Raw value:', page._direct_get('Value').value) ``` diff --git a/pyproject.toml b/pyproject.toml index 9819d0d..7e22d08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-notion-api" -version = "1.0.0" +version = "2.0.0" description = "Python wrapper for the official Notion API" authors = [ "Mihails Delmans ", diff --git a/python_notion_api/async_api/__init__.py b/python_notion_api/async_api/__init__.py index 74a4f26..5741c0e 100644 --- a/python_notion_api/async_api/__init__.py +++ b/python_notion_api/async_api/__init__.py @@ -6,6 +6,7 @@ create_property_iterator, ) from python_notion_api.async_api.notion_block import NotionBlock +from python_notion_api.async_api.notion_data_source import NotionDataSource from python_notion_api.async_api.notion_database import NotionDatabase from python_notion_api.async_api.notion_page import NotionPage @@ -14,6 +15,7 @@ "NotionBlock", "NotionPage", "NotionDatabase", + "NotionDataSource", "AsyncPropertyItemIterator", "AsyncRollupPropertyItemIterator", "AsyncBlockIterator", diff --git a/python_notion_api/async_api/api.py b/python_notion_api/async_api/api.py index d0e9ae4..e3dca12 100644 --- a/python_notion_api/async_api/api.py +++ b/python_notion_api/async_api/api.py @@ -8,6 +8,7 @@ from loguru import logger from python_notion_api.async_api.notion_block import NotionBlock +from python_notion_api.async_api.notion_data_source import NotionDataSource from python_notion_api.async_api.notion_database import NotionDatabase from python_notion_api.async_api.notion_page import NotionPage from python_notion_api.async_api.retry_strategy import RetryStrategy @@ -35,7 +36,7 @@ class AsyncNotionAPI: def __init__( self, access_token: str, - api_version: str = "2022-06-28", + api_version: str = "2025-09-03", page_limit: int = 20, rate_limit: tuple[int, int] = (500, 200), ): @@ -74,13 +75,25 @@ async def get_database(self, database_id: str) -> NotionDatabase: return database - async def get_page( - self, page_id: str, page_cast: type[NotionPage] = NotionPage - ) -> NotionPage: - """Gets Notion page. + async def get_data_source(self, data_source_id: str) -> NotionDataSource: + """Gets Notion DataSource Args: - page_id: Id of the database to fetch. + data_source_id: Id of the data source to fetch. + + Returns: + A Notion DataSource with the given id. + """ + data_source = NotionDataSource(self, data_source_id) + await data_source.reload() + + return data_source + + async def get_page(self, page_id, page_cast=NotionPage) -> NotionPage: + """Gets Notion Page + + Args: + page_id: Id of the page to fetch. page_cast: A subclass of a NotionPage. Allows custom property retrieval. Returns: diff --git a/python_notion_api/async_api/notion_data_source.py b/python_notion_api/async_api/notion_data_source.py new file mode 100644 index 0000000..e652ab1 --- /dev/null +++ b/python_notion_api/async_api/notion_data_source.py @@ -0,0 +1,172 @@ +from typing import TYPE_CHECKING, Any, Generator, Optional + +from pydantic.v1 import BaseModel + +from python_notion_api.async_api.notion_page import NotionPage +from python_notion_api.async_api.utils import ensure_loaded +from python_notion_api.models.common import FileObject, ParentObject +from python_notion_api.models.configurations import ( + NotionPropertyConfiguration, + RelationPropertyConfiguration, +) +from python_notion_api.models.filters import FilterItem +from python_notion_api.models.objects import DataSource +from python_notion_api.models.sorts import Sort +from python_notion_api.models.values import PropertyValue, generate_value + +if TYPE_CHECKING: + from python_notion_api.async_api.api import AsyncNotionAPI + + +class NotionDataSource: + """Wrapper for a Notion datasource object. + + Args: + api: Instance of the NotionAPI. + data_source_id: Id of the data source. + """ + + class CreatePageRequest(BaseModel): + parent: ParentObject + properties: dict[str, PropertyValue] + cover: Optional[FileObject] + + def __init__(self, api: "AsyncNotionAPI", data_source_id: str): + self._api = api + self._data_source_id = data_source_id + self._object = None + self._properties = None + self._title = None + + @ensure_loaded + def __getattr__(self, attr_key): + return getattr(self._object, attr_key) + + @property + def data_source_id(self) -> str: + return self._data_source_id.replace("-", "") + + async def reload(self): + self._object = await self._api._get( + endpoint=f"data_sources/{self._data_source_id}", + cast_cls=DataSource, + ) + + if self._object is None: + raise Exception( + f"Error loading data source {self._data_source_id}" + ) + + self._properties = { + key: NotionPropertyConfiguration.from_obj(val) + for key, val in self._object.properties.items() + } + self._title = "".join(rt.plain_text for rt in self._object.title) + + async def query( + self, + filters: Optional[FilterItem] = None, + sorts: Optional[list[Sort]] = None, + page_limit: Optional[int] = None, + cast_cls=NotionPage, + ) -> Generator[NotionPage, None, None]: + """A wrapper for 'Query a data source' action. + + Retrieves all pages belonging to the data source. + + Args: + filters: + sorts: + cast_cls: A subclass of a NotionPage. Allows custom + property retrieval + + """ + data = {} + if filters is not None: + filters = filters.dict(by_alias=True, exclude_unset=True) + data["filter"] = filters + + if sorts is not None: + data["sorts"] = [ + sort.dict(by_alias=True, exclude_unset=True) for sort in sorts + ] + + async for item in self._api._post_iterate( + endpoint=f"data_sources/{self._data_source_id}/query", + data=data, + page_limit=page_limit, + ): + yield cast_cls( + api=self._api, data_source=self, page_id=item.page_id, obj=item + ) + + @property + @ensure_loaded + def title(self) -> str: + """Get the title of the data source.""" + return self._title + + @property + @ensure_loaded + def properties(self) -> dict[str, NotionPropertyConfiguration]: + """Get all property configurations of the data source.""" + return self._properties + + @property + @ensure_loaded + def relations(self) -> dict[str, RelationPropertyConfiguration]: + """Get all property configurations of the data source that are + relations. + """ + return { + key: val + for key, val in self._properties.items() + if isinstance(val, RelationPropertyConfiguration) + } + + async def create_page( + self, + properties: dict[str, Any] = {}, + cover_url: Optional[str] = None, + ) -> NotionPage: + """Creates a new page in the Data Source and updates the new page with + the properties. + + Args: + properties: Dictionary of property names and values. Value types + will depend on the property type. Can be the raw value + (e.g. string, float) or an object (e.g. SelectValue, + NumberPropertyItem) + cover_url: URL of an image for the page cover. + """ + + validated_properties = {} + for prop_name, prop_value in properties.items(): + prop = self.properties.get(prop_name, None) + if prop is None: + raise ValueError(f"Unknown property: {prop_name}") + value = generate_value(prop.config_type, prop_value) + validated_properties[prop_name] = value + + request = NotionDataSource.CreatePageRequest( + parent=ParentObject( + type="data_source_id", data_source_id=self.data_source_id + ), + properties=validated_properties, + cover=( + FileObject.from_url(cover_url) + if cover_url is not None + else None + ), + ) + + data = request.json(by_alias=True, exclude_unset=True) + + new_page = await self._api._post("pages", data=data) + + return NotionPage( + api=self._api, + page_id=new_page.page_id, + obj=new_page, + data_source=self, + ) diff --git a/python_notion_api/async_api/notion_database.py b/python_notion_api/async_api/notion_database.py index 9ed7364..5c4a47e 100644 --- a/python_notion_api/async_api/notion_database.py +++ b/python_notion_api/async_api/notion_database.py @@ -1,18 +1,8 @@ -from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional +from typing import TYPE_CHECKING, List -from pydantic.v1 import BaseModel - -from python_notion_api.async_api.notion_page import NotionPage from python_notion_api.async_api.utils import ensure_loaded -from python_notion_api.models.common import FileObject, ParentObject -from python_notion_api.models.configurations import ( - NotionPropertyConfiguration, - RelationPropertyConfiguration, -) -from python_notion_api.models.filters import FilterItem +from python_notion_api.models.common import DataSourceObject from python_notion_api.models.objects import Database -from python_notion_api.models.sorts import Sort -from python_notion_api.models.values import PropertyValue, generate_value if TYPE_CHECKING: from python_notion_api.async_api.api import AsyncNotionAPI @@ -26,16 +16,11 @@ class NotionDatabase: database_id: Id of the database. """ - class CreatePageRequest(BaseModel): - parent: ParentObject - properties: Dict[str, PropertyValue] - cover: Optional[FileObject] - def __init__(self, api: "AsyncNotionAPI", database_id: str): self._api = api self._database_id = database_id self._object = None - self._properties = None + self._data_sources = None self._title = None @ensure_loaded @@ -47,62 +32,6 @@ def database_id(self) -> str: """Gets the database id.""" return self._database_id.replace("-", "") - async def reload(self): - """Reloads the database from Notion.""" - self._object = await self._api._get( - endpoint=f"databases/{self._database_id}", cast_cls=Database - ) - - if self._object is None: - raise Exception(f"Error loading database {self._database_id}") - - self._properties = { - key: NotionPropertyConfiguration.from_obj(val) - for key, val in self._object.properties.items() - } - self._title = "".join(rt.plain_text for rt in self._object.title) - - async def query( - self, - filters: Optional[FilterItem] = None, - sorts: Optional[List[Sort]] = None, - page_limit: Optional[int] = None, - cast_cls=NotionPage, - ) -> AsyncGenerator[NotionPage, None]: - """Queries the database. - - Retrieves all pages belonging to the database that satisfy the given filters - in the order specified by the sorts. - - Args: - filters: Filters to apply to the query. - sorts: Sorts to apply to the query. - cast_cls: A subclass of a NotionPage. Allows custom - property retrieval. - - Returns: - Generator of NotionPage objects. - """ - data: dict[str, Any] = {} - - if filters is not None: - filters = filters.dict(by_alias=True, exclude_unset=True) - data["filter"] = filters - - if sorts is not None: - data["sorts"] = [ - sort.dict(by_alias=True, exclude_unset=True) for sort in sorts - ] - - async for item in self._api._post_iterate( - endpoint=f"databases/{self._database_id}/query", - data=data, - page_limit=page_limit, - ): - yield cast_cls( - api=self._api, database=self, page_id=item.page_id, obj=item - ) - @property @ensure_loaded def title(self) -> str: @@ -112,70 +41,20 @@ def title(self) -> str: @property @ensure_loaded - def properties(self) -> Dict[str, NotionPropertyConfiguration]: - """Gets all property configurations of the database.""" - assert self._properties is not None - return self._properties - - @property - @ensure_loaded - def relations(self) -> Dict[str, RelationPropertyConfiguration]: - """Gets all property configurations of the database that are - relations. - """ - assert self._properties is not None - return { - key: val - for key, val in self._properties.items() - if isinstance(val, RelationPropertyConfiguration) - } - - async def create_page( - self, - properties: Dict[str, Any] = {}, - cover_url: Optional[str] = None, - ) -> NotionPage: - """Creates a new page in the Database and updates the new page with - the properties. - - Args: - properties: Dictionary of property names and values. Value types - will depend on the property type. Can be the raw value - (e.g. string, float) or an object (e.g. SelectValue, - NumberPropertyItem) - cover: URL of an image for the page cover. - - Returns: - A new page. - """ + def data_sources(self) -> List[DataSourceObject]: + """Gets all data sources of the database.""" + return self._data_sources - validated_properties = {} - for prop_name, prop_value in properties.items(): - prop = self.properties.get(prop_name, None) - if prop is None: - raise ValueError(f"Unknown property: {prop_name}") - value = generate_value(prop.config_type, prop_value) - validated_properties[prop_name] = value - - request = NotionDatabase.CreatePageRequest( - parent=ParentObject( - type="database_id", database_id=self.database_id - ), - properties=validated_properties, - cover=( - FileObject.from_url(cover_url) - if cover_url is not None - else None - ), + async def reload(self): + self._object = await self._api._get( + endpoint=f"databases/{self._database_id}", + cast_cls=Database, ) - data = request.json(by_alias=True, exclude_unset=True) - - new_page = await self._api._post("pages", data=data) + if self._object is None: + raise Exception(f"Error loading database {self._database_id}") - return NotionPage( - api=self._api, - page_id=new_page.page_id, - obj=new_page, - database=self, - ) + self._data_sources = [ + DataSourceObject(**val) for val in self._object.data_sources + ] + self._title = "".join(rt.plain_text for rt in self._object.title) diff --git a/python_notion_api/async_api/notion_page.py b/python_notion_api/async_api/notion_page.py index 817b59f..bffb346 100644 --- a/python_notion_api/async_api/notion_page.py +++ b/python_notion_api/async_api/notion_page.py @@ -9,7 +9,12 @@ create_property_iterator, ) from python_notion_api.async_api.utils import ensure_loaded -from python_notion_api.models.objects import Block, Database, Page, Pagination +from python_notion_api.models.objects import ( + Block, + DataSource, + Page, + Pagination, +) from python_notion_api.models.properties import PropertyItem from python_notion_api.models.values import PropertyValue, generate_value @@ -40,20 +45,20 @@ def __init__( api: "AsyncNotionAPI", page_id: str, obj: Optional[Page] = None, - database: Optional[Database] = None, + data_source: Optional[DataSource] = None, ): self._api = api self._page_id = page_id self._object = obj - self.database = database + self.data_source = data_source async def reload(self): """Reloads page from Notion.""" self._object = await self._api._get(endpoint=f"pages/{self._page_id}") if self._object is not None: - parent_id = self.parent.database_id + parent_id = self.parent.data_source_id if parent_id is not None: - self.database = await self._api.get_database(parent_id) + self.data_source = await self._api.get_data_source(parent_id) @ensure_loaded def __getattr__(self, attr_key: str): diff --git a/python_notion_api/async_api/tests/test_async_api.py b/python_notion_api/async_api/tests/test_async_api.py index 8132e70..f71da05 100644 --- a/python_notion_api/async_api/tests/test_async_api.py +++ b/python_notion_api/async_api/tests/test_async_api.py @@ -2,26 +2,29 @@ from pytest_asyncio import fixture as async_fixture from python_notion_api.async_api.notion_block import NotionBlock +from python_notion_api.async_api.notion_data_source import NotionDataSource from python_notion_api.async_api.notion_database import NotionDatabase from python_notion_api.async_api.notion_page import NotionPage -TEST_DATABASE_ID = "401076f6c7c04ae796bf3e4c847361e1" -TEST_BLOCK_ID = "f572e889cd374edbbd15d8bf13174bbc" + +@async_fixture +async def page(async_api, example_page_id): + return await async_api.get_page(page_id=example_page_id) @async_fixture -async def page(async_api, example_page_id_2): - return await async_api.get_page(page_id=example_page_id_2) +async def database(async_api, database_id): + return await async_api.get_database(database_id=database_id) @async_fixture -async def database(async_api): - return await async_api.get_database(database_id=TEST_DATABASE_ID) +async def data_source(async_api, data_source_id1): + return await async_api.get_data_source(data_source_id=data_source_id1) @async_fixture -async def block(async_api): - return await async_api.get_block(block_id=TEST_BLOCK_ID) +async def block(async_api, block_id): + return await async_api.get_block(block_id=block_id) @mark.asyncio @@ -32,14 +35,20 @@ async def test_api_is_valid(self, async_api): async def test_page_is_valid(self, page): assert isinstance(page, NotionPage) assert page._object is not None - assert isinstance(page.database, NotionDatabase) + assert isinstance(page.data_source, NotionDataSource) async def test_database_is_valid(self, database): assert isinstance(database, NotionDatabase) assert database._object is not None - assert database._properties is not None + assert database._data_sources is not None assert database._title is not None + async def test_data_source_is_valid(self, data_source): + assert isinstance(data_source, NotionDataSource) + assert data_source._object is not None + assert data_source._properties is not None + assert data_source._title is not None + async def test_block_is_valid(self, block): assert isinstance(block, NotionBlock) assert block._object is not None diff --git a/python_notion_api/async_api/tests/test_async_block.py b/python_notion_api/async_api/tests/test_async_block.py index 220c1b4..c2ab0fe 100644 --- a/python_notion_api/async_api/tests/test_async_block.py +++ b/python_notion_api/async_api/tests/test_async_block.py @@ -6,14 +6,12 @@ from python_notion_api.async_api.notion_block import NotionBlock from python_notion_api.models import ParagraphBlock, RichTextObject -TEST_BLOCK_ID = "f572e889cd374edbbd15d8bf13174bbc" - @mark.asyncio class TestAsyncBlock: @async_fixture - async def block(self, async_api): - block = NotionBlock(block_id=TEST_BLOCK_ID, api=async_api) + async def block(self, async_api, block_id): + block = NotionBlock(block_id=block_id, api=async_api) await block.reload() return block diff --git a/python_notion_api/async_api/tests/test_async_data_source.py b/python_notion_api/async_api/tests/test_async_data_source.py new file mode 100644 index 0000000..8deefd1 --- /dev/null +++ b/python_notion_api/async_api/tests/test_async_data_source.py @@ -0,0 +1,64 @@ +import random + +from pytest import mark +from pytest_asyncio import fixture as async_fixture + +from python_notion_api.async_api.notion_data_source import NotionDataSource +from python_notion_api.async_api.notion_page import NotionPage + + +@mark.asyncio +class TestAsyncDataSource: + @async_fixture + async def data_source(self, async_api, data_source_id1): + data_source = NotionDataSource( + data_source_id=data_source_id1, api=async_api + ) + await data_source.reload() + return data_source + + async def test_load_data_source(self, data_source): + assert data_source is not None + assert data_source._object is not None + assert data_source.title is not None + assert data_source.properties is not None + assert data_source.relations is not None + + async def test_create_data_source_page(self, data_source): + new_page = await data_source.create_page(properties={}) + assert isinstance(new_page, NotionPage) + assert new_page._object is not None + + async def test_create_data_source_page_with_properties(self, data_source): + properties = { + "Text": "".join([random.choice("abcd") for _ in range(10)]), + "Number": int("".join([random.choice("1234") for _ in range(3)])), + } + new_page = await data_source.create_page(properties=properties) + + assert await new_page.get("Text") == properties["Text"] + assert await new_page.get("Number") == properties["Number"] + + async def test_query_data_source(self, data_source): + pages = data_source.query() + page = await anext(pages) + assert isinstance(page, NotionPage) + + async def test_get_object_property(self, data_source): + created_time = data_source.created_time + assert created_time is not None + + async def test_get_title(self, data_source): + title = data_source.title + assert title is not None + + async def test_get_properties(self, data_source): + properties = data_source.properties + assert isinstance(properties, dict) + + async def test_get_relations(self, data_source): + relations = data_source.relations + assert isinstance(relations, dict) + + for _, relation in relations.items(): + assert relation.config_type == "relation" diff --git a/python_notion_api/async_api/tests/test_async_database.py b/python_notion_api/async_api/tests/test_async_database.py index edf940a..fef4fe3 100644 --- a/python_notion_api/async_api/tests/test_async_database.py +++ b/python_notion_api/async_api/tests/test_async_database.py @@ -1,19 +1,15 @@ -import random - from pytest import mark from pytest_asyncio import fixture as async_fixture from python_notion_api.async_api.notion_database import NotionDatabase -from python_notion_api.async_api.notion_page import NotionPage - -TEST_DATABASE_ID = "401076f6c7c04ae796bf3e4c847361e1" +from python_notion_api.models.common import DataSourceObject @mark.asyncio class TestAsyncDatabase: @async_fixture - async def database(self, async_api): - database = NotionDatabase(database_id=TEST_DATABASE_ID, api=async_api) + async def database(self, async_api, database_id): + database = NotionDatabase(database_id=database_id, api=async_api) await database.reload() return database @@ -21,28 +17,7 @@ async def test_load_database(self, database): assert database is not None assert database._object is not None assert database.title is not None - assert database.properties is not None - assert database.relations is not None - - async def test_create_database_page(self, database): - new_page = await database.create_page(properties={}) - assert isinstance(new_page, NotionPage) - assert new_page._object is not None - - async def test_create_database_page_with_properties(self, database): - properties = { - "Text": "".join([random.choice("abcd") for _ in range(10)]), - "Number": int("".join([random.choice("1234") for _ in range(3)])), - } - new_page = await database.create_page(properties=properties) - - assert await new_page.get("Text") == properties["Text"] - assert await new_page.get("Number") == properties["Number"] - - async def test_query_database(self, database): - pages = database.query() - page = await anext(pages) - assert isinstance(page, NotionPage) + assert database.data_sources is not None async def test_get_object_property(self, database): created_time = database.created_time @@ -52,13 +27,22 @@ async def test_get_title(self, database): title = database.title assert title is not None - async def test_get_properties(self, database): - properties = database.properties - assert isinstance(properties, dict) - - async def test_get_relations(self, database): - relations = database.relations - assert isinstance(relations, dict) - - for _, relation in relations.items(): - assert relation.config_type == "relation" + async def test_get_data_sources( + self, database, data_source_id1, data_source_id2 + ): + data_sources = database.data_sources + assert isinstance(data_sources, list) + + data_sources.sort(key=lambda x: x.data_source_id) + + assert len(data_sources) == 2 + assert isinstance(data_sources[0], DataSourceObject) + assert ( + data_sources[0].data_source_id.replace("-", "") == data_source_id2 + ) + assert data_sources[0].data_source_name is not None + assert isinstance(data_sources[1], DataSourceObject) + assert ( + data_sources[1].data_source_id.replace("-", "") == data_source_id1 + ) + assert data_sources[1].data_source_name is not None diff --git a/python_notion_api/async_api/tests/test_async_page.py b/python_notion_api/async_api/tests/test_async_page.py index f2178e0..e3c4ce0 100644 --- a/python_notion_api/async_api/tests/test_async_page.py +++ b/python_notion_api/async_api/tests/test_async_page.py @@ -12,8 +12,8 @@ @mark.asyncio class TestAsyncPage: @async_fixture - async def page(self, async_api, example_page_id_2): - async_page = NotionPage(page_id=example_page_id_2, api=async_api) + async def page(self, async_api, example_page_id): + async_page = NotionPage(page_id=example_page_id, api=async_api) await async_page.reload() return async_page diff --git a/python_notion_api/async_api/tests/test_properties.py b/python_notion_api/async_api/tests/test_properties.py index 67caac3..961bdd8 100644 --- a/python_notion_api/async_api/tests/test_properties.py +++ b/python_notion_api/async_api/tests/test_properties.py @@ -15,7 +15,6 @@ ) from python_notion_api.models.sorts import Sort -TEST_DB = "401076f6c7c04ae796bf3e4c847361e1" TEST_TITLE = f"API Test {datetime.now(UTC).isoformat()}" TEST_TEXT = "Test text is boring" TEST_NUMBER = 12.5 @@ -23,7 +22,7 @@ TEST_STATUS = "In progress" TEST_MULTI_SELECT = ["foo", "bar", "baz"] TEST_DATE = datetime.now() -TEST_PEOPLE = ["fa9e1df9-7c24-427c-9c20-eac629565fe4"] +TEST_PEOPLE = ["2ced872b-594c-8176-a765-0002860b6fdc"] TEST_FILES = [File(name="foo.pdf", url="http://example.com/file")] TEST_CHECKBOX = True TEST_URL = "http://example.com" @@ -32,21 +31,29 @@ @async_fixture -async def database(async_api): - return await async_api.get_database(database_id=TEST_DB) +async def database(async_api, database_id): + return await async_api.get_database(database_id=database_id) + + +@async_fixture +async def data_source(async_api, data_source_id1): + return await async_api.get_data_source(data_source_id=data_source_id1) @mark.asyncio class TestCore: - async def test_get_database(self, database): - assert database.database_id == TEST_DB + async def test_get_database(self, database, database_id): + assert database.database_id == database_id + + async def test_get_data_source(self, data_source, data_source_id1): + assert data_source.data_source_id == data_source_id1 - async def test_create_empty_page(self, database): - new_page = await database.create_page() + async def test_create_empty_page(self, data_source): + new_page = await data_source.create_page() assert new_page is not None - async def test_create_empty_page_with_cover(self, database, cover_url): - new_page = await database.create_page(cover_url=cover_url) + async def test_create_empty_page_with_cover(self, data_source, cover_url): + new_page = await data_source.create_page(cover_url=cover_url) assert new_page is not None async def test_get_page(self, async_api, example_page_id): @@ -72,17 +79,25 @@ def api(cls): return cls.async_api @async_fixture(scope="class") - async def database(cls, api): + async def data_source(cls, api, data_source_id1): + if not hasattr(cls, "async_data_source"): + cls.async_data_source = await cls.async_api.get_data_source( + data_source_id=data_source_id1 + ) + return cls.async_data_source + + @async_fixture(scope="class") + async def database(cls, api, database_id): if not hasattr(cls, "async_database"): cls.async_database = await cls.async_api.get_database( - database_id=TEST_DB + database_id=database_id ) return cls.async_database @async_fixture(scope="class") - async def page(cls, database): + async def page(cls, data_source): if not hasattr(cls, "async_page"): - cls.async_page = await cls.async_database.create_page() + cls.async_page = await cls.async_data_source.create_page() return cls.async_page @mark.parametrize( @@ -155,8 +170,8 @@ async def test_set_relation(self, page): @mark.skip( reason="This test will create a notification for the TEST_PEOPLE" ) - async def test_create_new_page(self, database): - new_page = await database.create_page( + async def test_create_new_page(self, data_source): + new_page = await data_source.create_page( properties={ "Name": TEST_TITLE, "Text": TEST_TEXT, @@ -181,18 +196,18 @@ async def test_get_unique_id(self, page): async def test_get_by_id(self, page): await page.set("Email", TEST_EMAIL) - email = await page.get("%3E%5Ehh", cache=False) + email = await page.get("FEe%40", cache=False) assert email == TEST_EMAIL async def test_set_by_id(self, page): - await page.set("%3E%5Ehh", TEST_EMAIL) + await page.set("FEe%40", TEST_EMAIL) email = await page.get("Email", cache=False) assert email == TEST_EMAIL async def test_update(self, page): await page.update( properties={ - "%3E%5Ehh": TEST_EMAIL, + "FEe%40": TEST_EMAIL, "Phone": TEST_PHONE, "Multi-select": None, } @@ -216,17 +231,15 @@ async def test_reload(self, page): @mark.asyncio class TestRollups: - NUMBER_PAGE_ID = "25e800a118414575ab30a8dc42689b74" - DATE_PAGE_ID = "e38bb90faf8a436895f089fed2446cc6" - EMPTY_ROLLUP_PAGE_ID = "2b5efae5bad24df884b4f953e3788b64" + EMPTY_ROLLUP_PAGE_ID = "2cef2075b1dc80b5b67edc58426e92f2" - async def test_number_rollup(self, async_api): - number_page = await async_api.get_page(self.NUMBER_PAGE_ID) + async def test_number_rollup(self, async_api, example_page_id): + number_page = await async_api.get_page(example_page_id) num = await number_page.get("Number rollup") assert num == 10 - async def test_date_rollup(self, async_api): - date_page = await async_api.get_page(self.DATE_PAGE_ID) + async def test_date_rollup(self, async_api, example_page_id): + date_page = await async_api.get_page(example_page_id) date = await date_page.get("Date rollup") assert isinstance(date.start, datetime) @@ -237,20 +250,20 @@ async def test_empty_rollup(self, async_api): @mark.asyncio -class TestDatabase: - async def test_query_database(self, database): - database.query() +class TestDataSource: + async def test_query_database(self, data_source): + data_source.query() - async def test_prop_filter(self, database): - pages = database.query( + async def test_prop_filter(self, data_source): + pages = data_source.query( filters=SelectFilter(property="Select", equals=TEST_SELECT) ) page = await anext(pages) value = await page.get("Select") assert value == TEST_SELECT - async def test_and_filter(self, database): - pages = database.query( + async def test_and_filter(self, data_source): + pages = data_source.query( filters=and_filter( [ SelectFilter(property="Select", equals=TEST_SELECT), @@ -262,8 +275,8 @@ async def test_and_filter(self, database): value = await page.get("Select") assert value == TEST_SELECT - async def test_or_filter(self, database): - pages = database.query( + async def test_or_filter(self, data_source): + pages = data_source.query( filters=or_filter( [ SelectFilter(property="Select", equals=TEST_SELECT), @@ -275,12 +288,14 @@ async def test_or_filter(self, database): value = await page.get("Select") assert value == TEST_SELECT - async def test_sort(self, database): - pages = database.query(sorts=[Sort(property="Date")]) + async def test_sort(self, data_source): + pages = data_source.query(sorts=[Sort(property="Date")]) page = await anext(pages) assert page is not None - async def test_descending_sort(self, database): - pages = database.query(sorts=[Sort(property="Date", descending=True)]) + async def test_descending_sort(self, data_source): + pages = data_source.query( + sorts=[Sort(property="Date", descending=True)] + ) page = await anext(pages) assert page is not None diff --git a/python_notion_api/async_api/tests/test_query.py b/python_notion_api/async_api/tests/test_query.py index e05d55b..d145b72 100644 --- a/python_notion_api/async_api/tests/test_query.py +++ b/python_notion_api/async_api/tests/test_query.py @@ -7,7 +7,9 @@ async def main(): api = AsyncNotionAPI(access_token=os.environ["NOTION_TOKEN"]) - db = await api.get_database(database_id="c0802577c79645e5af855f0ca46148b2") + db = await api.get_data_source( + data_source_id="2cef2075b1dc80bab834000b42b068d7" + ) async for page in db.query(): print(await page.get("title")) diff --git a/python_notion_api/async_api/utils.py b/python_notion_api/async_api/utils.py index 15108ef..7561000 100644 --- a/python_notion_api/async_api/utils.py +++ b/python_notion_api/async_api/utils.py @@ -3,10 +3,10 @@ def ensure_loaded(fn): """Checks that the `_object` of the method's class is fetched before - perfomign operations on it. + performing operations on it. Args: - fn: method of `NotionPage` or `NodtionDatabase` or other class + fn: method of `NotionPage` or `NotionDatabase` or other class that has `_object` attribute. """ is_coroutine = inspect.iscoroutinefunction(fn) diff --git a/python_notion_api/models/__init__.py b/python_notion_api/models/__init__.py index da1104a..b0bcbad 100644 --- a/python_notion_api/models/__init__.py +++ b/python_notion_api/models/__init__.py @@ -106,6 +106,7 @@ from python_notion_api.models.objects import ( Block, Database, + DataSource, NotionObject, NotionObjectBase, Page, @@ -177,6 +178,7 @@ "User", "Pagination", "Database", + "DataSource", "Page", "Block", "PropertyItem", diff --git a/python_notion_api/models/common.py b/python_notion_api/models/common.py index 0535b04..4ad7419 100644 --- a/python_notion_api/models/common.py +++ b/python_notion_api/models/common.py @@ -3,7 +3,7 @@ from pydantic.v1 import BaseModel -from python_notion_api.models.fields import idField, typeField +from python_notion_api.models.fields import idField, nameField, typeField class LinkObject(BaseModel): @@ -93,6 +93,7 @@ class ParentObject(BaseModel): parent_type: str = typeField page_id: Optional[str] database_id: Optional[str] + data_source_id: Optional[str] class SelectObject(BaseModel): @@ -136,3 +137,8 @@ class RollupObject(BaseModel): class UniqueIDObject(BaseModel): prefix: Optional[str] number: int + + +class DataSourceObject(BaseModel): + data_source_id: str = idField + data_source_name: str = nameField diff --git a/python_notion_api/models/configurations.py b/python_notion_api/models/configurations.py index b34fb3a..75eb5d8 100644 --- a/python_notion_api/models/configurations.py +++ b/python_notion_api/models/configurations.py @@ -13,6 +13,7 @@ class NotionPropertyConfiguration(NotionObjectBase): config_id: str = idField config_type: str = typeField name: str + description: Optional[str] _class_map = { "title": "TitlePropertyConfiguration", @@ -146,6 +147,7 @@ def _class_key_field(self): class SinglePropertyConfigurationObject(BaseModel): database_id: str + data_source_id: str relation_type: str = typeField single_property: Dict @@ -163,6 +165,7 @@ class SyncedPropertyConfigurationObject(BaseModel): class DualPropertyConfigurationObject(BaseModel): database_id: str + data_source_id: str dual_property: SyncedPropertyConfigurationObject diff --git a/python_notion_api/models/fields.py b/python_notion_api/models/fields.py index 0e7e731..d46dd5c 100644 --- a/python_notion_api/models/fields.py +++ b/python_notion_api/models/fields.py @@ -8,3 +8,4 @@ filterField = Field(alias="filter") andField = Field(alias="and") orField = Field(alias="or") +nameField = Field(alias="name") diff --git a/python_notion_api/models/objects.py b/python_notion_api/models/objects.py index 6bbb579..054bc0f 100644 --- a/python_notion_api/models/objects.py +++ b/python_notion_api/models/objects.py @@ -60,6 +60,7 @@ class NotionObject(NotionObjectBase, extra=Extra.allow): "list": "Pagination", "property_item": "PropertyItem", "database": "Database", + "data_source": "DataSource", "page": "Page", "user": "User", "block": "Block", @@ -103,14 +104,14 @@ class Pagination(NotionObject): "user", "database", "property_item", - "page_or_database", + "page_or_data_source", ] = typeField _class_map = { "property_item": "PropertyItemPagination", "page": "PagePagination", "block": "BlockPagination", - "page_or_database": "PageOrDatabasePagination", + "page_or_data_source": "PageOrDataSourcePagination", } @property @@ -118,24 +119,45 @@ def _class_key_field(self): return self.pagination_type +class DataSource(NotionObject): + _class_key_field = None + + ds_object: str = Optional[objectField] + ds_id: str = idField + created_time: Optional[str] + created_by: Optional[User] + last_edited_time: Optional[str] + last_edited_by: Optional[User] + properties: Optional[Dict] + parent: Optional[ParentObject] + database_parent: Optional[ParentObject] + title: Optional[List[RichTextObject]] + description: Optional[List[RichTextObject]] + icon: Optional[Union[FileObject, EmojiObject]] + cover: Optional[Union[FileObject, Dict[str, Union[str, FileObject]]]] + url: Optional[str] + archived: Optional[bool] + + class Database(NotionObject): _class_key_field = None db_object: str = objectField db_id: str = idField created_time: str - created_by: User + created_by: Optional[User] last_edited_time: str - last_edited_by: User + last_edited_by: Optional[User] title: List[RichTextObject] description: List[RichTextObject] icon: Optional[Union[FileObject, EmojiObject]] cover: Optional[Union[FileObject, Dict[str, Union[str, FileObject]]]] - properties: Dict - parent: Dict + parent: ParentObject url: str - archived: bool + in_trash: bool is_inline: bool + public_url: Optional[str] + data_sources: List[Dict] class Page(NotionObject): @@ -151,6 +173,11 @@ class Page(NotionObject): properties: Dict[str, Dict] parent: ParentObject archived: bool + in_trash: bool + is_locked: bool + url: str + public_url: Optional[str] + icon: Optional[Union[FileObject, EmojiObject]] class Block(NotionObject): diff --git a/python_notion_api/models/paginations.py b/python_notion_api/models/paginations.py index fe5b572..5b293f6 100644 --- a/python_notion_api/models/paginations.py +++ b/python_notion_api/models/paginations.py @@ -1,6 +1,11 @@ from typing import Dict, List, Union -from python_notion_api.models.objects import Block, Database, Page, Pagination +from python_notion_api.models.objects import ( + Block, + DataSource, + Page, + Pagination, +) from python_notion_api.models.properties import PropertyItem @@ -11,11 +16,11 @@ class PagePagination(Pagination): results: List[Page] -class PageOrDatabasePagination(Pagination): +class PageOrDataSourcePagination(Pagination): _class_key_field = None - page_or_database: Dict - results: List[Union[Page, Database]] + page_or_data_source: Dict + results: List[Union[Page, DataSource]] class PropertyItemPagination(Pagination): diff --git a/python_notion_api/sync_api/api.py b/python_notion_api/sync_api/api.py index 1644dd9..1dbdd41 100644 --- a/python_notion_api/sync_api/api.py +++ b/python_notion_api/sync_api/api.py @@ -10,7 +10,11 @@ from requests.packages.urllib3.exceptions import MaxRetryError from requests.packages.urllib3.util.retry import Retry -from python_notion_api.models.common import FileObject, ParentObject +from python_notion_api.models.common import ( + DataSourceObject, + FileObject, + ParentObject, +) from python_notion_api.models.configurations import ( NotionPropertyConfiguration, RelationPropertyConfiguration, @@ -24,6 +28,7 @@ from python_notion_api.models.objects import ( Block, Database, + DataSource, NotionObjectBase, Page, Pagination, @@ -57,12 +62,12 @@ def __init__( api: NotionAPI, page_id: str, obj: Optional[Page] = None, - database: Optional[NotionDatabase] = None, + data_source: Optional[NotionDataSource] = None, ): self._api = api self._page_id = page_id self._object = obj - self.database = database + self.data_source = data_source if self._object is None: self.reload() @@ -72,9 +77,10 @@ def __init__( if self._object is None: raise ValueError(f"Page {page_id} could not be found") - if database is None: - parent_id = self.parent.database_id - self.database = self._api.get_database(parent_id) + if data_source is None: + parent_id = self.parent.data_source_id + if parent_id is not None: + self.data_source = self._api.get_data_source(parent_id) def __getattr__(self, attr_key: str): return getattr(self._object, attr_key) @@ -455,11 +461,6 @@ class NotionDatabase: database_id: Id of the database. """ - class CreatePageRequest(BaseModel): - parent: ParentObject - properties: dict[str, PropertyValue] - cover: Optional[FileObject] - def __init__(self, api: NotionAPI, database_id: str): self._api = api self._database_id = database_id @@ -470,10 +471,9 @@ def __init__(self, api: NotionAPI, database_id: str): if self._object is None: raise Exception(f"Error accessing database {self._database_id}") - self._properties = { - key: NotionPropertyConfiguration.from_obj(val) - for key, val in self._object.properties.items() - } + self._data_sources = [ + DataSourceObject(**val) for val in self._object.data_sources + ] self._title = "".join(rt.plain_text for rt in self._object.title) @property @@ -491,20 +491,51 @@ def title(self) -> str: return self._title @property - def properties(self) -> dict[str, NotionPropertyConfiguration]: - """Gets all property configurations of the database.""" - return self._properties + def data_sources(self) -> list[DataSourceObject]: + """Gets all data sources of the database.""" + return self._data_sources + + +class NotionDataSource: + """Wrapper for a Notion data source object. + + Args: + api: Instance of the NotionAPI. + data_source_id: Id of the data source. + """ + + class CreatePageRequest(BaseModel): + parent: ParentObject + properties: dict[str, PropertyValue] + cover: Optional[FileObject] + + def __init__(self, api: NotionAPI, data_source_id: str): + self._api = api + self._data_source_id = data_source_id + self._object = self._api._get( + endpoint=f"data_sources/{self._data_source_id}", + cast_cls=DataSource, + ) + + if self._object is None: + raise Exception( + f"Error accessing data source {self._data_source_id}" + ) + + self._properties = { + key: NotionPropertyConfiguration.from_obj(val) + for key, val in self._object.properties.items() + } + self._title = "".join(rt.plain_text for rt in self._object.title) @property - def relations(self) -> dict[str, RelationPropertyConfiguration]: - """Gets all property configurations of the database that are - relations. + def data_source_id(self) -> str: + """Gets data source id. + + Returns: + Id of the data source. """ - return { - key: val - for key, val in self._properties.items() - if isinstance(val, RelationPropertyConfiguration) - } + return self._data_source_id.replace("-", "") def query( self, @@ -513,9 +544,9 @@ def query( cast_cls=NotionPage, page_limit: Optional[int] = None, ) -> Generator[NotionPage, None, None]: - """Queries the database. + """Queries the data source. - Retrieves all pages belonging to the database that satisfy the given filters + Retrieves all pages belonging to the data source that satisfy the given filters in the order specified by the sorts. Args: @@ -539,21 +570,63 @@ def query( ] for item in self._api._post_iterate( - endpoint=f"databases/{self._database_id}/query", + endpoint=f"data_sources/{self._data_source_id}/query", data=data, retry_strategy=self._api.post_retry_strategy, page_limit=page_limit, ): yield cast_cls( - api=self._api, database=self, page_id=item.page_id, obj=item + api=self._api, data_source=self, page_id=item.page_id, obj=item ) + @property + def title(self) -> str: + """Get the title of the data source.""" + return self._title + + @property + def properties(self) -> dict[str, NotionPropertyConfiguration]: + """Gets all property configurations of the data source.""" + return self._properties + + @property + def relations(self) -> dict[str, RelationPropertyConfiguration]: + """Gets all property configurations of the data source that are + relations. + """ + return { + key: val + for key, val in self._properties.items() + if isinstance(val, RelationPropertyConfiguration) + } + + def get_property(self, prop_config: Any, prop_value: str) -> Any: + """Create property for a given property configuration.""" + + if isinstance(prop_value, (PropertyItem, PropertyItemIterator)): + type_ = prop_value.property_type + + if type_ != prop_config.config_type: + # Have a mismatch between the property type and the + # given item + raise TypeError( + f"Item {prop_value.__class__} given as " + f"the value for property " + f"{prop_config.__class__}" + ) + new_prop = prop_value + + else: + new_prop = prop_config.create_property(prop_value) + + return new_prop + def create_page( self, properties: dict[str, Any] = {}, cover_url: Optional[str] = None, ) -> NotionPage: - """Creates a new page in the Database and updates the new page with + """Creates a new page in the data source and updates the new page with the properties. Args: @@ -561,7 +634,7 @@ def create_page( will depend on the property type. Can be the raw value (e.g. string, float) or an object (e.g. SelectValue, NumberPropertyItem) - cover: URL of an image for the page cover. + cover_url: URL of an image for the page cover. Returns: A new page. @@ -575,9 +648,9 @@ def create_page( value = generate_value(prop.config_type, prop_value) validated_properties[prop_name] = value - request = NotionDatabase.CreatePageRequest( + request = NotionDataSource.CreatePageRequest( parent=ParentObject( - type="database_id", database_id=self.database_id + type="data_source_id", data_source_id=self.data_source_id ), properties=validated_properties, cover=( @@ -599,7 +672,7 @@ def create_page( api=self._api, page_id=new_page.page_id, obj=new_page, - database=self, + data_source=self, ) @@ -615,7 +688,7 @@ class NotionAPI: def __init__( self, access_token: str, - api_version: str = "2022-06-28", + api_version: str = "2025-09-03", page_limit: int = 20, ): self._access_token = access_token @@ -888,19 +961,26 @@ def get_database(self, database_id: str) -> NotionDatabase: Args: database_id: Id of the database to fetch. - Returns: A Notion database with the given id. """ return NotionDatabase(self, database_id) + def get_data_source(self, data_source_id: str) -> NotionDataSource: + """Wrapper for 'Retrieve a data source' action. + + Args: + data_source_id: Id of the data source to fetch. + """ + return NotionDataSource(self, data_source_id) + def get_page( self, page_id: str, page_cast: Type[NotionPage] = NotionPage ) -> NotionPage: """Gets Notion page. Args: - page_id: Id of the database to fetch. + page_id: Id of the page to fetch. page_cast: A subclass of a NotionPage. Allows custom property retrieval. diff --git a/python_notion_api/sync_api/test_sync.py b/python_notion_api/sync_api/test_sync.py index 32e9696..21d5290 100644 --- a/python_notion_api/sync_api/test_sync.py +++ b/python_notion_api/sync_api/test_sync.py @@ -13,8 +13,6 @@ ) from python_notion_api.models.sorts import Sort -TEST_DB = "401076f6c7c04ae796bf3e4c847361e1" - TEST_TITLE = f"API Test {datetime.now(UTC).isoformat()}" TEST_TEXT = "Test text is boring" TEST_NUMBER = 12.5 @@ -22,7 +20,7 @@ TEST_STATUS = "In progress" TEST_MULTI_SELECT = ["foo", "bar", "baz"] TEST_DATE = datetime.now() -TEST_PEOPLE = ["fa9e1df9-7c24-427c-9c20-eac629565fe4"] +TEST_PEOPLE = ["2ced872b-594c-8176-a765-0002860b6fdc"] TEST_FILES = [File(name="foo.pdf", url="http://example.com/file")] TEST_CHECKBOX = True TEST_URL = "http://colorifix.com" @@ -36,20 +34,28 @@ def api(): @fixture -def database(api): - return api.get_database(database_id=TEST_DB) +def database(api, database_id): + return api.get_database(database_id=database_id) + + +@fixture +def data_source(api, data_source_id1): + return api.get_data_source(data_source_id=data_source_id1) class TestCore: - def test_database_id(self, database): - assert database.database_id == TEST_DB + def test_database_id(self, database, database_id): + assert database.database_id == database_id + + def test_get_data_source(self, data_source, data_source_id1): + assert data_source.data_source_id == data_source_id1 - def test_create_empty_page(self, database): - new_page = database.create_page() + def test_create_empty_page(self, data_source): + new_page = data_source.create_page() assert new_page is not None - def test_create_empty_page_with_cover(self, database, cover_url): - new_page = database.create_page(cover_url=cover_url) + def test_create_empty_page_with_cover(self, data_source, cover_url): + new_page = data_source.create_page(cover_url=cover_url) assert new_page is not None def test_get_page(self, api, example_page_id): @@ -63,12 +69,16 @@ def api(cls): return NotionAPI(access_token=os.environ.get("NOTION_TOKEN")) @fixture(scope="class") - def database(cls, api): - return api.get_database(database_id=TEST_DB) + def database(cls, api, database_id): + return api.get_database(database_id=database_id) @fixture(scope="class") - def new_page(cls, database): - return database.create_page() + def data_source(cls, api, data_source_id1): + return api.get_data_source(data_source_id=data_source_id1) + + @fixture(scope="class") + def new_page(cls, data_source): + return data_source.create_page() @mark.parametrize( "property,value", @@ -150,8 +160,8 @@ def test_set_alive(self, new_page): @mark.skip( reason="This test will create a notification for the TEST_PEOPLE" ) - def test_create_new_page(self, database): - new_page = database.create_page( + def test_create_new_page(self, data_source): + new_page = data_source.create_page( properties={ "Name": TEST_TITLE, "Text": TEST_TEXT, @@ -176,18 +186,18 @@ def test_get_unique_id(self, new_page): def test_get_by_id(self, new_page): new_page.set("Email", TEST_EMAIL) - email = new_page.get("%3E%5Ehh", cache=False).value + email = new_page.get("FEe%40", cache=False).value assert email == TEST_EMAIL def test_set_by_id(self, new_page): - new_page.set("%3E%5Ehh", TEST_EMAIL) + new_page.set("FEe%40", TEST_EMAIL) email = new_page.get("Email", cache=False).value assert email == TEST_EMAIL def test_update(self, new_page): new_page.update( properties={ - "%3E%5Ehh": TEST_EMAIL, + "FEe%40": TEST_EMAIL, "Phone": TEST_PHONE, "Multi-select": None, } @@ -209,17 +219,15 @@ def test_reload(self, new_page): class TestRollups: - NUMBER_PAGE_ID = "25e800a118414575ab30a8dc42689b74" - DATE_PAGE_ID = "e38bb90faf8a436895f089fed2446cc6" - EMPTY_ROLLUP_PAGE_ID = "2b5efae5bad24df884b4f953e3788b64" + EMPTY_ROLLUP_PAGE_ID = "2cef2075b1dc80b5b67edc58426e92f2" - def test_number_rollup(self, api): - number_page = api.get_page(self.NUMBER_PAGE_ID) + def test_number_rollup(self, api, example_page_id): + number_page = api.get_page(example_page_id) num = number_page.get("Number rollup") assert num.value == 10 - def test_date_rollup(self, api): - date_page = api.get_page(self.DATE_PAGE_ID) + def test_date_rollup(self, api, example_page_id): + date_page = api.get_page(example_page_id) date = date_page.get("Date rollup") assert isinstance(date.value.start, datetime) @@ -235,21 +243,42 @@ def api(cls): return NotionAPI(access_token=os.environ.get("NOTION_TOKEN")) @fixture(scope="class") - def database(cls, api): - return api.get_database(database_id=TEST_DB) + def database(cls, api, database_id): + return api.get_database(database_id=database_id) + + def test_get_datasources(self, database, data_source_id1, data_source_id2): + database.data_sources.sort(key=lambda x: x.data_source_id) + assert ( + database.data_sources[0].data_source_id.replace("-", "") + == data_source_id2 + ) + assert ( + database.data_sources[1].data_source_id.replace("-", "") + == data_source_id1 + ) + + +class TestDatasource: + @fixture(scope="class") + def api(cls): + return NotionAPI(access_token=os.environ.get("NOTION_TOKEN")) + + @fixture(scope="class") + def data_source(cls, api, data_source_id1): + return api.get_data_source(data_source_id=data_source_id1) - def test_query_database(self, database): - database.query() + def test_query_database(self, data_source): + data_source.query() - def test_prop_filter(self, database): - pages = database.query( + def test_prop_filter(self, data_source): + pages = data_source.query( filters=SelectFilter(property="Select", equals=TEST_SELECT) ) page = next(pages) assert page.get("Select").value == TEST_SELECT - def test_and_filter(self, database): - pages = database.query( + def test_and_filter(self, data_source): + pages = data_source.query( filters=and_filter( [ SelectFilter(property="Select", equals=TEST_SELECT), @@ -260,8 +289,8 @@ def test_and_filter(self, database): page = next(pages) assert page.get("Select").value == TEST_SELECT - def test_large_and_filter(self, database): - pages = database.query( + def test_large_and_filter(self, data_source): + pages = data_source.query( filters=and_filter( [NumberFilter(property="Number", equals=TEST_NUMBER)] + [ @@ -275,8 +304,8 @@ def test_large_and_filter(self, database): page = next(pages) assert page.get("Number").value == TEST_NUMBER - def test_or_filter(self, database): - pages = database.query( + def test_or_filter(self, data_source): + pages = data_source.query( filters=or_filter( [ SelectFilter(property="Select", equals=TEST_SELECT), @@ -287,8 +316,8 @@ def test_or_filter(self, database): page = next(pages) assert page.get("Select").value == TEST_SELECT - def test_large_or_filter(self, database): - pages = database.query( + def test_large_or_filter(self, data_source): + pages = data_source.query( filters=or_filter( [ SelectFilter(property="Select", equals=TEST_SELECT), @@ -302,13 +331,15 @@ def test_large_or_filter(self, database): page = next(pages) assert page.get("Select").value == TEST_SELECT - def test_sort(self, database): - pages = database.query(sorts=[Sort(property="Date")]) + def test_sort(self, data_source): + pages = data_source.query(sorts=[Sort(property="Date")]) page = next(pages) assert page is not None - def test_descending_sort(self, database): - pages = database.query(sorts=[Sort(property="Date", descending=True)]) + def test_descending_sort(self, data_source): + pages = data_source.query( + sorts=[Sort(property="Date", descending=True)] + ) page = next(pages) assert page is not None