diff --git a/packages/syft/src/syft/service/code/user_code.py b/packages/syft/src/syft/service/code/user_code.py index e68436a47b0..c779037a6c9 100644 --- a/packages/syft/src/syft/service/code/user_code.py +++ b/packages/syft/src/syft/service/code/user_code.py @@ -82,6 +82,7 @@ from ..response import SyftNotReady from ..response import SyftSuccess from ..response import SyftWarning +from ..user.user import UserView from .code_parse import GlobalsVisitor from .code_parse import LaunchJobVisitor from .unparse import unparse @@ -348,6 +349,18 @@ def _coll_repr_(self) -> dict[str, Any]: "Submit time": str(self.submit_time), } + @property + def user(self) -> UserView | SyftError: + api = APIRegistry.api_for( + node_uid=self.syft_node_location, + user_verify_key=self.user_verify_key, + ) + if api is None: + return SyftError( + message=f"Can't access Syft API. You must login to {self.syft_node_location}" + ) + return api.services.user.get_current_user() + @property def status(self) -> UserCodeStatusCollection | SyftError: # Clientside only diff --git a/packages/syft/src/syft/service/job/job_stash.py b/packages/syft/src/syft/service/job/job_stash.py index ec5dcfd19a1..2943913cf73 100644 --- a/packages/syft/src/syft/service/job/job_stash.py +++ b/packages/syft/src/syft/service/job/job_stash.py @@ -1,6 +1,7 @@ # stdlib from datetime import datetime from datetime import timedelta +from datetime import timezone from enum import Enum import random from string import Template @@ -28,6 +29,7 @@ from ...store.document_store import QueryKeys from ...store.document_store import UIDPartitionKey from ...types.datetime import DateTime +from ...types.datetime import format_timedelta from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SYFT_OBJECT_VERSION_6 from ...types.syft_object import SyftObject @@ -96,7 +98,9 @@ class Job(SyncableSyftObject): parent_job_id: UID | None = None n_iters: int | None = 0 current_iter: int | None = None - creation_time: str | None = Field(default_factory=lambda: str(datetime.now())) + creation_time: str | None = Field( + default_factory=lambda: str(datetime.now(tz=timezone.utc)) + ) action: Action | None = None job_pid: int | None = None job_worker_id: UID | None = None @@ -201,18 +205,7 @@ def eta_string(self) -> str | None: ): return None - def format_timedelta(local_timedelta: timedelta) -> str: - total_seconds = int(local_timedelta.total_seconds()) - hours, leftover = divmod(total_seconds, 3600) - minutes, seconds = divmod(leftover, 60) - - hours_string = f"{hours}:" if hours != 0 else "" - minutes_string = f"{minutes}:".zfill(3) - seconds_string = f"{seconds}".zfill(2) - - return f"{hours_string}{minutes_string}{seconds_string}" - - now = datetime.now() + now = datetime.now(tz=timezone.utc) time_passed = now - datetime.fromisoformat(self.creation_time) iter_duration_seconds: float = time_passed.total_seconds() / self.current_iter iters_remaining = self.n_iters - self.current_iter diff --git a/packages/syft/src/syft/types/datetime.py b/packages/syft/src/syft/types/datetime.py index 7b66f40f24a..65feab6633f 100644 --- a/packages/syft/src/syft/types/datetime.py +++ b/packages/syft/src/syft/types/datetime.py @@ -1,5 +1,6 @@ # stdlib from datetime import datetime +from datetime import timedelta from functools import total_ordering import re from typing import Any @@ -57,3 +58,34 @@ def __eq__(self, other: Any) -> bool: def __lt__(self, other: Self) -> bool: return self.utc_timestamp < other.utc_timestamp + + def timedelta(self, other: "DateTime") -> timedelta: + utc_timestamp_delta = self.utc_timestamp - other.utc_timestamp + return timedelta(seconds=utc_timestamp_delta) + + +def format_timedelta(local_timedelta: timedelta) -> str: + total_seconds = int(local_timedelta.total_seconds()) + hours, leftover = divmod(total_seconds, 3600) + minutes, seconds = divmod(leftover, 60) + + hours_string = f"{hours}:" if hours != 0 else "" + minutes_string = f"{minutes}:".zfill(3) + seconds_string = f"{seconds}".zfill(2) + + return f"{hours_string}{minutes_string}{seconds_string}" + + +def format_timedelta_human_readable(local_timedelta: timedelta) -> str: + # Returns a human-readable string representing the timedelta + units = [("day", 86400), ("hour", 3600), ("minute", 60), ("second", 1)] + total_seconds = int(local_timedelta.total_seconds()) + + for unit_name, unit_seconds in units: + unit_value, total_seconds = divmod(total_seconds, unit_seconds) + if unit_value > 0: + if unit_value == 1: + return f"{unit_value} {unit_name}" + else: + return f"{unit_value} {unit_name}s" + return "0 seconds" diff --git a/packages/syft/src/syft/util/notebook_ui/components/sync.py b/packages/syft/src/syft/util/notebook_ui/components/sync.py index 215cca934fe..4fdd0adf1b3 100644 --- a/packages/syft/src/syft/util/notebook_ui/components/sync.py +++ b/packages/syft/src/syft/util/notebook_ui/components/sync.py @@ -1,4 +1,5 @@ # stdlib +import datetime from typing import Any # third party @@ -9,6 +10,10 @@ from ....service.code.user_code import UserCode from ....service.job.job_stash import Job from ....service.request.request import Request +from ....service.response import SyftError +from ....service.user.user import UserView +from ....types.datetime import DateTime +from ....types.datetime import format_timedelta_human_readable from ....types.syft_object import SYFT_OBJECT_VERSION_1 from ....types.syft_object import SyftObject from ..icons import Icon @@ -101,6 +106,43 @@ def get_status_str(self) -> str: return status.value return "" # type: ignore + def get_updated_by(self) -> str: + # TODO replace with centralized SyftObject created/updated by attribute + if isinstance(self.object, Request): + email = self.object.requesting_user_email + if email is not None: + return f"Requested by {email}" + + user_view: UserView | SyftError | None = None + if isinstance(self.object, UserCode): + user_view = self.object.user + + if isinstance(user_view, UserView): + return f"Created by {user_view.email}" + return "" + + def get_updated_delta_str(self) -> str: + # TODO replace with centralized SyftObject created/updated by attribute + if isinstance(self.object, Job): + # NOTE Job is not using DateTime for creation_time, so we need to handle it separately + time_str = self.object.creation_time + if time_str is not None: + t = datetime.datetime.fromisoformat(time_str) + delta = datetime.datetime.now(datetime.timezone.utc) - t + return f"{format_timedelta_human_readable(delta)} ago" + + dt: DateTime | None = None + if isinstance(self.object, Request): + dt = self.object.request_time + if isinstance(self.object, UserCode): + dt = self.object.submit_time + if dt is not None: + delta = DateTime.now().timedelta(dt) + delta_str = format_timedelta_human_readable(delta) + return f"{delta_str} ago" + + return "" + def to_html(self) -> str: type_html = TypeLabel(object=self.object).to_html() @@ -110,10 +152,12 @@ def to_html(self) -> str: copy_text=str(self.object.id.id), max_width=60 ).to_html() - updated_delta_str = "29m ago" - updated_by = "john@doe.org" + updated_delta_str = self.get_updated_delta_str() + updated_by = self.get_updated_by() status_str = self.get_status_str() - status_seperator = " • " if len(status_str) else "" + status_row = " • ".join( + s for s in [status_str, updated_by, updated_delta_str] if s + ) summary_html = f"""