Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
50 changes: 31 additions & 19 deletions .claude/skills/syft-pr-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ package, and whether each claim in `body.md` is still true.

## Step 3 — Write it

Save to `koen/pr-reviews/pr-<N>-<slug>.md` when a git-ignored `koen/` folder exists, otherwise
`pr-reviews/` in the repo root — and say the file is untracked.
Save to `koen/reviews/<N>/summary.md` when a git-ignored `koen/` folder exists, otherwise
`reviews/<N>/summary.md` in the repo root — and say the file is untracked. One folder per PR, so
anything else about that review lands beside the summary later.

Flows come first, because they are why the reader opened the document.

Expand All @@ -87,36 +88,42 @@ Flows come first, because they are why the reader opened the document.

- [ ] **2. What is new** — additions only

- [ ] **NEW CLASS `Name`** — `path/file.py:<line>` — <what it is for>. Built by `A.b()`. (Flow 1.2)
- [ ] **NEW MODULE `path/file.py`** — <what it is for>. Defines `file.py: func()`. (Flow 1.1)
- [ ] **NEW helpers in `path/file.py`** — `f()` <does X>, `g()` <does Y>. (Flow 1.1)
- [ ] **2.1 NEW CLASS `Name`** — `path/file.py:<line>` — <what it is for>. Built by `A.b()`. (Flow 1.2)
- [ ] **2.2 NEW MODULE `path/file.py`** — <what it is for>. Defines `file.py: func()`. (Flow 1.1)
- [ ] **2.3 NEW helpers in `path/file.py`** — `f()` <does X>, `g()` <does Y>. (Flow 1.1)

- [ ] **3. Changes** — everything changed or deleted except tests, grouped by theme

- [ ] **A — <Theme>**
- [ ] **A1 <label>** (Flow 1.2) — When we <do X>, we previously <did Z>, now we <do A>.
- [ ] **3.1 <Theme>**
- [ ] **3.1.1 <label>** (Flow 1.2) — When we <do X>, we previously <did Z>, now we <do A>.
`path/file.py: Class.method()`
- [ ] **A2 <label>** — DELETED `Class.old()` and `file.py: helper()`, because <reason>.
- [ ] **3.1.2 <label>** — DELETED `Class.old()` and `file.py: helper()`, because <reason>.

- [ ] **4. Tests**

- [ ] **NEW `path/test_file.py`** — <n> tests<, and how they are set up, when it is worth a clause>
- [ ] **4.1 NEW `path/test_file.py`** — <n> tests<, and how they are set up, when it is worth a clause>
- [ ] `test_a()` — <the condition, what runs, and what is then true>
- [ ] `test_b()` — <the condition, what runs, and what is then true>
- [ ] **REWRITTEN `test_c()`** — `path/test_file.py` — now asserts <X> instead of <Y>, because
- [ ] **4.2 REWRITTEN `test_c()`** — `path/test_file.py` — now asserts <X> instead of <Y>, because
<reason>.
- [ ] **UPDATED for the new code** — <n> tests across <n> files follow the new
- [ ] **4.3 UPDATED for the new code** — <n> tests across <n> files follow the new
`Class.method()` signature. Nothing is asserted differently.
- [ ] Decision: <only when a test settles something a reader would otherwise wonder about>

- [ ] **5. Code standards** — only where the new code breaks one; drop the section when it does not

- [ ] `path/file.py: Class.method()` — <the problem, one line>
- [ ] **5.1** `path/file.py: Class.method()` — <the problem, one line>

- [ ] **6. Blocked on** — drop this section unless something real stops the merge
- [ ] <the problem, and what has to happen before this can go in>

- [ ] **6.1** <the problem, and what has to happen before this can go in>

