interloper-api 0.99.0__tar.gz → 0.101.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 (39) hide show
  1. {interloper_api-0.99.0 → interloper_api-0.101.0}/PKG-INFO +1 -1
  2. {interloper_api-0.99.0 → interloper_api-0.101.0}/pyproject.toml +12 -1
  3. {interloper_api-0.99.0 → interloper_api-0.101.0}/pyproject.toml.orig +10 -3
  4. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/app.py +58 -39
  5. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/__init__.py +4 -0
  6. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/auth.py +21 -42
  7. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/rbac.py +25 -7
  8. interloper_api-0.101.0/src/interloper_api/notifications/__init__.py +7 -0
  9. interloper_api-0.99.0/src/interloper_api/notifications/invitations.py → interloper_api-0.101.0/src/interloper_api/notifications/base.py +127 -51
  10. interloper_api-0.101.0/src/interloper_api/notifications/invitations.py +100 -0
  11. interloper_api-0.101.0/src/interloper_api/notifications/super_admins.py +110 -0
  12. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/admin.py +180 -487
  13. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/agent.py +11 -8
  14. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/auth.py +71 -106
  15. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/backfills.py +39 -65
  16. interloper_api-0.101.0/src/interloper_api/routes/catalog.py +316 -0
  17. interloper_api-0.101.0/src/interloper_api/routes/components.py +537 -0
  18. interloper_api-0.101.0/src/interloper_api/routes/executions.py +76 -0
  19. interloper_api-0.101.0/src/interloper_api/routes/organisations.py +448 -0
  20. interloper_api-0.101.0/src/interloper_api/routes/overview.py +283 -0
  21. interloper_api-0.101.0/src/interloper_api/routes/relations.py +73 -0
  22. interloper_api-0.101.0/src/interloper_api/routes/runs.py +331 -0
  23. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/tokens.py +16 -15
  24. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/websocket.py +3 -4
  25. interloper_api-0.101.0/src/interloper_api/utils/__init__.py +5 -0
  26. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/utils/time.py +0 -17
  27. interloper_api-0.99.0/src/interloper_api/notifications/__init__.py +0 -5
  28. interloper_api-0.99.0/src/interloper_api/routes/catalog.py +0 -83
  29. interloper_api-0.99.0/src/interloper_api/routes/components.py +0 -904
  30. interloper_api-0.99.0/src/interloper_api/routes/organisations.py +0 -384
  31. interloper_api-0.99.0/src/interloper_api/routes/overview.py +0 -997
  32. interloper_api-0.99.0/src/interloper_api/routes/runs.py +0 -495
  33. interloper_api-0.99.0/src/interloper_api/utils/__init__.py +0 -5
  34. {interloper_api-0.99.0 → interloper_api-0.101.0}/README.md +0 -0
  35. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/__init__.py +0 -0
  36. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/state.py +0 -0
  37. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/__init__.py +0 -0
  38. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/health.py +0 -0
  39. {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/oauth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.99.0
3
+ Version: 0.101.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.99.0"
3
+ version = "0.101.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -53,6 +53,7 @@ extend-select = [
53
53
  "ANN202",
54
54
  "DOC",
55
55
  "D",
56
+ "TID251",
56
57
  ]
57
58
 
58
59
  [tool.ruff.lint.pydocstyle]
@@ -71,12 +72,22 @@ extend-immutable-calls = [
71
72
  "fastapi.Security",
72
73
  ]
73
74
 
75
+ [tool.ruff.lint.flake8-tidy-imports.banned-api.sqlalchemy]
76
+ msg = "Database access goes through interloper_db.Store facets"
77
+
78
+ [tool.ruff.lint.flake8-tidy-imports.banned-api.sqlmodel]
79
+ msg = "Database access goes through interloper_db.Store facets"
80
+
81
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."interloper_db.session"]
82
+ msg = "Database access goes through interloper_db.Store facets"
83
+
74
84
  [tool.ruff.lint.per-file-ignores]
75
85
  "__init__.py" = [
76
86
  "F401",
77
87
  "F403",
78
88
  ]
