try: from ConfigParser import ConfigParser except ImportError: from configparser import ConfigParser import json import logging from tabpy.tabpy_server.management.util import write_state_config from threading import Lock from time import time logger = logging.getLogger(__name__) # State File Config Section Names _DEPLOYMENT_SECTION_NAME = "Query Objects Service Versions" _QUERY_OBJECT_DOCSTRING = "Query Objects Docstrings" _SERVICE_INFO_SECTION_NAME = "Service Info" _META_SECTION_NAME = "Meta" # Directory Names _QUERY_OBJECT_DIR = "query_objects" """ Lock to change the TabPy State. """ _PS_STATE_LOCK = Lock() def state_lock(func): """ Mutex for changing PS state """ def wrapper(self, *args, **kwargs): try: _PS_STATE_LOCK.acquire() return func(self, *args, **kwargs) finally: # ALWAYS RELEASE LOCK _PS_STATE_LOCK.release() return wrapper def _get_root_path(state_path): if state_path[-1] != "/": return state_path + "/" else: return state_path def get_query_object_path(state_file_path, name, version): """ Returns the query object path If the version is None, a path without the version will be returned. """ root_path = _get_root_path(state_file_path) if version is not None: full_path = root_path + "/".join([_QUERY_OBJECT_DIR, name, str(version)]) else: full_path = root_path + "/".join([_QUERY_OBJECT_DIR, name]) return full_path class TabPyState: """ The TabPy state object that stores attributes about this TabPy and perform GET/SET on these attributes. Attributes: - name - description - endpoints (name, description, docstring, version, target) - revision number When the state object is initialized, the state is saved as a ConfigParser. There is a config to any attribute. """ def __init__(self, settings, config=None): self.settings = settings self.set_config(config, _update=False) @state_lock def set_config(self, config, logger=logging.getLogger(__name__), _update=True): """ Set the local ConfigParser manually. This new ConfigParser will be used as current state. """ if not isinstance(config, ConfigParser): raise ValueError("Invalid config") self.config = config if _update: self._write_state(logger) def get_endpoints(self, name=None): """ Return a dictionary of endpoints Parameters ---------- name : str The name of the endpoint. If "name" is specified, only the information about that endpoint will be returned. Returns ------- endpoints : dict The dictionary containing information about each endpoint. The keys are the endpoint names. The values for each include: - description - doc string - type - target """ endpoints = {} try: endpoint_names = self._get_config_value(_DEPLOYMENT_SECTION_NAME, name) except Exception as e: logger.error(f"error in get_endpoints: {str(e)}") return {} if name: endpoint_info = json.loads(endpoint_names) docstring = self._get_config_value(_QUERY_OBJECT_DOCSTRING, name) endpoint_info["docstring"] = str( bytes(docstring, "utf-8").decode("unicode_escape") ) endpoints = {name: endpoint_info} else: for endpoint_name in endpoint_names: endpoint_info = json.loads( self._get_config_value(_DEPLOYMENT_SECTION_NAME, endpoint_name) ) docstring = self._get_config_value( _QUERY_OBJECT_DOCSTRING, endpoint_name, True, "" ) endpoint_info["docstring"] = str( bytes(docstring, "utf-8").decode("unicode_escape") ) endpoints[endpoint_name] = endpoint_info logger.debug(f"Collected endpoints: {endpoints}") return endpoints @state_lock def add_endpoint( self, name, description=None, docstring=None, endpoint_type=None, methods=None, target=None, dependencies=None, schema=None, ): """ Add a new endpoint to the TabPy. Parameters ---------- name : str Name of the endpoint description : str, optional Description of this endpoint doc_string : str, optional The doc string for this endpoint, if needed. endpoint_type : str The endpoint type (model, alias) target : str, optional The target endpoint name for the alias to be added. Note: The version of this endpoint will be set to 1 since it is a new endpoint. """ try: endpoints = self.get_endpoints() if name is None or not isinstance(name, str) or len(name) == 0: raise ValueError("name of the endpoint must be a valid string.") elif name in endpoints: raise ValueError(f"endpoint {name} already exists.") if description and not isinstance(description, str): raise ValueError("description must be a string.") elif not description: description = "" if docstring and not isinstance(docstring, str): raise ValueError("docstring must be a string.") elif not docstring: docstring = "-- no docstring found in query function --" if not endpoint_type or not isinstance(endpoint_type, str): raise ValueError("endpoint type must be a string.") if dependencies and not isinstance(dependencies, list): raise ValueError("dependencies must be a list.") elif not dependencies: dependencies = [] if target and not isinstance(target, str): raise ValueError("target must be a string.") elif target and target not in endpoints: raise ValueError("target endpoint is not valid.") endpoint_info = { "description": description, "docstring": docstring, "type": endpoint_type, "version": 1, "dependencies": dependencies, "target": target, "creation_time": int(time()), "last_modified_time": int(time()), "schema": schema, } endpoints[name] = endpoint_info self._add_update_endpoints_config(endpoints) except Exception as e: logger.error(f"Error in add_endpoint: {e}") raise def _add_update_endpoints_config(self, endpoints): # save the endpoint info to config dstring = "" for endpoint_name in endpoints: try: info = endpoints[endpoint_name] dstring = str( bytes(info["docstring"], "utf-8").decode("unicode_escape") ) self._set_config_value( _QUERY_OBJECT_DOCSTRING, endpoint_name, dstring, _update_revision=False, ) del info["docstring"] self._set_config_value( _DEPLOYMENT_SECTION_NAME, endpoint_name, json.dumps(info) ) except Exception as e: logger.error(f"Unable to write endpoints config: {e}") raise @state_lock def update_endpoint( self, name, description=None, docstring=None, endpoint_type=None, version=None, methods=None, target=None, dependencies=None, schema=None, ): """ Update an existing endpoint on the TabPy. Parameters ---------- name : str Name of the endpoint description : str, optional Description of this endpoint doc_string : str, optional The doc string for this endpoint, if needed. endpoint_type : str, optional The endpoint type (model, alias) version : str, optional The version of this endpoint dependencies=[] List of dependent endpoints for this existing endpoint target : str, optional The target endpoint name for the alias. Note: For those parameters that are not specified, those values will not get changed. """ try: endpoints = self.get_endpoints() if not name or not isinstance(name, str): raise ValueError("name of the endpoint must be string.") elif name not in endpoints: raise ValueError(f"endpoint {name} does not exist.") endpoint_info = endpoints[name] if description and not isinstance(description, str): raise ValueError("description must be a string.") elif not description: description = endpoint_info["description"] if docstring and not isinstance(docstring, str): raise ValueError("docstring must be a string.") elif not docstring: docstring = endpoint_info["docstring"] if endpoint_type and not isinstance(endpoint_type, str): raise ValueError("endpoint type must be a string.") elif not endpoint_type: endpoint_type = endpoint_info["type"] if version and not isinstance(version, int): raise ValueError("version must be an int.") elif not version: version = endpoint_info["version"] if dependencies and not isinstance(dependencies, list): raise ValueError("dependencies must be a list.") elif not dependencies: if "dependencies" in endpoint_info: dependencies = endpoint_info["dependencies"] else: dependencies = [] if target and not isinstance(target, str): raise ValueError("target must be a string.") elif target and target not in endpoints: raise ValueError("target endpoint is not valid.") elif not target: target = endpoint_info["target"] endpoint_info = { "description": description, "docstring": docstring, "type": endpoint_type, "version": version, "dependencies": dependencies, "target": target, "creation_time": endpoint_info["creation_time"], "last_modified_time": int(time()), "schema": schema, } endpoints[name] = endpoint_info self._add_update_endpoints_config(endpoints) except Exception as e: logger.error(f"Error in update_endpoint: {e}") raise @state_lock def delete_endpoint(self, name): """ Delete an existing endpoint on the TabPy Parameters ---------- name : str The name of the endpoint to be deleted. Returns ------- deleted endpoint object Note: Cannot delete this endpoint if other endpoints are currently depending on this endpoint. """ if not name or name == "": raise ValueError("Name of the endpoint must be a valid string.") endpoints = self.get_endpoints() if name not in endpoints: raise ValueError(f"Endpoint {name} does not exist.") endpoint_to_delete = endpoints[name] # get dependencies and target deps = set() for endpoint_name in endpoints: if endpoint_name != name: deps_list = endpoints[endpoint_name].get("dependencies", []) if name in deps_list: deps.add(endpoint_name) # check if other endpoints are depending on this endpoint if len(deps) > 0: raise ValueError( f"Cannot remove endpoint {name}, it is currently " f"used by {list(deps)} endpoints." ) del endpoints[name] # delete the endpoint from state try: self._remove_config_option( _QUERY_OBJECT_DOCSTRING, name, _update_revision=False ) self._remove_config_option(_DEPLOYMENT_SECTION_NAME, name) return endpoint_to_delete except Exception as e: logger.error(f"Unable to delete endpoint {e}") raise ValueError(f"Unable to delete endpoint: {e}") @property def name(self): """ Returns the name of the TabPy service. """ name = None try: name = self._get_config_value(_SERVICE_INFO_SECTION_NAME, "Name") except Exception as e: logger.error(f"Unable to get name: {e}") return name @property def creation_time(self): """ Returns the creation time of the TabPy service. """ creation_time = 0 try: creation_time = self._get_config_value( _SERVICE_INFO_SECTION_NAME, "Creation Time" ) except Exception as e: logger.error(f"Unable to get name: {e}") return creation_time @state_lock def set_name(self, name): """ Set the name of this TabPy service. Parameters ---------- name : str Name of TabPy service. """ if not isinstance(name, str): raise ValueError("name must be a string.") try: self._set_config_value(_SERVICE_INFO_SECTION_NAME, "Name", name) except Exception as e: logger.error(f"Unable to set name: {e}") def get_description(self): """ Returns the description of the TabPy service. """ description = None try: description = self._get_config_value( _SERVICE_INFO_SECTION_NAME, "Description" ) except Exception as e: logger.error(f"Unable to get description: {e}") return description @state_lock def set_description(self, description): """ Set the description of this TabPy service. Parameters ---------- description : str Description of TabPy service. """ if not isinstance(description, str): raise ValueError("Description must be a string.") try: self._set_config_value( _SERVICE_INFO_SECTION_NAME, "Description", description ) except Exception as e: logger.error(f"Unable to set description: {e}") def get_revision_number(self): """ Returns the revision number of this TabPy service. """ rev = -1 try: rev = int(self._get_config_value(_META_SECTION_NAME, "Revision Number")) except Exception as e: logger.error(f"Unable to get revision number: {e}") return rev def get_access_control_allow_origin(self): """ Returns Access-Control-Allow-Origin of this TabPy service. """ _cors_origin = "" try: logger.debug("Collecting Access-Control-Allow-Origin from state file ...") _cors_origin = self._get_config_value( "Service Info", "Access-Control-Allow-Origin" ) except Exception as e: logger.error(e) return _cors_origin def get_access_control_allow_headers(self): """ Returns Access-Control-Allow-Headers of this TabPy service. """ _cors_headers = "" try: _cors_headers = self._get_config_value( "Service Info", "Access-Control-Allow-Headers" ) except Exception: pass return _cors_headers def get_access_control_allow_methods(self): """ Returns Access-Control-Allow-Methods of this TabPy service. """ _cors_methods = "" try: _cors_methods = self._get_config_value( "Service Info", "Access-Control-Allow-Methods" ) except Exception: pass return _cors_methods def _set_revision_number(self, revision_number): """ Set the revision number of this TabPy service. """ if not isinstance(revision_number, int): raise ValueError("revision number must be an int.") try: self._set_config_value( _META_SECTION_NAME, "Revision Number", revision_number ) except Exception as e: logger.error(f"Unable to set revision number: {e}") def _remove_config_option( self, section_name, option_name, logger=logging.getLogger(__name__), _update_revision=True, ): if not self.config: raise ValueError("State configuration not yet loaded.") self.config.remove_option(section_name, option_name) # update revision number if _update_revision: self._increase_revision_number() self._write_state(logger=logger) def _has_config_value(self, section_name, option_name): if not self.config: raise ValueError("State configuration not yet loaded.") return self.config.has_option(section_name, option_name) def _increase_revision_number(self): if not self.config: raise ValueError("State configuration not yet loaded.") cur_rev = int(self.config.get(_META_SECTION_NAME, "Revision Number")) self.config.set(_META_SECTION_NAME, "Revision Number", str(cur_rev + 1)) def _set_config_value( self, section_name, option_name, option_value, logger=logging.getLogger(__name__), _update_revision=True, ): if not self.config: raise ValueError("State configuration not yet loaded.") if not self.config.has_section(section_name): logger.log(logging.DEBUG, f"Adding config section {section_name}") self.config.add_section(section_name) self.config.set(section_name, option_name, option_value) # update revision number if _update_revision: self._increase_revision_number() self._write_state(logger=logger) def _get_config_items(self, section_name): if not self.config: raise ValueError("State configuration not yet loaded.") return self.config.items(section_name) def _get_config_value( self, section_name, option_name, optional=False, default_value=None ): logger.log( logging.DEBUG, f"Loading option '{option_name}' from section [{section_name}]...") if not self.config: msg = "State configuration not yet loaded." logging.log(msg) raise ValueError(msg) res = None if not option_name: res = self.config.options(section_name) elif self.config.has_option(section_name, option_name): res = self.config.get(section_name, option_name) elif optional: res = default_value else: raise ValueError( f"Cannot find option name {option_name} " f"under section {section_name}" ) logger.log(logging.DEBUG, f"Returning value '{res}'") return res def _write_state(self, logger=logging.getLogger(__name__)): """ Write state (ConfigParser) to Consul """ logger.log(logging.INFO, "Writing state to config") write_state_config(self.config, self.settings, logger=logger)