Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
6 changes: 3 additions & 3 deletions notebooks/ai_audit/internal/test_email_e2e.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,9 @@
"metadata": {},
"outputs": [],
"source": [
"from syft_bg.approve.config import AutoApproveConfig\n",
"from syft_bg.common.syft_bg_config import SyftBgConfig\n",
"\n",
"cfg = AutoApproveConfig.load()\n",
"cfg = SyftBgConfig.load().approve\n",
"auto_obj = cfg.auto_approvals.objects[FIRST_JOB_NAME]\n",
"\n",
"content_names = {e.relative_path for e in auto_obj.file_contents}\n",
Expand Down Expand Up @@ -608,7 +608,7 @@
"metadata": {},
"outputs": [],
"source": [
"cfg = AutoApproveConfig.load()\n",
"cfg = SyftBgConfig.load().approve\n",
"assert API_JOB_NAME in cfg.auto_approvals.objects, \"auto_approve_job did not register a rule under the job's name\"\n",
"api_obj = cfg.auto_approvals.objects[API_JOB_NAME]\n",
"assert {e.relative_path for e in api_obj.file_contents} == {\"code.py\"}\n",
Expand Down
122 changes: 87 additions & 35 deletions packages/syft-bg/src/syft_bg/api/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Pythonic API for syft-bg initialization and configuration."""

import shutil
import tempfile
from collections.abc import Sequence
from pathlib import Path

from syft_bg.api.results import AutoApproveResult, InstallationResult, StatusResult
Expand All @@ -14,8 +16,8 @@
setup_orchestrator,
validate_auto_approve_job_inputs,
)
from syft_bg.approve.config import AutoApproveConfig, AutoApprovalObj
from syft_bg.common.config import get_syftbg_dir, get_default_paths
from syft_bg.approve.config import AutoApprovalObj, FileEntry
from syft_bg.common.config import get_default_paths, get_syftbg_dir
from syft_bg.common.drive import is_colab
from syft_bg.common.syft_bg_config import SyftBgConfig
from syft_bg.services import ServiceManager
Expand All @@ -31,15 +33,16 @@ def init(
token_path: str | Path | None = None,
settings: dict[str, dict] | None = None,
) -> None:
token_kwargs: dict = {}
if token_path is not None:
token_path = Path(token_path)
move_token_to_syftbg_dir(token_path)
# A single OAuth token is used for both the Gmail and Drive APIs.
token_path = move_token_to_syftbg_dir(Path(token_path))
token_kwargs = {"drive_token_path": token_path, "gmail_token_path": token_path}

config = SyftBgConfig(
do_email=do_email,
syftbox_root=syftbox_root,
token_path=token_path,
drive_token_path=token_path,
**token_kwargs,
)

if settings:
Expand All @@ -55,10 +58,8 @@ def ensure_running(
restart: bool = False,
install: bool = False,
) -> None:
# store new settings
try:
config = SyftBgConfig.from_path()
except FileNotFoundError:
config_path = get_default_paths().config
if not config_path.exists():
print("No config file found, run init first")
return

Expand All @@ -67,17 +68,22 @@ def ensure_running(
manager = ServiceManager()

# store new settings
for name, service_config in services.items():
service = manager.get_service(name)
if not service:
raise ValueError(f"Unknown service: {name}")
config.set_service_config(name, service_config)

config.save()
with SyftBgConfig.edit(config_path) as config:
for name, service_config in services.items():
service = manager.get_service(name)
if not service:
raise ValueError(f"Unknown service: {name}")
config.set_service_config(name, service_config)

# make sure services are running
for name, service_config in services.items():
service = manager.get_service(name)

if service is None:
# This is mainly a sanity check to satisfy type checkers, since we
# already checked above that the service exists.
raise ValueError(f"Unknown service: {name}")

if service.is_running() and not restart:
print(
f"{name} is already running, skipping. If you want to restart it, set restart=True."
Expand Down Expand Up @@ -233,7 +239,7 @@ def uninstall(service: str | None = None) -> list[InstallationResult]:
return results


def logs(service: str, n: int = 50, as_list: bool = False) -> list[str]:
def logs(service: str, n: int = 50, as_list: bool = False) -> list[str] | None:
"""Get recent log lines for a service.

Args:
Expand Down Expand Up @@ -306,8 +312,17 @@ def status() -> StatusResult:
# ---------------------------------------------------------------------------


class _AutoApprovalNotFound(Exception):
"""Raised inside SyftBgConfig.edit() to skip its save()."""


class _AutoApproveDirectoryConflict(Exception):
"""Raised inside SyftBgConfig.edit() to skip its save() when the staging
directory can't be moved into place."""


def auto_approve(
contents: list[str | Path],
contents: Sequence[str | Path],
file_paths: list[str] | None = None,
peers: list[str] | None = None,
name: str | None = None,
Expand Down Expand Up @@ -344,18 +359,50 @@ def auto_approve(
if not content_files and not file_paths:
return AutoApproveResult(success=False, error="No files to process")

config = AutoApproveConfig.load()
name = generate_unique_name(name, content_files, config)
# Copy/hash into a private staging directory (unique per call) unlocked,
# so the config lock in SyftBgConfig.edit() is held for the minimum time
# possible. Concurrent callers never share a directory before the lock
# resolves the final name, unlike copying straight into a name-derived
# directory. The staging dir lives inside auto_approvals_dir so the
# later rename into place is an atomic same-filesystem move.
auto_approvals_dir = get_default_paths().auto_approvals_dir
auto_approvals_dir.mkdir(parents=True, exist_ok=True)
staging_dir = Path(
tempfile.mkdtemp(prefix=".auto_approve_staging_", dir=auto_approvals_dir)
)
file_entries = copy_and_hash_files(content_files, staging_dir.name)

file_entries = copy_and_hash_files(content_files, name)
try:
with SyftBgConfig.edit() as syft_bg_config:
Comment thread
pjwerneck marked this conversation as resolved.
config = syft_bg_config.approve
name = generate_unique_name(name, content_files, config)
final_dir = auto_approvals_dir / name
try:
staging_dir.rename(final_dir)
except OSError as e:
raise _AutoApproveDirectoryConflict(str(e)) from e

file_entries = [
FileEntry(
relative_path=entry.relative_path,
path=str(final_dir / entry.relative_path),
hash=entry.hash,
)
for entry in file_entries
]

obj = AutoApprovalObj(
file_contents=file_entries,
file_paths=file_paths,
peers=peers,
)
config.auto_approvals.objects[name] = obj
config.save()
obj = AutoApprovalObj(
file_contents=file_entries,
file_paths=file_paths,
peers=peers,
)
config.auto_approvals.objects[name] = obj
except _AutoApproveDirectoryConflict as e:
shutil.rmtree(staging_dir, ignore_errors=True)
return AutoApproveResult(
success=False,
error=f"Could not finalize auto-approval directory '{name}': {e}",
Comment thread
pjwerneck marked this conversation as resolved.
)

return AutoApproveResult(
success=True,
Expand Down Expand Up @@ -421,7 +468,7 @@ def list_auto_approvals() -> dict[str, AutoApprovalObj]:
Returns:
Mapping of name → AutoApprovalObj.
"""
return AutoApproveConfig.load().auto_approvals.objects
return SyftBgConfig.load().approve.auto_approvals.objects


def remove_auto_approve(name: str) -> AutoApproveResult:
Expand All @@ -437,15 +484,20 @@ def remove_auto_approve(name: str) -> AutoApproveResult:
Returns:
AutoApproveResult with success/error status.
"""
config = AutoApproveConfig.load()
if name not in config.auto_approvals.objects:
# `return` inside edit() is normal control flow and wouldn't skip its
# save() — raise instead so it's actually skipped.
try:
with SyftBgConfig.edit() as syft_bg_config:
config = syft_bg_config.approve
if name not in config.auto_approvals.objects:
raise _AutoApprovalNotFound(name)

del config.auto_approvals.objects[name]
except _AutoApprovalNotFound:
return AutoApproveResult(
success=False, error=f"Auto-approval object '{name}' not found"
)

del config.auto_approvals.objects[name]
config.save()

obj_dir = get_default_paths().auto_approvals_dir / name
if obj_dir.exists():
shutil.rmtree(obj_dir, ignore_errors=True)
Expand Down
12 changes: 8 additions & 4 deletions packages/syft-bg/src/syft_bg/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import shutil
from collections.abc import Sequence
from pathlib import Path

from syft_bg.approve.config import AutoApproveConfig, FileEntry
Expand Down Expand Up @@ -104,6 +105,8 @@ def move_token_to_syftbg_dir(token_path: Path) -> Path:
else:
print(f"Warning: Provided token_path ({token_path}) does not exist.")

return Path(token_path)


def credentials_setup_steps(creds_path: Path, colab: bool) -> str:
"""Return step-by-step instructions for setting up credentials.json."""
Expand Down Expand Up @@ -189,9 +192,10 @@ def save_gcp_project_id(credentials_path: Path) -> None:
"""Extract project_id from credentials.json and save to config.yaml."""
try:
project_id = get_project_id_from_credentials(credentials_path)
config = SyftBgConfig.from_path()
config.email_approve.gcp_project_id = project_id
config.save()
if not get_default_paths().config.exists():
return
with SyftBgConfig.edit() as config:
config.email_approve.gcp_project_id = project_id
except Exception:
pass

Expand Down Expand Up @@ -220,7 +224,7 @@ def generate_unique_name(


def resolve_content_files(
contents: list[str | Path], base_dir: Path | None
contents: Sequence[str | Path], base_dir: Path | None
) -> tuple[list[tuple[str, Path]], str | None]:
"""Resolve content paths to (relative_path, absolute_path) pairs.

Expand Down
77 changes: 0 additions & 77 deletions packages/syft-bg/src/syft_bg/approve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from pathlib import Path
from typing import Optional

import yaml
from pydantic import BaseModel, Field

from syft_bg.common.config import get_default_paths
Expand Down Expand Up @@ -78,82 +77,6 @@ class AutoApproveConfig(BaseModel):
)
force_ignore_peer_version: bool = False

@classmethod
def load(cls, config_path: Optional[Path] = None) -> "AutoApproveConfig":
"""Load configuration from YAML file."""
if config_path is None:
config_path = get_default_paths().config
else:
config_path = Path(config_path).expanduser()

if not config_path.exists():
return cls()

with open(config_path) as f:
data = yaml.safe_load(f) or {}

common = {k: v for k, v in data.items() if not isinstance(v, dict)}
approve_section = data.get("approve", {})

auto_approvals_data = approve_section.get("auto_approvals", {})
peers_data = approve_section.get("peers", {})

kwargs: dict = {
"interval": approve_section.get("interval", 5),
"auto_approvals": AutoApprovalsConfig(**auto_approvals_data)
if auto_approvals_data
else AutoApprovalsConfig(),
"peers": PeerApprovalConfig(**peers_data)
if peers_data
else PeerApprovalConfig(),
"skip_peer_on_patch_version_diff": approve_section.get(
"skip_peer_on_patch_version_diff"
),
"force_ignore_peer_version": approve_section.get(
"force_ignore_peer_version", False
),
}
if common.get("do_email"):
kwargs["do_email"] = common["do_email"]
if common.get("syftbox_root"):
kwargs["syftbox_root"] = Path(common["syftbox_root"]).expanduser()
if common.get("drive_token_path"):
kwargs["drive_token_path"] = Path(common["drive_token_path"]).expanduser()

return cls(**kwargs)

def save(self, config_path: Optional[Path] = None) -> None:
"""Save configuration to YAML file."""
if config_path is None:
config_path = get_default_paths().config
else:
config_path = Path(config_path).expanduser()

config_path.parent.mkdir(parents=True, exist_ok=True)

data = {}
if config_path.exists():
with open(config_path) as f:
data = yaml.safe_load(f) or {}

if self.do_email:
data["do_email"] = self.do_email
if self.syftbox_root:
data["syftbox_root"] = str(self.syftbox_root)
if self.drive_token_path:
data["drive_token_path"] = str(self.drive_token_path)

data["approve"] = {
"interval": self.interval,
"auto_approvals": self.auto_approvals.model_dump(),
"peers": self.peers.model_dump(),
"skip_peer_on_patch_version_diff": self.skip_peer_on_patch_version_diff,
"force_ignore_peer_version": self.force_ignore_peer_version,
}

with open(config_path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)


# --- Backwards-compatible aliases (deprecated, will be removed) ---

Expand Down
9 changes: 3 additions & 6 deletions packages/syft-bg/src/syft_bg/approve/handlers/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@

from syft_job.job import JobInfo

from syft_bg.approve.config import (
AutoApprovalObj,
AutoApprovalsConfig,
AutoApproveConfig,
)
from syft_bg.approve.config import AutoApprovalObj, AutoApprovalsConfig
from syft_bg.approve.criteria import (
AutoApprovalValidationResult,
_validate_job_against_object,
)
from syft_bg.common.syft_bg_config import SyftBgConfig

if TYPE_CHECKING:
from syft_client.sync.syftbox_manager import SyftboxManager
Expand Down Expand Up @@ -49,7 +46,7 @@ def __init__(
@property
def config(self) -> AutoApprovalsConfig:
"""Always-fresh auto-approvals config, re-read from disk on every access."""
return AutoApproveConfig.load(self._config_path).auto_approvals
return SyftBgConfig.load(self._config_path).approve.auto_approvals

def _get_approved_peers(self) -> list[str]:
"""Get list of approved peer emails."""
Expand Down
Loading
Loading