79
89
  "tests/**" = [
90
+ "TID251",
80
91
  "ANN",
81
92
  "F811",
82
93
  "D101",
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.99.0"
6
+ version = "0.101.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -43,7 +43,7 @@ line-length = 120
43
43
 
44
44
  [tool.ruff.lint]
45
45
  preview = true
46
- extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D"]
46
+ extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D", "TID251"]
47
47
 
48
48
  [tool.ruff.lint.pydocstyle]
49
49
  convention = "google"
@@ -63,6 +63,13 @@ extend-immutable-calls = [
63
63
  "fastapi.Security",
64
64
  ]
65
65
 
66
+ # Raw database access lives in interloper-db alone; this package reads and
67
+ # writes through the store's facets.
68
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
69
+ "sqlalchemy".msg = "Database access goes through interloper_db.Store facets"
70
+ "sqlmodel".msg = "Database access goes through interloper_db.Store facets"
71
+ "interloper_db.session".msg = "Database access goes through interloper_db.Store facets"
72
+
66
73
  [tool.ruff.lint.per-file-ignores]
67
74
  "__init__.py" = ["F401", "F403"]
68
- "tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104", "D107", "RUF069", "PLW0108"]
75
+ "tests/**" = ["TID251", "ANN", "F811", "D101", "D102", "D103", "D104", "D107", "RUF069", "PLW0108"]
@@ -10,7 +10,15 @@ from fastapi import APIRouter, FastAPI, Request
10
10
  from fastapi.middleware.cors import CORSMiddleware
11
11
  from fastapi.responses import JSONResponse
12
12
  from interloper.catalog.base import Catalog
13
- from interloper.errors import ComponentDriftError, HydrationError, NotFoundError, QuotaExceededError
13
+ from interloper.errors import (
14
+ ComponentDriftError,
15
+ ConfigError,
16
+ ConflictError,
17
+ HydrationError,
18
+ InUseError,
19
+ NotFoundError,
20
+ QuotaExceededError,
21
+ )
14
22
  from interloper_db import Store
15
23
 