- [ ] **7. Comments for the author** — leave it empty; the review UI writes into it
```

Number the comments section after the sections that survived, so it is 5, 6 or 7 depending on which
of those were dropped. It is always last, and always there.

### What counts as a code-standards problem

Judge only the lines this PR touched. Do not review the code around them, and do not turn this into
Expand All @@ -131,11 +138,13 @@ line, and write nothing when there is nothing wrong. Look for:
- string building that is not an f-string
- a repeated or magic value that belongs in a module-level constant
- two functions or methods that do substantially the same work; one of them belongs, called from
both places. Name the pair and what they share
both places. Name the pair and what they share. Let a repeated line or two go — the copy has to be
big enough that pulling it out is worth a helper
- an import inside a function; fine only to break a circular import, and worth one short note
- a test name that does not say what it checks, or pads a name that does. Length is free when every
word earns it, so drop articles and filler: `test_no_hint_when_do_owns_no_jobs`, not
`test_no_hint_when_the_do_owns_none_of_the_jobs`
- a name that does not say what the thing is, or pads a name that does. Length is free when every
word earns it, so drop articles and filler. Say it as a plain sentence and stop there:
`test_no_hint_when_the_do_owns_none_of_the_jobs() violates the no-filler rule, rename to
test_no_hint_when_do_owns_no_jobs()`

## Rules

Expand Down Expand Up @@ -181,8 +190,11 @@ line, and write nothing when there is nothing wrong. Look for:
decisions that change how someone reads the code; skip the rest.
- [ ] **Budget reading time:** roughly 40 bullets for a normal PR, 120 for a very large one.
- [ ] Do not repeat the PR description. If your text matches it, you read the wrong thing.
- [ ] Cross-reference by name, e.g. `(Flow 1.2)`. Links such as `[x](#slug)` do not work: GitHub
gives headings no `id` inside a comment, so there is nothing to point at.
- [ ] **Number every section and subsection**, `**<N>.<M> <label>**`, in every section and to the
same depth as the template shows. A leaf bullet is not numbered — its position under a
numbered parent already names it. This is what lets a comment say which bullet it is about.
- [ ] Cross-reference by number, e.g. `(Flow 1.2)` or `(3.1.2)`. Links such as `[x](#slug)` do not
work: GitHub gives headings no `id` inside a comment, so there is nothing to point at.

## Finally

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,15 @@ Submit the job and retrieve results:
ds.submit_python_job(
user="do@org.com",
code_path="analysis.py",
job_name="analysis",
)
ds.sync(); do.sync()

# Data owner Approves & runs job
do.jobs[0].approve()
do.jobs["ds@org.com"]["analysis"].approve()
do.process_approved_jobs(share_outputs_with_submitter=True)
do.sync(); ds.sync()
result = open(ds.jobs[-1].output_paths[0]).read()
result = open(ds.jobs["do@org.com"]["analysis"].output_paths[0]).read()
```

## Packages
Expand Down
51 changes: 50 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,43 @@ Returns a `PeerList`.

Get the list of jobs. Auto-syncs before returning.

Returns a `JobsList`.
Returns a `JobsList`. Address a job by **email, then name** — a job name is
unique per datasite and submitter, so the email is what makes the name resolve
to one job, and both parts stay the same as jobs are added:

```python
# as the data scientist, naming the data owner
client.jobs["do@org.com"]["analysis"].output_paths

# as the data owner, naming the submitter
client.jobs["ds@org.com"]["analysis"].approve()
```

**An email keeps the jobs it is a party to**, on either side: the datasite they
sit on, or the person who submitted them. Usually that is the other party — a
data scientist names the data owner, a data owner names the submitter — but
naming yourself works and keeps your own, which is what a `PermissionError` on
someone else's job suggests. Job names cannot contain `@`, so the two kinds of
key never collide.

Chain both emails when one submitter sent the same name to two datasites:

```python
client.jobs["do@org.com"]["ds@org.com"]["analysis"].approve()
```

A bare name (`client.jobs["analysis"]`) searches every datasite at once. It
still works, but it raises when more than one job answers to it, and the message
names whichever key separates them.

A job name can no longer hold an `@`. A job submitted before that rule still
resolves by name, with a `DeprecationWarning`; the next version will not resolve
it, so rename such a job.

Positional indexing (`client.jobs[0]`) also works and matches the Index column
in the table, but positions shift as jobs are added, so prefer an email and a
name. A position is worth using in one case: two jobs that share a datasite, a
submitter and a name, which no email separates.

### `client.datasets`

Expand Down Expand Up @@ -174,6 +210,7 @@ Submit a Python job to a Data Owner. **DS only.**
ds_client.submit_python_job(
user="owner@example.com",
code_path="/path/to/script.py",
job_name="analysis",
)
```

Expand All @@ -195,11 +232,23 @@ Run all approved jobs. **DO only.**
- `stream_output`: Stream stdout/stderr in real-time.
- `timeout`: Timeout in seconds per job (default: 300).
- `force_execution`: Skip version compatibility checks.
- `ignore_peer_version`: Run jobs from peers whose version is incompatible.

```python
do_client.process_approved_jobs()
```

A job whose submitter runs an incompatible version is not run. Each one is
reported by name, with the submitter and the reason:

```
⏭️ 1 approved job(s) did not run:
• analysis (submitted by ds@example.com): Skipping peer ds@example.com: incompatible version.
Pass ignore_peer_version=True to run them anyway.
```

The job stays at `approved`, so it runs on the next call once the versions match.

---

## Cleanup
Expand Down
6 changes: 6 additions & 0 deletions packages/syft-bg/src/syft_bg/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
__version__ = "0.2.2"

from syft_job.logging_config import configure_package_logger

from syft_bg.api import (
AuthResult,
AutoApproveResult,
Expand Down Expand Up @@ -61,3 +63,7 @@ def __getattr__(name: str):
print("No config file found, run syft_bg.init() first")
return
raise AttributeError(f"module 'syft_bg' has no attribute {name!r}")


# Last, so the imports stay at the top. Nothing here logs at import time.
configure_package_logger(__name__)
2 changes: 2 additions & 0 deletions packages/syft-enclave/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ requires-python = ">=3.10"
dependencies = [
"syft>=0.10.0", # floor: the `syft` PyPI project also hosts legacy PySyft <=0.9
"syft-rds>=0.6.1",
"syft-job==0.1.40", # imported directly for logging_config
"pydantic-settings>=2.11.0",
"requests>=2.32.0",
"google-auth[pyjwt]>=2.22.0",
Expand All @@ -22,6 +23,7 @@ build-backend = "hatchling.build"
[tool.uv.sources]
"syft" = { workspace = true }
"syft-rds" = { workspace = true }
"syft-job" = { workspace = true }

[tool.hatch.build.targets.wheel]
packages = ["src/syft_enclaves"]
Expand Down
4 changes: 4 additions & 0 deletions packages/syft-enclave/src/syft_enclaves/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from syft_job.logging_config import configure_package_logger
from syft_enclaves.client import SyftEnclaveClient
from syft_enclaves.login import login_do, login_ds
from syft_enclaves.runner import EnclaveRunner
Expand All @@ -10,3 +11,6 @@
"login_do",
"login_ds",
]

# Last, so the imports stay at the top. Nothing here logs at import time.
configure_package_logger(__name__)
11 changes: 4 additions & 7 deletions packages/syft-enclave/src/syft_enclaves/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def jobs(self) -> JobsList:
else j
for j in jobs_list
]
return JobsList(wrapped, jobs_list._root_email)
return JobsList(wrapped, jobs_list._root_email, jobs_list._has_do_role)

def submit_python_job(
self,
Expand Down Expand Up @@ -266,13 +266,10 @@ def approve_job(self, job: JobInfo) -> None:
if os.environ.get("PRE_SYNC", "true").lower() == "true":
self._rds.sync()

# approve() refuses when the party's approval file is missing, so
# reaching the next line means there is a file to sync.
job.approve()
file_name = enclave_approval_file_name(self.email)
approval_file = job.job_review_path / file_name
if not approval_file.exists():
print(
"🟠 Approval file does not exist yet. Kindly wait until enclave sends it."
)
approval_file = job.job_review_path / enclave_approval_file_name(self.email)
relative_path = approval_file.relative_to(self._rds.syftbox_folder)
self._rds.sync_engine.datasite_watcher_syncer.on_file_change(
relative_path, process_now=True
Expand Down
7 changes: 5 additions & 2 deletions packages/syft-enclave/src/syft_enclaves/enclave_job_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,11 @@ def approve(self) -> None:
approval_file = self.job_review_path / file_name
if not approval_file.exists():
raise PermissionError(
f"No approval file found for {self.current_user_email}. "
f"You may not be a designated party for this job."
f"No approval file for {self.current_user_email} on job "
f"'{self.name}'. The enclave writes one per designated party "
f"when it distributes the job, so either it has not distributed "
f"this job yet — run client.sync() and retry — or you are not a "
f"party to it."
)
approval = PartyApprovalStatus.load_json(approval_file)
if approval.status != JobStatus.PENDING:
Expand Down
82 changes: 82 additions & 0 deletions packages/syft-enclave/tests/test_enclave_job_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Unit tests for EnclaveJobInfo, the per-party approval gate.

The gate lives here rather than in SyftEnclaveClient.approve_job, so these
build a job on a tmp_path SyftBox folder instead of a four-party enclave flow.
"""

from datetime import datetime, timezone
from pathlib import Path

import pytest
from syft_enclaves.enclave_job_info import (
EnclaveJobInfo,
PartyApprovalStatus,
enclave_approval_file_name,
)
from syft_job.client import JobClient
from syft_job.config import SyftJobConfig
from syft_job.job import JobInfo
from syft_job.job_storage import JobRef
from syft_job.models import JobState, JobStatus, JobSubmissionMetadata

DO_EMAIL = "do@test.org"
DS_EMAIL = "ds@test.org"


def _make_enclave_job(tmp_path: Path, job_name: str = "test_job") -> EnclaveJobInfo:
"""An enclave job on the DO's datasite, with no approval file written yet."""
syftbox = tmp_path / "SyftBox"
syftbox.mkdir()
client = JobClient(
config=SyftJobConfig(syftbox_folder=syftbox, current_user_email=DO_EMAIL)
)
ref = JobRef(
datasite_email=DO_EMAIL,
ds_email=DS_EMAIL,
job_name=job_name,
protocol_version="1",
)
job = JobInfo(
job_metadata=JobSubmissionMetadata(
name=job_name,
type="python",
submitted_by=DS_EMAIL,
datasite_email=DO_EMAIL,
submitted_at=datetime.now(timezone.utc),
),
state=JobState(status=JobStatus.PENDING),
client=client,
current_user_email=DO_EMAIL,
ref=ref,
)
return EnclaveJobInfo.from_job_info(job)


def test_approve_refuses_when_approval_file_missing(tmp_path: Path):
"""No approval file means the enclave has not distributed the job yet.

The message used to say the caller may not be a designated party, which is
the wrong cause for the common case and offers nothing to do about it.
"""
job = _make_enclave_job(tmp_path)

with pytest.raises(PermissionError) as exc:
job.approve()

message = str(exc.value)
assert DO_EMAIL in message
assert "test_job" in message
assert "client.sync()" in message


def test_approve_refuses_when_already_approved(tmp_path: Path):
"""A second approval must not overwrite the first one's timestamp."""
job = _make_enclave_job(tmp_path)
approval_file = job.job_review_path / enclave_approval_file_name(DO_EMAIL)
PartyApprovalStatus(party=DO_EMAIL).save_json(approval_file)

job.approve()
assert PartyApprovalStatus.load_json(approval_file).status == JobStatus.APPROVED

with pytest.raises(ValueError, match="Already in status: approved"):
job.approve()
4 changes: 4 additions & 0 deletions packages/syft-job/src/syft_job/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# __version__ comes from the installed distribution metadata (see version.py).
from .version import __version__
from .logging_config import configure_package_logger

from .client import BaseJobClient, JobClient, get_client
from .config import SyftJobConfig
Expand Down Expand Up @@ -32,3 +33,6 @@
# Migration registry
"job_registry",
]

# Last, so the imports stay at the top. Nothing here logs at import time.
configure_package_logger(__name__)
Loading
Loading