interloper-api 0.19.0__tar.gz → 0.21.0__tar.gz
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.
- {interloper_api-0.19.0 → interloper_api-0.21.0}/PKG-INFO +1 -1
- {interloper_api-0.19.0 → interloper_api-0.21.0}/pyproject.toml +1 -1
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/app.py +12 -1
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/assets.py +15 -7
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/sources.py +22 -9
- {interloper_api-0.19.0 → interloper_api-0.21.0}/README.md +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/dependencies.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/destinations.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/__init__.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/criteo.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/google_ads.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/google_cloud.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/impact.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/tiktok_ads.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/jobs.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/resources.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -5,9 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from fastapi import APIRouter, FastAPI
|
|
8
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
9
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
10
11
|
from interloper.catalog.base import Catalog
|
|
12
|
+
from interloper.errors import ComponentDriftError
|
|
11
13
|
from interloper_db import Store
|
|
12
14
|
|
|
13
15
|
from interloper_api.dependencies import set_auth_config, set_catalog, set_smtp_config, set_store
|
|
@@ -55,6 +57,15 @@ def create_app(
|
|
|
55
57
|
"""
|
|
56
58
|
app = FastAPI(title="Interloper API", lifespan=ws.realtime_lifespan, **kwargs)
|
|
57
59
|
|
|
60
|
+
@app.exception_handler(ComponentDriftError)
|
|
61
|
+
async def _component_drift_handler(_request: Request, exc: ComponentDriftError) -> JSONResponse:
|
|
62
|
+
"""A stored component whose catalog key has drifted is a conflict, not a 500.
|
|
63
|
+
|
|
64
|
+
Hydrating or running a drifted source/asset can't succeed until the
|
|
65
|
+
user resolves the drift, so surface it as a clean 409 the UI can act on.
|
|
66
|
+
"""
|
|
67
|
+
return JSONResponse(status_code=409, content={"detail": str(exc)})
|
|
68
|
+
|
|
58
69
|
if cors_origins:
|
|
59
70
|
app.add_middleware(
|
|
60
71
|
CORSMiddleware,
|
|
@@ -7,7 +7,7 @@ from uuid import UUID
|
|
|
7
7
|
|
|
8
8
|
from fastapi import APIRouter, Depends, HTTPException
|
|
9
9
|
from interloper.errors import DataNotFoundError, NotFoundError
|
|
10
|
-
from interloper_db import Profile, Store
|
|
10
|
+
from interloper_db import ComponentStatus, Profile, Store
|
|
11
11
|
from interloper_db.models import Asset, AssetResource, Destination, DestinationResource
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
from sqlmodel import Session, select
|
|
@@ -64,6 +64,7 @@ class AssetResponse(BaseModel):
|
|
|
64
64
|
org_id: UUID
|
|
65
65
|
key: str
|
|
66
66
|
materializable: bool
|
|
67
|
+
status: ComponentStatus
|
|
67
68
|
config: dict[str, Any] | None = None
|
|
68
69
|
resources: dict[str, str] = {}
|
|
69
70
|
destinations: list[DestinationResponse] = []
|
|
@@ -147,14 +148,21 @@ def _load_authorized_asset(asset_id: UUID, user: Profile, store: Store, *, minim
|
|
|
147
148
|
return asset
|
|
148
149
|
|
|
149
150
|
|
|
150
|
-
def _asset_to_response(asset: Asset) -> AssetResponse:
|
|
151
|
-
"""Convert a DB Asset to an AssetResponse.
|
|
151
|
+
def _asset_to_response(asset: Asset, store: Store) -> AssetResponse:
|
|
152
|
+
"""Convert a DB Asset to an AssetResponse.
|
|
153
|
+
|
|
154
|
+
Carries the asset's catalog-resolution ``status`` (derived from the same
|
|
155
|
+
resolver hydration uses) so the UI can flag drifted assets. Source-owned
|
|
156
|
+
assets resolve through their parent, so ``asset.source`` must be loaded.
|
|
157
|
+
"""
|
|
158
|
+
source_key = asset.source.key if asset.source else None
|
|
152
159
|
return AssetResponse(
|
|
153
160
|
id=asset.id,
|
|
154
161
|
source_id=asset.source_id,
|
|
155
162
|
org_id=asset.org_id,
|
|
156
163
|
key=asset.key,
|
|
157
164
|
materializable=asset.materializable,
|
|
165
|
+
status=store.asset_status(asset.key, source_key=source_key),
|
|
158
166
|
config=asset.config,
|
|
159
167
|
resources=_resource_map(AssetResource, "asset_id", asset.id),
|
|
160
168
|
destinations=[_dest_to_response(d) for d in asset.destinations],
|
|
@@ -173,7 +181,7 @@ def list_assets(
|
|
|
173
181
|
) -> list[AssetResponse]:
|
|
174
182
|
"""List all assets for the current organisation."""
|
|
175
183
|
assets = store.list_assets(org_id)
|
|
176
|
-
return [_asset_to_response(a) for a in assets]
|
|
184
|
+
return [_asset_to_response(a, store) for a in assets]
|
|
177
185
|
|
|
178
186
|
|
|
179
187
|
@router.post("/", status_code=201)
|
|
@@ -191,7 +199,7 @@ def create_asset(
|
|
|
191
199
|
resources=body.resources,
|
|
192
200
|
destination_ids=body.destination_ids,
|
|
193
201
|
)
|
|
194
|
-
return _asset_to_response(asset)
|
|
202
|
+
return _asset_to_response(asset, store)
|
|
195
203
|
|
|
196
204
|
|
|
197
205
|
@router.get("/dependencies")
|
|
@@ -219,7 +227,7 @@ def get_asset(
|
|
|
219
227
|
) -> AssetResponse:
|
|
220
228
|
"""Get a single asset by ID. Authorized by membership in the asset's org."""
|
|
221
229
|
asset = _load_authorized_asset(asset_id, user, store)
|
|
222
|
-
return _asset_to_response(asset)
|
|
230
|
+
return _asset_to_response(asset, store)
|
|
223
231
|
|
|
224
232
|
|
|
225
233
|
@router.put("/{asset_id}")
|
|
@@ -241,7 +249,7 @@ def update_asset(
|
|
|
241
249
|
)
|
|
242
250
|
except NotFoundError:
|
|
243
251
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
|
244
|
-
return _asset_to_response(asset)
|
|
252
|
+
return _asset_to_response(asset, store)
|
|
245
253
|
|
|
246
254
|
|
|
247
255
|
@router.delete("/{asset_id}")
|
|
@@ -7,7 +7,7 @@ from uuid import UUID
|
|
|
7
7
|
|
|
8
8
|
from fastapi import APIRouter, Depends, HTTPException
|
|
9
9
|
from interloper.errors import NotFoundError
|
|
10
|
-
from interloper_db import Profile, Store
|
|
10
|
+
from interloper_db import ComponentStatus, Profile, Store
|
|
11
11
|
from interloper_db.models import Destination, DestinationResource, Source, SourceResource
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
from sqlalchemy.orm import selectinload
|
|
@@ -43,6 +43,7 @@ class AssetResponse(BaseModel):
|
|
|
43
43
|
id: UUID
|
|
44
44
|
key: str
|
|
45
45
|
materializable: bool
|
|
46
|
+
status: ComponentStatus
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
class DestinationResponse(BaseModel):
|
|
@@ -64,6 +65,7 @@ class SourceResponse(BaseModel):
|
|
|
64
65
|
key: str
|
|
65
66
|
name: str
|
|
66
67
|
config: dict[str, Any] | None = None
|
|
68
|
+
status: ComponentStatus
|
|
67
69
|
resources: dict[str, str] = {}
|
|
68
70
|
destinations: list[DestinationResponse] = []
|
|
69
71
|
assets: list[AssetResponse] = []
|
|
@@ -77,14 +79,20 @@ def _resource_map(session: Session, junction_cls: type, fk_column: str, fk_value
|
|
|
77
79
|
return {r.key: str(r.resource_id) for r in rows} # ty: ignore[unresolved-attribute]
|
|
78
80
|
|
|
79
81
|
|
|
80
|
-
def _build_source_response(session: Session, source: Source) -> SourceResponse:
|
|
81
|
-
"""Convert a DB Source to a SourceResponse within a session.
|
|
82
|
+
def _build_source_response(session: Session, source: Source, store: Store) -> SourceResponse:
|
|
83
|
+
"""Convert a DB Source to a SourceResponse within a session.
|
|
84
|
+
|
|
85
|
+
Each source and asset carries its catalog-resolution ``status`` so the
|
|
86
|
+
UI can flag drift. Status is derived from the same resolver hydration
|
|
87
|
+
uses — detection is just building the response, not a separate pass.
|
|
88
|
+
"""
|
|
82
89
|
return SourceResponse(
|
|
83
90
|
id=source.id,
|
|
84
91
|
org_id=source.org_id,
|
|
85
92
|
key=source.key,
|
|
86
93
|
name=source.name,
|
|
87
94
|
config=source.config,
|
|
95
|
+
status=store.source_status(source.key),
|
|
88
96
|
resources=_resource_map(session, SourceResource, "source_id", source.id),
|
|
89
97
|
destinations=[
|
|
90
98
|
DestinationResponse(
|
|
@@ -98,14 +106,19 @@ def _build_source_response(session: Session, source: Source) -> SourceResponse:
|
|
|
98
106
|
for d in source.destinations
|
|
99
107
|
],
|
|
100
108
|
assets=[
|
|
101
|
-
AssetResponse(
|
|
109
|
+
AssetResponse(
|
|
110
|
+
id=a.id,
|
|
111
|
+
key=a.key,
|
|
112
|
+
materializable=a.materializable,
|
|
113
|
+
status=store.asset_status(a.key, source_key=source.key),
|
|
114
|
+
)
|
|
102
115
|
for a in source.assets
|
|
103
116
|
],
|
|
104
117
|
created_at=str(source.created_at) if source.created_at else None,
|
|
105
118
|
)
|
|
106
119
|
|
|
107
120
|
|
|
108
|
-
def _load_source_for_response(source_id: UUID) -> SourceResponse:
|
|
121
|
+
def _load_source_for_response(source_id: UUID, store: Store) -> SourceResponse:
|
|
109
122
|
"""Load a source with relations and build the response."""
|
|
110
123
|
from interloper_db.engine import get_engine
|
|
111
124
|
|
|
@@ -121,7 +134,7 @@ def _load_source_for_response(source_id: UUID) -> SourceResponse:
|
|
|
121
134
|
)
|
|
122
135
|
if not source:
|
|
123
136
|
raise NotFoundError(f"Source {source_id} not found")
|
|
124
|
-
return _build_source_response(session, source)
|
|
137
|
+
return _build_source_response(session, source, store)
|
|
125
138
|
|
|
126
139
|
|
|
127
140
|
@router.get("/")
|
|
@@ -135,7 +148,7 @@ def list_sources(
|
|
|
135
148
|
|
|
136
149
|
sources = store.list_sources(org_id)
|
|
137
150
|
with Session(get_engine()) as session:
|
|
138
|
-
return [_build_source_response(session, s) for s in sources]
|
|
151
|
+
return [_build_source_response(session, s, store) for s in sources]
|
|
139
152
|
|
|
140
153
|
|
|
141
154
|
@router.post("/")
|
|
@@ -156,7 +169,7 @@ def create_source(
|
|
|
156
169
|
destination_ids=body.destination_ids,
|
|
157
170
|
cross_deps=body.cross_deps,
|
|
158
171
|
)
|
|
159
|
-
return _load_source_for_response(source.id)
|
|
172
|
+
return _load_source_for_response(source.id, store)
|
|
160
173
|
|
|
161
174
|
|
|
162
175
|
def _authorize_source(source_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> None:
|
|
@@ -194,7 +207,7 @@ def update_source(
|
|
|
194
207
|
)
|
|
195
208
|
except NotFoundError:
|
|
196
209
|
raise HTTPException(status_code=404, detail=f"Source {source_id} not found")
|
|
197
|
-
return _load_source_for_response(source_id)
|
|
210
|
+
return _load_source_for_response(source_id, store)
|
|
198
211
|
|
|
199
212
|
|
|
200
213
|
@router.delete("/{source_id}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/__init__.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/amazon_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/criteo.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/facebook_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/google_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/google_cloud.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/impact.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/pinterest_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/snapchat_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/tiktok_ads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|