Skip to content
25 changes: 25 additions & 0 deletions cryosparc/api.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,31 @@ class JobsAPI(APINamespace):
Returns:
CheckpointEvent: Successful Response

"""
...
def update_event_log(
self,
project_uid: str,
job_uid: str,
event_id: str = "000000000000000000000000",
/,
text: Optional[str] = None,
*,
type: Optional[Literal["text", "warning", "error"]] = None,
) -> TextEvent:
"""
Update a text event log entry for a job.

Args:
project_uid (str): Project UID, e.g., "P3"
job_uid (str): Job UID, e.g., "J3"
event_id (str, optional): Defaults to '000000000000000000000000'
text (str, optional): Defaults to None
type (Literal['text', 'warning', 'error'], optional): Defaults to None

Returns:
TextEvent: Successful Response

"""
...
def recalculate_size(self, project_uid: str, job_uid: str, /) -> Job:
Expand Down
47 changes: 41 additions & 6 deletions cryosparc/controllers/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ class JobController(Controller[Job]):
Project unique ID, e.g., "P3"
"""

_events: Dict[str, str]
"""
Named event logs. Key can be user-provided name in log() method, or ID if
name not provided. If both name and ID are used, can have two keys with
the same value.

:meta private:
"""

def __init__(self, cs: "CryoSPARC", job: Union[Tuple[str, str], Job]) -> None:
self.cs = cs
if isinstance(job, tuple):
Expand All @@ -117,6 +126,7 @@ def __init__(self, cs: "CryoSPARC", job: Union[Tuple[str, str], Job]) -> None:
self.project_uid = job.project_uid
self.uid = job.uid
self.model = job
self._events = {}

