Skip to content
16 changes: 16 additions & 0 deletions cli/medperf/commands/benchmark/update_associations_poilcy.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ def validate_emails(self):
if self.model_emails is not None:
self.model_emails = validate_and_normalize_emails(self.model_emails)

def warn_if_empty_allow_list(self):
# self.dataset_emails is None means the field wasn't part of this
# submission at all (existing allow list, if any, stays untouched);
# only an explicitly-submitted empty list ([]) warrants a warning.
if self.dataset_mode == "ALLOWLIST" and self.dataset_emails == []:
config.ui.print_warning(
"Dataset allow list is empty: no dataset associations will be "
"auto-approved until you add emails to the allow list."
)
if self.model_mode == "ALLOWLIST" and self.model_emails == []:
config.ui.print_warning(
"Model allow list is empty: no model associations will be "
"auto-approved until you add emails to the allow list."
)

def update(self):
if all(
[
Expand All @@ -122,6 +137,7 @@ def update(self):
]
):
return
self.warn_if_empty_allow_list()
body = {}
if self.dataset_emails is not None:
body["dataset_auto_approval_allow_list"] = self.dataset_emails
Expand Down
7 changes: 6 additions & 1 deletion cli/medperf/commands/mlcube/grant_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ def get_approval(self):
f"registered in Benchmark (UID: {self.benchmark_id}) access to "
"your Model.\n"
)
if not self.allowed_emails:
msg += (
"No allow list was provided, so ALL eligible Data Owners will "
"be granted access (no email filtering).\n"
)

if not self.approved and not approval_prompt(msg):
raise CleanExit("Access granting operation cancelled")
Expand Down Expand Up @@ -104,7 +109,7 @@ def prepare_certificates_list(self):
self.cert_user_info = cert_user_info

def filter_certificates(self):
if self.allowed_emails is None:
if not self.allowed_emails:
return
logging.debug("Filtering certificates based on allowed emails list")
filtered_certificates = []
Expand Down
8 changes: 5 additions & 3 deletions cli/medperf/tests/commands/mlcube/test_grant_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,11 @@ def test_filter_certificates_when_empty(grantaccess):
}
grantaccess.allowed_emails = []

# Act & Assert
with pytest.raises(CleanExit):
grantaccess.filter_certificates()
# Act
grantaccess.filter_certificates()

# Assert
assert [cert.id for cert in grantaccess.certificates] == [1, 2]


def test_verify_certificates_filters_invalid_certs(mocker, grantaccess):
Expand Down
51 changes: 50 additions & 1 deletion cli/medperf/ui/web_ui.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import threading
from queue import Queue
from contextlib import contextmanager
from yaspin import yaspin
Expand All @@ -16,6 +17,39 @@ def __init__(self):
self.task_id = None
self.events_manager = EventsManager()
self.global_events_manager = GlobalEventsManager()
self._silenced = threading.local()

@property
def _is_silenced(self) -> bool:
return getattr(self._silenced, "value", False)

@property
def _captured_messages(self):
return getattr(self._silenced, "captured", None)

@contextmanager
def capture(self):
"""Run a block without emitting task-log events or touching the
shared spinner/interactive state, scoped to the calling thread only,
collecting any messages that would have been emitted instead.

Used by background workers (e.g. the auto grant access worker) that
call into commands reporting progress via this UI, so they don't
get attributed to - or interrupt - whichever foreground task
currently owns the task-log stream, while still keeping a record of
what happened. Callers not interested in the messages can simply
ignore the yielded list.

Yields:
list[str]: messages emitted during the block, in arrival order.
"""
self._silenced.value = True
self._silenced.captured = []
try:
yield self._silenced.captured
finally:
self._silenced.captured = None
self._silenced.value = False

