diff --git a/nslsii/sync_experiment/__init__.py b/nslsii/sync_experiment/__init__.py index 5528c0fa..eea544ee 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, unsync_experiment, switch_proposal diff --git a/nslsii/sync_experiment/sync_experiment.py b/nslsii/sync_experiment/sync_experiment.py index 03d53959..b9d51d08 100644 --- a/nslsii/sync_experiment/sync_experiment.py +++ b/nslsii/sync_experiment/sync_experiment.py @@ -1,323 +1,753 @@ import argparse import importlib.metadata +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 datetime import datetime from redis_json_dict import RedisJSONDict +from tiled.client.context import Context, device_code_grant, identity_provider_input +from tiled.profiles import load_profiles -data_session_re = re.compile(r"^pass-(?P\d+)$") +from nslsii.utils import open_redis_client -nslsii_api_client = httpx.Client(base_url="https://api.nsls2.bnl.gov") -from nslsii.utils import open_redis_client +facility_api_client = httpx.Client(base_url="https://api.nsls2.bnl.gov") -def get_current_cycle() -> str: - cycle_response = nslsii_api_client.get( - "/v1/facility/nsls2/cycles/current" - ).raise_for_status() - return cycle_response.json()["cycle"] +class KeyRemapper(dict): + def __missing__(self, key): + self[key] = key + return key -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 +normalized_beamlines = KeyRemapper( + { + "sst1": "sst", + "sst2": "sst", + } +) -def validate_proposal(data_session_value, beamline) -> Dict[str, Any]: - proposal_data = {} - data_session_match = data_session_re.match(data_session_value) +def sync_experiment( + proposal_ids: list[int | str], + activate_id: int | str | None = None, + facility: str = "nsls2", + beamline: str | None = None, + endstation: str | None = None, + redis_db: int = 0, + 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. - if data_session_match is None: + 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 + 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 + + Returns + ------- + md : RedisJSONDict + The updated metadata dictionary + """ + env_beamline, env_endstation = get_beamline_env() + beamline = beamline or env_beamline + endstation = endstation or env_endstation + proposal_ids = [str(proposal_id) for proposal_id in proposal_ids] + 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: 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." ) + for proposal_id in proposal_ids: + if not re.fullmatch(r"^\d{6}$", proposal_id): + raise ValueError( + f"Provided proposal ID '{proposal_id}' is not valid.\n " + f"A proposal ID must be a 6 character integer." + ) + if activate_proposal not in proposal_ids: + raise ValueError("Cannot activate a proposal which is not being authorized.") + + beamline = beamline.lower() + if endstation: + endstation = endstation.lower() + normalized_beamline = normalized_beamlines[beamline] + + # to-do:make this client use secure Redis (for API key storage) + apikey_redis_client = redis.Redis( + host=f"info.{normalized_beamline}.nsls2.bnl.gov", + port=6379, + db=15, + decode_responses=True, + ) + + 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, username = create_tiled_context(normalized_beamline, endstation) 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: + 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"while verifying data_session '{data_session_value}' " - f"an error was returned by {proposal_response.url}: " - f"{proposal_data}" + f"You do not have permissions to authorize all proposal IDs: {', '.join(proposal_ids)}" ) - 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}'" + proposals = retrieve_proposals(facility, beamline, proposal_ids) + revoke_active_api_key( + apikey_redis_client, normalized_beamline, endstation ) + api_key_info = create_api_key(tiled_context, data_sessions, normalized_beamline) + api_key = api_key_info["secret"] + set_api_key(apikey_redis_client, normalized_beamline, endstation, api_key) + finally: + tiled_context.api_key = None + try: + tiled_context.logout() + finally: + tiled_context.close() - return proposal_data + 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 + ), + ) -config_files = [ - os.path.expanduser("~/.config/n2sn_tools.yml"), - "/etc/n2sn_tools.yml", -] + 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) -def authenticate( - username, -): - config = None - for fn in config_files: - try: - with open(fn) as f: - config = yaml.safe_load(f) - except IOError: - pass - else: - break + activate_session = "pass-" + activate_proposal + proposal = proposals[activate_proposal] + + md["data_sessions_authorized"] = list(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"] = 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"] = [activate_session] + md["cycle"] = ( + "commissioning" + if activate_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, + } - if config is None: - raise RuntimeError("Unable to open a config file") + print( + 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']}." + ) - server = config.get("common", {}).get("server") + if verbose: + print(md) - if server is None: - raise RuntimeError("Server name not found!") + return md - 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, +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. + 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 + 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 + + Returns + ------- + md : RedisJSONDict + The updated metadata dictionary + """ + 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." ) - print(f"\nAuthenticated as : {connection.extend.standard.who_am_i()}") - except LDAPInvalidCredentialsResult: - raise RuntimeError(f"Invalid credentials for user '{username}'.") from None - except LDAPSocketOpenError: - print(f"{server} server connection failed...") + beamline = beamline.lower() + if endstation: + endstation = endstation.lower() + normalized_beamline = normalized_beamlines[beamline] + apikey_redis_client = redis.Redis( + host=f"info.{normalized_beamline}.nsls2.bnl.gov", + port=6379, + db=15, + decode_responses=True, + ) + 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 + md_redis_prefix = f"{redis_location}-" if not redis_ssl else "" + md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) - elif new_data_session in user_access_json["data_sessions"]: - return True + revoke_active_api_key(apikey_redis_client, normalized_beamline, endstation) + data_sessions_deauthorized = md.get("data_sessions_authorized") or [ + "" + ] + md["data_sessions_authorized"] = list() + data_session = md.get("data_session") or "" + md["data_session"] = "" + username = md.get("username") or "" + md["username"] = "" + md["start_datetime"] = "" + md["tiled_access_tags"] = list() + md["cycle"] = "" + md["proposal"] = { + "proposal_id": "", + "title": "", + "type": "", + "pi_name": "", + } - return False + 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)) -class AuthorizationError(Exception): ... + return md -def switch_redis_proposal( - proposal_number: Union[int, str], - beamline: str, - username: Optional[str] = None, - endstation: str = "", +def switch_proposal( + proposal_id: int | str, + username: str | None = None, + facility: str = "nsls2", + beamline: str | None = None, + endstation: str | None = None, redis_db: int = 0, redis_ssl: bool = False, verbose: bool = False, ) -> RedisJSONDict: - """Update information in RedisJSONDict for a specific beamline + """Switch the active experiment (proposal) 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 + 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) + 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 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, + 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." ) - redis_prefix = endstation + + beamline = beamline.lower() + if endstation: + endstation = endstation.lower() + normalized_beamline = normalized_beamlines[beamline] + username = username or input("Enter your username: ") + + 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}") + + md_redis_prefix = f"{redis_location}-" if not redis_ssl else "" + md = RedisJSONDict(redis_client=md_redis_client, prefix=md_redis_prefix) - if redis_prefix and redis_ssl: + 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( - 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 authorized data sessions (proposals).\n" + "Please run sync-experiment before attempting to switch the active 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 authorized data sessions (proposals) were authorized by a different user.\n" + "Please re-run sync-experiment to authorize as the intended user." ) - warnings.warn( - f"Experiment {new_data_session} was already started by the same user." + if activate_session not in data_sessions_authorized: + raise ValueError( + 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." ) - 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, [activate_proposal]) + proposal = proposals[activate_proposal] - 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() - ) - md["proposal"] = { - "proposal_id": proposal_data.get("proposal_id"), - "title": proposal_data.get("title"), - "type": proposal_data.get("type"), - "pi_name": pi_name, - } + 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"] = 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"] = [activate_session] + md["cycle"] = ( + "commissioning" + if activate_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"Started experiment {md['data_session']} by {md['username']}.") + print( + f"Switched to experiment wihh data session {md['data_session']} by {md['username']}." + ) return md -def sync_experiment( - proposal_number, - beamline, - verbose=False, - endstation="", - redis_db: int = 0, - redis_ssl=False, +def create_tiled_context( + beamline, endstation, api_key=None ): - # 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, + """ + 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 + 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. + + """ + 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"], api_key=api_key, verify=profile.get("verify", True) ) - if verbose: - print(md) + if api_key: + # the provider is not specified so no username is returned + return context, None - return md + 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 + + # 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: + username = "external user" if oauth2_spec else tokens["identity"]["id"] + print(confirmation_message.format(id=username)) + + context.configure_auth(tokens, remember_me=False) + + 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): + access_tags = [data_session for data_session in data_sessions] + access_tags.append("public") + 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}" + + if expires_in and expires_in.isdigit(): + expires_in = int(expires_in) + info = tiled_context.create_api_key( + access_tags=access_tags, scopes=scopes, expires_in=expires_in, note=note + ) + 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 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. + + The active API key is stored with key: + --apikey-active + + """ + 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, beamline, endstation): + """ + Use to get the active API key in Redis. + + The active API key is stored with key: + --apikey-active + + """ + redis_prefix = ( + f"{beamline}-{endstation}-apikey" if endstation else f"{beamline}-apikey" + ) + api_key = redis_client.get(f"{redis_prefix}-active") + + return api_key + + +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 proposals_can_be_authorized(username, facility, beamline, data_sessions): + """ + Check that the user can authorize 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_authorize = ( + 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_authorize + + +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 + 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. + + 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 authorized 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 = 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["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_proposals + if num_proposals > 1 and is_commissioning_proposal: + raise ValueError( + 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"]: + 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(): + beamline = os.getenv("BEAMLINE_ACRONYM") + endstation = os.getenv("ENDSTATION_ACRONYM") + 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, ) + + # 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) to authorize for the experiment", + ) + parser.add_argument( + "-a", + "--activate", + dest="activate", + type=int, + help="The ID of the proposal to activate, defaults to the first in the proposals list.", + required=False, + ) + 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.", + action="store_true", + ) parser.add_argument( "-d", "--database", @@ -325,22 +755,13 @@ def main(): type=int, default=0, help="Redis database index", - required=False, - ) - parser.add_argument( - "-p", - "--proposal", - dest="proposal", - type=int, - help="Which proposal (e.g. 123456)", - 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", @@ -351,11 +772,36 @@ def main(): parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) args = parser.parse_args() - sync_experiment( - proposal_number=args.proposal, - beamline=args.beamline, - verbose=args.verbose, - endstation=args.endstation, - redis_db=args.redis_db, - redis_ssl=args.redis_ssl, - ) + 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, + redis_db=args.redis_db, + redis_ssl=args.redis_ssl, + verbose=args.verbose, + ) + elif args.switch is not None: + switch_proposal( + facility=args.facility, + beamline=args.beamline, + endstation=args.endstation, + proposal_id=args.switch, + redis_db=args.redis_db, + redis_ssl=args.redis_ssl, + verbose=args.verbose, + ) + else: + sync_experiment( + facility=args.facility, + beamline=args.beamline, + endstation=args.endstation, + proposal_ids=args.proposals, + activate_id=args.activate, + redis_db=args.redis_db, + redis_ssl=args.redis_ssl, + verbose=args.verbose, + ) diff --git a/nslsii/tests/test_redis_integration.py b/nslsii/tests/test_redis_integration.py index 4b0110d0..30ce8a6d 100644 --- a/nslsii/tests/test_redis_integration.py +++ b/nslsii/tests/test_redis_integration.py @@ -2,13 +2,14 @@ from unittest.mock import MagicMock, patch, mock_open +import httpx import pytest import sys 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,58 +101,140 @@ 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 ) 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() 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"]