@property
def type(self) -> str:
Expand Down Expand Up @@ -526,24 +536,48 @@ def load_output(self, name: str, slots: LoadableSlots = "all", version: Union[in
"""
return self.cs.api.jobs.load_output(self.project_uid, self.uid, name, slots=slots, version=version)

def log(self, text: str, level: Literal["text", "warning", "error"] = "text"):
def log(self, text: str, *, level: Literal["text", "warning", "error"] = "text", name: Optional[str] = None):
"""
Append to a job's event log.
Append to a job's event log. Update an existing log by providing a name.

Args:
text (str): Text to log
level (str, optional): Log level ("text", "warning" or "error").
Defaults to "text".
name (str, optional): Event name or ID. If an event with the same
name or ID already exists, updates it instead of creating a new
one. Named events are reset when logging a checkpoint. Defaults
to None.

Example:

Log a warning message to the job log.
>>> job.log("This is a warning", level="warning")

Show a live progress bar in the job log.
>>> for pct in range(1, 10):
... # example log: "Progress: [#####-----] 50%"
... job.log(f"Progress: [{'#' * pct}{'-' * (10 - pct)}] {pct * 10}%", name="progress")
... sleep(1)
...
>>> job.log("Done!")

Returns:
str: Created log event ID
str: Created log event name or ID
"""
event = self.cs.api.jobs.add_event_log(self.project_uid, self.uid, text, type=level)
return event.id
if name and name in self._events:
event_id = self._events[name]
event = self.cs.api.jobs.update_event_log(self.project_uid, self.uid, event_id, text, type=level)
else:
event = self.cs.api.jobs.add_event_log(self.project_uid, self.uid, text, type=level)
self._events[event.id] = event.id
if name:
self._events[name] = event.id
return name or event.id

def log_checkpoint(self, meta: dict = {}):
"""
Append a checkpoint to the job's event log.
Append a checkpoint to the job's event log. Also resets named events.

Args:
meta (dict, optional): Additional meta information. Defaults to {}.
Expand All @@ -552,6 +586,7 @@ def log_checkpoint(self, meta: dict = {}):
str: Created checkpoint event ID
"""
event = self.cs.api.jobs.add_checkpoint(self.project_uid, self.uid, meta)
self._events = {}
return event.id

def log_plot(
Expand Down
83 changes: 82 additions & 1 deletion tests/controllers/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ def test_job_subprocess_io(job: JobController):
[sys.executable, "-c", 'import sys; print("hello"); print("error", file=sys.stderr); print("world")']
)

assert len(mock_log_endpoint.mock_calls) == 7 # includes some prelude/divider calls
mock_log_endpoint.assert_has_calls(
[
mock.call(job.project_uid, job.uid, "hello", type="text"),
Expand Down Expand Up @@ -185,3 +184,85 @@ def test_external_job_output(mock_external_job_with_saved_output: ExternalJobCon
def test_invalid_external_job_output(external_job):
with pytest.raises(ValueError, match="Invalid output name"):
external_job.add_output("particle", name="particles/1", slots=["blob", "ctf"])


@pytest.fixture
def mock_log_event():
return mock.MagicMock(id="event_123")


@pytest.fixture
def mock_checkpoint_event():
return mock.MagicMock(id="checkpoint_456")


def test_log_without_name(job: JobController, mock_log_event):
assert isinstance(mock_add_endpoint := APIClient.jobs.add_event_log, mock.Mock)
mock_add_endpoint.return_value = mock_log_event

result = job.log("Test message without name")

mock_add_endpoint.assert_called_once_with(job.project_uid, job.uid, "Test message without name", type="text")
assert result == mock_log_event.id


def test_log_with_name_create_and_update(job: JobController, mock_log_event):
assert isinstance(mock_add_endpoint := APIClient.jobs.add_event_log, mock.Mock)
assert isinstance(mock_update_endpoint := APIClient.jobs.update_event_log, mock.Mock)
mock_add_endpoint.return_value = mock_log_event
mock_update_endpoint.return_value = mock_log_event

# First call with name - should create
result1 = job.log("First message", name="progress")

mock_add_endpoint.assert_called_once_with(job.project_uid, job.uid, "First message", type="text")
assert result1 == "progress"

# Second call with same name - should update
result2 = job.log("Updated message", level="warning", name="progress")

mock_update_endpoint.assert_called_once_with(
job.project_uid, job.uid, mock_log_event.id, "Updated message", type="warning"
)
assert result2 == "progress"


def test_log_with_returned_event_id_as_name(job: JobController, mock_log_event):
assert isinstance(mock_add_endpoint := APIClient.jobs.add_event_log, mock.Mock)
assert isinstance(mock_update_endpoint := APIClient.jobs.update_event_log, mock.Mock)
mock_add_endpoint.return_value = mock_log_event
mock_update_endpoint.return_value = mock_log_event

# First call without name - returns event ID
event_id = job.log("Initial message")
assert event_id == mock_log_event.id

# Second call using the returned event ID as name - should update
result = job.log("Updated with event ID", name=event_id)

mock_update_endpoint.assert_called_once_with(
job.project_uid, job.uid, mock_log_event.id, "Updated with event ID", type="text"
)
assert result == event_id


def test_log_after_checkpoint_creates_new(job: JobController, mock_log_event, mock_checkpoint_event):
assert isinstance(mock_add_endpoint := APIClient.jobs.add_event_log, mock.Mock)
assert isinstance(mock_update_endpoint := APIClient.jobs.update_event_log, mock.Mock)
assert isinstance(mock_checkpoint_endpoint := APIClient.jobs.add_checkpoint, mock.Mock)

mock_add_endpoint.return_value = mock_log_event
mock_update_endpoint.return_value = mock_log_event
mock_checkpoint_endpoint.return_value = mock_checkpoint_event

job.log("Before checkpoint", name="status")

checkpoint_id = job.log_checkpoint()
mock_checkpoint_endpoint.assert_called_once_with(job.project_uid, job.uid, {})
assert checkpoint_id == mock_checkpoint_event.id

# Log again with same name - should create new
mock_add_endpoint.reset_mock() # Reset to track the second call
result = job.log("After checkpoint", name="status")
mock_add_endpoint.assert_called_once_with(job.project_uid, job.uid, "After checkpoint", type="text")
assert result == "status"
Loading