Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/codeocean/capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CapsuleSearchParams,
CapsuleSearchResults,
AppPanel,
GitSyncResults,
)
# Re-exports for backward compatibility
from codeocean.models.capsule import ( # noqa: F401
Expand Down Expand Up @@ -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(
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.3.0"
MIN_SERVER_VERSION = "4.6.0"

def __post_init__(self):
self.session = BaseUrlSession(base_url=f"{self.domain}/api/v1/")
Expand Down
19 changes: 19 additions & 0 deletions src/codeocean/models/capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/codeocean/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
CapsuleSearchParams,
CapsuleSearchResults,
AppPanel,
GitSyncResults,
)
from codeocean.models.components import Permissions
from codeocean.models.computation import Computation
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_git_sync.py
Original file line number Diff line number Diff line change
@@ -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))