Skip to content

Commit 802ef66

Browse files
committed
feat(client): raise MissingWorkspaceError before an unscoped request
A workspace-scoped operation sent without a workspace id came back as a bare 404. ApiClient.update_params_for_auth now calls hotdata._workspace.ensure_workspace, which raises MissingWorkspaceError (an ApiValueError) naming workspace_id= and HOTDATA_WORKSPACE whenever the operation's security lists WorkspaceId and no value would be sent. Explicit X-Workspace-Id headers, _request_auth overrides and BearerAuth-only endpoints (Workspaces) are untouched. api_client.py is generated, so the hook is applied by scripts/patch_workspace_guard.py from the regenerate workflow; _workspace.py is generator-ignored and the error is exported from hotdata via patch_query_exports.py.
1 parent db85746 commit 802ef66

10 files changed

Lines changed: 363 additions & 1 deletion

File tree

‎.github/workflows/regenerate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ jobs:
201201
- name: Patch transport request defaults (Accept-Encoding, User-Agent)
202202
run: python3 scripts/patch_request_defaults.py
203203

204+
- name: Patch missing-workspace guard into ApiClient
205+
run: python3 scripts/patch_workspace_guard.py
206+
204207
# The API-token -> JWT key exchange is deprecated: the configured API token
205208
# is the bearer credential and goes on the wire verbatim. This check is the
206209
# inverse of the old "did the exchange survive?" guard — it fails if the

‎.openapi-generator-ignore‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ setup.py
1212
# presigned direct-to-storage upload flow), _useragent.py (runtime-resolved
1313
# User-Agent; the generator's httpUserAgent property can only bake a literal at
1414
# generation time, which goes stale between releases), loads.py (409
15-
# RESOURCE_LOCKED retry of table loads while another load holds the table).
15+
# RESOURCE_LOCKED retry of table loads while another load holds the table),
16+
# _workspace.py (client-side missing-workspace guard, wired into api_client by
17+
# scripts/patch_workspace_guard.py).
1618
hotdata/arrow.py
1719
hotdata/query.py
1820
hotdata/_retry.py
1921
hotdata/uploads.py
2022
hotdata/loads.py
23+
hotdata/_workspace.py
2124
hotdata/_useragent.py
2225

2326
# Hand-written test for the patched ApiClient.close()/context-manager behavior

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
`HOTDATA_API_URL` from the environment when `api_key`, `workspace_id` or
1414
`host` is not passed, matching the CLI. Explicit arguments always win and an
1515
empty variable counts as unset.
16+
- A workspace-scoped call made with no workspace id configured now raises
17+
`hotdata.MissingWorkspaceError` (an `ApiValueError`) before anything is sent,
18+
naming `workspace_id=` and `HOTDATA_WORKSPACE`. Previously the request went
19+
out without `X-Workspace-Id` and the server answered a bare 404. Calls that
20+
pass the header via `_headers`, use `_request_auth`, or target endpoints that
21+
are not workspace-scoped (the Workspaces endpoints) are unaffected.
1622
- `DatabasesApi.load_database_table` and `ConnectionsApi.load_managed_table`
1723
(the classes exported from `hotdata`) retry automatically while another load
1824
holds the table: HTTP 409 with error code `RESOURCE_LOCKED` is retried,

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ explicit argument always wins.
4545
`host` defaults to `https://api.hotdata.dev`. Override it (or set
4646
`HOTDATA_API_URL`) if you target another environment.
4747

48+
Almost every call is workspace-scoped. If no workspace id is configured, the
49+
SDK raises `hotdata.MissingWorkspaceError` before sending the request rather
50+
than letting the server answer with an unexplained 404.
51+
4852
## Usage
4953

5054
```python

‎hotdata/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@
324324
LoadRetryPolicy as LoadRetryPolicy,
325325
TableLockedError as TableLockedError,
326326
)
327+
# Raised client-side (scripts/patch_workspace_guard.py) when a workspace-
328+
# scoped call has no workspace id configured.
329+
from hotdata._workspace import MissingWorkspaceError as MissingWorkspaceError # noqa: E402,F401
327330
# The upload error hierarchy + option/helper types, so `from hotdata import
328331
# UploadError` / `StorageError` / ... works (they otherwise live only under
329332
# hotdata.uploads and are invisible to autocomplete on `hotdata.`).
@@ -360,4 +363,5 @@
360363
"tqdm_progress",
361364
"LoadRetryPolicy",
362365
"TableLockedError",
366+
"MissingWorkspaceError",
363367
]

