From 2bc561af488771409955dfcbe66af31ad8a95ace Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Tue, 14 Jul 2026 13:43:06 -0300 Subject: [PATCH 1/4] Fix JobRef import path --- tests/unit/test_datasets_jobs_repr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_datasets_jobs_repr.py b/tests/unit/test_datasets_jobs_repr.py index c77026cf121..42dd28b8f9d 100644 --- a/tests/unit/test_datasets_jobs_repr.py +++ b/tests/unit/test_datasets_jobs_repr.py @@ -1,9 +1,9 @@ """Tests for SyftDatasetManager and JobsList repr and indexing.""" import pytest - from syft_client.sync.syftbox_manager import SyftboxManager from syft_job.job import JobInfo, JobsList + from tests.unit.utils import create_tmp_dataset_files @@ -141,7 +141,7 @@ def _make_job_info(name: str, status: str = "pending") -> JobInfo: from syft_job.client import JobClient from syft_job.config import SyftJobConfig - from syft_job.manager import JobRef + from syft_job.job_ref import JobRef from syft_job.models import JobState, JobStatus, JobSubmissionMetadata config = SyftJobConfig( From 9a1cc86df2506417196d47c520325d1307b757ed Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Tue, 14 Jul 2026 15:26:53 -0300 Subject: [PATCH 2/4] feat: improve job list display --- .../syft-enclave/src/syft_enclaves/client.py | 2 +- packages/syft-job/src/syft_job/client.py | 1 - packages/syft-job/src/syft_job/job.py | 7 +- packages/syft-job/src/syft_job/job_repr.py | 304 ++++++------------ tests/unit/test_datasets_jobs_repr.py | 124 +++++-- 5 files changed, 204 insertions(+), 234 deletions(-) diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 7437ff460ff..0d4bd0a3eee 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -128,7 +128,7 @@ def jobs(self) -> JobsList: else j for j in jobs_list ] - return JobsList(wrapped, jobs_list._root_email) + return JobsList(wrapped, has_do_role=jobs_list._has_do_role) def submit_python_job( self, diff --git a/packages/syft-job/src/syft_job/client.py b/packages/syft-job/src/syft_job/client.py index 05f958a7584..4e272e5e587 100644 --- a/packages/syft-job/src/syft_job/client.py +++ b/packages/syft-job/src/syft_job/client.py @@ -662,7 +662,6 @@ def job_sort_key(job): sorted_jobs = sorted(current_jobs, key=job_sort_key) return JobsList( sorted_jobs, - self.target_datasite_owner_email, has_do_role=self.has_do_role, ) diff --git a/packages/syft-job/src/syft_job/job.py b/packages/syft-job/src/syft_job/job.py index 0eed2bbf7ad..8e6e7bf7268 100644 --- a/packages/syft-job/src/syft_job/job.py +++ b/packages/syft-job/src/syft_job/job.py @@ -426,9 +426,8 @@ def _repr_html_(self) -> str: class JobsList: """A list-like container for JobInfo objects with nice display.""" - def __init__(self, jobs: List[JobInfo], root_email: str, has_do_role: bool = False): + def __init__(self, jobs: List[JobInfo], has_do_role: bool = False): self._jobs = jobs - self._root_email = root_email self._has_do_role = has_do_role def __getitem__(self, index: int | str) -> JobInfo: @@ -449,10 +448,10 @@ def __iter__(self): return iter(self._jobs) def __str__(self) -> str: - return jobs_list_str(self._jobs, self._root_email, self._has_do_role) + return jobs_list_str(self._jobs, has_do_role=self._has_do_role) def __repr__(self) -> str: return f"JobsList({len(self._jobs)} jobs)" def _repr_html_(self) -> str: - return jobs_list_repr_html(self._jobs, self._root_email, self._has_do_role) + return jobs_list_repr_html(self._jobs, has_do_role=self._has_do_role) diff --git a/packages/syft-job/src/syft_job/job_repr.py b/packages/syft-job/src/syft_job/job_repr.py index a12507aaab7..1db66610759 100644 --- a/packages/syft-job/src/syft_job/job_repr.py +++ b/packages/syft-job/src/syft_job/job_repr.py @@ -731,108 +731,63 @@ def job_info_repr_html(job: "JobInfo") -> str: """ -def jobs_list_str( - jobs: List["JobInfo"], root_email: str, has_do_role: bool = False -) -> str: - """Format jobs list as separate tables grouped by user.""" +def jobs_list_str(jobs: List["JobInfo"], has_do_role: bool = False) -> str: + """Format jobs list as a clean one-line-per-job summary.""" if not jobs: return "📭 No jobs found.\n" - jobs_by_user: dict[str, list["JobInfo"]] = {} - for job in jobs: - if job.datasite_owner_email not in jobs_by_user: - jobs_by_user[job.datasite_owner_email] = [] - jobs_by_user[job.datasite_owner_email].append(job) - status_emojis = { - "received": "📨", - "pending": "📥", - "approved": "✅", + "received": "⏳", + "pending": "⏳", + "approved": "🔄", "rejected": "❌", "running": "🔄", - "done": "🎉", + "done": "✅", "failed": "💥", } - lines = [] - lines.append("📊 Jobs Overview") - lines.append("=" * 50) - - total_jobs = 0 - global_status_counts: dict[str, int] = {} - - def user_sort_key(item): - user_email, _user_jobs = item - if user_email == root_email: - return (0, user_email) - return (1, user_email) - - sorted_users = sorted(jobs_by_user.items(), key=user_sort_key) - - job_index = 0 - - for user_email, user_jobs in sorted_users: - if not user_jobs: - continue - - total_jobs += len(user_jobs) - - lines.append("") - lines.append(f"👤 {user_email}") - lines.append("-" * 60) - - name_width = max(len(job.name) for job in user_jobs) + 2 - status_width = max(len(job.status) for job in user_jobs) + 2 - submitted_width = max(len(job.submitted_by) for job in user_jobs) + 2 - - name_width = max(name_width, 15) - status_width = max(status_width, 12) - submitted_width = max(submitted_width, 15) - - approval_width = 10 - - header = f"{'Index':<6} {'Job Name':<{name_width}} {'Submitted By':<{submitted_width}} {'Status':<{status_width}} {'Approval':<{approval_width}}" - lines.append(header) - lines.append("-" * len(header)) - - sorted_jobs = user_jobs - - for job in sorted_jobs: - emoji = status_emojis.get(job.status, "❓") - status_display = f"{emoji} {job.status}" - approval_display = job.approval_method or "—" - line = f"[{job_index:<4}] {job.name:<{name_width}} {job.submitted_by:<{submitted_width}} {status_display:<{status_width}} {approval_display:<{approval_width}}" - lines.append(line) - job_index += 1 - - global_status_counts[job.status] = ( - global_status_counts.get(job.status, 0) + 1 - ) - - user_status_counts: dict[str, int] = {} - for job in user_jobs: - user_status_counts[job.status] = user_status_counts.get(job.status, 0) + 1 - - summary_parts = [] - for status, count in user_status_counts.items(): - emoji = status_emojis.get(status, "❓") - summary_parts.append(f"{emoji} {count} {status}") - + def status_display(status: str) -> str: + label = "inbox" if status in ("received", "pending") else status + return f"{status_emojis.get(status, '❓')} {label}" + + lines = [f"📋 Your jobs ({len(jobs)}):", ""] + name_pad = max(len(job.name) for job in jobs) + status_pad = max(len(status_display(job.status)) for job in jobs) + done_index = None + all_inbox = True + + for index, job in enumerate(jobs): + st = status_display(job.status) + # Datasite owner is current user → incoming (DO view); else we submitted it. + incoming = job.datasite_owner_email == job.current_user_email + direction = "📥" if incoming else "📤" + target = ( + f"from: {job.submitted_by}" + if incoming + else f"submitted to: {job.datasite_owner_email}" + ) lines.append( - f"📋 {user_email}: {len(user_jobs)} jobs - " + " | ".join(summary_parts) + f" Job {index} | {direction} {job.name.ljust(name_pad)} | " + f"{st.ljust(status_pad)} | {target}" ) - - lines.append("") - lines.append("=" * 50) - lines.append(f"📈 Total: {total_jobs} jobs across {len(jobs_by_user)} users") - - global_summary_parts = [] - for status, count in global_status_counts.items(): - emoji = status_emojis.get(status, "❓") - global_summary_parts.append(f"{emoji} {count} {status}") - - if global_summary_parts: - lines.append("📋 Global: " + " | ".join(global_summary_parts)) + if job.status == "done" and done_index is None: + done_index = index + if incoming or job.status not in ("received", "pending"): + all_inbox = False + + if done_index is not None: + lines += [ + "", + f"💡 Job {done_index} is done — read results with:", + f" for path in client.jobs[{done_index}].output_paths:", + " print(open(path).read())", + ] + elif all_inbox: + lines += [ + "", + "⏳ Your jobs are waiting for the data owner to review them —", + " re-sync and check again with client.sync().", + ] if has_do_role: lines.append("") @@ -843,9 +798,7 @@ def user_sort_key(item): return "\n".join(lines) -def jobs_list_repr_html( - jobs: List["JobInfo"], root_email: str, has_do_role: bool = False -) -> str: +def jobs_list_repr_html(jobs: List["JobInfo"], has_do_role: bool = False) -> str: """HTML representation for Jupyter notebooks with enhanced visual appeal.""" if not jobs: return """ @@ -919,54 +872,29 @@ def jobs_list_repr_html( """ - jobs_by_user: dict[str, list["JobInfo"]] = {} - for job in jobs: - if job.datasite_owner_email not in jobs_by_user: - jobs_by_user[job.datasite_owner_email] = [] - jobs_by_user[job.datasite_owner_email].append(job) - - status_styles = { - "received": { - "emoji": "📨", - "light": {"color": "#888888", "bg": "#f0f0f0"}, - "dark": {"color": "#aaaaaa", "bg": "#444444"}, - }, - "pending": { - "emoji": "📥", - "light": {"color": "#6976ae", "bg": "#e8f2ff"}, - "dark": {"color": "#96d195", "bg": "#52a8c5"}, - }, - "approved": { - "emoji": "✅", - "light": {"color": "#53bea9", "bg": "#e6f9f4"}, - "dark": {"color": "#53bea9", "bg": "#2a5d52"}, - }, - "rejected": { - "emoji": "❌", - "light": {"color": "#cc3333", "bg": "#ffe8e8"}, - "dark": {"color": "#ff6666", "bg": "#5a2222"}, - }, - "running": { - "emoji": "🔄", - "light": {"color": "#cc8800", "bg": "#fff3e0"}, - "dark": {"color": "#ffcc66", "bg": "#5a4422"}, - }, - "done": { - "emoji": "🎉", - "light": {"color": "#937098", "bg": "#f3e5f5"}, - "dark": {"color": "#f2d98c", "bg": "#cc677b"}, - }, - "failed": { - "emoji": "💥", - "light": {"color": "#cc3333", "bg": "#ffe8e8"}, - "dark": {"color": "#ff6666", "bg": "#5a2222"}, - }, + status_emojis = { + "received": "⏳", + "pending": "⏳", + "approved": "🔄", + "rejected": "❌", + "running": "🔄", + "done": "✅", + "failed": "💥", } + def status_display(status: str) -> str: + label = "inbox" if status in ("received", "pending") else status + return f"{status_emojis.get(status, '❓')} {label}" + total_jobs = len(jobs) - global_status_counts: dict[str, int] = {} - for job in jobs: - global_status_counts[job.status] = global_status_counts.get(job.status, 0) + 1 + done_index = None + all_inbox = True + for index, job in enumerate(jobs): + incoming = job.datasite_owner_email == job.current_user_email + if job.status == "done" and done_index is None: + done_index = index + if incoming or job.status not in ("received", "pending"): + all_inbox = False html = f"""