Skip to content

Commit eff50f4

Browse files
audristroyerclaude
andcommitted
Add Pydantic argument validation to cloud API functions (Issue #6)
Add @validate_call with Annotated types to all ndi.cloud.api.* functions, matching MATLAB arguments blocks: non-empty string IDs, pagination bounds, mustBeMember scope enum, and mustBeFile path checks. 47 functions covered across datasets, documents, files, compute, and users modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5c14472 commit eff50f4

7 files changed

Lines changed: 231 additions & 122 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040
"networkx>=2.6",
4141
"jsonschema>=4.0.0",
4242
"requests>=2.28.0",
43+
"pydantic>=2.0",
4344
]
4445

4546
[project.optional-dependencies]

src/ndi/cloud/api/_validators.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Pydantic argument validators matching MATLAB arguments blocks.
2+
3+
Provides reusable ``Annotated`` types for cloud API functions. Each type
4+
maps to a specific MATLAB argument constraint:
5+
6+
CloudId -> (1,1) string (non-empty resource identifier)
7+
NonEmptyStr -> (1,1) string (non-empty general string)
8+
PageNumber -> (1,1) double (integer >= 1)
9+
PageSize -> (1,1) double (integer >= 1)
10+
Scope -> {mustBeMember} (Literal enum)
11+
FilePath -> {mustBeFile} (file must exist on disk)
12+
13+
Usage::
14+
15+
from pydantic import validate_call
16+
from ._validators import CloudId, PageNumber, VALIDATE_CONFIG
17+
18+
@_auto_client
19+
@validate_call(config=VALIDATE_CONFIG)
20+
def get_dataset(dataset_id: CloudId, *, client: CloudClient | None = None):
21+
...
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from pathlib import Path
27+
from typing import Annotated, Literal
28+
29+
from pydantic import AfterValidator, ConfigDict, Field
30+
31+
# -- (1,1) string: non-empty scalar string -----------------------------------
32+
CloudId = Annotated[str, Field(min_length=1)]
33+
NonEmptyStr = Annotated[str, Field(min_length=1)]
34+
35+
# -- (1,1) double: pagination integers ---------------------------------------
36+
PageNumber = Annotated[int, Field(ge=1)]
37+
PageSize = Annotated[int, Field(ge=1)]
38+
39+
# -- {mustBeMember(scope, ["public", "private", "all"])} ---------------------
40+
Scope = Literal["public", "private", "all"]
41+
42+
43+
# -- {mustBeFile}: file must exist on disk ------------------------------------
44+
def _check_file_exists(v: str) -> str:
45+
if not Path(v).is_file():
46+
raise ValueError(f"File not found: {v}")
47+
return v
48+
49+
50+
FilePath = Annotated[str, AfterValidator(_check_file_exists)]
51+
52+
# -- Shared validate_call config ---------------------------------------------
53+
VALIDATE_CONFIG = ConfigDict(arbitrary_types_allowed=True)

src/ndi/cloud/api/compute.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,23 @@
99

1010
from __future__ import annotations
1111

12-
from typing import TYPE_CHECKING, Any
12+
from typing import Annotated, Any
1313

14-
from ..client import APIResponse, _auto_client
14+
from pydantic import SkipValidation, validate_call
1515

16-
if TYPE_CHECKING:
17-
from ..client import CloudClient
16+
from ..client import APIResponse, CloudClient, _auto_client
17+
from ._validators import VALIDATE_CONFIG, NonEmptyStr
18+
19+
_Client = Annotated[CloudClient | None, SkipValidation()]
1820

1921

