Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
468 changes: 468 additions & 0 deletions docs/src/content/docs/connectors/notion.mdx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/src/data/docs-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const sidebar: SidebarItem[] = [
{ type: 'doc', slug: 'connectors/lancedb', label: 'LanceDB' },
{ type: 'doc', slug: 'connectors/localfs', label: 'Local filesystem' },
{ type: 'doc', slug: 'connectors/neo4j', label: 'Neo4j' },
{ type: 'doc', slug: 'connectors/notion', label: 'Notion' },
{ type: 'doc', slug: 'connectors/oci_object_storage', label: 'OCI Object Storage' },
{ type: 'doc', slug: 'connectors/postgres', label: 'Postgres' },
{ type: 'doc', slug: 'connectors/qdrant', label: 'Qdrant' },
Expand Down
101 changes: 101 additions & 0 deletions examples/notion_target_basics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Notion target — basics

Minimal example for the cocoindex Notion target connector. Declares three rows
of `Person` data; cocoindex syncs them to a Notion database — creating new
pages, patching changed ones, and archiving pages whose source row goes away.

## What it shows

| Behavior | How to trigger | What you see in Notion |
|---|---|---|
| Initial insert | First `cocoindex update` | Three pages created |
| Idempotent re-run | Re-run with no changes | ~0.2s, zero Notion writes |
| Field update | Change a field value on one row | That page PATCHed |
| Automatic archive | Remove a row from `PEOPLE` and re-run | That page archived in Notion |
| Re-add | Add the row back | New page (or revived) |

The archive step is the key win over hand-rolled `notion-client` plumbing —
cocoindex tracks what it wrote on the previous run and reconciles it against
what's declared this run.

## Setup

1. **Create a Notion database** under any page your integration can see, with
the properties this example declares:

| Property | Type |
|---|---|
| `Name` | Title |
| `Email` | Email |
| `Role` | Select (`Engineer` / `Researcher` / `Designer`) |
| `Active` | Checkbox |

The sandbox page used while developing this example looked like this:

![Sandbox parent page](https://cocoindex.io/blobs/docs/img/examples/notion_target_basics/test-sandbox-page.png)

2. **Share the parent page** with your Notion integration: top-right `···` →
Connections → `+ Add connections` → select your integration. (Sharing the
database is not enough — Notion checks access at the parent-page level.)

3. **Grab the data source ID** from the database URL, or via
`GET /v1/databases/{id}/data_sources`.

4. **Export tokens** and run:

```sh
export NOTION_TOKEN=ntn_...
export NOTION_DATA_SOURCE_ID=<your-data-source-id>
cocoindex update main.py:NotionTargetBasics
```

After the first run, the database fills up:

![Demo database after first run, with Alan archived](https://cocoindex.io/blobs/docs/img/examples/notion_target_basics/demo-database-after-archive.png)

(Pictured: after the `Alan Turing` row was removed from `PEOPLE` and the example
was re-run — his page was automatically archived, leaving only Ada and Grace.)

## Try the lifecycle

Edit `main.py`'s `PEOPLE` list and re-run `cocoindex update` after each change:

```python
# 1. Add a new row -> CocoIndex creates a new page
Person(name="Margaret Hamilton", email="margaret@example.com",
role="Engineer", active=True),

# 2. Change a value -> CocoIndex PATCHes that page
Person(name="Ada Lovelace", email="ada@new.example.com", ...),

# 3. Remove a row -> CocoIndex archives that page
# (delete the line)
```

## Switch the delete behavior

Pass `on_delete=...` to change what happens when a row is removed:

```python
target = await notion.mount_database_target(
notion_client,
os.environ["NOTION_DATA_SOURCE_ID"],
schema,
on_delete=notion.OnDelete.HARD, # send page to trash
# on_delete=notion.OnDelete.IGNORE # leave page alone
)
```

Default is `OnDelete.ARCHIVE` — reversible, matches what Notion users expect.

## Image files to add (developer note)

The two screenshots referenced above live in the `cocoindex-io/blobs` repo:

| README link → file path (in blobs repo) |
|---|
| `public/docs/img/examples/notion_target_basics/test-sandbox-page.png` |
| `public/docs/img/examples/notion_target_basics/demo-database-after-archive.png` |

Drop the images at those paths, then `git add . && git commit && git push` from
the blobs repo — the cocoindex.io Pages workflow will publish them.
79 changes: 79 additions & 0 deletions examples/notion_target_basics/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Minimal example for the cocoindex Notion target connector.

Declares three rows against a Notion database (data source). On the first
run, the rows are created. On subsequent runs with identical rows, nothing
happens. Modify the ``PEOPLE`` list below to see how reconciliation works:

- Edit a row's value -> CocoIndex PATCHes the corresponding Notion page.
- Remove a row -> CocoIndex archives the page (or hard-deletes, or leaves
it untouched, depending on the ``on_delete`` strategy).
- Add a row -> CocoIndex creates a new page.

Setup
-----
1. Create a Notion database with the properties: ``Name`` (title),
``Email`` (email), ``Role`` (select), ``Active`` (checkbox).
2. Share it with your integration (top-right ··· -> Connections).
3. Grab the data source ID from the URL (or via
``GET /v1/databases/{id}/data_sources``).
4. Export ``NOTION_TOKEN`` and ``NOTION_DATA_SOURCE_ID``.
5. ``cocoindex update main.py:NotionTargetBasics``
"""

import os
import pathlib
from dataclasses import dataclass
from typing import AsyncIterator

from typing_extensions import Annotated

import cocoindex as coco
from cocoindex.connectors import notion

notion_client = coco.ContextKey[notion.NotionClient]("notion")


@dataclass(frozen=True)
class Person:
"""One row in the Notion database."""

name: Annotated[str, notion.TitleProp("Name")]
email: Annotated[str, notion.EmailProp("Email")]
role: Annotated[str, notion.SelectProp("Role")]
active: Annotated[bool, notion.CheckboxProp("Active")]


PEOPLE: list[Person] = [
Person(name="Ada Lovelace", email="ada@example.com", role="Engineer", active=True),
Person(
name="Grace Hopper", email="grace@example.com", role="Engineer", active=True
),
Person(
name="Alan Turing", email="alan@example.com", role="Researcher", active=False
),
]


@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -> AsyncIterator[None]:
builder.settings.db_path = pathlib.Path("./cocoindex.db")
async with notion.NotionClient(token=os.environ["NOTION_TOKEN"]) as client:
builder.provide(notion_client, client)
yield


@coco.fn
async def app_main() -> None:
target = await notion.mount_database_target(
notion_client,
os.environ["NOTION_DATA_SOURCE_ID"],
await notion.DatabaseSchema.from_class(Person, primary_key=["name"]),
)
for person in PEOPLE:
target.declare_row(row=person)


app = coco.App(
coco.AppConfig(name="NotionTargetBasics"),
app_main,
)
9 changes: 9 additions & 0 deletions examples/notion_target_basics/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
name = "notion-target-hello-world"
version = "0.1.0"
description = "Hello-world for the cocoindex Notion target connector."
requires-python = ">=3.11"
dependencies = ["cocoindex[notion]>=1.0.7"]

[tool.setuptools]
packages = []
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ falkordb = ["falkordb>=1.1.0"]
neo4j = ["neo4j>=5.18.0"]
kafka = ["confluent_kafka>=2.6"]
iggy = ["apache-iggy>=0.8.0"]
notion = ["aiohttp>=3.9.0"]
oci = ["oci>=2.0"]
entity_resolution = ["faiss-cpu>=1.7"]
entity_resolution_llm = [
Expand Down
80 changes: 80 additions & 0 deletions python/cocoindex/connectors/notion/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Notion target connector for CocoIndex.

Declarative target for Notion databases (data sources) with automatic
upsert + delete semantics, mirroring ``connectors.postgres``.

Quick start::

from cocoindex.connectors import notion

notion_client = coco.ContextKey[notion.NotionClient]("notion_main")

@coco.lifespan
async def lifespan(builder):
async with notion.NotionClient(token=os.environ["NOTION_TOKEN"]) as c:
builder.provide(notion_client, c)
yield

@dataclass(frozen=True)
class AccountRow:
domain: Annotated[str, notion.UrlProp("Domain")]
name: Annotated[str, notion.TitleProp("Name")]

@coco.fn
async def app_main():
accounts = await notion.mount_database_target(
notion_client,
"d7b662bc-3241-49d2-b41f-92aed710630e",
await notion.DatabaseSchema.from_class(
AccountRow, primary_key=["domain"]
),
)
accounts.declare_row(row=AccountRow(domain="anthropic.com", name="Anthropic"))
"""

from ._client import NotionClient, NOTION_API_VERSION
from ._target import (
DatabaseTarget,
ManagedBy,
OnDelete,
database_target,
declare_database_target,
mount_database_target,
)
from ._types import (
CheckboxProp,
DatabaseSchema,
DateProp,
EmailProp,
MultiSelectProp,
NumberProp,
PropType,
RelationProp,
RichTextProp,
SelectProp,
TitleProp,
UrlProp,
)

__all__ = [
"CheckboxProp",
"DatabaseSchema",
"DatabaseTarget",
"DateProp",
"ManagedBy",
"EmailProp",
"MultiSelectProp",
"NOTION_API_VERSION",
"NotionClient",
"NumberProp",
"OnDelete",
"PropType",
"RelationProp",
"RichTextProp",
"SelectProp",
"TitleProp",
"UrlProp",
"database_target",
"declare_database_target",
"mount_database_target",
]
Loading
Loading