From a8146790eb09239bce58acb859217b4bca96d517 Mon Sep 17 00:00:00 2001 From: Stephen Date: Thu, 13 Aug 2026 16:24:03 +0300 Subject: [PATCH 1/3] feat: add public release capsule/pipeline API Add release_capsule / release_pipeline SDK methods wrapping the new POST /capsules/{id}/release and POST /pipelines/{id}/release endpoints, plus CapsuleReleaseResults / ReleaseVersion models. Bump version to 0.17.0 and MIN_SERVER_VERSION to 4.8.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++ pyproject.toml | 2 +- src/codeocean/capsule.py | 13 ++++ src/codeocean/client.py | 2 +- src/codeocean/models/capsule.py | 84 +++++++++++++++++++++++++ src/codeocean/pipeline.py | 10 +++ tests/test_release.py | 108 ++++++++++++++++++++++++++++++++ 7 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 tests/test_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5790ba6..083b227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ CHANGELOG ========= +## 0.17.0 (2026-08-13) +- feat: add release support for capsules and pipelines (`release_capsule` / `release_pipeline`) +- **Minimum Code Ocean platform version updated to `4.8.0`.** + ## 0.16.0 (2026-06-08) - [#71](https://github.com/codeocean/codeocean-sdk-python/pull/71) feat: add Git sync support for capsules and pipelines - **Minimum Code Ocean platform version updated to `4.6.0`.** diff --git a/pyproject.toml b/pyproject.toml index 4195e98..a56cfb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "codeocean" -version = "0.16.0" +version = "0.17.0" authors = [ { name="Code Ocean", email="dev@codeocean.com" }, ] diff --git a/src/codeocean/capsule.py b/src/codeocean/capsule.py index c8c44e0..261e9a1 100644 --- a/src/codeocean/capsule.py +++ b/src/codeocean/capsule.py @@ -6,6 +6,7 @@ from codeocean.models.capsule import ( Capsule, + CapsuleReleaseResults, CapsuleSearchParams, CapsuleSearchResults, AppPanel, @@ -13,6 +14,7 @@ ) # Re-exports for backward compatibility from codeocean.models.capsule import ( # noqa: F401 + ReleaseVersion, CapsuleStatus, CapsuleSortBy, OriginalCapsuleInfo, @@ -98,6 +100,17 @@ def sync_capsule(self, capsule_id: str) -> GitSyncResults: return GitSyncResults.from_dict(res.json()) + def release_capsule(self, capsule_id: str) -> CapsuleReleaseResults: + """Release a new version of an already-released capsule. + + Only subsequent releases are supported - the initial release must be done through + the app. The release runs asynchronously; poll the release capsule and watch for a + version higher than the returned release_version to know when the new release is ready. + """ + res = self.client.post(f"{self._route}/{capsule_id}/release") + + return CapsuleReleaseResults.from_dict(res.json()) + def archive_capsule(self, capsule_id: str, archive: bool): """Archive or unarchive a capsule to control its visibility and accessibility.""" self.client.patch( diff --git a/src/codeocean/client.py b/src/codeocean/client.py index 71ad38a..ea6955d 100644 --- a/src/codeocean/client.py +++ b/src/codeocean/client.py @@ -38,7 +38,7 @@ class CodeOcean: agent_id: Optional[str] = None # Minimum server version required by this SDK - MIN_SERVER_VERSION = "4.6.0" + MIN_SERVER_VERSION = "4.8.0" def __post_init__(self): self.session = BaseUrlSession(base_url=f"{self.domain}/api/v1/") diff --git a/src/codeocean/models/capsule.py b/src/codeocean/models/capsule.py index 2d8c9bc..0ebcd6e 100644 --- a/src/codeocean/models/capsule.py +++ b/src/codeocean/models/capsule.py @@ -178,6 +178,90 @@ class GitSyncResults: ) +@dataclass_json +@dataclass(frozen=True) +class ReleaseVersion: + """A released version of a capsule or pipeline.""" + + major_version: int = dataclass_field( + default=0, + metadata={"description": "Major version number of the release"}, + ) + minor_version: int = dataclass_field( + default=0, + metadata={"description": "Minor version number of the release"}, + ) + release_time: int = dataclass_field( + default=0, + metadata={"description": "Unix timestamp (seconds) when the version was released"}, + ) + doi: Optional[str] = dataclass_field( + default=None, + metadata={"description": "Digital Object Identifier of the release, if one was assigned"}, + ) + + +@dataclass_json +@dataclass(frozen=True) +class CapsuleReleaseResults: + """Results of releasing a new version of an already-released capsule or pipeline. + + Each boolean reflects a release-validation check. The release runs asynchronously: + ``release_capsule`` is the stable published capsule ID, and ``release_version`` is the + published capsule's current latest version, to be used as a baseline for polling - the + new release is ready once a version higher than this one appears. + """ + + reproducible_run: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the capsule has a completed reproducible run"}, + ) + all_tracked: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether all files are tracked"}, + ) + metadata: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the required metadata is present"}, + ) + no_credentials: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the capsule is free of embedded credentials"}, + ) + default_branch: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the capsule is on its default branch"}, + ) + git_sync: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the capsule is in sync with its external Git remote"}, + ) + pipeline_capsules_released: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether all capsules referenced by the pipeline are released"}, + ) + release_functionality: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the release functionality checks pass"}, + ) + valid_app_panel: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the app panel is valid"}, + ) + post_run_capsule_released: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Whether the post-run capsule, if any, is released"}, + ) + release_capsule: Optional[str] = dataclass_field( + default=None, + metadata={"description": "ID of the published (release) capsule, stable across releases"}, + ) + release_version: Optional[ReleaseVersion] = dataclass_field( + default=None, + metadata={"description": "The release capsule's current latest version, a baseline for polling"}, + ) + + @dataclass_json @dataclass(frozen=True) class CapsuleSearchParams: diff --git a/src/codeocean/pipeline.py b/src/codeocean/pipeline.py index 79c5d36..1111678 100644 --- a/src/codeocean/pipeline.py +++ b/src/codeocean/pipeline.py @@ -7,6 +7,7 @@ from codeocean.capsule import Capsules from codeocean.models.capsule import ( Capsule, + CapsuleReleaseResults, CapsuleSearchParams, CapsuleSearchResults, AppPanel, @@ -67,6 +68,15 @@ def sync_pipeline(self, pipeline_id: str) -> GitSyncResults: """Sync a pipeline with its linked external Git repository.""" return self._capsules.sync_capsule(pipeline_id) + def release_pipeline(self, pipeline_id: str) -> CapsuleReleaseResults: + """Release a new version of an already-released pipeline. + + Only subsequent releases are supported - the initial release must be done through + the app. The release runs asynchronously; poll the release pipeline and watch for a + version higher than the returned release_version to know when the new release is ready. + """ + return self._capsules.release_capsule(pipeline_id) + def archive_pipeline(self, pipeline_id: str, archive: bool): """Archive or unarchive a pipeline to control its visibility and accessibility.""" return self._capsules.archive_capsule(pipeline_id, archive) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..0e5d894 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,108 @@ +import unittest +from unittest.mock import MagicMock + +from codeocean.capsule import Capsules, CapsuleReleaseResults, ReleaseVersion +from codeocean.pipeline import Pipelines + + +class TestRelease(unittest.TestCase): + """Test cases for releasing capsules and pipelines.""" + + def _mock_session(self, body): + """Build a mock session whose post() returns a response with the given JSON body.""" + session = MagicMock() + response = MagicMock() + response.json.return_value = body + session.post.return_value = response + return session + + def test_release_capsule_returns_results(self): + """release_capsule posts to the capsule release route and parses the results.""" + body = { + "reproducible_run": True, + "all_tracked": True, + "metadata": True, + "no_credentials": True, + "default_branch": True, + "git_sync": True, + "pipeline_capsules_released": True, + "release_functionality": True, + "valid_app_panel": True, + "post_run_capsule_released": True, + "release_capsule": "pub-cap-999", + "release_version": { + "major_version": 2, + "minor_version": 5, + "release_time": 1700000000, + "doi": "10.1234/example", + }, + } + session = self._mock_session(body) + capsules = Capsules(client=session) + + result = capsules.release_capsule("cap-123") + + session.post.assert_called_once_with("capsules/cap-123/release") + self.assertEqual( + result, + CapsuleReleaseResults( + reproducible_run=True, + all_tracked=True, + metadata=True, + no_credentials=True, + default_branch=True, + git_sync=True, + pipeline_capsules_released=True, + release_functionality=True, + valid_app_panel=True, + post_run_capsule_released=True, + release_capsule="pub-cap-999", + release_version=ReleaseVersion( + major_version=2, + minor_version=5, + release_time=1700000000, + doi="10.1234/example", + ), + ), + ) + + def test_release_capsule_empty_body_defaults(self): + """An empty body (all fields optional on the server) deserializes to None defaults.""" + session = self._mock_session({}) + capsules = Capsules(client=session) + + result = capsules.release_capsule("cap-123") + + self.assertEqual(result, CapsuleReleaseResults()) + self.assertIsNone(result.release_capsule) + self.assertIsNone(result.release_version) + + def test_release_pipeline_returns_results(self): + """release_pipeline posts to the pipeline release route via the capsules delegate.""" + body = { + "reproducible_run": True, + "release_capsule": "pub-pipe-777", + "release_version": { + "major_version": 1, + "minor_version": 0, + "release_time": 1699999999, + }, + } + session = self._mock_session(body) + pipelines = Pipelines(client=session) + + result = pipelines.release_pipeline("pipe-456") + + session.post.assert_called_once_with("pipelines/pipe-456/release") + self.assertEqual( + result, + CapsuleReleaseResults( + reproducible_run=True, + release_capsule="pub-pipe-777", + release_version=ReleaseVersion( + major_version=1, + minor_version=0, + release_time=1699999999, + ), + ), + ) From 5ad062cf6aa9cee8662de00a303ae7e18d17a1bd Mon Sep 17 00:00:00 2001 From: Stephen Date: Fri, 14 Aug 2026 14:03:53 +0300 Subject: [PATCH 2/3] refactor: model release as async job per updated 4.8 contract Address PR review: the release API returns an asynchronous CapsuleReleaseJob (job_id + status), not a flags object, and is polled via GET .../release/{job_id}. Unmet requirements come back as 403 CapsuleReleaseValidationIssues. - Replace CapsuleReleaseResults/ReleaseVersion with CapsuleReleaseJob, CapsuleReleaseJobStatus, and a shared Version model (now also used by Capsule.versions); drop the removed no_credentials check. - Add CapsuleReleaseValidationIssues for the 403 error-path body. - Add get_release_job and wait_until_release_completed (plus pipeline delegates); release_capsule/release_pipeline now return the job. - Move new types out of the backward-compat re-export block. - Rewrite tests for the job response, completed-job GET, and validation issues. - Add PR link to the CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- src/codeocean/capsule.py | 91 ++++++++++++++++-- src/codeocean/models/capsule.py | 135 +++++++++++++++++---------- src/codeocean/pipeline.py | 43 +++++++-- tests/test_release.py | 160 ++++++++++++++++++-------------- 5 files changed, 295 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 083b227..8242149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ CHANGELOG ========= ## 0.17.0 (2026-08-13) -- feat: add release support for capsules and pipelines (`release_capsule` / `release_pipeline`) +- [#75](https://github.com/codeocean/codeocean-sdk-python/pull/75) feat: add release support for capsules and pipelines (`release_capsule` / `release_pipeline`, `get_release_job`, `wait_until_release_completed`) - **Minimum Code Ocean platform version updated to `4.8.0`.** ## 0.16.0 (2026-06-08) diff --git a/src/codeocean/capsule.py b/src/codeocean/capsule.py index 261e9a1..82e8b8a 100644 --- a/src/codeocean/capsule.py +++ b/src/codeocean/capsule.py @@ -2,19 +2,25 @@ from dataclasses import dataclass from requests_toolbelt.sessions import BaseUrlSession +from time import sleep, time from typing import Optional, Iterator from codeocean.models.capsule import ( Capsule, - CapsuleReleaseResults, + CapsuleReleaseJob, + CapsuleReleaseJobStatus, CapsuleSearchParams, CapsuleSearchResults, AppPanel, GitSyncResults, ) +# Related release models re-exported for convenient access from this module +from codeocean.models.capsule import ( # noqa: F401 + Version, + CapsuleReleaseValidationIssues, +) # Re-exports for backward compatibility from codeocean.models.capsule import ( # noqa: F401 - ReleaseVersion, CapsuleStatus, CapsuleSortBy, OriginalCapsuleInfo, @@ -100,16 +106,85 @@ def sync_capsule(self, capsule_id: str) -> GitSyncResults: return GitSyncResults.from_dict(res.json()) - def release_capsule(self, capsule_id: str) -> CapsuleReleaseResults: - """Release a new version of an already-released capsule. + def release_capsule(self, capsule_id: str) -> CapsuleReleaseJob: + """Start releasing a new version of an already-released capsule. - Only subsequent releases are supported - the initial release must be done through - the app. The release runs asynchronously; poll the release capsule and watch for a - version higher than the returned release_version to know when the new release is ready. + Only subsequent releases are supported - the initial release must be done through the + app. The release runs asynchronously: this returns a CapsuleReleaseJob with a job_id; + poll it with get_release_job (or wait_until_release_completed) until the status is + terminal, at which point release_capsule and release_version are populated. + + Raises: + codeocean.error.Error: 400 if the capsule has never been released; 403 if the + capsule does not meet the release requirements - the body carried in + Error.data can be parsed with CapsuleReleaseValidationIssues.from_dict. """ res = self.client.post(f"{self._route}/{capsule_id}/release") - return CapsuleReleaseResults.from_dict(res.json()) + return CapsuleReleaseJob.from_dict(res.json()) + + def get_release_job(self, capsule_id: str, job_id: str) -> CapsuleReleaseJob: + """Get the status of a capsule release job. + + On completion the returned job's release_capsule and release_version identify the + newly released capsule version. + """ + res = self.client.get(f"{self._route}/{capsule_id}/release/{job_id}") + + return CapsuleReleaseJob.from_dict(res.json()) + + def wait_until_release_completed( + self, + capsule_id: str, + job: CapsuleReleaseJob, + polling_interval: float = 5, + timeout: Optional[float] = None, + ) -> CapsuleReleaseJob: + """Poll a release job until it reaches a terminal state. + + Args: + capsule_id: The capsule (or pipeline) the release job belongs to + job: The release job to monitor (as returned by release_capsule) + polling_interval: Time between status checks in seconds (minimum 5 seconds) + timeout: Maximum time to wait in seconds, or None for no timeout + + Returns: + Updated release job once it has completed, failed, or been canceled + + Raises: + ValueError: If polling_interval < 5 or timeout constraints are violated + TimeoutError: If the job doesn't reach a terminal state within the timeout period + """ + if polling_interval < 5: + raise ValueError( + f"Polling interval {polling_interval} should be greater than or equal to 5" + ) + if timeout is not None and timeout < polling_interval: + raise ValueError( + f"Timeout {timeout} should be greater than or equal to polling interval {polling_interval}" + ) + if timeout is not None and timeout < 0: + raise ValueError( + f"Timeout {timeout} should be greater than or equal to 0 (seconds), or None" + ) + terminal = [ + CapsuleReleaseJobStatus.Completed, + CapsuleReleaseJobStatus.Failed, + CapsuleReleaseJobStatus.Canceled, + ] + t0 = time() + while True: + current = self.get_release_job(capsule_id, job.job_id) + + if current.status in terminal: + return current + + if timeout is not None and (time() - t0) > timeout: + raise TimeoutError( + f"Release job {job.job_id} did not complete within {timeout} seconds" + ) + + sleep(polling_interval) def archive_capsule(self, capsule_id: str, archive: bool): """Archive or unarchive a capsule to control its visibility and accessibility.""" diff --git a/src/codeocean/models/capsule.py b/src/codeocean/models/capsule.py index 0ebcd6e..9ec950a 100644 --- a/src/codeocean/models/capsule.py +++ b/src/codeocean/models/capsule.py @@ -15,6 +15,17 @@ class CapsuleStatus(StrEnum): Release = "release" +class CapsuleReleaseJobStatus(StrEnum): + """Status of an asynchronous capsule or pipeline release job.""" + + Created = "created" + Started = "started" + Completed = "completed" + Failed = "failed" + Canceled = "canceled" + Canceling = "canceling" + + class CapsuleSortBy(StrEnum): """Fields available for sorting capsule search results.""" @@ -78,6 +89,26 @@ class OriginalCapsuleInfo: ) +@dataclass_json +@dataclass(frozen=True) +class Version: + """A released version of a capsule or pipeline.""" + + major_version: int = dataclass_field( + metadata={"description": "Major version number of the release"}, + ) + minor_version: int = dataclass_field( + metadata={"description": "Minor version number of the release"}, + ) + release_time: int = dataclass_field( + metadata={"description": "Release time (int64 timestamp, seconds)"}, + ) + doi: Optional[str] = dataclass_field( + default=None, + metadata={"description": "Digital Object Identifier of the release, if one was assigned"}, + ) + + @dataclass_json @dataclass(frozen=True) class Capsule: @@ -151,7 +182,7 @@ class Capsule: "verified_timestamp" }, ) - versions: Optional[list[dict]] = dataclass_field( + versions: Optional[list[Version]] = dataclass_field( default=None, metadata={ "description": "Capsule versions with major_version, minor_version, release_time, and DOI" @@ -180,85 +211,89 @@ class GitSyncResults: @dataclass_json @dataclass(frozen=True) -class ReleaseVersion: - """A released version of a capsule or pipeline.""" +class CapsuleReleaseJob: + """An asynchronous capsule or pipeline release job. - major_version: int = dataclass_field( - default=0, - metadata={"description": "Major version number of the release"}, + Returned when a release is started (``release_capsule`` / ``release_pipeline``) and when + its status is polled (``get_release_job``). The release runs asynchronously - poll the job + until ``status`` is terminal (completed / failed / canceled). On completion, + ``release_capsule`` and ``release_version`` identify the newly released capsule version. + """ + + job_id: str = dataclass_field( + metadata={"description": "ID of the release job, used to poll its status"}, ) - minor_version: int = dataclass_field( - default=0, - metadata={"description": "Minor version number of the release"}, + status: CapsuleReleaseJobStatus = dataclass_field( + metadata={"description": "Current status of the release job"}, ) - release_time: int = dataclass_field( - default=0, - metadata={"description": "Unix timestamp (seconds) when the version was released"}, + started: Optional[int] = dataclass_field( + default=None, + metadata={"description": "Job start time (int64 timestamp, seconds)"}, ) - doi: Optional[str] = dataclass_field( + duration: Optional[int] = dataclass_field( default=None, - metadata={"description": "Digital Object Identifier of the release, if one was assigned"}, + metadata={"description": "Job duration in seconds"}, + ) + release_capsule: Optional[str] = dataclass_field( + default=None, + metadata={"description": "ID of the published (release) capsule, set once the job completes"}, + ) + release_version: Optional[Version] = dataclass_field( + default=None, + metadata={"description": "The newly released version, set once the job completes"}, + ) + error: Optional[str] = dataclass_field( + default=None, + metadata={"description": "Error message when the job failed"}, ) @dataclass_json @dataclass(frozen=True) -class CapsuleReleaseResults: - """Results of releasing a new version of an already-released capsule or pipeline. +class CapsuleReleaseValidationIssues: + """Release requirements that were not met, returned with a 403 from the release endpoints. - Each boolean reflects a release-validation check. The release runs asynchronously: - ``release_capsule`` is the stable published capsule ID, and ``release_version`` is the - published capsule's current latest version, to be used as a baseline for polling - the - new release is ready once a version higher than this one appears. + Each flag is present and ``True`` only when its requirement is not met (a missing flag + means that requirement is satisfied). Because the SDK raises ``codeocean.error.Error`` on a + 403, these are reachable via ``Error.data`` - e.g. + ``CapsuleReleaseValidationIssues.from_dict(err.data)``. """ - reproducible_run: Optional[bool] = dataclass_field( - default=None, - metadata={"description": "Whether the capsule has a completed reproducible run"}, - ) - all_tracked: Optional[bool] = dataclass_field( - default=None, - metadata={"description": "Whether all files are tracked"}, - ) - metadata: Optional[bool] = dataclass_field( + missing_reproducible_run: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether the required metadata is present"}, + metadata={"description": "The capsule has no completed reproducible run"}, ) - no_credentials: Optional[bool] = dataclass_field( + uncommitted_files: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether the capsule is free of embedded credentials"}, + metadata={"description": "The capsule has uncommitted files"}, ) - default_branch: Optional[bool] = dataclass_field( + missing_metadata: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether the capsule is on its default branch"}, + metadata={"description": "Required metadata is missing"}, ) - git_sync: Optional[bool] = dataclass_field( + non_default_branch: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether the capsule is in sync with its external Git remote"}, + metadata={"description": "The capsule is not on its default branch"}, ) - pipeline_capsules_released: Optional[bool] = dataclass_field( + git_out_of_sync: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether all capsules referenced by the pipeline are released"}, + metadata={"description": "The capsule is out of sync with its external Git remote"}, ) - release_functionality: Optional[bool] = dataclass_field( + unreleased_pipeline_capsules: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether the release functionality checks pass"}, + metadata={"description": "One or more capsules referenced by the pipeline are not released"}, ) - valid_app_panel: Optional[bool] = dataclass_field( + missing_release_functionality: Optional[bool] = dataclass_field( default=None, - metadata={"description": "Whether the app panel is valid"}, + metadata={"description": "Required release functionality is missing"}, ) - post_run_capsule_released: Optional[bool] = dataclass_field( - default=None, - metadata={"description": "Whether the post-run capsule, if any, is released"}, - ) - release_capsule: Optional[str] = dataclass_field( + invalid_app_panel: Optional[bool] = dataclass_field( default=None, - metadata={"description": "ID of the published (release) capsule, stable across releases"}, + metadata={"description": "The app panel is invalid"}, ) - release_version: Optional[ReleaseVersion] = dataclass_field( + unreleased_post_run_capsule: Optional[bool] = dataclass_field( default=None, - metadata={"description": "The release capsule's current latest version, a baseline for polling"}, + metadata={"description": "The post-run capsule is not released"}, ) diff --git a/src/codeocean/pipeline.py b/src/codeocean/pipeline.py index 1111678..447a8da 100644 --- a/src/codeocean/pipeline.py +++ b/src/codeocean/pipeline.py @@ -1,13 +1,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Iterator +from typing import Iterator, Optional from requests_toolbelt.sessions import BaseUrlSession from codeocean.capsule import Capsules from codeocean.models.capsule import ( Capsule, - CapsuleReleaseResults, + CapsuleReleaseJob, CapsuleSearchParams, CapsuleSearchResults, AppPanel, @@ -68,15 +68,44 @@ def sync_pipeline(self, pipeline_id: str) -> GitSyncResults: """Sync a pipeline with its linked external Git repository.""" return self._capsules.sync_capsule(pipeline_id) - def release_pipeline(self, pipeline_id: str) -> CapsuleReleaseResults: - """Release a new version of an already-released pipeline. + def release_pipeline(self, pipeline_id: str) -> CapsuleReleaseJob: + """Start releasing a new version of an already-released pipeline. - Only subsequent releases are supported - the initial release must be done through - the app. The release runs asynchronously; poll the release pipeline and watch for a - version higher than the returned release_version to know when the new release is ready. + Only subsequent releases are supported - the initial release must be done through the + app. The release runs asynchronously: this returns a CapsuleReleaseJob with a job_id; + poll it with get_release_job (or wait_until_release_completed) until the status is + terminal, at which point release_capsule and release_version are populated. + + Raises: + codeocean.error.Error: 400 if the pipeline has never been released; 403 if the + pipeline does not meet the release requirements - the body carried in + Error.data can be parsed with CapsuleReleaseValidationIssues.from_dict. """ return self._capsules.release_capsule(pipeline_id) + def get_release_job(self, pipeline_id: str, job_id: str) -> CapsuleReleaseJob: + """Get the status of a pipeline release job. + + On completion the returned job's release_capsule and release_version identify the + newly released pipeline version. + """ + return self._capsules.get_release_job(pipeline_id, job_id) + + def wait_until_release_completed( + self, + pipeline_id: str, + job: CapsuleReleaseJob, + polling_interval: float = 5, + timeout: Optional[float] = None, + ) -> CapsuleReleaseJob: + """Poll a pipeline release job until it reaches a terminal state. + + See Capsules.wait_until_release_completed for details. + """ + return self._capsules.wait_until_release_completed( + pipeline_id, job, polling_interval, timeout + ) + def archive_pipeline(self, pipeline_id: str, archive: bool): """Archive or unarchive a pipeline to control its visibility and accessibility.""" return self._capsules.archive_capsule(pipeline_id, archive) diff --git a/tests/test_release.py b/tests/test_release.py index 0e5d894..85af5c4 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,108 +1,128 @@ import unittest from unittest.mock import MagicMock -from codeocean.capsule import Capsules, CapsuleReleaseResults, ReleaseVersion +from codeocean.capsule import ( + Capsules, + CapsuleReleaseJob, + CapsuleReleaseJobStatus, + CapsuleReleaseValidationIssues, + Version, +) from codeocean.pipeline import Pipelines class TestRelease(unittest.TestCase): - """Test cases for releasing capsules and pipelines.""" + """Test cases for releasing capsules and pipelines and polling the release job.""" - def _mock_session(self, body): - """Build a mock session whose post() returns a response with the given JSON body.""" + def _mock_session(self, post_body=None, get_body=None): + """Build a mock session whose post()/get() return responses with the given JSON.""" session = MagicMock() - response = MagicMock() - response.json.return_value = body - session.post.return_value = response + post_response = MagicMock() + post_response.json.return_value = post_body or {} + session.post.return_value = post_response + get_response = MagicMock() + get_response.json.return_value = get_body or {} + session.get.return_value = get_response return session - def test_release_capsule_returns_results(self): - """release_capsule posts to the capsule release route and parses the results.""" + def test_release_capsule_returns_job(self): + """release_capsule posts to the capsule release route and parses the job.""" + session = self._mock_session(post_body={"job_id": "job-1", "status": "created"}) + capsules = Capsules(client=session) + + result = capsules.release_capsule("cap-123") + + session.post.assert_called_once_with("capsules/cap-123/release") + self.assertEqual( + result, + CapsuleReleaseJob(job_id="job-1", status=CapsuleReleaseJobStatus.Created), + ) + + def test_release_pipeline_returns_job(self): + """release_pipeline posts to the pipeline release route via the capsules delegate.""" + session = self._mock_session(post_body={"job_id": "job-2", "status": "started"}) + pipelines = Pipelines(client=session) + + result = pipelines.release_pipeline("pipe-456") + + session.post.assert_called_once_with("pipelines/pipe-456/release") + self.assertEqual(result.job_id, "job-2") + self.assertEqual(result.status, CapsuleReleaseJobStatus.Started) + + def test_get_release_job_completed(self): + """A completed job carries release_capsule and release_version.""" body = { - "reproducible_run": True, - "all_tracked": True, - "metadata": True, - "no_credentials": True, - "default_branch": True, - "git_sync": True, - "pipeline_capsules_released": True, - "release_functionality": True, - "valid_app_panel": True, - "post_run_capsule_released": True, + "job_id": "job-1", + "status": "completed", + "started": 1700000000, + "duration": 42, "release_capsule": "pub-cap-999", "release_version": { "major_version": 2, "minor_version": 5, - "release_time": 1700000000, + "release_time": 1700000100, "doi": "10.1234/example", }, } - session = self._mock_session(body) + session = self._mock_session(get_body=body) capsules = Capsules(client=session) - result = capsules.release_capsule("cap-123") + result = capsules.get_release_job("cap-123", "job-1") - session.post.assert_called_once_with("capsules/cap-123/release") + session.get.assert_called_once_with("capsules/cap-123/release/job-1") self.assertEqual( result, - CapsuleReleaseResults( - reproducible_run=True, - all_tracked=True, - metadata=True, - no_credentials=True, - default_branch=True, - git_sync=True, - pipeline_capsules_released=True, - release_functionality=True, - valid_app_panel=True, - post_run_capsule_released=True, + CapsuleReleaseJob( + job_id="job-1", + status=CapsuleReleaseJobStatus.Completed, + started=1700000000, + duration=42, release_capsule="pub-cap-999", - release_version=ReleaseVersion( + release_version=Version( major_version=2, minor_version=5, - release_time=1700000000, + release_time=1700000100, doi="10.1234/example", ), ), ) - def test_release_capsule_empty_body_defaults(self): - """An empty body (all fields optional on the server) deserializes to None defaults.""" - session = self._mock_session({}) + def test_get_release_job_pipeline_route(self): + """get_release_job delegates to the pipeline release-job route.""" + session = self._mock_session(get_body={"job_id": "job-2", "status": "started"}) + pipelines = Pipelines(client=session) + + pipelines.get_release_job("pipe-456", "job-2") + + session.get.assert_called_once_with("pipelines/pipe-456/release/job-2") + + def test_wait_until_release_completed_returns_terminal_job(self): + """wait_until_release_completed returns immediately once the job is terminal.""" + session = self._mock_session(get_body={"job_id": "job-1", "status": "completed"}) capsules = Capsules(client=session) + job = CapsuleReleaseJob(job_id="job-1", status=CapsuleReleaseJobStatus.Created) - result = capsules.release_capsule("cap-123") + result = capsules.wait_until_release_completed("cap-123", job) - self.assertEqual(result, CapsuleReleaseResults()) - self.assertIsNone(result.release_capsule) - self.assertIsNone(result.release_version) + session.get.assert_called_once_with("capsules/cap-123/release/job-1") + self.assertEqual(result.status, CapsuleReleaseJobStatus.Completed) - def test_release_pipeline_returns_results(self): - """release_pipeline posts to the pipeline release route via the capsules delegate.""" - body = { - "reproducible_run": True, - "release_capsule": "pub-pipe-777", - "release_version": { - "major_version": 1, - "minor_version": 0, - "release_time": 1699999999, - }, - } - session = self._mock_session(body) - pipelines = Pipelines(client=session) + def test_wait_until_release_completed_rejects_short_interval(self): + """A polling interval below 5 seconds is rejected.""" + capsules = Capsules(client=self._mock_session()) + job = CapsuleReleaseJob(job_id="job-1", status=CapsuleReleaseJobStatus.Created) - result = pipelines.release_pipeline("pipe-456") + with self.assertRaises(ValueError): + capsules.wait_until_release_completed("cap-123", job, polling_interval=1) - session.post.assert_called_once_with("pipelines/pipe-456/release") - self.assertEqual( - result, - CapsuleReleaseResults( - reproducible_run=True, - release_capsule="pub-pipe-777", - release_version=ReleaseVersion( - major_version=1, - minor_version=0, - release_time=1699999999, - ), - ), - ) + def test_validation_issues_parse_from_403_body(self): + """The 403 validation-issues body (issue-only flags) parses into the typed model.""" + body = {"missing_reproducible_run": True, "git_out_of_sync": True} + + issues = CapsuleReleaseValidationIssues.from_dict(body) + + self.assertTrue(issues.missing_reproducible_run) + self.assertTrue(issues.git_out_of_sync) + # Requirements that are met are simply absent from the body. + self.assertIsNone(issues.uncommitted_files) + self.assertIsNone(issues.invalid_app_panel) From 7fdb9633384cba571a8380a8d6982210b2d438e2 Mon Sep 17 00:00:00 2001 From: Stephen Date: Fri, 14 Aug 2026 17:28:54 +0300 Subject: [PATCH 3/3] chore: apply PR review polish - Remove doi from Version (not used in VPCs, per review) and drop it from the Capsule.versions description; update the release-job test accordingly. - CHANGELOG: mark 0.17.0 as (TBD) and use the PR title for the entry. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++-- src/codeocean/models/capsule.py | 6 +----- tests/test_release.py | 2 -- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8242149..028eb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ CHANGELOG ========= -## 0.17.0 (2026-08-13) -- [#75](https://github.com/codeocean/codeocean-sdk-python/pull/75) feat: add release support for capsules and pipelines (`release_capsule` / `release_pipeline`, `get_release_job`, `wait_until_release_completed`) +## 0.17.0 (TBD) +- [#75](https://github.com/codeocean/codeocean-sdk-python/pull/75) feat: add public release capsule/pipeline API - **Minimum Code Ocean platform version updated to `4.8.0`.** ## 0.16.0 (2026-06-08) diff --git a/src/codeocean/models/capsule.py b/src/codeocean/models/capsule.py index 9ec950a..fd62400 100644 --- a/src/codeocean/models/capsule.py +++ b/src/codeocean/models/capsule.py @@ -103,10 +103,6 @@ class Version: release_time: int = dataclass_field( metadata={"description": "Release time (int64 timestamp, seconds)"}, ) - doi: Optional[str] = dataclass_field( - default=None, - metadata={"description": "Digital Object Identifier of the release, if one was assigned"}, - ) @dataclass_json @@ -185,7 +181,7 @@ class Capsule: versions: Optional[list[Version]] = dataclass_field( default=None, metadata={ - "description": "Capsule versions with major_version, minor_version, release_time, and DOI" + "description": "Capsule versions with major and minor version, and release time" }, ) diff --git a/tests/test_release.py b/tests/test_release.py index 85af5c4..213e0a3 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -61,7 +61,6 @@ def test_get_release_job_completed(self): "major_version": 2, "minor_version": 5, "release_time": 1700000100, - "doi": "10.1234/example", }, } session = self._mock_session(get_body=body) @@ -82,7 +81,6 @@ def test_get_release_job_completed(self): major_version=2, minor_version=5, release_time=1700000100, - doi="10.1234/example", ), ), )