Skip to content
Open
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
127 changes: 89 additions & 38 deletions syft_client/sync/peers/peer_list.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,108 @@
import html
import os
from collections.abc import Iterator

from syft_client.sync.peers.peer import Peer, PeerState
from syft_client.sync.reprs.peer_repr import get_peer_list_table

# Emoji + friendly label shown per peer state in the summary view.
_STATE_DISPLAY = {
PeerState.ACCEPTED: ("✅", "connected"),
PeerState.REQUESTED_BY_ME: ("⏳", "requested_by_us (waiting for approval)"),
PeerState.REQUESTED_BY_PEER: (
"📩",
"requested_by_peer (waiting for your approval)",
),
PeerState.REJECTED: ("❌", "rejected"),
}

# Display order: accepted first, then outgoing requests, then incoming requests.
_STATE_ORDER = {
PeerState.ACCEPTED: 0,
PeerState.REQUESTED_BY_ME: 1,
PeerState.REQUESTED_BY_PEER: 2,
}


def _peer_sort_key(peer: Peer) -> int:
return _STATE_ORDER.get(peer.state, 3)

class PeerList(list):
def __init__(self, *args: Peer, **kwargs):

class PeerList:
"""A list-like container for Peer objects with a friendly summary display."""

def __init__(self, peers: list[Peer] | None = None) -> None:
"""
PeerList is a list specifically for Peer objects.
Validates that all items are Peer objects and that they are sorted correctly.
Validates that all items are Peer objects, then sorts them for display:
accepted first, then requested_by_me, then requested_by_peer.
"""
super().__init__(*args, **kwargs)
# Validate all items are Peer objects
for item in self:
peers = list(peers) if peers else []
for item in peers:
if not isinstance(item, Peer):
raise TypeError(
f"All items in PeerList must be Peer objects, but got {type(item)}"
)
# Validate sorting (approved before pending)
self._validate_sorting()

def _validate_sorting(self):
"""Ensure peers are sorted: accepted, then requested_by_me, then requested_by_peer"""
order = {
PeerState.ACCEPTED: 0,
PeerState.REQUESTED_BY_ME: 1,
PeerState.REQUESTED_BY_PEER: 2,
}
last_order = -1
for peer in self:
peer_order = order.get(peer.state, 3)
if peer_order < last_order:
raise ValueError(
"PeerList must be sorted: accepted first, then requested_by_me, then requested_by_peer"
)
last_order = peer_order
# ensure consistent ordering for display
self._peers: list[Peer] = sorted(peers, key=_peer_sort_key)

def __len__(self) -> int:
return len(self._peers)

def __iter__(self) -> Iterator[Peer]:
return iter(self._peers)

def __getitem__(self, index: str | int) -> Peer:
if isinstance(index, int):
return super().__getitem__(index)
return self._peers[index]
elif isinstance(index, str):
key = index
for peer in self:
if peer.email == key:
return peer
raise ValueError(f"Peer with email {index} not found")
try:
return next(peer for peer in self._peers if peer.email == index)
except StopIteration:
raise ValueError(f"Peer with email {index} not found") from None
else:
raise ValueError(f"Invalid index type: {type(index)}")
raise TypeError(f"Invalid index type: {type(index)}")

def _repr_html_(self) -> str:
"""Used by Jupyter to display Rich HTML."""
peers = [p for p in self]
return get_peer_list_table(peers)
def _summary_text(self) -> str:
"""Clean, human-friendly summary of the peers and what to do next."""
if not self:
return "👥 You have no peers yet."

lines = [f"👥 Your peers ({len(self)}):", ""]
pad = max(len(peer.email) for peer in self)
connected = []
has_pending = False
for peer in self:
emoji, label = _STATE_DISPLAY.get(peer.state, ("❓", str(peer.state.value)))
lines.append(f" {emoji} {peer.email.ljust(pad)} — {label}")
if peer.state == PeerState.ACCEPTED:
connected.append(peer)
elif peer.state in (PeerState.REQUESTED_BY_ME, PeerState.REQUESTED_BY_PEER):
has_pending = True

if connected:
lines += [
"",
"💡 Tip: Once connected, you can access their datasets with:",
f' client.datasets.get("dataset_name", datasite="{connected[0].email}")',
]
if has_pending:
lines += [
"",
"⏳ You have pending requests waiting for approval — "
"follow up with the peer or check back later.",
]
return "\n".join(lines)

def __str__(self) -> str:
"""Clean summary shown by print() and outside notebooks."""
return self._summary_text()

def _repr_html_(self) -> str | None:
"""Used by Jupyter to display the summary; falls back to text elsewhere."""
if os.environ.get("SYFT_NO_REPR_HTML", "").lower() in {"1", "true", "yes"}:
return None
return f"<pre>{html.escape(self._summary_text())}</pre>"
Comment thread
Copilot marked this conversation as resolved.

