def test_make_request__raises_an_Abort_if_the_response_data_cannot_be_serialized_into_the_response_model_cls( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will raise an Abort if the response data cannot be serialized as and validated with the ``response_model_cls``. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.OK, json=dict(a=1, b=2, c=3), ), ) with pytest.raises( Abort, match="There was a big problem: Unexpected data in response" ) as err_info: make_request( client, req_path, "GET", abort_message="There was a big problem", abort_subject="BIG PROBLEM", support=True, response_model_cls=DummyResponseModel, ) assert err_info.value.subject == "BIG PROBLEM" assert err_info.value.support is True assert err_info.value.log_message == f"Unexpected format in response data: {dict(a=1, b=2, c=3)}" assert isinstance(err_info.value.original_error, pydantic.ValidationError)
def test_make_request__raises_an_Abort_if_the_response_cannot_be_deserialized_with_JSON( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will raise an Abort if the response is not JSON de-serializable. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.OK, text="Not JSON, my dude", ), ) with pytest.raises( Abort, match="There was a big problem: Response carried no data" ) as err_info: make_request(client, req_path, "GET", abort_message="There was a big problem", abort_subject="BIG PROBLEM", support=True) assert err_info.value.subject == "BIG PROBLEM" assert err_info.value.support is True assert err_info.value.log_message == "Failed unpacking json: Not JSON, my dude" assert isinstance(err_info.value.original_error, json.decoder.JSONDecodeError)
def test_make_request__raises_Abort_when_expected_status_is_not_None_and_response_status_does_not_match_it( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will raise an Abort if the ``expected_status`` arg is set and it does not match the status code of the response. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.BAD_REQUEST, text="It blowed up", ), ) with pytest.raises( Abort, match="There was a big problem: Received an error response" ) as err_info: make_request( client, req_path, "GET", expected_status=200, abort_message="There was a big problem", abort_subject="BIG PROBLEM", support=True, ) assert err_info.value.subject == "BIG PROBLEM" assert err_info.value.support is True assert err_info.value.log_message == "Got an error code for request: 400: It blowed up" assert err_info.value.original_error is None
def delete( ctx: typer.Context, id: int = typer.Option( ..., help="The id of the job submission to delete", ), ): """ Delete an existing job submission. """ jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx.client is not None, "Client is uninitialized" make_request( jg_ctx.client, f"/job-submissions/{id}", "DELETE", expected_status=204, abort_message="Request to delete job submission was not accepted by the API", support=True, ) terminal_message( "The job submission was successfully deleted.", subject="Job submission delete succeeded", )
def test_make_request__raises_Abort_if_client_request_raises_exception( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will raise an Abort if the call to ``client.send`` raises an exception. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" original_error = httpx.RequestError("BOOM!") respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( side_effect=original_error) with pytest.raises( Abort, match="There was a big problem: Communication with the API failed" ) as err_info: make_request(client, req_path, "GET", abort_message="There was a big problem", abort_subject="BIG PROBLEM", support=True) assert err_info.value.subject == "BIG PROBLEM" assert err_info.value.support is True assert err_info.value.log_message == "There was an error making the request to the API" assert err_info.value.original_error == original_error
def delete( ctx: typer.Context, id: int = typer.Option( ..., help=f"the specific id of the application to delete. {ID_NOTE}", ), ): """ Delete an existing application. """ jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx.client is not None # Delete the upload. The API will also remove the application data files make_request( jg_ctx.client, f"/applications/{id}", "DELETE", expected_status=204, expect_response=False, abort_message="Request to delete application was not accepted by the API", support=True, ) terminal_message( """ The application was successfully deleted. """, subject="Application delete succeeded", )
def test_make_request__success(respx_mock, dummy_client): """ Validate that the ``make_request()`` function will make a request to the supplied path for the client domain, check the status code, extract the data, validate it, and return an instance of the supplied response model. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.OK, json=dict( foo=1, bar="one", ), ), ) dummy_response_instance = make_request( client, req_path, "GET", expected_status=200, response_model_cls=DummyResponseModel, ) assert isinstance(dummy_response_instance, DummyResponseModel) assert dummy_response_instance.foo == 1 assert dummy_response_instance.bar == "one"
def upload_application( jg_ctx: JobbergateContext, application_path: pathlib.Path, application_id: int, ) -> bool: """ Upload an application given an application path and the application id. :param: jg_ctx: The JobbergateContext. Needed to access the Httpx client with which to make API calls :param: application_path: The directory where the application files to upload may be found :param: application_id: The id of the application for which to upload data :returns: True if the upload was successful; False otherwise """ # Make static type checkers happy assert jg_ctx.client is not None with tempfile.TemporaryDirectory() as temp_dir_str: build_path = pathlib.Path(temp_dir_str) logger.debug("Building application tar file at {build_path}") tar_path = build_application_tarball(application_path, build_path) response_code = cast( int, make_request( jg_ctx.client, f"/applications/{application_id}/upload", "POST", expect_response=False, abort_message= "Request to upload application files was not accepted by the API", support=True, files=dict(upload_file=open(tar_path, "rb")), ), ) return response_code == 201
def _upload_application(jg_ctx: JobbergateContext, application_path: pathlib.Path, application_id: int) -> bool: """ Utility function that uploads an application from a given path to the Jobbergate API. :param: jg_ctx: The JobbergateContext. Needed to access the Httpx client to use for requests :param: application_path: The directory on the system where the application files are found :param: application_id: The ``id`` of the Application to be uploaded """ # Make static type checkers happy assert jg_ctx.client is not None with tempfile.TemporaryDirectory() as temp_dir_str: build_path = pathlib.Path(temp_dir_str) logger.debug("Building application tar file at {build_path}") tar_path = build_application_tarball(application_path, build_path) response_code = cast( int, make_request( jg_ctx.client, f"/applications/{application_id}/upload", "POST", expect_response=False, abort_message="Request to upload application files was not accepted by the API", support=True, files=dict(upload_file=open(tar_path, "rb")), ), ) return response_code == 201
def test_make_request__uses_request_model_instance_for_request_body_if_passed( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will use a pydantic model instance to build the body of the request if the ``request_model`` argument is passed. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" dummy_route = respx_mock.post(f"{DEFAULT_DOMAIN}{req_path}") dummy_route.mock(return_value=httpx.Response( httpx.codes.CREATED, json=dict( foo=1, bar="one", ), ), ) dummy_response_instance = make_request( client, req_path, "POST", expected_status=201, response_model_cls=DummyResponseModel, request_model=DummyResponseModel(foo=1, bar="one"), ) assert isinstance(dummy_response_instance, DummyResponseModel) assert dummy_response_instance.foo == 1 assert dummy_response_instance.bar == "one" assert dummy_route.calls.last.request.content == json.dumps( dict(foo=1, bar="one")).encode("utf-8") assert dummy_route.calls.last.request.headers[ "Content-Type"] == "application/json"
def create_job_submission( jg_ctx: JobbergateContext, job_script_id: int, name: str, description: Optional[str] = None, cluster_id: Optional[str] = None, execution_directory: Optional[Path] = None, ) -> JobSubmissionResponse: """ Create a Job Submission from the given Job Script. :param: jg_ctx: The JobbergateContext. Used to retrieve the client for requests and the email of the submitting user :param: job_script_id: The ``id`` of the Job Script to submit to Slurm :param: name: The name to attach to the Job Submission :param: description: An optional description that may be added to the Job Submission :param: cluster_id: An optional cluster_id for the cluster where the job should be executed, if left off, it will default to the current cluster. :param: execution_directory: An optional directory where the job should be executed. If provided as a relative path, it will be constructed as an absolute path relative to the current working directory. :returns: The Job Submission data returned by the API after creating the new Job Submission """ # Make static type checkers happy assert jg_ctx.client is not None, "jg_ctx.client is uninitialized" assert jg_ctx.persona is not None, "jg_ctx.persona is uninitialized" job_submission_data = JobSubmissionCreateRequestData( job_submission_name=name, job_submission_description=description, job_script_id=job_script_id, cluster_id=cluster_id, ) if execution_directory is not None: if not execution_directory.is_absolute(): execution_directory = Path.cwd() / execution_directory execution_directory.resolve() job_submission_data.execution_directory = execution_directory result = cast( JobSubmissionResponse, make_request( jg_ctx.client, "/job-submissions", "POST", expected_status=201, abort_message="Couldn't create job submission", support=True, request_model=job_submission_data, response_model_cls=JobSubmissionResponse, ), ) return result
def fetch_application_data( jg_ctx: JobbergateContext, id: Optional[int] = None, identifier: Optional[str] = None, ) -> ApplicationResponse: """ Retrieve an application from the API by ``id`` or ``identifier``. :param: jg_ctx: The JobbergateContext. Needed to access the Httpx client with which to make API calls :param: id: The id of the application to fetch :param: identifier: If supplied, look for an application instance with the provided identifier :returns: An instance of ApplicationResponse containing the application data """ url = f"/applications/{id}" params = dict() if id is None and identifier is None: raise Abort( """ You must supply either [yellow]id[/yellow] or [yellow]identifier[/yellow]. """, subject="Invalid params", warn_only=True, ) elif id is not None and identifier is not None: raise Abort( """ You may not supply both [yellow]id[/yellow] and [yellow]identifier[/yellow]. """, subject="Invalid params", warn_only=True, ) elif identifier is not None: url = "/applications" params["identifier"] = identifier # Make static type checkers happy assert jg_ctx.client is not None stub = f"id={id}" if id is not None else f"identifier={identifier}" return cast( ApplicationResponse, make_request( jg_ctx.client, url, "GET", expected_status=200, abort_message=f"Couldn't retrieve application {stub} from API", response_model_cls=ApplicationResponse, support=True, params=params, ), )
def test_make_request__returns_the_response_status_code_if_expect_response_is_False( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will return None if the ``expect_response`` arg is False and the request was successfull. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.post(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response(httpx.codes.BAD_REQUEST), ) assert make_request(client, req_path, "POST", expect_response=False) == httpx.codes.BAD_REQUEST
def list_all( ctx: typer.Context, show_all: bool = typer.Option( False, "--all", help="Show all job scripts, even the ones owned by others"), search: Optional[str] = typer.Option( None, help="Apply a search term to results"), sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"), sort_field: Optional[str] = typer.Option( None, help="The field by which results should be sorted"), ): """ Show available job scripts """ jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx is not None assert jg_ctx.client is not None params: Dict[str, Any] = dict(all=show_all) if search is not None: params["search"] = search if sort_order is not SortOrder.UNSORTED: params["sort_ascending"] = SortOrder is SortOrder.ASCENDING if sort_field is not None: params["sort_field"] = sort_field envelope = cast( ListResponseEnvelope, make_request( jg_ctx.client, "/job-scripts", "GET", expected_status=200, abort_message="Couldn't retrieve job scripts list from API", support=True, response_model_cls=ListResponseEnvelope, params=params, ), ) render_list_results( jg_ctx, envelope, title="Job Scripts List", style_mapper=style_mapper, hidden_fields=HIDDEN_FIELDS, )
def test_make_request__returns_the_response_status_code_if_the_method_is_DELETE( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will return None if the ``method`` arg is DELETE and the request was successfull. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.delete(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.OK, json=dict(error="It blowed up"), ), ) assert make_request(client, req_path, "DELETE") == httpx.codes.OK
def test_make_request__returns_a_plain_dict_if_response_model_cls_is_None( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will return a plain dictionary containing the response data if the ``response_model_cls`` argument is not supplied. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.OK, json=dict(a=1, b=2, c=3), ), ) assert make_request(client, req_path, "GET") == dict(a=1, b=2, c=3)
def update( ctx: typer.Context, id: int = typer.Option( ..., help="The id of the job script to update", ), job_script: str = typer.Option( ..., help=""" The data with which to update job script. Format: string form of dictionary with main script as entry "application.sh" Example: '{"application.sh":"#!/bin/bash \\n hostname"}' """, ), ): """ Update an existing job script. """ jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx.client is not None job_script_result = cast( JobScriptResponse, make_request( jg_ctx.client, f"/job-scripts/{id}", "PUT", expected_status=200, abort_message="Couldn't update job script", support=True, json=dict(job_script_data_as_string=job_script), response_model_cls=JobScriptResponse, ), ) render_single_result( jg_ctx, job_script_result, hidden_fields=HIDDEN_FIELDS, title="Updated Job Script", )
def test_make_request__does_not_raise_Abort_when_expected_status_is_None_and_response_status_is_a_fail_code( respx_mock, dummy_client): """ Validate that the ``make_request()`` function will not raise an Abort if the ``expected_status`` arg is not set and the response is an error status code. """ client = dummy_client(headers={"content-type": "garbage"}) req_path = "/fake-path" respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock( return_value=httpx.Response( httpx.codes.BAD_REQUEST, json=dict(error="It blowed up"), ), ) err = make_request( client, req_path, "GET", response_model_cls=ErrorResponseModel, ) assert err.error == "It blowed up"
def fetch_job_script_data( jg_ctx: JobbergateContext, id: int, ) -> JobScriptResponse: """ Retrieve a job_script from the API by ``id`` """ # Make static type checkers happy assert jg_ctx.client is not None return cast( JobScriptResponse, make_request( jg_ctx.client, f"/job-scripts/{id}", "GET", expected_status=200, abort_message=f"Couldn't retrieve job script ({id}) from API", support=True, response_model_cls=JobScriptResponse, ), )
def refresh_access_token(ctx: JobbergateContext, token_set: TokenSet): """ Attempt to fetch a new access token given a refresh token in a token_set. Sets the access token in-place. If refresh fails, notify the user that they need to log in again. """ url = f"https://{settings.AUTH0_DOMAIN}/oauth/token" logger.debug(f"Requesting refreshed access token from {url}") JobbergateCliError.require_condition( ctx.client is not None, "Attempted to refresh with a null client. This should not happen", ) # Make static type-checkers happy assert ctx.client is not None refreshed_token_set = cast( TokenSet, make_request( ctx.client, "/oauth/token", "POST", abort_message="The auth token could not be refreshed. Please try logging in again.", abort_subject="EXPIRED ACCESS TOKEN", support=True, response_model_cls=TokenSet, data=dict( client_id=settings.AUTH0_CLIENT_ID, audience=settings.AUTH0_AUDIENCE, grant_type="refresh_token", refresh_token=token_set.refresh_token, ), ), ) token_set.access_token = refreshed_token_set.access_token
def fetch_job_submission_data( jg_ctx: JobbergateContext, job_submission_id: int, ) -> JobSubmissionResponse: """ Retrieve a job submission from the API by ``id`` """ # Make static type checkers happy assert jg_ctx.client is not None, "Client is uninitialized" return cast( JobSubmissionResponse, make_request( jg_ctx.client, f"/job-submissions/{job_submission_id}", "GET", expected_status=200, abort_message= f"Couldn't retrieve job submission {job_submission_id} from API", support=True, response_model_cls=JobSubmissionResponse, ), )
def create( ctx: typer.Context, name: str = typer.Option( ..., help="The name of the job script to create.", ), application_id: Optional[int] = typer.Option( None, help="The id of the application from which to create the job script.", ), application_identifier: Optional[str] = typer.Option( None, help= "The identifier of the application from which to create the job script.", ), sbatch_params: Optional[List[str]] = typer.Option( None, help="Optional parameter to submit raw sbatch parameters.", ), param_file: Optional[pathlib.Path] = typer.Option( None, help=dedent(""" Supply a yaml file that contains the parameters for populating templates. If this is not supplied, the question asking in the application is triggered. """), ), fast: bool = typer.Option( False, help="Use default answers (when available) instead of asking the user.", ), submit: Optional[bool] = typer.Option( None, help="Do not ask the user if they want to submit a job.", ), ): """ Create a new job script. """ jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx.client is not None app_data = fetch_application_data(jg_ctx, id=application_id, identifier=application_identifier) (app_config, app_module) = load_application_data(app_data) request_data = JobScriptCreateRequestData( application_id=app_data.id, job_script_name=name, sbatch_params=sbatch_params, param_dict=app_config, ) supplied_params = validate_parameter_file( param_file) if param_file else dict() execute_application(app_module, app_config, supplied_params, fast_mode=fast) if app_config.jobbergate_config.job_script_name is not None: request_data.job_script_name = app_config.jobbergate_config.job_script_name job_script_result = cast( JobScriptResponse, make_request( jg_ctx.client, "/job-scripts", "POST", expected_status=201, abort_message="Couldn't create job script", support=True, request_model=request_data, response_model_cls=JobScriptResponse, ), ) render_single_result( jg_ctx, job_script_result, hidden_fields=HIDDEN_FIELDS, title="Created Job Script", ) # `submit` will be `None` --submit/--no-submit flag was not set if submit is None: # If not running in "fast" mode, ask the user what to do. if not fast: submit = typer.confirm( "Would you like to submit this job immediately?") # Otherwise, assume that the job script should be submitted immediately else: submit = True if not submit: return try: job_submission_result = create_job_submission( jg_ctx, job_script_result.id, job_script_result.job_script_name) except Exception as err: raise Abort( "Failed to immediately submit the job after job script creation.", subject="Automatic job submission failed", support=True, log_message= f"There was an issue submitting the job immediately job_script_id={job_script_result.id}.", original_error=err, ) render_single_result( jg_ctx, job_submission_result, hidden_fields=JOB_SUBMISSION_HIDDEN_FIELDS, title="Created Job Submission (Fast Mode)", )
def update( ctx: typer.Context, id: int = typer.Option( ..., help=f"The specific id of the application to update. {ID_NOTE}", ), application_path: Optional[pathlib.Path] = typer.Option( None, help="The path to the directory where the application files are located", ), identifier: Optional[str] = typer.Option( None, help="Optional new application identifier to be set", ), application_desc: Optional[str] = typer.Option( None, help="Optional new application description to be set", ), ): """ Update an existing application. """ req_data = dict() if identifier: req_data["application_identifier"] = identifier if application_desc: req_data["application_description"] = application_desc if application_path is not None: validate_application_files(application_path) req_data["application_config"] = dump_full_config(application_path) req_data["application_file"] = read_application_module(application_path) jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx.client is not None result = cast( Dict[str, Any], make_request( jg_ctx.client, f"/applications/{id}", "PUT", expected_status=200, abort_message="Request to update application was not accepted by the API", support=True, json=req_data, ), ) if application_path is not None: successful_upload = _upload_application(jg_ctx, application_path, id) if not successful_upload: terminal_message( f""" The zipped application files could not be uploaded. [yellow]If the problem persists, please contact [bold]{OV_CONTACT}[/bold] for support and trouble-shooting[/yellow] """, subject="File upload failed", color="yellow", ) else: result["application_uploaded"] = True render_single_result( jg_ctx, result, hidden_fields=HIDDEN_FIELDS, title="Updated Application", )
def create( ctx: typer.Context, name: str = typer.Option( ..., help="The name of the application to create", ), identifier: Optional[str] = typer.Option( None, help=f"The human-friendly identifier of the application. {IDENTIFIER_NOTE}", ), application_path: pathlib.Path = typer.Option( ..., help="The path to the directory where the application files are located", ), application_desc: Optional[str] = typer.Option( None, help="A helpful description of the application", ), ): """ Create a new application. """ req_data = load_default_config() req_data["application_name"] = name if identifier: req_data["application_identifier"] = identifier if application_desc: req_data["application_description"] = application_desc validate_application_files(application_path) req_data["application_config"] = dump_full_config(application_path) req_data["application_file"] = read_application_module(application_path) jg_ctx: JobbergateContext = ctx.obj # Make static type checkers happy assert jg_ctx.client is not None result = cast( Dict[str, Any], make_request( jg_ctx.client, "/applications", "POST", expected_status=201, abort_message="Request to create application was not accepted by the API", support=True, json=req_data, ), ) application_id = result["id"] successful_upload = upload_application(jg_ctx, application_path, application_id) if not successful_upload: terminal_message( f""" The zipped application files could not be uploaded. Try running the `update` command including the application path to re-upload. [yellow]If the problem persists, please contact [bold]{OV_CONTACT}[/bold] for support and trouble-shooting[/yellow] """, subject="File upload failed", color="yellow", ) else: result["application_uploaded"] = True render_single_result( jg_ctx, result, hidden_fields=HIDDEN_FIELDS, title="Created Application", )
def fetch_auth_tokens(ctx: JobbergateContext) -> TokenSet: """ Fetch an access token (and possibly a refresh token) from Auth0. Prints out a URL for the user to use to authenticate and polls the token endpoint to fetch it when the browser-based process finishes """ # Make static type-checkers happy assert ctx.client is not None device_code_data = cast( DeviceCodeData, make_request( ctx.client, "/oauth/device/code", "POST", expected_status=200, abort_message="There was a problem retrieving a device verification code from the auth provider", abort_subject="COULD NOT RETRIEVE TOKEN", support=True, response_model_cls=DeviceCodeData, data=dict( client_id=settings.AUTH0_CLIENT_ID, audience=settings.AUTH0_AUDIENCE, scope="offline_access", # To get refresh token ), ), ) terminal_message( f""" To complete login, please open the following link in a browser: {device_code_data.verification_uri_complete} Waiting up to {settings.AUTH0_MAX_POLL_TIME / 60} minutes for you to complete the process... """, subject="Waiting for login", ) for tick in TimeLoop( settings.AUTH0_MAX_POLL_TIME, message="Waiting for web login", ): response_data = cast( Dict, make_request( ctx.client, "/oauth/token", "POST", abort_message="There was a problem retrieving a device verification code from the auth provider", abort_subject="COULD NOT FETCH ACCESS TOKEN", support=True, data=dict( grant_type="urn:ietf:params:oauth:grant-type:device_code", device_code=device_code_data.device_code, client_id=settings.AUTH0_CLIENT_ID, ), ), ) if "error" in response_data: if response_data["error"] == "authorization_pending": logger.debug(f"Token fetch attempt #{tick.counter} failed") sleep(device_code_data.interval) else: # TODO: Test this failure condition raise Abort( unwrap( """ There was a problem retrieving a device verification code from the auth provider: Unexpected failure retrieving access token. """ ), subject="Unexpected error", support=True, log_message=f"Unexpected error response: {response_data}", ) else: return TokenSet(**response_data) raise Abort( "Login process was not completed in time. Please try again.", subject="Timed out", log_message="Timed out while waiting for user to complete login", )