Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.**
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
88 changes: 88 additions & 0 deletions src/codeocean/capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/codeocean/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand Down
119 changes: 117 additions & 2 deletions src/codeocean/models/capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
},
)

Expand All @@ -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:
Expand Down
41 changes: 40 additions & 1 deletion src/codeocean/pipeline.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading