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.
Files changed (33) hide show
  1. {codeocean-0.16.0 → codeocean-0.17.0}/CHANGELOG.md +4 -0
  2. {codeocean-0.16.0 → codeocean-0.17.0}/PKG-INFO +2 -2
  3. {codeocean-0.16.0 → codeocean-0.17.0}/RELEASE.md +9 -3
  4. {codeocean-0.16.0 → codeocean-0.17.0}/pyproject.toml +1 -1
  5. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/capsule.py +88 -0
  6. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/client.py +1 -1
  7. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/capsule.py +117 -2
  8. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/pipeline.py +40 -1
  9. codeocean-0.17.0/tests/test_release.py +126 -0
  10. {codeocean-0.16.0 → codeocean-0.17.0}/.flake8 +0 -0
  11. {codeocean-0.16.0 → codeocean-0.17.0}/.gitignore +0 -0
  12. {codeocean-0.16.0 → codeocean-0.17.0}/LICENSE +0 -0
  13. {codeocean-0.16.0 → codeocean-0.17.0}/README.md +0 -0
  14. {codeocean-0.16.0 → codeocean-0.17.0}/examples/create_data_asset.py +0 -0
  15. {codeocean-0.16.0 → codeocean-0.17.0}/examples/run_capsule.py +0 -0
  16. {codeocean-0.16.0 → codeocean-0.17.0}/examples/run_pipeline.py +0 -0
  17. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/__init__.py +0 -0
  18. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/components.py +0 -0
  19. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/computation.py +0 -0
  20. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/custom_metadata.py +0 -0
  21. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/data_asset.py +0 -0
  22. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/enum.py +0 -0
  23. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/error.py +0 -0
  24. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/__init__.py +0 -0
  25. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/components.py +0 -0
  26. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/computation.py +0 -0
  27. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/data_asset.py +0 -0
  28. {codeocean-0.16.0 → codeocean-0.17.0}/src/codeocean/models/folder.py +0 -0
  29. {codeocean-0.16.0 → codeocean-0.17.0}/tests/__init__.py +0 -0
  30. {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_client.py +0 -0
  31. {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_error.py +0 -0
  32. {codeocean-0.16.0 → codeocean-0.17.0}/tests/test_git_sync.py +0 -0
  33. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: codeocean
3
- Version: 0.16.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. Merge the PR.
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 generate a GitHub release
25
- and publish the new version to PyPI.
31
+ This will trigger a workflow on CircleCI that will publish the new version to PyPI.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codeocean"
7
- version = "0.16.0"
7
+ version = "0.17.0"
8
8
  authors = [
9
9
  { name="Code Ocean", email="dev@codeocean.com" },
10
10
  ]
@@ -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.6.0"
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[dict]] = dataclass_field(
181
+ versions: Optional[list[Version]] = dataclass_field(
155
182
  default=None,
156
183
  metadata={
157
- "description": "Capsule versions with major_version, minor_version, release_time, and DOI"
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