diff --git a/astakosclient/astakosclient/__init__.py b/astakosclient/astakosclient/__init__.py index 8e5f32f72d07b72d804e025b38fe25ca2c074e5f..6e34793d23b5e3666ec0997980d215c0f43a9aba 100644 --- a/astakosclient/astakosclient/__init__.py +++ b/astakosclient/astakosclient/__init__.py @@ -31,6 +31,10 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +""" +Simple and minimal client for the Astakos authentication service +""" + import logging import urlparse import urllib @@ -39,45 +43,10 @@ from copy import copy import simplejson from astakosclient.utils import \ - retry, scheme_to_class, parse_request, check_input + retry_dec, scheme_to_class, parse_request, check_input, join_urls from astakosclient.errors import \ AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \ - NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse -from .keypath import get_path -from .services import astakos_services - - -# Customize astakos_services here? - - -def join_urls(a, b): - """join_urls from synnefo.lib""" - return a.rstrip("/") + "/" + b.lstrip("/") - -# -------------------------------------------------------------------- -# Astakos API urls -UI_PREFIX = get_path(astakos_services, 'astakos_ui.prefix') -ACCOUNTS_PREFIX = get_path(astakos_services, 'astakos_account.prefix') -ACCOUNTS_PREFIX = join_urls(ACCOUNTS_PREFIX, 'v1.0') -API_AUTHENTICATE = join_urls(ACCOUNTS_PREFIX, "authenticate") -API_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "user_catalogs") -API_SERVICE_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "service/user_catalogs") -API_GETSERVICES = join_urls(UI_PREFIX, "get_services") -API_RESOURCES = join_urls(ACCOUNTS_PREFIX, "resources") -API_QUOTAS = join_urls(ACCOUNTS_PREFIX, "quotas") -API_SERVICE_QUOTAS = join_urls(ACCOUNTS_PREFIX, "service_quotas") -API_COMMISSIONS = join_urls(ACCOUNTS_PREFIX, "commissions") -API_COMMISSIONS_ACTION = join_urls(API_COMMISSIONS, "action") -API_FEEDBACK = join_urls(ACCOUNTS_PREFIX, "feedback") -API_PROJECTS = join_urls(ACCOUNTS_PREFIX, "projects") -API_APPLICATIONS = join_urls(API_PROJECTS, "apps") -API_MEMBERSHIPS = join_urls(API_PROJECTS, "memberships") - -# -------------------------------------------------------------------- -# Astakos Keystone API urls -IDENTITY_PREFIX = get_path(astakos_services, 'astakos_identity.prefix') -IDENTITY_PREFIX = join_urls(IDENTITY_PREFIX, "v2.0") -API_TOKENS = join_urls(IDENTITY_PREFIX, "tokens") + NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse, NoEndpoints # -------------------------------------------------------------------- @@ -94,80 +63,149 @@ def get_token_from_cookie(request, cookie_name): try: cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None)) return cookie_content.split("|")[1] - except: + except BaseException: return None -class AstakosClient(): +# Too many instance attributes. pylint: disable-msg=R0902 +# Too many public methods. pylint: disable-msg=R0904 +class AstakosClient(object): """AstakosClient Class Implementation""" # ---------------------------------- - def __init__(self, astakos_url, retry=0, - use_pool=False, pool_size=8, logger=None): + # Initialize AstakosClient Class + # Too many arguments. pylint: disable-msg=R0913 + # Too many local variables. pylint: disable-msg=R0914 + # Too many statements. pylint: disable-msg=R0915 + def __init__(self, token, auth_url, + retry=0, use_pool=False, pool_size=8, logger=None): """Initialize AstakosClient Class Keyword arguments: - astakos_url -- i.e https://accounts.example.com (string) - use_pool -- use objpool for http requests (boolean) + token -- user's/service's token (string) + auth_url -- i.e https://accounts.example.com/identity/v2.0 retry -- how many time to retry (integer) + use_pool -- use objpool for http requests (boolean) + pool_size -- if using pool, define the pool size logger -- pass a different logger """ + + # Get logger if logger is None: logging.basicConfig( format='%(asctime)s [%(levelname)s] %(name)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=logging.INFO) logger = logging.getLogger("astakosclient") - logger.debug("Intialize AstakosClient: astakos_url = %s, " - "use_pool = %s" % (astakos_url, use_pool)) + logger.debug("Intialize AstakosClient: auth_url = %s, " + "use_pool = %s, pool_size = %s", + auth_url, use_pool, pool_size) - check_input("__init__", logger, astakos_url=astakos_url) + # Check that token and auth_url (mandatory options) are given + check_input("__init__", logger, token=token, auth_url=auth_url) - # Check for supported scheme - p = urlparse.urlparse(astakos_url) - conn_class = scheme_to_class(p.scheme, use_pool, pool_size) + # Initialize connection class + parsed_auth_url = urlparse.urlparse(auth_url) + conn_class = \ + scheme_to_class(parsed_auth_url.scheme, use_pool, pool_size) if conn_class is None: - m = "Unsupported scheme: %s" % p.scheme - logger.error(m) - raise BadValue(m) + msg = "Unsupported scheme: %s" % parsed_auth_url.scheme + logger.error(msg) + raise BadValue(msg) - # Save astakos_url etc. in our class + # Save astakos base url, logger, connection class etc in our class self.retry = retry self.logger = logger - self.netloc = p.netloc - self.scheme = p.scheme - self.path = p.path.rstrip('/') + self.token = token + self.astakos_base_url = parsed_auth_url.netloc + self.scheme = parsed_auth_url.scheme self.conn_class = conn_class + # Initialize astakos api prefixes + # API urls under auth_url + self.auth_prefix = parsed_auth_url.path + self.api_tokens = join_urls(self.auth_prefix, "tokens") + + # ------------------------------ + # API urls under account_url + # Get account_url from get_endpoints + # get_endpoints needs self.api_tokens + endpoints = self.get_endpoints(non_authentication=True) + account_service_catalog = parse_endpoints( + endpoints, ep_name="astakos_account", ep_version_id="v1.0") + self.account_url = \ + account_service_catalog[0]['endpoints'][0]['publicURL'] + parsed_account_url = urlparse.urlparse(self.account_url) + + self.account_prefix = parsed_account_url.path + self.logger.debug("Got account_prefix \"%s\"" % self.account_prefix) + + self.api_authenticate = join_urls( + self.account_prefix, "authenticate") + self.api_usercatalogs = join_urls( + self.account_prefix, "user_catalogs") + self.api_service_usercatalogs = join_urls( + self.account_prefix, "service/user_catalogs") + self.api_resources = join_urls( + self.account_prefix, "resources") + self.api_quotas = join_urls( + self.account_prefix, "quotas") + self.api_service_quotas = join_urls( + self.account_prefix, "service_quotas") + self.api_commissions = join_urls( + self.account_prefix, "commissions") + self.api_commissions_action = join_urls( + self.api_commissions, "action") + self.api_feedback = join_urls( + self.account_prefix, "feedback") + self.api_projects = join_urls( + self.account_prefix, "projects") + self.api_applications = join_urls( + self.api_projects, "apps") + self.api_memberships = join_urls( + self.api_projects, "memberships") + + # ------------------------------ + # API urls under ui_url + # Get ui url from get_endpoints + # get_endpoints needs self.api_tokens + ui_service_catalog = parse_endpoints( + endpoints, ep_name="astakos_account", ep_version_id="v1.0") + parsed_ui_url = urlparse.urlparse( + ui_service_catalog[0]['endpoints'][0]['SNF:uiURL']) + self.ui_url = \ + ui_service_catalog[0]['endpoints'][0]['SNF:uiURL'] + parsed_ui_url = urlparse.urlparse(self.ui_url) + + self.ui_prefix = parsed_ui_url.path + self.logger.debug("Got ui_prefix \"%s\"" % self.ui_prefix) + + self.api_getservices = join_urls(self.ui_prefix, "get_services") + # ---------------------------------- - @retry - def _call_astakos(self, token, request_path, headers=None, + @retry_dec + def _call_astakos(self, request_path, headers=None, body=None, method="GET", log_body=True): """Make the actual call to Astakos Service""" - if token is not None: - hashed_token = hashlib.sha1() - hashed_token.update(token) - using_token = "using token %s" % (hashed_token.hexdigest()) - else: - using_token = "without using token" + hashed_token = hashlib.sha1() + hashed_token.update(self.token) self.logger.debug( - "Make a %s request to %s %s with headers %s and body %s" - % (method, request_path, using_token, headers, - body if log_body else "(not logged)")) + "Make a %s request to %s, using token with hash %s, " + "with headers %s and body %s", + method, request_path, hashed_token.hexdigest(), headers, + body if log_body else "(not logged)") # Check Input if headers is None: headers = {} if body is None: body = {} - path = self.path + "/" + request_path.strip('/') # Build request's header and body kwargs = {} kwargs['headers'] = copy(headers) - if token is not None: - kwargs['headers']['X-Auth-Token'] = token + kwargs['headers']['X-Auth-Token'] = self.token if body: kwargs['body'] = copy(body) kwargs['headers'].setdefault( @@ -177,10 +215,11 @@ class AstakosClient(): try: # Get the connection object - with self.conn_class(self.netloc) as conn: + with self.conn_class(self.astakos_base_url) as conn: # Send request + # Used * or ** magic. pylint: disable-msg=W0142 (message, data, status) = \ - _do_request(conn, method, path, **kwargs) + _do_request(conn, method, request_path, **kwargs) except Exception as err: self.logger.error("Failed to send request: %s" % repr(err)) raise AstakosClientException(str(err)) @@ -210,11 +249,10 @@ class AstakosClient(): # ------------------------ # do a GET to ``API_AUTHENTICATE`` - def get_user_info(self, token, usage=False): + def get_user_info(self, usage=False): """Authenticate user and get user's info as a dictionary Keyword arguments: - token -- user's token (string) usage -- return usage information for user (boolean) In case of success return user information (json parsed format). @@ -222,59 +260,57 @@ class AstakosClient(): """ # Send request - auth_path = copy(API_AUTHENTICATE) + auth_path = self.api_authenticate if usage: auth_path += "?usage=1" - return self._call_astakos(token, auth_path) + return self._call_astakos(auth_path) # ---------------------------------- # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``) # with {'uuids': uuids} - def _uuid_catalog(self, token, uuids, req_path): + def _uuid_catalog(self, uuids, req_path): + """Helper function to retrieve uuid catalog""" req_headers = {'content-type': 'application/json'} req_body = parse_request({'uuids': uuids}, self.logger) - data = self._call_astakos( - token, req_path, req_headers, req_body, "POST") + data = self._call_astakos(req_path, headers=req_headers, + body=req_body, method="POST") if "uuid_catalog" in data: return data.get("uuid_catalog") else: - m = "_uuid_catalog request returned %s. No uuid_catalog found" \ - % data - self.logger.error(m) - raise AstakosClientException(m) + msg = "_uuid_catalog request returned %s. No uuid_catalog found" \ + % data + self.logger.error(msg) + raise AstakosClientException(msg) - def get_usernames(self, token, uuids): + def get_usernames(self, uuids): """Return a uuid_catalog dictionary for the given uuids Keyword arguments: - token -- user's token (string) uuids -- list of user ids (list of strings) The returned uuid_catalog is a dictionary with uuids as keys and the corresponding user names as values """ - req_path = copy(API_USERCATALOGS) - return self._uuid_catalog(token, uuids, req_path) + return self._uuid_catalog(uuids, self.api_usercatalogs) - def get_username(self, token, uuid): + def get_username(self, uuid): """Return the user name of a uuid (see get_usernames)""" check_input("get_username", self.logger, uuid=uuid) - uuid_dict = self.get_usernames(token, [uuid]) + uuid_dict = self.get_usernames([uuid]) if uuid in uuid_dict: return uuid_dict.get(uuid) else: raise NoUserName(uuid) - def service_get_usernames(self, token, uuids): + def service_get_usernames(self, uuids): """Return a uuid_catalog dict using a service's token""" - req_path = copy(API_SERVICE_USERCATALOGS) - return self._uuid_catalog(token, uuids, req_path) + return self._uuid_catalog(uuids, self.api_service_usercatalogs) - def service_get_username(self, token, uuid): + def service_get_username(self, uuid): """Return the displayName of a uuid using a service's token""" check_input("service_get_username", self.logger, uuid=uuid) - uuid_dict = self.service_get_usernames(token, [uuid]) + uuid_dict = self.service_get_usernames([uuid]) if uuid in uuid_dict: return uuid_dict.get(uuid) else: @@ -283,51 +319,51 @@ class AstakosClient(): # ---------------------------------- # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``) # with {'displaynames': display_names} - def _displayname_catalog(self, token, display_names, req_path): + def _displayname_catalog(self, display_names, req_path): + """Helper function to retrieve display names catalog""" req_headers = {'content-type': 'application/json'} req_body = parse_request({'displaynames': display_names}, self.logger) - data = self._call_astakos( - token, req_path, req_headers, req_body, "POST") + data = self._call_astakos(req_path, headers=req_headers, + body=req_body, method="POST") if "displayname_catalog" in data: return data.get("displayname_catalog") else: - m = "_displayname_catalog request returned %s. " \ - "No displayname_catalog found" % data - self.logger.error(m) - raise AstakosClientException(m) + msg = "_displayname_catalog request returned %s. " \ + "No displayname_catalog found" % data + self.logger.error(msg) + raise AstakosClientException(msg) - def get_uuids(self, token, display_names): + def get_uuids(self, display_names): """Return a displayname_catalog for the given names Keyword arguments: - token -- user's token (string) display_names -- list of user names (list of strings) The returned displayname_catalog is a dictionary with the names as keys and the corresponding uuids as values """ - req_path = copy(API_USERCATALOGS) - return self._displayname_catalog(token, display_names, req_path) + return self._displayname_catalog( + display_names, self.api_usercatalogs) - def get_uuid(self, token, display_name): + def get_uuid(self, display_name): """Return the uuid of a name (see getUUIDs)""" check_input("get_uuid", self.logger, display_name=display_name) - name_dict = self.get_uuids(token, [display_name]) + name_dict = self.get_uuids([display_name]) if display_name in name_dict: return name_dict.get(display_name) else: raise NoUUID(display_name) - def service_get_uuids(self, token, display_names): + def service_get_uuids(self, display_names): """Return a display_name catalog using a service's token""" - req_path = copy(API_SERVICE_USERCATALOGS) - return self._displayname_catalog(token, display_names, req_path) + return self._displayname_catalog( + display_names, self.api_service_usercatalogs) - def service_get_uuid(self, token, display_name): + def service_get_uuid(self, display_name): """Return the uuid of a name using a service's token""" check_input("service_get_uuid", self.logger, display_name=display_name) - name_dict = self.service_get_uuids(token, [display_name]) + name_dict = self.service_get_uuids([display_name]) if display_name in name_dict: return name_dict.get(display_name) else: @@ -337,21 +373,20 @@ class AstakosClient(): # do a GET to ``API_GETSERVICES`` def get_services(self): """Return a list of dicts with the registered services""" - return self._call_astakos(None, copy(API_GETSERVICES)) + return self._call_astakos(self.api_getservices) # ---------------------------------- # do a GET to ``API_RESOURCES`` def get_resources(self): """Return a dict of dicts with the available resources""" - return self._call_astakos(None, copy(API_RESOURCES)) + return self._call_astakos(self.api_resources) # ---------------------------------- # do a POST to ``API_FEEDBACK`` - def send_feedback(self, token, message, data): + def send_feedback(self, message, data): """Send feedback to astakos service keyword arguments: - token -- user's token (string) message -- Feedback message data -- Additional information about service client status @@ -360,55 +395,63 @@ class AstakosClient(): """ check_input("send_feedback", self.logger, message=message, data=data) - path = copy(API_FEEDBACK) req_body = urllib.urlencode( {'feedback_msg': message, 'feedback_data': data}) - self._call_astakos(token, path, None, req_body, "POST") + self._call_astakos(self.api_feedback, headers=None, + body=req_body, method="POST") # ---------------------------------- # do a POST to ``API_TOKENS`` - def get_endpoints(self, token, uuid=None): - """ Fallback call for authenticate + def get_endpoints(self, tenant_name=None, non_authentication=False): + """ Authenticate and get services' endpoints Keyword arguments: - token -- user's token (string) - uuid -- user's uniq id + tenant_name -- user's uniq id (optional) + non_authentication -- get only non authentication protected info + It returns back the token as well as information about the token holder and the services he/she can acess (in json format). + + The tenant_name is optional and if it is given it must match the + user's uuid. + + In case on of the `name', `type', `region', `version_id' parameters + is given, return only the endpoints that match all of these criteria. + If no match is found then raise NoEndpoints exception. + In case of error raise an AstakosClientException. """ - req_path = copy(API_TOKENS) req_headers = {'content-type': 'application/json'} - body = {'auth': {'token': {'id': token}}} - if uuid is not None: - body['auth']['tenantName'] = uuid - req_body = parse_request(body, self.logger) - return self._call_astakos(token, req_path, req_headers, - req_body, "POST", False) + if non_authentication: + req_body = None + else: + body = {'auth': {'token': {'id': self.token}}} + if tenant_name is not None: + body['auth']['tenantName'] = tenant_name + req_body = parse_request(body, self.logger) + return self._call_astakos(self.api_tokens, headers=req_headers, + body=req_body, method="POST", + log_body=False) # ---------------------------------- # do a GET to ``API_QUOTAS`` - def get_quotas(self, token): + def get_quotas(self): """Get user's quotas - Keyword arguments: - token -- user's token (string) - In case of success return a dict of dicts with user's current quotas. Otherwise raise an AstakosClientException """ - return self._call_astakos(token, copy(API_QUOTAS)) + return self._call_astakos(self.api_quotas) # ---------------------------------- # do a GET to ``API_SERVICE_QUOTAS`` - def service_get_quotas(self, token, user=None): + def service_get_quotas(self, user=None): """Get all quotas for resources associated with the service Keyword arguments: - token -- service's token (string) user -- optionally, the uuid of a specific user In case of success return a dict of dicts of dicts with current quotas @@ -416,18 +459,17 @@ class AstakosClient(): Otherwise raise an AstakosClientException """ - query = copy(API_SERVICE_QUOTAS) + query = self.api_service_quotas if user is not None: query += "?user=" + user - return self._call_astakos(token, query) + return self._call_astakos(query) # ---------------------------------- # do a POST to ``API_COMMISSIONS`` - def issue_commission(self, token, request): + def issue_commission(self, request): """Issue a commission Keyword arguments: - token -- service's token (string) request -- commision request (dict) In case of success return commission's id (int). @@ -437,8 +479,10 @@ class AstakosClient(): req_headers = {'content-type': 'application/json'} req_body = parse_request(request, self.logger) try: - response = self._call_astakos(token, copy(API_COMMISSIONS), - req_headers, req_body, "POST") + response = self._call_astakos(self.api_commissions, + headers=req_headers, + body=req_body, + method="POST") except AstakosClientException as err: if err.status == 413: raise QuotaLimit(err.message, err.details) @@ -448,17 +492,16 @@ class AstakosClient(): if "serial" in response: return response['serial'] else: - m = "issue_commission_core request returned %s. No serial found" \ - % response - self.logger.error(m) - raise AstakosClientException(m) + msg = "issue_commission_core request returned %s. " + \ + "No serial found" % response + self.logger.error(msg) + raise AstakosClientException(msg) - def issue_one_commission(self, token, holder, source, provisions, + def issue_one_commission(self, holder, source, provisions, name="", force=False, auto_accept=False): """Issue one commission (with specific holder and source) keyword arguments: - token -- service's token (string) holder -- user's id (string) source -- commission's source (ex system) (string) provisions -- resources with their quantity (dict from string to int) @@ -482,36 +525,32 @@ class AstakosClient(): try: request["provisions"] = [] for resource, quantity in provisions.iteritems(): - t = {"holder": holder, "source": source, - "resource": resource, "quantity": quantity} - request["provisions"].append(t) + prov = {"holder": holder, "source": source, + "resource": resource, "quantity": quantity} + request["provisions"].append(prov) except Exception as err: self.logger.error(str(err)) raise BadValue(str(err)) - return self.issue_commission(token, request) + return self.issue_commission(request) # ---------------------------------- # do a GET to ``API_COMMISSIONS`` - def get_pending_commissions(self, token): + def get_pending_commissions(self): """Get Pending Commissions - Keyword arguments: - token -- service's token (string) - In case of success return a list of pending commissions' ids (list of integers) """ - return self._call_astakos(token, copy(API_COMMISSIONS)) + return self._call_astakos(self.api_commissions) # ---------------------------------- # do a GET to ``API_COMMISSIONS``/<serial> - def get_commission_info(self, token, serial): + def get_commission_info(self, serial): """Get Description of a Commission Keyword arguments: - token -- service's token (string) serial -- commission's id (int) In case of success return a dict of dicts containing @@ -520,16 +559,15 @@ class AstakosClient(): """ check_input("get_commission_info", self.logger, serial=serial) - path = API_COMMISSIONS + "/" + str(serial) - return self._call_astakos(token, path) + path = self.api_commissions.rstrip('/') + "/" + str(serial) + return self._call_astakos(path) # ---------------------------------- # do a POST to ``API_COMMISSIONS``/<serial>/action" - def commission_action(self, token, serial, action): + def commission_action(self, serial, action): """Perform a commission action Keyword arguments: - token -- service's token (string) serial -- commission's id (int) action -- action to perform, currently accept/reject (string) @@ -539,26 +577,26 @@ class AstakosClient(): check_input("commission_action", self.logger, serial=serial, action=action) - path = API_COMMISSIONS + "/" + str(serial) + "/action" + path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action" req_headers = {'content-type': 'application/json'} req_body = parse_request({str(action): ""}, self.logger) - self._call_astakos(token, path, req_headers, req_body, "POST") + self._call_astakos(path, headers=req_headers, + body=req_body, method="POST") - def accept_commission(self, token, serial): + def accept_commission(self, serial): """Accept a commission (see commission_action)""" - self.commission_action(token, serial, "accept") + self.commission_action(serial, "accept") - def reject_commission(self, token, serial): + def reject_commission(self, serial): """Reject a commission (see commission_action)""" - self.commission_action(token, serial, "reject") + self.commission_action(serial, "reject") # ---------------------------------- # do a POST to ``API_COMMISSIONS_ACTION`` - def resolve_commissions(self, token, accept_serials, reject_serials): + def resolve_commissions(self, accept_serials, reject_serials): """Resolve multiple commissions at once Keyword arguments: - token -- service's token (string) accept_serials -- commissions to accept (list of ints) reject_serials -- commissions to reject (list of ints) @@ -571,27 +609,26 @@ class AstakosClient(): accept_serials=accept_serials, reject_serials=reject_serials) - path = copy(API_COMMISSIONS_ACTION) req_headers = {'content-type': 'application/json'} req_body = parse_request({"accept": accept_serials, "reject": reject_serials}, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(self.api_commissions_action, + headers=req_headers, body=req_body, + method="POST") # ---------------------------- # do a GET to ``API_PROJECTS`` - def get_projects(self, token, name=None, state=None, owner=None): + def get_projects(self, name=None, state=None, owner=None): """Retrieve all accessible projects Arguments: - token -- user's token (string) name -- filter by name (optional) state -- filter by state (optional) owner -- filter by owner (optional) In case of success, return a list of project descriptions. """ - path = API_PROJECTS filters = {} if name is not None: filters["name"] = name @@ -602,62 +639,61 @@ class AstakosClient(): req_headers = {'content-type': 'application/json'} req_body = (parse_request({"filter": filters}, self.logger) if filters else None) - return self._call_astakos(token, path, req_headers, req_body) + return self._call_astakos(self.api_projects, + headers=req_headers, body=req_body) # ----------------------------------------- # do a GET to ``API_PROJECTS``/<project_id> - def get_project(self, token, project_id): + def get_project(self, project_id): """Retrieve project description, if accessible Arguments: - token -- user's token (string) project_id -- project identifier In case of success, return project description. """ - path = join_urls(API_PROJECTS, str(project_id)) - return self._call_astakos(token, path) + path = join_urls(self.api_projects, str(project_id)) + return self._call_astakos(path) # ----------------------------- # do a POST to ``API_PROJECTS`` - def create_project(self, token, specs): + def create_project(self, specs): """Submit application to create a new project Arguments: - token -- user's token (string) specs -- dict describing a project In case of success, return project and application identifiers. """ - path = API_PROJECTS req_headers = {'content-type': 'application/json'} req_body = parse_request(specs, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(self.api_projects, + headers=req_headers, body=req_body, + method="POST") # ------------------------------------------ # do a POST to ``API_PROJECTS``/<project_id> - def modify_project(self, token, project_id, specs): + def modify_project(self, project_id, specs): """Submit application to modify an existing project Arguments: - token -- user's token (string) project_id -- project identifier specs -- dict describing a project In case of success, return project and application identifiers. """ - path = join_urls(API_PROJECTS, str(project_id)) + path = join_urls(self.api_projects, str(project_id)) req_headers = {'content-type': 'application/json'} req_body = parse_request(specs, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(path, headers=req_headers, + body=req_body, method="POST") # ------------------------------------------------- # do a POST to ``API_PROJECTS``/<project_id>/action - def project_action(self, token, project_id, action, reason=""): + def project_action(self, project_id, action, reason=""): """Perform action on a project Arguments: - token -- user's token (string) project_id -- project identifier action -- action to perform, one of "suspend", "unsuspend", "terminate", "reinstate" @@ -665,50 +701,48 @@ class AstakosClient(): In case of success, return nothing. """ - path = join_urls(API_PROJECTS, str(project_id)) + path = join_urls(self.api_projects, str(project_id)) path = join_urls(path, "action") req_headers = {'content-type': 'application/json'} req_body = parse_request({action: reason}, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(path, headers=req_headers, + body=req_body, method="POST") # -------------------------------- # do a GET to ``API_APPLICATIONS`` - def get_applications(self, token, project=None): + def get_applications(self, project=None): """Retrieve all accessible applications Arguments: - token -- user's token (string) project -- filter by project (optional) In case of success, return a list of application descriptions. """ - path = API_APPLICATIONS req_headers = {'content-type': 'application/json'} body = {"project": project} if project is not None else None req_body = parse_request(body, self.logger) if body else None - return self._call_astakos(token, path, req_headers, req_body) + return self._call_astakos(self.api_applications, + headers=req_headers, body=req_body) # ----------------------------------------- # do a GET to ``API_APPLICATIONS``/<app_id> - def get_application(self, token, app_id): + def get_application(self, app_id): """Retrieve application description, if accessible Arguments: - token -- user's token (string) app_id -- application identifier In case of success, return application description. """ - path = join_urls(API_APPLICATIONS, str(app_id)) - return self._call_astakos(token, path) + path = join_urls(self.api_applications, str(app_id)) + return self._call_astakos(path) # ------------------------------------------------- # do a POST to ``API_APPLICATIONS``/<app_id>/action - def application_action(self, token, app_id, action, reason=""): + def application_action(self, app_id, action, reason=""): """Perform action on an application Arguments: - token -- user's token (string) app_id -- application identifier action -- action to perform, one of "approve", "deny", "dismiss", "cancel" @@ -716,50 +750,48 @@ class AstakosClient(): In case of success, return nothing. """ - path = join_urls(API_APPLICATIONS, str(app_id)) + path = join_urls(self.api_applications, str(app_id)) path = join_urls(path, "action") req_headers = {'content-type': 'application/json'} req_body = parse_request({action: reason}, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(path, headers=req_headers, + body=req_body, method="POST") # ------------------------------- # do a GET to ``API_MEMBERSHIPS`` - def get_memberships(self, token, project=None): + def get_memberships(self, project=None): """Retrieve all accessible memberships Arguments: - token -- user's token (string) project -- filter by project (optional) In case of success, return a list of membership descriptions. """ - path = API_MEMBERSHIPS req_headers = {'content-type': 'application/json'} body = {"project": project} if project is not None else None req_body = parse_request(body, self.logger) if body else None - return self._call_astakos(token, path, req_headers, req_body) + return self._call_astakos(self.api_memberships, + headers=req_headers, body=req_body) # ----------------------------------------- # do a GET to ``API_MEMBERSHIPS``/<memb_id> - def get_membership(self, token, memb_id): + def get_membership(self, memb_id): """Retrieve membership description, if accessible Arguments: - token -- user's token (string) memb_id -- membership identifier In case of success, return membership description. """ - path = join_urls(API_MEMBERSHIPS, str(memb_id)) - return self._call_astakos(token, path) + path = join_urls(self.api_memberships, str(memb_id)) + return self._call_astakos(path) # ------------------------------------------------- # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action - def membership_action(self, token, memb_id, action, reason=""): + def membership_action(self, memb_id, action, reason=""): """Perform action on a membership Arguments: - token -- user's token (string) memb_id -- membership identifier action -- action to perform, one of "leave", "cancel", "accept", "reject", "remove" @@ -767,46 +799,96 @@ class AstakosClient(): In case of success, return nothing. """ - path = join_urls(API_MEMBERSHIPS, str(memb_id)) + path = join_urls(self.api_memberships, str(memb_id)) path = join_urls(path, "action") req_headers = {'content-type': 'application/json'} req_body = parse_request({action: reason}, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(path, headers=req_headers, + body=req_body, method="POST") # -------------------------------- # do a POST to ``API_MEMBERSHIPS`` - def join_project(self, token, project_id): + def join_project(self, project_id): """Join a project Arguments: - token -- user's token (string) project_id -- project identifier In case of success, return membership identifier. """ - path = API_MEMBERSHIPS req_headers = {'content-type': 'application/json'} body = {"join": {"project": project_id}} req_body = parse_request(body, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(self.api_memberships, headers=req_headers, + body=req_body, method="POST") # -------------------------------- # do a POST to ``API_MEMBERSHIPS`` - def enroll_member(self, token, project_id, email): + def enroll_member(self, project_id, email): """Enroll a user in a project Arguments: - token -- user's token (string) project_id -- project identifier email -- user identified by email In case of success, return membership identifier. """ - path = API_MEMBERSHIPS req_headers = {'content-type': 'application/json'} body = {"enroll": {"project": project_id, "user": email}} req_body = parse_request(body, self.logger) - return self._call_astakos(token, path, req_headers, req_body, "POST") + return self._call_astakos(self.api_memberships, headers=req_headers, + body=req_body, method="POST") + + +# -------------------------------------------------------------------- +# parse endpoints +def parse_endpoints(endpoints, ep_name=None, ep_type=None, + ep_region=None, ep_version_id=None): + """Parse endpoints server response and extract the ones needed + + Keyword arguments: + endpoints -- the endpoints (json response from get_endpoints) + ep_name -- return only endpoints with this name (optional) + ep_type -- return only endpoints with this type (optional) + ep_region -- return only endpoints with this region (optional) + ep_version_id -- return only endpoints with this versionId (optional) + + In case on of the `name', `type', `region', `version_id' parameters + is given, return only the endpoints that match all of these criteria. + If no match is found then raise NoEndpoints exception. + + """ + try: + catalog = endpoints['access']['serviceCatalog'] + if ep_name is not None: + catalog = \ + [c for c in catalog if c['name'] == ep_name] + if ep_type is not None: + catalog = \ + [c for c in catalog if c['type'] == ep_type] + if ep_region is not None: + for c in catalog: + c['endpoints'] = [e for e in c['endpoints'] + if e['region'] == ep_region] + # Remove catalog entries with no endpoints + catalog = \ + [c for c in catalog if c['endpoints']] + if ep_version_id is not None: + for c in catalog: + c['endpoints'] = [e for e in c['endpoints'] + if e['versionId'] == ep_version_id] + # Remove catalog entries with no endpoints + catalog = \ + [c for c in catalog if c['endpoints']] + + if not catalog: + raise NoEndpoints(ep_name, ep_type, + ep_region, ep_version_id) + else: + return catalog + except KeyError: + raise NoEndpoints() + # -------------------------------------------------------------------- # Private functions diff --git a/astakosclient/astakosclient/errors.py b/astakosclient/astakosclient/errors.py index a5f66beff3fdbfd4cf99fdf951ad22f84229720c..a85d38da02da784637ea4316565f10a1e45fe83f 100644 --- a/astakosclient/astakosclient/errors.py +++ b/astakosclient/astakosclient/errors.py @@ -31,8 +31,13 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +""" +Astakos Client Exceptions +""" + class AstakosClientException(Exception): + """Base AstakosClientException Class""" def __init__(self, message='', details='', status=500): self.message = message self.details = details @@ -43,47 +48,65 @@ class AstakosClientException(Exception): class BadValue(AstakosClientException): + """Re-define ValueError Exception under AstakosClientException""" def __init__(self, details): - """Re-define ValueError Exception under AstakosClientException""" message = "ValueError" super(BadValue, self).__init__(message, details) class InvalidResponse(AstakosClientException): + """Return simplejson parse Exception as AstakosClient one""" def __init__(self, message, details): - """Return simplejson parse Exception as AstakosClient one""" super(InvalidResponse, self).__init__(message, details) class BadRequest(AstakosClientException): + """BadRequest Exception""" status = 400 class Unauthorized(AstakosClientException): + """Unauthorized Exception""" status = 401 class Forbidden(AstakosClientException): + """Forbidden Exception""" status = 403 class NotFound(AstakosClientException): + """NotFound Exception""" status = 404 class QuotaLimit(AstakosClientException): + """QuotaLimit Exception""" status = 413 class NoUserName(AstakosClientException): + """No display name for the given uuid""" def __init__(self, uuid): - """No display name for the given uuid""" message = "No display name for the given uuid: %s" % uuid super(NoUserName, self).__init__(message) class NoUUID(AstakosClientException): + """No uuid for the given display name""" def __init__(self, display_name): - """No uuid for the given display name""" message = "No uuid for the given display name: %s" % display_name super(NoUUID, self).__init__(message) + + +class NoEndpoints(AstakosClientException): + """No endpoints found matching the criteria given""" + def __init__(self, ep_name, ep_type, ep_region, ep_version_id): + message = "No endpoints found matching" + \ + (", name = %s" % ep_name) if ep_name is not None else "" + \ + (", type = %s" % ep_type) if ep_type is not None else "" + \ + (", region = %s" % ep_region) \ + if ep_region is not None else "" + \ + (", version_id = %s" % ep_version_id) \ + if ep_version_id is not None else "." + super(NoEndpoints, self).__init__(message) diff --git a/astakosclient/astakosclient/keypath.py b/astakosclient/astakosclient/keypath.py deleted file mode 100644 index 883a40c8d3f7785444c12dff74f7f14fbc3619f3..0000000000000000000000000000000000000000 --- a/astakosclient/astakosclient/keypath.py +++ /dev/null @@ -1,216 +0,0 @@ -# Copyright 2012, 2013 GRNET S.A. All rights reserved. -# -# Redistribution and use in source and binary forms, with or -# without modification, are permitted provided that the following -# conditions are met: -# -# 1. Redistributions of source code must retain the above -# copyright notice, this list of conditions and the following -# disclaimer. -# -# 2. Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials -# provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS -# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED -# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# The views and conclusions contained in the software and -# documentation are those of the authors and should not be -# interpreted as representing official policies, either expressed -# or implied, of GRNET S.A. -import copy - - -def dict_merge(a, b): - """ - http://www.xormedia.com/recursively-merge-dictionaries-in-python/ - """ - if not isinstance(b, dict): - return b - result = copy.deepcopy(a) - for k, v in b.iteritems(): - if k in result and isinstance(result[k], dict): - result[k] = dict_merge(result[k], v) - else: - result[k] = copy.deepcopy(v) - return result - - -def lookup_path(container, path, sep='.', createpath=False): - """ - return (['a','b'], - [container['a'], container['a']['b']], - 'c') where path=sep.join(['a','b','c']) - - """ - names = path.split(sep) - dirnames = names[:-1] - basename = names[-1] - - node = container - name_path = [] - node_path = [node] - for name in dirnames: - name_path.append(name) - if name not in node: - if not createpath: - m = "'{0}': path not found".format(sep.join(name_path)) - raise KeyError(m) - node[name] = {} - try: - node = node[name] - except TypeError as e: - m = "'{0}': cannot traverse path beyond this node: {1}" - m = m.format(sep.join(name_path), str(e)) - raise ValueError(m) - node_path.append(node) - - return name_path, node_path, basename - - -def walk_paths(container): - for name, node in container.iteritems(): - if not hasattr(node, 'items'): - yield [name], [node] - else: - for names, nodes in walk_paths(node): - yield [name] + names, [node] + nodes - - -def list_paths(container, sep='.'): - """ - >>> sorted(list_paths({'a': {'b': {'c': 'd'}}})) - [('a.b.c', 'd')] - >>> sorted(list_paths({'a': {'b': {'c': 'd'}, 'e': 3}})) - [('a.b.c', 'd'), ('a.e', 3)] - >>> sorted(list_paths({'a': {'b': {'c': 'd'}, 'e': {'f': 3}}})) - [('a.b.c', 'd'), ('a.e.f', 3)] - >>> list_paths({}) - [] - - """ - return [(sep.join(name_path), node_path[-1]) - for name_path, node_path in walk_paths(container)] - - -def del_path(container, path, sep='.', collect=True): - """ - del container['a']['b']['c'] where path=sep.join(['a','b','c']) - - >>> d = {'a': {'b': {'c': 'd'}}}; del_path(d, 'a.b.c'); d - {} - >>> d = {'a': {'b': {'c': 'd'}}}; del_path(d, 'a.b.c', collect=False); d - {'a': {'b': {}}} - >>> d = {'a': {'b': {'c': 'd'}}}; del_path(d, 'a.b.c.d') - Traceback (most recent call last): - ValueError: 'a.b.c': cannot traverse path beyond this node:\ - 'str' object does not support item deletion - """ - - name_path, node_path, basename = \ - lookup_path(container, path, sep=sep, createpath=False) - - lastnode = node_path.pop() - try: - if basename in lastnode: - del lastnode[basename] - except (TypeError, KeyError) as e: - m = "'{0}': cannot traverse path beyond this node: {1}" - m = m.format(sep.join(name_path), str(e)) - raise ValueError(m) - - if collect: - while node_path and not lastnode: - basename = name_path.pop() - lastnode = node_path.pop() - del lastnode[basename] - - -def get_path(container, path, sep='.'): - """ - return container['a']['b']['c'] where path=sep.join(['a','b','c']) - - >>> get_path({'a': {'b': {'c': 'd'}}}, 'a.b.c.d') - Traceback (most recent call last): - ValueError: 'a.b.c.d': cannot traverse path beyond this node:\ - string indices must be integers, not str - >>> get_path({'a': {'b': {'c': 1}}}, 'a.b.c.d') - Traceback (most recent call last): - ValueError: 'a.b.c.d': cannot traverse path beyond this node:\ - 'int' object is unsubscriptable - >>> get_path({'a': {'b': {'c': 1}}}, 'a.b.c') - 1 - >>> get_path({'a': {'b': {'c': 1}}}, 'a.b') - {'c': 1} - - """ - name_path, node_path, basename = \ - lookup_path(container, path, sep=sep, createpath=False) - name_path.append(basename) - node = node_path[-1] - - try: - return node[basename] - except TypeError as e: - m = "'{0}': cannot traverse path beyond this node: {1}" - m = m.format(sep.join(name_path), str(e)) - raise ValueError(m) - except KeyError as e: - m = "'{0}': path not found: {1}" - m = m.format(sep.join(name_path), str(e)) - raise KeyError(m) - - -def set_path(container, path, value, sep='.', - createpath=False, overwrite=True): - """ - container['a']['b']['c'] = value where path=sep.join(['a','b','c']) - - >>> set_path({'a': {'b': {'c': 'd'}}}, 'a.b.c.d', 1) - Traceback (most recent call last): - ValueError: 'a.b.c.d': cannot traverse path beyond this node:\ - 'str' object does not support item assignment - >>> set_path({'a': {'b': {'c': 'd'}}}, 'a.b.x.d', 1) - Traceback (most recent call last): - KeyError: "'a.b.x': path not found" - >>> set_path({'a': {'b': {'c': 'd'}}}, 'a.b.x.d', 1, createpath=True) - - >>> set_path({'a': {'b': {'c': 'd'}}}, 'a.b.c', 1) - - >>> set_path({'a': {'b': {'c': 'd'}}}, 'a.b.c', 1, overwrite=False) - Traceback (most recent call last): - ValueError: will not overwrite path 'a.b.c' - - """ - name_path, node_path, basename = \ - lookup_path(container, path, sep=sep, createpath=createpath) - name_path.append(basename) - node = node_path[-1] - - if basename in node and not overwrite: - m = "will not overwrite path '{0}'".format(path) - raise ValueError(m) - - try: - node[basename] = value - except TypeError as e: - m = "'{0}': cannot traverse path beyond this node: {1}" - m = m.format(sep.join(name_path), str(e)) - raise ValueError(m) - - -if __name__ == '__main__': - import doctest - doctest.testmod() diff --git a/astakosclient/astakosclient/services.py b/astakosclient/astakosclient/services.py deleted file mode 100644 index 98fa6b144ba23e554ad269ab1c9af965f429f02b..0000000000000000000000000000000000000000 --- a/astakosclient/astakosclient/services.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (C) 2013 GRNET S.A. All rights reserved. -# -# Redistribution and use in source and binary forms, with or -# without modification, are permitted provided that the following -# conditions are met: -# -# 1. Redistributions of source code must retain the above -# copyright notice, this list of conditions and the following -# disclaimer. -# -# 2. Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials -# provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS -# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED -# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# The views and conclusions contained in the software and -# documentation are those of the authors and should not be -# interpreted as representing official policies, either expressed -# or implied, of GRNET S.A. - - -astakos_services = { - 'astakos_account': { - 'type': 'account', - 'component': 'astakos', - 'prefix': 'account', - 'public': True, - 'endpoints': [ - {'versionId': 'v1.0', - 'publicURL': None}, - ], - 'resources': { - 'pending_app': { - 'desc': "Number of pending project applications", - 'name': "astakos.pending_app", - 'service_type': "account", - 'service_origin': "astakos_account", - 'allow_in_projects': False}, - }, - }, - - 'astakos_identity': { - 'type': 'identity', - 'component': 'astakos', - 'prefix': 'identity', - 'public': True, - 'endpoints': [ - {'versionId': 'v2.0', - 'publicURL': None}, - ], - 'resources': {}, - }, - - 'astakos_weblogin': { - 'type': 'astakos_weblogin', - 'component': 'astakos', - 'prefix': 'weblogin', - 'public': True, - 'endpoints': [ - {'versionId': '', - 'publicURL': None}, - ], - }, - - 'astakos_ui': { - 'type': 'astakos_ui', - 'component': 'astakos', - 'prefix': 'ui', - 'public': False, - 'endpoints': [ - {'versionId': '', - 'publicURL': None}, - ], - }, -} diff --git a/astakosclient/astakosclient/utils.py b/astakosclient/astakosclient/utils.py index 22919cc4a5f445864488654cf1ae74f14f6305ec..70cd87e82a81e405b58f917a651ec6c92874fed3 100644 --- a/astakosclient/astakosclient/utils.py +++ b/astakosclient/astakosclient/utils.py @@ -31,6 +31,10 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +""" +Astakos Client utility module +""" + from httplib import HTTPConnection, HTTPSConnection from contextlib import closing @@ -39,8 +43,10 @@ from objpool.http import PooledHTTPConnection from astakosclient.errors import AstakosClientException, BadValue -def retry(func): +def retry_dec(func): + """Class Method Decorator""" def decorator(self, *args, **kwargs): + """Retry `self.retry' times if connection fails""" attemps = 0 while True: try: @@ -64,13 +70,16 @@ def retry(func): def scheme_to_class(scheme, use_pool, pool_size): """Return the appropriate conn class for given scheme""" def _objpool(netloc): + """Helper function to return a PooledHTTPConnection object""" return PooledHTTPConnection( netloc=netloc, scheme=scheme, size=pool_size) def _http_connection(netloc): + """Helper function to return an HTTPConnection object""" return closing(HTTPConnection(netloc)) def _https_connection(netloc): + """Helper function to return an HTTPSConnection object""" return closing(HTTPSConnection(netloc)) if scheme == "http": @@ -92,16 +101,22 @@ def parse_request(request, logger): try: return simplejson.dumps(request) except Exception as err: - m = "Cannot parse request \"%s\" with simplejson: %s" \ - % (request, str(err)) - logger.error(m) - raise BadValue(m) + msg = "Cannot parse request \"%s\" with simplejson: %s" \ + % (request, str(err)) + logger.error(msg) + raise BadValue(msg) def check_input(function_name, logger, **kwargs): """Check if given arguments are not None""" for i in kwargs: if kwargs[i] is None: - m = "in " + function_name + ": " + str(i) + " parameter not given" - logger.error(m) - raise BadValue(m) + msg = "in " + function_name + ": " + \ + str(i) + " parameter not given" + logger.error(msg) + raise BadValue(msg) + + +def join_urls(url_a, url_b): + """Join_urls from synnefo.lib""" + return url_a.rstrip("/") + "/" + url_b.lstrip("/")