From cdec2af2c32e30cb4469bc3ba0656af0904f0033 Mon Sep 17 00:00:00 2001 From: Nikola Rasulic Date: Mon, 16 Jun 2025 11:32:46 +0000 Subject: [PATCH 1/2] move code from quartzbio client --- solvebio/__init__.py | 107 +++++++------- solvebio/__main__.py | 7 + solvebio/auth.py | 146 ++++++++++++++++++++ solvebio/cli/auth.py | 65 +++++---- solvebio/cli/credentials.py | 46 +++--- solvebio/cli/main.py | 14 +- solvebio/client.py | 90 ++++-------- solvebio/contrib/dash/solvebio_auth.py | 2 +- solvebio/contrib/dash/solvebio_dash.py | 3 - solvebio/contrib/streamlit/solvebio_auth.py | 4 +- solvebio/test/test_credentials.py | 16 +-- solvebio/test/test_login.py | 6 +- solvebio/test/test_object.py | 2 +- solvebio/test/test_shortcuts.py | 4 - solvebio/utils/validators.py | 31 ----- 15 files changed, 322 insertions(+), 221 deletions(-) create mode 100644 solvebio/__main__.py create mode 100644 solvebio/auth.py delete mode 100644 solvebio/utils/validators.py diff --git a/solvebio/__init__.py b/solvebio/__init__.py index 6dbbad6c..3ded3040 100644 --- a/solvebio/__init__.py +++ b/solvebio/__init__.py @@ -22,17 +22,6 @@ # Python 2.6 doesn't support this pass -# Read/Write API key -api_key = _os.environ.get('SOLVEBIO_API_KEY', None) -# OAuth2 access tokens -access_token = _os.environ.get('SOLVEBIO_ACCESS_TOKEN', None) -if access_token is None: - access_token = _os.environ.get('EDP_ACCESS_TOKEN', None) - -api_host = _os.environ.get('SOLVEBIO_API_HOST', None) -if api_host is None: - api_host = _os.environ.get('EDP_API_HOST', 'https://api.solvebio.com') - def help(): _open_help('/docs') @@ -99,7 +88,8 @@ def emit(self, record): from .query import Query, BatchQuery, Filter, GenomicFilter from .global_search import GlobalSearch from .annotate import Annotator, Expression -from .client import SolveClient +from .client import client, SolveClient +from .auth import authenticate from .resource import ( Application, Beacon, @@ -125,53 +115,60 @@ def emit(self, record): ) -def login(**kwargs): +def login( + api_host: str = None, + api_key: str = None, + access_token: str = None, + name: str = None, + version: str = None, +): + """ + Function to login to the QuartzBio/EDP API when using EDP in a python script. + Note that another function is used when CLI command `quartzbio login` is used! + EDP checks user credentials & host URL from multiple sources, in the following order: + + 1) Parameters provided (e.g. the parameters of this function) + 2) Environment variables (if the above parameters weren't provided) + 3) quartzbio credentials file stored in the user's HOME directory + (if parameters and environment variables weren't found) + + :param api_host: the QuartzBio EDP instance's URL to access. + :param access_token: your user's access token, which you can generate at the EDP website + (user menu > `Personal Access Tokens`) + :param api_key: Your API key. You can use this instead of providing an access token + :param name: name + :param version: version + + Example: + .. code-block:: python + + import quartzbio + quartzbio.login( + api_host="https://solvebio.api.az.aws.quartz.bio", + api_key=YOUR_API_KEY + ) """ - Sets up the auth credentials using the provided key/token, - or checks the credentials file (if no token provided). + if access_token: + client._host, client._auth = authenticate( + api_host, access_token, token_type="Bearer" + ) + elif api_key: + client._host, client._auth = authenticate(api_host, api_key, token_type="Token") - Lookup order: - 1. access_token - 2. api_key - 3. local credentials + client.set_user_agent(name=name, version=version) - No errors are raised if no key is found. - """ - from .cli.auth import get_credentials - global access_token, api_key, api_host - - # Clear any existing auth keys - access_token, api_key = None, None - # Update the host - api_host = kwargs.get('api_host') or api_host - - if kwargs.get('access_token'): - access_token = kwargs.get('access_token') - elif kwargs.get('api_key'): - api_key = kwargs.get('api_key') - else: - creds = get_credentials() - # creds = (host, email, token_type, token) - if creds: - api_host = creds[0] - if creds[2] == 'Bearer': - access_token = creds[3] - else: - # By default, assume it is an API key. - api_key = creds[3] - - # Always update the client host, version and agent - from solvebio.client import client - client.set_host() - client.set_user_agent(name=kwargs.get('name'), - version=kwargs.get('version')) - - if not (api_key or access_token): - return False + +def whoami(): + try: + user = client.whoami() + except Exception as e: + print("{} (code: {})".format(e.message, e.status_code)) else: - # Update the client token - client.set_token() - return True + return user + + +def get_api_host(): + return client._host __all__ = [ diff --git a/solvebio/__main__.py b/solvebio/__main__.py new file mode 100644 index 00000000..5c84fbf4 --- /dev/null +++ b/solvebio/__main__.py @@ -0,0 +1,7 @@ + +import sys + +from .cli.main import main + +if __name__ == "__main__": + main(sys.argv[1:]) \ No newline at end of file diff --git a/solvebio/auth.py b/solvebio/auth.py new file mode 100644 index 00000000..c2f0f9ba --- /dev/null +++ b/solvebio/auth.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import + +import os +from typing import Literal, Tuple + +from six.moves.urllib.parse import urlparse + +import logging + +from requests.auth import AuthBase +import requests + +from solvebio import SolveError +from solvebio.cli.credentials import get_credentials + +logger = logging.getLogger("solvebio") + + +class SolveBioTokenAuth(AuthBase): + """Custom auth handler for SolveBio API token authentication""" + + def __init__(self, token=None, token_type="Token"): + self.token = token + self.token_type = token_type + + def __call__(self, r): + if self.token: + r.headers["Authorization"] = "{0} {1}".format(self.token_type, self.token) + return r + + def __repr__(self): + if self.token: + return self.token_type + else: + return "Anonymous" + + +def authenticate( + host: str, + token: str, + token_type: Literal["Bearer", "Token"], + *, + raise_on_missing=True, +) -> Tuple[str, SolveBioTokenAuth]: + """ + Sets login credentials for SolveBio API authentication. + + :param str host: API host url + :param str token: API access token or API key + :param str token_type: API token type. `Bearer` is used for access tokens, while `Token` is used for API Keys. + :param bool raise_on_missing: Raise an exception if no credentials are available. + """ + + # Find credentials from environment variables + if not host: + host = ( + os.environ.get("SOLVEBIO_API_HOST", None) + or os.environ.get("SOLVEBIO_API_HOST", None) + or os.environ.get("EDP_API_HOST", None) + ) + + if not token: + api_key = ( + os.environ.get("SOLVEBIO_API_KEY", None) + or os.environ.get("SOLVEBIO_API_KEY", None) + or os.environ.get("EDP_API_KEY", None) + ) + + access_token = ( + os.environ.get("SOLVEBIO_ACCESS_TOKEN", None) + or os.environ.get("SOLVEBIO_ACCESS_TOKEN", None) + or os.environ.get("EDP_ACCESS_TOKEN", None) + ) + + if access_token: + token = access_token + token_type = "Bearer" + elif api_key: + token = api_key + token_type = "Token" + + # Find credentials from local credentials file + if not token: + if creds := get_credentials(host): + token_type = creds.token_type + token = creds.token + + if host is None: + # this happens when user/ennvars provided no API host for the login command + # but the credentials file still contains login credentials + host = creds.api_host + + if not host and raise_on_missing: + raise SolveError("No SolveBio API host is set") + + host = validate_api_host_url(host) + + # If the domain ends with .solvebio.com, determine if + # we are being redirected. If so, update the url with the new host + # and log a warning. + if host and host.rstrip("/").endswith(".api.solvebio.com"): + old_host = host.rstrip("/") + response = requests.head(old_host, allow_redirects=True) + # Strip the port number from the host for comparison + new_host = validate_api_host_url(response.url).rstrip("/").replace(":443", "") + + if old_host != new_host: + logger.warning( + 'API host redirected from "{}" to "{}", ' + "please update your local credentials file".format(old_host, new_host) + ) + host = new_host + + if token is not None: + auth = SolveBioTokenAuth(token, token_type) + else: + auth = None + + # TODO: warn user if WWW url is provided in edp_login! + + # @TODO: remove references to solvebio.api_host, etc... + + return host, auth + + +def validate_api_host_url(url): + """ + Validate SolveBio API host url. + + Valid urls must not be empty and + must contain either HTTP or HTTPS scheme. + """ + + # Default to https if no scheme is set + if "://" not in url: + url = "https://" + url + + parsed = urlparse(url) + if parsed.scheme not in ["http", "https"]: + raise SolveError( + "Invalid API host: %s. " "Missing url scheme (HTTP or HTTPS)." % url + ) + elif not parsed.netloc: + raise SolveError("Invalid API host: %s." % url) + + return parsed.geturl() \ No newline at end of file diff --git a/solvebio/cli/auth.py b/solvebio/cli/auth.py index 304d8625..fe463e36 100644 --- a/solvebio/cli/auth.py +++ b/solvebio/cli/auth.py @@ -9,34 +9,45 @@ from .credentials import delete_credentials -def login_and_save_credentials(*args, **kwargs): +def login_and_save_credentials( + *args, + api_host: str = None, + api_key: str = None, + access_token: str = None, + name: str = None, + version: str = None, +): """ - Domain, email and password are used to get the user's API key. + CLI command to login and persist credentials to a file """ - if args and args[0].api_key: - # Handle command-line arguments if provided. - logged_in = solvebio.login(api_key=args[0].api_key, - api_host=solvebio.api_host) - elif args and args[0].access_token: - # Handle command-line arguments if provided. - logged_in = solvebio.login(access_token=args[0].access_token, - api_host=solvebio.api_host) - elif solvebio.login(**kwargs): - logged_in = True - else: - logged_in = False + if args and args[0].access_token: + access_token = args[0].access_token + elif args and args[0].api_key: + api_key = args[0].api_key - if logged_in: - # Print information about the current user - user = client.whoami() - print_user(user) - save_credentials( - user['email'].lower(), client._auth.token, - client._auth.token_type, solvebio.api_host) - print('Updated local credentials file.') - else: - print('You are not logged-in. Visit ' - 'https://docs.solvebio.com/#authentication to get started.') + if args and args[0].api_host: + api_host = args[0].api_host + + solvebio.login( + api_host=api_host, + api_key=api_key, + access_token=access_token, + name=name, + version=version, + ) + + # Print information about the current user + user = client.whoami() + print_user(user) + + # fixme: how to detect if login was successful + save_credentials( + user["email"].lower(), + client._auth.token, + client._auth.token_type, + solvebio.get_api_host(), + ) + print("Updated local credentials file.") def logout(*args): @@ -69,5 +80,5 @@ def print_user(user): """ email = user['email'] domain = user['account']['domain'] - print('You are logged-in to the "{}" domain as {} (server: {}).' - .format(domain, email, solvebio.api_host)) + print(f'You are logged-in to the "{domain}" domain as {email}' + f' (server: {solvebio.get_api_host()}).') diff --git a/solvebio/cli/credentials.py b/solvebio/cli/credentials.py index 323a9939..f8ecf9b4 100644 --- a/solvebio/cli/credentials.py +++ b/solvebio/cli/credentials.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals from __future__ import absolute_import +from collections import namedtuple import six import solvebio @@ -73,7 +74,12 @@ class CredentialsError(BaseException): pass -def get_credentials(): +ApiCredentials = namedtuple( + "ApiCredentials", ["api_host", "email", "token_type", "token"] +) + + +def get_credentials(api_host: str = None) -> ApiCredentials: """ Returns the user's stored API key if a valid credentials file is found. Raises CredentialsError if no valid credentials file is found. @@ -86,28 +92,26 @@ def get_credentials(): except (IOError, TypeError, NetrcParseError) as e: raise CredentialsError("Could not open credentials file: " + str(e)) - host = as_netrc_machine(solvebio.api_host) - if host in netrc_obj.hosts: - proto = "http://" if "http://" in solvebio.api_host else "https://" - return (proto + host,) + netrc_obj.authenticators(host) + netrc_host: str = None + if api_host is not None and api_host in netrc_obj.hosts: + netrc_host = api_host - # If the preferred host is not the global default, don't try - # to select any other. - if host != "api.solvebio.com": - return None + if netrc_host is None: + # If there are no stored credentials for the default host, + # but there are other stored credentials, use the first + # available option that ends with '.api.quartzbio.com', + netrc_host = next( + filter(lambda h: h.endswith(".api.quartzbio.com"), netrc_obj.hosts), None + ) - # If there are no stored credentials for the default host, - # but there are other stored credentials, use the first - # available option that ends with '.api.solvebio.com', # Otherwise use the first available. - for h in netrc_obj.hosts: - if h.endswith(".api.solvebio.com"): - return ("https://" + h,) + netrc_obj.authenticators(h) - - # Return the first available - for host in netrc_obj.hosts: - return ("https://" + host,) + netrc_obj.authenticators(host) + if netrc_host is None: + netrc_host = next(iter(netrc_obj.hosts)) + if netrc_host is not None: + return ApiCredentials( + "https://" + netrc_host, *netrc_obj.authenticators(netrc_host) + ) return None @@ -119,7 +123,7 @@ def delete_credentials(): raise CredentialsError("Could not open netrc file: " + str(e)) try: - del rc.hosts[as_netrc_machine(solvebio.api_host)] + del rc.hosts[as_netrc_machine(solvebio.get_api_host())] except KeyError: pass else: @@ -127,7 +131,7 @@ def delete_credentials(): def save_credentials(email, token, token_type="Token", api_host=None): - api_host = api_host or solvebio.api_host + api_host = api_host or solvebio.get_api_host() try: netrc_path = netrc.path() diff --git a/solvebio/cli/main.py b/solvebio/cli/main.py index 774a643b..5bb0caf9 100644 --- a/solvebio/cli/main.py +++ b/solvebio/cli/main.py @@ -7,6 +7,7 @@ import argparse import solvebio +from solvebio.errors import SolveError from . import auth from . import data @@ -505,23 +506,26 @@ def parse_solvebio_args(self, args=None, namespace=None): return super(SolveArgumentParser, self).parse_args(args, namespace) def api_host_url(self, value): + if not value: + raise SolveError("No QuartzBio API host is set") + validate_api_host_url(value) return value -def main(argv=sys.argv[1:]): +def main(argv): """Main entry point for SolveBio CLI""" parser = SolveArgumentParser() args = parser.parse_solvebio_args(argv) solvebio.login( - api_host=args.api_host or solvebio.api_host, - api_key=args.api_key or solvebio.api_key, - access_token=args.access_token or solvebio.access_token, + api_host=args.api_host, + api_key=args.api_key, + access_token=args.access_token, ) return args.func(args) if __name__ == "__main__": - main() + main(sys.argv[1:]) diff --git a/solvebio/client.py b/solvebio/client.py index 812880e9..2de6fc13 100644 --- a/solvebio/client.py +++ b/solvebio/client.py @@ -10,30 +10,40 @@ from .version import VERSION from .errors import SolveError -from .utils.validators import validate_api_host_url +from .auth import authenticate, SolveBioTokenAuth import platform import requests import textwrap import logging -from requests import Session -from requests import codes -from requests.auth import AuthBase -from requests.adapters import HTTPAdapter +from requests import Session, codes, adapters from requests.packages.urllib3.util.retry import Retry +import ssl +import sys + from six.moves.urllib.parse import urljoin # Try using pyopenssl if available. # Requires: pip install pyopenssl ndg-httpsclient pyasn1 # See http://urllib3.readthedocs.org/en/latest/contrib.html#module-urllib3.contrib.pyopenssl # noqa try: - import urllib3.contrib.pyopenssl - urllib3.contrib.pyopenssl.inject_into_urllib3() + import urllib3 + + if sys.version_info <= (3, 9): + import urllib3.contrib.pyopenssl + else: + # Python 3.10+ automatically handles SSL; no need for inject_into_urllib3() + pass except ImportError: pass +# Ensure SSL/TLS support is available +if not ssl.HAS_TLSv1_2: + raise RuntimeError("TLS 1.2 support is required but not available.") + + logger = logging.getLogger('solvebio') @@ -53,7 +63,14 @@ def _handle_request_error(e): "issue locally.\nIf this problem persists, let us " "know at support@solvebio.com.") err = "A %s was raised" % (type(e).__name__,) - if str(e): + if isinstance(e, (urllib3.exceptions.SSLError, ssl.SSLError)) or sys.version_info >= (3, 12): + err += ("\n\nThis is an SSLError. If you're using python 3.12, " + "it could be because of stricter SSL requirements:\n" + "https://docs.python.org/3/whatsnew/3.12.html\n" + "https://docs.python.org/3/whatsnew/3.12.html\n" + "Try upgrading urllib3 and certifi:\n" + " pip install --upgrade urllib3 certifi\n\n") + elif str(e): err += " with error message %s" % (str(e),) else: err += " with no error message" @@ -61,42 +78,15 @@ def _handle_request_error(e): raise SolveError(message=msg) -class SolveTokenAuth(AuthBase): - """Custom auth handler for SolveBio API token authentication""" - - def __init__(self, token=None, token_type='Token'): - self.token = token - self.token_type = token_type - - if not self.token: - # Prefer the OAuth2 access token over the API key. - if solvebio.access_token: - self.token_type = 'Bearer' - self.token = solvebio.access_token - elif solvebio.api_key: - self.token_type = 'Token' - self.token = solvebio.api_key - - def __call__(self, r): - if self.token: - r.headers['Authorization'] = '{0} {1}'.format(self.token_type, - self.token) - return r - - def __repr__(self): - if self.token: - return self.token_type - else: - return 'Anonymous' - - class SolveClient(object): """A requests-based HTTP client for SolveBio API resources""" + _host: str = None + _auth: SolveBioTokenAuth = None + def __init__(self, host=None, token=None, token_type='Token', include_resources=True, retry_all=None): - self.set_host(host) - self.set_token(token, token_type) + self._host, self._auth = authenticate(host, token, token_type) self._headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', @@ -144,7 +134,7 @@ def __init__(self, host=None, token=None, token_type='Token', # Use a session with a retry policy to handle # intermittent connection errors. - adapter = HTTPAdapter(max_retries=retries) + adapter = adapters.HTTPAdapter(max_retries=retries) self._session = Session() self._session.mount(self._host, adapter) @@ -157,26 +147,6 @@ def __init__(self, host=None, token=None, token_type='Token', subclass = type(name, (class_,), {'_client': self}) setattr(self, name, subclass) - def set_host(self, host=None): - self._host = validate_api_host_url(host or solvebio.api_host) - # If the domain ends with .solvebio.com, determine if - # we are being redirected. If so, update the url with the new host - # and log a warning. - if self._host and self._host.rstrip('/').endswith('.api.solvebio.com'): - old_host = self._host.rstrip('/') - response = requests.head(old_host, allow_redirects=True) - # Strip the port number from the host for comparison - new_host = validate_api_host_url(response.url).rstrip('/').replace(':443', '') - if old_host != new_host: - logger.warn( - 'API host redirected from "{}" to "{}", ' - 'please update your local credentials file' - .format(old_host, new_host)) - self._host = new_host - - def set_token(self, token=None, token_type='Token'): - self._auth = SolveTokenAuth(token, token_type) - def set_user_agent(self, name=None, version=None): ua = 'solvebio-python-client/{} python-requests/{} {}/{}'.format( VERSION, diff --git a/solvebio/contrib/dash/solvebio_auth.py b/solvebio/contrib/dash/solvebio_auth.py index 3be68d35..ed15f22f 100644 --- a/solvebio/contrib/dash/solvebio_auth.py +++ b/solvebio/contrib/dash/solvebio_auth.py @@ -48,7 +48,7 @@ def __init__(self, app, app_url, client_id, **kwargs): # Handle optional parameters self._solvebio_url = \ kwargs.get('solvebio_url') or self.DEFAULT_SOLVEBIO_URL - self._api_host = kwargs.get('api_host') or solvebio.api_host + self._api_host = kwargs.get('api_host') or solvebio.get_api_host() self._oauth_client_secret = kwargs.get('client_secret') self._oauth_grant_type = \ kwargs.get('grant_type') or self.DEFAULT_GRANT_TYPE diff --git a/solvebio/contrib/dash/solvebio_dash.py b/solvebio/contrib/dash/solvebio_dash.py index 15fc03bf..067291eb 100644 --- a/solvebio/contrib/dash/solvebio_dash.py +++ b/solvebio/contrib/dash/solvebio_dash.py @@ -51,9 +51,6 @@ def __init__(self, name, *args, **kwargs): self.auth = None print("WARNING: No SolveBio client ID found. " "Your app (but not your data) will be publicly accessible.") - if solvebio.api_key: - print("SolveBio API key detected. All users of this app will " - "use this API key.") @server.before_request def set_solve_client(): diff --git a/solvebio/contrib/streamlit/solvebio_auth.py b/solvebio/contrib/streamlit/solvebio_auth.py index 10332371..136b4be0 100644 --- a/solvebio/contrib/streamlit/solvebio_auth.py +++ b/solvebio/contrib/streamlit/solvebio_auth.py @@ -22,9 +22,9 @@ def __init__(self, client_id, client_secret, name="solvebio"): client_id, client_secret, self.SOLVEBIO_URL, - urljoin(solvebio.api_host, self.OAUTH2_TOKEN_URL), + urljoin(solvebio.get_api_host(), self.OAUTH2_TOKEN_URL), revoke_token_endpoint=urljoin( - solvebio.api_host, self.OAUTH2_REVOKE_TOKEN_URL + solvebio.get_api_host(), self.OAUTH2_REVOKE_TOKEN_URL ), name=name, ) diff --git a/solvebio/test/test_credentials.py b/solvebio/test/test_credentials.py index 6923b1a2..ae2fdab6 100644 --- a/solvebio/test/test_credentials.py +++ b/solvebio/test/test_credentials.py @@ -28,11 +28,11 @@ class TestCredentials(unittest.TestCase): def setUp(self): self.solvebiodir = os.path.join(os.path.dirname(__file__), 'data', '.solvebio') - self.api_host = solvebio.api_host - solvebio.api_host = 'https://api.solvebio.com' + self.api_host = solvebio.get_api_host() + solvebio.client._host = 'https://api.solvebio.com' def tearDown(self): - solvebio.api_host = self.api_host + solvebio.client._host = self.api_host if os.path.isdir(self.solvebiodir): shutil.rmtree(self.solvebiodir) @@ -58,26 +58,26 @@ def test_credentials(self): auths = creds.get_credentials() self.assertTrue(auths is not None, 'Should find credentials') - solvebio.api_host = 'https://example.com' + solvebio.client._host = 'https://example.com' auths = creds.get_credentials() self.assertEqual(auths, None, 'Should not find credentials for host {0}' .format(solvebio.api_host)) - solvebio.api_host = 'https://api.solvebio.com' + solvebio.client._host = 'https://api.solvebio.com' creds.delete_credentials() auths = creds.get_credentials() self.assertEqual(auths, None, 'Should not find removed credentials for ' - 'host {0}'.format(solvebio.api_host)) + 'host {0}'.format(solvebio.get_api_host())) pair = ('testagain@solvebio.com', 'b00b00',) creds.save_credentials(*pair) auths = creds.get_credentials() self.assertTrue(auths is not None, 'Should get newly set credentials for ' - 'host {0}'.format(solvebio.api_host)) + 'host {0}'.format(solvebio.get_api_host())) - expected = (solvebio.api_host, pair[0], 'Token', pair[1]) + expected = (solvebio.get_api_host(), pair[0], 'Token', pair[1]) self.assertEqual(auths, expected, 'Should get back creds we saved') diff --git a/solvebio/test/test_login.py b/solvebio/test/test_login.py index 929b17bd..40271127 100644 --- a/solvebio/test/test_login.py +++ b/solvebio/test/test_login.py @@ -24,13 +24,13 @@ def write(self, _): class TestLogin(unittest.TestCase): def setUp(self): - self.api_host = solvebio.api_host + self.api_host = solvebio.get_api_host() # temporarily replace with dummy methods for testing self.delete_credentials = auth.delete_credentials auth.delete_credentials = lambda: None def tearDown(self): - solvebio.api_host = self.api_host + solvebio.client._host = self.api_host auth.delete_credentials = self.delete_credentials def test_bad_login(self): @@ -39,7 +39,7 @@ def test_bad_login(self): 'Invalid login') # Test invalid host - solvebio.api_host = 'https://some.fake.domain.foobar' + solvebio.client._host = 'https://some.fake.domain.foobar' self.assertEqual(auth.login_and_save_credentials(), None, 'Invalid login') diff --git a/solvebio/test/test_object.py b/solvebio/test/test_object.py index 5b9f8f0a..0a931e87 100644 --- a/solvebio/test/test_object.py +++ b/solvebio/test/test_object.py @@ -307,7 +307,7 @@ def test_object_serialize_metadata(self): self.assertEqual(res, {'metadata': {'hello': 'world'}, 'name': 'test'}) @mock.patch('solvebio.resource.Object.create') - @mock.patch('solvebio.client.SolveClient.post') + @mock.patch('solvebio.SolveClient.post') def test_object_query(self, SolveClientPost, ObjectCreate): ObjectCreate.side_effect = fake_object_create valid_response = { diff --git a/solvebio/test/test_shortcuts.py b/solvebio/test/test_shortcuts.py index 65d4108d..94c78ec0 100644 --- a/solvebio/test/test_shortcuts.py +++ b/solvebio/test/test_shortcuts.py @@ -9,7 +9,6 @@ from .helper import SolveBioTestCase -import solvebio from solvebio.cli import main from solvebio import DatasetTemplate from solvebio import Vault @@ -38,9 +37,6 @@ def upload_path(*args, **kwargs): class CLITests(SolveBioTestCase): def setUp(self): super(CLITests, self).setUp() - # Set the global key for CLI tests only - solvebio.api_key = os.environ.get("SOLVEBIO_API_KEY", None) - solvebio.api_host = os.environ.get("SOLVEBIO_API_HOST", None) class CreateDatasetTests(CLITests): diff --git a/solvebio/utils/validators.py b/solvebio/utils/validators.py deleted file mode 100644 index 3cdf3b62..00000000 --- a/solvebio/utils/validators.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import absolute_import - -from six.moves.urllib.parse import urlparse - -from ..errors import SolveError - - -def validate_api_host_url(url): - """ - Validate SolveBio API host url. - - Valid urls must not be empty and - must contain either HTTP or HTTPS scheme. - """ - if not url: - raise SolveError('No SolveBio API host is set') - - # Default to https if no scheme is set - if '://' not in url: - url = 'https://' + url - - parsed = urlparse(url) - if parsed.scheme not in ['http', 'https']: - raise SolveError( - 'Invalid API host: %s. ' - 'Missing url scheme (HTTP or HTTPS).' % url - ) - elif not parsed.netloc: - raise SolveError('Invalid API host: %s.' % url) - - return parsed.geturl() From b6e1214fc9d5600baedee48b56336de682678f24 Mon Sep 17 00:00:00 2001 From: Rajmund Csombordi Date: Wed, 18 Jun 2025 15:01:45 +0200 Subject: [PATCH 2/2] Patched login debug fix --- solvebio/__init__.py | 17 +++++++++--- solvebio/auth.py | 55 +++++++++++++++++++++++++++++++------ solvebio/cli/auth.py | 28 ++++++------------- solvebio/cli/credentials.py | 13 +++++++-- solvebio/cli/main.py | 18 ++++++++---- 5 files changed, 90 insertions(+), 41 deletions(-) diff --git a/solvebio/__init__.py b/solvebio/__init__.py index 3ded3040..6bbbc2bf 100644 --- a/solvebio/__init__.py +++ b/solvebio/__init__.py @@ -121,6 +121,7 @@ def login( access_token: str = None, name: str = None, version: str = None, + debug: bool = False, ): """ Function to login to the QuartzBio/EDP API when using EDP in a python script. @@ -148,14 +149,22 @@ def login( api_key=YOUR_API_KEY ) """ + token_type = None + token = None + if access_token: + token_type = "Bearer" + token = access_token + elif api_key: + token_type = "Token" + token = api_key + + if api_host or token or debug: client._host, client._auth = authenticate( - api_host, access_token, token_type="Bearer" + api_host, token, token_type=token_type, debug=debug ) - elif api_key: - client._host, client._auth = authenticate(api_host, api_key, token_type="Token") - client.set_user_agent(name=name, version=version) + client.set_user_agent(name=name, version=version) def whoami(): diff --git a/solvebio/auth.py b/solvebio/auth.py index c2f0f9ba..5aa73e27 100644 --- a/solvebio/auth.py +++ b/solvebio/auth.py @@ -40,7 +40,8 @@ def authenticate( token: str, token_type: Literal["Bearer", "Token"], *, - raise_on_missing=True, + raise_on_missing: bool = True, + debug: bool = False ) -> Tuple[str, SolveBioTokenAuth]: """ Sets login credentials for SolveBio API authentication. @@ -50,26 +51,29 @@ def authenticate( :param str token_type: API token type. `Bearer` is used for access tokens, while `Token` is used for API Keys. :param bool raise_on_missing: Raise an exception if no credentials are available. """ + # used for debugging + source_host = None + source_token = None # Find credentials from environment variables if not host: host = ( - os.environ.get("SOLVEBIO_API_HOST", None) - or os.environ.get("SOLVEBIO_API_HOST", None) + os.environ.get("QUARTZBIO_API_HOST", None) or os.environ.get("EDP_API_HOST", None) + or os.environ.get("SOLVEBIO_API_HOST", None) ) if not token: api_key = ( - os.environ.get("SOLVEBIO_API_KEY", None) - or os.environ.get("SOLVEBIO_API_KEY", None) + os.environ.get("QUARTZBIO_API_KEY", None) or os.environ.get("EDP_API_KEY", None) + or os.environ.get("SOLVEBIO_API_KEY", None) ) access_token = ( - os.environ.get("SOLVEBIO_ACCESS_TOKEN", None) - or os.environ.get("SOLVEBIO_ACCESS_TOKEN", None) + os.environ.get("QUARTZ_ACCESS_TOKEN", None) or os.environ.get("EDP_ACCESS_TOKEN", None) + or os.environ.get("SOLVEBIO_ACCESS_TOKEN", None) ) if access_token: @@ -79,6 +83,11 @@ def authenticate( token = api_key token_type = "Token" + if token: + source_token = 'envvars' + else: + source_token = 'params' + # Find credentials from local credentials file if not token: if creds := get_credentials(host): @@ -90,8 +99,36 @@ def authenticate( # but the credentials file still contains login credentials host = creds.api_host - if not host and raise_on_missing: - raise SolveError("No SolveBio API host is set") + if host: + source_host = 'creds' + if token: + source_token = 'creds' + + if (not host and raise_on_missing) or debug: + # this will tell the user where QB Client found the credentials from + creds_path = netrc.path() + print('\n'.join([ + "Login Debug:", + f"--> Host: {host}\n (source: {source_host})", + f"--> Token Type: {token_type}\n (source: {source_token})", + "\n1) source: params", + " Means that you've passed this through the login CLI command:", + " quartzbio login --host --access_token ", + "\n or the quartzbio.login function:", + " import quartzbio", + " quartzbio.login(debug=True)", + "\n2) source: creds", + " Means that the QB client has saved your credentials in:", + f" {creds_path}", + "\n3) source: envvars", + " Means that you've set your credentials through environment variables:", + " QUARTZBIO_API_HOST", + " QUARTZBIO_ACCESS_TOKEN", + " QUARTZBIO_API_KEY", + ])) + + if not debug: + raise SolveError("No SolveBio API host is set") host = validate_api_host_url(host) diff --git a/solvebio/cli/auth.py b/solvebio/cli/auth.py index fe463e36..c0ce6e8e 100644 --- a/solvebio/cli/auth.py +++ b/solvebio/cli/auth.py @@ -9,31 +9,19 @@ from .credentials import delete_credentials -def login_and_save_credentials( - *args, - api_host: str = None, - api_key: str = None, - access_token: str = None, - name: str = None, - version: str = None, -): +def login_and_save_credentials(*args): """ CLI command to login and persist credentials to a file """ - if args and args[0].access_token: - access_token = args[0].access_token - elif args and args[0].api_key: - api_key = args[0].api_key - - if args and args[0].api_host: - api_host = args[0].api_host + args = args[0] solvebio.login( - api_host=api_host, - api_key=api_key, - access_token=access_token, - name=name, - version=version, + api_host=args.api_host, + api_key=args.api_key, + access_token=args.access_token, + # name=args.name, + # version=args.version, + debug=args.debug, ) # Print information about the current user diff --git a/solvebio/cli/credentials.py b/solvebio/cli/credentials.py index f8ecf9b4..a28c9051 100644 --- a/solvebio/cli/credentials.py +++ b/solvebio/cli/credentials.py @@ -93,10 +93,17 @@ def get_credentials(api_host: str = None) -> ApiCredentials: raise CredentialsError("Could not open credentials file: " + str(e)) netrc_host: str = None - if api_host is not None and api_host in netrc_obj.hosts: - netrc_host = api_host - if netrc_host is None: + # if user provides a host, then find its token in the credentials file + if api_host is not None: + api_host = api_host.removeprefix("https://") + if api_host in netrc_obj.hosts: + netrc_host = api_host + else: + # login has failed for the requested host, + # the rest of the credentials file is ignored + return None + else: # If there are no stored credentials for the default host, # but there are other stored credentials, use the first # available option that ends with '.api.quartzbio.com', diff --git a/solvebio/cli/main.py b/solvebio/cli/main.py index 5bb0caf9..33619c4e 100644 --- a/solvebio/cli/main.py +++ b/solvebio/cli/main.py @@ -13,7 +13,7 @@ from . import data from .tutorial import print_tutorial from .ipython import launch_ipython_shell -from ..utils.validators import validate_api_host_url +from ..auth import validate_api_host_url from ..utils.files import get_home_dir @@ -60,6 +60,14 @@ class SolveArgumentParser(argparse.ArgumentParser): "login": { "func": auth.login_and_save_credentials, "help": "Login and save credentials", + "arguments": [ + { + "flags": "--debug", + "action": "store_true", + "default": False, + "help": "Shows the source of the user credentials", + }, + ] }, "logout": {"func": auth.logout, "help": "Logout and delete saved credentials"}, "whoami": {"func": auth.whoami, "help": "Show your SolveBio email address"}, @@ -508,15 +516,15 @@ def parse_solvebio_args(self, args=None, namespace=None): def api_host_url(self, value): if not value: raise SolveError("No QuartzBio API host is set") - + validate_api_host_url(value) return value -def main(argv): +def main(): """Main entry point for SolveBio CLI""" parser = SolveArgumentParser() - args = parser.parse_solvebio_args(argv) + args = parser.parse_quartzbio_args(sys.argv[1:]) solvebio.login( api_host=args.api_host, @@ -528,4 +536,4 @@ def main(argv): if __name__ == "__main__": - main(sys.argv[1:]) + main()