Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]" build

- name: Regenerate models from the spec
run: ./scripts/generate.sh

- name: Check generated code is not stale (drift check)
run: git diff --exit-code src/lunogram/gen

- name: Build wheel
run: python -m build

- name: Import smoke test
run: |
python -c "from lunogram import Lunogram, random_user; print('import OK')"

- name: Run tests
run: python -m pytest
30 changes: 30 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: E2E Tests

on:
push:

jobs:
e2e:
name: "E2E Tests"
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"

- name: Run E2E tests
run: python -m pytest tests/test_e2e.py -v
env:
LUNOGRAM_API_KEY: ${{ secrets.LUNOGRAM_API_KEY }}
LUNOGRAM_API_URL: ${{ secrets.LUNOGRAM_API_URL }}
LUNOGRAM_PROJECT_ID: ${{ secrets.LUNOGRAM_PROJECT_ID }}
20 changes: 20 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
PYTHON ?= python3.12

.PHONY: install generate check-drift build test

install:
$(PYTHON) -m pip install -e ".[dev]"

# Regenerate the low-level model layer from the vendored spec.
generate:
./scripts/generate.sh

# Fail if the committed generated code is stale relative to the spec.
check-drift: generate
git diff --exit-code src/lunogram/gen

build:
$(PYTHON) -m build

test:
$(PYTHON) -m pytest
166 changes: 166 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Lunogram Python SDK

Python SDK for the Lunogram Client API.

## Installation

```bash
pip install lunogram-sdk
```

## Usage

Every authenticated Client API endpoint is scoped to a **project**. You supply
the project UUID **once**, when constructing the client, and the SDK injects it
into every request path automatically. You never pass the project per call.

```python
from lunogram import Lunogram, random_user

client = Lunogram(
api_key="your-api-key",
project_id="11111111-2222-3333-4444-555555555555", # your project UUID
# url_endpoint="https://console.lunogram.com/api", # optional host override
)

# Upsert a user
result = client.user.upsert(random_user())
print(result[0]) # API response

# Send a user event
client.user.events.post({
"identifier": [{"source": "default", "external_id": "user_123"}],
"name": "signed_in",
})

# Organizations work the same way
client.organization.upsert({
"identifier": [{"source": "default", "external_id": "org_123"}],
"name": "Acme Inc.",
})
```

> All SDK actions return a tuple: the API response on index `0`, and a possible
> error on index `1`.

## Project-scoped URLs

As of this release, every Client API path includes the project UUID as a path
segment:

```
/api/client/projects/<project_id>/...
```

| Resource | Path (under `/api/client/projects/<project_id>/`) |
| --- | --- |
| Users | `users` |
| User events | `users/events` |
| User scheduled | `users/scheduled` |
| User devices | `users/devices` |
| User inbox | `users/inbox` (`/count`, `/read`, `/archived`) |
| Organizations | `organizations` |
| Organization users | `organizations/users` |
| Organization events | `organizations/events` |
| Organization scheduled | `organizations/scheduled` |
| Organization inbox | `organizations/inbox` (`/count`, `/read`, `/archived`) |
| Push VAPID | `push/vapid` |
| Auth method sessions | `auth-methods/<auth_method_id>/sessions` |

Authentication is unchanged — the same API key / token is used. Only the URL
gained the project segment.

## More resources

The same `Lunogram` client exposes the full Client API surface. Every method
accepts either a plain dict or the matching generated model from
`lunogram.gen.models`.

```python
# Inbox — users and organizations share the same surface
# (client.organization.inbox.* mirrors client.user.inbox.*)
client.user.inbox.create([
{
"target": [{"external_id": "user_123"}],
"identifier": {"external_id": "msg-1"},
"channel": "inbox",
"content": {"title": "Welcome", "body": "Thanks for joining!"},
},
])
messages = client.user.inbox.query(source="default", external_id="user_123", channel="inbox", status="unread")
counts = client.user.inbox.count(source="default", external_id="user_123", channel="inbox")
client.user.inbox.mark_read([{"target": [{"external_id": "user_123"}], "message_id": "msg-1"}])
client.user.inbox.mark_archived([{"target": [{"external_id": "user_123"}], "message_id": "msg-1"}])

# Organization membership
client.organization.add_user({
"organization": {"identifier": [{"external_id": "org_123"}]},
"user": {"identifier": [{"external_id": "user_123"}]},
})
client.organization.remove_user({
"organization": {"identifier": [{"external_id": "org_123"}]},
"user": {"identifier": [{"external_id": "user_123"}]},
})

# Devices & push
vapid = client.push.get_vapid_public_key()
client.user.devices.register({
"identifier": [{"external_id": "user_123"}],
"device_id": "device-1",
"os": "web",
"config": {"endpoint": "https://push.example.com/...", "keys": {"p256dh": "...", "auth": "..."}},
})

# Sessions — mint a short-lived token for an end user (server-side)
session = client.sessions.create("auth-method-uuid", {"user_id": "user_123"})
```