‎hotdata/_workspace.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Fail fast when a workspace-scoped request has no workspace to go to.
2+
3+
Most operations are scoped to a workspace through the ``X-Workspace-Id``
4+
header (the ``WorkspaceId`` security scheme). The generated client simply
5+
skips a scheme that has no configured value, so a ``Configuration`` built
6+
without ``workspace_id`` sends the request anyway and the server answers with
7+
a bare 404 that says nothing about what was missing.
8+
9+
:func:`ensure_workspace` runs before the request is sent (wired into
10+
``ApiClient.update_params_for_auth`` by ``scripts/patch_workspace_guard.py``)
11+
and raises :class:`MissingWorkspaceError` naming the constructor argument and
12+
the environment variable. Operations whose security does not list
13+
``WorkspaceId`` (workspace management itself) are unaffected, as are calls
14+
that supply the header explicitly or override auth per request.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any, Iterable, Mapping, Optional
20+
21+
from hotdata.exceptions import ApiValueError
22+
23+
WORKSPACE_SCHEME = "WorkspaceId"
24+
WORKSPACE_HEADER = "X-Workspace-Id"
25+
26+
27+
class MissingWorkspaceError(ApiValueError):
28+
"""Raised before sending a workspace-scoped request when no workspace id
29+
is configured.
30+
31+
Pass ``workspace_id=`` to :class:`hotdata.Configuration`, set the
32+
``HOTDATA_WORKSPACE`` environment variable, or send the ``X-Workspace-Id``
33+
header explicitly for the call.
34+
"""
35+
36+
def __init__(self, resource_path: str, method: str) -> None:
37+
self.resource_path = resource_path
38+
self.method = method
39+
super().__init__(
40+
f"{method} {resource_path} is scoped to a workspace but no workspace id "
41+
"is configured. Pass workspace_id=... to hotdata.Configuration(...), "
42+
"set the HOTDATA_WORKSPACE environment variable, or send the "
43+
f"{WORKSPACE_HEADER} header explicitly (_headers=...)."
44+
)
45+
46+
47+
def ensure_workspace(
48+
configuration: Any,
49+
auth_settings: Optional[Iterable[str]],
50+
headers: Optional[Mapping[str, Any]],
51+
resource_path: str,
52+
method: str,
53+
request_auth: Optional[Mapping[str, Any]] = None,
54+
) -> None:
55+
"""Raise :class:`MissingWorkspaceError` if the operation needs a workspace
56+
id and none would be sent.
57+
58+
:param configuration: The client's :class:`hotdata.Configuration`.
59+
:param auth_settings: The operation's security scheme names.
60+
:param headers: Header params already assembled for the request.
61+
:param request_auth: A per-request auth override; when given the
62+
configuration is bypassed entirely, so the check is skipped.
63+
"""
64+
if request_auth:
65+
return
66+
if not auth_settings or WORKSPACE_SCHEME not in auth_settings:
67+
return
68+
if headers and any(
69+
str(name).lower() == WORKSPACE_HEADER.lower() for name in headers
70+
):
71+
return
72+
settings = configuration.auth_settings()
73+
scheme = settings.get(WORKSPACE_SCHEME) if settings else None
74+
if scheme and scheme.get("value"):
75+
return
76+
raise MissingWorkspaceError(resource_path, method)
77+
78+
79+
__all__ = [
80+
"WORKSPACE_HEADER",
81+
"WORKSPACE_SCHEME",
82+
"MissingWorkspaceError",
83+
"ensure_workspace",
84+
]