def __repr__(self):
"""Fallback for normal REPL"""
"""Technical repr for debugging / normal REPL."""
peers = [p for p in self]
return f"PeerList({peers!r})"
4 changes: 2 additions & 2 deletions tests/unit/test_datasets_jobs_repr.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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(
Expand Down
147 changes: 147 additions & 0 deletions tests/unit/test_peer_list_repr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Tests for PeerList's human-friendly summary rendering (__str__/_repr_html_/__repr__)."""

import pytest
from syft_client.sync.peers.peer import Peer, PeerState
from syft_client.sync.peers.peer_list import PeerList


def _mixed_peer_list() -> PeerList:
"""A PeerList with one peer in each visible state, in required sort order."""
return PeerList(
[
Peer(email="do@org.com", state=PeerState.ACCEPTED),
Peer(email="other@org.com", state=PeerState.REQUESTED_BY_ME),
Peer(email="incoming@org.com", state=PeerState.REQUESTED_BY_PEER),
]
)


def test_str_header_shows_count():
pl = _mixed_peer_list()
assert str(pl).startswith("👥 Your peers (3):")


def test_str_renders_emoji_and_friendly_labels():
text = str(_mixed_peer_list())
assert "✅ do@org.com" in text
assert "— connected" in text
assert "⏳ other@org.com" in text
assert "requested_by_us (waiting for approval)" in text
assert "📩 incoming@org.com" in text
assert "requested_by_peer (waiting for your approval)" in text


def test_connected_tip_present_when_connected_peer_exists():
text = str(_mixed_peer_list())
assert "client.datasets.get" in text
assert 'datasite="do@org.com"' in text


def test_connected_tip_absent_when_no_connected_peer():
pl = PeerList(
[
Peer(email="other@org.com", state=PeerState.REQUESTED_BY_ME),
Peer(email="incoming@org.com", state=PeerState.REQUESTED_BY_PEER),
]
)
assert "client.datasets.get" not in str(pl)


def test_pending_tip_present_when_pending_peer_exists():
text = str(_mixed_peer_list())
assert "pending requests" in text


def test_pending_tip_absent_when_only_connected():
pl = PeerList([Peer(email="do@org.com", state=PeerState.ACCEPTED)])
assert "pending requests" not in str(pl)


def test_empty_peer_list_message():
assert str(PeerList([])) == "👥 You have no peers yet."


def test_repr_html_wraps_and_escapes_summary():
html = _mixed_peer_list()._repr_html_()
assert html is not None
assert html.startswith("<pre>")
assert html.endswith("</pre>")
assert "👥 Your peers (3):" in html


def test_repr_html_escapes_html_in_email():
pl = PeerList([Peer(email="a<b>@org.com", state=PeerState.ACCEPTED)])
html = pl._repr_html_()
assert html is not None
assert "a<b>@org.com" not in html
assert "a&lt;b&gt;@org.com" in html


def test_repr_html_returns_none_when_disabled(monkeypatch):
monkeypatch.setenv("SYFT_NO_REPR_HTML", "1")
assert _mixed_peer_list()._repr_html_() is None


def test_repr_preserves_technical_string():
assert repr(_mixed_peer_list()).startswith("PeerList(")


def test_peer_list_len_and_iter():
pl = _mixed_peer_list()
assert len(pl) == 3
assert [p.email for p in pl] == [
"do@org.com",
"other@org.com",
"incoming@org.com",
]


def test_peer_list_getitem_int():
assert _mixed_peer_list()[0].email == "do@org.com"


def test_peer_list_getitem_str():
assert _mixed_peer_list()["other@org.com"].state == PeerState.REQUESTED_BY_ME


def test_peer_list_getitem_str_not_found():
with pytest.raises(ValueError, match="not found"):
_mixed_peer_list()["nobody@org.com"]


def test_peer_list_getitem_invalid_type():
with pytest.raises(TypeError, match="Invalid index type"):
_mixed_peer_list()[3.14] # type: ignore[index]


def test_peer_list_rejects_non_peer_items():
with pytest.raises(TypeError):
PeerList(["not-a-peer"]) # type: ignore[list-item]


def test_peer_list_sorts_unsorted_input():
# Construction sorts by state (accepted → requested_by_me → requested_by_peer)
# rather than requiring the caller to pre-sort.
pl = PeerList(
[
Peer(email="incoming@org.com", state=PeerState.REQUESTED_BY_PEER),
Peer(email="connected@org.com", state=PeerState.ACCEPTED),
Peer(email="outgoing@org.com", state=PeerState.REQUESTED_BY_ME),
]
)
assert [p.state for p in pl] == [
PeerState.ACCEPTED,
PeerState.REQUESTED_BY_ME,
PeerState.REQUESTED_BY_PEER,
]


def test_peer_list_sort_is_stable_within_a_state():
# Peers sharing a state keep their input order (stable sort).
pl = PeerList(
[
Peer(email="second@org.com", state=PeerState.ACCEPTED),
Peer(email="first@org.com", state=PeerState.ACCEPTED),
]
)
assert [p.email for p in pl] == ["second@org.com", "first@org.com"]
Loading