Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
481 changes: 481 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
96 changes: 96 additions & 0 deletions examples/notion_target_basics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Notion target — basics

Minimal example for the cocoindex Notion target connector. Declares three rows
of `Person` data; cocoindex creates the Notion database for you
(`managed_by="system"`, the default) and syncs the rows — 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 (or pick) a Notion page** to hold the database. CocoIndex creates
the database — titled `People`, with the `Name` / `Email` / `Role` /
`Active` properties this example declares — under it on the first run.

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 page** with your Notion integration: top-right `···` →
Connections → `+ Add connections` → select your integration. Every parent
page in the path must be shared — Notion checks access at the page level.

3. **Grab the page ID** from the page URL (the 32-char hex after the title).

4. **Export tokens** and run:

```sh
export NOTION_TOKEN=ntn_...
export NOTION_PARENT_PAGE=<your-parent-page-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,
schema=schema,
parent_page_id=os.environ["NOTION_PARENT_PAGE"],
title="People",
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.
86 changes: 86 additions & 0 deletions examples/notion_target_basics/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Minimal example for the cocoindex Notion target connector.

CocoIndex manages the database for you (``managed_by="system"``, the default):
on the first run it creates a database titled "People" under the parent page
you point it at, then keeps the rows in sync on every run. 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.
- Add a field to ``Person`` -> CocoIndex adds the property to the database.

Setup
-----
1. Create (or pick) a Notion page to hold the database.
2. Share it with your integration (top-right ··· -> Connections). Every parent
page in the path must be shared, not just the database.
3. Export ``NOTION_TOKEN`` and ``NOTION_PARENT_PAGE`` (the page's ID, from its
URL).
4. ``cocoindex update main.py:NotionTargetBasics``

To point at an existing database instead of creating one, pass
``managed_by="user"`` with a ``data_source_id`` — see the connector docs.
"""

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:
# managed_by="system" is the default — CocoIndex creates the "People"
# database under the parent page on first run and evolves it as Person grows.
target = await notion.mount_database_target(
notion_client,
schema=await notion.DatabaseSchema.from_class(Person, primary_key=["name"]),
parent_page_id=os.environ["NOTION_PARENT_PAGE"],
title="People",
)
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
81 changes: 81 additions & 0 deletions python/cocoindex/connectors/notion/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""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,
schema=await notion.DatabaseSchema.from_class(
AccountRow, primary_key=["domain"]
),
parent_page_id=os.environ["NOTION_PARENT_PAGE_ID"],
title="Accounts",
)
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