Skip to content

Commit

Permalink
add feature to create database items
Browse files Browse the repository at this point in the history
  • Loading branch information
thrau committed Oct 14, 2023
1 parent f7000a8 commit 2275b6b
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 15 deletions.
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,30 @@
[![PyPI License](https://img.shields.io/pypi/l/notion-objects.svg)](https://img.shields.io/pypi/l/notion-objects.svg)
[![Codestyle](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)

A Python library that makes it easy to work with notion databases. 🚧 under development!
A Python library that makes it easy to work with notion databases, built on top of [notion-sdk-py](https://github.com/ramnes/notion-sdk-py).
It provides a higher-level API with a data mapper, allowing you to define custom mappings between notion database records and your Python objects.

## Usage
With notion-objects you can:
* [Transform properties](#defining-models)
* [Query databases](#querying-databases)
* [Update records](#updating-records)
* [Create records](#creating-records)

## User guide

### Defining models

Suppose your database `tasks` has four fields, the title `Task`, a date range `Date`, and a person `Assigned to`, and a status field `Status`.
You want to transform notion database queries into records of:

```json
{"task": "my task", "date_start": "2022-01-01", "date_end": "2022-01-02", "assigned_to": "Thomas", "status": "In progress"}
{
"task": "my task",
"date_start": "2022-01-01",
"date_end": "2022-01-02",
"assigned_to": "Thomas",
"status": "In progress"
}
```

First, declare a model that contains all the necessary transformations as descriptors:
Expand All @@ -28,6 +41,7 @@ class Task(NotionObject):
assigned_to = Person("Assigned to")
date_start = DateRangeStart("Date")
date_end = DateRangeEnd("Date")
closed_at = Date("Closed at")
status = Status("Status")
```

Expand Down Expand Up @@ -83,3 +97,39 @@ for record in database:
```

**NOTE** not all types have yet been implemented. Type mapping is very rudimentary.

### Updating records

You can update database records by simply calling attributes with normal python assignments.
The data mapper will map the types correctly to Notion's internal format.
You can then call `Database.update(...)` to run an update API call.
notion-objects keeps track of all the changes that were made to the object, and only sends the changes.

```python
database: Database[Task] = Database(Task, ...)

task = database.find_by_id("...")
task.status = "Done"
task.closed_at = datetime.utcnow()
database.update(task)
```

**Note** not all properties can be set yet.

### Creating records

Similarly, you can also create new pages.
You can use `NotionObject.new()` on any subclass to create new unmanaged instances of that type.
Then, call `Database.create(...)` to create a new item in the database.

```python
database: Database[Task] = Database(Task, ...)

task = Task.new()
task.task = "My New Task"
task.status = "In progress"
task.assigned_to = "6aa4d3cd-3928-4f61-9072-f74a3ebfc3ca"

task = database.create(task)
print(task.id) # will print the page ID that was created
```
23 changes: 22 additions & 1 deletion notion_objects/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def properties(self) -> Properties:
return Properties.parse(self._database_object())

@cached_property
def title(self):
def title(self) -> str:
"""
Returns the plain text name of the database.
"""
Expand Down Expand Up @@ -139,6 +139,27 @@ def update(self, page: ChangeTracker):

self.client.pages.update(page_id, properties=page.__changes__)

def create(self, page: _N) -> _N:
"""
Creates the given page in the database. The page should be created as follows::
obj = MyPageObject.new()
obj.my_attr = "Some Value"
db_obj = database.create(obj)
:param page: the page object
:return: a new instance of the object managed in the database
"""
return self.type(
self.client.pages.create(
parent={
"database_id": self.database_id,
},
properties=page._obj["properties"],
)
)

def query(self, query: Query = None) -> Iterable[_N]:
"""
Query the database with the given query object (see
Expand Down
10 changes: 7 additions & 3 deletions notion_objects/encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ def default(self, o):


class _ConverterMixin:
def to_dict(self, flat=False) -> dict:
def to_dict(self, flat: bool = False) -> dict:
result = {}

for prop in self._get_properties():
value = getattr(self, prop.attr)
try:
value = getattr(self, prop.attr)
except KeyError:
# TODO: handle more gracefully
continue
key = prop.attr

if isinstance(value, DateValue):
Expand All @@ -49,7 +53,7 @@ def to_dict(self, flat=False) -> dict:

return result

def to_json(self, flat=False):
def to_json(self, flat: bool = False):
return json.dumps(self.to_dict(flat=flat), cls=JSONEncoder)

def _get_properties(self) -> Iterable[Property]:
Expand Down
15 changes: 15 additions & 0 deletions notion_objects/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ def __init__(self, obj):
def _get_properties(self) -> Iterable[Property]:
return self.__properties__

@classmethod
def new(cls, **kwargs):
"""
Creates a new unmanaged object.
:param kwargs: the object attributes
:return: a new object that can be added with Database.create(obj)
"""
obj = cls({"properties": {}})

for k, v in kwargs.items():
setattr(obj, k, v)

return obj


class DynamicNotionObject(_ConverterMixin, ChangeTracker):
_properties: Properties
Expand Down
84 changes: 76 additions & 8 deletions notion_objects/properties.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from datetime import date, datetime
from functools import cached_property
from typing import Dict, Generic, List, Optional, Tuple, Type, TypeVar, Union
from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar, Union

import dateutil.parser

Expand All @@ -28,6 +28,8 @@
"last_edited_time",
"last_edited_by", # TODO
"status",
"emoji", # TODO
"external", # TODO
]

_T = TypeVar("_T")
Expand All @@ -45,12 +47,12 @@ def __init__(self, field: str = None, object_locator: str = "_obj"):

def get(self, field: str, obj: dict) -> _T:
raise NotImplementedError(
"get operation not implemented for property '%s'" % (self.__class__.__name__)
f"get operation not implemented for property '{self.__class__.__name__}'"
)

def set(self, field: str, value: _T, obj: dict):
raise NotImplementedError(
"set operation not implemented for property '%s'" % (self.__class__.__name__)
f"set operation not implemented for property '{self.__class__.__name__}'"
)

def __set_name__(self, owner, name):
Expand Down Expand Up @@ -95,7 +97,7 @@ def __str__(self):

class ChangeTracker:
@cached_property
def __changes__(self) -> Dict[str, Tuple[Property, object]]:
def __changes__(self) -> Dict[str, Any]:
return {}


Expand All @@ -114,6 +116,17 @@ def get(self, field: str, obj: dict) -> str:
return "".join([item["plain_text"] for item in items])
return ""

def set(self, field: str, value: Optional[str], obj: dict):
obj[field] = {
"title": [
{
"type": "text",
"text": {"content": value},
"plain_text": value,
}
]
}


class TitleText(Property[str]):
def get(self, field: str, obj: dict) -> str:
Expand All @@ -122,6 +135,18 @@ def get(self, field: str, obj: dict) -> str:
return "".join([item["text"]["content"] for item in items])
return ""

def set(self, field: str, value: Optional[str], obj: dict):
# TODO: allow rich-text
obj[field] = {
"title": [
{
"type": "text",
"text": {"content": value},
"plain_text": value,
}
]
}


class Text(Property[str]):
def get(self, field: str, obj: dict) -> str:
Expand All @@ -132,6 +157,31 @@ def get(self, field: str, obj: dict) -> str:

return ""

def set(self, field: str, value: Optional[str], obj: dict):
if value is None:
obj[field] = {"rich_text": []}
return

# TODO: allow rich-text
obj[field] = {
"rich_text": [
{
"type": "text",
"text": {"content": value},
"annotations": {
"bold": False,
"italic": False,
"strikethrough": False,
"underline": False,
"code": False,
"color": "default",
},
"plain_text": value,
"href": None,
}
]
}


class Email(Property[Optional[str]]):
def get(self, field: str, obj: dict) -> Optional[str]:
Expand Down Expand Up @@ -161,7 +211,12 @@ class Number(Property[Optional[Union[float, int]]]):
def get(self, field: str, obj: dict) -> Optional[Union[float, int]]:
return obj["properties"][field]["number"]

def set(self, field: str, value: Union[float, int], obj: dict):
def set(self, field: str, value: Optional[Union[float, int, str]], obj: dict):
if isinstance(value, str):
if "." in value:
value = float(value)
else:
value = int(value)
obj[field] = {"number": value}


Expand All @@ -170,15 +225,17 @@ def get(self, field: str, obj: dict) -> Optional[int]:
if value := obj["properties"][field]["number"]:
return int(value)

def set(self, field: str, value: Optional[int], obj: dict):
def set(self, field: str, value: Optional[Union[int, str]], obj: dict):
if isinstance(value, str):
value = int(value)
obj[field] = {"number": value}


class Checkbox(Property[bool]):
def get(self, field: str, obj: dict) -> bool:
return obj["properties"][field]["checkbox"] in [True, "true"]

def set(self, field: str, value: bool, obj: dict):
def set(self, field: str, value: Optional[bool], obj: dict):
obj[field] = {"checkbox": value}


Expand Down Expand Up @@ -248,7 +305,8 @@ def get_value(field: str, obj: dict) -> DateValue:
@staticmethod
def set_value(field: str, value: DateValue, obj: dict):
if value.start is None and value.end is None:
obj[field] = {"date": {}}
obj[field] = {"date": None}
return

obj[field] = {
"date": {
Expand All @@ -270,13 +328,23 @@ def get(self, field: str, obj: dict) -> Optional[date]:
return dateutil.parser.parse(container["start"]).date()
return None

def set(self, field: str, value: Optional[Union[date, str]], obj: dict):
if isinstance(value, str):
value = dateutil.parser.parse(value)
DateProperty.set_value(field, DateValue(start=value, include_time=False), obj)


class DateTime(Property[Optional[datetime]]):
def get(self, field: str, obj: dict) -> Optional[datetime]:
if container := obj["properties"][field]["date"]:
return dateutil.parser.parse(container["start"])
return None

def set(self, field: str, value: Optional[Union[datetime, str]], obj: dict):
if isinstance(value, str):
value = dateutil.parser.parse(value)
DateProperty.set_value(field, DateValue(start=value, include_time=True), obj)


class DateTimeRange(Property[Tuple[Optional[datetime], Optional[datetime]]]):
def get(self, field: str, obj: dict) -> Tuple[Optional[datetime], Optional[datetime]]:
Expand Down

0 comments on commit 2275b6b

Please sign in to comment.