diff --git a/CHANGELOG.md b/CHANGELOG.md index 5790ba6..028eb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ CHANGELOG ========= +## 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) - [#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..82e8b8a 100644 --- a/src/codeocean/capsule.py +++ b/src/codeocean/capsule.py @@ -2,15 +2,23 @@ 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, + 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 CapsuleStatus, @@ -98,6 +106,86 @@ def sync_capsule(self, capsule_id: str) -> GitSyncResults: return GitSyncResults.from_dict(res.json()) + 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: 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 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.""" 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..fd62400 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,22 @@ 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)"}, + ) + + @dataclass_json @dataclass(frozen=True) class Capsule: @@ -151,10 +178,10 @@ 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" + "description": "Capsule versions with major and minor version, and release time" }, ) @@ -178,6 +205,94 @@ class GitSyncResults: ) +@dataclass_json +@dataclass(frozen=True) +class CapsuleReleaseJob: + """An asynchronous capsule or pipeline release job. + + 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"}, + ) + status: CapsuleReleaseJobStatus = dataclass_field( + metadata={"description": "Current status of the release job"}, + ) + started: Optional[int] = dataclass_field( + default=None, + metadata={"description": "Job start time (int64 timestamp, seconds)"}, + ) + duration: Optional[int] = dataclass_field( + default=None, + 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 CapsuleReleaseValidationIssues: + """Release requirements that were not met, returned with a 403 from the release endpoints. + + 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)``. + """ + + missing_reproducible_run: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "The capsule has no completed reproducible run"}, + ) + uncommitted_files: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "The capsule has uncommitted files"}, + ) + missing_metadata: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Required metadata is missing"}, + ) + non_default_branch: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "The capsule is not on its default branch"}, + ) + git_out_of_sync: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "The capsule is out of sync with its external Git remote"}, + ) + unreleased_pipeline_capsules: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "One or more capsules referenced by the pipeline are not released"}, + ) + missing_release_functionality: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "Required release functionality is missing"}, + ) + invalid_app_panel: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "The app panel is invalid"}, + ) + unreleased_post_run_capsule: Optional[bool] = dataclass_field( + default=None, + metadata={"description": "The post-run capsule is not released"}, + ) + + @dataclass_json @dataclass(frozen=True) class CapsuleSearchParams: diff --git a/src/codeocean/pipeline.py b/src/codeocean/pipeline.py index 79c5d36..447a8da 100644 --- a/src/codeocean/pipeline.py +++ b/src/codeocean/pipeline.py @@ -1,12 +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, + CapsuleReleaseJob, CapsuleSearchParams, CapsuleSearchResults, AppPanel, @@ -67,6 +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) -> 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: 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 new file mode 100644 index 0000000..213e0a3 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,126 @@ +import unittest +from unittest.mock import MagicMock + +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 and polling the release job.""" + + 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() + 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_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 = { + "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": 1700000100, + }, + } + session = self._mock_session(get_body=body) + capsules = Capsules(client=session) + + result = capsules.get_release_job("cap-123", "job-1") + + session.get.assert_called_once_with("capsules/cap-123/release/job-1") + self.assertEqual( + result, + CapsuleReleaseJob( + job_id="job-1", + status=CapsuleReleaseJobStatus.Completed, + started=1700000000, + duration=42, + release_capsule="pub-cap-999", + release_version=Version( + major_version=2, + minor_version=5, + release_time=1700000100, + ), + ), + ) + + 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.wait_until_release_completed("cap-123", job) + + session.get.assert_called_once_with("capsules/cap-123/release/job-1") + self.assertEqual(result.status, CapsuleReleaseJobStatus.Completed) + + 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) + + with self.assertRaises(ValueError): + capsules.wait_until_release_completed("cap-123", job, polling_interval=1) + + 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)