2022
@_auto_client
23+
@validate_call(config=VALIDATE_CONFIG)
2124
def start_session(
22-
pipeline_id: str,
25+
pipeline_id: NonEmptyStr,
2326
input_params: dict[str, Any] | None = None,
2427
*,
25-
client: CloudClient | None = None,
28+
client: _Client = None,
2629
) -> dict[str, Any]:
2730
"""POST /compute/start -- Start a new compute session."""
2831
body: dict[str, Any] = {"pipelineId": pipeline_id}
@@ -32,17 +35,19 @@ def start_session(
3235

3336

3437
@_auto_client
35-
def get_session_status(session_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
38+
@validate_call(config=VALIDATE_CONFIG)
39+
def get_session_status(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
3640
"""GET /compute/{sessionId} -- Get session status."""
3741
return client.get("/compute/{sessionId}", sessionId=session_id)
3842

3943

4044
@_auto_client
45+
@validate_call(config=VALIDATE_CONFIG)
4146
def trigger_stage(
42-
session_id: str,
43-
stage_id: str,
47+
session_id: NonEmptyStr,
48+
stage_id: NonEmptyStr,
4449
*,
45-
client: CloudClient | None = None,
50+
client: _Client = None,
4651
) -> dict[str, Any]:
4752
"""POST /compute/{sessionId}/stage/{stageId}"""
4853
return client.post(
@@ -53,7 +58,8 @@ def trigger_stage(
5358

5459

5560
@_auto_client
56-
def finalize_session(session_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
61+
@validate_call(config=VALIDATE_CONFIG)
62+
def finalize_session(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
5763
"""POST /compute/{sessionId}/finalize"""
5864
return client.post(
5965
"/compute/{sessionId}/finalize",
@@ -62,14 +68,15 @@ def finalize_session(session_id: str, *, client: CloudClient | None = None) -> d
6268

6369

6470
@_auto_client
65-
def abort_session(session_id: str, *, client: CloudClient | None = None) -> bool:
71+
@validate_call(config=VALIDATE_CONFIG)
72+
def abort_session(session_id: NonEmptyStr, *, client: _Client = None) -> bool:
6673
"""POST /compute/{sessionId}/abort"""
6774
client.post("/compute/{sessionId}/abort", sessionId=session_id)
6875
return True
6976

7077

7178
@_auto_client
72-
def list_sessions(*, client: CloudClient | None = None) -> APIResponse:
79+
def list_sessions(*, client: _Client = None) -> APIResponse:
7380
"""GET /compute -- List all compute sessions."""
7481
result = client.get("/compute")
7582
# Handle both APIResponse (has .data) and raw dict/list from mocks

src/ndi/cloud/api/datasets.py

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,31 @@
1111

1212
from __future__ import annotations
1313

14-
from typing import TYPE_CHECKING, Any
14+
from typing import Annotated, Any
1515

16-
from ..client import APIResponse, _auto_client
16+
from pydantic import SkipValidation, validate_call
1717

18-
if TYPE_CHECKING:
19-
from ..client import CloudClient
18+
from ..client import APIResponse, CloudClient, _auto_client
19+
from ._validators import VALIDATE_CONFIG, CloudId, NonEmptyStr, PageNumber, PageSize
20+
21+
_Client = Annotated[CloudClient | None, SkipValidation()]
2022

2123

2224
@_auto_client
23-
def get_dataset(dataset_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
25+
@validate_call(config=VALIDATE_CONFIG)
26+
def get_dataset(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
2427
"""GET /datasets/{datasetId}"""
2528
return client.get("/datasets/{datasetId}", datasetId=dataset_id)
2629

2730

2831
@_auto_client
32+
@validate_call(config=VALIDATE_CONFIG)
2933
def create_dataset(
30-
org_id: str,
31-
name: str,
34+
org_id: NonEmptyStr,
35+
name: NonEmptyStr,
3236
description: str = "",
3337
*,
34-
client: CloudClient | None = None,
38+
client: _Client = None,
3539
**kwargs: Any,
3640
) -> dict[str, Any]:
3741
"""POST /organizations/{organizationId}/datasets"""
@@ -47,10 +51,11 @@ def create_dataset(
4751

4852

4953
@_auto_client
54+
@validate_call(config=VALIDATE_CONFIG)
5055
def update_dataset(
51-
dataset_id: str,
56+
dataset_id: CloudId,
5257
*,
53-
client: CloudClient | None = None,
58+
client: _Client = None,
5459
**fields: Any,
5560
) -> dict[str, Any]:
5661
"""POST /datasets/{datasetId}"""
@@ -62,11 +67,12 @@ def update_dataset(
6267

6368

6469
@_auto_client
70+
@validate_call(config=VALIDATE_CONFIG)
6571
def delete_dataset(
66-
dataset_id: str,
72+
dataset_id: CloudId,
6773
when: str = "7d",
6874
*,
69-
client: CloudClient | None = None,
75+
client: _Client = None,
7076
) -> dict[str, Any]:
7177
"""DELETE /datasets/{datasetId}?when=...
7278
@@ -86,12 +92,13 @@ def delete_dataset(
8692

8793

8894
@_auto_client
95+
@validate_call(config=VALIDATE_CONFIG)
8996
def list_datasets(
90-
org_id: str,
91-
page: int = 1,
92-
page_size: int = 1000,
97+
org_id: NonEmptyStr,
98+
page: PageNumber = 1,
99+
page_size: PageSize = 1000,
93100
*,
94-
client: CloudClient | None = None,
101+
client: _Client = None,
95102
) -> dict[str, Any]:
96103
"""GET /organizations/{organizationId}/datasets?page=&pageSize="""
97104
return client.get(
@@ -105,7 +112,8 @@ def list_datasets(
105112

106113

107114
@_auto_client
108-
def list_all_datasets(org_id: str, *, client: CloudClient | None = None) -> APIResponse:
115+
@validate_call(config=VALIDATE_CONFIG)
116+
def list_all_datasets(org_id: NonEmptyStr, *, client: _Client = None) -> APIResponse:
109117
"""Auto-paginate through all datasets for an organisation."""
110118
all_datasets: list[dict[str, Any]] = []
111119
page = 1
@@ -121,11 +129,12 @@ def list_all_datasets(org_id: str, *, client: CloudClient | None = None) -> APIR
121129

122130

123131
@_auto_client
132+
@validate_call(config=VALIDATE_CONFIG)
124133
def get_published_datasets(
125-
page: int = 1,
126-
page_size: int = 1000,
134+
page: PageNumber = 1,
135+
page_size: PageSize = 1000,
127136
*,
128-
client: CloudClient | None = None,
137+
client: _Client = None,
129138
) -> dict[str, Any]:
130139
"""GET /datasets/published"""
131140
return client.get(
@@ -135,41 +144,47 @@ def get_published_datasets(
135144

136145

137146
@_auto_client
138-
def publish_dataset(dataset_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
147+
@validate_call(config=VALIDATE_CONFIG)
148+
def publish_dataset(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
139149
"""POST /datasets/{datasetId}/publish"""
140150
return client.post("/datasets/{datasetId}/publish", datasetId=dataset_id)
141151

142152

143153
@_auto_client
144-
def unpublish_dataset(dataset_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
154+
@validate_call(config=VALIDATE_CONFIG)
155+
def unpublish_dataset(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
145156
"""POST /datasets/{datasetId}/unpublish"""
146157
return client.post("/datasets/{datasetId}/unpublish", datasetId=dataset_id)
147158

148159

149160
@_auto_client
150-
def submit_dataset(dataset_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
161+
@validate_call(config=VALIDATE_CONFIG)
162+
def submit_dataset(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
151163
"""POST /datasets/{datasetId}/submit"""
152164
return client.post("/datasets/{datasetId}/submit", datasetId=dataset_id)
153165

154166

155167
@_auto_client
156-
def create_branch(dataset_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
168+
@validate_call(config=VALIDATE_CONFIG)
169+
def create_branch(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
157170
"""POST /datasets/{datasetId}/branch"""
158171
return client.post("/datasets/{datasetId}/branch", datasetId=dataset_id)
159172

160173

161174
@_auto_client
162-
def get_branches(dataset_id: str, *, client: CloudClient | None = None) -> list[dict[str, Any]]:
175+
@validate_call(config=VALIDATE_CONFIG)
176+
def get_branches(dataset_id: CloudId, *, client: _Client = None) -> list[dict[str, Any]]:
163177
"""GET /datasets/{datasetId}/branches"""
164178
return client.get("/datasets/{datasetId}/branches", datasetId=dataset_id)
165179

166180

167181
@_auto_client
182+
@validate_call(config=VALIDATE_CONFIG)
168183
def get_unpublished(
169-
page: int = 1,
170-
page_size: int = 20,
184+
page: PageNumber = 1,
185+
page_size: PageSize = 20,
171186
*,
172-
client: CloudClient | None = None,
187+
client: _Client = None,
173188
) -> dict[str, Any]:
174189
"""GET /datasets/unpublished
175190
@@ -182,7 +197,8 @@ def get_unpublished(
182197

183198

184199
@_auto_client
185-
def undelete_dataset(dataset_id: str, *, client: CloudClient | None = None) -> dict[str, Any]:
200+
@validate_call(config=VALIDATE_CONFIG)
201+
def undelete_dataset(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
186202
"""POST /datasets/{datasetId}/undelete
187203
188204
Reverse a deferred (soft) delete before the pruner runs.
@@ -193,11 +209,12 @@ def undelete_dataset(dataset_id: str, *, client: CloudClient | None = None) -> d
193209

194210

195211
@_auto_client
212+
@validate_call(config=VALIDATE_CONFIG)
196213
def list_deleted_datasets(
197-
page: int = 1,
198-
page_size: int = 1000,
214+
page: PageNumber = 1,
215+
page_size: PageSize = 1000,
199216
*,
200-
client: CloudClient | None = None,
217+
client: _Client = None,
201218
) -> dict[str, Any]:
202219
"""GET /datasets/deleted?page=&pageSize=
203220

0 commit comments

Comments
 (0)