Skip to content

Commit 60f2287

Browse files
zvikagartclaude
andauthored
feat: add Git sync support for capsules and pipelines (#71)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 674170b commit 60f2287

5 files changed

Lines changed: 78 additions & 1 deletion

File tree

src/codeocean/capsule.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
CapsuleSearchParams,
1010
CapsuleSearchResults,
1111
AppPanel,
12+
GitSyncResults,
1213
)
1314
# Re-exports for backward compatibility
1415
from codeocean.models.capsule import ( # noqa: F401
@@ -91,6 +92,12 @@ def detach_data_assets(self, capsule_id: str, data_assets: list[str]):
9192
json=data_assets,
9293
)
9394

95+
def sync_capsule(self, capsule_id: str) -> GitSyncResults:
96+
"""Sync a capsule with its linked external Git repository."""
97+
res = self.client.post(f"{self._route}/{capsule_id}/sync")
98+
99+
return GitSyncResults.from_dict(res.json())
100+
94101
def archive_capsule(self, capsule_id: str, archive: bool):
95102
"""Archive or unarchive a capsule to control its visibility and accessibility."""
96103
self.client.patch(

src/codeocean/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class CodeOcean:
3838
agent_id: Optional[str] = None
3939

4040
# Minimum server version required by this SDK
41-
MIN_SERVER_VERSION = "4.3.0"
41+
MIN_SERVER_VERSION = "4.6.0"
4242

4343
def __post_init__(self):
4444
self.session = BaseUrlSession(base_url=f"{self.domain}/api/v1/")

src/codeocean/models/capsule.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,25 @@ class Capsule:
159159
)
160160

161161

162+
@dataclass_json
163+
@dataclass(frozen=True)
164+
class GitSyncResults:
165+
"""Results of syncing a capsule or pipeline with its external Git remote."""
166+
167+
pushed: int = dataclass_field(
168+
default=0,
169+
metadata={"description": "Number of commits pushed to the external Git remote"},
170+
)
171+
pulled: int = dataclass_field(
172+
default=0,
173+
metadata={"description": "Number of commits pulled from the external Git remote"},
174+
)
175+
new_branch: bool = dataclass_field(
176+
default=False,
177+
metadata={"description": "Whether the current branch was newly created on the external remote by this sync"},
178+
)
179+
180+
162181
@dataclass_json
163182
@dataclass(frozen=True)
164183
class CapsuleSearchParams:

src/codeocean/pipeline.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CapsuleSearchParams,
1111
CapsuleSearchResults,
1212
AppPanel,
13+
GitSyncResults,
1314
)
1415
from codeocean.models.components import Permissions
1516
from codeocean.models.computation import Computation
@@ -62,6 +63,10 @@ def detach_data_assets(self, pipeline_id: str, data_assets: list[str]):
6263
"""Detach one or more data assets from a pipeline by their IDs."""
6364
return self._capsules.detach_data_assets(pipeline_id, data_assets)
6465

66+
def sync_pipeline(self, pipeline_id: str) -> GitSyncResults:
67+
"""Sync a pipeline with its linked external Git repository."""
68+
return self._capsules.sync_capsule(pipeline_id)
69+
6570
def archive_pipeline(self, pipeline_id: str, archive: bool):
6671
"""Archive or unarchive a pipeline to control its visibility and accessibility."""
6772
return self._capsules.archive_capsule(pipeline_id, archive)

tests/test_git_sync.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import unittest
2+
from unittest.mock import MagicMock
3+
4+
from codeocean.capsule import Capsules, GitSyncResults
5+
from codeocean.pipeline import Pipelines
6+
7+
8+
class TestGitSync(unittest.TestCase):
9+
"""Test cases for capsule and pipeline Git sync."""
10+
11+
def _mock_session(self, body):
12+
"""Build a mock session whose post() returns a response with the given JSON body."""
13+
session = MagicMock()
14+
response = MagicMock()
15+
response.json.return_value = body
16+
session.post.return_value = response
17+
return session
18+
19+
def test_sync_capsule_returns_results(self):
20+
"""sync_capsule posts to the capsule sync route and parses the results."""
21+
session = self._mock_session({"pushed": 2, "pulled": 1, "new_branch": True})
22+
capsules = Capsules(client=session)
23+
24+
result = capsules.sync_capsule("cap-123")
25+
26+
session.post.assert_called_once_with("capsules/cap-123/sync")
27+
self.assertEqual(result, GitSyncResults(pushed=2, pulled=1, new_branch=True))
28+
29+
def test_sync_capsule_empty_body_defaults(self):
30+
"""An empty body (all fields omitempty on the server) deserializes to zero defaults."""
31+
session = self._mock_session({})
32+
capsules = Capsules(client=session)
33+
34+
result = capsules.sync_capsule("cap-123")
35+
36+
self.assertEqual(result, GitSyncResults(pushed=0, pulled=0, new_branch=False))
37+
38+
def test_sync_pipeline_returns_results(self):
39+
"""sync_pipeline posts to the pipeline sync route and parses the results."""
40+
session = self._mock_session({"pushed": 0, "pulled": 3, "new_branch": False})
41+
pipelines = Pipelines(client=session)
42+
43+
result = pipelines.sync_pipeline("pipe-456")
44+
45+
session.post.assert_called_once_with("pipelines/pipe-456/sync")
46+
self.assertEqual(result, GitSyncResults(pushed=0, pulled=3, new_branch=False))

0 commit comments

Comments
 (0)