codeocean 0.16.0__tar.gz → 0.17.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.
- {codeocean-0.16.0 → codeocean-0.17.0}/CHANGELOG.md +4 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/PKG-INFO +2 -2
- {codeocean-0.16.0 → codeocean-0.17.0}/RELEASE.md +9 -3
- {codeocean-0.16.0 → codeocean-0.17.0}/pyproject.toml +1 -1
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/capsule.py +88 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/client.py +1 -1
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/capsule.py +117 -2
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/pipeline.py +40 -1
- codeocean-0.17.0/tests/test_release.py +126 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/.flake8 +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/.gitignore +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/LICENSE +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/README.md +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/examples/create_data_asset.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/examples/run_capsule.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/examples/run_pipeline.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/__init__.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/components.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/computation.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/custom_metadata.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/data_asset.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/enum.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/error.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/__init__.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/components.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/computation.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/data_asset.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/folder.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/tests/__init__.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_client.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_error.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_git_sync.py +0 -0
- {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_package.py +0 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
CHANGELOG
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
+
## 0.17.0 (2026-09-09)
|
|
5
|
+
- [#75](https://github.com/codeocean/codeocean-sdk-python/pull/75) feat: add public release capsule/pipeline API
|
|
6
|
+
- **Minimum Code Ocean platform version updated to `4.8.0`.**
|
|
7
|
+
|
|
4
8
|
## 0.16.0 (2026-06-08)
|
|
5
9
|
- [#71](https://github.com/codeocean/codeocean-sdk-python/pull/71) feat: add Git sync support for capsules and pipelines
|
|
6
10
|
- **Minimum Code Ocean platform version updated to `4.6.0`.**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: codeocean
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Code Ocean Python SDK
|
|
5
5
|
Project-URL: Homepage, https://github.com/codeocean/codeocean-sdk-python
|
|
6
6
|
Project-URL: Issues, https://github.com/codeocean/codeocean-sdk-python/issues
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
1. Update the [CHANGELOG.md](CHANGELOG.md).
|
|
6
6
|
1. If the change requires a newer Code Ocean server version, update `MIN_SERVER_VERSION` in [client.py](src/codeocean/client.py).
|
|
7
7
|
1. Commit the updates with the message `Bump version to X.Y.Z`.
|
|
8
|
-
1.
|
|
8
|
+
1. **Review, approve, and merge the PR.** This is a manual step performed by a
|
|
9
|
+
human after CI passes — it is the approval gate for the release.
|
|
10
|
+
|
|
11
|
+
> **⚠️ Automated agents: stop after step 1.** Open the release PR, then hand
|
|
12
|
+
> control back to the user. A human performs this step. Do **not** merge the
|
|
13
|
+
> PR, create the tag, or push the tag on your own — pushing the tag publishes
|
|
14
|
+
> to PyPI and **cannot be undone**. Only continue to the steps below after the
|
|
15
|
+
> user explicitly tells you to proceed.
|
|
9
16
|
1. Locally, sync your clone with GitHub:
|
|
10
17
|
```
|
|
11
18
|
git fetch origin
|
|
@@ -21,5 +28,4 @@
|
|
|
21
28
|
git push origin vX.Y.Z
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
This will trigger a workflow on CircleCI that will
|
|
25
|
-
and publish the new version to PyPI.
|
|
31
|
+
This will trigger a workflow on CircleCI that will publish the new version to PyPI.
|
|
@@ -2,15 +2,23 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from requests_toolbelt.sessions import BaseUrlSession
|
|
5
|
+
from time import sleep, time
|
|
5
6
|
from typing import Optional, Iterator
|
|
6
7
|
|
|
7
8
|
from codeocean.models.capsule import (
|
|
8
9
|
Capsule,
|
|
10
|
+
CapsuleReleaseJob,
|
|
11
|
+
CapsuleReleaseJobStatus,
|
|
9
12
|
CapsuleSearchParams,
|
|
10
13
|
CapsuleSearchResults,
|
|
11
14
|
AppPanel,
|
|
12
15
|
GitSyncResults,
|
|
13
16
|
)
|
|
17
|
+
# Related release models re-exported for convenient access from this module
|
|
18
|
+
from codeocean.models.capsule import ( # noqa: F401
|
|
19
|
+
Version,
|
|
20
|
+
CapsuleReleaseValidationIssues,
|
|
21
|
+
)
|
|
14
22
|
# Re-exports for backward compatibility
|
|
15
23
|
from codeocean.models.capsule import ( # noqa: F401
|
|
16
24
|
CapsuleStatus,
|
|
@@ -98,6 +106,86 @@ class Capsules:
|
|
|
98
106
|
|
|
99
107
|
return GitSyncResults.from_dict(res.json())
|
|
100
108
|
|
|
109
|
+
def release_capsule(self, capsule_id: str) -> CapsuleReleaseJob:
|
|
110
|
+
"""Start releasing a new version of an already-released capsule.
|
|
111
|
+
|
|
112
|
+
Only subsequent releases are supported - the initial release must be done through the
|
|
113
|
+
app. The release runs asynchronously: this returns a CapsuleReleaseJob with a job_id;
|
|
114
|
+
poll it with get_release_job (or wait_until_release_completed) until the status is
|
|
115
|
+
terminal, at which point release_capsule and release_version are populated.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
codeocean.error.Error: 400 if the capsule has never been released; 403 if the
|
|
119
|
+
capsule does not meet the release requirements - the body carried in
|
|
120
|
+
Error.data can be parsed with CapsuleReleaseValidationIssues.from_dict.
|
|
121
|
+
"""
|
|
122
|
+
res = self.client.post(f"{self._route}/{capsule_id}/release")
|
|
123
|
+
|
|
124
|
+
return CapsuleReleaseJob.from_dict(res.json())
|
|
125
|
+
|
|
126
|
+
def get_release_job(self, capsule_id: str, job_id: str) -> CapsuleReleaseJob:
|
|
127
|
+
"""Get the status of a capsule release job.
|
|
128
|
+
|
|
129
|
+
On completion the returned job's release_capsule and release_version identify the
|
|
130
|
+
newly released capsule version.
|
|
131
|
+
"""
|
|
132
|
+
res = self.client.get(f"{self._route}/{capsule_id}/release/{job_id}")
|
|
133
|
+
|
|
134
|
+
return CapsuleReleaseJob.from_dict(res.json())
|
|
135
|
+
|
|
136
|
+
def wait_until_release_completed(
|
|
137
|
+
self,
|
|
138
|
+
capsule_id: str,
|
|
139
|
+
job: CapsuleReleaseJob,
|
|
140
|
+
polling_interval: float = 5,
|
|
141
|
+
timeout: Optional[float] = None,
|
|
142
|
+
) -> CapsuleReleaseJob:
|
|
143
|
+
"""Poll a release job until it reaches a terminal state.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
capsule_id: The capsule (or pipeline) the release job belongs to
|
|
147
|
+
job: The release job to monitor (as returned by release_capsule)
|
|
148
|
+
polling_interval: Time between status checks in seconds (minimum 5 seconds)
|
|
149
|
+
timeout: Maximum time to wait in seconds, or None for no timeout
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Updated release job once it has completed, failed, or been canceled
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If polling_interval < 5 or timeout constraints are violated
|
|
156
|
+
TimeoutError: If the job doesn't reach a terminal state within the timeout period
|
|
157
|
+
"""
|
|
158
|
+
if polling_interval < 5:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Polling interval {polling_interval} should be greater than or equal to 5"
|
|
161
|
+
)
|
|
162
|
+
if timeout is not None and timeout < polling_interval:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"Timeout {timeout} should be greater than or equal to polling interval {polling_interval}"
|
|
165
|
+
)
|
|
166
|
+
if timeout is not None and timeout < 0:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"Timeout {timeout} should be greater than or equal to 0 (seconds), or None"
|
|
169
|
+
)
|
|
170
|
+
terminal = [
|
|
171
|
+
CapsuleReleaseJobStatus.Completed,
|
|
172
|
+
CapsuleReleaseJobStatus.Failed,
|
|
173
|
+
CapsuleReleaseJobStatus.Canceled,
|
|
174
|
+
]
|
|
175
|
+
t0 = time()
|
|
176
|
+
while True:
|
|
177
|
+
current = self.get_release_job(capsule_id, job.job_id)
|
|
178
|
+
|
|
179
|
+
if current.status in terminal:
|
|
180
|
+
return current
|
|
181
|
+
|
|
182
|
+
if timeout is not None and (time() - t0) > timeout:
|
|
183
|
+
raise TimeoutError(
|
|
184
|
+
f"Release job {job.job_id} did not complete within {timeout} seconds"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
sleep(polling_interval)
|
|
188
|
+
|
|
101
189
|
def archive_capsule(self, capsule_id: str, archive: bool):
|
|
102
190
|
"""Archive or unarchive a capsule to control its visibility and accessibility."""
|
|
103
191
|
self.client.patch(
|
|
@@ -38,7 +38,7 @@ class CodeOcean:
|
|
|
38
38
|
agent_id: Optional[str] = None
|
|
39
39
|
|
|
40
40
|
# Minimum server version required by this SDK
|
|
41
|
-
MIN_SERVER_VERSION = "4.
|
|
41
|
+
MIN_SERVER_VERSION = "4.8.0"
|
|
42
42
|
|
|
43
43
|
def __post_init__(self):
|
|
44
44
|
self.session = BaseUrlSession(base_url=f"{self.domain}/api/v1/")
|
|
@@ -15,6 +15,17 @@ class CapsuleStatus(StrEnum):
|
|
|
15
15
|
Release = "release"
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class CapsuleReleaseJobStatus(StrEnum):
|
|
19
|
+
"""Status of an asynchronous capsule or pipeline release job."""
|
|
20
|
+
|
|
21
|
+
Created = "created"
|
|
22
|
+
Started = "started"
|
|
23
|
+
Completed = "completed"
|
|
24
|
+
Failed = "failed"
|
|
25
|
+
Canceled = "canceled"
|
|
26
|
+
Canceling = "canceling"
|
|
27
|
+
|
|
28
|
+
|
|
18
29
|
class CapsuleSortBy(StrEnum):
|
|
19
30
|
"""Fields available for sorting capsule search results."""
|
|
20
31
|
|
|
@@ -78,6 +89,22 @@ class OriginalCapsuleInfo:
|
|
|
78
89
|
)
|
|
79
90
|
|
|
80
91
|
|
|
92
|
+
@dataclass_json
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Version:
|
|
95
|
+
"""A released version of a capsule or pipeline."""
|
|
96
|
+
|
|
97
|
+
major_version: int = dataclass_field(
|
|
98
|
+
metadata={"description": "Major version number of the release"},
|
|
99
|
+
)
|
|
100
|
+
minor_version: int = dataclass_field(
|
|
101
|
+
metadata={"description": "Minor version number of the release"},
|
|
102
|
+
)
|
|
103
|
+
release_time: int = dataclass_field(
|
|
104
|
+
metadata={"description": "Release time (int64 timestamp, seconds)"},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
81
108
|
@dataclass_json
|
|
82
109
|
@dataclass(frozen=True)
|
|
83
110
|
class Capsule:
|
|
@@ -151,10 +178,10 @@ class Capsule:
|
|
|
151
178
|
"verified_timestamp"
|
|
152
179
|
},
|
|
153
180
|
)
|
|
154
|
-
versions: Optional[list[
|
|
181
|
+
versions: Optional[list[Version]] = dataclass_field(
|
|
155
182
|
default=None,
|
|
156
183
|
metadata={
|
|
157
|
-
"description": "Capsule versions with
|
|
184
|
+
"description": "Capsule versions with major and minor version, and release time"
|
|
158
185
|
},
|
|
159
186
|
)
|
|
160
187
|
|
|
@@ -178,6 +205,94 @@ class GitSyncResults:
|
|
|
178
205
|
)
|
|
179
206
|
|
|
180
207
|
|
|
208
|
+
@dataclass_json
|
|
209
|
+
@dataclass(frozen=True)
|
|
210
|
+
class CapsuleReleaseJob:
|
|
211
|
+
"""An asynchronous capsule or pipeline release job.
|
|
212
|
+
|
|
213
|
+
Returned when a release is started (``release_capsule`` / ``release_pipeline``) and when
|
|
214
|
+
its status is polled (``get_release_job``). The release runs asynchronously - poll the job
|
|
215
|
+
until ``status`` is terminal (completed / failed / canceled). On completion,
|
|
216
|
+
``release_capsule`` and ``release_version`` identify the newly released capsule version.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
job_id: str = dataclass_field(
|
|
220
|
+
metadata={"description": "ID of the release job, used to poll its status"},
|
|
221
|
+
)
|
|
222
|
+
status: CapsuleReleaseJobStatus = dataclass_field(
|
|
223
|
+
metadata={"description": "Current status of the release job"},
|
|
224
|
+
)
|
|
225
|
+
started: Optional[int] = dataclass_field(
|
|
226
|
+
default=None,
|
|
227
|
+
metadata={"description": "Job start time (int64 timestamp, seconds)"},
|
|
228
|
+
)
|
|
229
|
+
duration: Optional[int] = dataclass_field(
|
|
230
|
+
default=None,
|
|
231
|
+
metadata={"description": "Job duration in seconds"},
|
|
232
|
+
)
|
|
233
|
+
release_capsule: Optional[str] = dataclass_field(
|
|
234
|
+
default=None,
|
|
235
|
+
metadata={"description": "ID of the published (release) capsule, set once the job completes"},
|
|
236
|
+
)
|
|
237
|
+
release_version: Optional[Version] = dataclass_field(
|
|
238
|
+
default=None,
|
|
239
|
+
metadata={"description": "The newly released version, set once the job completes"},
|
|
240
|
+
)
|
|
241
|
+
error: Optional[str] = dataclass_field(
|
|
242
|
+
default=None,
|
|
243
|
+
metadata={"description": "Error message when the job failed"},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass_json
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class CapsuleReleaseValidationIssues:
|
|
250
|
+
"""Release requirements that were not met, returned with a 403 from the release endpoints.
|
|
251
|
+
|
|
252
|
+
Each flag is present and ``True`` only when its requirement is not met (a missing flag
|
|
253
|
+
means that requirement is satisfied). Because the SDK raises ``codeocean.error.Error`` on a
|
|
254
|
+
403, these are reachable via ``Error.data`` - e.g.
|
|
255
|
+
``CapsuleReleaseValidationIssues.from_dict(err.data)``.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
missing_reproducible_run: Optional[bool] = dataclass_field(
|
|
259
|
+
default=None,
|
|
260
|
+
metadata={"description": "The capsule has no completed reproducible run"},
|
|
261
|
+
)
|
|
262
|
+
uncommitted_files: Optional[bool] = dataclass_field(
|
|
263
|
+
default=None,
|
|
264
|
+
metadata={"description": "The capsule has uncommitted files"},
|
|
265
|
+
)
|
|
266
|
+
missing_metadata: Optional[bool] = dataclass_field(
|
|
267
|
+
default=None,
|
|
268
|
+
metadata={"description": "Required metadata is missing"},
|
|
269
|
+
)
|
|
270
|
+
non_default_branch: Optional[bool] = dataclass_field(
|
|
271
|
+
default=None,
|
|
272
|
+
metadata={"description": "The capsule is not on its default branch"},
|
|
273
|
+
)
|
|
274
|
+
git_out_of_sync: Optional[bool] = dataclass_field(
|
|
275
|
+
default=None,
|
|
276
|
+
metadata={"description": "The capsule is out of sync with its external Git remote"},
|
|
277
|
+
)
|
|
278
|
+
unreleased_pipeline_capsules: Optional[bool] = dataclass_field(
|
|
279
|
+
default=None,
|
|
280
|
+
metadata={"description": "One or more capsules referenced by the pipeline are not released"},
|
|
281
|
+
)
|
|
282
|
+
missing_release_functionality: Optional[bool] = dataclass_field(
|
|
283
|
+
default=None,
|
|
284
|
+
metadata={"description": "Required release functionality is missing"},
|
|
285
|
+
)
|
|
286
|
+
invalid_app_panel: Optional[bool] = dataclass_field(
|
|
287
|
+
default=None,
|
|
288
|
+
metadata={"description": "The app panel is invalid"},
|
|
289
|
+
)
|
|
290
|
+
unreleased_post_run_capsule: Optional[bool] = dataclass_field(
|
|
291
|
+
default=None,
|
|
292
|
+
metadata={"description": "The post-run capsule is not released"},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
181
296
|
@dataclass_json
|
|
182
297
|
@dataclass(frozen=True)
|
|
183
298
|
class CapsuleSearchParams:
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from typing import Iterator
|
|
4
|
+
from typing import Iterator, Optional
|
|
5
5
|
from requests_toolbelt.sessions import BaseUrlSession
|
|
6
6
|
|
|
7
7
|
from codeocean.capsule import Capsules
|
|
8
8
|
from codeocean.models.capsule import (
|
|
9
9
|
Capsule,
|
|
10
|
+
CapsuleReleaseJob,
|
|
10
11
|
CapsuleSearchParams,
|
|
11
12
|
CapsuleSearchResults,
|
|
12
13
|
AppPanel,
|
|
@@ -67,6 +68,44 @@ class Pipelines:
|
|
|
67
68
|
"""Sync a pipeline with its linked external Git repository."""
|
|
68
69
|
return self._capsules.sync_capsule(pipeline_id)
|
|
69
70
|
|
|
71
|
+
def release_pipeline(self, pipeline_id: str) -> CapsuleReleaseJob:
|
|
72
|
+
"""Start releasing a new version of an already-released pipeline.
|
|
73
|
+
|
|
74
|
+
Only subsequent releases are supported - the initial release must be done through the
|
|
75
|
+
app. The release runs asynchronously: this returns a CapsuleReleaseJob with a job_id;
|
|
76
|
+
poll it with get_release_job (or wait_until_release_completed) until the status is
|
|
77
|
+
terminal, at which point release_capsule and release_version are populated.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
codeocean.error.Error: 400 if the pipeline has never been released; 403 if the
|
|
81
|
+
pipeline does not meet the release requirements - the body carried in
|
|
82
|
+
Error.data can be parsed with CapsuleReleaseValidationIssues.from_dict.
|
|
83
|
+
"""
|
|
84
|
+
return self._capsules.release_capsule(pipeline_id)
|
|
85
|
+
|
|
86
|
+
def get_release_job(self, pipeline_id: str, job_id: str) -> CapsuleReleaseJob:
|
|
87
|
+
"""Get the status of a pipeline release job.
|
|
88
|
+
|
|
89
|
+
On completion the returned job's release_capsule and release_version identify the
|
|
90
|
+
newly released pipeline version.
|
|
91
|
+
"""
|
|
92
|
+
return self._capsules.get_release_job(pipeline_id, job_id)
|
|
93
|
+
|
|
94
|
+
def wait_until_release_completed(
|
|
95
|
+
self,
|
|
96
|
+
pipeline_id: str,
|
|
97
|
+
job: CapsuleReleaseJob,
|
|
98
|
+
polling_interval: float = 5,
|
|
99
|
+
timeout: Optional[float] = None,
|
|
100
|
+
) -> CapsuleReleaseJob:
|
|
101
|
+
"""Poll a pipeline release job until it reaches a terminal state.
|
|
102
|
+
|
|
103
|
+
See Capsules.wait_until_release_completed for details.
|
|
104
|
+
"""
|
|
105
|
+
return self._capsules.wait_until_release_completed(
|
|
106
|
+
pipeline_id, job, polling_interval, timeout
|
|
107
|
+
)
|
|
108
|
+
|
|
70
109
|
def archive_pipeline(self, pipeline_id: str, archive: bool):
|
|
71
110
|
"""Archive or unarchive a pipeline to control its visibility and accessibility."""
|
|
72
111
|
return self._capsules.archive_capsule(pipeline_id, archive)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from codeocean.capsule import (
|
|
5
|
+
Capsules,
|
|
6
|
+
CapsuleReleaseJob,
|
|
7
|
+
CapsuleReleaseJobStatus,
|
|
8
|
+
CapsuleReleaseValidationIssues,
|
|
9
|
+
Version,
|
|
10
|
+
)
|
|
11
|
+
from codeocean.pipeline import Pipelines
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestRelease(unittest.TestCase):
|
|
15
|
+
"""Test cases for releasing capsules and pipelines and polling the release job."""
|
|
16
|
+
|
|
17
|
+
def _mock_session(self, post_body=None, get_body=None):
|
|
18
|
+
"""Build a mock session whose post()/get() return responses with the given JSON."""
|
|
19
|
+
session = MagicMock()
|
|
20
|
+
post_response = MagicMock()
|
|
21
|
+
post_response.json.return_value = post_body or {}
|
|
22
|
+
session.post.return_value = post_response
|
|
23
|
+
get_response = MagicMock()
|
|
24
|
+
get_response.json.return_value = get_body or {}
|
|
25
|
+
session.get.return_value = get_response
|
|
26
|
+
return session
|
|
27
|
+
|
|
28
|
+
def test_release_capsule_returns_job(self):
|
|
29
|
+
"""release_capsule posts to the capsule release route and parses the job."""
|
|
30
|
+
session = self._mock_session(post_body={"job_id": "job-1", "status": "created"})
|
|
31
|
+
capsules = Capsules(client=session)
|
|
32
|
+
|
|
33
|
+
result = capsules.release_capsule("cap-123")
|
|
34
|
+
|
|
35
|
+
session.post.assert_called_once_with("capsules/cap-123/release")
|
|
36
|
+
self.assertEqual(
|
|
37
|
+
result,
|
|
38
|
+
CapsuleReleaseJob(job_id="job-1", status=CapsuleReleaseJobStatus.Created),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_release_pipeline_returns_job(self):
|
|
42
|
+
"""release_pipeline posts to the pipeline release route via the capsules delegate."""
|
|
43
|
+
session = self._mock_session(post_body={"job_id": "job-2", "status": "started"})
|
|
44
|
+
pipelines = Pipelines(client=session)
|
|
45
|
+
|
|
46
|
+
result = pipelines.release_pipeline("pipe-456")
|
|
47
|
+
|
|
48
|
+
session.post.assert_called_once_with("pipelines/pipe-456/release")
|
|
49
|
+
self.assertEqual(result.job_id, "job-2")
|
|
50
|
+
self.assertEqual(result.status, CapsuleReleaseJobStatus.Started)
|
|
51
|
+
|
|
52
|
+
def test_get_release_job_completed(self):
|
|
53
|
+
"""A completed job carries release_capsule and release_version."""
|
|
54
|
+
body = {
|
|
55
|
+
"job_id": "job-1",
|
|
56
|
+
"status": "completed",
|
|
57
|
+
"started": 1700000000,
|
|
58
|
+
"duration": 42,
|
|
59
|
+
"release_capsule": "pub-cap-999",
|
|
60
|
+
"release_version": {
|
|
61
|
+
"major_version": 2,
|
|
62
|
+
"minor_version": 5,
|
|
63
|
+
"release_time": 1700000100,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
session = self._mock_session(get_body=body)
|
|
67
|
+
capsules = Capsules(client=session)
|
|
68
|
+
|
|
69
|
+
result = capsules.get_release_job("cap-123", "job-1")
|
|
70
|
+
|
|
71
|
+
session.get.assert_called_once_with("capsules/cap-123/release/job-1")
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
result,
|
|
74
|
+
CapsuleReleaseJob(
|
|
75
|
+
job_id="job-1",
|
|
76
|
+
status=CapsuleReleaseJobStatus.Completed,
|
|
77
|
+
started=1700000000,
|
|
78
|
+
duration=42,
|
|
79
|
+
release_capsule="pub-cap-999",
|
|
80
|
+
release_version=Version(
|
|
81
|
+
major_version=2,
|
|
82
|
+
minor_version=5,
|
|
83
|
+
release_time=1700000100,
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def test_get_release_job_pipeline_route(self):
|
|
89
|
+
"""get_release_job delegates to the pipeline release-job route."""
|
|
90
|
+
session = self._mock_session(get_body={"job_id": "job-2", "status": "started"})
|
|
91
|
+
pipelines = Pipelines(client=session)
|
|
92
|
+
|
|
93
|
+
pipelines.get_release_job("pipe-456", "job-2")
|
|
94
|
+
|
|
95
|
+
session.get.assert_called_once_with("pipelines/pipe-456/release/job-2")
|
|
96
|
+
|
|
97
|
+
def test_wait_until_release_completed_returns_terminal_job(self):
|
|
98
|
+
"""wait_until_release_completed returns immediately once the job is terminal."""
|
|
99
|
+
session = self._mock_session(get_body={"job_id": "job-1", "status": "completed"})
|
|
100
|
+
capsules = Capsules(client=session)
|
|
101
|
+
job = CapsuleReleaseJob(job_id="job-1", status=CapsuleReleaseJobStatus.Created)
|
|
102
|
+
|
|
103
|
+
result = capsules.wait_until_release_completed("cap-123", job)
|
|
104
|
+
|
|
105
|
+
session.get.assert_called_once_with("capsules/cap-123/release/job-1")
|
|
106
|
+
self.assertEqual(result.status, CapsuleReleaseJobStatus.Completed)
|
|
107
|
+
|
|
108
|
+
def test_wait_until_release_completed_rejects_short_interval(self):
|
|
109
|
+
"""A polling interval below 5 seconds is rejected."""
|
|
110
|
+
capsules = Capsules(client=self._mock_session())
|
|
111
|
+
job = CapsuleReleaseJob(job_id="job-1", status=CapsuleReleaseJobStatus.Created)
|
|
112
|
+
|
|
113
|
+
with self.assertRaises(ValueError):
|
|
114
|
+
capsules.wait_until_release_completed("cap-123", job, polling_interval=1)
|
|
115
|
+
|
|
116
|
+
def test_validation_issues_parse_from_403_body(self):
|
|
117
|
+
"""The 403 validation-issues body (issue-only flags) parses into the typed model."""
|
|
118
|
+
body = {"missing_reproducible_run": True, "git_out_of_sync": True}
|
|
119
|
+
|
|
120
|
+
issues = CapsuleReleaseValidationIssues.from_dict(body)
|
|
121
|
+
|
|
122
|
+
self.assertTrue(issues.missing_reproducible_run)
|
|
123
|
+
self.assertTrue(issues.git_out_of_sync)
|
|
124
|
+
# Requirements that are met are simply absent from the body.
|
|
125
|
+
self.assertIsNone(issues.uncommitted_files)
|
|
126
|
+
self.assertIsNone(issues.invalid_app_panel)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|