def print_error(self, msg: str):
"""Display an error message on the command line
Expand All @@ -37,6 +71,12 @@ def print_warning(self, msg: str):
self._print(msg, "warning")

def _print(self, msg: str = "", type: str = "print"):
if self._is_silenced:
captured = self._captured_messages
if captured is not None:
captured.append(msg)
return

if self.is_interactive:
self.spinner.write(msg)
else:
Expand Down Expand Up @@ -70,7 +110,10 @@ def interactive(self):
Yields:
CLI: Yields the current CLI instance with an interactive session initialized
"""
if self.is_interactive:
if self._is_silenced:
# don't touch the shared spinner/interactive state
yield self
elif self.is_interactive:
# if already interactive, do nothing
yield self
else:
Expand All @@ -97,6 +140,12 @@ def text(self, msg: str = ""):
# if not self.is_interactive:
# self.print(msg)

if self._is_silenced:
captured = self._captured_messages
if captured is not None:
captured.append(msg)
return

self.set_event(
Event(
task_id=self.task_id,
Expand Down
36 changes: 25 additions & 11 deletions cli/medperf/web_ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

import typer
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles

from medperf import config
Expand Down Expand Up @@ -91,15 +92,8 @@ def startup_event():
# List of [schemas.Notification] will appear in the notifications tab
web_app.state.notifications = []

# Container auto grant access initial values
web_app.state.model_auto_give_access = {
"running": False,
"worker": None,
"benchmark": 0,
"model": 0,
"emails": "",
"interval": 0,
}
# Dictionary for tracking auto give access status for each model and benchmark combination
web_app.state.model_auto_give_access = {}

# Set default UI mode to evaluation on startup, will be updated by NavModeMiddleware on each request based on cookie
web_app.state.ui_mode = UI_MODE_EVALUATION
Expand Down Expand Up @@ -129,6 +123,26 @@ def not_authenticated_exception_handler(
return RedirectResponse(url=exc.redirect_url)


@web_app.exception_handler(HTTPException)
def http_exception_handler(request: Request, exc: HTTPException):
# Reshape into the {"status", "error"} format for consistency with other error responses,
# instead of FastAPI's default {"detail": ...} body.
return JSONResponse(
status_code=exc.status_code,
content={"status": "failed", "error": exc.detail},
)


@web_app.exception_handler(RequestValidationError)
def validation_exception_handler(request: Request, exc: RequestValidationError):
messages = []
for error in exc.errors():
field = ".".join(str(p) for p in error.get("loc", []) if p != "body")
messages.append(f"{field}: {error['msg']}" if field else error["msg"])
message = "\n".join(messages) if messages else "Invalid request data."
return JSONResponse(status_code=422, content={"status": "failed", "error": message})


@web_app.get("/", include_in_schema=False)
def read_root(request: Request):
if request.app.state.ui_mode == UI_MODE_TRAINING:
Expand Down
14 changes: 11 additions & 3 deletions cli/medperf/web_ui/benchmarks/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging

import anyio

from fastapi import APIRouter, Depends, Form
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import Request
Expand Down Expand Up @@ -288,11 +290,17 @@ def update_associations_policy(
request: Request,
benchmark_id: int = Form(...),
dataset_mode: Optional[str] = Form(None),
dataset_emails: Optional[str] = Form(None),
model_mode: Optional[str] = Form(None),
model_emails: Optional[str] = Form(None),
current_user: bool = Depends(check_user_api),
):
# dataset_emails/model_emails are read from the raw form instead of via
# FastAPI's Form(None): an empty-string value there is indistinguishable
# from the field being absent, which breaks "explicitly clear the allow
# list" (as opposed to "the field wasn't part of this submission at all").
form_data = anyio.from_thread.run(lambda: request.form())
dataset_emails = form_data.get("dataset_emails")
model_emails = form_data.get("model_emails")

initialize_state_task(request, task_name="update_associations_policy")
return_response = {"status": "", "error": ""}
try:
Expand Down Expand Up @@ -325,7 +333,7 @@ def update_associations_policy(
def update_committee_members(
request: Request,
benchmark_id: int = Form(...),
committee_emails: Optional[str] = Form(None),
committee_emails: str = Form(""),
current_user: bool = Depends(check_user_api),
):
initialize_state_task(request, task_name="update_committee_members")
Expand Down
1 change: 1 addition & 0 deletions cli/medperf/web_ui/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"/current_task",
"/api/running_tasks",
"/api/stop_task",
"/containers/auto_access_logs",
]


Expand Down
Loading
Loading