## Architecture: spec-driven, layered

The SDK is split into a **generated low-level layer** and a **hand-written
facade**:

| Layer | Location | How it's produced |
| --- | --- | --- |
| Models / payload types | `src/lunogram/gen/models.py` | **Generated** from the OpenAPI spec (Pydantic v2 models) |
| Facade (`Lunogram`, path factory, UUID validation, auth header, transport) | `src/lunogram/client.py`, `app/**`, `utils/reference.py` | **Hand-written** |

Cross-cutting concerns live in the facade in a single place: the
`/api/client/projects/{project_id}` prefix (in `utils/reference.py`) and the
`Bearer`-style auth header (in `app/http.py`). Resource methods never repeat the
project scoping.

Because Python is snake_case end-to-end — matching the spec — the generated
models flow straight through to the request body with **no camelCase↔snake_case
mapping layer**. A generated model and a plain dict are both accepted by every
method; models are dumped with `model_dump(mode="json", exclude_none=True)`.

### The spec is vendored from a platform release

`spec/client.yaml` is copied verbatim from a Lunogram platform **release**.
`spec/SOURCE.md` records the source repo, pinned tag and asset URL. Every tagged
release publishes the client OpenAPI spec as a `client.yaml` asset, so the spec
is fetched from a stable, versioned, immutable source. Bumping the pin is a
manual edit to `spec/SOURCE.md` followed by `make generate`.

### Regenerating the models

The generator is [`datamodel-code-generator`](https://github.com/koxudaxi/datamodel-code-generator)
(pinned in the `dev` extra). Install it and run the generate step:

```bash
make install # pip install -e ".[dev]"
make generate # ./scripts/generate.sh -> src/lunogram/gen/models.py
```

`scripts/generate.sh` is the single source of truth for the generator flags;
the `make generate` target and CI both call it. The output carries a
"`DO NOT EDIT`" header — never hand-edit it; change the spec (or the script) and
regenerate.

### CI

- **`ci.yml`** (push / PR, Python 3.12) — installs deps, regenerates, runs
`git diff --exit-code src/lunogram/gen` as a **drift check** (fails if the
committed generated code is stale), builds the wheel, import-smoke-tests, and
runs `pytest`.
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ requires-python = ">=3.10"
dependencies = [
"python-dotenv>=1.0.0",
"requests>=2.31.0",
"pydantic[email]>=2.6",
]

[project.optional-dependencies]
# Code generation toolchain. Pinned so generated output is deterministic and
# the CI drift check is stable. Run `make generate` after bumping.
dev = [
"datamodel-code-generator==0.26.5",
"pytest>=8.0",
]

[project.urls]
Expand Down
31 changes: 31 additions & 0 deletions scripts/generate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Generate the low-level Pydantic model layer from the vendored OpenAPI spec.
#
# Input : spec/client.yaml (vendored from the platform repo; see spec/SOURCE.md)
# Output: src/lunogram/gen/models.py (committed; "DO NOT EDIT" header)
#
# This is the single source of truth for codegen flags. It is invoked by the
# `make generate` target and by CI. Requires datamodel-code-generator (pinned in
# the project's `dev` extra) on a Python 3.10+ interpreter.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

datamodel-codegen \
--input spec/client.yaml \
--input-file-type openapi \
--output src/lunogram/gen/models.py \
--output-model-type pydantic_v2.BaseModel \
--target-python-version 3.10 \
--use-standard-collections \
--use-union-operator \
--snake-case-field \
--use-schema-description \
--use-field-description \
--field-constraints \
--disable-timestamp \
--custom-file-header "# generated by datamodel-codegen; DO NOT EDIT.
# source: spec/client.yaml (run \`make generate\` to regenerate)"

echo "Generated src/lunogram/gen/models.py from spec/client.yaml"
34 changes: 34 additions & 0 deletions spec/SOURCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Spec source

`client.yaml` in this directory is **vendored** (copied verbatim) from a Lunogram
platform **release**. It is the single input to the code generator
(`make generate` → `src/lunogram/gen/models.py`). Do not hand-edit either file.

| | |
| --- | --- |
| Source repo | https://github.com/lunogram/platform |
| Pinned tag | `v0.1.0-rc.1` |
| Spec asset | `client.yaml` |

Every tagged platform release publishes the client OpenAPI spec as a `client.yaml`
asset (see the platform's `.github/workflows/release.yml` → `openapi-specs` job),
so the spec is fetched from a stable, versioned, immutable source — no platform
checkout or branch pin required.

## Release asset URL

```
https://github.com/lunogram/platform/releases/download/v0.1.0-rc.1/client.yaml
```

## Refreshing the spec

To track a newer release, update the **Pinned tag** above and re-fetch:

```bash
TAG=v0.1.0-rc.1
curl -fsSL "https://github.com/lunogram/platform/releases/download/$TAG/client.yaml" -o spec/client.yaml
make generate
```

Commit `spec/client.yaml` and `src/lunogram/gen/models.py` together.
Loading
Loading