16
24
  from interloper_api.dependencies import (
@@ -28,10 +36,12 @@ from interloper_api.routes import (
28
36
  auth,
29
37
  backfills,
30
38
  components,
39
+ executions,
31
40
  health,
32
41
  oauth,
33
42
  organisations,
34
43
  overview,
44
+ relations,
35
45
  runs,
36
46
  tokens,
37
47
  websocket,
@@ -48,7 +58,9 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
48
58
  admin,
49
59
  catalog_routes,
50
60
  components,
61
+ relations,
51
62
  runs,
63
+ executions,
52
64
  backfills,
53
65
  overview,
54
66
  oauth,
@@ -61,52 +73,48 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
61
73
  # -- Error handling ------------------------------------------------------------
62
74
 
63
75
 
64
- async def _not_found(_request: Request, exception: NotFoundError) -> JSONResponse:
65
- """Render a missing store target as a plain 404.
66
-
67
- Args:
68
- _request: The incoming request, unused.
69
- exception: The raised :class:`NotFoundError`.
70
-
71
- Returns:
72
- A 404 response carrying the exception message as ``detail``.
73
- """
74
- return JSONResponse(status_code=404, content={"detail": str(exception)})
75
-
76
+ #: Framework errors that have an HTTP meaning, by the status each answers.
77
+ #: Drift and an unhydratable record are 409s: the record stays broken until a
78
+ #: user repoints, re-keys or rewrites it, so the reason is surfaced instead of
79
+ #: an opaque 500. Anything absent here is a bug and stays a 500.
80
+ _ERROR_STATUS: dict[type[Exception], int] = {
81
+ ConfigError: 400,
82
+ NotFoundError: 404,
83
+ ConflictError: 409,
84
+ ComponentDriftError: 409,
85
+ HydrationError: 409,
86
+ }
76
87
 
77
- async def _component_drift(_request: Request, exception: ComponentDriftError) -> JSONResponse:
78
- """Render catalog drift as a conflict rather than a 500.
79
88
 
80
- Hydrating or running a drifted source/asset cannot succeed until the user
81
- resolves the drift, so it surfaces as a clean 409 the UI can act on.
89
+ async def _plain_error(_request: Request, exception: Exception) -> JSONResponse:
90
+ """Render a framework error as its mapped status, the message as ``detail``.
82
91
 
83
92
  Args:
84
93
  _request: The incoming request, unused.
85
- exception: The raised :class:`ComponentDriftError`.
94
+ exception: The raised error, an instance of exactly one key of
95
+ :data:`_ERROR_STATUS` (none of the keys subclasses another).
86
96
 
87
97
  Returns:
88
- A 409 response carrying the exception message as ``detail``.
98
+ The response, at the status mapped to the error's class.
89
99
  """
90
- return JSONResponse(status_code=409, content={"detail": str(exception)})
91
-
100
+ status = next(status for error_type, status in _ERROR_STATUS.items() if isinstance(exception, error_type))
101
+ return JSONResponse(status_code=status, content={"detail": str(exception)})
92
102
 
93
- async def _hydration_failed(_request: Request, exception: HydrationError) -> JSONResponse:
94
- """Render an unreadable stored record as a conflict rather than a 500.
95
103
 
96
- A record that cannot be rebuilt from the database (a payload the active
97
- ``INTERLOPER_ENCRYPTION_KEY`` cannot decrypt, a stored config the class no
98
- longer accepts) stays broken until an operator re-keys or rewrites it, so
99
- like drift it surfaces as a 409 carrying the reason instead of an opaque
100
- 500 that only the server log explains.
104
+ async def _in_use(_request: Request, exception: InUseError) -> JSONResponse:
105
+ """Render a refused deletion as a 409 naming what still depends on the record.
101
106
 
102
107
  Args:
103
108
  _request: The incoming request, unused.
104
- exception: The raised :class:`HydrationError`.
109
+ exception: The raised :class:`InUseError`.
105
110
 
106
111
  Returns:
107
- A 409 response carrying the exception message as ``detail``.
112
+ A 409 response whose ``detail`` carries the message and the referrers
113
+ as ``used_by``.
108
114
  """
109
- return JSONResponse(status_code=409, content={"detail": str(exception)})
115
+ return JSONResponse(
116
+ status_code=409, content={"detail": {"message": str(exception), "used_by": exception.referrers}}
117
+ )
110
118
 
111
119
 
112
120
  async def _quota_exceeded(_request: Request, exception: QuotaExceededError) -> JSONResponse:
@@ -133,16 +141,28 @@ async def _quota_exceeded(_request: Request, exception: QuotaExceededError) -> J
133
141
  )
134
142
 
135
143
 
136
- #: Framework errors that have an HTTP meaning, and the response each becomes.
137
- #: Anything absent here is a bug and stays a 500.
138
- _ERROR_HANDLERS: dict[type[Exception], Any] = {
139
- NotFoundError: _not_found,
140
- ComponentDriftError: _component_drift,
141
- HydrationError: _hydration_failed,
144
+ #: Errors whose response carries more than the message.
145
+ _STRUCTURED_ERRORS: dict[type[Exception], Any] = {
146
+ InUseError: _in_use,
142
147
  QuotaExceededError: _quota_exceeded,
143
148
  }
144
149
 
145
150
 
151
+ def install_error_handlers(app: FastAPI) -> None:
152
+ """Answer every framework error that has an HTTP meaning with its status.
153
+
154
+ Routes raise the store's errors as they come; this one mapping turns them
155
+ into responses, so no handler translates an exception by hand.
156
+
157
+ Args:
158
+ app: The application to install the handlers on.
159
+ """
160
+ for error_type in _ERROR_STATUS:
161
+ app.add_exception_handler(error_type, _plain_error)
162
+ for error_type, handler in _STRUCTURED_ERRORS.items():
163
+ app.add_exception_handler(error_type, handler)
164
+
165
+
146
166
  # -- Application factory -------------------------------------------------------
147
167
 
148
168
 
@@ -172,8 +192,7 @@ def create_app(
172
192
  """
173
193
  app = FastAPI(title="Interloper API", lifespan=websocket.realtime_lifespan, **kwargs)
174
194
 
175
- for error_type, handler in _ERROR_HANDLERS.items():
176
- app.add_exception_handler(error_type, handler)
195
+ install_error_handlers(app)
177
196
 
178
197
  if cors_origins:
179
198
  app.add_middleware(
@@ -8,6 +8,7 @@ organisation per request; ``rbac`` gates handlers on the caller's role.
8
8
  from interloper_api.dependencies.auth import (
9
9
  CurrentUserDep,
10
10
  OrgIdDep,
11
+ SessionContextDep,
11
12
  get_current_org,
12
13
  get_current_user,
13
14
  get_org_id,
@@ -19,6 +20,7 @@ from interloper_api.dependencies.rbac import (
19
20
  SuperAdminDep,
20
21
  ViewerDep,
21
22
  authorize_org_member,
23
+ authorize_organisation,
22
24
  load_authorized,
23
25
  require_admin,
24
26
  require_editor,
@@ -60,10 +62,12 @@ __all__ = [
60
62
  "EditorDep",
61
63
  "OrgIdDep",
62
64
  "QuotaDefaultsDep",
65
+ "SessionContextDep",
63
66
  "StoreDep",
64
67
  "SuperAdminDep",
65
68
  "ViewerDep",
66
69
  "authorize_org_member",
70
+ "authorize_organisation",
67
71
  "get_admin_config",
68
72
  "get_agent",
69
73
  "get_auth_config",
@@ -12,86 +12,64 @@ from interloper_db.models import AuthSession
12
12
  from interloper_api.dependencies.state import get_store
13
13
 
14
14
 
15
- def get_current_user(
15
+ def get_session_context(
16
16
  store: Store = Depends(get_store),
17
17
  session_token: str | None = Cookie(default=None),
18
- ) -> Profile:
19
- """Resolve the current user from the session cookie.
18
+ ) -> tuple[Profile, AuthSession]:
19
+ """Resolve the caller and their login session from the session cookie.
20
20
 
21
21
  Args:
22
22
  store: The Store instance.
23
23
  session_token: Session cookie value.
24
24
 
25
25
  Returns:
26
- The authenticated Profile.
26
+ ``(Profile, AuthSession)``.
27
27
 
28
28
  Raises:
29
- HTTPException: 401 if not authenticated or session invalid/expired.
29
+ HTTPException: 401 if not authenticated or the session is invalid or expired.
30
30
  """
31
31
  if not session_token:
32
32
  raise HTTPException(status_code=401, detail="Not authenticated")
33
-
34
- result = store.auth.resolve_session(session_token)
33
+ result = store.sessions.resolve(session_token)
35
34
  if not result:
36
35
  raise HTTPException(status_code=401, detail="Invalid or expired session")
37
-
38
- profile, _ = result
39
- return profile
36
+ return result
40
37
 
41
38
 
42
- def get_session_context(
43
- store: Store = Depends(get_store),
44
- session_token: str | None = Cookie(default=None),
45
- ) -> tuple[Profile, AuthSession]:
46
- """Resolve user and session from the cookie.
39
+ def get_current_user(
40
+ context: tuple[Profile, AuthSession] = Depends(get_session_context),
41
+ ) -> Profile:
42
+ """Resolve the current user from the session.
47
43
 
48
44
  Args:
49
- store: The Store instance.
50
- session_token: Session cookie value.
45
+ context: The caller and their session.
51
46
 
52
47
  Returns:
53
- ``(Profile, Session)`` tuple.
54
-
55
- Raises:
56
- HTTPException: 401 if not authenticated.
48
+ The authenticated Profile.
57
49
  """
58
- if not session_token:
59
- raise HTTPException(status_code=401, detail="Not authenticated")
60
-
61
- result = store.auth.resolve_session(session_token)
62
- if not result:
63
- raise HTTPException(status_code=401, detail="Invalid or expired session")
64
-
65
- return result
50
+ profile, _ = context
51
+ return profile
66
52
 
67
53
 
68
54
  def get_current_org(
55
+ context: tuple[Profile, AuthSession] = Depends(get_session_context),
69
56
  store: Store = Depends(get_store),
70
- session_token: str | None = Cookie(default=None),
71
57
  ) -> Organisation:
72
- """Resolve the current organisation from the session.
58
+ """Resolve the organisation the session is working in.
73
59
 
74
60
  Args:
61
+ context: The caller and their session.
75
62
  store: The Store instance.
76
- session_token: Session cookie value.
77
63
 
78
64
  Returns:
79
65
  The active Organisation.
80
66
 
81
67
  Raises:
82
- HTTPException: 400 if no organisation selected, 401 if not authenticated.
68
+ HTTPException: 400 if no organisation is selected.
83
69
  """
84
- if not session_token:
85
- raise HTTPException(status_code=401, detail="Not authenticated")
86
-
87
- result = store.auth.resolve_session(session_token)
88
- if not result:
89
- raise HTTPException(status_code=401, detail="Invalid or expired session")
90
-
91
- _, session_row = result
70
+ _, session_row = context
92
71
  if not session_row.organisation_id:
93
72
  raise HTTPException(status_code=400, detail="No organisation selected")
94
-
95
73
  return store.organisations.get(session_row.organisation_id)
96
74
 
97
75
 
@@ -112,4 +90,5 @@ def get_org_id(
112
90
  # -- Dependency aliases --------------------------------------------------------
113
91
 
114
92
  CurrentUserDep = Annotated[Profile, Depends(get_current_user)]
93
+ SessionContextDep = Annotated[tuple[Profile, AuthSession], Depends(get_session_context)]
115
94
  OrgIdDep = Annotated[UUID, Depends(get_org_id)]
@@ -8,13 +8,11 @@ from uuid import UUID
8
8
 
9
9
  from fastapi import Depends, HTTPException
10
10
  from interloper.errors import NotFoundError
11
- from interloper_db import Profile, Store
11
+ from interloper_db import Profile, Role, Store
12
12
 
13
13
  from interloper_api.dependencies.auth import get_current_user, get_org_id
14
14
  from interloper_api.dependencies.state import get_store
15
15
 
16
- _ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
17
-
18
16
 
19
17
  def authorize_org_member(
20
18
  user: Profile,
@@ -43,13 +41,33 @@ def authorize_org_member(
43
41
  Raises:
44
42
  HTTPException: 404 if not a member, 403 if the role is insufficient.
45
43
  """
46
- role = store.organisations.member_role(user.id, org_id)
44
+ role = store.members.role(org_id, user.id)
47
45
  if role is None:
48
46
  raise HTTPException(status_code=404, detail=detail)
49
- if _ROLE_RANK.get(role, -1) < _ROLE_RANK[minimum]:
47
+ if not Role.at_least(role, minimum):
50
48
  raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
51
49
 
52
50
 
51
+ def authorize_organisation(user: Profile, org_id: UUID, store: Store, *, minimum: str = "viewer") -> None:
52
+ """Authorize an organisation-addressed route: a member holding *minimum*, or a super-admin.
53
+
54
+ The organisation routes take the organisation from the path, so they serve
55
+ its own members and the platform's super-admins through one set of
56
+ endpoints. A super-admin passes on any live organisation; anyone else must
57
+ be a member, a non-member reading as a missing organisation.
58
+
59
+ Args:
60
+ user: The authenticated user.
61
+ org_id: The organisation the path addresses.
62
+ store: The Store instance.
63
+ minimum: Minimum role a member needs (``viewer``, ``editor``, ``admin``).
64
+ """
65
+ if user.is_super_admin:
66
+ store.organisations.get(org_id)
67
+ return
68
+ authorize_org_member(user, org_id, store, minimum=minimum, detail=f"Organisation {org_id} not found")
69
+
70
+
53
71
  def load_authorized(
54
72
  fetch: Callable[[UUID], Any],
55
73
  entity_id: UUID,
@@ -109,10 +127,10 @@ def _check_role(
109
127
  Raises:
110
128
  HTTPException: 403 if insufficient permissions.
111
129
  """
112
- role = store.organisations.member_role(user.id, org_id)
130
+ role = store.members.role(org_id, user.id)
113
131
  if role is None:
114
132
  raise HTTPException(status_code=403, detail="Not a member of this organisation")
115
- if _ROLE_RANK.get(role, -1) < _ROLE_RANK[minimum]:
133
+ if not Role.at_least(role, minimum):
116
134
  raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
117
135
  return user
118
136
 
@@ -0,0 +1,7 @@
1
+ """Outbound notifications the API sends on a user's behalf."""
2
+
3
+ from interloper_api.notifications.base import Email
4
+ from interloper_api.notifications.invitations import InvitationEmail
5
+ from interloper_api.notifications.super_admins import SuperAdminPromotionEmail
6
+
7
+ __all__ = ["Email", "InvitationEmail", "SuperAdminPromotionEmail"]