interloper-api 0.65.1__tar.gz → 0.67.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 (31) hide show
  1. {interloper_api-0.65.1 → interloper_api-0.67.0}/PKG-INFO +1 -1
  2. {interloper_api-0.65.1 → interloper_api-0.67.0}/pyproject.toml +25 -1
  3. {interloper_api-0.65.1 → interloper_api-0.67.0}/pyproject.toml.orig +22 -3
  4. interloper_api-0.67.0/src/interloper_api/__init__.py +5 -0
  5. interloper_api-0.67.0/src/interloper_api/app.py +241 -0
  6. interloper_api-0.67.0/src/interloper_api/dependencies/__init__.py +64 -0
  7. interloper_api-0.67.0/src/interloper_api/dependencies/auth.py +108 -0
  8. interloper_api-0.67.0/src/interloper_api/dependencies/rbac.py +193 -0
  9. interloper_api-0.67.0/src/interloper_api/dependencies/state.py +171 -0
  10. interloper_api-0.67.0/src/interloper_api/notifications/__init__.py +5 -0
  11. interloper_api-0.65.1/src/interloper_api/email.py → interloper_api-0.67.0/src/interloper_api/notifications/invitations.py +86 -75
  12. interloper_api-0.67.0/src/interloper_api/routes/__init__.py +1 -0
  13. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/admin.py +447 -189
  14. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/agent.py +79 -18
  15. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/auth.py +142 -25
  16. interloper_api-0.67.0/src/interloper_api/routes/backfills.py +207 -0
  17. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/catalog.py +20 -1
  18. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/components.py +328 -129
  19. interloper_api-0.67.0/src/interloper_api/routes/health.py +17 -0
  20. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/oauth.py +40 -23
  21. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/organisations.py +131 -38
  22. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/runs.py +146 -62
  23. {interloper_api-0.65.1 → interloper_api-0.67.0}/src/interloper_api/routes/tokens.py +44 -9
  24. interloper_api-0.65.1/src/interloper_api/routes/ws.py → interloper_api-0.67.0/src/interloper_api/routes/websocket.py +72 -30
  25. interloper_api-0.65.1/src/interloper_api/__init__.py +0 -3
  26. interloper_api-0.65.1/src/interloper_api/app.py +0 -145
  27. interloper_api-0.65.1/src/interloper_api/components.py +0 -64
  28. interloper_api-0.65.1/src/interloper_api/dependencies.py +0 -449
  29. interloper_api-0.65.1/src/interloper_api/routes/__init__.py +0 -0
  30. interloper_api-0.65.1/src/interloper_api/routes/backfills.py +0 -149
  31. {interloper_api-0.65.1 → interloper_api-0.67.0}/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.65.1
3
+ Version: 0.67.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-api"
3
- version = "0.65.1"
3
+ version = "0.67.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -43,6 +43,7 @@ workspace = true
43
43
  line-length = 120
44
44
 
45
45
  [tool.ruff.lint]
