diff --git a/.gitignore b/.gitignore index a54596f9..ff527bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ /rsconnect/version.py htmlcov /tests/testdata/**/rsconnect-python/ -test-home/ /docs/docs/index.md /docs/docs/changelog.md /rsconnect-build diff --git a/requirements.txt b/requirements.txt index 1baec990..5a53544e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ click>=7.0.0 coverage flake8 funcsigs -httpretty==1.1.4 importlib-metadata ipykernel ipython diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 55fdbc94..a7cfca04 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -218,15 +218,6 @@ def test_server(connect_server): raise RSConnectException("\n".join(failures)) -def test_shinyapps_server(server: api.ShinyappsServer): - with api.ShinyappsClient(server) as client: - try: - result = client.get_current_user() - server.handle_bad_response(result) - except RSConnectException as exc: - raise RSConnectException("Failed to verify with shinyapps.io ({}).".format(exc)) - - def test_api_key(connect_server): """ Test that an API Key may be used to authenticate with the given RStudio Connect server. @@ -322,7 +313,7 @@ def check_server_capabilities(connect_server, capability_functions, details_sour raise RSConnectException(message) -def _make_deployment_name(remote_server: api.TargetableServer, title: str, force_unique: bool) -> str: +def _make_deployment_name(connect_server, title, force_unique) -> str: """ Produce a name for a deployment based on its title. It is assumed that the title is already defaulted and validated as appropriate (meaning the title @@ -333,7 +324,7 @@ def _make_deployment_name(remote_server: api.TargetableServer, title: str, force that we collapse repeating underscores and, if the name is too short, it is padded to the left with underscores. - :param remote_server: the information needed to interact with the Connect server. + :param connect_server: the information needed to interact with the Connect server. :param title: the title to start with. :param force_unique: a flag noting whether the generated name must be forced to be unique. @@ -347,7 +338,7 @@ def _make_deployment_name(remote_server: api.TargetableServer, title: str, force # Now, make sure it's unique, if needed. if force_unique: - name = api.find_unique_name(remote_server, name) + name = api.find_unique_name(connect_server, name) return name @@ -1456,7 +1447,7 @@ def _generate_gather_basic_deployment_info_for_python(app_mode: AppMode) -> typi """ def gatherer( - remote_server: api.TargetableServer, + connect_server: api.RSConnectServer, app_store: AppStore, directory: str, entry_point: str, @@ -1465,7 +1456,7 @@ def gatherer( title: str, ) -> typing.Tuple[str, int, str, str, bool, AppMode]: return _gather_basic_deployment_info_for_framework( - remote_server, + connect_server, app_store, directory, entry_point, @@ -1486,7 +1477,7 @@ def gatherer( def _gather_basic_deployment_info_for_framework( - remote_server: api.TargetableServer, + connect_server: api.RSConnectServer, app_store: AppStore, directory: str, entry_point: str, @@ -1498,7 +1489,7 @@ def _gather_basic_deployment_info_for_framework( """ Helps to gather the necessary info for performing a deployment. - :param remote_server: the server information. + :param connect_server: the Connect server information. :param app_store: the store for the specified directory. :param directory: the primary file being deployed. :param entry_point: the entry point for the API in ': format. if @@ -1523,19 +1514,13 @@ def _gather_basic_deployment_info_for_framework( if app_id is None: # Possible redeployment - check for saved metadata. # Use the saved app information unless overridden by the user. - app_id, existing_app_mode = app_store.resolve(remote_server.url, app_id, app_mode) + app_id, existing_app_mode = app_store.resolve(connect_server.url, app_id, app_mode) logger.debug("Using app mode from app %s: %s" % (app_id, app_mode)) elif app_id is not None: # Don't read app metadata if app-id is specified. Instead, we need # to get this from Connect. - if isinstance(remote_server, api.RSConnectServer): - app = api.get_app_info(remote_server, app_id) - existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) - elif isinstance(remote_server, api.ShinyappsServer): - app = api.get_shinyapp_info(remote_server, app_id) - existing_app_mode = AppModes.get_by_cloud_name(app.json_data["mode"]) - else: - raise RSConnectException("Unable to infer Connect client.") + app = api.get_app_info(connect_server, app_id) + existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) if existing_app_mode and app_mode != existing_app_mode: msg = ( "Deploying with mode '%s',\n" @@ -1553,7 +1538,7 @@ def _gather_basic_deployment_info_for_framework( return ( entry_point, app_id, - _make_deployment_name(remote_server, title, app_id is None), + _make_deployment_name(connect_server, title, app_id is None), title, default_title, app_mode, @@ -1712,7 +1697,7 @@ def create_quarto_deployment_bundle( def deploy_bundle( - remote_server: api.TargetableServer, + connect_server: api.RSConnectServer, app_id: int, name: str, title: str, @@ -1723,7 +1708,7 @@ def deploy_bundle( """ Deploys the specified bundle. - :param remote_server: the server information. + :param connect_server: the Connect server information. :param app_id: the ID of the app to deploy, if this is a redeploy. :param name: the name for the deploy. :param title: the title for the deploy. @@ -1733,17 +1718,7 @@ def deploy_bundle( :return: application information about the deploy. This includes the ID of the task that may be queried for deployment progress. """ - ce = RSConnectExecutor( - server=remote_server, - app_id=app_id, - name=name, - title=title, - title_is_default=title_is_default, - bundle=bundle, - env_vars=env_vars, - ) - ce.deploy_bundle() - return ce.state["deployed_info"] + return api.do_bundle_deploy(connect_server, app_id, name, title, title_is_default, bundle, env_vars) def spool_deployment_log(connect_server, app, log_callback): diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index 906d3566..509bdb57 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -4,11 +4,13 @@ import json import time import traceback + from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta + import semver -from .api import RSConnectClient, emit_task_log +from .api import RSConnect, emit_task_log from .log import logger from .models import BuildStatus, ContentGuidWithBundle from .metadata import ContentBuildStore @@ -35,7 +37,7 @@ def build_add_content(connect_server, content_guids_with_bundle): + "please wait for it to finish before adding new content." ) - with RSConnectClient(connect_server, timeout=120) as client: + with RSConnect(connect_server, timeout=120) as client: if len(content_guids_with_bundle) == 1: all_content = [client.content_get(content_guids_with_bundle[0].guid)] else: @@ -226,7 +228,7 @@ def _monitor_build(connect_server, content_items): def _build_content_item(connect_server, content, poll_wait): init_content_build_store(connect_server) - with RSConnectClient(connect_server) as client: + with RSConnect(connect_server) as client: # Pending futures will still try to execute when ThreadPoolExecutor.shutdown() is called # so just exit immediately if the current build has been aborted. # ThreadPoolExecutor.shutdown(cancel_futures=) isnt available until py3.9 @@ -290,7 +292,7 @@ def download_bundle(connect_server, guid_with_bundle): """ :param guid_with_bundle: models.ContentGuidWithBundle """ - with RSConnectClient(connect_server, timeout=120) as client: + with RSConnect(connect_server, timeout=120) as client: # bundle_id not provided so grab the latest if not guid_with_bundle.bundle_id: content = client.get_content(guid_with_bundle.guid) @@ -309,7 +311,7 @@ def get_content(connect_server, guid): :param guid: a single guid as a string or list of guids. :return: a list of content items. """ - with RSConnectClient(connect_server, timeout=120) as client: + with RSConnect(connect_server, timeout=120) as client: if isinstance(guid, str): result = [client.get_content(guid)] else: @@ -320,7 +322,7 @@ def get_content(connect_server, guid): def search_content( connect_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by ): - with RSConnectClient(connect_server, timeout=120) as client: + with RSConnect(connect_server, timeout=120) as client: result = client.search_content() result = _apply_content_filters( result, published, unpublished, content_type, r_version, py_version, title_contains diff --git a/rsconnect/api.py b/rsconnect/api.py index ac5078f3..224f5d3e 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1,37 +1,36 @@ """ RStudio Connect API client and utility functions """ -from os.path import abspath + +from os.path import abspath, basename import time from typing import IO, Callable -import base64 -import datetime -import hashlib -import hmac -import typing -import webbrowser from _ssl import SSLError -from urllib import parse -from urllib.parse import urlparse - import re from warnings import warn from six import text_type import gc - -from . import validation +from .bundle import fake_module_file_from_directory from .http_support import HTTPResponse, HTTPServer, append_to_path, CookieJar from .log import logger, connect_logger, cls_logged, console_logger from .models import AppModes from .metadata import ServerStore, AppStore from .exception import RSConnectException -from .bundle import _default_title, fake_module_file_from_directory -class AbstractRemoteServer: - def __init__(self, url: str, remote_name: str): +class RSConnectServer(object): + """ + A simple class to encapsulate the information needed to interact with an + instance of the Connect server. + """ + + def __init__(self, url, api_key, insecure=False, ca_data=None): self.url = url - self.remote_name = remote_name + self.api_key = api_key + self.insecure = insecure + self.ca_data = ca_data + # This is specifically not None. + self.cookie_jar = CookieJar() def handle_bad_response(self, response): if isinstance(response, HTTPResponse): @@ -43,67 +42,21 @@ def handle_bad_response(self, response): # search page so trap that since we know we're expecting JSON from Connect. This # also catches all error conditions which we will report as "not running Connect". else: - if response.json_data and "error" in response.json_data and response.json_data["error"] is not None: - error = "%s reported an error (calling %s): %s" % ( - self.remote_name, - response.full_uri, - response.json_data["error"], - ) + if response.json_data and "error" in response.json_data: + error = "The Connect server reported an error: %s" % response.json_data["error"] raise RSConnectException(error) if response.status < 200 or response.status > 299: raise RSConnectException( - "Received an unexpected response from %s (calling %s): %s %s\n%s" - % ( - self.remote_name, - response.full_uri, - response.status, - response.reason, - response.response_body, - ) + "Received an unexpected response from RStudio Connect: %s %s" + % (response.status, response.reason) ) -class ShinyappsServer(AbstractRemoteServer): - """ - A simple class to encapsulate the information needed to interact with an - instance of the shinyapps.io server. - """ - - def __init__(self, url: str, account_name: str, token: str, secret: str): - super().__init__(url or "https://api.shinyapps.io", "shinyapps.io") - self.account_name = account_name - self.token = token - self.secret = secret - - -class RSConnectServer(AbstractRemoteServer): - """ - A simple class to encapsulate the information needed to interact with an - instance of the Connect server. - """ - - def __init__(self, url, api_key, insecure=False, ca_data=None): - super().__init__(url, "RStudio Connect") - self.api_key = api_key - self.insecure = insecure - self.ca_data = ca_data - # This is specifically not None. - self.cookie_jar = CookieJar() - - -TargetableServer = typing.Union[ShinyappsServer, RSConnectServer] - - -class S3Server(AbstractRemoteServer): - def __init__(self, url: str): - super().__init__(url, "S3") - - -class RSConnectClient(HTTPServer): - def __init__(self, server: RSConnectServer, cookies=None, timeout=30): +class RSConnect(HTTPServer): + def __init__(self, server, cookies=None, timeout=30): if cookies is None: cookies = server.cookie_jar - super().__init__( + super(RSConnect, self).__init__( append_to_path(server.url, "__api__"), server.insecure, server.ca_data, @@ -326,26 +279,13 @@ def __init__( cacert: IO = None, ca_data: str = None, cookies=None, - account=None, - token: str = None, - secret: str = None, timeout: int = 30, logger=console_logger, **kwargs ) -> None: self.reset() self._d = kwargs - self.setup_remote_server( - name=name, - url=url or kwargs.get("server"), - api_key=api_key, - insecure=insecure, - cacert=cacert, - ca_data=ca_data, - account_name=account, - token=token, - secret=secret, - ) + self.setup_connect_server(name, url or kwargs.get("server"), api_key, insecure, cacert, ca_data) self.setup_client(cookies, timeout) self.logger = logger @@ -361,7 +301,7 @@ def fromConnectServer(cls, connect_server, **kwargs): def reset(self): self._d = None - self.remote_server = None + self.connect_server = None self.client = None self.logger = None gc.collect() @@ -372,7 +312,7 @@ def drop_context(self): gc.collect() return self - def setup_remote_server( + def setup_connect_server( self, name: str = None, url: str = None, @@ -380,49 +320,20 @@ def setup_remote_server( insecure: bool = False, cacert: IO = None, ca_data: str = None, - account_name: str = None, - token: str = None, - secret: str = None, ): - validation.validate_connection_options( - url=url, - api_key=api_key, - insecure=insecure, - cacert=cacert, - account_name=account_name, - token=token, - secret=secret, - name=name, - ) + if name and url: + raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both.") + if not name and not url: + raise RSConnectException("You must specify one of -n/--name or -s/--server.") if cacert and not ca_data: ca_data = text_type(cacert.read()) - server_data = ServerStore().resolve(name, url) - if server_data.from_store: - url = server_data.url - api_key = server_data.api_key - insecure = server_data.insecure - ca_data = server_data.ca_data - account_name = server_data.account_name - token = server_data.token - secret = server_data.secret - self.is_server_from_store = server_data.from_store - - if api_key: - self.remote_server = RSConnectServer(url, api_key, insecure, ca_data) - elif token and secret: - self.remote_server = ShinyappsServer(url, account_name, token, secret) - else: - raise RSConnectException("Unable to infer Connect server type and setup server.") + url, api_key, insecure, ca_data, _ = ServerStore().resolve(name, url, api_key, insecure, ca_data) + self.connect_server = RSConnectServer(url, api_key, insecure, ca_data) def setup_client(self, cookies=None, timeout=30, **kwargs): - if isinstance(self.remote_server, RSConnectServer): - self.client = RSConnectClient(self.remote_server, cookies, timeout) - elif isinstance(self.remote_server, ShinyappsServer): - self.client = ShinyappsClient(self.remote_server, timeout) - else: - raise RSConnectException("Unable to infer Connect client.") + self.client = RSConnect(self.connect_server, cookies, timeout) @property def state(self): @@ -436,27 +347,6 @@ def pipe(self, func, *args, **kwargs): @cls_logged("Validating server...") def validate_server( - self, - name: str = None, - url: str = None, - api_key: str = None, - insecure: bool = False, - cacert: IO = None, - api_key_is_required: bool = False, - account_name: str = None, - token: str = None, - secret: str = None, - ): - if (url and api_key) or isinstance(self.remote_server, RSConnectServer): - self.validate_connect_server(name, url, api_key, insecure, cacert, api_key_is_required) - elif (url and token and secret) or isinstance(self.remote_server, ShinyappsServer): - self.validate_shinyapps_server(url, account_name, token, secret) - else: - raise RSConnectException("Unable to validate server from information provided.") - - return self - - def validate_connect_server( self, name: str = None, url: str = None, @@ -467,7 +357,8 @@ def validate_connect_server( **kwargs ): """ - Validate that the user gave us enough information to talk to shinyapps.io or a Connect server. + Validate that the user gave us enough information to talk to a Connect server. + :param name: the nickname, if any, specified by the user. :param url: the URL, if any, specified by the user. :param api_key: the API key, if any, specified by the user. @@ -475,34 +366,36 @@ def validate_connect_server( :param cacert: the file object of a CA certs file containing certificates to use. :param api_key_is_required: a flag that notes whether the API key is required or may be omitted. - :param token: The shinyapps.io authentication token. - :param secret: The shinyapps.io authentication secret. """ - url = url or self.remote_server.url - api_key = api_key or self.remote_server.api_key - insecure = insecure or self.remote_server.insecure + url = url or self.connect_server.url + api_key = api_key or self.connect_server.api_key + insecure = insecure or self.connect_server.insecure api_key_is_required = api_key_is_required or self.get("api_key_is_required", **kwargs) + server_store = ServerStore() - ca_data = None if cacert: ca_data = text_type(cacert.read()) - api_key = api_key or self.remote_server.api_key - insecure = insecure or self.remote_server.insecure - if not ca_data: - ca_data = self.remote_server.ca_data - - api_key_is_required = api_key_is_required or self.get("api_key_is_required", **kwargs) + else: + ca_data = self.connect_server.ca_data if name and url: - raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both") + raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both.") if not name and not url: raise RSConnectException("You must specify one of -n/--name or -s/--server.") - server_data = ServerStore().resolve(name, url) - connect_server = RSConnectServer(url, None, insecure, ca_data) + real_server, api_key, insecure, ca_data, from_store = server_store.resolve( + name, url, api_key, insecure, ca_data + ) + + # This can happen if the user specifies neither --name or --server and there's not + # a single default to go with. + if not real_server: + raise RSConnectException("You must specify one of -n/--name or -s/--server.") + + connect_server = RSConnectServer(real_server, None, insecure, ca_data) # If our info came from the command line, make sure the URL really works. - if not server_data.from_store: + if not from_store: self.server_settings connect_server.api_key = api_key @@ -513,30 +406,14 @@ def validate_connect_server( return self # If our info came from the command line, make sure the key really works. - if not server_data.from_store: - _ = self.verify_api_key(connect_server) + if not from_store: + _ = self.verify_api_key() - self.remote_server = connect_server - self.client = RSConnectClient(self.remote_server) + self.connect_server = connect_server + self.client = RSConnect(self.connect_server) return self - def validate_shinyapps_server( - self, url: str = None, account_name: str = None, token: str = None, secret: str = None, **kwargs - ): - url = url or self.remote_server.url - account_name = account_name or self.remote_server.account_name - token = token or self.remote_server.token - secret = secret or self.remote_server.secret - server = ShinyappsServer(url, account_name, token, secret) - - with ShinyappsClient(server) as client: - try: - result = client.get_current_user() - server.handle_bad_response(result) - except RSConnectException as exc: - raise RSConnectException("Failed to verify with shinyapps.io ({}).".format(exc)) - @cls_logged("Making bundle ...") def make_bundle(self, func: Callable, *args, **kwargs): path = ( @@ -556,8 +433,7 @@ def make_bundle(self, func: Callable, *args, **kwargs): d = self.state d["title_is_default"] = not bool(title) d["title"] = title or _default_title(path) - force_unique_name = app_id is None - d["deployment_name"] = self.make_deployment_name(d["title"], force_unique_name) + d["deployment_name"] = self.make_deployment_name(d["title"], app_id is None) try: bundle = func(*args, **kwargs) @@ -587,9 +463,6 @@ def check_server_capabilities(self, capability_functions): :param details_source: the source for obtaining server details, gather_server_details(), by default. """ - if isinstance(self.remote_server, ShinyappsServer): - return self - details = self.server_details for function in capability_functions: @@ -612,62 +485,17 @@ def deploy_bundle( bundle: IO = None, env_vars=None, ): - app_id = app_id or self.get("app_id") - deployment_name = deployment_name or self.get("deployment_name") - title = title or self.get("title") - title_is_default = title_is_default or self.get("title_is_default") - bundle = bundle or self.get("bundle") - env_vars = env_vars or self.get("env_vars") - - if isinstance(self.remote_server, RSConnectServer): - result = self.client.deploy( - app_id, - deployment_name, - title, - title_is_default, - bundle, - env_vars, - ) - self.remote_server.handle_bad_response(result) - self.state["deployed_info"] = result - return self - else: - contents = bundle.read() - bundle_size = len(contents) - bundle_hash = hashlib.md5(contents).hexdigest() - - prepare_deploy_result = self.client.prepare_deploy( - app_id, - deployment_name, - bundle_size, - bundle_hash, - ) - - upload_url = prepare_deploy_result.presigned_url - parsed_upload_url = urlparse(upload_url) - with S3Client( - "{}://{}".format(parsed_upload_url.scheme, parsed_upload_url.netloc), timeout=120 - ) as s3_client: - upload_result = s3_client.upload( - "{}?{}".format(parsed_upload_url.path, parsed_upload_url.query), - prepare_deploy_result.presigned_checksum, - bundle_size, - contents, - ) - S3Server(upload_url).handle_bad_response(upload_result) - - self.client.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) - - print("Application successfully deployed to {}".format(prepare_deploy_result.app_url)) - webbrowser.open_new(prepare_deploy_result.app_url) - - self.state["deployed_info"] = { - "app_url": prepare_deploy_result.app_url, - "app_id": prepare_deploy_result.app_id, - "app_guid": None, - "title": title, - } - return self + result = self.client.deploy( + app_id or self.get("app_id"), + deployment_name or self.get("deployment_name"), + title or self.get("title"), + title_is_default or self.get("title_is_default"), + bundle or self.get("bundle"), + env_vars or self.get("env_vars"), + ) + self.connect_server.handle_bad_response(result) + self.state["deployed_info"] = result + return self def emit_task_log( self, @@ -682,6 +510,7 @@ def emit_task_log( """ Helper for spooling the deployment log for an app. + :param connect_server: the Connect server information. :param app_id: the ID of the app that was deployed. :param task_id: the ID of the task that is tracking the deployment of the app.. :param log_callback: the callback to use to write the log to. If this is None @@ -693,19 +522,18 @@ def emit_task_log( :param raise_on_error: whether to raise an exception when a task is failed, otherwise we return the task_result so we can record the exit code. """ - if isinstance(self.remote_server, RSConnectServer): - app_id = app_id or self.state["deployed_info"]["app_id"] - task_id = task_id or self.state["deployed_info"]["task_id"] - log_lines, _ = self.client.wait_for_task( - task_id, log_callback.info, abort_func, timeout, poll_wait, raise_on_error - ) - self.remote_server.handle_bad_response(log_lines) - app_config = self.client.app_config(app_id) - self.remote_server.handle_bad_response(app_config) - app_dashboard_url = app_config.get("config_url") - log_callback.info("Deployment completed successfully.") - log_callback.info("\t Dashboard content URL: %s", app_dashboard_url) - log_callback.info("\t Direct content URL: %s", self.state["deployed_info"]["app_url"]) + app_id = app_id or self.state["deployed_info"]["app_id"] + task_id = task_id or self.state["deployed_info"]["task_id"] + log_lines, _ = self.client.wait_for_task( + task_id, log_callback.info, abort_func, timeout, poll_wait, raise_on_error + ) + self.connect_server.handle_bad_response(log_lines) + app_config = self.client.app_config(app_id) + self.connect_server.handle_bad_response(app_config) + app_dashboard_url = app_config.get("config_url") + log_callback.info("Deployment completed successfully.") + log_callback.info("\t Dashboard content URL: %s", app_dashboard_url) + log_callback.info("\t Direct content URL: %s", self.state["deployed_info"]["app_url"]) return self @@ -722,7 +550,7 @@ def save_deployed_info(self, *args, **kwargs): deployed_info = self.get("deployed_info", *args, **kwargs) app_store.set( - self.remote_server.url, + self.connect_server.url, abspath(path), deployed_info["app_url"], deployed_info["app_id"], @@ -735,6 +563,7 @@ def save_deployed_info(self, *args, **kwargs): @cls_logged("Validating app mode...") def validate_app_mode(self, *args, **kwargs): + connect_server = self.connect_server path = ( self.get("path", **kwargs) or self.get("file", **kwargs) @@ -758,19 +587,13 @@ def validate_app_mode(self, *args, **kwargs): if app_id is None: # Possible redeployment - check for saved metadata. # Use the saved app information unless overridden by the user. - app_id, existing_app_mode = app_store.resolve(self.remote_server.url, app_id, app_mode) + app_id, existing_app_mode = app_store.resolve(connect_server.url, app_id, app_mode) logger.debug("Using app mode from app %s: %s" % (app_id, app_mode)) elif app_id is not None: # Don't read app metadata if app-id is specified. Instead, we need - # to get this from the remote. - if isinstance(self.remote_server, RSConnectServer): - app = get_app_info(self.remote_server, app_id) - existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) - elif isinstance(self.remote_server, ShinyappsServer): - app = get_shinyapp_info(self.remote_server, app_id) - existing_app_mode = AppModes.get_by_cloud_name(app.json_data["mode"]) - else: - raise RSConnectException("Unable to infer Connect client.") + # to get this from Connect. + app = get_app_info(connect_server, app_id) + existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) if existing_app_mode and app_mode != existing_app_mode: msg = ( "Deploying with mode '%s',\n" @@ -787,32 +610,27 @@ def validate_app_mode(self, *args, **kwargs): def server_settings(self): try: result = self.client.server_settings() - self.remote_server.handle_bad_response(result) + self.connect_server.handle_bad_response(result) except SSLError as ssl_error: raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error) return result - def verify_api_key(self, server=None): + def verify_api_key(self): """ Verify that an API Key may be used to authenticate with the given RStudio Connect server. If the API key verifies, we return the username of the associated user. """ - if not server: - server = self.remote_server - if isinstance(server, ShinyappsServer): - raise RSConnectException("Shinnyapps server does not use an API key.") - with RSConnectClient(server) as client: - result = client.me() - if isinstance(result, HTTPResponse): - if result.json_data and "code" in result.json_data and result.json_data["code"] == 30: - raise RSConnectException("The specified API key is not valid.") - raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason)) + result = self.client.me() + if isinstance(result, HTTPResponse): + if result.json_data and "code" in result.json_data and result.json_data["code"] == 30: + raise RSConnectException("The specified API key is not valid.") + raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason)) return self @property def api_username(self): result = self.client.me() - self.remote_server.handle_bad_response(result) + self.connect_server.handle_bad_response(result) return result["username"] @property @@ -824,7 +642,7 @@ def python_info(self): :return: the Python installation information from Connect. """ result = self.client.python_settings() - self.remote_server.handle_bad_response(result) + self.connect_server.handle_bad_response(result) return result @property @@ -869,6 +687,7 @@ def make_deployment_name(self, title, force_unique): that we collapse repeating underscores and, if the name is too short, it is padded to the left with underscores. + :param connect_server: the information needed to interact with the Connect server. :param title: the title to start with. :param force_unique: a flag noting whether the generated name must be forced to be unique. @@ -883,7 +702,7 @@ def make_deployment_name(self, title, force_unique): # Now, make sure it's unique, if needed. if force_unique: - name = find_unique_name(self.remote_server, name) + name = find_unique_name(self.connect_server, name) return name @@ -894,186 +713,6 @@ def filter_out_server_info(**kwargs): return new_kwargs -class S3Client(HTTPServer): - def upload(self, path, presigned_checksum, bundle_size, contents): - headers = { - "content-type": "application/x-tar", - "content-length": str(bundle_size), - "content-md5": presigned_checksum, - } - return self.put(path, headers=headers, body=contents, decode_response=False) - - -class PrepareDeployResult: - def __init__(self, app_id: int, app_url: str, bundle_id: int, presigned_url: str, presigned_checksum: str): - self.app_id = app_id - self.app_url = app_url - self.bundle_id = bundle_id - self.presigned_url = presigned_url - self.presigned_checksum = presigned_checksum - - -class ShinyappsClient(HTTPServer): - - _TERMINAL_STATUSES = {"success", "failed", "error"} - - def __init__(self, shinyapps_server: ShinyappsServer, timeout: int = 30): - self._token = shinyapps_server.token - self._key = base64.b64decode(shinyapps_server.secret) - self._server = shinyapps_server - super().__init__(shinyapps_server.url, timeout=timeout) - - def _get_canonical_request(self, method, path, timestamp, content_hash): - return "\n".join([method, path, timestamp, content_hash]) - - def _get_canonical_request_signature(self, request): - result = hmac.new(self._key, request.encode(), hashlib.sha256).hexdigest() - return base64.b64encode(result.encode()).decode() - - def get_extra_headers(self, url, method, body): - canonical_request_method = method.upper() - canonical_request_path = parse.urlparse(url).path - canonical_request_date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") - - # get request checksum - md5 = hashlib.md5() - body = body or b"" - body_bytes = body if isinstance(body, bytes) else body.encode() - md5.update(body_bytes) - canonical_request_checksum = md5.hexdigest() - - canonical_request = self._get_canonical_request( - canonical_request_method, canonical_request_path, canonical_request_date, canonical_request_checksum - ) - - signature = self._get_canonical_request_signature(canonical_request) - - return { - "X-Auth-Token": "{0}".format(self._token), - "X-Auth-Signature": "{0}; version=1".format(signature), - "Date": canonical_request_date, - "X-Content-Checksum": canonical_request_checksum, - } - - def get_application(self, application_id): - return self.get("/v1/applications/{}".format(application_id)) - - def create_application(self, account_id, application_name): - application_data = { - "account": account_id, - "name": application_name, - "template": "shiny", - } - return self.post("/v1/applications/", body=application_data) - - def get_accounts(self): - return self.get("/v1/accounts/") - - def _get_applications_like_name_page(self, name: str, offset: int): - return self.get( - "/v1/applications?filter=name:like:{}&offset={}&count=100&use_advanced_filters=true".format(name, offset) - ) - - def create_bundle(self, application_id: int, content_type: str, content_length: int, checksum: str): - bundle_data = { - "application": application_id, - "content_type": content_type, - "content_length": content_length, - "checksum": checksum, - } - result = self.post("/v1/bundles", body=bundle_data) - return result - - def set_bundle_status(self, bundle_id, bundle_status): - return self.post("/v1/bundles/{}/status".format(bundle_id), body={"status": bundle_status}) - - def deploy_application(self, bundle_id, app_id): - return self.post("/v1/applications/{}/deploy".format(app_id), body={"bundle": bundle_id, "rebuild": False}) - - def get_task(self, task_id): - return self.get("/v1/tasks/{}".format(task_id), query_params={"legacy": "true"}) - - def get_current_user(self): - return self.get("/v1/users/me") - - def wait_until_task_is_successful(self, task_id, timeout=180): - print() - print("Waiting for task: {}".format(task_id)) - start_time = time.time() - while time.time() - start_time < timeout: - task = self.get_task(task_id) - self._server.handle_bad_response(task) - status = task.json_data["status"] - description = task.json_data["description"] - error = task.json_data["error"] - - if status == "success": - break - - if status in {"failed", "error"}: - raise RSConnectException("Application deployment failed with error: {}".format(error)) - - print(" {} - {}".format(status, description)) - time.sleep(2) - - print("Task done: {}".format(description)) - - def prepare_deploy(self, app_id: typing.Optional[str], app_name: str, bundle_size: int, bundle_hash: str): - accounts = self.get_accounts() - self._server.handle_bad_response(accounts) - account = next( - filter(lambda acct: acct["name"] == self._server.account_name, accounts.json_data["accounts"]), None - ) - # TODO: also check this during `add` command - if account is None: - raise RSConnectException( - "No account found by name : %s for given user credential" % self._server.account_name - ) - - if app_id is None: - application = self.create_application(account["id"], app_name) - else: - application = self.get_application(app_id) - self._server.handle_bad_response(application) - app_id_int = application.json_data["id"] - app_url = application.json_data["url"] - - bundle = self.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) - self._server.handle_bad_response(bundle) - - return PrepareDeployResult( - app_id_int, - app_url, - int(bundle.json_data["id"]), - bundle.json_data["presigned_url"], - bundle.json_data["presigned_checksum"], - ) - - def do_deploy(self, bundle_id, app_id): - bundle_status_response = self.set_bundle_status(bundle_id, "ready") - self._server.handle_bad_response(bundle_status_response) - - deploy_task = self.deploy_application(bundle_id, app_id) - self._server.handle_bad_response(deploy_task) - self.wait_until_task_is_successful(deploy_task.json_data["id"]) - - def get_applications_like_name(self, name): - applications = [] - - results = self._get_applications_like_name_page(name, 0) - self._server.handle_bad_response(results) - offset = 0 - - while len(applications) < int(results.json_data["total"]): - results = self._get_applications_like_name_page(name, offset) - self._server.handle_bad_response(results) - applications = results.json_data["applications"] - applications.extend(applications) - offset += int(results.json_data["count"]) - - return [app["name"] for app in applications] - - def verify_server(connect_server): """ Verify that the given server information represents a Connect instance that is @@ -1085,7 +724,7 @@ def verify_server(connect_server): """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) try: - with RSConnectClient(connect_server) as client: + with RSConnect(connect_server) as client: result = client.server_settings() connect_server.handle_bad_response(result) return result @@ -1102,7 +741,8 @@ def verify_api_key(connect_server): :return: the username of the user to whom the API key belongs. """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - with RSConnectClient(connect_server) as client: + + with RSConnect(connect_server) as client: result = client.me() if isinstance(result, HTTPResponse): if result.json_data and "code" in result.json_data and result.json_data["code"] == 30: @@ -1120,7 +760,8 @@ def get_python_info(connect_server): :return: the Python installation information from Connect. """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - with RSConnectClient(connect_server) as client: + + with RSConnect(connect_server) as client: result = client.python_settings() connect_server.handle_bad_response(result) return result @@ -1134,19 +775,12 @@ def get_app_info(connect_server, app_id): :param app_id: the ID (numeric or GUID) of the application to get info for. :return: the Python installation information from Connect. """ - with RSConnectClient(connect_server) as client: + with RSConnect(connect_server) as client: result = client.app_get(app_id) connect_server.handle_bad_response(result) return result -def get_shinyapp_info(server, app_id): - with ShinyappsClient(server) as client: - result = client.get_application(app_id) - server.handle_bad_response(result) - return result - - def get_app_config(connect_server, app_id): """ Return the configuration information for an application that has been created @@ -1156,12 +790,32 @@ def get_app_config(connect_server, app_id): :param app_id: the ID (numeric or GUID) of the application to get the info for. :return: the Python installation information from Connect. """ - with RSConnectClient(connect_server) as client: + with RSConnect(connect_server) as client: result = client.app_config(app_id) connect_server.handle_bad_response(result) return result +def do_bundle_deploy(connect_server, app_id, name, title, title_is_default, bundle, env_vars): + """ + Deploys the specified bundle. + + :param connect_server: the Connect server information. + :param app_id: the ID of the app to deploy, if this is a redeploy. + :param name: the name for the deploy. + :param title: the title for the deploy. + :param title_is_default: a flag noting whether the title carries a defaulted value. + :param bundle: the bundle to deploy. + :param env_vars: list of NAME=VALUE pairs for the app environment + :return: application information about the deploy. This includes the ID of the + task that may be queried for deployment progress. + """ + with RSConnect(connect_server, timeout=120) as client: + result = client.deploy(app_id, name, title, title_is_default, bundle, env_vars) + connect_server.handle_bad_response(result) + return result + + def emit_task_log( connect_server, app_id, @@ -1189,7 +843,7 @@ def emit_task_log( :return: the ultimate URL where the deployed app may be accessed and the sequence of log lines. The log lines value will be None if a log callback was provided. """ - with RSConnectClient(connect_server) as client: + with RSConnect(connect_server) as client: result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error) connect_server.handle_bad_response(result) app_config = client.app_config(app_id) @@ -1224,7 +878,7 @@ def retrieve_matching_apps(connect_server, filters=None, limit=None, mapping_fun maximum = limit finished = False - with RSConnectClient(connect_server) as client: + with RSConnect(connect_server) as client: while not finished: response = client.app_search(search_filters) connect_server.handle_bad_response(response) @@ -1334,24 +988,20 @@ def mapping_filter(client, app): return apps -def find_unique_name(remote_server: TargetableServer, name: str): +def find_unique_name(connect_server, name): """ Poll through existing apps to see if anything with a similar name exists. If so, start appending numbers until a unique name is found. - :param remote_server: the remote server information. + :param connect_server: the Connect server information. :param name: the default name for an app. :return: the name, potentially with a suffixed number to guarantee uniqueness. """ - if isinstance(remote_server, RSConnectServer): - existing_names = retrieve_matching_apps( - remote_server, - filters={"search": name}, - mapping_function=lambda client, app: app["name"], - ) - else: - client = ShinyappsClient(remote_server) - existing_names = client.get_applications_like_name(name) + existing_names = retrieve_matching_apps( + connect_server, + filters={"search": name}, + mapping_function=lambda client, app: app["name"], + ) if name in existing_names: suffix = 1 @@ -1382,3 +1032,18 @@ def _to_server_check_list(url): items = ["%s"] return [item % url for item in items] + + +def _default_title(file_name): + """ + Produce a default content title from the given file path. The result is + guaranteed to be between 3 and 1024 characters long, as required by RStudio + Connect. + + :param file_name: the name from which the title will be derived. + :return: the derived title. + """ + # Make sure we have enough of a path to derive text from. + file_name = abspath(file_name) + # noinspection PyTypeChecker + return basename(file_name).rsplit(".", 1)[0][:1024].rjust(3, "0") diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index ae53b62a..decab61d 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -225,34 +225,14 @@ def post(self, path, query_params=None, body=None): def patch(self, path, query_params=None, body=None): return self.request("PATCH", path, query_params, body) - def put(self, path, query_params=None, body=None, headers=None, decode_response=True): - if headers is None: - headers = {} - return self.request( - "PUT", path, query_params=query_params, body=body, headers=headers, decode_response=decode_response - ) - - def request( - self, - method, - path, - query_params=None, - body=None, - maximum_redirects=5, - decode_response=True, - headers=None, - ): + def request(self, method, path, query_params=None, body=None, maximum_redirects=5, decode_response=True): path = self._get_full_path(path) - extra_headers = headers or {} + extra_headers = None if isinstance(body, (dict, list)): body = json.dumps(body).encode("utf-8") extra_headers = {"Content-Type": "application/json; charset=utf-8"} - extra_headers = {**extra_headers, **self.get_extra_headers(path, method, body)} return self._do_request(method, path, query_params, body, maximum_redirects, extra_headers, decode_response) - def get_extra_headers(self, url, method, body): - return {} - def _do_request( self, method, path, query_params, body, maximum_redirects, extra_headers=None, decode_response=True ): @@ -303,27 +283,17 @@ def _do_request( raise http.CannotSendRequest("Too many redirects") location = response.getheader("Location") + next_url = urljoin(self._url.geturl(), location) - # Assume the redirect location will always be on the same domain. - if location.startswith("http"): - parsed_location = urlparse(location) - if parsed_location.query: - next_url = "{}?{}".format(parsed_location.path, parsed_location.query) - else: - next_url = parsed_location.path - else: - next_url = location - - logger.debug("--> Redirected to: %s" % urljoin(self._url.geturl(), location)) + logger.debug("--> Redirected to: %s" % next_url) - redirect_extra_headers = self.get_extra_headers(next_url, "GET", body) return self._do_request( - "GET", + method, next_url, query_params, body, maximum_redirects - 1, - {**extra_headers, **redirect_extra_headers}, + extra_headers, ) self._handle_set_cookie(response) diff --git a/rsconnect/main.py b/rsconnect/main.py index 3ab5ff81..a528334c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -21,7 +21,6 @@ test_server, validate_quarto_engines, which_quarto, - test_shinyapps_server, ) from .actions_content import ( download_bundle, @@ -35,7 +34,7 @@ emit_build_log, ) -from . import api, VERSION, validation +from . import api, VERSION from .api import RSConnectExecutor, filter_out_server_info from .bundle import ( are_apis_supported_on_server, @@ -239,14 +238,9 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): return real_server, me -def _test_shinyappsio_creds(server: api.ShinyappsServer): - with cli_feedback("Checking shinyapps.io credential"): - test_shinyapps_server(server) - - # noinspection SpellCheckingInspection @cli.command( - short_help="Define a nickname for an RStudio Connect or shinyapps.io server and credential.", + short_help="Define a nickname for an RStudio Connect server.", help=( "Associate a simple nickname with the information needed to interact with an RStudio Connect server. " "Specifying an existing nickname will cause its stored information to be replaced by what is given " @@ -257,12 +251,14 @@ def _test_shinyappsio_creds(server: api.ShinyappsServer): @click.option( "--server", "-s", + required=True, envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", ) @click.option( "--api-key", "-k", + required=True, envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", ) @@ -280,72 +276,27 @@ def _test_shinyappsio_creds(server: api.ShinyappsServer): type=click.File(), help="The path to trusted TLS CA certificates.", ) -@click.option( - "--account", - "-a", - envvar="SHINYAPPS_ACCOUNT", - help="The shinyapps.io account name.", -) -@click.option( - "--token", - "-T", - envvar="SHINYAPPS_TOKEN", - help="The shinyapps.io token.", -) -@click.option( - "--secret", - "-S", - envvar="SHINYAPPS_SECRET", - help="The shinyapps.io token secret.", -) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") -def add(name, server, api_key, insecure, cacert, account, token, secret, verbose): - +def add(name, server, api_key, insecure, cacert, verbose): set_verbosity(verbose) - validation.validate_connection_options( - url=server, - api_key=api_key, - insecure=insecure, - cacert=cacert, - account_name=account, - token=token, - secret=secret, - ) - old_server = server_store.get_by_name(name) - if account: - shinyapps_server = api.ShinyappsServer(server, account, token, secret) - _test_shinyappsio_creds(shinyapps_server) + # Server must be pingable and the API key must work to be added. + real_server, _ = _test_server_and_api(server, api_key, insecure, cacert) - server_store.set( - name, - shinyapps_server.url, - account_name=shinyapps_server.account_name, - token=shinyapps_server.token, - secret=shinyapps_server.secret, - ) - if old_server: - click.echo('Updated shinyapps.io credential "%s".' % name) - else: - click.echo('Added shinyapps.io credential "%s".' % name) - else: - # Server must be pingable and the API key must work to be added. - real_server, _ = _test_server_and_api(server, api_key, insecure, cacert) - - server_store.set( - name, - real_server.url, - real_server.api_key, - real_server.insecure, - real_server.ca_data, - ) + server_store.set( + name, + real_server.url, + real_server.api_key, + real_server.insecure, + real_server.ca_data, + ) - if old_server: - click.echo('Updated Connect server "%s" with URL %s' % (name, real_server.url)) - else: - click.echo('Added Connect server "%s" with URL %s' % (name, real_server.url)) + if old_server: + click.echo('Updated server "%s" with URL %s' % (name, real_server.url)) + else: + click.echo('Added server "%s" with URL %s' % (name, real_server.url)) @cli.command( @@ -393,9 +344,9 @@ def details(name, server, api_key, insecure, cacert, verbose): ce = RSConnectExecutor(name, server, api_key, insecure, cacert).validate_server() - click.echo(" RStudio Connect URL: %s" % ce.remote_server.url) + click.echo(" RStudio Connect URL: %s" % ce.connect_server.url) - if not ce.remote_server.api_key: + if not ce.connect_server.api_key: return with cli_feedback("Gathering details"): @@ -522,6 +473,51 @@ def deploy(): pass +def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_required=True): + """ + Validate that the user gave us enough information to talk to a Connect server. + + :param name: the nickname, if any, specified by the user. + :param url: the URL, if any, specified by the user. + :param api_key: the API key, if any, specified by the user. + :param insecure: a flag noting whether TLS host/validation should be skipped. + :param ca_cert: the name of a CA certs file containing certificates to use. + :param api_key_is_required: a flag that notes whether the API key is required or may + be omitted. + :return: a ConnectServer object that carries all the right info. + """ + ca_data = ca_cert and text_type(ca_cert.read()) + + if name and url: + raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both.") + + real_server, api_key, insecure, ca_data, from_store = server_store.resolve(name, url, api_key, insecure, ca_data) + + # This can happen if the user specifies neither --name or --server and there's not + # a single default to go with. + if not real_server: + raise RSConnectException("You must specify one of -n/--name or -s/--server.") + + connect_server = api.RSConnectServer(real_server, None, insecure, ca_data) + + # If our info came from the command line, make sure the URL really works. + if not from_store: + connect_server, _ = test_server(connect_server) + + connect_server.api_key = api_key + + if not connect_server.api_key: + if api_key_is_required: + raise RSConnectException('An API key must be specified for "%s".' % connect_server.url) + return connect_server + + # If our info came from the command line, make sure the key really works. + if not from_store: + _ = test_api_key(connect_server) + + return connect_server + + def _warn_on_ignored_manifest(directory): """ Checks for the existence of a file called manifest.json in the given directory. @@ -728,24 +724,6 @@ def deploy_notebook( ) @server_args @content_args -@click.option( - "--account", - "-a", - envvar="SHINYAPPS_ACCOUNT", - help="The shinyapps.io account name.", -) -@click.option( - "--token", - "-T", - envvar="SHINYAPPS_TOKEN", - help="The shinyapps.io token.", -) -@click.option( - "--secret", - "-S", - envvar="SHINYAPPS_SECRET", - help="The shinyapps.io token secret.", -) @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @cli_exception_handler def deploy_manifest( @@ -754,9 +732,6 @@ def deploy_manifest( api_key: str, insecure: bool, cacert: typing.IO, - account: str, - token: str, - secret: str, new: bool, app_id: str, title: str, @@ -1618,7 +1593,7 @@ def content_search( with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() result = search_content( - ce.remote_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by + ce.connect_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by ) json.dump(result, sys.stdout, indent=2) @@ -1670,7 +1645,7 @@ def content_describe(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - result = get_content(ce.remote_server, guid) + result = get_content(ce.connect_server, guid) json.dump(result, sys.stdout, indent=2) @@ -1734,7 +1709,7 @@ def content_bundle_download(name, server, api_key, insecure, cacert, guid, outpu if exists(output) and not overwrite: raise RSConnectException("The output file already exists: %s" % output) - result = download_bundle(ce.remote_server, guid) + result = download_bundle(ce.connect_server, guid) with open(output, "wb") as f: f.write(result.response_body) @@ -1789,7 +1764,7 @@ def add_content_build(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - build_add_content(ce.remote_server, guid) + build_add_content(ce.connect_server, guid) if len(guid) == 1: logger.info('Added "%s".' % guid[0]) else: @@ -1854,7 +1829,7 @@ def remove_content_build(name, server, api_key, insecure, cacert, guid, all, pur with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() _validate_build_rm_args(guid, all, purge) - guids = build_remove_content(ce.remote_server, guid, all, purge) + guids = build_remove_content(ce.connect_server, guid, all, purge) if len(guids) == 1: logger.info('Removed "%s".' % guids[0]) else: @@ -1907,7 +1882,7 @@ def list_content_build(name, server, api_key, insecure, cacert, status, guid, ve set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - result = build_list_content(ce.remote_server, guid, status) + result = build_list_content(ce.connect_server, guid, status) json.dump(result, sys.stdout, indent=2) @@ -1955,7 +1930,7 @@ def get_build_history(name, server, api_key, insecure, cacert, guid, verbose): with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert) ce.validate_server() - result = build_history(ce.remote_server, guid) + result = build_history(ce.connect_server, guid) json.dump(result, sys.stdout, indent=2) @@ -2018,7 +1993,7 @@ def get_build_logs(name, server, api_key, insecure, cacert, guid, task_id, forma set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - for line in emit_build_log(ce.remote_server, guid, format, task_id): + for line in emit_build_log(ce.connect_server, guid, format, task_id): sys.stdout.write(line) @@ -2085,7 +2060,7 @@ def start_content_build( logger.set_log_output_format(format) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - build_start(ce.remote_server, parallelism, aborted, error, all, poll_wait, debug) + build_start(ce.connect_server, parallelism, aborted, error, all, poll_wait, debug) if __name__ == "__main__": diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 33fa9d99..d735bc77 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -7,7 +7,6 @@ import os import glob import sys -import typing from datetime import datetime, timezone from os.path import abspath, basename, dirname, exists, join from urllib.parse import urlparse @@ -138,7 +137,7 @@ def _get_sorted_values(self, sort_by): """ Return all the values in the store sorted by the given lambda expression. - :param sort_by: a lambda expression to use to sort the values. + :param sort_by: a lambda expression to use to sort the values.. :return: the sorted values. """ return sorted(self._data.values(), key=sort_by) @@ -218,30 +217,6 @@ def save(self, open=open): os.chmod(self._real_path, 0o600) -class ServerData: - def __init__( - self, - name: str, - url: str, - from_store: bool, - api_key: typing.Optional[str] = None, - insecure: typing.Optional[bool] = None, - ca_data: typing.Optional[str] = None, - account_name: typing.Optional[str] = None, - token: typing.Optional[str] = None, - secret: typing.Optional[str] = None, - ): - self.name = name - self.url = url - self.from_store = from_store - self.api_key = api_key - self.insecure = insecure - self.ca_data = ca_data - self.account_name = account_name - self.token = token - self.secret = secret - - class ServerStore(DataStore): """Defines a metadata store for server information. @@ -277,7 +252,7 @@ def get_all_servers(self): """ return self._get_sorted_values(lambda s: s["name"]) - def set(self, name, url, api_key=None, insecure=False, ca_data=None, account_name=None, token=None, secret=None): + def set(self, name, url, api_key, insecure=False, ca_data=None): """ Add (or update) information about a Connect server @@ -286,19 +261,17 @@ def set(self, name, url, api_key=None, insecure=False, ca_data=None, account_nam :param api_key: the API key to use to authenticate with the Connect server. :param insecure: a flag to disable TLS verification. :param ca_data: client side certificate data to use for TLS. - :param account_name: shinyapps.io account name. - :param token: shinyapps.io token. - :param secret: shinyapps.io secret. """ - common_data = dict( - name=name, - url=url, + self._set( + name, + dict( + name=name, + url=url, + api_key=api_key, + insecure=insecure, + ca_cert=ca_data, + ), ) - if api_key: - target_data = dict(api_key=api_key, insecure=insecure, ca_cert=ca_data) - else: - target_data = dict(account_name=account_name, token=token, secret=secret) - self._set(name, {**common_data, **target_data}) def remove_by_name(self, name): """ @@ -316,7 +289,7 @@ def remove_by_url(self, url): """ return self._remove_by_value_attr("name", "url", url) - def resolve(self, name, url): + def resolve(self, name, url, api_key, insecure, ca_data): """ This function will resolve the given inputs into a set of server information. It assumes that either `name` or `url` is provided. @@ -334,6 +307,9 @@ def resolve(self, name, url): :param name: the nickname to look for. :param url: the Connect server URL to look for. + :param api_key: the API key provided on the command line. + :param insecure: the insecure flag provided on the command line. + :param ca_data: the CA certification data provided on the command line. :return: the information needed to interact with the resolved server and whether it came from the store or the arguments. """ @@ -351,23 +327,15 @@ def resolve(self, name, url): entry = None if entry: - return ServerData( - name, + return ( entry["url"], + entry["api_key"], + entry["insecure"], + entry["ca_cert"], True, - insecure=entry.get("insecure"), - ca_data=entry.get("ca_cert"), - api_key=entry.get("api_key"), - account_name=entry.get("account_name"), - token=entry.get("token"), - secret=entry.get("secret"), ) else: - return ServerData( - name, - url, - False, - ) + return url, api_key, insecure, ca_data, False def sha1(s): diff --git a/rsconnect/models.py b/rsconnect/models.py index 6c7a1d73..82fbac97 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -96,18 +96,6 @@ class AppModes(object): STATIC_QUARTO, ] - _cloud_to_connect_modes = { - "shiny": SHINY, - "rmarkdown_static": RMD, - "rmarkdown": SHINY_RMD, - "plumber": PLUMBER, - "flask": PYTHON_API, - "dash": DASH_APP, - "streamlit": STREAMLIT_APP, - "fastapi": PYTHON_FASTAPI, - "bokeh": BOKEH_APP, - } - @classmethod def get_by_ordinal(cls, ordinal, return_unknown=False): """Get an AppMode by its associated ordinal (integer)""" @@ -137,10 +125,6 @@ def get_by_extension(cls, extension, return_unknown=False): return_unknown, ) - @classmethod - def get_by_cloud_name(cls, name): - return cls._cloud_to_connect_modes.get(name, cls.UNKNOWN) - @classmethod def _find_by(cls, predicate, message, return_unknown): for mode in cls._modes: diff --git a/rsconnect/validation.py b/rsconnect/validation.py deleted file mode 100644 index a7ff2acb..00000000 --- a/rsconnect/validation.py +++ /dev/null @@ -1,43 +0,0 @@ -import typing - -from rsconnect.exception import RSConnectException - - -def _get_present_options(options: typing.Dict[str, typing.Optional[str]]) -> typing.List[str]: - return [k for k, v in options.items() if v] - - -def validate_connection_options(url, api_key, insecure, cacert, account_name, token, secret, name=None): - """ - Validates provided Connect or shinyapps.io connection options and returns which target to use given the provided - options. - """ - connect_options = {"-k/--api-key": api_key, "-i/--insecure": insecure, "-c/--cacert": cacert} - shinyapps_options = {"-T/--token": token, "-S/--secret": secret, "-a/--account": account_name} - options_mutually_exclusive_with_name = {"-s/--server": url, **connect_options, **shinyapps_options} - present_options_mutually_exclusive_with_name = _get_present_options(options_mutually_exclusive_with_name) - - if name and present_options_mutually_exclusive_with_name: - raise RSConnectException( - "-n/--name cannot be specified in conjunction with options {}".format( - ", ".join(present_options_mutually_exclusive_with_name) - ) - ) - if not name and not url and not shinyapps_options: - raise RSConnectException( - "You must specify one of -n/--name OR -s/--server OR -a/--account, -T/--token, -S/--secret." - ) - - present_connect_options = _get_present_options(connect_options) - present_shinyapps_options = _get_present_options(shinyapps_options) - - if present_connect_options and present_shinyapps_options: - raise RSConnectException( - "Connect options ({}) may not be passed alongside shinyapps.io options ({}).".format( - ", ".join(present_connect_options), ", ".join(present_shinyapps_options) - ) - ) - - if present_shinyapps_options: - if len(present_shinyapps_options) != 3: - raise RSConnectException("-a/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io.") diff --git a/tests/rsconnect-build-test/connect_remote_6443.json b/tests/rsconnect-build-test/connect_remote_6443.json deleted file mode 100644 index 32f7e05c..00000000 --- a/tests/rsconnect-build-test/connect_remote_6443.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "rsconnect_build_running": false, - "rsconnect_content": { - "c96db3f3-87a1-4df5-9f58-eb109c397718": { - "guid": "c96db3f3-87a1-4df5-9f58-eb109c397718", - "bundle_id": "177", - "title": "orphan-proc-shiny-test", - "name": "orphan-proc-shiny-test", - "app_mode": "shiny", - "content_url": "https://connect.remote:6443/content/c96db3f3-87a1-4df5-9f58-eb109c397718/", - "dashboard_url": "https://connect.remote:6443/connect/#/apps/c96db3f3-87a1-4df5-9f58-eb109c397718", - "created_time": "2021-11-04T18:07:12Z", - "last_deployed_time": "2021-11-10T19:10:56Z", - "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", - "rsconnect_build_status": "NEEDS_BUILD" - }, - "fe673896-f92a-40cc-be4c-e4872bb90a37": { - "guid": "fe673896-f92a-40cc-be4c-e4872bb90a37", - "bundle_id": "185", - "title": "interactive-rmd", - "name": "interactive-rmd", - "app_mode": "rmd-shiny", - "content_url": "https://connect.remote:6443/content/fe673896-f92a-40cc-be4c-e4872bb90a37/", - "dashboard_url": "https://connect.remote:6443/connect/#/apps/fe673896-f92a-40cc-be4c-e4872bb90a37", - "created_time": "2021-11-15T15:37:53Z", - "last_deployed_time": "2021-11-15T15:37:57Z", - "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", - "rsconnect_build_status": "ERROR" - }, - "a0b6b5a2-5fbe-4293-8310-4f80054bc24f": { - "guid": "a0b6b5a2-5fbe-4293-8310-4f80054bc24f", - "bundle_id": "184", - "title": "stock-report-jupyter", - "name": "stock-report-jupyter", - "app_mode": "jupyter-static", - "content_url": "https://connect.remote:6443/content/a0b6b5a2-5fbe-4293-8310-4f80054bc24f/", - "dashboard_url": "https://connect.remote:6443/connect/#/apps/a0b6b5a2-5fbe-4293-8310-4f80054bc24f", - "created_time": "2021-11-15T15:27:18Z", - "last_deployed_time": "2021-11-15T15:35:27Z", - "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", - "rsconnect_build_status": "RUNNING" - }, - "23315cc9-ed2a-40ad-9e99-e5e49066531a": { - "guid": "23315cc9-ed2a-40ad-9e99-e5e49066531a", - "bundle_id": "180", - "title": "static-rmd", - "name": "static-rmd2", - "app_mode": "rmd-static", - "content_url": "https://connect.remote:6443/content/23315cc9-ed2a-40ad-9e99-e5e49066531a/", - "dashboard_url": "https://connect.remote:6443/connect/#/apps/23315cc9-ed2a-40ad-9e99-e5e49066531a", - "created_time": "2021-11-15T15:20:58Z", - "last_deployed_time": "2021-11-15T15:25:31Z", - "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", - "rsconnect_build_status": "COMPLETE", - "rsconnect_last_build_time": "2021-12-13T18:10:38Z", - "rsconnect_last_build_log": "/logs/localhost_3939/23315cc9-ed2a-40ad-9e99-e5e49066531a/ZUf44zVWHjODv1Rq.log", - "rsconnect_build_task_result": { - "id": "ZUf44zVWHjODv1Rq", - "user_id": 1, - "result": { - "type": "", - "data": null - }, - "finished": true, - "code": 0, - "error": "" - } - }, - "015143da-b75f-407c-81b1-99c4a724341e": { - "guid": "015143da-b75f-407c-81b1-99c4a724341e", - "bundle_id": "176", - "title": "plumber-async", - "name": "plumber-async", - "app_mode": "api", - "content_url": "https://connect.remote:6443/content/015143da-b75f-407c-81b1-99c4a724341e/", - "dashboard_url": "https://connect.remote:6443/connect/#/apps/015143da-b75f-407c-81b1-99c4a724341e", - "created_time": "2021-11-01T20:43:32Z", - "last_deployed_time": "2021-11-03T17:48:59Z", - "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", - "rsconnect_build_status": "ERROR" - } - } -} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index cbd0d5ba..f527a13e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,7 +3,7 @@ require_api_key, require_connect, ) -from rsconnect.api import RSConnectClient, RSConnectExecutor, _to_server_check_list +from rsconnect.api import RSConnect, RSConnectExecutor, _to_server_check_list class TestAPI(TestCase): @@ -11,7 +11,7 @@ def test_executor_init(self): connect_server = require_connect(self) api_key = require_api_key(self) ce = RSConnectExecutor(None, connect_server, api_key, True, None) - self.assertEqual(ce.remote_server.url, connect_server) + self.assertEqual(ce.connect_server.url, connect_server) def test_output_task_log(self): lines = ["line 1", "line 2", "line 3"] @@ -23,12 +23,12 @@ def test_output_task_log(self): } output = [] - self.assertEqual(RSConnectClient.output_task_log(task_status, 0, output.append), 3) + self.assertEqual(RSConnect.output_task_log(task_status, 0, output.append), 3) self.assertEqual(lines, output) task_status["last_status"] = 4 task_status["status"] = ["line 4"] - self.assertEqual(RSConnectClient.output_task_log(task_status, 3, output.append), 4) + self.assertEqual(RSConnect.output_task_log(task_status, 3, output.append), 4) self.assertEqual(len(output), 4) self.assertEqual(output[3], "line 4") diff --git a/tests/test_main.py b/tests/test_main.py index f2c39dcc..7828ac13 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,11 +1,7 @@ -import json import os -import shutil from os.path import join from unittest import TestCase - -import httpretty from click.testing import CliRunner from .utils import ( @@ -18,28 +14,26 @@ require_api_key, require_connect, ) -from rsconnect.main import cli +from rsconnect.exception import RSConnectException +from rsconnect.main import cli, _validate_deploy_to_args, server_store from rsconnect import VERSION -def _error_to_response(error): - """ - HTTPretty is unable to show errors resulting from callbacks, so this method attempts to raise failure visibility by - passing the return back through HTTP. - """ - return [500, {}, str(error)] - +class TestMain(TestCase): + def test_validate_deploy_to_args(self): + server_store.set("fake", "http://example.com", None) -def _load_json(data): - if isinstance(data, bytes): - return json.loads(data.decode()) - return json.loads(data) + try: + with self.assertRaises(RSConnectException): + _validate_deploy_to_args("name", "url", None, False, None) + with self.assertRaises(RSConnectException): + _validate_deploy_to_args(None, None, None, False, None) -class TestMain(TestCase): - def setUp(self): - shutil.rmtree("test-home", ignore_errors=True) - os.environ["HOME"] = "test-home" + with self.assertRaises(RSConnectException): + _validate_deploy_to_args("fake", None, None, False, None) + finally: + server_store.remove_by_name("fake") def require_connect(self): connect_server = os.environ.get("CONNECT_SERVER", None) @@ -110,233 +104,9 @@ def test_deploy_manifest(self): result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) - # noinspection SpellCheckingInspection - @httpretty.activate(verbose=True, allow_net_connect=False) - def test_deploy_manifest_shinyapps(self): - original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) - original_server_value = os.environ.pop("CONNECT_SERVER", None) - - httpretty.register_uri( - httpretty.GET, - "https://api.shinyapps.io/v1/users/me", - body=open("tests/testdata/shinyapps-responses/get-user.json", "r").read(), - status=200, - ) - httpretty.register_uri( - httpretty.GET, - "https://api.shinyapps.io/v1/applications" - "?filter=name:like:shinyapp&offset=0&count=100&use_advanced_filters=true", - body=open("tests/testdata/shinyapps-responses/get-applications.json", "r").read(), - adding_headers={"Content-Type": "application/json"}, - status=200, - ) - httpretty.register_uri( - httpretty.GET, - "https://api.shinyapps.io/v1/accounts/", - body=open("tests/testdata/shinyapps-responses/get-accounts.json", "r").read(), - adding_headers={"Content-Type": "application/json"}, - status=200, - ) - - def post_application_callback(request, uri, response_headers): - parsed_request = _load_json(request.body) - try: - self.assertDictEqual(parsed_request, {"account": 82069, "name": "myapp", "template": "shiny"}) - except AssertionError as e: - return _error_to_response(e) - return [ - 201, - {"Content-Type": "application/json"}, - open("tests/testdata/shinyapps-responses/create-application.json", "r").read(), - ] - - httpretty.register_uri( - httpretty.POST, - "https://api.shinyapps.io/v1/applications/", - body=post_application_callback, - status=200, - ) - - def post_bundle_callback(request, uri, response_headers): - parsed_request = _load_json(request.body) - del parsed_request["checksum"] - del parsed_request["content_length"] - try: - self.assertDictEqual( - parsed_request, - { - "application": 8442, - "content_type": "application/x-tar", - }, - ) - except AssertionError as e: - return _error_to_response(e) - return [ - 201, - {"Content-Type": "application/json"}, - open("tests/testdata/shinyapps-responses/create-bundle.json", "r").read(), - ] - - httpretty.register_uri( - httpretty.POST, - "https://api.shinyapps.io/v1/bundles", - body=post_bundle_callback, - ) - - httpretty.register_uri( - httpretty.PUT, - "https://lucid-uploads-staging.s3.amazonaws.com/bundles/application-8442/" - "6c9ed0d91ee9426687d9ac231d47dc83.tar.gz" - "?AWSAccessKeyId=theAccessKeyId" - "&Signature=dGhlU2lnbmF0dXJlCg%3D%3D" - "&content-md5=D1blMI4qTiI3tgeUOYXwkg%3D%3D" - "&content-type=application%2Fx-tar" - "&x-amz-security-token=dGhlVG9rZW4K" - "&Expires=1656715153", - body="", - ) - - def post_bundle_status_callback(request, uri, response_headers): - parsed_request = _load_json(request.body) - try: - self.assertDictEqual(parsed_request, {"status": "ready"}) - except AssertionError as e: - return _error_to_response(e) - return [303, {"Location": "https://api.shinyapps.io/v1/bundles/12640"}, ""] - - httpretty.register_uri( - httpretty.POST, - "https://api.shinyapps.io/v1/bundles/12640/status", - body=post_bundle_status_callback, - ) - - httpretty.register_uri( - httpretty.GET, - "https://api.shinyapps.io/v1/bundles/12640", - body=open("tests/testdata/shinyapps-responses/get-accounts.json", "r").read(), - adding_headers={"Content-Type": "application/json"}, - status=200, - ) - - def post_deploy_callback(request, uri, response_headers): - parsed_request = _load_json(request.body) - try: - self.assertDictEqual(parsed_request, {"bundle": 12640, "rebuild": False}) - except AssertionError as e: - return _error_to_response(e) - return [ - 303, - {"Location": "https://api.shinyapps.io/v1/tasks/333"}, - open("tests/testdata/shinyapps-responses/post-deploy.json", "r").read(), - ] - - httpretty.register_uri( - httpretty.POST, - "https://api.shinyapps.io/v1/applications/8442/deploy", - body=post_deploy_callback, - ) - - httpretty.register_uri( - httpretty.GET, - "https://api.shinyapps.io/v1/tasks/333", - body=open("tests/testdata/shinyapps-responses/get-task.json", "r").read(), - adding_headers={"Content-Type": "application/json"}, - status=200, - ) - - runner = CliRunner() - args = [ - "deploy", - "manifest", - get_manifest_path("shinyapp"), - "--account", - "some-account", - "--token", - "someToken", - "--secret", - "c29tZVNlY3JldAo=", - "--title", - "myApp", - ] - try: - result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0, result.output) - finally: - if original_api_key_value: - os.environ["CONNECT_API_KEY"] = original_api_key_value - if original_server_value: - os.environ["CONNECT_SERVER"] = original_server_value - def test_deploy_api(self): target = optional_target(get_api_path("flask")) runner = CliRunner() args = self.create_deploy_args("api", target) result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) - - def test_add_connect(self): - connect_server = self.require_connect() - api_key = self.require_api_key() - runner = CliRunner() - result = runner.invoke(cli, ["add", "--name", "my-connect", "--server", connect_server, "--api-key", api_key]) - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("OK", result.output) - - @httpretty.activate(verbose=True, allow_net_connect=False) - def test_add_shinyapps(self): - original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) - original_server_value = os.environ.pop("CONNECT_SERVER", None) - try: - httpretty.register_uri( - httpretty.GET, "https://api.shinyapps.io/v1/users/me", body='{"id": 1000}', status=200 - ) - - runner = CliRunner() - result = runner.invoke( - cli, - [ - "add", - "--account", - "some-account", - "--name", - "my-shinyapps", - "--token", - "someToken", - "--secret", - "c29tZVNlY3JldAo=", - ], - ) - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("shinyapps.io credential", result.output) - - finally: - if original_api_key_value: - os.environ["CONNECT_API_KEY"] = original_api_key_value - if original_server_value: - os.environ["CONNECT_SERVER"] = original_server_value - - def test_add_shinyapps_missing_options(self): - original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) - original_server_value = os.environ.pop("CONNECT_SERVER", None) - try: - runner = CliRunner() - result = runner.invoke( - cli, - [ - "add", - "--name", - "my-shinyapps", - "--token", - "someToken", - ], - ) - self.assertEqual(result.exit_code, 1, result.output) - self.assertEqual( - str(result.exception), - "-a/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io.", - ) - finally: - if original_api_key_value: - os.environ["CONNECT_API_KEY"] = original_api_key_value - if original_server_value: - os.environ["CONNECT_SERVER"] = original_server_value diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 651d20cf..ffcefe48 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -19,14 +19,7 @@ def setUp(self): self.server_store.set("foo", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") self.server_store.set("bar", "http://connect.remote", "differentApiKey", insecure=True) - self.server_store.set( - "baz", - "https://shinyapps.io", - account_name="some-account", - token="someToken", - secret="c29tZVNlY3JldAo=", - ) - self.assertEqual(len(self.server_store.get_all_servers()), 3, "Unexpected servers after setup") + self.assertEqual(len(self.server_store.get_all_servers()), 2, "Unexpected servers after setup") def tearDown(self): # clean up our temp test directory created with tempfile.mkdtemp() @@ -55,17 +48,6 @@ def test_add(self): ), ) - self.assertEqual( - self.server_store.get_by_name("baz"), - dict( - name="baz", - url="https://shinyapps.io", - account_name="some-account", - token="someToken", - secret="c29tZVNlY3JldAo=", - ), - ) - def test_remove_by_name(self): self.server_store.remove_by_name("foo") self.assertIsNone(self.server_store.get_by_name("foo")) @@ -82,28 +64,28 @@ def test_remove_by_url(self): def test_remove_not_found(self): self.assertFalse(self.server_store.remove_by_name("frazzle")) - self.assertEqual(len(self.server_store.get_all_servers()), 3) + self.assertEqual(len(self.server_store.get_all_servers()), 2) self.assertFalse(self.server_store.remove_by_url("http://frazzle")) - self.assertEqual(len(self.server_store.get_all_servers()), 3) + self.assertEqual(len(self.server_store.get_all_servers()), 2) def test_list(self): servers = self.server_store.get_all_servers() - self.assertEqual(len(servers), 3) + self.assertEqual(len(servers), 2) self.assertEqual(servers[0]["name"], "bar") self.assertEqual(servers[0]["url"], "http://connect.remote") - self.assertEqual(servers[1]["name"], "baz") - self.assertEqual(servers[1]["url"], "https://shinyapps.io") - self.assertEqual(servers[2]["name"], "foo") - self.assertEqual(servers[2]["url"], "http://connect.local") + self.assertEqual(servers[1]["name"], "foo") + self.assertEqual(servers[1]["url"], "http://connect.local") def check_resolve_call(self, name, server, api_key, insecure, ca_cert, should_be_from_store): - server_data = self.server_store.resolve(name, server) + server, api_key, insecure, ca_cert, from_store = self.server_store.resolve( + name, server, api_key, insecure, ca_cert + ) - self.assertEqual(server_data.url, "http://connect.local") - self.assertEqual(server_data.api_key, "notReallyAnApiKey") - self.assertEqual(server_data.insecure, False) - self.assertEqual(server_data.ca_data, "/certs/connect") - self.assertTrue(server_data.from_store, should_be_from_store) + self.assertEqual(server, "http://connect.local") + self.assertEqual(api_key, "notReallyAnApiKey") + self.assertEqual(insecure, False) + self.assertEqual(ca_cert, "/certs/connect") + self.assertTrue(from_store, should_be_from_store) def test_resolve_by_name(self): self.check_resolve_call("foo", None, None, None, None, True) @@ -113,26 +95,31 @@ def test_resolve_by_url(self): def test_resolve_by_default(self): # with multiple entries, server None will not resolve by default - server_data = self.server_store.resolve(None, None) - self.assertEqual(server_data.url, None) + name, server, api_key, insecure, ca_cert = None, None, None, None, None + server, api_key, insecure, ca_cert, _ = self.server_store.resolve(name, server, api_key, insecure, ca_cert) + self.assertEqual(server, None) # with only a single entry, server None will resolve to that entry self.server_store.remove_by_url("http://connect.remote") - self.server_store.remove_by_url("https://shinyapps.io") self.check_resolve_call(None, None, None, None, None, True) def test_resolve_from_args(self): - name, server = ( + name, server, api_key, insecure, ca_cert = ( None, "https://secured.connect", + "an-api-key", + True, + "fake-cert", + ) + server, api_key, insecure, ca_cert, from_store = self.server_store.resolve( + name, server, api_key, insecure, ca_cert ) - server_data = self.server_store.resolve(name, server) - self.assertEqual(server_data.url, "https://secured.connect") - self.assertEqual(server_data.api_key, None) - self.assertEqual(server_data.insecure, None) - self.assertEqual(server_data.ca_data, None) - self.assertFalse(server_data.from_store) + self.assertEqual(server, "https://secured.connect") + self.assertEqual(api_key, "an-api-key") + self.assertTrue(insecure) + self.assertEqual(ca_cert, "fake-cert") + self.assertFalse(from_store) def test_save_and_load(self): temp = tempfile.mkdtemp() @@ -141,7 +128,7 @@ def test_save_and_load(self): self.assertFalse(exists(path)) - server_store.set("foo", "http://connect.local", api_key="notReallyAnApiKey", ca_data="/certs/connect") + server_store.set("foo", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") self.assertTrue(exists(path)) @@ -268,8 +255,8 @@ def test_normalize_server_url(self): class TestBuildMetadata(TestCase): def setUp(self): self.server_store = ServerStore() - self.server_store.set("connect", "https://connect.remote:6443", api_key="apiKey", insecure=True) - self.server = RSConnectServer("https://connect.remote:6443", api_key="apiKey", insecure=True, ca_data=None) + self.server_store.set("connect", "https://connect.remote:6443", "apiKey", insecure=True) + self.server = RSConnectServer("https://connect.remote:6443", "apiKey", True, None) self.build_store = ContentBuildStore(self.server) self.build_store._set("rsconnect_build_running", False) self.build_store._set( diff --git a/tests/testdata/shinyapps-responses/create-application.json b/tests/testdata/shinyapps-responses/create-application.json deleted file mode 100644 index f238305f..00000000 --- a/tests/testdata/shinyapps-responses/create-application.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "id": 8442, - "name": "myapp", - "uuid": "799b44bc47c1424cab750fe9e5261792", - "url": "https://some-account.staging.shinyapps.io/myapp/", - "type": "shiny", - "mode": null, - "scheduler": "legacy", - "status": "pending", - "account_id": 1037, - "content_id": null, - "logplex_channel": null, - "logplex_token": null, - "storage_initialized": true, - "deployment_id": 10258, - "deployment": { - "id": 10258, - "application_memory_limit": 1024, - "application_cpu_limit": 1, - "application_timeout_minutes": 15, - "application_timeout_kill_minutes": 60, - "application_os_version": "xenial", - "image": { - "id": 3341, - "app_id": 8442, - "active": true, - "status": "ready", - "bundle_id": 485, - "bundle": { - "id": 485, - "app_id": 8442, - "user_id": 26, - "status": "ready", - "name": null, - "url": "s3://lucid-uploads-staging/bundles/application-8442/f34d4cd69fc74eeab7ddbf944b63c3af.tar.gz", - "checksum": "101f6fb239fdab6852f20257640abfda", - "parent_id": null, - "created_time": "2018-07-20T23:05:44", - "updated_time": "2018-07-20T23:05:45" - }, - "manifest": null, - "repository": "app-8442", - "registry": "registry01", - "tag": "image-3341", - "agents": [], - "created_time": "2018-07-20T23:05:47", - "updated_time": "2021-06-03T21:44:03" - }, - "properties": { - "application.visibility": "public", - "application.base-image.registry": null, - "application.base-image.repository": null, - "application.base-image.compiler": null, - "application.build-pool": null, - "application.ide.image.tag": null, - "application.jupyter.image.tag": null, - "application.initialize.image": null, - "application.initialize.image.tag": null, - "application.sidecar.image.tag": null, - "application.ide.autosave.on.idle": false, - "application.instances.template": "large", - "application.instances.start": 1, - "application.instances.max": 3, - "application.instances.load.factor": 0.5, - "application.instances.idle-threshold": 15, - "application.instances.down-threshold": 1, - "application.instances.fault-threshold": 1, - "application.instances.agent-pool": null, - "application.shiny.timeout.init": 60, - "application.shiny.timeout.idle": 5, - "application.shiny.timeout.conn": 900, - "application.shiny.timeout.read": 3600, - "application.shiny.timeout.session": 3600, - "application.shiny.timeout.reconnect": null, - "application.shiny.scheduler.max.requests": 20, - "application.shiny.scheduler.max.processes": 3, - "application.shiny.scheduler.min.processes": 0, - "application.shiny.scheduler.load.factor": 0.05, - "application.shiny.websockets": false, - "application.shiny.sockjs.protocols.disabled": null, - "application.connect.debug.log": "", - "application.connect.client.debugging": false, - "application.connect.client.transport.debugging": false, - "application.connect.version": "current", - "application.package.cache": true, - "application.connect.injection.version": 62, - "application.connect.branding": false, - "application.ide.version": "current", - "application.frontend.iFrameResizer.log": false, - "application.frontend.iFrameResizer.sizeHeight": true, - "application.frontend.iFrameResizer.sizeWidth": false, - "application.frontend.iFrameResizer.heightCalculationMethod": "bodyOffset", - "application.frontend.iFrameResizer.widthCalculationMethod": "bodyOffset", - "application.storage.size": "20G", - "application.unmigratable": "" - }, - "environment": {}, - "user": null, - "created_time": "2018-07-20T23:05:47", - "updated_time": "2018-07-20T23:05:47" - }, - "environment": {}, - "resources": { - "memory_limit": 1024, - "cpu_limit": 1, - "effective_memory_limit": 1024, - "effective_cpu_limit": 1 - }, - "configuration": { - "timeout_minutes": 15, - "timeout_kill_minutes": 60, - "effective_timeout_minutes": 15, - "effective_timeout_kill_minutes": 60 - }, - "runtime_options": null, - "next_deployment_id": null, - "prev_deployment_id": 10257, - "clone_parent_id": null, - "copy_parent_id": null, - "storage": [], - "exportable": true, - "created_time": "2018-07-20T22:46:41", - "updated_time": "2018-07-20T23:06:06" -} diff --git a/tests/testdata/shinyapps-responses/create-bundle.json b/tests/testdata/shinyapps-responses/create-bundle.json deleted file mode 100644 index 3d1d71e6..00000000 --- a/tests/testdata/shinyapps-responses/create-bundle.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": 12640, - "app_id": 8442, - "user_id": 47261, - "status": "pending", - "name": null, - "url": "s3://lucid-uploads-staging/bundles/application-1122350/6c9ed0d91ee9426687d9ac231d47dc83.tar.gz", - "checksum": "0f56e5308e2a4e2237b607943985f092", - "parent_id": null, - "created_time": "2022-07-01T21:39:13", - "updated_time": "2022-07-01T21:39:13", - "presigned_url": "https://lucid-uploads-staging.s3.amazonaws.com/bundles/application-8442/6c9ed0d91ee9426687d9ac231d47dc83.tar.gz?AWSAccessKeyId=theAccessKeyId&Signature=dGhlU2lnbmF0dXJlCg%3D%3D&content-md5=D1blMI4qTiI3tgeUOYXwkg%3D%3D&content-type=application%2Fx-tar&x-amz-security-token=dGhlVG9rZW4K&Expires=1656715153", - "presigned_checksum": "D1blMI4qTiI3tgeUOYXwkg==" -} diff --git a/tests/testdata/shinyapps-responses/get-accounts.json b/tests/testdata/shinyapps-responses/get-accounts.json deleted file mode 100644 index 1c31c457..00000000 --- a/tests/testdata/shinyapps-responses/get-accounts.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "count": 1, - "total": 1, - "offset": 0, - "accounts": [ - { - "id": 82069, - "name": "some-account", - "type": "individual", - "display_name": null, - "permissions": [ - "DELETE_LEGACY_TASK", - "CREATE_LEGACY_TASK_LOGS", - "MODIFY_VOLUME", - "DELETE_VOLUME", - "VIEW_ACCOUNT_TASK", - "VIEW_VOLUME", - "VIEW_DOMAIN", - "VIEW_ACCOUNT_NOTIFICATION_PREFERENCE", - "CREATE_APPLICATION", - "VIEW_ACCOUNT_USERS", - "CREATE_VOLUME", - "MANAGE_ACCOUNT_NOTIFICATION_PREFERENCE", - "DELETE_ACCOUNT", - "VIEW_ACCOUNT", - "DELETE_DOMAIN", - "VIEW_LEGACY_TASK", - "PURGE_VOLUME", - "MODIFY_LEGACY_TASK", - "CREATE_CONTENT_IN_NULL_SPACE", - "KILL_LEGACY_TASK", - "CREATE_SPACE", - "MANAGE_ACCOUNT", - "VIEW_ACCOUNT_USAGE", - "CREATE_CONTENT_IN_NAMED_SPACE", - "MANAGE_ACCOUNT_SUBSCRIPTION", - "VIEW_LEGACY_TASK_LOGS", - "PROVISION_VOLUME", - "MANAGE_ACCOUNT_ACCESS", - "CREATE_DOMAIN" - ], - "suspended_until": null, - "suspended_reason": null, - "suspended_pending": null, - "sso_enabled": false, - "sso_hint": null, - "beta_enabled": false, - "beta": { - "jupyter_enabled": false, - "publishing_enabled": true, - "sharing_enabled": false - }, - "owner": { - "id": 47261, - "first_name": "Example", - "last_name": "User", - "display_name": "Example User", - "organization": null, - "homepage": null, - "location": null, - "picture_url": "https://www.gravatar.com/avatar/e1bc03fb1393b9b1c3b3dc309cc0b4cf", - "email": "example.user@rstudio.com", - "superuser": false, - "email_verified": true, - "local_auth": true, - "referral": null, - "google_auth_id": "100000000000000000000", - "github_auth_id": null, - "github_auth_token": null, - "last_login_attempt": "2022-05-31T22:20:28", - "login_attempts": 0, - "lockout_until": null, - "sso_account_id": null, - "grant": null, - "created_time": "2021-09-28T19:32:47", - "updated_time": "2022-05-31T22:20:28" - }, - "subscription": { - "id": 82069, - "type": { - "id": 4, - "name": "professional", - "premium": true, - "created_time": "2016-11-03T15:37:09", - "updated_time": "2016-11-03T15:37:09" - }, - "start_date": null, - "end_date": null, - "cancel_date": null, - "cycle_start_date": "2022-06-28T19:32:52", - "cycle_end_date": "2022-07-28T19:32:52", - "status": null, - "plan": null, - "discount": null, - "billing": { - "name": null, - "email": null, - "organization": null, - "address": { - "address_1": null, - "address_2": null, - "city": null, - "state": null, - "zip": null, - "country": null - } - }, - "payment": null, - "entitlements": { - "AuthenticationFeature": { - "enabled": true - }, - "BrandingFeature": { - "enabled": true - }, - "MaxApplicationsLimit": { - "limit": 1000 - }, - "MaxInstancesLimit": { - "limit": 10 - }, - "InstanceIdleLimit": { - "limit": 480 - }, - "BundleSizeLimit": { - "limit": 50000000000 - }, - "MaxCustomDomainsLimit": { - "limit": 100 - }, - "MaxAccountUsersLimit": { - "limit": 25 - }, - "MaxApplicationWorkersLimit": { - "limit": 10 - }, - "MaxApplicationHoursLimit": { - "limit": 10000 - }, - "MaxProjectsLimit": { - "limit": 100000 - }, - "MaxPublicSpacesLimit": { - "limit": 100 - }, - "MaxPrivateSpacesLimit": { - "limit": 100 - }, - "MaxApplicationCpuLimit": { - "limit": 1 - }, - "MaxApplicationMemoryLimit": { - "limit": 1024 - }, - "ApplicationInstanceType": { - "items": [ - "small", - "medium", - "large", - "xlarge", - "xxlarge", - "xxxlarge" - ] - } - }, - "created_time": "2021-09-28T19:32:52", - "updated_time": "2021-09-28T19:32:52" - }, - "licenses": [ - { - "id": 103876, - "type": "cloud", - "status": "active", - "name": "cloud-premium", - "expires": null, - "exempt": false, - "exempt_until": null, - "suspended": false, - "suspended_until": null, - "suspended_reason": null, - "suspension_pending": false, - "suspension_pending_until": null, - "cycle_anchor": "2021-11-09T16:09:31", - "cycle_start": "2022-06-09T16:09:31", - "cycle_end": "2022-07-09T16:09:31", - "overage_until": null, - "overage": false, - "created_time": "2021-09-28T19:32:52", - "updated_time": "2021-12-06T21:11:49" - } - ], - "account_role": "none", - "stripe_customer_id": null, - "created_time": "2021-09-28T19:32:52", - "updated_time": "2021-09-28T19:32:52" - } - ] -} diff --git a/tests/testdata/shinyapps-responses/get-applications.json b/tests/testdata/shinyapps-responses/get-applications.json deleted file mode 100644 index db604309..00000000 --- a/tests/testdata/shinyapps-responses/get-applications.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "count": 0, - "total": 0, - "offset": 0, - "applications": [] -} diff --git a/tests/testdata/shinyapps-responses/get-bundle.json b/tests/testdata/shinyapps-responses/get-bundle.json deleted file mode 100644 index 20c42f4e..00000000 --- a/tests/testdata/shinyapps-responses/get-bundle.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": 12640, - "app_id": 8442, - "user_id": 47261, - "status": "ready", - "name": null, - "url": "s3://lucid-uploads-staging/bundles/application-8442/6c9ed0d91ee9426687d9ac231d47dc83.tar.gz", - "checksum": "0f56e5308e2a4e2237b607943985f092", - "parent_id": null, - "created_time": "2022-07-01T21:39:13", - "updated_time": "2022-07-01T21:39:14" -} diff --git a/tests/testdata/shinyapps-responses/get-task.json b/tests/testdata/shinyapps-responses/get-task.json deleted file mode 100644 index 0bdb2962..00000000 --- a/tests/testdata/shinyapps-responses/get-task.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 333, - "account_id": 82069, - "parent_id": null, - "action": "application-deploy", - "description": "Stopping old instances", - "status": "success", - "error": null, - "finished": true, - "finished_time": "2022-07-05T16:07:16", - "arguments": { - "app_id": 8442, - "deployment_id": 850618 - }, - "created_time": "2022-07-05T16:06:42", - "updated_time": "2022-07-05T16:07:16" -} diff --git a/tests/testdata/shinyapps-responses/get-user.json b/tests/testdata/shinyapps-responses/get-user.json deleted file mode 100644 index 1e5351fb..00000000 --- a/tests/testdata/shinyapps-responses/get-user.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": 47261, - "first_name": "Example", - "last_name": "User", - "display_name": "Example User", - "organization": null, - "homepage": null, - "location": null, - "picture_url": "https://www.gravatar.com/avatar/e1bc03fb1393b9b1c3b3dc309cc0b4cf", - "email": "example.user@rstudio.com", - "superuser": false, - "email_verified": true, - "local_auth": true, - "referral": null, - "google_auth_id": "100000000000000000000", - "github_auth_id": null, - "github_auth_token": null, - "last_login_attempt": "2022-05-31T22:20:28", - "login_attempts": 0, - "lockout_until": null, - "sso_account_id": null, - "grant": null, - "created_time": "2021-09-28T19:32:47", - "updated_time": "2022-05-31T22:20:28" -} diff --git a/tests/testdata/shinyapps-responses/post-deploy.json b/tests/testdata/shinyapps-responses/post-deploy.json deleted file mode 100644 index c045572f..00000000 --- a/tests/testdata/shinyapps-responses/post-deploy.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "task_id": 333 -}