interloper-api 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ """Sources API: CRUD for source instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from interloper.errors import NotFoundError
10
+ from interloper_db import Profile, Store
11
+ from interloper_db.models import Destination, DestinationResource, Source, SourceResource
12
+ from pydantic import BaseModel
13
+ from sqlalchemy.orm import selectinload
14
+ from sqlmodel import Session, select
15
+
16
+ from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ class SourceCreateRequest(BaseModel):
22
+ """Request body for creating or updating a source."""
23
+
24
+ key: str
25
+ name: str
26
+ config: dict[str, Any] | None = None
27
+ resources: dict[str, str] | None = None
28
+ asset_keys: list[str] | None = None
29
+ destination_ids: list[str] | None = None
30
+ cross_deps: dict[str, dict[str, str]] | None = None
31
+
32
+
33
+ class AssetResponse(BaseModel):
34
+ """Nested asset in source response."""
35
+
36
+ id: UUID
37
+ key: str
38
+ materializable: bool
39
+
40
+
41
+ class DestinationResponse(BaseModel):
42
+ """Nested destination in source response."""
43
+
44
+ id: UUID
45
+ key: str
46
+ name: str | None = None
47
+ config: dict[str, Any] | None = None
48
+ resources: dict[str, str] = {}
49
+ created_at: str | None = None
50
+
51
+
52
+ class SourceResponse(BaseModel):
53
+ """Response body for a source."""
54
+
55
+ id: UUID
56
+ org_id: UUID
57
+ key: str
58
+ name: str
59
+ config: dict[str, Any] | None = None
60
+ resources: dict[str, str] = {}
61
+ destinations: list[DestinationResponse] = []
62
+ assets: list[AssetResponse] = []
63
+ created_at: str | None = None
64
+
65
+
66
+ def _resource_map(session: Session, junction_cls: type, fk_column: str, fk_value: UUID) -> dict[str, str]:
67
+ """Build a {slot_key: resource_id} map from junction rows."""
68
+ col = getattr(junction_cls, fk_column)
69
+ rows = session.exec(select(junction_cls).where(col == fk_value)).all()
70
+ return {r.key: str(r.resource_id) for r in rows}
71
+
72
+
73
+ def _build_source_response(session: Session, source: Source) -> SourceResponse:
74
+ """Convert a DB Source to a SourceResponse within a session."""
75
+ return SourceResponse(
76
+ id=source.id, # type: ignore[arg-type]
77
+ org_id=source.org_id,
78
+ key=source.key,
79
+ name=source.name,
80
+ config=source.config,
81
+ resources=_resource_map(session, SourceResource, "source_id", source.id), # type: ignore[arg-type]
82
+ destinations=[
83
+ DestinationResponse(
84
+ id=d.id, # type: ignore[arg-type]
85
+ key=d.key,
86
+ name=d.name,
87
+ config=d.config,
88
+ resources=_resource_map(session, DestinationResource, "destination_id", d.id), # type: ignore[arg-type]
89
+ created_at=str(d.created_at) if d.created_at else None,
90
+ )
91
+ for d in source.destinations
92
+ ],
93
+ assets=[
94
+ AssetResponse(id=a.id, key=a.key, materializable=a.materializable) # type: ignore[arg-type]
95
+ for a in source.assets
96
+ ],
97
+ created_at=str(source.created_at) if source.created_at else None,
98
+ )
99
+
100
+
101
+ def _load_source_for_response(source_id: UUID) -> SourceResponse:
102
+ """Load a source with relations and build the response."""
103
+ from interloper_db.engine import get_engine
104
+
105
+ with Session(get_engine()) as session:
106
+ source = session.get(
107
+ Source,
108
+ source_id,
109
+ options=[
110
+ selectinload(Source.assets), # type: ignore[arg-type]
111
+ selectinload(Source.resources), # type: ignore[arg-type]
112
+ selectinload(Source.destinations).selectinload(Destination.resources), # type: ignore[arg-type]
113
+ ],
114
+ )
115
+ if not source:
116
+ raise NotFoundError(f"Source {source_id} not found")
117
+ return _build_source_response(session, source)
118
+
119
+
120
+ @router.get("/")
121
+ def list_sources(
122
+ user: Profile = Depends(require_viewer),
123
+ org_id: UUID = Depends(get_org_id),
124
+ store: Store = Depends(get_store),
125
+ ) -> list[SourceResponse]:
126
+ """List all sources for the current organisation."""
127
+ from interloper_db.engine import get_engine
128
+
129
+ sources = store.list_sources(org_id)
130
+ with Session(get_engine()) as session:
131
+ return [_build_source_response(session, s) for s in sources]
132
+
133
+
134
+ @router.post("/")
135
+ def create_source(
136
+ body: SourceCreateRequest,
137
+ user: Profile = Depends(require_editor),
138
+ org_id: UUID = Depends(get_org_id),
139
+ store: Store = Depends(get_store),
140
+ ) -> SourceResponse:
141
+ """Create a new source."""
142
+ source = store.create_source(
143
+ org_id,
144
+ key=body.key,
145
+ name=body.name,
146
+ config=body.config,
147
+ resources=body.resources,
148
+ asset_keys=body.asset_keys,
149
+ destination_ids=body.destination_ids,
150
+ cross_deps=body.cross_deps,
151
+ )
152
+ return _load_source_for_response(source.id) # type: ignore[arg-type]
153
+
154
+
155
+ @router.put("/{source_id}")
156
+ def update_source(
157
+ source_id: UUID,
158
+ body: SourceCreateRequest,
159
+ user: Profile = Depends(require_editor),
160
+ org_id: UUID = Depends(get_org_id),
161
+ store: Store = Depends(get_store),
162
+ ) -> SourceResponse:
163
+ """Update a source."""
164
+ try:
165
+ store.update_source(
166
+ source_id,
167
+ name=body.name,
168
+ config=body.config,
169
+ resources=body.resources,
170
+ asset_keys=body.asset_keys,
171
+ destination_ids=body.destination_ids,
172
+ cross_deps=body.cross_deps,
173
+ )
174
+ except NotFoundError:
175
+ raise HTTPException(status_code=404, detail=f"Source {source_id} not found")
176
+ return _load_source_for_response(source_id)
177
+
178
+
179
+ @router.delete("/{source_id}")
180
+ def delete_source(
181
+ source_id: UUID,
182
+ user: Profile = Depends(require_editor),
183
+ store: Store = Depends(get_store),
184
+ ) -> dict[str, str]:
185
+ """Delete a source. Assets cascade via FK."""
186
+ store.delete_source(source_id)
187
+ return {"status": "deleted"}
@@ -0,0 +1,164 @@
1
+ """WebSocket realtime endpoint — bridges PostgreSQL NOTIFY to connected clients.
2
+
3
+ Architecture:
4
+ - A background thread LISTENs on the ``table_changes`` PostgreSQL channel.
5
+ - When a notification arrives, the payload (table, op, org_id, record) is
6
+ broadcast to all WebSocket clients belonging to that org.
7
+ - Clients subscribe by connecting to ``/api/ws`` with a valid session cookie.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import select as _select
16
+ import threading
17
+ from collections.abc import AsyncGenerator
18
+ from contextlib import asynccontextmanager
19
+
20
+ import psycopg2
21
+ import psycopg2.extensions
22
+ from fastapi import APIRouter, Cookie, FastAPI, WebSocket, WebSocketDisconnect
23
+
24
+ from interloper_api.dependencies import get_store
25
+
26
+ logger = logging.getLogger(__name__)
27
+ router = APIRouter()
28
+
29
+
30
+ class ConnectionManager:
31
+ """Manages WebSocket connections grouped by organisation ID."""
32
+
33
+ def __init__(self) -> None:
34
+ self._connections: dict[str, set[WebSocket]] = {}
35
+ self._lock = asyncio.Lock()
36
+
37
+ async def connect(self, ws: WebSocket, org_id: str) -> None:
38
+ """Register a WebSocket for an org."""
39
+ async with self._lock:
40
+ if org_id not in self._connections:
41
+ self._connections[org_id] = set()
42
+ self._connections[org_id].add(ws)
43
+
44
+ async def disconnect(self, ws: WebSocket, org_id: str) -> None:
45
+ """Unregister a WebSocket."""
46
+ async with self._lock:
47
+ if org_id in self._connections:
48
+ self._connections[org_id].discard(ws)
49
+ if not self._connections[org_id]:
50
+ del self._connections[org_id]
51
+
52
+ async def broadcast(self, org_id: str, message: dict[str, object]) -> None:
53
+ """Send a message to all connections for an org."""
54
+ async with self._lock:
55
+ connections = list(self._connections.get(org_id, []))
56
+ for ws in connections:
57
+ try:
58
+ await ws.send_json(message)
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ _manager = ConnectionManager()
64
+
65
+
66
+ def _start_notify_listener(dsn: str, loop: asyncio.AbstractEventLoop) -> None:
67
+ """Background thread: listens for PostgreSQL NOTIFY and dispatches to WebSocket clients.
68
+
69
+ Args:
70
+ dsn: PostgreSQL connection string.
71
+ loop: The asyncio event loop to schedule coroutines on.
72
+ """
73
+ while True:
74
+ try:
75
+ conn = psycopg2.connect(dsn)
76
+ conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
77
+
78
+ with conn.cursor() as cur:
79
+ cur.execute("LISTEN table_changes")
80
+
81
+ logger.info("[Realtime] NOTIFY listener started")
82
+
83
+ while True:
84
+ if _select.select([conn], [], [], 1.0):
85
+ conn.poll()
86
+ while conn.notifies:
87
+ notify = conn.notifies.pop(0)
88
+ try:
89
+ payload = json.loads(notify.payload)
90
+ org_id = str(payload["org_id"])
91
+ message = {
92
+ "table": payload["table"],
93
+ "event": payload["op"],
94
+ "record": payload.get("record"),
95
+ }
96
+ asyncio.run_coroutine_threadsafe(
97
+ _manager.broadcast(org_id, message), loop
98
+ )
99
+ except (json.JSONDecodeError, KeyError) as e:
100
+ logger.warning("[Realtime] Invalid NOTIFY payload: %s", e)
101
+ except Exception as e:
102
+ logger.error("[Realtime] NOTIFY listener error: %s, reconnecting in 5s...", e)
103
+ import time
104
+ time.sleep(5)
105
+
106
+
107
+ @asynccontextmanager
108
+ async def realtime_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
109
+ """Start the NOTIFY listener thread on app startup.
110
+
111
+ Reads the DSN from the engine already initialized by ``init_engine()``.
112
+ """
113
+ from interloper_db import get_engine
114
+
115
+ engine = get_engine()
116
+ # str(engine.url) masks the password as '***' — psycopg2 needs the real one.
117
+ dsn = engine.url.render_as_string(hide_password=False)
118
+
119
+ loop = asyncio.get_running_loop()
120
+ thread = threading.Thread(
121
+ target=_start_notify_listener,
122
+ args=(dsn, loop),
123
+ daemon=True,
124
+ name="NotifyListener",
125
+ )
126
+ thread.start()
127
+ yield
128
+
129
+
130
+ @router.websocket("/ws")
131
+ async def websocket_endpoint(
132
+ ws: WebSocket,
133
+ session_token: str | None = Cookie(default=None),
134
+ ) -> None:
135
+ """Authenticate via session cookie, then stream table change events."""
136
+ store = get_store()
137
+
138
+ if not session_token:
139
+ await ws.close(code=4001, reason="Unauthorized")
140
+ return
141
+
142
+ result = store.resolve_session(session_token)
143
+ if not result:
144
+ await ws.close(code=4001, reason="Unauthorized")
145
+ return
146
+
147
+ _, session_row = result
148
+ if not session_row.organisation_id:
149
+ await ws.close(code=4002, reason="No organisation selected")
150
+ return
151
+
152
+ org_id = str(session_row.organisation_id)
153
+ logger.info("[WS] Authenticated. org=%s — accepting connection", org_id)
154
+
155
+ await ws.accept()
156
+ await _manager.connect(ws, org_id)
157
+
158
+ try:
159
+ while True:
160
+ await ws.receive_text()
161
+ except WebSocketDisconnect:
162
+ pass
163
+ finally:
164
+ await _manager.disconnect(ws, org_id)
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: interloper-api
3
+ Version: 0.2.0
4
+ Summary: Interloper FastAPI routes
5
+ Author: Guillaume Onfroy
6
+ Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: interloper-core
8
+ Requires-Dist: interloper-db
9
+ Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: httpx>=0.28.0
11
+ Requires-Dist: psycopg2-binary>=2.9.0
12
+ Requires-Dist: uvicorn[standard]>=0.41.0
13
+ Requires-Dist: wsproto>=1.2.0
14
+ Requires-Dist: interloper-agent ; extra == 'agent'
15
+ Requires-Python: >=3.10
16
+ Provides-Extra: agent
17
+ Description-Content-Type: text/markdown
18
+
@@ -0,0 +1,27 @@
1
+ interloper_api/__init__.py,sha256=rAB0arSfA2ngJ8YcD3CbOpE5p9_mM5dFsRZzg-J78cE,68
2
+ interloper_api/app.py,sha256=bG406EnP9RPjkM9zFzDHzl0Pt5inocOzn-6SLzkfsOg,3272
3
+ interloper_api/dependencies.py,sha256=-8GsFhQydL4m983sUzsHevPS9VJsdEZFVQwhoJB5o5Y,7536
4
+ interloper_api/email.py,sha256=VTw_j476yOHamcdFMZtrE7vfri6NtugLkqUDDppCb9A,2697
5
+ interloper_api/routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ interloper_api/routes/agent.py,sha256=kL-kimx7UWqR8kcWw4VeZ4pL9hvxeExvfotBdQ-56Mc,7351
7
+ interloper_api/routes/assets.py,sha256=4PjaXieLam59itFlK0oSvj-pXjx33bBJ_yWo1xLgnxM,9184
8
+ interloper_api/routes/auth.py,sha256=OsYaDSGYUkgjRd-BDaLRkz__RzQk2wIQSW0rEdyUjNU,7943
9
+ interloper_api/routes/backfills.py,sha256=MMG08H1Xtw5gKdiF3MrpgNcGavrbXd72ANMA2gor5zk,2555
10
+ interloper_api/routes/catalog.py,sha256=WsQByqYV6bNgWvsukNvGnz30brjYLvwRZnzRhjhgJr0,1297
11
+ interloper_api/routes/destinations.py,sha256=MPAympSigDa5jfCSQm-qnk2PzxveFkTUd_4LOjVbxl0,3436
12
+ interloper_api/routes/external/__init__.py,sha256=uLSboVkjjLienHNKUIyDYv7xqcHX1McIcsRqGOSv-zA,1830
13
+ interloper_api/routes/external/amazon_ads.py,sha256=2A0TRaGadVGPPfHj1S5IwzMzIb5F1KEb3GX-XDXXYqo,2700
14
+ interloper_api/routes/external/facebook_ads.py,sha256=SrCEcwJwZWP7Z-jKmLYP2gzvZ-NCC96ZN_hu4BLNM7k,1665
15
+ interloper_api/routes/external/google_ads.py,sha256=HXTzzwl6lgxYsKgUJHEEM2ahuTNC_EcIOuvnFcKawWk,3810
16
+ interloper_api/routes/external/pinterest_ads.py,sha256=NRasE18Q6CvUCJBvIr3XRDDrNy0-JmuztFPOBdffcBc,2453
17
+ interloper_api/routes/external/snapchat_ads.py,sha256=wEWd0E9UmUPvC5-N0EDh9DjHuuJSmSqUiB7fwn96GiM,2959
18
+ interloper_api/routes/jobs.py,sha256=Q2Wmffp4uODHCZRoUL4KZc_TIMMuIctKbJC9R0kqrO4,5887
19
+ interloper_api/routes/oauth.py,sha256=0oG-1FXy-MX_IHDBsGfDlNmo1wI1a_N9hS334grrEO4,11439
20
+ interloper_api/routes/organisations.py,sha256=YIZbWnqDgRyQ7PTpwIlFx6jq0Q8nO2YNEEtgsQZXN70,8546
21
+ interloper_api/routes/resources.py,sha256=VgsA8gcfomfvJGOgrHVxGl95nrsqpfwLVoHZfPDEq7Y,5352
22
+ interloper_api/routes/runs.py,sha256=-FljEfdasRDCmcSEC55lJnHkBc9rr4xmseV4i19wPL8,4811
23
+ interloper_api/routes/sources.py,sha256=NbZohsruzt-fpfzIQyrZup_2HQHtcUr3igZ0iw7_K10,5957
24
+ interloper_api/routes/ws.py,sha256=h4u1CADw2OpxjtOpUCmjiJVqbfiCR9k1wzXTYbNFlyU,5468
25
+ interloper_api-0.2.0.dist-info/WHEEL,sha256=f5fWSvWsg5Knq5GWa6t1nJIug0Tqo69GqAWD_9LbBKw,81
26
+ interloper_api-0.2.0.dist-info/METADATA,sha256=DEGBsv3FUXUM529-CpdctRcz0yxFotsAOiN6HW8L9hA,545
27
+ interloper_api-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.16
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any