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.
- interloper_api/__init__.py +3 -0
- interloper_api/app.py +100 -0
- interloper_api/dependencies.py +293 -0
- interloper_api/email.py +79 -0
- interloper_api/routes/__init__.py +0 -0
- interloper_api/routes/agent.py +229 -0
- interloper_api/routes/assets.py +304 -0
- interloper_api/routes/auth.py +241 -0
- interloper_api/routes/backfills.py +87 -0
- interloper_api/routes/catalog.py +46 -0
- interloper_api/routes/destinations.py +118 -0
- interloper_api/routes/external/__init__.py +48 -0
- interloper_api/routes/external/amazon_ads.py +82 -0
- interloper_api/routes/external/facebook_ads.py +55 -0
- interloper_api/routes/external/google_ads.py +104 -0
- interloper_api/routes/external/pinterest_ads.py +77 -0
- interloper_api/routes/external/snapchat_ads.py +86 -0
- interloper_api/routes/jobs.py +216 -0
- interloper_api/routes/oauth.py +345 -0
- interloper_api/routes/organisations.py +278 -0
- interloper_api/routes/resources.py +180 -0
- interloper_api/routes/runs.py +177 -0
- interloper_api/routes/sources.py +187 -0
- interloper_api/routes/ws.py +164 -0
- interloper_api-0.2.0.dist-info/METADATA +18 -0
- interloper_api-0.2.0.dist-info/RECORD +27 -0
- interloper_api-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|