diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7e042af56..85c97e508 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,19 @@ dcicutils Change Log ---------- +8.6.0 +===== +* Minor fix to misc_utils.to_integer to handle float strings. +* Minor fix to structured_data to accumulate unique resolved_refs across schemas. +* Added ability to autoadd properties structured_data.StructuredDataSet; + to automatically pass in submission_centers on submission, and + not require that the user explicitly set this in the spreadsheet. +* Changes to structured_data to respect uniqueItems for arrays. +* Handle no schemas better in structured_data. +* Added portal_utils.Portal.ping(). +* Minor fix in portal_utils.Portal._uri(). + + 8.5.0 ===== * Moved structured_data.py from smaht-portal to here; new portal_utils and data_readers modules. diff --git a/LICENSE.txt b/LICENSE.txt index dbd9eb7db..e7d2f54ef 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License -Copyright 2017-2023 President and Fellows of Harvard College +Copyright 2017-2024 President and Fellows of Harvard College Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/dcicutils/misc_utils.py b/dcicutils/misc_utils.py index baecf97c6..922e5de5d 100644 --- a/dcicutils/misc_utils.py +++ b/dcicutils/misc_utils.py @@ -982,7 +982,11 @@ def to_integer(value: str, fallback: Optional[Any] = None) -> Optional[Any]: try: return int(value) except Exception: - return fallback + try: + return int(float(value)) + except Exception: + pass + return fallback def to_float(value: str, fallback: Optional[Any] = None) -> Optional[Any]: @@ -1465,28 +1469,34 @@ def string_list(s): return [p for p in [part.strip() for part in s.split(",")] if p] -def split_string(value: str, delimiter: str, escape: Optional[str] = None) -> List[str]: +def split_string(value: str, delimiter: str, escape: Optional[str] = None, unique: bool = False) -> List[str]: """ Splits the given string into an array of string based on the given delimiter, and an optional escape character. + If the given unique flag is True then duplicate values will not be included. """ if not isinstance(value, str) or not (value := value.strip()): return [] - if not isinstance(escape, str) or not escape: - return [item.strip() for item in value.split(delimiter)] result = [] + if not isinstance(escape, str) or not escape: + for item in value.split(delimiter): + if (item := item.strip()) and (unique is not True or item not in result): + result.append(item) + return result item = r"" escaped = False for c in value: if c == delimiter and not escaped: - result.append(item.strip()) + if (item := item.strip()) and (unique is not True or item not in result): + result.append(item) item = r"" elif c == escape and not escaped: escaped = True else: item += c escaped = False - result.append(item.strip()) - return [item for item in result if item] + if (item := item.strip()) and (unique is not True or item not in result): + result.append(item) + return result def right_trim(list_or_tuple: Union[List[Any], Tuple[Any]], @@ -2181,6 +2191,20 @@ def merge_objects(target: Union[dict, List[Any]], source: Union[dict, List[Any]] return target +def load_json_from_file_expanding_environment_variables(file: str) -> Union[dict, list]: + def expand_envronment_variables(data): # noqa + if isinstance(data, dict): + return {key: expand_envronment_variables(value) for key, value in data.items()} + if isinstance(data, list): + return [expand_envronment_variables(element) for element in data] + if isinstance(data, str): + return re.sub(r"\$\{([^}]+)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)", + lambda match: os.environ.get(match.group(1) or match.group(2), match.group(0)), data) + return data + with open(file, "r") as file: + return expand_envronment_variables(json.load(file)) + + # Stealing topological sort classes below from python's graphlib module introduced # in v3.9 with minor refactoring. # Source: https://github.com/python/cpython/blob/3.11/Lib/graphlib.py diff --git a/dcicutils/portal_utils.py b/dcicutils/portal_utils.py index 82a34f574..2856eb889 100644 --- a/dcicutils/portal_utils.py +++ b/dcicutils/portal_utils.py @@ -1,19 +1,26 @@ from collections import deque -from pyramid.paster import get_app -from pyramid.router import Router +import io +import json +from pyramid.config import Configurator as PyramidConfigurator +from pyramid.paster import get_app as pyramid_get_app +from pyramid.response import Response as PyramidResponse +from pyramid.router import Router as PyramidRouter +import os import re import requests -from requests.models import Response as RequestResponse -from typing import Optional, Type, Union +from requests.models import Response +from threading import Thread +from typing import Callable, Dict, List, Optional, Type, Union +from uuid import uuid4 as uuid from webtest.app import TestApp, TestResponse -from dcicutils.common import OrchestratedApp, APP_CGAP, APP_FOURFRONT, APP_SMAHT, ORCHESTRATED_APPS -from dcicutils.creds_utils import CGAPKeyManager, FourfrontKeyManager, SMaHTKeyManager +from wsgiref.simple_server import make_server as wsgi_make_server +from dcicutils.common import OrchestratedApp, ORCHESTRATED_APPS from dcicutils.ff_utils import get_metadata, get_schema, patch_metadata, post_metadata from dcicutils.misc_utils import to_camel_case, VirtualApp -from dcicutils.zip_utils import temporary_file +from dcicutils.tmpfile_utils import temporary_file Portal = Type["Portal"] # Forward type reference for type hints. -FILE_SCHEMA_NAME = "File" +OptionalResponse = Optional[Union[Response, TestResponse]] class Portal: @@ -21,144 +28,224 @@ class Portal: This is meant to be an uber wrapper for Portal access. It can be created in a variety of ways: 1. From a (Portal) .ini file (e.g. development.ini) 2. From a key dictionary, containing "key" and "secret" property values. - 3. From a key tuple, containing (in order) a key and secret values. + 3. From a key pair tuple, containing (in order) a key and secret values. 4. From a keys file assumed to reside in ~/.{app}-keys.json where the given "app" value is either "smaht", "cgap", - or "fourfront"; and where this file is assumed to contain a dictionary with a key equal to the given "env" - value (e.g. smaht-localhost) and with a dictionary value containing "key" and "secret" property values; if - an "app" value is not specified but the given "env" value begins with one of the app values then that value - will be used, i.e. e.g. if env is "smaht-localhost" and app is unspecified than it is assumed to be "smaht". + or "fourfront"; where is assumed to contain a dictionary with a key for the given "env" value, e.g. smaht-local; + and with a dictionary value containing "key" and "secret" property values, and an optional "server" property; + if an "app" value is not specified but the given "env" value begins with one of the app values then that value + will be used, i.e. e.g. if "env" is "smaht-local" and app is unspecified than app is assumed to be "smaht". 5. From a keys file as described above (#4) but rather than be identified by the given "env" value it - is looked up by the given "server" name and the "server" key dictionary value in the key file. - 6. From a given "vapp" value (which is assumed to be a TestApp or VirtualApp). - 7. From another Portal object. - 8. From a a pyramid Router object. + is looked up via the given "server" name and the "server" key dictionary value in the key file. + 6. From a given "vapp" value (which may be a webtest/TestApp or VirtualApp or even a pyramid/Router). + 7. From another Portal object; or from a a pyramid Router object. """ + FILE_SCHEMA_NAME = "File" + KEYS_FILE_DIRECTORY = os.path.expanduser(f"~") + def __init__(self, - arg: Optional[Union[VirtualApp, TestApp, Router, Portal, dict, tuple, str]] = None, - env: Optional[str] = None, app: Optional[OrchestratedApp] = None, server: Optional[str] = None, - key: Optional[Union[dict, tuple]] = None, - vapp: Optional[Union[VirtualApp, TestApp, Router, Portal, str]] = None, - portal: Optional[Union[VirtualApp, TestApp, Router, Portal, str]] = None) -> Portal: - if vapp and not portal: - portal = vapp - if ((isinstance(arg, (VirtualApp, TestApp, Router, Portal)) or - isinstance(arg, str) and arg.endswith(".ini")) and not portal): - portal = arg - elif isinstance(arg, str) and not env: - env = arg - elif (isinstance(arg, dict) or isinstance(arg, tuple)) and not key: - key = arg - if not app and env: - if env.startswith(APP_SMAHT): - app = APP_SMAHT - elif env.startswith(APP_CGAP): - app = APP_CGAP - elif env.startswith(APP_FOURFRONT): - app = APP_FOURFRONT - if isinstance(portal, Portal): - self._vapp = portal._vapp - self._env = portal._env - self._app = portal._app - self._server = portal._server + arg: Optional[Union[Portal, TestApp, VirtualApp, PyramidRouter, dict, tuple, str]] = None, + env: Optional[str] = None, server: Optional[str] = None, + app: Optional[OrchestratedApp] = None) -> None: + + def init(unspecified: Optional[list] = []) -> None: + self._ini_file = None + self._key = None + self._key_pair = None + self._key_id = None + self._secret = None + self._keys_file = None + self._env = None + self._server = None + self._app = None + self._vapp = None + for arg in unspecified: + if arg is not None: + raise Exception("Portal init error; extraneous args.") + + def init_from_portal(portal: Portal, unspecified: Optional[list] = None) -> None: + init(unspecified) + self._ini_file = portal._ini_file self._key = portal._key self._key_pair = portal._key_pair - self._key_file = portal._key_file - return - self._vapp = None - self._env = env - self._app = app - self._server = server - self._key = None - self._key_pair = None - self._key_file = None - if isinstance(portal, (VirtualApp, TestApp)): - self._vapp = portal - elif isinstance(portal, (Router, str)): - self._vapp = Portal._create_vapp(portal) - elif isinstance(key, dict): - self._key = key - self._key_pair = (key.get("key"), key.get("secret")) if key else None - if key_server := key.get("server"): - self._server = key_server - elif isinstance(key, tuple) and len(key) >= 2: - self._key = {"key": key[0], "secret": key[1]} - self._key_pair = key - elif isinstance(env, str): - key_managers = {APP_CGAP: CGAPKeyManager, APP_FOURFRONT: FourfrontKeyManager, APP_SMAHT: SMaHTKeyManager} - if not (key_manager := key_managers.get(self._app)) or not (key_manager := key_manager()): - raise Exception(f"Invalid app name: {self._app} (valid: {', '.join(ORCHESTRATED_APPS)}).") - if isinstance(env, str): - self._key = key_manager.get_keydict_for_env(env) - if key_server := self._key.get("server"): - self._server = key_server - elif isinstance(self._server, str): - self._key = key_manager.get_keydict_for_server(self._server) - self._key_pair = key_manager.keydict_to_keypair(self._key) if self._key else None - self._key_file = key_manager.keys_file + self._key_id = portal._key_id + self._secret = portal._secret + self._keys_file = portal._keys_file + self._env = portal._env + self._server = portal._server + self._app = portal._app + self._vapp = portal._vapp - @property - def env(self): - return self._env + def init_from_vapp(vapp: Union[TestApp, VirtualApp, PyramidRouter], unspecified: Optional[list] = []) -> None: + init(unspecified) + self._vapp = Portal._create_vapp(vapp) - @property - def app(self): - return self._app + def init_from_ini_file(ini_file: str, unspecified: Optional[list] = []) -> None: + init(unspecified) + self._ini_file = ini_file + self._vapp = Portal._create_vapp(ini_file) + + def init_from_key(key: dict, server: Optional[str], unspecified: Optional[list] = []) -> None: + init(unspecified) + if (isinstance(key_id := key.get("key"), str) and key_id and + isinstance(secret := key.get("secret"), str) and secret): # noqa + self._key = {"key": key_id, "secret": secret} + self._key_id = key_id + self._secret = secret + self._key_pair = (key_id, secret) + if ((isinstance(server, str) and server) or (isinstance(server := key.get("server"), str) and server)): + if server := normalize_server(server): + self._key["server"] = self._server = server + if not self._key: + raise Exception("Portal init error; from key.") + + def init_from_key_pair(key_pair: tuple, server: Optional[str], unspecified: Optional[list] = []) -> None: + if len(key_pair) == 2: + init_from_key({"key": key_pair[0], "secret": key_pair[1]}, server, unspecified) + else: + raise Exception("Portal init error; from key-pair.") + + def init_from_keys_file(keys_file: str, env: Optional[str], server: Optional[str], + unspecified: Optional[list] = []) -> None: + try: + with io.open(keys_file) as f: + keys = json.load(f) + except Exception: + raise Exception(f"Portal init error; cannot open keys-file: {keys_file}") + if isinstance(env, str) and env and isinstance(key := keys.get(env), dict): + init_from_key(key, server) + self._keys_file = keys_file + self._env = env + elif (isinstance(server, str) and (server := normalize_server(server)) and + (key := [keys[k] for k in keys if normalize_server(keys[k].get("server")) == server])): + init_from_key(key[0], server) + self._keys_file = keys_file + elif len(keys) == 1 and (env := next(iter(keys))) and isinstance(key := keys[env], dict) and key: + init_from_key(key, server) + self._keys_file = keys_file + self._env = env + else: + raise Exception(f"Portal init error; {env or server or None} not found in keys-file: {keys_file}") + + def init_from_env_server_app(env: str, server: str, app: Optional[str], + unspecified: Optional[list] = None) -> None: + return init_from_keys_file(self._default_keys_file(app, env), env, server, unspecified) + + def normalize_server(server: str) -> Optional[str]: + prefix = "" + if (lserver := server.lower()).startswith("http://"): + prefix = "http://" + elif lserver.startswith("https://"): + prefix = "https://" + if prefix: + if (server := re.sub(r"/+", "/", server[len(prefix):])).startswith("/"): + server = server[1:] + if len(server) > 1 and server.endswith("/"): + server = server[:-1] + return prefix + server if server else None + + if isinstance(arg, Portal): + init_from_portal(arg, unspecified=[env, server, app]) + elif isinstance(arg, (TestApp, VirtualApp, PyramidRouter)): + init_from_vapp(arg, unspecified=[env, server, app]) + elif isinstance(arg, str) and arg.endswith(".ini"): + init_from_ini_file(arg, unspecified=[env, server, app]) + elif isinstance(arg, dict): + init_from_key(arg, server, unspecified=[env, app]) + elif isinstance(arg, tuple): + init_from_key_pair(arg, server, unspecified=[env, app]) + elif isinstance(arg, str) and arg.endswith(".json"): + init_from_keys_file(arg, env, server, unspecified=[app]) + elif isinstance(arg, str) and arg: + init_from_env_server_app(arg, server, app, unspecified=[env]) + elif (isinstance(env, str) and env) or (isinstance(server, str) and server): + init_from_env_server_app(env, server, app, unspecified=[arg]) + else: + raise Exception("Portal init error; invalid args.") @property - def server(self): - return self._server + def ini_file(self) -> Optional[str]: + return self._ini_file @property - def key(self): + def key(self) -> Optional[dict]: return self._key @property - def key_pair(self): + def key_pair(self) -> Optional[tuple]: return self._key_pair @property - def key_file(self): - return self._key_file + def key_id(self) -> Optional[str]: + return self._key_id @property - def vapp(self): + def secret(self) -> Optional[str]: + return self._secret + + @property + def keys_file(self) -> Optional[str]: + return self._keys_file + + @property + def env(self) -> Optional[str]: + return self._env + + @property + def server(self) -> Optional[str]: + return self._server + + @property + def app(self) -> Optional[str]: + return self._app + + @property + def vapp(self) -> Optional[TestApp]: return self._vapp + def get(self, url: str, follow: bool = True, **kwargs) -> OptionalResponse: + if not self._vapp: + return requests.get(self.url(url), allow_redirects=follow, **self._kwargs(**kwargs)) + response = self._vapp.get(self.url(url), **self._kwargs(**kwargs)) + if response and response.status_code in [301, 302, 303, 307, 308] and follow: + response = response.follow() + return self._response(response) + + def patch(self, url: str, data: Optional[dict] = None, json: Optional[dict] = None, **kwargs) -> OptionalResponse: + if not self._vapp: + return requests.patch(self.url(url), data=data, json=json, **self._kwargs(**kwargs)) + return self._response(self._vapp.patch_json(self.url(url), json or data, **self._kwargs(**kwargs))) + + def post(self, url: str, data: Optional[dict] = None, json: Optional[dict] = None, + files: Optional[dict] = None, **kwargs) -> OptionalResponse: + if not self._vapp: + return requests.post(self.url(url), data=data, json=json, files=files, **self._kwargs(**kwargs)) + if files: + response = self._vapp.post(self.url(url), json or data, upload_files=files, **self._kwargs(**kwargs)) + else: + response = self._vapp.post_json(self.url(url), json or data, upload_files=files, **self._kwargs(**kwargs)) + return self._response(response) + def get_metadata(self, object_id: str) -> Optional[dict]: return get_metadata(obj_id=object_id, vapp=self._vapp, key=self._key) def patch_metadata(self, object_id: str, data: str) -> Optional[dict]: if self._key: return patch_metadata(obj_id=object_id, patch_item=data, key=self._key) - return self.patch(f"/{object_id}", data) + return self.patch(f"/{object_id}", data).json() def post_metadata(self, object_type: str, data: str) -> Optional[dict]: if self._key: return post_metadata(schema_name=object_type, post_item=data, key=self._key) - return self.post(f"/{object_type}", data) - - def get(self, uri: str, follow: bool = True, **kwargs) -> Optional[Union[RequestResponse, TestResponse]]: - if isinstance(self._vapp, (VirtualApp, TestApp)): - response = self._vapp.get(self._uri(uri), **self._kwargs(**kwargs)) - if response and response.status_code in [301, 302, 303, 307, 308] and follow: - response = response.follow() - return self._response(response) - return requests.get(self._uri(uri), allow_redirects=follow, **self._kwargs(**kwargs)) - - def patch(self, uri: str, data: Optional[dict] = None, - json: Optional[dict] = None, **kwargs) -> Optional[Union[RequestResponse, TestResponse]]: - if isinstance(self._vapp, (VirtualApp, TestApp)): - return self._vapp.patch_json(self._uri(uri), json or data, **self._kwargs(**kwargs)) - return requests.patch(self._uri(uri), json=json or data, **self._kwargs(**kwargs)) - - def post(self, uri: str, data: Optional[dict] = None, json: Optional[dict] = None, - files: Optional[dict] = None, **kwargs) -> Optional[Union[RequestResponse, TestResponse]]: - if isinstance(self._vapp, (VirtualApp, TestApp)): - if files: - return self._vapp.post(self._uri(uri), json or data, upload_files=files, **self._kwargs(**kwargs)) - else: - return self._vapp.post_json(self._uri(uri), json or data, upload_files=files, **self._kwargs(**kwargs)) - return requests.post(self._uri(uri), json=json or data, files=files, **self._kwargs(**kwargs)) + return self.post(f"/{object_type}", data).json() + + def get_health(self) -> OptionalResponse: + return self.get("/health") + + def ping(self) -> bool: + try: + return self.get_health().status_code == 200 + except Exception: + return False def get_schema(self, schema_name: str) -> Optional[dict]: return get_schema(self.schema_name(schema_name), portal_vapp=self._vapp, key=self._key) @@ -168,11 +255,11 @@ def get_schemas(self) -> dict: @staticmethod def schema_name(name: str) -> str: - return to_camel_case(name) + return to_camel_case(name if not name.endswith(".json") else name[:-5]) if isinstance(name, str) else "" def is_file_schema(self, schema_name: str) -> bool: if super_type_map := self.get_schemas_super_type_map(): - if file_super_type := super_type_map.get(FILE_SCHEMA_NAME): + if file_super_type := super_type_map.get(Portal.FILE_SCHEMA_NAME): return self.schema_name(schema_name) in file_super_type return False @@ -182,7 +269,7 @@ def get_schemas_super_type_map(self) -> dict: This is a dictionary of all types which have (one or more) sub-types whose value is an array of all of those sub-types (direct and all descendents), in breadth first order. """ - def breadth_first(super_type_map: dict, super_type_name: str) -> dict: + def list_breadth_first(super_type_map: dict, super_type_name: str) -> dict: result = [] queue = deque(super_type_map.get(super_type_name, [])) while queue: @@ -203,16 +290,17 @@ def breadth_first(super_type_map: dict, super_type_name: str) -> dict: super_type_map[super_type_name].append(type_name) super_type_map_flattened = {} for super_type_name in super_type_map: - super_type_map_flattened[super_type_name] = breadth_first(super_type_map, super_type_name) + super_type_map_flattened[super_type_name] = list_breadth_first(super_type_map, super_type_name) return super_type_map_flattened - def _uri(self, uri: str) -> str: - if not isinstance(uri, str) or not uri: + def url(self, url: str) -> str: + if not isinstance(url, str) or not url: return "/" - if uri.lower().startswith("http://") or uri.lower().startswith("https://"): - return uri - uri = re.sub(r"/+", "/", uri) - return (self._server + ("/" if uri.startswith("/") else "") + uri) if self._server else uri + if (lurl := url.lower()).startswith("http://") or lurl.startswith("https://"): + return url + if not (url := re.sub(r"/+", "/", url)).startswith("/"): + url = "/" + return self._server + url if self._server else url def _kwargs(self, **kwargs) -> dict: result_kwargs = {"headers": @@ -223,9 +311,19 @@ def _kwargs(self, **kwargs) -> dict: result_kwargs["timeout"] = timeout return result_kwargs - def _response(self, response) -> Optional[RequestResponse]: + def _default_keys_file(self, app: Optional[str], env: Optional[str] = None) -> Optional[str]: + def is_valid_app(app: Optional[str]) -> bool: # noqa + return app and app.lower() in [name.lower() for name in ORCHESTRATED_APPS] + def infer_app_from_env(env: str) -> Optional[str]: # noqa + if isinstance(env, str) and (lenv := env.lower()): + if app := [app for app in ORCHESTRATED_APPS if lenv.startswith(app.lower())]: + return app[0] + if is_valid_app(app) or (app := infer_app_from_env(env)): + return os.path.join(Portal.KEYS_FILE_DIRECTORY, f".{app.lower()}-keys.json") + + def _response(self, response: TestResponse) -> TestResponse: if response and isinstance(getattr(response.__class__, "json"), property): - class RequestResponseWrapper: # For consistency change json property to method. + class TestResponseWrapper(TestResponse): # For consistency change json property to method. def __init__(self, response, **kwargs): super().__init__(**kwargs) self._response = response @@ -233,46 +331,88 @@ def __getattr__(self, attr): # noqa return getattr(self._response, attr) def json(self): # noqa return self._response.json - response = RequestResponseWrapper(response) + response = TestResponseWrapper(response) return response @staticmethod - def create_for_testing(ini_file: Optional[str] = None) -> Portal: - if isinstance(ini_file, str): - return Portal(Portal._create_vapp(ini_file)) - minimal_ini_for_unit_testing = "[app:app]\nuse = egg:encoded\nsqlalchemy.url = postgresql://dummy\n" - with temporary_file(content=minimal_ini_for_unit_testing, suffix=".ini") as ini_file: + def create_for_testing(arg: Optional[Union[str, bool, List[dict], dict, Callable]] = None) -> Portal: + if isinstance(arg, list) or isinstance(arg, dict) or isinstance(arg, Callable): + return Portal(Portal._create_router_for_testing(arg)) + if isinstance(arg, str) and arg.endswith(".ini"): + return Portal(Portal._create_vapp(arg)) + if arg == "local" or arg is True: + minimal_ini_for_testing = "\n".join([ + "[app:app]\nuse = egg:encoded\nfile_upload_bucket = dummy", + "sqlalchemy.url = postgresql://postgres@localhost:5441/postgres?host=/tmp/snovault/pgdata", + "multiauth.groupfinder = encoded.authorization.smaht_groupfinder", + "multiauth.policies = auth0 session remoteuser accesskey", + "multiauth.policy.session.namespace = mailto", + "multiauth.policy.session.use = encoded.authentication.NamespacedAuthenticationPolicy", + "multiauth.policy.session.base = pyramid.authentication.SessionAuthenticationPolicy", + "multiauth.policy.remoteuser.namespace = remoteuser", + "multiauth.policy.remoteuser.use = encoded.authentication.NamespacedAuthenticationPolicy", + "multiauth.policy.remoteuser.base = pyramid.authentication.RemoteUserAuthenticationPolicy", + "multiauth.policy.accesskey.namespace = accesskey", + "multiauth.policy.accesskey.use = encoded.authentication.NamespacedAuthenticationPolicy", + "multiauth.policy.accesskey.base = encoded.authentication.BasicAuthAuthenticationPolicy", + "multiauth.policy.accesskey.check = encoded.authentication.basic_auth_check", + "multiauth.policy.auth0.use = encoded.authentication.NamespacedAuthenticationPolicy", + "multiauth.policy.auth0.namespace = auth0", + "multiauth.policy.auth0.base = encoded.authentication.Auth0AuthenticationPolicy" + ]) + else: + minimal_ini_for_testing = "[app:app]\nuse = egg:encoded\nsqlalchemy.url = postgresql://dummy\n" + with temporary_file(content=minimal_ini_for_testing, suffix=".ini") as ini_file: return Portal(Portal._create_vapp(ini_file)) @staticmethod - def create_for_testing_local(ini_file: Optional[str] = None) -> Portal: - if isinstance(ini_file, str) and ini_file: - return Portal(Portal._create_vapp(ini_file)) - minimal_ini_for_testing_local = "\n".join([ - "[app:app]\nuse = egg:encoded\nfile_upload_bucket = dummy", - "sqlalchemy.url = postgresql://postgres@localhost:5441/postgres?host=/tmp/snovault/pgdata", - "multiauth.groupfinder = encoded.authorization.smaht_groupfinder", - "multiauth.policies = auth0 session remoteuser accesskey", - "multiauth.policy.session.namespace = mailto", - "multiauth.policy.session.use = encoded.authentication.NamespacedAuthenticationPolicy", - "multiauth.policy.session.base = pyramid.authentication.SessionAuthenticationPolicy", - "multiauth.policy.remoteuser.namespace = remoteuser", - "multiauth.policy.remoteuser.use = encoded.authentication.NamespacedAuthenticationPolicy", - "multiauth.policy.remoteuser.base = pyramid.authentication.RemoteUserAuthenticationPolicy", - "multiauth.policy.accesskey.namespace = accesskey", - "multiauth.policy.accesskey.use = encoded.authentication.NamespacedAuthenticationPolicy", - "multiauth.policy.accesskey.base = encoded.authentication.BasicAuthAuthenticationPolicy", - "multiauth.policy.accesskey.check = encoded.authentication.basic_auth_check", - "multiauth.policy.auth0.use = encoded.authentication.NamespacedAuthenticationPolicy", - "multiauth.policy.auth0.namespace = auth0", - "multiauth.policy.auth0.base = encoded.authentication.Auth0AuthenticationPolicy" - ]) - with temporary_file(content=minimal_ini_for_testing_local, suffix=".ini") as minimal_ini_file: - return Portal(Portal._create_vapp(minimal_ini_file)) + def _create_vapp(arg: Union[TestApp, VirtualApp, PyramidRouter, str] = None) -> TestApp: + if isinstance(arg, TestApp): + return arg + elif isinstance(arg, VirtualApp): + if not isinstance(arg.wrapped_app, TestApp): + raise Exception("Portal._create_vapp VirtualApp argument error.") + return arg.wrapped_app + if isinstance(arg, PyramidRouter): + router = arg + elif isinstance(arg, str) or not arg: + router = pyramid_get_app(arg or "development.ini", "app") + else: + raise Exception("Portal._create_vapp argument error.") + return TestApp(router, {"HTTP_ACCEPT": "application/json", "REMOTE_USER": "TEST"}) @staticmethod - def _create_vapp(value: Union[str, Router, TestApp] = "development.ini", app_name: str = "app") -> TestApp: - if isinstance(value, TestApp): - return value - app = value if isinstance(value, Router) else get_app(value, app_name) - return TestApp(app, {"HTTP_ACCEPT": "application/json", "REMOTE_USER": "TEST"}) + def _create_router_for_testing(endpoints: Optional[List[Dict[str, Union[str, Callable]]]] = None) -> PyramidRouter: + if isinstance(endpoints, dict): + endpoints = [endpoints] + elif isinstance(endpoints, Callable): + endpoints = [{"path": "/", "method": "GET", "function": endpoints}] + if not isinstance(endpoints, list) or not endpoints: + endpoints = [{"path": "/", "method": "GET", "function": lambda request: {"status": "OK"}}] + with PyramidConfigurator() as config: + nendpoints = 0 + for endpoint in endpoints: + if (endpoint_path := endpoint.get("path")) and (endpoint_function := endpoint.get("function")): + endpoint_method = endpoint.get("method", "GET") + def endpoint_wrapper(request): # noqa + response = endpoint_function(request) + return PyramidResponse(json.dumps(response), content_type="application/json; charset=utf-8") + endpoint_id = str(uuid()) + config.add_route(endpoint_id, endpoint_path) + config.add_view(endpoint_wrapper, route_name=endpoint_id, request_method=endpoint_method) + nendpoints += 1 + if nendpoints == 0: + return Portal._create_router_for_testing([]) + return config.make_wsgi_app() + + def start_for_testing(self, port: int = 7070, asynchronous: bool = False) -> Optional[Thread]: + if isinstance(self._vapp, TestApp) and hasattr(self._vapp, "app") and isinstance(self._vapp.app, PyramidRouter): + def start_server() -> None: # noqa + with wsgi_make_server("0.0.0.0", port or 7070, self._vapp.app) as server: + server.serve_forever() + if asynchronous: + server_thread = Thread(target=start_server) + server_thread.daemon = True + server_thread.start() + return server_thread + start_server() diff --git a/dcicutils/structured_data.py b/dcicutils/structured_data.py index de25ef114..a889ee3a3 100644 --- a/dcicutils/structured_data.py +++ b/dcicutils/structured_data.py @@ -42,23 +42,24 @@ class StructuredDataSet: def __init__(self, file: Optional[str] = None, portal: Optional[Union[VirtualApp, TestApp, Portal]] = None, - schemas: Optional[List[dict]] = None, data: Optional[List[dict]] = None, + schemas: Optional[List[dict]] = None, autoadd: Optional[dict] = None, order: Optional[List[str]] = None, prune: bool = True) -> None: - self.data = {} if not data else data # If portal is None then no schemas nor refs. + self.data = {} self._portal = Portal(portal, data=self.data, schemas=schemas) if portal else None self._order = order self._prune = prune self._warnings = {} self._errors = {} - self._resolved_refs = [] + self._resolved_refs = set() self._validated = False + self._autoadd_properties = autoadd if isinstance(autoadd, dict) and autoadd else None self._load_file(file) if file else None @staticmethod def load(file: str, portal: Optional[Union[VirtualApp, TestApp, Portal]] = None, - schemas: Optional[List[dict]] = None, + schemas: Optional[List[dict]] = None, autoadd: Optional[dict] = None, order: Optional[List[str]] = None, prune: bool = True) -> StructuredDataSet: - return StructuredDataSet(file=file, portal=portal, schemas=schemas, order=order, prune=prune) + return StructuredDataSet(file=file, portal=portal, schemas=schemas, autoadd=autoadd, order=order, prune=prune) def validate(self, force: bool = False) -> None: if self._validated and not force: @@ -96,7 +97,7 @@ def validation_errors(self) -> List[dict]: @property def resolved_refs(self) -> List[str]: - return self._resolved_refs + return list(self._resolved_refs) @property def upload_files(self) -> List[str]: @@ -112,7 +113,7 @@ def upload_files(self) -> List[str]: def _load_file(self, file: str) -> None: # Returns a dictionary where each property is the name (i.e. the type) of the data, # and the value is array of dictionaries for the data itself. Handle these kinds of files: - # 1. Single CSV of JSON file, where the (base) name of the file is the data type name. + # 1. Single CSV, TSV, or JSON file, where the (base) name of the file is the data type name. # 2. Single Excel file containing one or more sheets, where each sheet # represents (i.e. is named for, and contains data for) a different type. # 3. Zip file (.zip or .tar.gz or .tgz or .tar), containing data files to load, where the @@ -163,11 +164,13 @@ def _load_reader(self, reader: RowReader, type_name: str) -> None: structured_row = structured_row_template.create_row() for column_name, value in row.items(): structured_row_template.set_value(structured_row, column_name, value, reader.file, reader.row_number) + if self._autoadd_properties: + self._add_properties(structured_row, self._autoadd_properties, schema) self._add(type_name, structured_row) self._note_warning(reader.warnings, "reader") if schema: self._note_error(schema._unresolved_refs, "ref") - self._resolved_refs = schema._resolved_refs + self._resolved_refs.update(schema._resolved_refs) def _add(self, type_name: str, data: Union[dict, List[dict]]) -> None: if self._prune: @@ -177,6 +180,11 @@ def _add(self, type_name: str, data: Union[dict, List[dict]]) -> None: else: self.data[type_name] = [data] if isinstance(data, dict) else data + def _add_properties(self, structured_row: dict, properties: dict, schema: Optional[dict] = None) -> None: + for name in properties: + if name not in structured_row and (not schema or schema.data.get("properties", {}).get(name)): + structured_row[name] = properties[name] + def _note_warning(self, item: Optional[Union[dict, List[dict]]], group: str) -> None: self._note_issue(self._warnings, item, group) @@ -237,7 +245,7 @@ def parse_components(column_components: List[str], path: List[Union[str, int]]) return {array_name: array} if array_name else {column_component: value} def set_value_internal(data: Union[dict, list], value: Optional[Any], src: Optional[str], - path: List[Union[str, int]], mapv: Optional[Callable]) -> None: + path: List[Union[str, int]], typeinfo: Optional[dict], mapv: Optional[Callable]) -> None: def set_value_backtrack_object(path_index: int, path_element: str) -> None: nonlocal data, path, original_data @@ -257,7 +265,7 @@ def set_value_backtrack_object(path_index: int, path_element: str) -> None: set_value_backtrack_object(i, p) data = data[p] if (p := path[-1]) == -1 and isinstance(value, str): - values = _split_array_string(value) + values = _split_array_string(value, unique=typeinfo.get("unique") if typeinfo else False) if mapv: values = [mapv(value, src) for value in values] merge_objects(data, values) @@ -288,11 +296,13 @@ def ensure_column_consistency(column_name: str) -> None: for column_name in column_names or []: ensure_column_consistency(column_name) rational_column_name = self._schema.rationalize_column_name(column_name) if self._schema else column_name - map_value_function = self._schema.get_map_value_function(rational_column_name) if self._schema else None + column_typeinfo = self._schema.get_typeinfo(rational_column_name) if self._schema else None + map_value_function = column_typeinfo.get("map") if column_typeinfo else None if (column_components := _split_dotted_string(rational_column_name)): merge_objects(structured_row_template, parse_components(column_components, path := []), True) - self._set_value_functions[column_name] = (lambda data, value, src, path=path, mapv=map_value_function: - set_value_internal(data, value, src, path, mapv)) + self._set_value_functions[column_name] = ( + lambda data, value, src, path=path, typeinfo=column_typeinfo, mapv=map_value_function: + set_value_internal(data, value, src, path, typeinfo, mapv)) return structured_row_template @@ -315,7 +325,8 @@ def __init__(self, schema_json: dict, portal: Optional[Portal] = None) -> None: @staticmethod def load_by_name(name: str, portal: Portal) -> Optional[dict]: - return Schema(portal.get_schema(Schema.type_name(name)), portal) if portal else None + schema_json = portal.get_schema(Schema.type_name(name)) if portal else None + return Schema(schema_json, portal) if schema_json else None def validate(self, data: dict) -> List[str]: errors = [] @@ -331,10 +342,7 @@ def unresolved_refs(self) -> List[dict]: def resolved_refs(self) -> List[str]: return list(self._resolved_refs) - def get_map_value_function(self, column_name: str) -> Optional[Any]: - return (self._get_typeinfo(column_name) or {}).get("map") - - def _get_typeinfo(self, column_name: str) -> Optional[dict]: + def get_typeinfo(self, column_name: str) -> Optional[dict]: if isinstance(info := self._typeinfo.get(column_name), str): info = self._typeinfo.get(info) if not info and isinstance(info := self._typeinfo.get(self.unadorn_column_name(column_name)), str): @@ -467,9 +475,14 @@ def _create_typeinfo(self, schema_json: dict, parent_key: Optional[str] = None) raise Exception(f"Array of undefined or multiple types in JSON schema NOT supported: {key}") raise Exception(f"Invalid array type specifier in JSON schema: {key}") key = key + ARRAY_NAME_SUFFIX_CHAR + if unique := (property_value.get("uniqueItems") is True): + pass property_value = array_property_items property_value_type = property_value.get("type") - result.update(self._create_typeinfo(array_property_items, parent_key=key)) + typeinfo = self._create_typeinfo(array_property_items, parent_key=key) + if unique: + typeinfo[key]["unique"] = True + result.update(typeinfo) continue result[key] = {"type": property_value_type, "map": self._map_function({**property_value, "column": key})} if ARRAY_NAME_SUFFIX_CHAR in key: @@ -537,16 +550,13 @@ class Portal(PortalBase): def __init__(self, arg: Optional[Union[VirtualApp, TestApp, Router, Portal, dict, tuple, str]] = None, - env: Optional[str] = None, app: OrchestratedApp = None, server: Optional[str] = None, - key: Optional[Union[dict, tuple]] = None, - portal: Optional[Union[VirtualApp, TestApp, Router, Portal, str]] = None, - data: Optional[dict] = None, schemas: Optional[List[dict]] = None) -> Optional[Portal]: - super().__init__(arg, env=env, app=app, server=server, key=key, portal=portal) - if isinstance(arg, Portal) and not portal: - portal = arg - if isinstance(portal, Portal): - self._schemas = schemas if schemas is not None else portal._schemas # Explicitly specified/known schemas. - self._data = data if data is not None else portal._data # Data set being loaded; e.g. by StructuredDataSet. + env: Optional[str] = None, server: Optional[str] = None, + app: Optional[OrchestratedApp] = None, + data: Optional[dict] = None, schemas: Optional[List[dict]] = None) -> None: + super().__init__(arg, env=env, server=server, app=app) + if isinstance(arg, Portal): + self._schemas = schemas if schemas is not None else arg._schemas # Explicitly specified/known schemas. + self._data = data if data is not None else arg._data # Data set being loaded; e.g. by StructuredDataSet. else: self._schemas = schemas self._data = data @@ -560,7 +570,9 @@ def get_metadata(self, object_name: str) -> Optional[dict]: @lru_cache(maxsize=256) def get_schema(self, schema_name: str) -> Optional[dict]: - if (schemas := self.get_schemas()) and (schema := schemas.get(schema_name := Schema.type_name(schema_name))): + if not (schemas := self.get_schemas()): + return None + if schema := schemas.get(schema_name := Schema.type_name(schema_name)): return schema if schema_name == schema_name.upper() and (schema := schemas.get(schema_name.lower().title())): return schema @@ -568,8 +580,9 @@ def get_schema(self, schema_name: str) -> Optional[dict]: return schema @lru_cache(maxsize=1) - def get_schemas(self) -> dict: - schemas = super().get_schemas() + def get_schemas(self) -> Optional[dict]: + if not (schemas := super().get_schemas()) or (schemas.get("status") == "error"): + return None if self._schemas: schemas = copy.deepcopy(schemas) for user_specified_schema in self._schemas: @@ -603,17 +616,14 @@ def _ref_exists_single(self, type_name: str, value: str) -> bool: return self.get_metadata(f"/{type_name}/{value}") is not None @staticmethod - def create_for_testing(ini_file: Optional[str] = None, schemas: Optional[List[dict]] = None) -> Portal: - return Portal(PortalBase.create_for_testing(ini_file), schemas=schemas) - - @staticmethod - def create_for_testing_local(ini_file: Optional[str] = None, schemas: Optional[List[dict]] = None) -> Portal: - return Portal(PortalBase.create_for_testing_local(ini_file), schemas=schemas) + def create_for_testing(arg: Optional[Union[str, bool, List[dict], dict, Callable]] = None, + schemas: Optional[List[dict]] = None) -> Portal: + return Portal(PortalBase.create_for_testing(arg), schemas=schemas) def _split_dotted_string(value: str): return split_string(value, DOTTED_NAME_DELIMITER_CHAR) -def _split_array_string(value: str): - return split_string(value, ARRAY_VALUE_DELIMITER_CHAR, ARRAY_VALUE_DELIMITER_ESCAPE_CHAR) +def _split_array_string(value: str, unique: bool = False): + return split_string(value, ARRAY_VALUE_DELIMITER_CHAR, ARRAY_VALUE_DELIMITER_ESCAPE_CHAR, unique=unique) diff --git a/dcicutils/tmpfile_utils.py b/dcicutils/tmpfile_utils.py new file mode 100644 index 000000000..ba1652cb6 --- /dev/null +++ b/dcicutils/tmpfile_utils.py @@ -0,0 +1,36 @@ +from contextlib import contextmanager +import os +import shutil +import tempfile +from typing import List, Optional, Union + + +@contextmanager +def temporary_directory() -> str: + try: + with tempfile.TemporaryDirectory() as tmp_directory_name: + yield tmp_directory_name + finally: + remove_temporary_directory(tmp_directory_name) + + +@contextmanager +def temporary_file(name: Optional[str] = None, suffix: Optional[str] = None, + content: Optional[Union[str, bytes, List[str]]] = None) -> str: + with temporary_directory() as tmp_directory_name: + tmp_file_name = os.path.join(tmp_directory_name, name or tempfile.mktemp(dir="")) + (suffix or "") + if content is not None: + with open(tmp_file_name, "wb" if isinstance(content, bytes) else "w") as tmp_file: + tmp_file.write("\n".join(content) if isinstance(content, list) else content) + yield tmp_file_name + + +def remove_temporary_directory(tmp_directory_name: str) -> None: + def is_temporary_directory(path: str) -> bool: + try: + tmpdir = tempfile.gettempdir() + return os.path.commonpath([path, tmpdir]) == tmpdir and os.path.exists(path) and os.path.isdir(path) + except Exception: + return False + if is_temporary_directory(tmp_directory_name): # Guard against errant deletion. + shutil.rmtree(tmp_directory_name) diff --git a/dcicutils/zip_utils.py b/dcicutils/zip_utils.py index e98e6ac70..8a65f8abe 100644 --- a/dcicutils/zip_utils.py +++ b/dcicutils/zip_utils.py @@ -1,10 +1,9 @@ from contextlib import contextmanager +from dcicutils.tmpfile_utils import temporary_directory, temporary_file import gzip import os -import shutil import tarfile -import tempfile -from typing import List, Optional, Union +from typing import List, Optional import zipfile @@ -46,34 +45,3 @@ def unpack_gz_file_to_temporary_file(file: str, suffix: Optional[str] = None) -> outputf.write(inputf.read()) outputf.close() yield tmp_file_name - - -@contextmanager -def temporary_directory() -> str: - try: - with tempfile.TemporaryDirectory() as tmp_directory_name: - yield tmp_directory_name - finally: - remove_temporary_directory(tmp_directory_name) - - -@contextmanager -def temporary_file(name: Optional[str] = None, suffix: Optional[str] = None, - content: Optional[Union[str, bytes, List[str]]] = None) -> str: - with temporary_directory() as tmp_directory_name: - tmp_file_name = os.path.join(tmp_directory_name, name or tempfile.mktemp(dir="")) + (suffix or "") - if content is not None: - with open(tmp_file_name, "wb" if isinstance(content, bytes) else "w") as tmp_file: - tmp_file.write("\n".join(content) if isinstance(content, list) else content) - yield tmp_file_name - - -def remove_temporary_directory(tmp_directory_name: str) -> None: - def is_temporary_directory(path: str) -> bool: - try: - tmpdir = tempfile.gettempdir() - return os.path.commonpath([path, tmpdir]) == tmpdir and os.path.exists(path) and os.path.isdir(path) - except Exception: - return False - if is_temporary_directory(tmp_directory_name): # Guard against errant deletion. - shutil.rmtree(tmp_directory_name) diff --git a/docs/source/dcicutils.rst b/docs/source/dcicutils.rst index 37da22490..470e11828 100644 --- a/docs/source/dcicutils.rst +++ b/docs/source/dcicutils.rst @@ -337,6 +337,13 @@ task_utils :members: +tmpfile_utils +^^^^^^^^^^^^^ + +.. automodule:: dcicutils.tmpfile_utils + :members: + + trace_utils ^^^^^^^^^^^ diff --git a/pyproject.toml b/pyproject.toml index 9a9e81594..e7fa23cfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dcicutils" -version = "8.5.0" +version = "8.6.0" description = "Utility package for interacting with the 4DN Data Portal and other 4DN resources" authors = ["4DN-DCIC Team "] license = "MIT" diff --git a/test/test_license_utils.py b/test/test_license_utils.py index e0d0fdc25..80fa64a62 100644 --- a/test/test_license_utils.py +++ b/test/test_license_utils.py @@ -759,13 +759,13 @@ def mocked_license_logger(message): # Test that with no analysis argument, problems get sent out as warnings LicenseFileParser.validate_simple_license_file(filename='LICENSE.txt') - assert license_warnings == ["The copyright year, '2020', should have '2023' at the end."] + assert license_warnings == ["The copyright year, '2020', should have '2024' at the end."] # Test that with an analysis argument, problems get summarized to that object analysis = LicenseAnalysis() license_warnings = [] LicenseFileParser.validate_simple_license_file(filename='LICENSE.txt', analysis=analysis) - assert analysis.miscellaneous == ["The copyright year, '2020', should have '2023' at the end."] + assert analysis.miscellaneous == ["The copyright year, '2020', should have '2024' at the end."] assert license_warnings == [] diff --git a/test/test_misc_utils.py b/test/test_misc_utils.py index 6bf0afebe..c3f5f0ba3 100644 --- a/test/test_misc_utils.py +++ b/test/test_misc_utils.py @@ -31,12 +31,14 @@ ObsoleteError, CycleError, TopologicalSorter, keys_and_values_to_dict, dict_to_keys_and_values, is_c4_arn, deduplicate_list, chunked, parse_in_radix, format_in_radix, managed_property, future_datetime, MIN_DATETIME, MIN_DATETIME_UTC, INPUT, builtin_print, map_chunked, to_camel_case, json_file_contents, - pad_to, JsonLinesReader, split_string, merge_objects + pad_to, JsonLinesReader, split_string, merge_objects, to_integer, + load_json_from_file_expanding_environment_variables ) from dcicutils.qa_utils import ( Occasionally, ControlledTime, override_environ as qa_override_environ, MockFileSystem, printed_output, raises_regexp, MockId, MockLog, input_series, ) +from dcicutils.tmpfile_utils import temporary_file from typing import Any, Dict, List from unittest import mock @@ -3593,8 +3595,8 @@ def test_json_lines_reader_lists(): def test_split_array_string(): - def split_array_string(value: str) -> List[str]: - return split_string(value, "|", "\\") + def split_array_string(value: str, unique: bool = False) -> List[str]: + return split_string(value, "|", "\\", unique=unique) assert split_array_string(r"abc|def|ghi") == ["abc", "def", "ghi"] assert split_array_string(r"abc\|def|ghi") == ["abc|def", "ghi"] assert split_array_string(r"abc\\|def|ghi") == ["abc\\", "def", "ghi"] @@ -3609,6 +3611,12 @@ def split_array_string(value: str) -> List[str]: assert split_array_string(r"|") == [] assert split_array_string(r"\|") == ["|"] assert split_array_string(r"\\|") == ["\\"] + assert split_array_string(r"abc|def|abc|ghi", unique=False) == ["abc", "def", "abc", "ghi"] + assert split_array_string(r"abc|def|abc|ghi", unique=True) == ["abc", "def", "ghi"] + assert split_array_string(r"abc\\\|def\|ghi|jkl|mno|jkl", unique=False) == ["abc\\|def|ghi", "jkl", "mno", "jkl"] + assert split_array_string(r"abc\\\|def\|ghi|jkl|mno|jkl", unique=True) == ["abc\\|def|ghi", "jkl", "mno"] + assert split_string(r"abc|def|ghi|def", delimiter="|", unique=False) == ["abc", "def", "ghi", "def"] + assert split_string(r"abc|def|ghi|def", delimiter="|", unique=True) == ["abc", "def", "ghi"] def test_merge_objects_1(): @@ -3674,3 +3682,22 @@ def test_merge_objects_8(): "xyzzy": [{"foo": None}, {"goo": None}, {"hoo": None}, {"hoo": None}, {"hoo": None}]} merge_objects(target, source, True) assert target == expected + + +def test_to_integer(): + assert to_integer("17") == 17 + assert to_integer("17.0") == 17 + assert to_integer("17.1") == 17 + assert to_integer("17.9", "123") == 17 + assert to_integer("0") == 0 + assert to_integer("0.0") == 0 + assert to_integer("asdf") is None + assert to_integer("asdf", "myfallback") == "myfallback" + + +def test_load_json_from_file_expanding_environment_variables(): + with mock.patch.object(os, "environ", {"Auth0Secret": "dgakjhdgretqobv", "SomeEnvVar": "xyzzy"}): + some_json = {"Auth0Secret": "${Auth0Secret}", "abc": "def", "someproperty": "$SomeEnvVar"} + with temporary_file(content=json.dumps(some_json), suffix=".json") as tmpfile: + expanded_json = load_json_from_file_expanding_environment_variables(tmpfile) + assert expanded_json == {"Auth0Secret": "dgakjhdgretqobv", "abc": "def", "someproperty": "xyzzy"} diff --git a/test/test_portal_utils.py b/test/test_portal_utils.py new file mode 100644 index 000000000..b9e12dfc3 --- /dev/null +++ b/test/test_portal_utils.py @@ -0,0 +1,137 @@ +import json +import os +from dcicutils.portal_utils import Portal +from dcicutils.zip_utils import temporary_file + + +_TEST_KEY_ID = "TTVJOW2A" +_TEST_SECRET = "3fbswrice6xosnjw" +_TEST_KEY = {"key": _TEST_KEY_ID, "secret": _TEST_SECRET} +_TEST_KEY_PAIR = (_TEST_KEY_ID, _TEST_SECRET) + + +def test_portal_constructor_a(): + + def assert_for_server(server, expected): + + portal = Portal(_TEST_KEY, server=server) if server is not None else Portal(_TEST_KEY) + assert portal.key_id == _TEST_KEY_ID + assert portal.secret == _TEST_SECRET + assert portal.key_pair == _TEST_KEY_PAIR + assert portal.server == expected + assert portal.keys_file is None + assert portal.app is None + assert portal.env is None + assert portal.vapp is None + + portal = Portal({**_TEST_KEY, "server": server}) if server is not None else Portal(_TEST_KEY) + assert portal.key_id == _TEST_KEY_ID + assert portal.secret == _TEST_SECRET + assert portal.key_pair == _TEST_KEY_PAIR + assert portal.server == expected + assert portal.keys_file is None + assert portal.app is None + assert portal.env is None + assert portal.vapp is None + + assert_for_server(None, None) + assert_for_server("http://localhost:8000", "http://localhost:8000") + assert_for_server("http://localhost:8000/", "http://localhost:8000") + assert_for_server("hTtP://localhost:8000//", "http://localhost:8000") + assert_for_server("hTtP://localhost:8000//", "http://localhost:8000") + assert_for_server("Http://xyzzy.com//abc/", "http://xyzzy.com/abc") + assert_for_server("xhttp://localhost:8000", None) + assert_for_server("http:/localhost:8000", None) + + +def test_portal_constructor_b(): + + keys_file_content = json.dumps({ + "smaht-local": { + "key": "ABCDEFGHI", + "secret": "adfxdloiebvhzp", + "server": "http://localhost:8080/" + }, + "smaht-remote": { + "key": "GHIDEFABC", + "secret": "zpadfxdloiebvh", + "server": "https://smaht.hms.harvard.edu/" + } + }, indent=4) + + with temporary_file(name=".smaht-keys.json", content=keys_file_content) as keys_file: + + portal = Portal(keys_file, env="smaht-local") + assert portal.key_id == "ABCDEFGHI" + assert portal.secret == "adfxdloiebvhzp" + assert portal.key_pair == ("ABCDEFGHI", "adfxdloiebvhzp") + assert portal.server == "http://localhost:8080" + assert portal.key == {"key": "ABCDEFGHI", "secret": "adfxdloiebvhzp", "server": "http://localhost:8080"} + assert portal.keys_file == keys_file + assert portal.env == "smaht-local" + assert portal.app is None + assert portal.vapp is None + assert portal.ini_file is None + + portal = Portal(keys_file, env="smaht-remote") + assert portal.key_id == "GHIDEFABC" + assert portal.secret == "zpadfxdloiebvh" + assert portal.key_pair == ("GHIDEFABC", "zpadfxdloiebvh") + assert portal.server == "https://smaht.hms.harvard.edu" + assert portal.key == {"key": "GHIDEFABC", "secret": "zpadfxdloiebvh", "server": "https://smaht.hms.harvard.edu"} + assert portal.keys_file == keys_file + assert portal.env == "smaht-remote" + assert portal.app is None + assert portal.vapp is None + assert portal.ini_file is None + + Portal.KEYS_FILE_DIRECTORY = os.path.dirname(keys_file) + portal = Portal("smaht-local", app="smaht") + assert portal.key_id == "ABCDEFGHI" + assert portal.secret == "adfxdloiebvhzp" + assert portal.key_pair == ("ABCDEFGHI", "adfxdloiebvhzp") + assert portal.server == "http://localhost:8080" + assert portal.key == {"key": "ABCDEFGHI", "secret": "adfxdloiebvhzp", "server": "http://localhost:8080"} + assert portal.keys_file == keys_file + assert portal.env == "smaht-local" + assert portal.app is None + assert portal.vapp is None + assert portal.ini_file is None + + +def test_portal_constructor_c(): + + keys_file_content = json.dumps({ + "cgap-local": { + "key": "ABCDEFGHI", + "secret": "adfxdloiebvhzp", + "server": "http://localhost:8080/" + } + }, indent=4) + + with temporary_file(name=".cgap-keys.json", content=keys_file_content) as keys_file: + + portal = Portal(keys_file) + assert portal.key_id == "ABCDEFGHI" + assert portal.secret == "adfxdloiebvhzp" + assert portal.key_pair == ("ABCDEFGHI", "adfxdloiebvhzp") + assert portal.server == "http://localhost:8080" + assert portal.key == {"key": "ABCDEFGHI", "secret": "adfxdloiebvhzp", "server": "http://localhost:8080"} + assert portal.keys_file == keys_file + assert portal.env == "cgap-local" + assert portal.app is None + assert portal.vapp is None + assert portal.ini_file is None + + portal_copy = Portal(portal) + assert portal.ini_file == portal_copy.ini_file + assert portal.key == portal_copy.key + assert portal.key_pair == portal_copy.key_pair + assert portal.key_id == portal_copy.key_id + assert portal.secret == portal_copy.secret + assert portal.keys_file == portal_copy.keys_file + assert portal.env == portal_copy.env + assert portal.server == portal_copy.server + assert portal.app == portal_copy.app + assert portal.vapp == portal_copy.vapp + assert portal.secret == portal_copy.secret