From 651044556383a25021a7dc768eee1a78d408564c Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 18 Nov 2025 17:10:33 -0500 Subject: [PATCH 01/32] Draft of Tiled-based sync-experiment --- nslsii/sync_experiment/__init__.py | 2 +- nslsii/sync_experiment/sync_experiment.py | 831 +++++++++++++++------- 2 files changed, 583 insertions(+), 250 deletions(-) diff --git a/nslsii/sync_experiment/__init__.py b/nslsii/sync_experiment/__init__.py index 5528c0fa..c834ba3a 100644 --- a/nslsii/sync_experiment/__init__.py +++ b/nslsii/sync_experiment/__init__.py @@ -1 +1 @@ -from .sync_experiment import main, sync_experiment, validate_proposal, switch_redis_proposal +from .sync_experiment import main, sync_experiment, switch_proposal diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 03d53959..e062c7c5 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -1,321 +1,644 @@ import argparse import importlib.metadata +import base64 +import httpx +import json import os import re -import warnings -from datetime import datetime -from getpass import getpass -from typing import Any, Dict, Union, Optional - -import httpx import redis -import yaml -from ldap3 import NTLM, Connection, Server -from ldap3.core.exceptions import LDAPInvalidCredentialsResult, LDAPSocketOpenError -from redis_json_dict import RedisJSONDict - -data_session_re = re.compile(r"^pass-(?P\d+)$") -nslsii_api_client = httpx.Client(base_url="https://api.nsls2.bnl.gov") +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from getpass import getpass +from pydantic.types import SecretStr +from redis_json_dict import RedisJSONDict +from tiled.client.context import Context, password_grant, identity_provider_input +from tiled.profiles import load_profiles from nslsii.utils import open_redis_client -def get_current_cycle() -> str: - cycle_response = nslsii_api_client.get( - "/v1/facility/nsls2/cycles/current" - ).raise_for_status() - return cycle_response.json()["cycle"] - +facility_api_client = httpx.Client(base_url="https://api.nsls2.bnl.gov") +normalized_beamlines = { + "sst1": "sst", + "sst2": "sst", +} -def is_commissioning_proposal(proposal_number, beamline) -> bool: - """True if proposal_number is registered as a commissioning proposal; else False.""" - commissioning_proposals_response = nslsii_api_client.get( - f"/v1/proposals/commissioning?beamline={beamline}" - ).raise_for_status() - commissioning_proposals = commissioning_proposals_response.json()[ - "commissioning_proposals" - ] - return proposal_number in commissioning_proposals - -def validate_proposal(data_session_value, beamline) -> Dict[str, Any]: - proposal_data = {} - data_session_match = data_session_re.match(data_session_value) - - if data_session_match is None: +def sync_experiment( + proposal_ids: list(int), + beamline: str | None = None, + endstation: str | None = None, + facility: str = "nsls2", + select_id: int | None = None, + redis_db: int = 0, + redis_ssl: bool = False, + verbose: bool = False, +) -> RedisJSONDict: + env_beamline, env_endstation = get_beamline_env() + beamline = beamline or env_beamline + beamline = beamline.lower() + endstation = endstation or env_endstation + endstation = endstation.lower() + proposals_ids = ([str(proposal_id) for proposal_id in proposal_ids],) + select_id = select_id or proposal_ids[0] + select_id = str(select_id) + + if not beamline: raise ValueError( - f"RE.md['data_session']='{data_session_value}' " - f"is not matched by regular expression '{data_session_re.pattern}'" + "No beamline provided! Please provide a beamline argument, " + "or set the 'BEAMLINE_ACRONYM' environment variable." ) - - try: - current_cycle = get_current_cycle() - proposal_number = data_session_match.group("proposal_number") - proposal_commissioning = is_commissioning_proposal(proposal_number, beamline) - proposal_response = nslsii_api_client.get( - f"/v1/proposal/{proposal_number}" - ).raise_for_status() - proposal_data = proposal_response.json()["proposal"] - if "error_message" in proposal_data: + for proposal_id in proposal_ids: + if not re.fullmatch(r"^\d{6}$", proposal_id): raise ValueError( - f"while verifying data_session '{data_session_value}' " - f"an error was returned by {proposal_response.url}: " - f"{proposal_data}" + f"Provided proposal ID '{proposal_id}' is not valid.\n " + f"A proposal ID must be a 6 character integer." ) - else: - if ( - not proposal_commissioning - and current_cycle not in proposal_data["cycles"] - ): - raise ValueError( - f"Proposal {data_session_value} is not valid in the current NSLS2 cycle ({current_cycle})." - ) - if beamline.upper() not in proposal_data["instruments"]: - raise ValueError( - f"Wrong beamline ({beamline.upper()}) for proposal {data_session_value} ({', '.join(proposal_data['instruments'])})." - ) - # data_session is valid! - - except httpx.RequestError as rerr: - # give the user a warning but allow the run to start - warnings.warn( - f"while verifying data_session '{data_session_value}' " - f"the request {rerr.request.url!r} failed with " - f"'{rerr}'" - ) - - return proposal_data - + if select_id not in proposal_ids: + raise ValueError(f"Cannot select a proposal which is not being activated.") + + normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) + # to-do:make this client use secure Redis (for API key storage) + redis_client = redis.Redis( + host=f"info.{normalized_beeamline}.nsls2.bnl.gov", + port=6379, + db=15, + decode_responses=True, + ) -config_files = [ - os.path.expanduser("~/.config/n2sn_tools.yml"), - "/etc/n2sn_tools.yml", -] + username, password = prompt_for_login() + try: + tiled_context = create_tiled_context( + username, password, normalized_beamline, endstation + ) + except Exception: + raise # except if login fails + data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} + if not proposal_can_be_activated(username, facility, beamline, data_sessions): + tiled_context.logout() + raise ValueError( + f"You do not have permissions to activate all proposal IDs: ', '.join(proposal_ids)" + ) + try: + proposals = retrieve_proposals(facility, beamline, proposal_ids) + except Exception: + tiled_context.logout() + raise # except if proposal retrieval fails -def authenticate( - username, -): - config = None - for fn in config_files: + api_key = get_api_key( + redis_client, password, normalized_beamline, endstation, data_sessions + ) + if not api_key: try: - with open(fn) as f: - config = yaml.safe_load(f) - except IOError: - pass - else: - break - - if config is None: - raise RuntimeError("Unable to open a config file") - - server = config.get("common", {}).get("server") - - if server is None: - raise RuntimeError("Server name not found!") - - auth_server = Server(server, use_ssl=True) - - try: - connection = Connection( - auth_server, - user=f"BNL\\{username}", - password=getpass("Password : "), - authentication=NTLM, - auto_bind=True, - raise_exceptions=True, + api_key_info = create_api_key(redis_client, tiled_context, data_sessions) + except: + tiled_context.logout() + raise # when cant create key + cache_api_key( + redis_client, + username, + password, + normalized_beamline, + endstation, + data_sessions, + api_key_info, ) - print(f"\nAuthenticated as : {connection.extend.standard.who_am_i()}") + api_key = get_api_key( + redis_client, password, normalized_beamline, endstation, data_sessions + ) + set_api_key(redis_client, normalized_beamline, endstation, data_sessions, api_key) - except LDAPInvalidCredentialsResult: - raise RuntimeError(f"Invalid credentials for user '{username}'.") from None - except LDAPSocketOpenError: - print(f"{server} server connection failed...") + tiled_context.logout() + # activate_proposals(username, select_id, data_sessions, proposals, normalized_beamline, endstation, facility) + # this was a function, but is now inlined below to prevent changing experiment without auth + redis_location = ( + f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" + ) -def should_they_be_here(username, new_data_session, beamline): - user_access_json = nslsii_api_client.get(f"/v1/data-session/{username}").json() + md_redis_client = open_redis_client( + redis_ssl=redis_ssl, + redis_location=redis_location, + redis_db=redis_db, + redis_url=( + f"info.{normalized_beamline}.nsls2.bnl.gov" + if not redis_ssl + else None + ), + ) - if "nsls2" in user_access_json["facility_all_access"]: - return True + if verbose: + print(f"Redis connection info: {md_redis_client.client().connection}") - elif beamline.lower() in user_access_json["beamline_all_access"]: - return True + redis_prefix = f"{redis_location}-" if not redis_ssl else "" + md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) - elif new_data_session in user_access_json["data_sessions"]: - return True + select_session = "pass-" + select_id + proposal = proposals[select_proposal] + + md["data_sessions_active"] = data_sessions + users = proposal.pop("users") + pi_name = "" + for user in users: + if user.get("is_pi"): + pi_name = ( + f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() + ) + md["data_session"] = select_session # e.g. "pass-123456" + md["username"] = username + md["start_datetime"] = datetime.now().isoformat() + # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata + md["tiled_access_tags"] = list(select_session) + md["cycle"] = ( + "commissioning" + if select_proposal in get_commissioning_proposals(facility, beamline) + else get_current_cycle(facility) + ) + md["proposal"] = { + "proposal_id": proposal.get("proposal_id"), + "title": proposal.get("title"), + "type": proposal.get("type"), + "pi_name": pi_name, + } - return False + print( + f"Activated experiments with data sessions {', '.join(md['data_sessions_active'])}\n" + ) + print(f"Switched to experiment {md['data_session']} by {md['username']}.") + if verbose: + print(md) -class AuthorizationError(Exception): ... + return md -def switch_redis_proposal( - proposal_number: Union[int, str], - beamline: str, - username: Optional[str] = None, - endstation: str = "", - redis_db: int = 0, - redis_ssl: bool = False, - verbose: bool = False, +def switch_proposal( + username, proposal_id, beamline, endstation=None, facility="nsls2", redis_db=0, redis_ssl=False, verbose=False, ) -> RedisJSONDict: - """Update information in RedisJSONDict for a specific beamline + """Swith the loaded experiment (proposal) information at the beamline. Parameters ---------- - proposal_number : int or str - number of the desired proposal, e.g. `123456` - beamline : str - Beamline acronym, case-insensitive, e.g. `SMI` or `sst1` - username : str or None - login name of the user assigned to the proposal; if None, current user will be kept - endstation : str, optional - optional name identify a specific endstation, e.g. `opls` - redis_db : int, optional - optional Redis database index, defaults to 0 - redis_ssl : bool, optional - optional flag to enable/disable ssl connections to redis + username: str + the current user's username + proposal_id: int or str + the ID number of the proposal to load + beamline: str + the TLA of the beamline from which the experiment is running, not case-sensitive + endstation : str or None (optional) + the endstation at the beamline from which the experiment is running, not case-sensitive + facility: str (optional) + the facility that the beamline belongs to (defaults to "nsls2") Returns ------- md : RedisJSONDict - The updated redis dictionary. + The updated metadata dictionary """ - normalized_beamlines = { - "sst1": "sst", - "sst2": "sst", - } - redis_beamline = normalized_beamlines.get(beamline.lower(), beamline) - location = endstation if endstation else redis_beamline - if redis_ssl: - redis_client = open_redis_client( - redis_ssl=redis_ssl, - redis_location=location, - redis_db=redis_db, - ) - redis_prefix = None - else: - redis_url = f"info.{redis_beamline}.nsls2.bnl.gov" - redis_client = open_redis_client( - redis_ssl=redis_ssl, - redis_location=location, - redis_url=redis_url, - redis_db=redis_db, - ) - redis_prefix = endstation + beamline = normalized_beamlines.get(beamline.lower(), beamline.lower()) + endstation = endstation.lower() + redis_location = ( + f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" + ) + + md_redis_client = open_redis_client( + redis_ssl=redis_ssl, + redis_location=redis_location, + redis_db=redis_db, + redis_url=( + f"info.{normalized_beamline}.nsls2.bnl.gov" + if not redis_ssl + else None + ), + ) + if verbose: - print(f"Redis connection info: {redis_client.client().connection}") + print(f"Redis connection info: {md_redis_client.client().connection}") + + redis_prefix = f"{redis_location}-" if not redis_ssl else "" + md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) - if redis_prefix and redis_ssl: + select_proposal = str(proposal_id) + select_session = "pass-" + select_proposal + data_sessions_active = md.get("data_sessions_active") + if not data_sessions_active: raise ValueError( - f"Incompatible arguments: '{redis_prefix=}' and '{redis_ssl=}'. Prefixes are no longer supported " - f"when using SSL encryption. Specify `redis_db` if you want to use a distinct set of keys with '{redis_ssl=}'." + "There are no currently active data sessions (proposals).\n" + "Please run sync-experiment before attempting to switch the loaded proposal." ) - prefix = f"{redis_prefix}-" if redis_prefix and not redis_ssl else "" - md = RedisJSONDict(redis_client=redis_client, prefix=prefix) - username = username or md.get("username") - - new_data_session = f"pass-{proposal_number}" - if (new_data_session == md.get("data_session")) and ( - username == md.get("username") - ): - # The cycle needs to get updated regardless of experiment status - md["cycle"] = ( - "commissioning" - if is_commissioning_proposal(str(proposal_number), beamline) - else get_current_cycle() + if not username == md.get("username"): + raise ValueError( + "The currently active data sessions (proposals) were activated by a different user.\n" + "Please re-run sync-experiment to authenticate as different user." ) - warnings.warn( - f"Experiment {new_data_session} was already started by the same user." + if select_session not in data_sessions_active: + raise ValueError( + f"Cannot switch to proposal which has not been activated.\n" + f"The activated data sessions are: {', '.join(data_sessions_active)}\n" + f"To activate different proposals, re-run sync-experiment." ) - else: - if not should_they_be_here(username, new_data_session, beamline): - raise AuthorizationError( - f"User '{username}' is not allowed to take data on proposal {new_data_session}" + proposals = retrieve_proposals(facility, beamline, [select_proposal]) + proposal = proposals[select_proposal] + + users = proposal.pop("users") + pi_name = "" + for user in users: + if user.get("is_pi"): + pi_name = ( + f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() ) + md["data_session"] = select_session # e.g. "pass-123456" + md["username"] = username + md["start_datetime"] = datetime.now().isoformat() + # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata + md["tiled_access_tags"] = list(select_session) + md["cycle"] = ( + "commissioning" + if select_proposal in get_commissioning_proposals(facility, beamline) + else get_current_cycle(facility) + ) + md["proposal"] = { + "proposal_id": proposal.get("proposal_id"), + "title": proposal.get("title"), + "type": proposal.get("type"), + "pi_name": pi_name, + } + + print(f"Switched to experiment {md['data_session']} by {md['username']}.") + + return md + + +def encrypt_api_key(password, api_key): + """ + This is a reference implementation taken from the + cryptography library docs: + https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet + + The number of iterations was taken from Django's current settings. + """ + salt = os.urandom(16) + kdf = PBKDF2HMAC( + algorithm=hashes.SA256(), + length=32, + salt=salt, + iteration=1_500_000, + ) + key = base64.urlsafe_b64_encode(kdf.derive(password.get_secret_value())) + f = Fernet(key) + token = f.encrypt(api_key.encode("UTF-8")) + return token, salt + + +def decrypt_api_key(password, salt, api_key_encrypted): + """ + This is a reference implementation taken from the + cryptography library docs: + https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet + + The number of iterations was taken from Django's current settings. + """ + kdf = PBKDF2HMAC( + algorithm=hashes.SA256(), + length=32, + salt=salt, + iteration=1_500_000, + ) + key = base64.urlsafe_b64_encode(kdf.derive(password.get_secret_value())) + f = Fernet(key) + token = f.decrypt(api_key_encrypted.encode("UTF-8")) + return token + + +def prompt_for_login(facility, beamline, endstation, proposal_ids): + print(f"Welcome to the {beamline.upper()} beamline at {facility.upper()}!") + if endstation: + print(f"This is the {endstation.upper()} endstation.") + print( + f"Attempting to sync experiment for proposal ID(s) {(', ').join(proposal_ids)}." + ) + print(f"Please login with your BNL credentials.") + username = input("Username: ") + password = SecretStr(getpass(prompt="Password: ")) + return username, password + + +def create_tiled_context(username, password, beamline, endstation): + """ + Createa new Tiled context and authenticate. + + Loads the beamline Tiled profile, instantiates the new context, + selects an AuthN provider, attempts to retrieve tokens via password_grant, + then prints a confirmation message and authenticates the context. - proposal_data = validate_proposal(new_data_session, beamline) - users = proposal_data.pop("users") - pi_name = "" - for user in users: - if user.get("is_pi"): - pi_name = ( - f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() - ) - md["data_session"] = new_data_session # e.g. "pass-123456" - md["username"] = username - md["start_datetime"] = datetime.now().isoformat() - # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata - md["tiled_access_tags"] = [new_data_session] - md["cycle"] = ( - "commissioning" - if is_commissioning_proposal(str(proposal_number), beamline) - else get_current_cycle() + """ + profiles = load_profiles() + if endstation and endstation in profiles: + _, profile = profiles[endstation] + elif beamline in profiles: + _, profile = profiles[beamline] + else: + raise ValueError(f"Cannot find Tiled profile for beamline {beamline.upper()}") + + context, _ = Context.from_any_uri( + profile["uri"], verify=profile.get("verify", True) + ) + + providers = context.server_info.authentication.providers + http_client = context.http_client + if len(providers) == 1: + # There is only one choice, so no need to prompt the user. + spec = providers[0] + else: + spec = identity_provider_input(providers) + auth_endpoint = spec.links["auth_endpoint"] + provider = spec.provider + client_id = spec.links.get("client_id") + token_endpoint = spec.links.get("token_endpoint") + oauth2_spec = True if client_id and token_endpoint else False + mode = spec.mode + + if mode != "internal": + raise ValueError( + "Selected provider is not mode 'internal', and " + "sync-experiment only supports password auth currently." + "Please select a provider with mode='internal'." ) - md["proposal"] = { - "proposal_id": proposal_data.get("proposal_id"), - "title": proposal_data.get("title"), - "type": proposal_data.get("type"), - "pi_name": pi_name, - } - print(f"Started experiment {md['data_session']} by {md['username']}.") + try: + tokens = password_grant( + http_client, auth_endpoint, provider, username, password.get_secret_value() + ) + except httpx.HTTPStatusError as err: + if err.response.status_code == httpx.codes.UNAUTHORIZED: + raise ValueError("Username or password not recognized.") + else: + raise - return md + confirmation_message = spec.confirmation_message + if confirmation_message: + username = "external user" if oauth2_spec else tokens["identity"]["id"] + print(confirmation_message.format(id=username)) + context.configure_auth(tokens, remember_me=False) -def sync_experiment( - proposal_number, - beamline, - verbose=False, - endstation="", - redis_db: int = 0, - redis_ssl=False, + return context + + +def create_api_key(tiled_context, data_sessions): + tags = [data_session for data_session in data_sessions] + expires_in = "7d" + hostname = os.getenv("HOSTNAME", "unknown host") + note = f"Auto-generated by sync-experiment from {hostname}" + + if expires_in and expires_in.isdigit(): + expires_in = int(expires_in) + info = tiled_context.create_api_key( + access_tags=access_tags, expires_in=expires_in, note=note + ) + return info + + +def set_api_key(redis_client, beamline, endstation, data_sessions, api_key): + """ + Use to set the active API key in Redis. + + The active API key is stored with key: + ----apikey-active + + """ + redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" + redis_prefix = f"{redis_prefix}-{username}-{data_sessions.join('-')}-apikey" + redis_client.set(f"{redis_prefix}-active", api_key) + + +def get_api_key(redis_client, password, beamline, endstation, data_sessions): + """ + Retrieve an API key from Redis. + Query Redis for key information, decrypt the API key if found, + delete the key if it is expired, and finally return the key + if it is still fresh. + + """ + redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" + redis_prefix = f"{redis_prefix}-{username}-{data_sessions.join('-')}-apikey" + + api_key_cached = {} + while True: + cursor, keys = redis_client.scan(cursor=cursor, match=f"{redis_prefix}*") + if keys: + values = redis_client.mget(keys) + api_key_cached.update(dict(zip(keys, values))) + if cursor == 0: + break + + if api_key_info: + expires = datetime.fromisoformat(api_key_info[f"{redis_prefix}-expires"]) + now = datetime.now().replace(microsecond=0).isoformat() + if expires > now: + salt = api_key_cached[f"{redis_prefix}-salt"] + api_key_encrypted = api_key_cached[f"{redis_prefix}-encrypted"] + api_key = decrypt_api_key( + password.get_secret_value(), salt, api_key_encrypted + ) + else: + api_key = None + else: + api_key = None + + return api_key + + +def cache_api_key( + redis_client, username, password, beamline, endstation, data_sessions, api_key_info ): - # Authenticate the user - username = input("Username : ") - authenticate(username) - - md = switch_redis_proposal( - proposal_number, - beamline=beamline, - username=username, - endstation=endstation, - redis_db=redis_db, - redis_ssl=redis_ssl, - verbose=verbose, + """ + Encrypt and cache and API key in Redis. + + Keys are rotated out via priority queue according to their + expiration dates. There is a limit set on the number of API keys that + may be cached a time. + + The cached values have keys that are prefixed with: + ----apikey + + There are 4 keys per each API key: + -expires : the timestamp of when the key will expire + -created : the timestamp of when the key was created/cached + -encrypted : the encrypted API key + -salt : the salt used for encryption + + """ + redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" + redis_prefix = f"{redis_prefix}-{username}-{data_sessions.join('-')}-apikey" + + MAX_ENTRIES = 5 + COUNT_PER_ENTRY = 4 + COUNT_NON_ENTRIES = 1 + length = redis_client.dbsize + + cursor = 0 + expiry_dates = [] + while True: + cursor, keys = redis_client.scan(cursor=cursor, match="*expires") + if keys: + values = redis_client.mget(keys) + expiry_dates.extend( + [ + (datetime.fromisoformat(v), k.removesuffix("-expires")) + for k, v in zip(keys, values) + ] + ) + if cursor == 0: + break + + heapq.heapify(expiry_dates) + while (length() - COUNT_NON_ENTRIES) / COUNTS_PER_ENTRY >= MAX_ENTRIES: + expiry_prefix = heapify.heappop(expiry_heap)[1] + cursor = 0 + while True: + cursor, keys = r.scan(cursor=cursor, match=f"{expiry_prefix}*", count=10) + if keys: + r.delete(*keys) + if cursor == 0: + break + + encrypted_key, salt = encrypt_api_key( + password.get_secret_value(), api_key_info[secret] + ) + redis_client.set(f"{redis_prefix}-expires", api_key_info[expiration_time]) + redis_client.set( + f"{redis_prefix}-created", datetime.now().replace(microsecond=0).isoformat() ) + redis_client.set(f"{redis_prefix}-encrypted", encrypted_key) + redis_client.set(f"{redis_prefix}-salt", salt) - if verbose: - print(md) - return md +def get_current_cycle(facility): + cycle_response = facility_api_client.get(f"/v1/facility/{facility}/cycles/current") + cycle_response.raise_for_status() + cycle = cycle_response.json()["cycle"] + return cycle + + +def get_commissioning_proposals(facility, beamline): + proposals_response = facility_api_client.get( + f"/v1/proposals/commissioning?beamline={beamline}&facility={facility}" + ) + proposals_response.raise_for_status() + commissioning_proposals = proposals_response.json()["commissioning_proposals"] + return commissioning_proposals + + +def proposal_can_be_activated(username, facility, beamline, data_sessions): + """ + Check that the user can activate the requested proposals, + given their data_sessions. + + Activation will be allowed if the user has facility or + beamline "all access", or is listed on all of the proposals. + + Note: for this check to be effective, each proposal also + needs to be checked to ensure that the beamline matches + on the requested proposals. + This must be done in a subsequent validation step. + Otherwise, access could be granted to the wrong proposals. + """ + user_access_response = facility_api_client.get(f"/v1/data-session/{username}") + user_access_response.raise_for_status() + user_access = user_access_response.json() + + can_activate = ( + facility.lower() in user_access["facility_all_access"] + or beamline.lower() in user_access["beamline_all_access"] + or all( + data_session in user_access["data_sessions"] + for data_session in data_sessions + ) + ) + return can_activate + + +def retrieve_proposals(facility, beamline, proposal_ids): + """ + Retrieve the data for the proposals that are being activated. + This is also a validation step, ensuring that all the + reequested proposals match the beamline. Without this validation, + access could be granted to the wrong proposals. + + In the future, this should also match proposals by facility as well. + + If multiple proposals are to be activated, they must all be allocated + for the same (current) cycle. For commissioning proposals, only one proposal + can be activated at a time. + """ + current_cycle = get_current_cycle(facility) + commissioning_proposals = get_commissioning_proposals(facility, beamline) + num_proposals = len(proposal_ids) + proposals = {} + for proposal_id in proposal_ids: + proposal_response = nslsii_api_client.get(f"/v1/proposal/{proposal_id}") + proposal_response.raise_for_status() + proposal = proposal_response.json()["proposal"] + if beamline.upper() not in proposal_data["instruments"]: + raise ValueError( + f"Proposal {proposal_id} is not at this beamline ({beamline.upper()})." + f"This proposal is at the following beamline(s): {', '.join(proposal['instruments'])}." + ) + is_commissioning_proposal = proposal_id in commissioning_rpoposals + if num_proposals > 1 and is_commissioning_proposal: + raise ValueError( + f"Cannot activate multiple experiments alongside a commmissioning proposal." + f"Proposal {proposal_id} is a commissioning proposal." + ) + if not is_commissioning_proposal and current_cycle not in proposal["cycles"]: + raise ValueError( + f"Proposal {proposal_id} is not allocated for the current {facility.upper()} cycle ({current_cycle})." + ) + proposals[proposal_id] = proposal + + return proposals + + +def get_beamline_env(): + os.getenv("BEAMLINE_ACRONYM").lower() + os.getenv("ENDSTATION_ACRONYM").lower() + return beamline, endstation def main(): # Used by the `sync-experiment` command parser = argparse.ArgumentParser( - description="Start or switch beamline experiment and record it in Redis" + description="Activate an experiment (proposal) - requires authentication" + ) + parser.add_argument( + "-f", + "--facility", + dest="facility", + type=str, + help="The facility for the experiment (e.g. NSLS2)", + required=False, + default="nsls2", ) parser.add_argument( "-b", "--beamline", dest="beamline", type=str, - help="Which beamline (e.g. CHX)", - required=True, + help="The beamline for the experiment (e.g. CHX)", + required=False, ) parser.add_argument( "-e", "--endstation", dest="endstation", type=str, - default="", - help="Beamline endstation (for Redis lookup)", + help="The beamline endstation for the experiment, if applicable", required=False, ) parser.add_argument( @@ -325,22 +648,22 @@ def main(): type=int, default=0, help="Redis database index", - required=False, ) parser.add_argument( "-p", - "--proposal", - dest="proposal", + "--proposals", + dest="proposals", + nargs="+", type=int, - help="Which proposal (e.g. 123456)", + help="The proposal ID for the experiment", required=True, ) parser.add_argument( - "-s", + "-S", "--enable-ssl", dest="redis_ssl", action="store_true", - help="Flag to enable ssl connection with redis", + help="Enable SSL for Redis connections", ) parser.add_argument( "-V", @@ -348,14 +671,24 @@ def main(): action="version", version=f"%(prog)s {importlib.metadata.version('nslsii')}", ) + parser.add_argument( + "-s", + "--select", + dest="select", + type=int, + help="The proposal ID to select and load, defaults to the first in the proposals list.", + required=False, + ) parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) args = parser.parse_args() sync_experiment( - proposal_number=args.proposal, + facility=args.facility, beamline=args.beamline, - verbose=args.verbose, endstation=args.endstation, + proposal_ids=args.proposals, + select_id=args.select, + verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, ) From ddd82b1cb1f66b3429ca31a7998603fb8f77de39 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 18 Nov 2025 17:28:14 -0500 Subject: [PATCH 02/32] Cleanups --- nslsii/sync_experiment/sync_experiment.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index e062c7c5..119ac867 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -10,6 +10,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from datetime import datetime from getpass import getpass from pydantic.types import SecretStr from redis_json_dict import RedisJSONDict @@ -27,7 +28,7 @@ def sync_experiment( - proposal_ids: list(int), + proposal_ids: list[int], beamline: str | None = None, endstation: str | None = None, facility: str = "nsls2", @@ -41,9 +42,9 @@ def sync_experiment( beamline = beamline.lower() endstation = endstation or env_endstation endstation = endstation.lower() - proposals_ids = ([str(proposal_id) for proposal_id in proposal_ids],) - select_id = select_id or proposal_ids[0] - select_id = str(select_id) + proposals_ids = [str(proposal_id) for proposal_id in proposal_ids] + select_proposal = select_id or proposal_ids[0] + select_proposal = str(select_proposal) if not beamline: raise ValueError( @@ -56,19 +57,19 @@ def sync_experiment( f"Provided proposal ID '{proposal_id}' is not valid.\n " f"A proposal ID must be a 6 character integer." ) - if select_id not in proposal_ids: + if select_proposal not in proposal_ids: raise ValueError(f"Cannot select a proposal which is not being activated.") normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) # to-do:make this client use secure Redis (for API key storage) redis_client = redis.Redis( - host=f"info.{normalized_beeamline}.nsls2.bnl.gov", + host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, db=15, decode_responses=True, ) - username, password = prompt_for_login() + username, password = prompt_for_login(facility, beamline, endstation, proposal_ids) try: tiled_context = create_tiled_context( username, password, normalized_beamline, endstation @@ -93,7 +94,7 @@ def sync_experiment( ) if not api_key: try: - api_key_info = create_api_key(redis_client, tiled_context, data_sessions) + api_key_info = create_api_key(tiled_context, data_sessions) except: tiled_context.logout() raise # when cant create key @@ -112,7 +113,7 @@ def sync_experiment( set_api_key(redis_client, normalized_beamline, endstation, data_sessions, api_key) tiled_context.logout() - # activate_proposals(username, select_id, data_sessions, proposals, normalized_beamline, endstation, facility) + # activate_proposals(username, select_proposal, data_sessions, proposals, normalized_beamline, endstation, facility) # this was a function, but is now inlined below to prevent changing experiment without auth redis_location = ( @@ -136,7 +137,7 @@ def sync_experiment( redis_prefix = f"{redis_location}-" if not redis_ssl else "" md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) - select_session = "pass-" + select_id + select_session = "pass-" + select_proposal proposal = proposals[select_proposal] md["data_sessions_active"] = data_sessions From 18dd94be44ae7c464f1640a9e2f1bef9bc018e75 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 19 Nov 2025 18:58:09 -0500 Subject: [PATCH 03/32] Smoothing out wrinkles --- nslsii/sync_experiment/sync_experiment.py | 114 +++++++++++++--------- 1 file changed, 68 insertions(+), 46 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 119ac867..869292d0 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -1,6 +1,7 @@ import argparse import importlib.metadata import base64 +import heapq import httpx import json import os @@ -10,7 +11,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from datetime import datetime +from datetime import datetime, timezone from getpass import getpass from pydantic.types import SecretStr from redis_json_dict import RedisJSONDict @@ -39,10 +40,8 @@ def sync_experiment( ) -> RedisJSONDict: env_beamline, env_endstation = get_beamline_env() beamline = beamline or env_beamline - beamline = beamline.lower() endstation = endstation or env_endstation - endstation = endstation.lower() - proposals_ids = [str(proposal_id) for proposal_id in proposal_ids] + proposal_ids = [str(proposal_id) for proposal_id in proposal_ids] select_proposal = select_id or proposal_ids[0] select_proposal = str(select_proposal) @@ -58,15 +57,17 @@ def sync_experiment( f"A proposal ID must be a 6 character integer." ) if select_proposal not in proposal_ids: - raise ValueError(f"Cannot select a proposal which is not being activated.") + raise ValueError("Cannot select a proposal which is not being activated.") + beamline = beamline.lower() + if endstation: + endstation = endstation.lower() normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) # to-do:make this client use secure Redis (for API key storage) redis_client = redis.Redis( host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, db=15, - decode_responses=True, ) username, password = prompt_for_login(facility, beamline, endstation, proposal_ids) @@ -81,7 +82,7 @@ def sync_experiment( if not proposal_can_be_activated(username, facility, beamline, data_sessions): tiled_context.logout() raise ValueError( - f"You do not have permissions to activate all proposal IDs: ', '.join(proposal_ids)" + f"You do not have permissions to activate all proposal IDs: {', '.join(proposal_ids)}" ) try: proposals = retrieve_proposals(facility, beamline, proposal_ids) @@ -90,7 +91,7 @@ def sync_experiment( raise # except if proposal retrieval fails api_key = get_api_key( - redis_client, password, normalized_beamline, endstation, data_sessions + redis_client, username, password, normalized_beamline, endstation, data_sessions ) if not api_key: try: @@ -108,9 +109,14 @@ def sync_experiment( api_key_info, ) api_key = get_api_key( - redis_client, password, normalized_beamline, endstation, data_sessions + redis_client, + username, + password, + normalized_beamline, + endstation, + data_sessions, ) - set_api_key(redis_client, normalized_beamline, endstation, data_sessions, api_key) + set_api_key(redis_client, normalized_beamline, endstation, api_key) tiled_context.logout() # activate_proposals(username, select_proposal, data_sessions, proposals, normalized_beamline, endstation, facility) @@ -140,7 +146,7 @@ def sync_experiment( select_session = "pass-" + select_proposal proposal = proposals[select_proposal] - md["data_sessions_active"] = data_sessions + md["data_sessions_active"] = list(data_sessions) users = proposal.pop("users") pi_name = "" for user in users: @@ -284,12 +290,14 @@ def encrypt_api_key(password, api_key): """ salt = os.urandom(16) kdf = PBKDF2HMAC( - algorithm=hashes.SA256(), + algorithm=hashes.SHA256(), length=32, salt=salt, - iteration=1_500_000, + iterations=1_500_000, + ) + key = base64.urlsafe_b64encode( + kdf.derive(password.get_secret_value().encode("UTF-8")) ) - key = base64.urlsafe_b64_encode(kdf.derive(password.get_secret_value())) f = Fernet(key) token = f.encrypt(api_key.encode("UTF-8")) return token, salt @@ -304,15 +312,17 @@ def decrypt_api_key(password, salt, api_key_encrypted): The number of iterations was taken from Django's current settings. """ kdf = PBKDF2HMAC( - algorithm=hashes.SA256(), + algorithm=hashes.SHA256(), length=32, salt=salt, - iteration=1_500_000, + iterations=1_500_000, + ) + key = base64.urlsafe_b64encode( + kdf.derive(password.get_secret_value().encode("UTF-8")) ) - key = base64.urlsafe_b64_encode(kdf.derive(password.get_secret_value())) f = Fernet(key) - token = f.decrypt(api_key_encrypted.encode("UTF-8")) - return token + token = f.decrypt(api_key_encrypted) + return token.decode("UTF-8") def prompt_for_login(facility, beamline, endstation, proposal_ids): @@ -322,7 +332,7 @@ def prompt_for_login(facility, beamline, endstation, proposal_ids): print( f"Attempting to sync experiment for proposal ID(s) {(', ').join(proposal_ids)}." ) - print(f"Please login with your BNL credentials.") + print("Please login with your BNL credentials.") username = input("Username: ") password = SecretStr(getpass(prompt="Password: ")) return username, password @@ -391,7 +401,7 @@ def create_tiled_context(username, password, beamline, endstation): def create_api_key(tiled_context, data_sessions): - tags = [data_session for data_session in data_sessions] + access_tags = [data_session for data_session in data_sessions] expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") note = f"Auto-generated by sync-experiment from {hostname}" @@ -404,20 +414,21 @@ def create_api_key(tiled_context, data_sessions): return info -def set_api_key(redis_client, beamline, endstation, data_sessions, api_key): +def set_api_key(redis_client, beamline, endstation, api_key): """ Use to set the active API key in Redis. The active API key is stored with key: - ----apikey-active + --apikey-active """ - redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" - redis_prefix = f"{redis_prefix}-{username}-{data_sessions.join('-')}-apikey" + redis_prefix = ( + f"{beamline}-{endstation}-apikey" if endstation else f"{beamline}-apikey" + ) redis_client.set(f"{redis_prefix}-active", api_key) -def get_api_key(redis_client, password, beamline, endstation, data_sessions): +def get_api_key(redis_client, username, password, beamline, endstation, data_sessions): """ Retrieve an API key from Redis. Query Redis for key information, decrypt the API key if found, @@ -426,26 +437,32 @@ def get_api_key(redis_client, password, beamline, endstation, data_sessions): """ redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" - redis_prefix = f"{redis_prefix}-{username}-{data_sessions.join('-')}-apikey" + redis_prefix = ( + f"{redis_prefix}-{username}-" + f"{'-'.join(data_session.replace('-', '') for data_session in data_sessions)}" + f"-apikey" + ) api_key_cached = {} + cursor = 0 while True: cursor, keys = redis_client.scan(cursor=cursor, match=f"{redis_prefix}*") if keys: values = redis_client.mget(keys) + keys = [key.decode("UTF-8") for key in keys] api_key_cached.update(dict(zip(keys, values))) if cursor == 0: break - if api_key_info: - expires = datetime.fromisoformat(api_key_info[f"{redis_prefix}-expires"]) - now = datetime.now().replace(microsecond=0).isoformat() + if api_key_cached: + expires = datetime.fromisoformat( + api_key_cached[f"{redis_prefix}-expires"].decode("UTF-8") + ) + now = datetime.now(timezone.utc) if expires > now: salt = api_key_cached[f"{redis_prefix}-salt"] api_key_encrypted = api_key_cached[f"{redis_prefix}-encrypted"] - api_key = decrypt_api_key( - password.get_secret_value(), salt, api_key_encrypted - ) + api_key = decrypt_api_key(password, salt, api_key_encrypted) else: api_key = None else: @@ -475,7 +492,11 @@ def cache_api_key( """ redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" - redis_prefix = f"{redis_prefix}-{username}-{data_sessions.join('-')}-apikey" + redis_prefix = ( + f"{redis_prefix}-{username}-" + f"{'-'.join(data_session.replace('-', '') for data_session in data_sessions)}" + f"-apikey" + ) MAX_ENTRIES = 5 COUNT_PER_ENTRY = 4 @@ -490,7 +511,10 @@ def cache_api_key( values = redis_client.mget(keys) expiry_dates.extend( [ - (datetime.fromisoformat(v), k.removesuffix("-expires")) + ( + datetime.fromisoformat(v.decode("UTF-8")), + k.decode("UTF-8").removesuffix("-expires"), + ) for k, v in zip(keys, values) ] ) @@ -498,7 +522,7 @@ def cache_api_key( break heapq.heapify(expiry_dates) - while (length() - COUNT_NON_ENTRIES) / COUNTS_PER_ENTRY >= MAX_ENTRIES: + while (length() - COUNT_NON_ENTRIES) / COUNT_PER_ENTRY >= MAX_ENTRIES: expiry_prefix = heapify.heappop(expiry_heap)[1] cursor = 0 while True: @@ -508,13 +532,11 @@ def cache_api_key( if cursor == 0: break - encrypted_key, salt = encrypt_api_key( - password.get_secret_value(), api_key_info[secret] - ) - redis_client.set(f"{redis_prefix}-expires", api_key_info[expiration_time]) + encrypted_key, salt = encrypt_api_key(password, api_key_info["secret"]) redis_client.set( - f"{redis_prefix}-created", datetime.now().replace(microsecond=0).isoformat() + f"{redis_prefix}-expires", api_key_info["expiration_time"].isoformat() ) + redis_client.set(f"{redis_prefix}-created", datetime.now(timezone.utc).isoformat()) redis_client.set(f"{redis_prefix}-encrypted", encrypted_key) redis_client.set(f"{redis_prefix}-salt", salt) @@ -582,15 +604,15 @@ def retrieve_proposals(facility, beamline, proposal_ids): num_proposals = len(proposal_ids) proposals = {} for proposal_id in proposal_ids: - proposal_response = nslsii_api_client.get(f"/v1/proposal/{proposal_id}") + proposal_response = facility_api_client.get(f"/v1/proposal/{proposal_id}") proposal_response.raise_for_status() proposal = proposal_response.json()["proposal"] - if beamline.upper() not in proposal_data["instruments"]: + if beamline.upper() not in proposal["instruments"]: raise ValueError( f"Proposal {proposal_id} is not at this beamline ({beamline.upper()})." f"This proposal is at the following beamline(s): {', '.join(proposal['instruments'])}." ) - is_commissioning_proposal = proposal_id in commissioning_rpoposals + is_commissioning_proposal = proposal_id in commissioning_proposals if num_proposals > 1 and is_commissioning_proposal: raise ValueError( f"Cannot activate multiple experiments alongside a commmissioning proposal." @@ -606,8 +628,8 @@ def retrieve_proposals(facility, beamline, proposal_ids): def get_beamline_env(): - os.getenv("BEAMLINE_ACRONYM").lower() - os.getenv("ENDSTATION_ACRONYM").lower() + beamline = os.getenv("BEAMLINE_ACRONYM") + endstation = os.getenv("ENDSTATION_ACRONYM") return beamline, endstation From 497f5b53523a78d561c7a24aeb92a4df64678fc7 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Fri, 21 Nov 2025 16:24:37 -0500 Subject: [PATCH 04/32] Add catalog root node tags to API keys --- nslsii/sync_experiment/sync_experiment.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 869292d0..d9a828ca 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -95,7 +95,7 @@ def sync_experiment( ) if not api_key: try: - api_key_info = create_api_key(tiled_context, data_sessions) + api_key_info = create_api_key(tiled_context, data_sessions, beamline) except: tiled_context.logout() raise # when cant create key @@ -400,8 +400,9 @@ def create_tiled_context(username, password, beamline, endstation): return context -def create_api_key(tiled_context, data_sessions): +def create_api_key(tiled_context, data_sessions, beamline): access_tags = [data_session for data_session in data_sessions] + access_tags.append(f"_ROOT_NODE_{beamline}") expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") note = f"Auto-generated by sync-experiment from {hostname}" From 88b811e5a63eb34c1fc103cebd8bc45af4b9ab97 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Fri, 21 Nov 2025 16:28:52 -0500 Subject: [PATCH 05/32] Upper the beamline in the root node tag --- nslsii/sync_experiment/sync_experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index d9a828ca..9826e826 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -402,7 +402,7 @@ def create_tiled_context(username, password, beamline, endstation): def create_api_key(tiled_context, data_sessions, beamline): access_tags = [data_session for data_session in data_sessions] - access_tags.append(f"_ROOT_NODE_{beamline}") + access_tags.append(f"_ROOT_NODE_{beamline.upper()}") expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") note = f"Auto-generated by sync-experiment from {hostname}" From e7816a7b9481f672b20cafe397e3f5a50b010dd3 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 16:56:49 -0500 Subject: [PATCH 06/32] Treat decrypt failure as missing key --- nslsii/sync_experiment/sync_experiment.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 9826e826..4241b86f 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -8,7 +8,7 @@ import re import redis -from cryptography.fernet import Fernet +from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from datetime import datetime, timezone @@ -433,8 +433,10 @@ def get_api_key(redis_client, username, password, beamline, endstation, data_ses """ Retrieve an API key from Redis. Query Redis for key information, decrypt the API key if found, - delete the key if it is expired, and finally return the key + ignore the key if it is expired, and finally return the key if it is still fresh. + Failure to decrypt (e.g. a changed password) will also cause + existing keys to be ignored. """ redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" @@ -463,7 +465,10 @@ def get_api_key(redis_client, username, password, beamline, endstation, data_ses if expires > now: salt = api_key_cached[f"{redis_prefix}-salt"] api_key_encrypted = api_key_cached[f"{redis_prefix}-encrypted"] - api_key = decrypt_api_key(password, salt, api_key_encrypted) + try: + api_key = decrypt_api_key(password, salt, api_key_encrypted) + except InvalidToken: + api_key = None else: api_key = None else: From 1def64ef73fb179a5f123e6536b87d2e67ade21f Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 17:00:59 -0500 Subject: [PATCH 07/32] Fix vars in cache PQ --- nslsii/sync_experiment/sync_experiment.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 4241b86f..344ff738 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -529,12 +529,14 @@ def cache_api_key( heapq.heapify(expiry_dates) while (length() - COUNT_NON_ENTRIES) / COUNT_PER_ENTRY >= MAX_ENTRIES: - expiry_prefix = heapify.heappop(expiry_heap)[1] + expiry_prefix = heapq.heappop(expiry_dates)[1] cursor = 0 while True: - cursor, keys = r.scan(cursor=cursor, match=f"{expiry_prefix}*", count=10) + cursor, keys = redis_client.scan( + cursor=cursor, match=f"{expiry_prefix}*", count=10 + ) if keys: - r.delete(*keys) + redis_client.delete(*keys) if cursor == 0: break From d2eda3ee4d2ffb05663a7c4e7fe447f2b4419951 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 17:51:50 -0500 Subject: [PATCH 08/32] Update terminology used by sync-experiment Experiments (proposals) are "authorized". Then, one of the authorized experiments is "activated". Multiple proposals may be authorized at the same time, but only one proposal can be active at a time. This should make more clear what the tool is doing, and how the policy works, as opposed to before (authorize/activate vs activate/select or activate/load). Includes some other minor word fixes. --- nslsii/sync_experiment/sync_experiment.py | 104 +++++++++++----------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 344ff738..6005ce9f 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -33,7 +33,7 @@ def sync_experiment( beamline: str | None = None, endstation: str | None = None, facility: str = "nsls2", - select_id: int | None = None, + activate_id: int | None = None, redis_db: int = 0, redis_ssl: bool = False, verbose: bool = False, @@ -42,8 +42,8 @@ def sync_experiment( beamline = beamline or env_beamline endstation = endstation or env_endstation proposal_ids = [str(proposal_id) for proposal_id in proposal_ids] - select_proposal = select_id or proposal_ids[0] - select_proposal = str(select_proposal) + activate_proposal = activate_id or proposal_ids[0] + activate_proposal = str(activate_proposal) if not beamline: raise ValueError( @@ -56,8 +56,8 @@ def sync_experiment( f"Provided proposal ID '{proposal_id}' is not valid.\n " f"A proposal ID must be a 6 character integer." ) - if select_proposal not in proposal_ids: - raise ValueError("Cannot select a proposal which is not being activated.") + if activate_proposal not in proposal_ids: + raise ValueError("Cannot activate a proposal which is not being authorized.") beamline = beamline.lower() if endstation: @@ -79,10 +79,10 @@ def sync_experiment( raise # except if login fails data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} - if not proposal_can_be_activated(username, facility, beamline, data_sessions): + if not proposal_can_be_authorized(username, facility, beamline, data_sessions): tiled_context.logout() raise ValueError( - f"You do not have permissions to activate all proposal IDs: {', '.join(proposal_ids)}" + f"You do not have permissions to authorize all proposal IDs: {', '.join(proposal_ids)}" ) try: proposals = retrieve_proposals(facility, beamline, proposal_ids) @@ -119,8 +119,6 @@ def sync_experiment( set_api_key(redis_client, normalized_beamline, endstation, api_key) tiled_context.logout() - # activate_proposals(username, select_proposal, data_sessions, proposals, normalized_beamline, endstation, facility) - # this was a function, but is now inlined below to prevent changing experiment without auth redis_location = ( f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" @@ -143,10 +141,10 @@ def sync_experiment( redis_prefix = f"{redis_location}-" if not redis_ssl else "" md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) - select_session = "pass-" + select_proposal - proposal = proposals[select_proposal] + activate_session = "pass-" + activate_proposal + proposal = proposals[activate_proposal] - md["data_sessions_active"] = list(data_sessions) + md["data_sessions_authorized"] = list(data_sessions) users = proposal.pop("users") pi_name = "" for user in users: @@ -154,14 +152,14 @@ def sync_experiment( pi_name = ( f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() ) - md["data_session"] = select_session # e.g. "pass-123456" + md["data_session"] = activate_session # e.g. "pass-123456" md["username"] = username md["start_datetime"] = datetime.now().isoformat() # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata - md["tiled_access_tags"] = list(select_session) + md["tiled_access_tags"] = list(activate_session) md["cycle"] = ( "commissioning" - if select_proposal in get_commissioning_proposals(facility, beamline) + if activate_proposal in get_commissioning_proposals(facility, beamline) else get_current_cycle(facility) ) md["proposal"] = { @@ -172,9 +170,11 @@ def sync_experiment( } print( - f"Activated experiments with data sessions {', '.join(md['data_sessions_active'])}\n" + f"Authorized experiments with data sessions {', '.join(md['data_sessions_authorized'])}\n" + ) + print( + f"Activated experiment with data session {md['data_session']} by {md['username']}." ) - print(f"Switched to experiment {md['data_session']} by {md['username']}.") if verbose: print(md) @@ -185,14 +185,14 @@ def sync_experiment( def switch_proposal( username, proposal_id, beamline, endstation=None, facility="nsls2", redis_db=0, redis_ssl=False, verbose=False, ) -> RedisJSONDict: - """Swith the loaded experiment (proposal) information at the beamline. + """Switch the active experiment (proposal) at the beamline. Parameters ---------- username: str the current user's username proposal_id: int or str - the ID number of the proposal to load + the ID number of the proposal to activate beamline: str the TLA of the beamline from which the experiment is running, not case-sensitive endstation : str or None (optional) @@ -228,28 +228,28 @@ def switch_proposal( redis_prefix = f"{redis_location}-" if not redis_ssl else "" md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) - select_proposal = str(proposal_id) - select_session = "pass-" + select_proposal - data_sessions_active = md.get("data_sessions_active") - if not data_sessions_active: + activate_proposal = str(proposal_id) + activate_session = "pass-" + activate_proposal + data_sessions_authorized = md.get("data_sessions_authorized") + if not data_sessions_authorized: raise ValueError( - "There are no currently active data sessions (proposals).\n" - "Please run sync-experiment before attempting to switch the loaded proposal." + "There are no currently authorized data sessions (proposals).\n" + "Please run sync-experiment before attempting to switch the active proposal." ) if not username == md.get("username"): raise ValueError( - "The currently active data sessions (proposals) were activated by a different user.\n" - "Please re-run sync-experiment to authenticate as different user." + "The currently authorized data sessions (proposals) were authorized by a different user.\n" + "Please re-run sync-experiment to authorize as the intended user." ) - if select_session not in data_sessions_active: + if activate_session not in data_sessions_authorized: raise ValueError( - f"Cannot switch to proposal which has not been activated.\n" - f"The activated data sessions are: {', '.join(data_sessions_active)}\n" - f"To activate different proposals, re-run sync-experiment." + f"Cannot switch to proposal which has not been authorized.\n" + f"The authorized data sessions are: {', '.join(data_sessions_authorized)}\n" + f"To authorize different proposals, re-run sync-experiment." ) - proposals = retrieve_proposals(facility, beamline, [select_proposal]) - proposal = proposals[select_proposal] + proposals = retrieve_proposals(facility, beamline, [activate_proposal]) + proposal = proposals[activate_proposal] users = proposal.pop("users") pi_name = "" @@ -258,14 +258,14 @@ def switch_proposal( pi_name = ( f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() ) - md["data_session"] = select_session # e.g. "pass-123456" + md["data_session"] = activate_session # e.g. "pass-123456" md["username"] = username md["start_datetime"] = datetime.now().isoformat() # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata - md["tiled_access_tags"] = list(select_session) + md["tiled_access_tags"] = list(activate_session) md["cycle"] = ( "commissioning" - if select_proposal in get_commissioning_proposals(facility, beamline) + if activate_proposal in get_commissioning_proposals(facility, beamline) else get_current_cycle(facility) ) md["proposal"] = { @@ -275,7 +275,9 @@ def switch_proposal( "pi_name": pi_name, } - print(f"Switched to experiment {md['data_session']} by {md['username']}.") + print( + f"Switched to experiment wihh data session {md['data_session']} by {md['username']}." + ) return md @@ -340,7 +342,7 @@ def prompt_for_login(facility, beamline, endstation, proposal_ids): def create_tiled_context(username, password, beamline, endstation): """ - Createa new Tiled context and authenticate. + Create a new Tiled context and authenticate. Loads the beamline Tiled profile, instantiates the new context, selects an AuthN provider, attempts to retrieve tokens via password_grant, @@ -565,9 +567,9 @@ def get_commissioning_proposals(facility, beamline): return commissioning_proposals -def proposal_can_be_activated(username, facility, beamline, data_sessions): +def proposal_can_be_authorized(username, facility, beamline, data_sessions): """ - Check that the user can activate the requested proposals, + Check that the user can authorize the requested proposals, given their data_sessions. Activation will be allowed if the user has facility or @@ -583,7 +585,7 @@ def proposal_can_be_activated(username, facility, beamline, data_sessions): user_access_response.raise_for_status() user_access = user_access_response.json() - can_activate = ( + can_authorize = ( facility.lower() in user_access["facility_all_access"] or beamline.lower() in user_access["beamline_all_access"] or all( @@ -591,21 +593,21 @@ def proposal_can_be_activated(username, facility, beamline, data_sessions): for data_session in data_sessions ) ) - return can_activate + return can_authorize def retrieve_proposals(facility, beamline, proposal_ids): """ - Retrieve the data for the proposals that are being activated. + Retrieve the data for the proposals that are being authorized. This is also a validation step, ensuring that all the reequested proposals match the beamline. Without this validation, access could be granted to the wrong proposals. In the future, this should also match proposals by facility as well. - If multiple proposals are to be activated, they must all be allocated + If multiple proposals are to be authorized, they must all be allocated for the same (current) cycle. For commissioning proposals, only one proposal - can be activated at a time. + can be authorized at a time. """ current_cycle = get_current_cycle(facility) commissioning_proposals = get_commissioning_proposals(facility, beamline) @@ -623,7 +625,7 @@ def retrieve_proposals(facility, beamline, proposal_ids): is_commissioning_proposal = proposal_id in commissioning_proposals if num_proposals > 1 and is_commissioning_proposal: raise ValueError( - f"Cannot activate multiple experiments alongside a commmissioning proposal." + f"Cannot authorize multiple experiments alongside a commmissioning proposal." f"Proposal {proposal_id} is a commissioning proposal." ) if not is_commissioning_proposal and current_cycle not in proposal["cycles"]: @@ -686,7 +688,7 @@ def main(): dest="proposals", nargs="+", type=int, - help="The proposal ID for the experiment", + help="The proposal ID(s) for the experiment", required=True, ) parser.add_argument( @@ -704,10 +706,10 @@ def main(): ) parser.add_argument( "-s", - "--select", - dest="select", + "--activate", + dest="activate", type=int, - help="The proposal ID to select and load, defaults to the first in the proposals list.", + help="The ID of the proposal to activate, defaults to the first in the proposals list.", required=False, ) parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) @@ -718,7 +720,7 @@ def main(): beamline=args.beamline, endstation=args.endstation, proposal_ids=args.proposals, - select_id=args.select, + activate_id=args.activate, verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, From da5d5982346d27f0f4be45e34485b4422dd702bf Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 18:15:07 -0500 Subject: [PATCH 09/32] Add note about duo prompt to login message --- nslsii/sync_experiment/sync_experiment.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 6005ce9f..b276551d 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -328,13 +328,13 @@ def decrypt_api_key(password, salt, api_key_encrypted): def prompt_for_login(facility, beamline, endstation, proposal_ids): - print(f"Welcome to the {beamline.upper()} beamline at {facility.upper()}!") + print(f"\nWelcome to the {beamline.upper()} beamline at {facility.upper()}!\n") if endstation: - print(f"This is the {endstation.upper()} endstation.") + print(f"This is the {endstation.upper()} endstation.\n") print( - f"Attempting to sync experiment for proposal ID(s) {(', ').join(proposal_ids)}." + f"Attempting to sync experiment for proposal ID(s) {(', ').join(proposal_ids)}.\n" ) - print("Please login with your BNL credentials.") + print("Please login with your BNL credentials (you may receive a Duo prompt):") username = input("Username: ") password = SecretStr(getpass(prompt="Password: ")) return username, password From b74e83cf79d0ba0fd34ff79bb50c5d66a2d155ba Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 19:19:01 -0500 Subject: [PATCH 10/32] Add unsync_experiment, update switch_proposal, correct use cases of normalized_beamline --- nslsii/sync_experiment/__init__.py | 2 +- nslsii/sync_experiment/sync_experiment.py | 158 ++++++++++++++++++---- 2 files changed, 134 insertions(+), 26 deletions(-) diff --git a/nslsii/sync_experiment/__init__.py b/nslsii/sync_experiment/__init__.py index c834ba3a..eea544ee 100644 --- a/nslsii/sync_experiment/__init__.py +++ b/nslsii/sync_experiment/__init__.py @@ -1 +1 @@ -from .sync_experiment import main, sync_experiment, switch_proposal +from .sync_experiment import main, sync_experiment, unsync_experiment, switch_proposal diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index b276551d..2460d270 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -29,11 +29,11 @@ def sync_experiment( - proposal_ids: list[int], + proposal_ids: list[int | str], + activate_id: int | str | None = None, + facility: str = "nsls2", beamline: str | None = None, endstation: str | None = None, - facility: str = "nsls2", - activate_id: int | None = None, redis_db: int = 0, redis_ssl: bool = False, verbose: bool = False, @@ -63,8 +63,9 @@ def sync_experiment( if endstation: endstation = endstation.lower() normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) + # to-do:make this client use secure Redis (for API key storage) - redis_client = redis.Redis( + apikey_redis_client = redis.Redis( host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, db=15, @@ -91,16 +92,23 @@ def sync_experiment( raise # except if proposal retrieval fails api_key = get_api_key( - redis_client, username, password, normalized_beamline, endstation, data_sessions + apikey_redis_client, + username, + password, + normalized_beamline, + endstation, + data_sessions, ) if not api_key: try: - api_key_info = create_api_key(tiled_context, data_sessions, beamline) + api_key_info = create_api_key( + tiled_context, data_sessions, normalized_beamline + ) except: tiled_context.logout() raise # when cant create key cache_api_key( - redis_client, + apikey_redis_client, username, password, normalized_beamline, @@ -109,14 +117,14 @@ def sync_experiment( api_key_info, ) api_key = get_api_key( - redis_client, + apikey_redis_client, username, password, normalized_beamline, endstation, data_sessions, ) - set_api_key(redis_client, normalized_beamline, endstation, api_key) + set_api_key(apikey_redis_client, normalized_beamline, endstation, api_key) tiled_context.logout() @@ -182,8 +190,77 @@ def sync_experiment( return md +def unsync_experiment( + facility: str = "nsls2", + beamline: str | None = None, + endstation: str | None = None, + verbose: bool = False, +) -> RedisJSONDict: + env_beamline, env_endstation = get_beamline_env() + beamline = beamline or env_beamline + endstation = endstation or env_endstation + + if not beamline: + raise ValueError( + "No beamline provided! Please provide a beamline argument, " + "or set the 'BEAMLINE_ACRONYM' environment variable." + ) + + beamline = beamline.lower() + if endstation: + endstation = endstation.lower() + normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) + apikey_redis_client = redis.Redis( + host=f"info.{normalized_beamline}.nsls2.bnl.gov", + port=6379, + db=15, + ) + + md_redis_client = redis.Redis(host=f"info.{normalized_beamline}.nsls2.bnl.gov") + md_redis_prefix = ( + f"{normalized_beamline}-{endstation}-" + if endstation + else f"{normalized_beamline}-" + ) + md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) + + set_api_key(apikey_redis_client, normalized_beamline, endstation, "") + data_sessions_deauthorized = md["data_sessions_authorized"] + md["data_sessions_authorized"] = list() + data_session = md["data_session"] + md["data_session"] = "" + username = md["username"] + md["username"] = "" + md["start_datetime"] = "" + md["tiled_access_tags"] = list() + md["cycle"] = "" + md["proposal"] = { + "proposal_id": "", + "title": "", + "type": "", + "pi_name": "", + } + + print( + f"Deauthorized experiments with data sessions {', '.join(data_sessions_deauthorized)}\n" + ) + print(f"Deactivated experiment with data session {data_session} by {username}.") + + if verbose: + print(json.dumps(md, indent=2)) + + return md + + def switch_proposal( - username, proposal_id, beamline, endstation=None, facility="nsls2", redis_db=0, redis_ssl=False, verbose=False, + username: str, + proposal_id: int | str, + facility: str = "nsls2", + beamline: str | None = None, + endstation: str | None = None, + verbose: bool = False, + redis_db: int = 0, + redis_ssl: bool = False, ) -> RedisJSONDict: """Switch the active experiment (proposal) at the beamline. @@ -193,20 +270,33 @@ def switch_proposal( the current user's username proposal_id: int or str the ID number of the proposal to activate - beamline: str + facility: str (optional) + the facility that the beamline belongs to (defaults to "nsls2") + beamline: str or None (optional) the TLA of the beamline from which the experiment is running, not case-sensitive endstation : str or None (optional) the endstation at the beamline from which the experiment is running, not case-sensitive - facility: str (optional) - the facility that the beamline belongs to (defaults to "nsls2") Returns ------- md : RedisJSONDict The updated metadata dictionary """ - beamline = normalized_beamlines.get(beamline.lower(), beamline.lower()) - endstation = endstation.lower() + env_beamline, env_endstation = get_beamline_env() + beamline = beamline or env_beamline + endstation = endstation or env_endstation + + if not beamline: + raise ValueError( + "No beamline provided! Please provide a beamline argument, " + "or set the 'BEAMLINE_ACRONYM' environment variable." + ) + + beamline = beamline.lower() + if endstation: + endstation = endstation.lower() + normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline.lower()) + redis_location = ( f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" ) @@ -712,16 +802,34 @@ def main(): help="The ID of the proposal to activate, defaults to the first in the proposals list.", required=False, ) + parser.add_argument( + "-u", + "--unsync", + dest="unsync", + help="Unsync experiment - deauthorize all proposals and deactivate the experiment.", + required=False, + action=argparse.BooleanOptionalAction, + ) parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) args = parser.parse_args() - sync_experiment( - facility=args.facility, - beamline=args.beamline, - endstation=args.endstation, - proposal_ids=args.proposals, - activate_id=args.activate, - verbose=args.verbose, - redis_db=args.redis_db, - redis_ssl=args.redis_ssl, - ) + if not args.unsync: + sync_experiment( + facility=args.facility, + beamline=args.beamline, + endstation=args.endstation, + proposal_ids=args.proposals, + activate_id=args.activate, + verbose=args.verbose, + redis_db=args.redis_db, + redis_ssl=args.redis_ssl, + ) + else: + unsync_experiment( + facility=args.facility, + beamline=args.beamline, + endstation=args.endstation, + verbose=args.verbose, + redis_db=args.redis_db, + redis_ssl=args.redis_ssl, + ) From 2f03a2fde05ecf34001f85c440cae4b21b9dabfc Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 19:36:10 -0500 Subject: [PATCH 11/32] Sort data_sessions used for Redis keys to ensure determinism --- nslsii/sync_experiment/sync_experiment.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 2460d270..74392bc6 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -531,11 +531,12 @@ def get_api_key(redis_client, username, password, beamline, endstation, data_ses existing keys to be ignored. """ + data_sessions_sanitized = sorted( + data_session.replace("-", "") for data_session in data_sessions + ) redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" redis_prefix = ( - f"{redis_prefix}-{username}-" - f"{'-'.join(data_session.replace('-', '') for data_session in data_sessions)}" - f"-apikey" + f"{redis_prefix}-{username}-{'-'.join(data_sessions_sanitized)}-apikey" ) api_key_cached = {} @@ -589,11 +590,12 @@ def cache_api_key( -salt : the salt used for encryption """ + data_sessions_sanitized = sorted( + data_session.replace("-", "") for data_session in data_sessions + ) redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" redis_prefix = ( - f"{redis_prefix}-{username}-" - f"{'-'.join(data_session.replace('-', '') for data_session in data_sessions)}" - f"-apikey" + f"{redis_prefix}-{username}-{'-'.join(data_sessions_sanitized)}-apikey" ) MAX_ENTRIES = 5 From c65d6a9bd649000f37ef5e77ac42d2b466ec1112 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 20:01:04 -0500 Subject: [PATCH 12/32] Make switch_proposal work from CLI --- nslsii/sync_experiment/sync_experiment.py | 49 +++++++++++++++++------ 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 74392bc6..3fd3778c 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -253,8 +253,8 @@ def unsync_experiment( def switch_proposal( - username: str, proposal_id: int | str, + username: str | None = None, facility: str = "nsls2", beamline: str | None = None, endstation: str | None = None, @@ -266,10 +266,10 @@ def switch_proposal( Parameters ---------- - username: str - the current user's username proposal_id: int or str the ID number of the proposal to activate + username: str or None (optional) + the current user's username - will prompt if no provided. facility: str (optional) the facility that the beamline belongs to (defaults to "nsls2") beamline: str or None (optional) @@ -296,6 +296,7 @@ def switch_proposal( if endstation: endstation = endstation.lower() normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline.lower()) + username = username or input("Enter your username: ") redis_location = ( f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" @@ -774,14 +775,17 @@ def main(): default=0, help="Redis database index", ) - parser.add_argument( + + # Mutually exclusive modes: sync (proposals+activate), switch, unsync + modes_group = parser.add_mutually_exclusive_group(required=True) + + modes_group.add_argument( "-p", "--proposals", dest="proposals", nargs="+", type=int, - help="The proposal ID(s) for the experiment", - required=True, + help="The proposal ID(s) to authorize for the experiment", ) parser.add_argument( "-S", @@ -804,33 +808,52 @@ def main(): help="The ID of the proposal to activate, defaults to the first in the proposals list.", required=False, ) - parser.add_argument( + modes_group.add_argument( + "-s", + "--switch", + dest="switch", + type=int, + help="Switch the active proposal to this ID. The proposal must already be authorized.", + ) + modes_group.add_argument( "-u", "--unsync", dest="unsync", help="Unsync experiment - deauthorize all proposals and deactivate the experiment.", - required=False, action=argparse.BooleanOptionalAction, ) parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) args = parser.parse_args() - if not args.unsync: - sync_experiment( + if args.activate is not None and args.proposals is None: + parser.error("--activate can only be used when --proposals is provided") + + if args.unsync: + unsync_experiment( facility=args.facility, beamline=args.beamline, endstation=args.endstation, - proposal_ids=args.proposals, - activate_id=args.activate, + verbose=args.verbose, + redis_db=args.redis_db, + redis_ssl=args.redis_ssl, + ) + elif args.switch is not None: + switch_proposal( + facility=args.facility, + beamline=args.beamline, + endstation=args.endstation, + proposal_id=args.switch, verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, ) else: - unsync_experiment( + sync_experiment( facility=args.facility, beamline=args.beamline, endstation=args.endstation, + proposal_ids=args.proposals, + activate_id=args.activate, verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, From 7c8b8dc8388e8370eac916d94ee7c83e44430f16 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 25 Nov 2025 20:09:06 -0500 Subject: [PATCH 13/32] Fix method name for authz check --- nslsii/sync_experiment/sync_experiment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 3fd3778c..41f48f53 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -80,7 +80,7 @@ def sync_experiment( raise # except if login fails data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} - if not proposal_can_be_authorized(username, facility, beamline, data_sessions): + if not proposals_can_be_authorized(username, facility, beamline, data_sessions): tiled_context.logout() raise ValueError( f"You do not have permissions to authorize all proposal IDs: {', '.join(proposal_ids)}" @@ -660,7 +660,7 @@ def get_commissioning_proposals(facility, beamline): return commissioning_proposals -def proposal_can_be_authorized(username, facility, beamline, data_sessions): +def proposals_can_be_authorized(username, facility, beamline, data_sessions): """ Check that the user can authorize the requested proposals, given their data_sessions. From e8488b29b4560591a1ba02e95bec15537af96172 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 15:53:21 -0500 Subject: [PATCH 14/32] Remove redundant lowers for beamline --- nslsii/sync_experiment/sync_experiment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 41f48f53..0bc56305 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -62,7 +62,7 @@ def sync_experiment( beamline = beamline.lower() if endstation: endstation = endstation.lower() - normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) + normalized_beamline = normalized_beamlines.get(beamline, beamline) # to-do:make this client use secure Redis (for API key storage) apikey_redis_client = redis.Redis( @@ -209,7 +209,7 @@ def unsync_experiment( beamline = beamline.lower() if endstation: endstation = endstation.lower() - normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline) + normalized_beamline = normalized_beamlines.get(beamline, beamline) apikey_redis_client = redis.Redis( host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, @@ -295,7 +295,7 @@ def switch_proposal( beamline = beamline.lower() if endstation: endstation = endstation.lower() - normalized_beamline = normalized_beamlines.get(beamline.lower(), beamline.lower()) + normalized_beamline = normalized_beamlines.get(beamline, beamline) username = username or input("Enter your username: ") redis_location = ( From cb03a26167126d0493af51c14d96fdf4d82c44e0 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 16:39:23 -0500 Subject: [PATCH 15/32] Add 'ts' for timestamp keys --- nslsii/sync_experiment/sync_experiment.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 0bc56305..032b9037 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -553,7 +553,7 @@ def get_api_key(redis_client, username, password, beamline, endstation, data_ses if api_key_cached: expires = datetime.fromisoformat( - api_key_cached[f"{redis_prefix}-expires"].decode("UTF-8") + api_key_cached[f"{redis_prefix}-ts-expires"].decode("UTF-8") ) now = datetime.now(timezone.utc) if expires > now: @@ -579,16 +579,16 @@ def cache_api_key( Keys are rotated out via priority queue according to their expiration dates. There is a limit set on the number of API keys that - may be cached a time. + may be cached at a time. The cached values have keys that are prefixed with: ----apikey There are 4 keys per each API key: - -expires : the timestamp of when the key will expire - -created : the timestamp of when the key was created/cached - -encrypted : the encrypted API key - -salt : the salt used for encryption + -ts-expires : the timestamp of when the key will expire + -ts-created : the timestamp of when the key was created/cached + -encrypted : the encrypted API key + -salt : the salt used for encryption """ data_sessions_sanitized = sorted( @@ -607,14 +607,14 @@ def cache_api_key( cursor = 0 expiry_dates = [] while True: - cursor, keys = redis_client.scan(cursor=cursor, match="*expires") + cursor, keys = redis_client.scan(cursor=cursor, match="*ts-expires") if keys: values = redis_client.mget(keys) expiry_dates.extend( [ ( datetime.fromisoformat(v.decode("UTF-8")), - k.decode("UTF-8").removesuffix("-expires"), + k.decode("UTF-8").removesuffix("-ts-expires"), ) for k, v in zip(keys, values) ] @@ -637,9 +637,11 @@ def cache_api_key( encrypted_key, salt = encrypt_api_key(password, api_key_info["secret"]) redis_client.set( - f"{redis_prefix}-expires", api_key_info["expiration_time"].isoformat() + f"{redis_prefix}-ts-expires", api_key_info["expiration_time"].isoformat() + ) + redis_client.set( + f"{redis_prefix}-ts-created", datetime.now(timezone.utc).isoformat() ) - redis_client.set(f"{redis_prefix}-created", datetime.now(timezone.utc).isoformat()) redis_client.set(f"{redis_prefix}-encrypted", encrypted_key) redis_client.set(f"{redis_prefix}-salt", salt) From 4037a73a7be95eea467cb45448068a82dc993c42 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 18:10:08 -0500 Subject: [PATCH 16/32] Use __missing__ to default normalized beamline --- nslsii/sync_experiment/sync_experiment.py | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 032b9037..acac36a0 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -22,10 +22,20 @@ facility_api_client = httpx.Client(base_url="https://api.nsls2.bnl.gov") -normalized_beamlines = { - "sst1": "sst", - "sst2": "sst", -} + + +class KeyRemapper(dict): + def __missing__(self, key): + self[key] = key + return key + + +normalized_beamlines = KeyRemapper( + { + "sst1": "sst", + "sst2": "sst", + } +) def sync_experiment( @@ -62,7 +72,7 @@ def sync_experiment( beamline = beamline.lower() if endstation: endstation = endstation.lower() - normalized_beamline = normalized_beamlines.get(beamline, beamline) + normalized_beamline = normalized_beamlines[beamline] # to-do:make this client use secure Redis (for API key storage) apikey_redis_client = redis.Redis( @@ -209,7 +219,7 @@ def unsync_experiment( beamline = beamline.lower() if endstation: endstation = endstation.lower() - normalized_beamline = normalized_beamlines.get(beamline, beamline) + normalized_beamline = normalized_beamlines[beamline] apikey_redis_client = redis.Redis( host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, @@ -295,7 +305,7 @@ def switch_proposal( beamline = beamline.lower() if endstation: endstation = endstation.lower() - normalized_beamline = normalized_beamlines.get(beamline, beamline) + normalized_beamline = normalized_beamlines[beamline] username = username or input("Enter your username: ") redis_location = ( From 6846a79a0e2bddd0784a454bcca850ed3c9a9d76 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 19:00:56 -0500 Subject: [PATCH 17/32] Add prompt for Duo passcode or method --- nslsii/sync_experiment/sync_experiment.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index acac36a0..4376e557 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -81,10 +81,12 @@ def sync_experiment( db=15, ) - username, password = prompt_for_login(facility, beamline, endstation, proposal_ids) + username, password, duo_append = prompt_for_login( + facility, beamline, endstation, proposal_ids + ) try: tiled_context = create_tiled_context( - username, password, normalized_beamline, endstation + username, password, duo_append, normalized_beamline, endstation ) except Exception: raise # except if login fails @@ -438,10 +440,11 @@ def prompt_for_login(facility, beamline, endstation, proposal_ids): print("Please login with your BNL credentials (you may receive a Duo prompt):") username = input("Username: ") password = SecretStr(getpass(prompt="Password: ")) - return username, password + duo_append = input("Duo Passcode or Method (press Enter to ignore): ") + return username, password, duo_append -def create_tiled_context(username, password, beamline, endstation): +def create_tiled_context(username, password, duo_append, beamline, endstation): """ Create a new Tiled context and authenticate. @@ -483,6 +486,8 @@ def create_tiled_context(username, password, beamline, endstation): "Please select a provider with mode='internal'." ) + if duo_append: + password = SecretStr(f"{password.get_secret_value()},{duo_append}") try: tokens = password_grant( http_client, auth_endpoint, provider, username, password.get_secret_value() From a063a280c5f2b4b99721db12f74de225b1d23705 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 19:27:31 -0500 Subject: [PATCH 18/32] Minor exception tweaks --- nslsii/sync_experiment/sync_experiment.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 4376e557..97a5f027 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -84,12 +84,10 @@ def sync_experiment( username, password, duo_append = prompt_for_login( facility, beamline, endstation, proposal_ids ) - try: - tiled_context = create_tiled_context( - username, password, duo_append, normalized_beamline, endstation - ) - except Exception: - raise # except if login fails + + tiled_context = create_tiled_context( + username, password, duo_append, normalized_beamline, endstation + ) data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} if not proposals_can_be_authorized(username, facility, beamline, data_sessions): @@ -101,7 +99,7 @@ def sync_experiment( proposals = retrieve_proposals(facility, beamline, proposal_ids) except Exception: tiled_context.logout() - raise # except if proposal retrieval fails + raise api_key = get_api_key( apikey_redis_client, @@ -118,7 +116,7 @@ def sync_experiment( ) except: tiled_context.logout() - raise # when cant create key + raise cache_api_key( apikey_redis_client, username, @@ -494,7 +492,7 @@ def create_tiled_context(username, password, duo_append, beamline, endstation): ) except httpx.HTTPStatusError as err: if err.response.status_code == httpx.codes.UNAUTHORIZED: - raise ValueError("Username or password not recognized.") + raise ValueError("Username or password not recognized.") from err else: raise From f479cf1600b59022331a539ab7eec216a4a72373 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 20:05:21 -0500 Subject: [PATCH 19/32] Update docstrings --- nslsii/sync_experiment/sync_experiment.py | 52 +++++++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 97a5f027..a055f0b9 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -48,6 +48,29 @@ def sync_experiment( redis_ssl: bool = False, verbose: bool = False, ) -> RedisJSONDict: + """Sync a new experiment (proposal) at the beamline. + Authorizes the requested proposals, and activates one of them. + + Parameters + ---------- + proposal_ids : list[int or str] + the list of proposal IDs to authorize + activate_id : int or str (optional) + the ID number of the proposal to activate (defaults to the first proposal in proposal_ids) + facility : str (optional) + the facility that the beamline belongs to (defaults to "nsls2") + beamline : str or None (optional) + the TLA of the beamline from which the experiment is running, not case-sensitive + endstation : str or None (optional) + the endstation at the beamline from which the experiment is running, not case-sensitive + verbose : bool (optional) + turn on verbose printing + + Returns + ------- + md : RedisJSONDict + The updated metadata dictionary + """ env_beamline, env_endstation = get_beamline_env() beamline = beamline or env_beamline endstation = endstation or env_endstation @@ -206,6 +229,25 @@ def unsync_experiment( endstation: str | None = None, verbose: bool = False, ) -> RedisJSONDict: + """Unsync the currently active experiment (proposal) at the beamline. + Also deauthorizes all currently authorized proposals. + + Parameters + ---------- + facility : str (optional) + the facility that the beamline belongs to (defaults to "nsls2") + beamline : str or None (optional) + the TLA of the beamline from which the experiment is running, not case-sensitive + endstation : str or None (optional) + the endstation at the beamline from which the experiment is running, not case-sensitive + verbose : bool (optional) + turn on verbose printing + + Returns + ------- + md : RedisJSONDict + The updated metadata dictionary + """ env_beamline, env_endstation = get_beamline_env() beamline = beamline or env_beamline endstation = endstation or env_endstation @@ -276,16 +318,18 @@ def switch_proposal( Parameters ---------- - proposal_id: int or str + proposal_id : int or str the ID number of the proposal to activate - username: str or None (optional) + username : str or None (optional) the current user's username - will prompt if no provided. - facility: str (optional) + facility : str (optional) the facility that the beamline belongs to (defaults to "nsls2") - beamline: str or None (optional) + beamline : str or None (optional) the TLA of the beamline from which the experiment is running, not case-sensitive endstation : str or None (optional) the endstation at the beamline from which the experiment is running, not case-sensitive + verbose : bool (optional) + turn on verbose printing Returns ------- From 2e253f15d04adbd8760406364361de6ad79fa0dd Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 3 Dec 2025 22:10:17 -0500 Subject: [PATCH 20/32] Fix access_tags dtype, update prints --- nslsii/sync_experiment/sync_experiment.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index a055f0b9..c493dc9c 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -197,7 +197,7 @@ def sync_experiment( md["username"] = username md["start_datetime"] = datetime.now().isoformat() # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata - md["tiled_access_tags"] = list(activate_session) + md["tiled_access_tags"] = [activate_session] md["cycle"] = ( "commissioning" if activate_proposal in get_commissioning_proposals(facility, beamline) @@ -277,11 +277,13 @@ def unsync_experiment( md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - data_sessions_deauthorized = md["data_sessions_authorized"] + data_sessions_deauthorized = md["data_sessions_authorized"] or [ + "" + ] md["data_sessions_authorized"] = list() - data_session = md["data_session"] + data_session = md["data_session"] or "" md["data_session"] = "" - username = md["username"] + username = md["username"] or "" md["username"] = "" md["start_datetime"] = "" md["tiled_access_tags"] = list() @@ -407,7 +409,7 @@ def switch_proposal( md["username"] = username md["start_datetime"] = datetime.now().isoformat() # tiled-access-tags used by bluesky-tiled-writer, not saved to metadata - md["tiled_access_tags"] = list(activate_session) + md["tiled_access_tags"] = [activate_session] md["cycle"] = ( "commissioning" if activate_proposal in get_commissioning_proposals(facility, beamline) @@ -752,8 +754,8 @@ def retrieve_proposals(facility, beamline, proposal_ids): """ Retrieve the data for the proposals that are being authorized. This is also a validation step, ensuring that all the - reequested proposals match the beamline. Without this validation, - access could be granted to the wrong proposals. + requested proposals match the beamline. + ***Without this validation, access could be granted to the wrong proposals.*** In the future, this should also match proposals by facility as well. From 03deff549ba01139c6de2f97f9ec9bfd042f0781 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Mon, 8 Dec 2025 17:36:43 -0500 Subject: [PATCH 21/32] Restrict scopes on generated apikeys --- nslsii/sync_experiment/sync_experiment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index c493dc9c..21fba2ec 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -555,6 +555,7 @@ def create_tiled_context(username, password, duo_append, beamline, endstation): def create_api_key(tiled_context, data_sessions, beamline): access_tags = [data_session for data_session in data_sessions] access_tags.append(f"_ROOT_NODE_{beamline.upper()}") + scopes = ["read:data", "read:metadata", "apikeys"] expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") note = f"Auto-generated by sync-experiment from {hostname}" @@ -562,7 +563,7 @@ def create_api_key(tiled_context, data_sessions, beamline): if expires_in and expires_in.isdigit(): expires_in = int(expires_in) info = tiled_context.create_api_key( - access_tags=access_tags, expires_in=expires_in, note=note + access_tags=access_tags, scopes=scopes, expires_in=expires_in, note=note ) return info From 10d0005a4eae905db4082c2694374d268d8ad3b1 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Tue, 9 Dec 2025 14:24:37 -0500 Subject: [PATCH 22/32] Remove apikeys scope --- nslsii/sync_experiment/sync_experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 21fba2ec..24ebb97f 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -555,7 +555,7 @@ def create_tiled_context(username, password, duo_append, beamline, endstation): def create_api_key(tiled_context, data_sessions, beamline): access_tags = [data_session for data_session in data_sessions] access_tags.append(f"_ROOT_NODE_{beamline.upper()}") - scopes = ["read:data", "read:metadata", "apikeys"] + scopes = ["read:data", "read:metadata"] expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") note = f"Auto-generated by sync-experiment from {hostname}" From cd23069fe69c6c5b5e1c10e3a2ce599ced69c2d1 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Mon, 13 Apr 2026 20:32:17 -0400 Subject: [PATCH 23/32] Revoke api keys instead of caching --- nslsii/sync_experiment/sync_experiment.py | 276 ++++++---------------- 1 file changed, 72 insertions(+), 204 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 24ebb97f..db57dcf8 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -1,17 +1,12 @@ import argparse import importlib.metadata -import base64 -import heapq import httpx import json import os import re import redis -from cryptography.fernet import Fernet, InvalidToken -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from datetime import datetime, timezone +from datetime import datetime from getpass import getpass from pydantic.types import SecretStr from redis_json_dict import RedisJSONDict @@ -102,6 +97,7 @@ def sync_experiment( host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, db=15, + decode_responses=True, ) username, password, duo_append = prompt_for_login( @@ -109,11 +105,12 @@ def sync_experiment( ) tiled_context = create_tiled_context( - username, password, duo_append, normalized_beamline, endstation + normalized_beamline, endstation, username, password, duo_append ) data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} if not proposals_can_be_authorized(username, facility, beamline, data_sessions): + tiled_context.api_key = None tiled_context.logout() raise ValueError( f"You do not have permissions to authorize all proposal IDs: {', '.join(proposal_ids)}" @@ -121,44 +118,33 @@ def sync_experiment( try: proposals = retrieve_proposals(facility, beamline, proposal_ids) except Exception: + tiled_context.api_key = None tiled_context.logout() raise - api_key = get_api_key( - apikey_redis_client, - username, - password, - normalized_beamline, - endstation, - data_sessions, - ) - if not api_key: - try: - api_key_info = create_api_key( - tiled_context, data_sessions, normalized_beamline - ) - except: - tiled_context.logout() - raise - cache_api_key( - apikey_redis_client, - username, - password, - normalized_beamline, - endstation, - data_sessions, - api_key_info, - ) - api_key = get_api_key( - apikey_redis_client, - username, - password, - normalized_beamline, - endstation, - data_sessions, + api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) + if api_key_active: + set_api_key(apikey_redis_client, normalized_beamline, endstation, "") + tiled_context_revoke = create_tiled_context( + normalized_beamline, endstation, api_key=api_key_active ) + try: + revoke_api_key(tiled_context_revoke) + except Exception as e: + print(f"Revocation of existing API key may have failed: {e}") + finally: + tiled_context_revoke.api_key = None + tiled_context_revoke.logout() + try: + api_key_info = create_api_key(tiled_context, data_sessions, normalized_beamline) + api_key = api_key_info["secret"] + except Exception: + tiled_context.api_key = None + tiled_context.logout() + raise set_api_key(apikey_redis_client, normalized_beamline, endstation, api_key) + tiled_context.api_key = None tiled_context.logout() redis_location = ( @@ -266,6 +252,7 @@ def unsync_experiment( host=f"info.{normalized_beamline}.nsls2.bnl.gov", port=6379, db=15, + decode_responses=True, ) md_redis_client = redis.Redis(host=f"info.{normalized_beamline}.nsls2.bnl.gov") @@ -276,7 +263,19 @@ def unsync_experiment( ) md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) - set_api_key(apikey_redis_client, normalized_beamline, endstation, "") + api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) + if api_key_active: + set_api_key(apikey_redis_client, normalized_beamline, endstation, "") + tiled_context_revoke = create_tiled_context( + normalized_beamline, endstation, api_key=api_key_active + ) + try: + revoke_api_key(tiled_context_revoke) + except Exception as e: + print(f"Revocation of existing API key may have failed: {e}") + finally: + tiled_context_revoke.api_key = None + tiled_context_revoke.logout() data_sessions_deauthorized = md["data_sessions_authorized"] or [ "" ] @@ -429,51 +428,6 @@ def switch_proposal( return md -def encrypt_api_key(password, api_key): - """ - This is a reference implementation taken from the - cryptography library docs: - https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet - - The number of iterations was taken from Django's current settings. - """ - salt = os.urandom(16) - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=1_500_000, - ) - key = base64.urlsafe_b64encode( - kdf.derive(password.get_secret_value().encode("UTF-8")) - ) - f = Fernet(key) - token = f.encrypt(api_key.encode("UTF-8")) - return token, salt - - -def decrypt_api_key(password, salt, api_key_encrypted): - """ - This is a reference implementation taken from the - cryptography library docs: - https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet - - The number of iterations was taken from Django's current settings. - """ - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=1_500_000, - ) - key = base64.urlsafe_b64encode( - kdf.derive(password.get_secret_value().encode("UTF-8")) - ) - f = Fernet(key) - token = f.decrypt(api_key_encrypted) - return token.decode("UTF-8") - - def prompt_for_login(facility, beamline, endstation, proposal_ids): print(f"\nWelcome to the {beamline.upper()} beamline at {facility.upper()}!\n") if endstation: @@ -488,7 +442,9 @@ def prompt_for_login(facility, beamline, endstation, proposal_ids): return username, password, duo_append -def create_tiled_context(username, password, duo_append, beamline, endstation): +def create_tiled_context( + beamline, endstation, username=None, password=None, duo_append=None, api_key=None +): """ Create a new Tiled context and authenticate. @@ -496,6 +452,9 @@ def create_tiled_context(username, password, duo_append, beamline, endstation): selects an AuthN provider, attempts to retrieve tokens via password_grant, then prints a confirmation message and authenticates the context. + If an api key is provided, the context is returned with that api key + attached, and further authentication is skipped. + """ profiles = load_profiles() if endstation and endstation in profiles: @@ -506,9 +465,18 @@ def create_tiled_context(username, password, duo_append, beamline, endstation): raise ValueError(f"Cannot find Tiled profile for beamline {beamline.upper()}") context, _ = Context.from_any_uri( - profile["uri"], verify=profile.get("verify", True) + profile["uri"], api_key=api_key, verify=profile.get("verify", True) ) + if api_key: + return context + + if not username or not password: + raise ValueError( + "Please provide a username and password, " + "or an API key, to authenticate the Tiled context" + ) + providers = context.server_info.authentication.providers http_client = context.http_client if len(providers) == 1: @@ -555,7 +523,7 @@ def create_tiled_context(username, password, duo_append, beamline, endstation): def create_api_key(tiled_context, data_sessions, beamline): access_tags = [data_session for data_session in data_sessions] access_tags.append(f"_ROOT_NODE_{beamline.upper()}") - scopes = ["read:data", "read:metadata"] + scopes = ["read:data", "read:metadata", "revoke:apikeys"] expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") note = f"Auto-generated by sync-experiment from {hostname}" @@ -568,6 +536,14 @@ def create_api_key(tiled_context, data_sessions, beamline): return info +def revoke_api_key(tiled_context): + api_key = getattr(tiled_context, "api_key", None) + if not api_key: + raise ValueError("No API key attached to Tiled context. No API key to revoke.") + first_eight = api_key[:8] + tiled_context.revoke_api_key(first_eight) + + def set_api_key(redis_client, beamline, endstation, api_key): """ Use to set the active API key in Redis. @@ -582,128 +558,20 @@ def set_api_key(redis_client, beamline, endstation, api_key): redis_client.set(f"{redis_prefix}-active", api_key) -def get_api_key(redis_client, username, password, beamline, endstation, data_sessions): - """ - Retrieve an API key from Redis. - Query Redis for key information, decrypt the API key if found, - ignore the key if it is expired, and finally return the key - if it is still fresh. - Failure to decrypt (e.g. a changed password) will also cause - existing keys to be ignored. - - """ - data_sessions_sanitized = sorted( - data_session.replace("-", "") for data_session in data_sessions - ) - redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" - redis_prefix = ( - f"{redis_prefix}-{username}-{'-'.join(data_sessions_sanitized)}-apikey" - ) - - api_key_cached = {} - cursor = 0 - while True: - cursor, keys = redis_client.scan(cursor=cursor, match=f"{redis_prefix}*") - if keys: - values = redis_client.mget(keys) - keys = [key.decode("UTF-8") for key in keys] - api_key_cached.update(dict(zip(keys, values))) - if cursor == 0: - break - - if api_key_cached: - expires = datetime.fromisoformat( - api_key_cached[f"{redis_prefix}-ts-expires"].decode("UTF-8") - ) - now = datetime.now(timezone.utc) - if expires > now: - salt = api_key_cached[f"{redis_prefix}-salt"] - api_key_encrypted = api_key_cached[f"{redis_prefix}-encrypted"] - try: - api_key = decrypt_api_key(password, salt, api_key_encrypted) - except InvalidToken: - api_key = None - else: - api_key = None - else: - api_key = None - - return api_key - - -def cache_api_key( - redis_client, username, password, beamline, endstation, data_sessions, api_key_info -): +def get_api_key(redis_client, beamline, endstation): """ - Encrypt and cache and API key in Redis. - - Keys are rotated out via priority queue according to their - expiration dates. There is a limit set on the number of API keys that - may be cached at a time. + Use to get the active API key in Redis. - The cached values have keys that are prefixed with: - ----apikey - - There are 4 keys per each API key: - -ts-expires : the timestamp of when the key will expire - -ts-created : the timestamp of when the key was created/cached - -encrypted : the encrypted API key - -salt : the salt used for encryption + The active API key is stored with key: + --apikey-active """ - data_sessions_sanitized = sorted( - data_session.replace("-", "") for data_session in data_sessions - ) - redis_prefix = f"{beamline}-{endstation}" if endstation else f"{beamline}" redis_prefix = ( - f"{redis_prefix}-{username}-{'-'.join(data_sessions_sanitized)}-apikey" - ) - - MAX_ENTRIES = 5 - COUNT_PER_ENTRY = 4 - COUNT_NON_ENTRIES = 1 - length = redis_client.dbsize - - cursor = 0 - expiry_dates = [] - while True: - cursor, keys = redis_client.scan(cursor=cursor, match="*ts-expires") - if keys: - values = redis_client.mget(keys) - expiry_dates.extend( - [ - ( - datetime.fromisoformat(v.decode("UTF-8")), - k.decode("UTF-8").removesuffix("-ts-expires"), - ) - for k, v in zip(keys, values) - ] - ) - if cursor == 0: - break - - heapq.heapify(expiry_dates) - while (length() - COUNT_NON_ENTRIES) / COUNT_PER_ENTRY >= MAX_ENTRIES: - expiry_prefix = heapq.heappop(expiry_dates)[1] - cursor = 0 - while True: - cursor, keys = redis_client.scan( - cursor=cursor, match=f"{expiry_prefix}*", count=10 - ) - if keys: - redis_client.delete(*keys) - if cursor == 0: - break - - encrypted_key, salt = encrypt_api_key(password, api_key_info["secret"]) - redis_client.set( - f"{redis_prefix}-ts-expires", api_key_info["expiration_time"].isoformat() - ) - redis_client.set( - f"{redis_prefix}-ts-created", datetime.now(timezone.utc).isoformat() + f"{beamline}-{endstation}-apikey" if endstation else f"{beamline}-apikey" ) - redis_client.set(f"{redis_prefix}-encrypted", encrypted_key) - redis_client.set(f"{redis_prefix}-salt", salt) + api_key = redis_client.get(f"{redis_prefix}-active") + + return api_key def get_current_cycle(facility): From 9195e56dec163d07c4fa0cd0b7b0da84426b618b Mon Sep 17 00:00:00 2001 From: nmaytan Date: Mon, 4 May 2026 18:29:23 -0400 Subject: [PATCH 24/32] Change from root_node tag to public tag --- nslsii/sync_experiment/sync_experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index db57dcf8..9017ba86 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -522,7 +522,7 @@ def create_tiled_context( def create_api_key(tiled_context, data_sessions, beamline): access_tags = [data_session for data_session in data_sessions] - access_tags.append(f"_ROOT_NODE_{beamline.upper()}") + access_tags.append("public") scopes = ["read:data", "read:metadata", "revoke:apikeys"] expires_in = "7d" hostname = os.getenv("HOSTNAME", "unknown host") From ee85a3957dc8d53ee499fffa597b32fb377c01a6 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Mon, 31 Aug 2026 13:04:17 -0400 Subject: [PATCH 25/32] Make sync-experiment work with Tiled Entra auth --- nslsii/sync_experiment/sync_experiment.py | 75 ++++++++--------------- 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 9017ba86..2fff3ffa 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -7,10 +7,8 @@ import redis from datetime import datetime -from getpass import getpass -from pydantic.types import SecretStr from redis_json_dict import RedisJSONDict -from tiled.client.context import Context, password_grant, identity_provider_input +from tiled.client.context import Context, device_code_grant, identity_provider_input from tiled.profiles import load_profiles from nslsii.utils import open_redis_client @@ -100,13 +98,15 @@ def sync_experiment( decode_responses=True, ) - username, password, duo_append = prompt_for_login( - facility, beamline, endstation, proposal_ids + print(f"\nWelcome to the {beamline.upper()} beamline at {facility.upper()}!\n") + if endstation: + print(f"This is the {endstation.upper()} endstation.\n") + print( + f"Attempting to sync experiment for proposal ID(s) {(', ').join(proposal_ids)}.\n" ) + print("Please login to Tiled with your BNL credentials.") - tiled_context = create_tiled_context( - normalized_beamline, endstation, username, password, duo_append - ) + tiled_context, username = create_tiled_context(normalized_beamline, endstation) data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} if not proposals_can_be_authorized(username, facility, beamline, data_sessions): @@ -125,7 +125,7 @@ def sync_experiment( api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) if api_key_active: set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - tiled_context_revoke = create_tiled_context( + tiled_context_revoke, username = create_tiled_context( normalized_beamline, endstation, api_key=api_key_active ) try: @@ -266,7 +266,7 @@ def unsync_experiment( api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) if api_key_active: set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - tiled_context_revoke = create_tiled_context( + tiled_context_revoke, username = create_tiled_context( normalized_beamline, endstation, api_key=api_key_active ) try: @@ -428,22 +428,8 @@ def switch_proposal( return md -def prompt_for_login(facility, beamline, endstation, proposal_ids): - print(f"\nWelcome to the {beamline.upper()} beamline at {facility.upper()}!\n") - if endstation: - print(f"This is the {endstation.upper()} endstation.\n") - print( - f"Attempting to sync experiment for proposal ID(s) {(', ').join(proposal_ids)}.\n" - ) - print("Please login with your BNL credentials (you may receive a Duo prompt):") - username = input("Username: ") - password = SecretStr(getpass(prompt="Password: ")) - duo_append = input("Duo Passcode or Method (press Enter to ignore): ") - return username, password, duo_append - - def create_tiled_context( - beamline, endstation, username=None, password=None, duo_append=None, api_key=None + beamline, endstation, api_key=None ): """ Create a new Tiled context and authenticate. @@ -471,12 +457,6 @@ def create_tiled_context( if api_key: return context - if not username or not password: - raise ValueError( - "Please provide a username and password, " - "or an API key, to authenticate the Tiled context" - ) - providers = context.server_info.authentication.providers http_client = context.http_client if len(providers) == 1: @@ -491,24 +471,14 @@ def create_tiled_context( oauth2_spec = True if client_id and token_endpoint else False mode = spec.mode - if mode != "internal": - raise ValueError( - "Selected provider is not mode 'internal', and " - "sync-experiment only supports password auth currently." - "Please select a provider with mode='internal'." - ) - - if duo_append: - password = SecretStr(f"{password.get_secret_value()},{duo_append}") - try: - tokens = password_grant( - http_client, auth_endpoint, provider, username, password.get_secret_value() - ) - except httpx.HTTPStatusError as err: - if err.response.status_code == httpx.codes.UNAUTHORIZED: - raise ValueError("Username or password not recognized.") from err - else: - raise + # Display link and access code, and try to open web browser. + # Block while polling the server awaiting confirmation of authorization. + scopes = " ".join( + sorted({"openid", "offline_access"} | set(spec.extra_scopes or [])) + ) + tokens = device_code_grant( + http_client, auth_endpoint, client_id, token_endpoint, scopes, + ) confirmation_message = spec.confirmation_message if confirmation_message: @@ -517,7 +487,12 @@ def create_tiled_context( context.configure_auth(tokens, remember_me=False) - return context + for identity in context.whoami()["identities"]: + if identity["provider"] == provider: + username = identity["id"] + break + + return context, username def create_api_key(tiled_context, data_sessions, beamline): From 0e432ade5075f946c7becd6dc60323a5622f6766 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 7 Oct 2026 17:49:56 -0400 Subject: [PATCH 26/32] Bug fix from switch to OIDC --- nslsii/sync_experiment/sync_experiment.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 2fff3ffa..88041cbd 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -125,7 +125,7 @@ def sync_experiment( api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) if api_key_active: set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - tiled_context_revoke, username = create_tiled_context( + tiled_context_revoke, _ = create_tiled_context( normalized_beamline, endstation, api_key=api_key_active ) try: @@ -266,7 +266,7 @@ def unsync_experiment( api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) if api_key_active: set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - tiled_context_revoke, username = create_tiled_context( + tiled_context_revoke, _ = create_tiled_context( normalized_beamline, endstation, api_key=api_key_active ) try: @@ -435,8 +435,9 @@ def create_tiled_context( Create a new Tiled context and authenticate. Loads the beamline Tiled profile, instantiates the new context, - selects an AuthN provider, attempts to retrieve tokens via password_grant, - then prints a confirmation message and authenticates the context. + selects an AuthN provider, attempts to retrieve tokens via + device_code_grant, then optionally prints a confirmation message + and authenticates the context. If an api key is provided, the context is returned with that api key attached, and further authentication is skipped. @@ -455,7 +456,8 @@ def create_tiled_context( ) if api_key: - return context + # the provider is not specified so no username is returned + return context, None providers = context.server_info.authentication.providers http_client = context.http_client From 887037c11d0bcb08933ae41ecae8faf641593158 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 7 Oct 2026 22:30:40 -0400 Subject: [PATCH 27/32] Cleanups and docstrings for redis params --- nslsii/sync_experiment/sync_experiment.py | 55 +++++++++++++++++------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 88041cbd..39b3784e 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -56,6 +56,10 @@ def sync_experiment( the TLA of the beamline from which the experiment is running, not case-sensitive endstation : str or None (optional) the endstation at the beamline from which the experiment is running, not case-sensitive + redis_db : int (optional) + the Redis database index to use for the md Redis client (defaults to 0) + redis_ssl : bool (optional) + use SSL for Redis connections (defaults to False) verbose : bool (optional) turn on verbose printing @@ -165,8 +169,8 @@ def sync_experiment( if verbose: print(f"Redis connection info: {md_redis_client.client().connection}") - redis_prefix = f"{redis_location}-" if not redis_ssl else "" - md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) + md_redis_prefix = f"{redis_location}-" if not redis_ssl else "" + md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) activate_session = "pass-" + activate_proposal proposal = proposals[activate_proposal] @@ -213,6 +217,8 @@ def unsync_experiment( facility: str = "nsls2", beamline: str | None = None, endstation: str | None = None, + redis_db: int = 0, + redis_ssl: bool = False, verbose: bool = False, ) -> RedisJSONDict: """Unsync the currently active experiment (proposal) at the beamline. @@ -226,6 +232,10 @@ def unsync_experiment( the TLA of the beamline from which the experiment is running, not case-sensitive endstation : str or None (optional) the endstation at the beamline from which the experiment is running, not case-sensitive + redis_db : int (optional) + the Redis database index to use for the md Redis client (defaults to 0) + redis_ssl : bool (optional) + use SSL for Redis connections (defaults to False) verbose : bool (optional) turn on verbose printing @@ -255,12 +265,25 @@ def unsync_experiment( decode_responses=True, ) - md_redis_client = redis.Redis(host=f"info.{normalized_beamline}.nsls2.bnl.gov") - md_redis_prefix = ( - f"{normalized_beamline}-{endstation}-" - if endstation - else f"{normalized_beamline}-" + redis_location = ( + f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" + ) + + md_redis_client = open_redis_client( + redis_ssl=redis_ssl, + redis_location=redis_location, + redis_db=redis_db, + redis_url=( + f"info.{normalized_beamline}.nsls2.bnl.gov" + if not redis_ssl + else None + ), ) + + if verbose: + print(f"Redis connection info: {md_redis_client.client().connection}") + + md_redis_prefix = f"{redis_location}-" if not redis_ssl else "" md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) @@ -311,9 +334,9 @@ def switch_proposal( facility: str = "nsls2", beamline: str | None = None, endstation: str | None = None, - verbose: bool = False, redis_db: int = 0, redis_ssl: bool = False, + verbose: bool = False, ) -> RedisJSONDict: """Switch the active experiment (proposal) at the beamline. @@ -329,6 +352,10 @@ def switch_proposal( the TLA of the beamline from which the experiment is running, not case-sensitive endstation : str or None (optional) the endstation at the beamline from which the experiment is running, not case-sensitive + redis_db : int (optional) + the Redis database index to use for the md Redis client (defaults to 0) + redis_ssl : bool (optional) + use SSL for Redis connections (defaults to False) verbose : bool (optional) turn on verbose printing @@ -371,8 +398,8 @@ def switch_proposal( if verbose: print(f"Redis connection info: {md_redis_client.client().connection}") - redis_prefix = f"{redis_location}-" if not redis_ssl else "" - md = RedisJSONDict(redis_client=md_redis_client, prefix=redis_prefix) + md_redis_prefix = f"{redis_location}-" if not redis_ssl else "" + md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) activate_proposal = str(proposal_id) activate_session = "pass-" + activate_proposal @@ -708,7 +735,7 @@ def main(): version=f"%(prog)s {importlib.metadata.version('nslsii')}", ) parser.add_argument( - "-s", + "-a", "--activate", dest="activate", type=int, @@ -740,9 +767,9 @@ def main(): facility=args.facility, beamline=args.beamline, endstation=args.endstation, - verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, + verbose=args.verbose, ) elif args.switch is not None: switch_proposal( @@ -750,9 +777,9 @@ def main(): beamline=args.beamline, endstation=args.endstation, proposal_id=args.switch, - verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, + verbose=args.verbose, ) else: sync_experiment( @@ -761,7 +788,7 @@ def main(): endstation=args.endstation, proposal_ids=args.proposals, activate_id=args.activate, - verbose=args.verbose, redis_db=args.redis_db, redis_ssl=args.redis_ssl, + verbose=args.verbose, ) From d4891fb4240726d2bf4a0b574c0f35d2792afc87 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 7 Oct 2026 22:45:43 -0400 Subject: [PATCH 28/32] Update dependencies and pixi.lock --- pixi.lock | 41 +++++++++++++++++++---------------------- pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/pixi.lock b/pixi.lock index 85af3e65..9571673c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -66,7 +66,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/6b/3feec8016ab2263946c4480510d2d1a7924a5400c455974a7a0344bb4304/setuptools_dso-2.12.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl @@ -95,7 +94,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl @@ -185,6 +183,7 @@ environments: - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl @@ -223,7 +222,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/6b/3feec8016ab2263946c4480510d2d1a7924a5400c455974a7a0344bb4304/setuptools_dso-2.12.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl @@ -272,7 +270,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl @@ -381,6 +378,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl @@ -423,7 +421,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/6b/3feec8016ab2263946c4480510d2d1a7924a5400c455974a7a0344bb4304/setuptools_dso-2.12.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl @@ -473,7 +470,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl @@ -576,6 +572,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -612,7 +609,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/7b/55f41ec7746713db21aff5e794fc98b5a2e4c40fc72d69f6a1cdb04df360/epicscorelibs-7.0.10.99.0.1-cp312-cp312-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/51/6b/3feec8016ab2263946c4480510d2d1a7924a5400c455974a7a0344bb4304/setuptools_dso-2.12.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl @@ -666,7 +662,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl @@ -768,6 +763,7 @@ environments: - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl @@ -806,7 +802,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/6b/3feec8016ab2263946c4480510d2d1a7924a5400c455974a7a0344bb4304/setuptools_dso-2.12.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl @@ -855,7 +850,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl @@ -1435,7 +1429,6 @@ packages: - httpx - ipython - ipywidgets - - ldap3 - matplotlib - msgpack-numpy - msgpack>=1.0.0 @@ -1453,6 +1446,7 @@ packages: - redis-json-dict - requests - shortuuid + - tiled requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: charset-normalizer @@ -1478,6 +1472,20 @@ packages: version: 1.2.1 sha256: a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl + name: pytest-asyncio + version: 1.4.0 + sha256: 933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 + requires_dist: + - backports-asyncio-runner>=1.1,<2 ; python_full_version < '3.11' + - pytest>=8.4,<10 + - typing-extensions>=4.12 ; python_full_version < '3.13' + - sphinx>=5.3 ; extra == 'docs' + - sphinx-rtd-theme>=1 ; extra == 'docs' + - sphinx-tabs>=3.5 ; extra == 'docs' + - coverage>=6.2 ; extra == 'testing' + - hypothesis>=5.7.1 ; extra == 'testing' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl name: h11 version: 0.16.0 @@ -2655,12 +2663,6 @@ packages: version: 3.1.1 sha256: 7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752 requires_python: '>=3.3' -- pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - name: ldap3 - version: 2.9.1 - sha256: 5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70 - requires_dist: - - pyasn1>=0.4.6 - pypi: https://files.pythonhosted.org/packages/4f/7b/55f41ec7746713db21aff5e794fc98b5a2e4c40fc72d69f6a1cdb04df360/epicscorelibs-7.0.10.99.0.1-cp312-cp312-manylinux2014_x86_64.whl name: epicscorelibs version: 7.0.10.99.0.1 @@ -3946,11 +3948,6 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl - name: pyasn1 - version: 0.6.4 - sha256: deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl name: jedi version: 0.20.0 diff --git a/pyproject.toml b/pyproject.toml index 5d77fe1b..9ba891b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "httpx", "ipython", "ipywidgets", - "ldap3", "matplotlib", "msgpack>=1.0.0", "msgpack-numpy", @@ -46,6 +45,7 @@ dependencies = [ "redis-json-dict", "requests", "shortuuid", + "tiled", ] dynamic = ["version"] From 286f530ea126353666e749890530596012bff9e3 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 7 Oct 2026 22:48:08 -0400 Subject: [PATCH 29/32] Reorder argsparse args --- nslsii/sync_experiment/sync_experiment.py | 42 +++++++++++------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 39b3784e..c79ae457 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -701,14 +701,6 @@ def main(): help="The beamline endstation for the experiment, if applicable", required=False, ) - parser.add_argument( - "-d", - "--database", - dest="redis_db", - type=int, - default=0, - help="Redis database index", - ) # Mutually exclusive modes: sync (proposals+activate), switch, unsync modes_group = parser.add_mutually_exclusive_group(required=True) @@ -721,19 +713,6 @@ def main(): type=int, help="The proposal ID(s) to authorize for the experiment", ) - parser.add_argument( - "-S", - "--enable-ssl", - dest="redis_ssl", - action="store_true", - help="Enable SSL for Redis connections", - ) - parser.add_argument( - "-V", - "--version", - action="version", - version=f"%(prog)s {importlib.metadata.version('nslsii')}", - ) parser.add_argument( "-a", "--activate", @@ -756,6 +735,27 @@ def main(): help="Unsync experiment - deauthorize all proposals and deactivate the experiment.", action=argparse.BooleanOptionalAction, ) + parser.add_argument( + "-d", + "--database", + dest="redis_db", + type=int, + default=0, + help="Redis database index", + ) + parser.add_argument( + "-S", + "--enable-ssl", + dest="redis_ssl", + action="store_true", + help="Enable SSL for Redis connections", + ) + parser.add_argument( + "-V", + "--version", + action="version", + version=f"%(prog)s {importlib.metadata.version('nslsii')}", + ) parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) args = parser.parse_args() From 2e6e841cbef81ca67bb9195d59f9ab8804a3807c Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 7 Oct 2026 23:00:30 -0400 Subject: [PATCH 30/32] Minor improvements --- nslsii/sync_experiment/sync_experiment.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index c79ae457..ae0e29c6 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -299,13 +299,13 @@ def unsync_experiment( finally: tiled_context_revoke.api_key = None tiled_context_revoke.logout() - data_sessions_deauthorized = md["data_sessions_authorized"] or [ + data_sessions_deauthorized = md.get("data_sessions_authorized") or [ "" ] md["data_sessions_authorized"] = list() - data_session = md["data_session"] or "" + data_session = md.get("data_session") or "" md["data_session"] = "" - username = md["username"] or "" + username = md.get("username") or "" md["username"] = "" md["start_datetime"] = "" md["tiled_access_tags"] = list() @@ -498,7 +498,6 @@ def create_tiled_context( client_id = spec.links.get("client_id") token_endpoint = spec.links.get("token_endpoint") oauth2_spec = True if client_id and token_endpoint else False - mode = spec.mode # Display link and access code, and try to open web browser. # Block while polling the server awaiting confirmation of authorization. @@ -733,7 +732,7 @@ def main(): "--unsync", dest="unsync", help="Unsync experiment - deauthorize all proposals and deactivate the experiment.", - action=argparse.BooleanOptionalAction, + action="store_true", ) parser.add_argument( "-d", From ae9b895d34c3cb75e38599c7ef9f32200891d01a Mon Sep 17 00:00:00 2001 From: nmaytan Date: Wed, 7 Oct 2026 23:14:51 -0400 Subject: [PATCH 31/32] Fix switch_proposal tests (done by Sol-5.6) --- nslsii/tests/test_redis_integration.py | 43 ++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/nslsii/tests/test_redis_integration.py b/nslsii/tests/test_redis_integration.py index 4b0110d0..23f143a8 100644 --- a/nslsii/tests/test_redis_integration.py +++ b/nslsii/tests/test_redis_integration.py @@ -8,7 +8,7 @@ import nslsii from nslsii.utils import open_redis_client -from nslsii.sync_experiment.sync_experiment import switch_redis_proposal +from nslsii.sync_experiment.sync_experiment import switch_proposal # The __init__.py of nslsii.sync_experiment re-exports a function called # sync_experiment, which shadows the module of the same name. We need the @@ -100,56 +100,61 @@ def test_configure_base_passes_redis_db(mock_open_rc): # --------------------------------------------------------------------------- -# switch_redis_proposal tests +# switch_proposal tests # --------------------------------------------------------------------------- @pytest.fixture() def switch_mocks(): - """Patch all external dependencies of switch_redis_proposal and yield a dict of mocks.""" + """Patch all external dependencies of switch_proposal and yield a dict of mocks.""" + md = { + "data_sessions_authorized": ["pass-123456"], + "username": "testuser", + } with ( patch.object(_sync_mod, "open_redis_client") as mock_open_rc, - patch.object(_sync_mod, "RedisJSONDict", return_value={}) as mock_rjd, - patch.object(_sync_mod, "should_they_be_here", return_value=True), + patch.object(_sync_mod, "RedisJSONDict", return_value=md) as mock_rjd, patch.object( _sync_mod, - "validate_proposal", + "retrieve_proposals", return_value={ - "proposal_id": "123", - "title": "t", - "type": "GU", - "users": [], + "123456": { + "proposal_id": "123456", + "title": "t", + "type": "GU", + "users": [], + } }, ), - patch.object(_sync_mod, "is_commissioning_proposal", return_value=False), + patch.object(_sync_mod, "get_commissioning_proposals", return_value=[]), patch.object(_sync_mod, "get_current_cycle", return_value="2026-1"), ): yield {"open_redis_client": mock_open_rc, "RedisJSONDict": mock_rjd} -def test_switch_redis_proposal_ssl_no_prefix(switch_mocks): +def test_switch_proposal_ssl_no_prefix(switch_mocks): """With redis_ssl=True the RedisJSONDict prefix should be empty.""" - switch_redis_proposal(123456, beamline="SMI", username="testuser", redis_ssl=True) + switch_proposal(123456, beamline="SMI", username="testuser", redis_ssl=True) mock_rjd = switch_mocks["RedisJSONDict"] mock_rjd.assert_called_once() assert mock_rjd.call_args[1]["prefix"] == "" -def test_switch_redis_proposal_endstation_no_ssl(switch_mocks): - """With endstation set and redis_ssl=False the prefix should be '{endstation}-'.""" - switch_redis_proposal( +def test_switch_proposal_endstation_no_ssl(switch_mocks): + """With an endstation and no SSL, prefix should identify beamline and endstation.""" + switch_proposal( 123456, beamline="SMI", username="testuser", endstation="opls", redis_ssl=False ) mock_rjd = switch_mocks["RedisJSONDict"] mock_rjd.assert_called_once() - assert mock_rjd.call_args[1]["prefix"] == "opls-" + assert mock_rjd.call_args[1]["prefix"] == "smi-opls-" -def test_switch_redis_proposal_passes_redis_db(switch_mocks): +def test_switch_proposal_passes_redis_db(switch_mocks): """redis_db should be forwarded to open_redis_client.""" - switch_redis_proposal( + switch_proposal( 123456, beamline="SMI", username="testuser", redis_db=7, redis_ssl=False ) From bbe28899bb99dc36a520dfe477e2b4a9cb0d7a87 Mon Sep 17 00:00:00 2001 From: nmaytan Date: Thu, 8 Oct 2026 00:15:43 -0400 Subject: [PATCH 32/32] Harden decision around API key clearing, better client cleanup --- nslsii/sync_experiment/sync_experiment.py | 106 ++++++++++++---------- nslsii/tests/test_redis_integration.py | 78 ++++++++++++++++ 2 files changed, 138 insertions(+), 46 deletions(-) diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index ae0e29c6..b9d51d08 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -72,7 +72,9 @@ def sync_experiment( beamline = beamline or env_beamline endstation = endstation or env_endstation proposal_ids = [str(proposal_id) for proposal_id in proposal_ids] - activate_proposal = activate_id or proposal_ids[0] + if not proposal_ids: + raise ValueError("At least one proposal ID must be provided.") + activate_proposal = activate_id if activate_id is not None else proposal_ids[0] activate_proposal = str(activate_proposal) if not beamline: @@ -112,44 +114,25 @@ def sync_experiment( tiled_context, username = create_tiled_context(normalized_beamline, endstation) - data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} - if not proposals_can_be_authorized(username, facility, beamline, data_sessions): - tiled_context.api_key = None - tiled_context.logout() - raise ValueError( - f"You do not have permissions to authorize all proposal IDs: {', '.join(proposal_ids)}" - ) try: + data_sessions = {"pass-" + proposal_id for proposal_id in proposal_ids} + if not proposals_can_be_authorized(username, facility, beamline, data_sessions): + raise ValueError( + f"You do not have permissions to authorize all proposal IDs: {', '.join(proposal_ids)}" + ) proposals = retrieve_proposals(facility, beamline, proposal_ids) - except Exception: - tiled_context.api_key = None - tiled_context.logout() - raise - - api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) - if api_key_active: - set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - tiled_context_revoke, _ = create_tiled_context( - normalized_beamline, endstation, api_key=api_key_active + revoke_active_api_key( + apikey_redis_client, normalized_beamline, endstation ) - try: - revoke_api_key(tiled_context_revoke) - except Exception as e: - print(f"Revocation of existing API key may have failed: {e}") - finally: - tiled_context_revoke.api_key = None - tiled_context_revoke.logout() - try: api_key_info = create_api_key(tiled_context, data_sessions, normalized_beamline) api_key = api_key_info["secret"] - except Exception: + set_api_key(apikey_redis_client, normalized_beamline, endstation, api_key) + finally: tiled_context.api_key = None - tiled_context.logout() - raise - set_api_key(apikey_redis_client, normalized_beamline, endstation, api_key) - - tiled_context.api_key = None - tiled_context.logout() + try: + tiled_context.logout() + finally: + tiled_context.close() redis_location = ( f"{normalized_beamline}-{endstation}" if endstation else f"{normalized_beamline}" @@ -286,19 +269,7 @@ def unsync_experiment( md_redis_prefix = f"{redis_location}-" if not redis_ssl else "" md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) - api_key_active = get_api_key(apikey_redis_client, normalized_beamline, endstation) - if api_key_active: - set_api_key(apikey_redis_client, normalized_beamline, endstation, "") - tiled_context_revoke, _ = create_tiled_context( - normalized_beamline, endstation, api_key=api_key_active - ) - try: - revoke_api_key(tiled_context_revoke) - except Exception as e: - print(f"Revocation of existing API key may have failed: {e}") - finally: - tiled_context_revoke.api_key = None - tiled_context_revoke.logout() + revoke_active_api_key(apikey_redis_client, normalized_beamline, endstation) data_sessions_deauthorized = md.get("data_sessions_authorized") or [ "" ] @@ -547,6 +518,49 @@ def revoke_api_key(tiled_context): tiled_context.revoke_api_key(first_eight) +def revoke_active_api_key(redis_client, beamline, endstation): + api_key = get_api_key(redis_client, beamline, endstation) + if not api_key: + return + + try: + tiled_context, _ = create_tiled_context( + beamline, endstation, api_key=api_key + ) + except httpx.HTTPStatusError as exc: + # The stored key cannot authenticate, so it is safe to discard. + if exc.response.status_code == httpx.codes.UNAUTHORIZED: + set_api_key(redis_client, beamline, endstation, "") + return + raise + + try: + # Check validity separately because Tiled also uses 401 for missing scopes. + try: + tiled_context.which_api_key() + except httpx.HTTPStatusError as exc: + if exc.response.status_code == httpx.codes.UNAUTHORIZED: + set_api_key(redis_client, beamline, endstation, "") + return + raise + + try: + revoke_api_key(tiled_context) + except httpx.HTTPStatusError as exc: + # A 404 is already revoked; other failures may leave a valid key. + if exc.response.status_code != httpx.codes.NOT_FOUND: + raise + + # A successful revocation or 404 means the key is unusable. + set_api_key(redis_client, beamline, endstation, "") + finally: + tiled_context.api_key = None + try: + tiled_context.logout() + finally: + tiled_context.close() + + def set_api_key(redis_client, beamline, endstation, api_key): """ Use to set the active API key in Redis. diff --git a/nslsii/tests/test_redis_integration.py b/nslsii/tests/test_redis_integration.py index 23f143a8..30ce8a6d 100644 --- a/nslsii/tests/test_redis_integration.py +++ b/nslsii/tests/test_redis_integration.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch, mock_open +import httpx import pytest import sys @@ -160,3 +161,80 @@ def test_switch_proposal_passes_redis_db(switch_mocks): call_kwargs = switch_mocks["open_redis_client"].call_args[1] assert call_kwargs["redis_db"] == 7 + + +def test_revoke_active_api_key_clears_unusable_key(): + redis_client = MagicMock() + tiled_context = MagicMock() + request = httpx.Request("GET", "https://tiled.example/auth/apikey") + tiled_context.which_api_key.side_effect = httpx.HTTPStatusError( + "API key is no longer valid", + request=request, + response=httpx.Response(401, request=request), + ) + + with ( + patch.object(_sync_mod, "get_api_key", return_value="expired-key"), + patch.object( + _sync_mod, "create_tiled_context", return_value=(tiled_context, None) + ), + patch.object(_sync_mod, "revoke_api_key") as mock_revoke_api_key, + patch.object(_sync_mod, "set_api_key") as mock_set_api_key, + ): + _sync_mod.revoke_active_api_key(redis_client, "smi", None) + + mock_set_api_key.assert_called_once_with(redis_client, "smi", None, "") + mock_revoke_api_key.assert_not_called() + tiled_context.logout.assert_called_once_with() + tiled_context.close.assert_called_once_with() + + +def test_revoke_active_api_key_retains_key_without_revoke_scope(): + redis_client = MagicMock() + tiled_context = MagicMock() + request = httpx.Request("DELETE", "https://tiled.example/auth/apikey") + response = httpx.Response(401, request=request) + error = httpx.HTTPStatusError( + "Not enough permissions", request=request, response=response + ) + + with ( + patch.object(_sync_mod, "get_api_key", return_value="under-scoped-key"), + patch.object( + _sync_mod, "create_tiled_context", return_value=(tiled_context, None) + ), + patch.object(_sync_mod, "revoke_api_key", side_effect=error), + patch.object(_sync_mod, "set_api_key") as mock_set_api_key, + pytest.raises(httpx.HTTPStatusError), + ): + _sync_mod.revoke_active_api_key(redis_client, "smi", None) + + tiled_context.which_api_key.assert_called_once_with() + mock_set_api_key.assert_not_called() + tiled_context.logout.assert_called_once_with() + tiled_context.close.assert_called_once_with() + + +def test_revoke_active_api_key_retains_key_on_server_error(): + redis_client = MagicMock() + tiled_context = MagicMock() + request = httpx.Request("DELETE", "https://tiled.example/auth/apikey") + response = httpx.Response(503, request=request) + error = httpx.HTTPStatusError( + "Tiled is unavailable", request=request, response=response + ) + + with ( + patch.object(_sync_mod, "get_api_key", return_value="active-key"), + patch.object( + _sync_mod, "create_tiled_context", return_value=(tiled_context, None) + ), + patch.object(_sync_mod, "revoke_api_key", side_effect=error), + patch.object(_sync_mod, "set_api_key") as mock_set_api_key, + pytest.raises(httpx.HTTPStatusError), + ): + _sync_mod.revoke_active_api_key(redis_client, "smi", None) + + mock_set_api_key.assert_not_called() + tiled_context.logout.assert_called_once_with() + tiled_context.close.assert_called_once_with()