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.
Files changed (32) hide show
  1. {interloper_api-0.19.0 → interloper_api-0.21.0}/PKG-INFO +1 -1
  2. {interloper_api-0.19.0 → interloper_api-0.21.0}/pyproject.toml +1 -1
  3. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/app.py +12 -1
  4. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/assets.py +15 -7
  5. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/sources.py +22 -9
  6. {interloper_api-0.19.0 → interloper_api-0.21.0}/README.md +0 -0
  7. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/__init__.py +0 -0
  8. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/dependencies.py +0 -0
  9. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/email.py +0 -0
  10. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/__init__.py +0 -0
  11. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/admin.py +0 -0
  12. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/agent.py +0 -0
  13. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/auth.py +0 -0
  14. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/backfills.py +0 -0
  15. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/destinations.py +0 -0
  17. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/__init__.py +0 -0
  18. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
  19. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/criteo.py +0 -0
  20. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
  21. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/google_ads.py +0 -0
  22. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/google_cloud.py +0 -0
  23. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/impact.py +0 -0
  24. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
  25. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
  26. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/external/tiktok_ads.py +0 -0
  27. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/jobs.py +0 -0
  28. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/oauth.py +0 -0
  29. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/organisations.py +0 -0
  30. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/resources.py +0 -0
  31. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/runs.py +0 -0
  32. {interloper_api-0.19.0 → interloper_api-0.21.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.19.0
3
+ Version: 0.21.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.19.0"
6
+ version = "0.21.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -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(id=a.id, key=a.key, materializable=a.materializable)
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}")