From f68760132f764093a60402f2358170da2de39c0e Mon Sep 17 00:00:00 2001 From: Zvika Gart Date: Mon, 8 Jun 2026 12:25:52 +0100 Subject: [PATCH] feat: add Git sync support for capsules and pipelines Expose the v4.6 Git sync public API through the SDK via Capsules.sync_capsule and Pipelines.sync_pipeline, returning a GitSyncResults model (pushed, pulled, new_branch). Bump MIN_SERVER_VERSION to 4.6.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/codeocean/capsule.py | 7 +++++ src/codeocean/client.py | 2 +- src/codeocean/models/capsule.py | 19 ++++++++++++++ src/codeocean/pipeline.py | 5 ++++ tests/test_git_sync.py | 46 +++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/test_git_sync.py diff --git a/src/codeocean/capsule.py b/src/codeocean/capsule.py index baabe10..c8c44e0 100644 --- a/src/codeocean/capsule.py +++ b/src/codeocean/capsule.py @@ -9,6 +9,7 @@ CapsuleSearchParams, CapsuleSearchResults, AppPanel, + GitSyncResults, ) # Re-exports for backward compatibility from codeocean.models.capsule import ( # noqa: F401 @@ -91,6 +92,12 @@ def detach_data_assets(self, capsule_id: str, data_assets: list[str]): json=data_assets, ) + def sync_capsule(self, capsule_id: str) -> GitSyncResults: + """Sync a capsule with its linked external Git repository.""" + res = self.client.post(f"{self._route}/{capsule_id}/sync") + + return GitSyncResults.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 f3e86a2..71ad38a 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.3.0" + MIN_SERVER_VERSION = "4.6.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 459d9df..2d8c9bc 100644 --- a/src/codeocean/models/capsule.py +++ b/src/codeocean/models/capsule.py @@ -159,6 +159,25 @@ class Capsule: ) +@dataclass_json +@dataclass(frozen=True) +class GitSyncResults: + """Results of syncing a capsule or pipeline with its external Git remote.""" + + pushed: int = dataclass_field( + default=0, + metadata={"description": "Number of commits pushed to the external Git remote"}, + ) + pulled: int = dataclass_field( + default=0, + metadata={"description": "Number of commits pulled from the external Git remote"}, + ) + new_branch: bool = dataclass_field( + default=False, + metadata={"description": "Whether the current branch was newly created on the external remote by this sync"}, + ) + + @dataclass_json @dataclass(frozen=True) class CapsuleSearchParams: diff --git a/src/codeocean/pipeline.py b/src/codeocean/pipeline.py index b85349c..79c5d36 100644 --- a/src/codeocean/pipeline.py +++ b/src/codeocean/pipeline.py @@ -10,6 +10,7 @@ CapsuleSearchParams, CapsuleSearchResults, AppPanel, + GitSyncResults, ) from codeocean.models.components import Permissions from codeocean.models.computation import Computation @@ -62,6 +63,10 @@ def detach_data_assets(self, pipeline_id: str, data_assets: list[str]): """Detach one or more data assets from a pipeline by their IDs.""" return self._capsules.detach_data_assets(pipeline_id, data_assets) + 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 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_git_sync.py b/tests/test_git_sync.py new file mode 100644 index 0000000..e07027c --- /dev/null +++ b/tests/test_git_sync.py @@ -0,0 +1,46 @@ +import unittest +from unittest.mock import MagicMock + +from codeocean.capsule import Capsules, GitSyncResults +from codeocean.pipeline import Pipelines + + +class TestGitSync(unittest.TestCase): + """Test cases for capsule and pipeline Git sync.""" + + 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_sync_capsule_returns_results(self): + """sync_capsule posts to the capsule sync route and parses the results.""" + session = self._mock_session({"pushed": 2, "pulled": 1, "new_branch": True}) + capsules = Capsules(client=session) + + result = capsules.sync_capsule("cap-123") + + session.post.assert_called_once_with("capsules/cap-123/sync") + self.assertEqual(result, GitSyncResults(pushed=2, pulled=1, new_branch=True)) + + def test_sync_capsule_empty_body_defaults(self): + """An empty body (all fields omitempty on the server) deserializes to zero defaults.""" + session = self._mock_session({}) + capsules = Capsules(client=session) + + result = capsules.sync_capsule("cap-123") + + self.assertEqual(result, GitSyncResults(pushed=0, pulled=0, new_branch=False)) + + def test_sync_pipeline_returns_results(self): + """sync_pipeline posts to the pipeline sync route and parses the results.""" + session = self._mock_session({"pushed": 0, "pulled": 3, "new_branch": False}) + pipelines = Pipelines(client=session) + + result = pipelines.sync_pipeline("pipe-456") + + session.post.assert_called_once_with("pipelines/pipe-456/sync") + self.assertEqual(result, GitSyncResults(pushed=0, pulled=3, new_branch=False))