‎hotdata/api_client.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from hotdata.configuration import Configuration
3131
from hotdata.api_response import ApiResponse, T as ApiResponseT
32+
from hotdata._workspace import ensure_workspace
3233
from hotdata._useragent import USER_AGENT
3334
import hotdata.models
3435
from hotdata import rest
@@ -637,6 +638,19 @@ def update_params_for_auth(
637638
if not auth_settings:
638639
return
639640

641+
# Hand-applied (scripts/patch_workspace_guard.py): a workspace-scoped
642+
# operation with no workspace id configured would otherwise go out
643+
# without X-Workspace-Id and come back as a bare 404. Fail here with
644+
# an error that says what is missing and how to supply it.
645+
ensure_workspace(
646+
self.configuration,
647+
auth_settings,
648+
headers,
649+
resource_path,
650+
method,
651+
request_auth=request_auth,
652+
)
653+
640654
if request_auth:
641655
self._apply_auth_params(
642656
headers,

‎scripts/patch_query_exports.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
" LoadRetryPolicy as LoadRetryPolicy,\n"
4646
" TableLockedError as TableLockedError,\n"
4747
")\n"
48+
"# Raised client-side (scripts/patch_workspace_guard.py) when a workspace-\n"
49+
"# scoped call has no workspace id configured.\n"
50+
"from hotdata._workspace import MissingWorkspaceError as MissingWorkspaceError # noqa: E402,F401\n"
4851
"# The upload error hierarchy + option/helper types, so `from hotdata import\n"
4952
"# UploadError` / `StorageError` / ... works (they otherwise live only under\n"
5053
"# hotdata.uploads and are invisible to autocomplete on `hotdata.`).\n"
@@ -81,6 +84,7 @@
8184
' "tqdm_progress",\n'
8285
' "LoadRetryPolicy",\n'
8386
' "TableLockedError",\n'
87+
' "MissingWorkspaceError",\n'
8488
"]\n"
8589
)
8690

‎scripts/patch_workspace_guard.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env python3
2+
"""Re-apply the missing-workspace guard after OpenAPI regeneration.
3+
4+
The generated ``ApiClient.update_params_for_auth`` silently skips a security
5+
scheme that has no configured value. For the ``WorkspaceId`` scheme that means
6+
a ``Configuration`` built without ``workspace_id`` still sends the request,
7+
and the server answers a workspace-scoped call with a bare 404.
8+
9+
The guard itself lives in ``hotdata/_workspace.py`` (generator-ignored); this
10+
patch adds the import and one call at the top of ``update_params_for_auth`` so
11+
the request fails client-side with :class:`hotdata.MissingWorkspaceError`,
12+
which names the constructor argument and the environment variable.
13+
Idempotent: a no-op if already applied.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import pathlib
19+
import sys
20+
21+
ROOT = pathlib.Path(__file__).resolve().parents[1]
22+
23+
MARKER = "_workspace import ensure_workspace"
24+
25+
26+
def patch_api_client() -> None:
27+
path = ROOT / "hotdata" / "api_client.py"
28+
src = path.read_text()
29+
if MARKER in src:
30+
return
31+
32+
import_needle = "from hotdata.api_response import ApiResponse, T as ApiResponseT\n"
33+
import_replacement = (
34+
"from hotdata.api_response import ApiResponse, T as ApiResponseT\n"
35+
"from hotdata._workspace import ensure_workspace\n"
36+
)
37+
if import_needle not in src:
38+
sys.exit(f"Failed to patch {path}: api_response import anchor not found")
39+
src = src.replace(import_needle, import_replacement, 1)
40+
41+
needle = (
42+
" if not auth_settings:\n"
43+
" return\n"
44+
"\n"
45+
" if request_auth:\n"
46+
" self._apply_auth_params(\n"
47+
)
48+
replacement = (
49+
" if not auth_settings:\n"
50+
" return\n"
51+
"\n"
52+
" # Hand-applied (scripts/patch_workspace_guard.py): a workspace-scoped\n"
53+
" # operation with no workspace id configured would otherwise go out\n"
54+
" # without X-Workspace-Id and come back as a bare 404. Fail here with\n"
55+
" # an error that says what is missing and how to supply it.\n"
56+
" ensure_workspace(\n"
57+
" self.configuration,\n"
58+
" auth_settings,\n"
59+
" headers,\n"
60+
" resource_path,\n"
61+
" method,\n"
62+
" request_auth=request_auth,\n"
63+
" )\n"
64+
"\n"
65+
" if request_auth:\n"
66+
" self._apply_auth_params(\n"
67+
)
68+
if needle not in src:
69+
sys.exit(f"Failed to patch {path}: update_params_for_auth anchor not found")
70+
src = src.replace(needle, replacement, 1)
71+
72+
path.write_text(src)
73+
74+
75+
def main() -> None:
76+
patch_api_client()
77+
78+
79+
if __name__ == "__main__":
80+
main()

0 commit comments

Comments
 (0)