46
+ preview = true
46
47
  extend-select = [
47
48
  "E",
48
49
  "I",
@@ -50,6 +51,24 @@ extend-select = [
50
51
  "ANN001",
51
52
  "ANN201",
52
53
  "ANN202",
54
+ "DOC",
55
+ "D",
56
+ ]
57
+
58
+ [tool.ruff.lint.pydocstyle]
59
+ convention = "google"
60
+
61
+ [tool.ruff.lint.flake8-bugbear]
62
+ extend-immutable-calls = [
63
+ "fastapi.Body",
64
+ "fastapi.Cookie",
65
+ "fastapi.Depends",
66
+ "fastapi.File",
67
+ "fastapi.Form",
68
+ "fastapi.Header",
69
+ "fastapi.Path",
70
+ "fastapi.Query",
71
+ "fastapi.Security",
53
72
  ]
54
73
 
55
74
  [tool.ruff.lint.per-file-ignores]
@@ -60,4 +79,9 @@ extend-select = [
60
79
  "tests/**" = [
61
80
  "ANN",
62
81
  "F811",
82
+ "D101",
83
+ "D102",
84
+ "D103",
85
+ "D104",
86
+ "D107",
63
87
  ]
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.65.1"
6
+ version = "0.67.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -42,8 +42,27 @@ interloper-agent = { workspace = true }
42
42
  line-length = 120
43
43
 
44
44
  [tool.ruff.lint]
45
- extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202"]
45
+ preview = true
46
+ extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D"]
47
+
48
+ [tool.ruff.lint.pydocstyle]
49
+ convention = "google"
50
+
51
+ # FastAPI's whole dependency-injection surface is calls in argument defaults;
52
+ # B008 exists for mutable defaults, which these are not.
53
+ [tool.ruff.lint.flake8-bugbear]
54
+ extend-immutable-calls = [
55
+ "fastapi.Body",
56
+ "fastapi.Cookie",
57
+ "fastapi.Depends",
58
+ "fastapi.File",
59
+ "fastapi.Form",
60
+ "fastapi.Header",
61
+ "fastapi.Path",
62
+ "fastapi.Query",
63
+ "fastapi.Security",
64
+ ]
46
65
 
47
66
  [tool.ruff.lint.per-file-ignores]
48
67
  "__init__.py" = ["F401", "F403"]
49
- "tests/**" = ["ANN", "F811"]
68
+ "tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104", "D107"]
@@ -0,0 +1,5 @@
1
+ """HTTP API for interloper: catalog, collection, run, and auth endpoints."""
2
+
3
+ from interloper_api.app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -0,0 +1,241 @@
1
+ """FastAPI application factory for the interloper API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from types import ModuleType
7
+ from typing import Any
8
+
9
+ from fastapi import APIRouter, FastAPI, Request
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import JSONResponse
12
+ from interloper.catalog.base import Catalog
13
+ from interloper.errors import ComponentDriftError, NotFoundError, QuotaExceededError
14
+ from interloper_db import Store
15
+
16
+ from interloper_api.dependencies import (
17
+ set_admin_config,
18
+ set_auth_config,
19
+ set_catalog,
20
+ set_features,
21
+ set_quota_defaults,
22
+ set_smtp_config,
23
+ set_store,
24
+ )
25
+ from interloper_api.routes import (
26
+ admin,
27
+ auth,
28
+ backfills,
29
+ components,
30
+ health,
31
+ oauth,
32
+ organisations,
33
+ runs,
34
+ tokens,
35
+ websocket,
36
+ )
37
+ from interloper_api.routes import catalog as catalog_routes
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ #: Routers mounted under ``/api`` for every deployment. The agent router is
42
+ #: absent by design: it is optional and mounted by :func:`_mount_agent`.
43
+ _ROUTE_MODULES: tuple[ModuleType, ...] = (
44
+ auth,
45
+ organisations,
46
+ admin,
47
+ catalog_routes,
48
+ components,
49
+ runs,
50
+ backfills,
51
+ oauth,
52
+ tokens,
53
+ websocket,
54
+ health,
55
+ )
56
+
57
+
58
+ # -- Error handling ------------------------------------------------------------
59
+
60
+
61
+ async def _not_found(_request: Request, exception: NotFoundError) -> JSONResponse:
62
+ """Render a missing store target as a plain 404.
63
+
64
+ Args:
65
+ _request: The incoming request, unused.
66
+ exception: The raised :class:`NotFoundError`.
67
+
68
+ Returns:
69
+ A 404 response carrying the exception message as ``detail``.
70
+ """
71
+ return JSONResponse(status_code=404, content={"detail": str(exception)})
72
+
73
+
74
+ async def _component_drift(_request: Request, exception: ComponentDriftError) -> JSONResponse:
75
+ """Render catalog drift as a conflict rather than a 500.
76
+
77
+ Hydrating or running a drifted source/asset cannot succeed until the user
78
+ resolves the drift, so it surfaces as a clean 409 the UI can act on.
79
+
80
+ Args:
81
+ _request: The incoming request, unused.
82
+ exception: The raised :class:`ComponentDriftError`.
83
+
84
+ Returns:
85
+ A 409 response carrying the exception message as ``detail``.
86
+ """
87
+ return JSONResponse(status_code=409, content={"detail": str(exception)})
88
+
89
+
90
+ async def _quota_exceeded(_request: Request, exception: QuotaExceededError) -> JSONResponse:
91
+ """Render store-level quota enforcement as a 429 with structured context.
92
+
93
+ Args:
94
+ _request: The incoming request, unused.
95
+ exception: The raised :class:`QuotaExceededError`.
96
+
97
+ Returns:
98
+ A 429 response whose ``detail`` carries the message plus the quota
99
+ name, its limit, and the amount already used.
100
+ """
101
+ return JSONResponse(
102
+ status_code=429,
103
+ content={
104
+ "detail": {
105
+ "message": str(exception),
106
+ "quota": exception.quota,
107
+ "limit": exception.limit,
108
+ "used": exception.used,
109
+ }
110
+ },
111
+ )
112
+
113
+
114
+ #: Framework errors that have an HTTP meaning, and the response each becomes.
115
+ #: Anything absent here is a bug and stays a 500.
116
+ _ERROR_HANDLERS: dict[type[Exception], Any] = {
117
+ NotFoundError: _not_found,
118
+ ComponentDriftError: _component_drift,
119
+ QuotaExceededError: _quota_exceeded,
120
+ }
121
+
122
+
123
+ # -- Application factory -------------------------------------------------------
124
+
125
+
126
+ def create_app(
127
+ store: Store | None = None,
128
+ catalog: Catalog | None = None,
129
+ settings: Any | None = None,
130
+ cors_origins: list[str] | None = None,
131
+ **kwargs: Any,
132
+ ) -> FastAPI:
133
+ """Create the FastAPI application with all routes.
134
+
135
+ Args:
136
+ store: The ``Store`` instance for persistence.
137
+ catalog: Catalog instance.
138
+ settings: Full ``AppSettings``; the factory slices what it needs
139
+ (auth, smtp, agent) and builds the secrets-redacted snapshot for
140
+ the super-admin ``/admin/config`` view. The agent routes mount
141
+ only when enabled (or with no settings) and the ``agent`` extra
142
+ is installed.
143
+ cors_origins: Allowed CORS origins. Only needed in dev mode for direct
144
+ WebSocket connections that bypass the Vite proxy.
145
+ **kwargs: Additional kwargs forwarded to ``FastAPI()``.
146
+
147
+ Returns:
148
+ The configured FastAPI application.
149
+ """
150
+ app = FastAPI(title="Interloper API", lifespan=websocket.realtime_lifespan, **kwargs)
151
+
152
+ for error_type, handler in _ERROR_HANDLERS.items():
153
+ app.add_exception_handler(error_type, handler)
154
+
155
+ if cors_origins:
156
+ app.add_middleware(
157
+ CORSMiddleware,
158
+ allow_origins=cors_origins,
159
+ allow_credentials=True,
160
+ allow_methods=["*"],
161
+ allow_headers=["*"],
162
+ )
163
+
164
+ api = APIRouter(prefix="/api")
165
+ for module in _ROUTE_MODULES:
166
+ api.include_router(module.router)
167
+ agent_available = _mount_agent(api, settings)
168
+ app.include_router(api)
169
+
170
+ # After mounting, so the feature flag and the config snapshot can record
171
+ # whether the agent actually mounted. Routes read state per request, never
172
+ # at mount time, so the order is safe.
173
+ _install_state(store, catalog, settings, agent_available=agent_available)
174
+
175
+ oauth.log_provider_status()
176
+
177
+ return app
178
+
179
+
180
+ # -- Internals -----------------------------------------------------------------
181
+
182
+
183
+ def _mount_agent(api: APIRouter, settings: Any | None) -> bool:
184
+ """Mount the optional agent routes, reporting whether they are available.
185
+
186
+ Args:
187
+ api: The ``/api`` router to mount onto.
188
+ settings: Full ``AppSettings``, or ``None`` to mount whenever the
189
+ extra is installed.
190
+
191
+ Returns:
192
+ True when the agent routes are mounted.
193
+ """
194
+ if settings is not None and not settings.agent.enabled:
195
+ logger.info("Agent routes not mounted: disabled via settings.")
196
+ return False
197
+
198
+ try:
199
+ from interloper_api.routes import agent as agent_routes
200
+ except ImportError:
201
+ logger.warning(
202
+ "Agent routes not mounted: the 'agent' extra is not installed "
203
+ "(install interloper-api[agent]); /agent endpoints will return 404."
204
+ )
205
+ return False
206
+
207
+ api.include_router(agent_routes.router)
208
+ return True
209
+
210
+
211
+ def _install_state(
212
+ store: Store | None,
213
+ catalog: Catalog | None,
214
+ settings: Any | None,
215
+ *,
216
+ agent_available: bool,
217
+ ) -> None:
218
+ """Install the process-wide state the request dependencies read back.
219
+
220
+ Args:
221
+ store: The ``Store`` instance, or ``None`` to leave it unset.
222
+ catalog: Catalog instance, or ``None`` to leave it unset.
223
+ settings: Full ``AppSettings``, or ``None`` to install neither the
224
+ settings slices nor the admin config snapshot.
225
+ agent_available: Whether the agent routes mounted, recorded as a
226
+ feature flag and in the admin config snapshot.
227
+ """
228
+ if store:
229
+ set_store(store)
230
+ if catalog:
231
+ set_catalog(catalog)
232
+
233
+ set_features({"agent": agent_available})
234
+
235
+ if settings:
236
+ set_auth_config(settings.auth)
237
+ set_smtp_config(settings.smtp)
238
+ set_quota_defaults(settings.quota)
239
+ set_admin_config(
240
+ admin.AdminConfigResponse.from_settings(settings, features={"agent": agent_available}, catalog=catalog)
241
+ )
@@ -0,0 +1,64 @@
1
+ """Shared FastAPI dependencies: application state, identity, and role gates.
2
+
3
+ Three concerns, one import surface. ``state`` holds what ``create_app``
4
+ installs once at startup; ``auth`` resolves the caller and their active
5
+ organisation per request; ``rbac`` gates handlers on the caller's role.
6
+ """
7
+
8
+ from interloper_api.dependencies.auth import (
9
+ get_current_org,
10
+ get_current_user,
11
+ get_org_id,
12
+ get_session_context,
13
+ )
14
+ from interloper_api.dependencies.rbac import (
15
+ authorize_org_member,
16
+ load_authorized,
17
+ require_admin,
18
+ require_editor,
19
+ require_super_admin,
20
+ require_viewer,
21
+ )
22
+ from interloper_api.dependencies.state import (
23
+ get_admin_config,
24
+ get_auth_config,
25
+ get_catalog,
26
+ get_features,
27
+ get_quota_defaults,
28
+ get_smtp_config,
29
+ get_store,
30
+ set_admin_config,
31
+ set_auth_config,
32
+ set_catalog,
33
+ set_features,
34
+ set_quota_defaults,
35
+ set_smtp_config,
36
+ set_store,
37
+ )
38
+
39
+ __all__ = [
40
+ "authorize_org_member",
41
+ "get_admin_config",
42
+ "get_auth_config",
43
+ "get_catalog",
44
+ "get_current_org",
45
+ "get_current_user",
46
+ "get_features",
47
+ "get_org_id",
48
+ "get_quota_defaults",
49
+ "get_session_context",
50
+ "get_smtp_config",
51
+ "get_store",
52
+ "load_authorized",
53
+ "require_admin",
54
+ "require_editor",
55
+ "require_super_admin",
56
+ "require_viewer",
57
+ "set_admin_config",
58
+ "set_auth_config",
59
+ "set_catalog",
60
+ "set_features",
61
+ "set_quota_defaults",
62
+ "set_smtp_config",
63
+ "set_store",
64
+ ]
@@ -0,0 +1,108 @@
1
+ """Request-scoped identity: who is calling, and which organisation they are in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from fastapi import Cookie, Depends, HTTPException
8
+ from interloper_db import Organisation, Profile, Store
9
+ from interloper_db.models import AuthSession
10
+
11
+ from interloper_api.dependencies.state import get_store
12
+
13
+
14
+ def get_current_user(
15
+ store: Store = Depends(get_store),
16
+ session_token: str | None = Cookie(default=None),
17
+ ) -> Profile:
18
+ """Resolve the current user from the session cookie.
19
+
20
+ Args:
21
+ store: The Store instance.
22
+ session_token: Session cookie value.
23
+
24
+ Returns:
25
+ The authenticated Profile.
26
+
27
+ Raises:
28
+ HTTPException: 401 if not authenticated or session invalid/expired.
29
+ """
30
+ if not session_token:
31
+ raise HTTPException(status_code=401, detail="Not authenticated")
32
+
33
+ result = store.auth.resolve_session(session_token)
34
+ if not result:
35
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
36
+
37
+ profile, _ = result
38
+ return profile
39
+
40
+
41
+ def get_session_context(
42
+ store: Store = Depends(get_store),
43
+ session_token: str | None = Cookie(default=None),
44
+ ) -> tuple[Profile, AuthSession]:
45
+ """Resolve user and session from the cookie.
46
+
47
+ Args:
48
+ store: The Store instance.
49
+ session_token: Session cookie value.
50
+
51
+ Returns:
52
+ ``(Profile, Session)`` tuple.
53
+
54
+ Raises:
55
+ HTTPException: 401 if not authenticated.
56
+ """
57
+ if not session_token:
58
+ raise HTTPException(status_code=401, detail="Not authenticated")
59
+
60
+ result = store.auth.resolve_session(session_token)
61
+ if not result:
62
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
63
+
64
+ return result
65
+
66
+
67
+ def get_current_org(
68
+ store: Store = Depends(get_store),
69
+ session_token: str | None = Cookie(default=None),
70
+ ) -> Organisation:
71
+ """Resolve the current organisation from the session.
72
+
73
+ Args:
74
+ store: The Store instance.
75
+ session_token: Session cookie value.
76
+
77
+ Returns:
78
+ The active Organisation.
79
+
80
+ Raises:
81
+ HTTPException: 400 if no organisation selected, 401 if not authenticated.
82
+ """
83
+ if not session_token:
84
+ raise HTTPException(status_code=401, detail="Not authenticated")
85
+
86
+ result = store.auth.resolve_session(session_token)
87
+ if not result:
88
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
89
+
90
+ _, session_row = result
91
+ if not session_row.organisation_id:
92
+ raise HTTPException(status_code=400, detail="No organisation selected")
93
+
94
+ return store.organisations.get(session_row.organisation_id)
95
+
96
+
97
+ def get_org_id(
98
+ org: Organisation = Depends(get_current_org),
99
+ ) -> UUID:
100
+ """Shorthand: return just the org UUID for route handlers.
101
+
102
+ Args:
103
+ org: The resolved Organisation.
104
+
105
+ Returns:
106
+ The organisation UUID.
107
+ """
108
+ return org.id