diff --git a/mock_connect/Dockerfile b/mock_connect/Dockerfile index 6abd3c20..99c7310a 100644 --- a/mock_connect/Dockerfile +++ b/mock_connect/Dockerfile @@ -2,4 +2,4 @@ FROM python:3.7-alpine MAINTAINER RStudio Connect # Add the Python packags we need. -RUN pip install flask +RUN pip install flask==2.1.3 diff --git a/rsconnect/actions.py b/rsconnect/actions.py index e49037f1..d52963d7 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -218,13 +218,13 @@ def test_server(connect_server): raise RSConnectException("\n".join(failures)) -def test_shinyapps_server(server: api.ShinyappsServer): - with api.ShinyappsClient(server) as client: +def test_rstudio_server(server: api.RStudioServer): + with api.RStudioClient(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)) + raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc)) def test_api_key(connect_server): @@ -1588,8 +1588,8 @@ def _gather_basic_deployment_info_for_framework( 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) + elif isinstance(remote_server, api.RStudioServer): + app = api.get_rstudio_app_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.") diff --git a/rsconnect/api.py b/rsconnect/api.py index 038e1827..1143193b 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1,6 +1,8 @@ """ RStudio Connect API client and utility functions """ +import binascii +import os from os.path import abspath import time from typing import IO, Callable @@ -62,19 +64,44 @@ def handle_bad_response(self, response): ) -class ShinyappsServer(AbstractRemoteServer): +class RStudioServer(AbstractRemoteServer): """ - A simple class to encapsulate the information needed to interact with an - instance of the shinyapps.io server. + A class used to represent the server of the shinyapps.io and RStudio Cloud APIs. """ - def __init__(self, url: str, account_name: str, token: str, secret: str): - super().__init__(url or "https://api.shinyapps.io", "shinyapps.io") + def __init__(self, remote_name: str, url: str, account_name: str, token: str, secret: str): + super().__init__(url, remote_name) self.account_name = account_name self.token = token self.secret = secret +class ShinyappsServer(RStudioServer): + """ + A 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): + remote_name = "shinyapps.io" + if url == "shinyapps.io" or url is None: + url = "https://api.shinyapps.io" + super().__init__(remote_name=remote_name, url=url, account_name=account_name, token=token, secret=secret) + + +class CloudServer(RStudioServer): + """ + A class to encapsulate the information needed to interact with an + instance of the RStudio Cloud server. + """ + + def __init__(self, url: str, account_name: str, token: str, secret: str): + remote_name = "RStudio Cloud" + if url == "rstudio.cloud" or url is None: + url = "https://api.rstudio.cloud" + super().__init__(remote_name=remote_name, url=url, account_name=account_name, token=token, secret=secret) + + class RSConnectServer(AbstractRemoteServer): """ A simple class to encapsulate the information needed to interact with an @@ -90,7 +117,7 @@ def __init__(self, url, api_key, insecure=False, ca_data=None): self.cookie_jar = CookieJar() -TargetableServer = typing.Union[ShinyappsServer, RSConnectServer] +TargetableServer = typing.Union[ShinyappsServer, RSConnectServer, CloudServer] class S3Server(AbstractRemoteServer): @@ -441,15 +468,18 @@ def setup_remote_server( 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) + if url and "rstudio.cloud" in url: + self.remote_server = CloudServer(url, account_name, token, secret) + else: + self.remote_server = ShinyappsServer(url, account_name, token, secret) else: raise RSConnectException("Unable to infer Connect server type and setup server.") 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) + elif isinstance(self.remote_server, RStudioServer): + self.client = RStudioClient(self.remote_server, timeout) else: raise RSConnectException("Unable to infer Connect client.") @@ -478,8 +508,8 @@ def validate_server( ): 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) + elif (url and token and secret) or isinstance(self.remote_server, RStudioServer): + self.validate_rstudio_server(url, account_name, token, secret) else: raise RSConnectException("Unable to validate server from information provided.") @@ -550,21 +580,25 @@ def validate_connect_server( return self - def validate_shinyapps_server( + def validate_rstudio_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) + server = ( + CloudServer(url, account_name, token, secret) + if "rstudio.cloud" in url + else ShinyappsServer(url, account_name, token, secret) + ) - with ShinyappsClient(server) as client: + with RStudioClient(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)) + raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc)) @cls_logged("Making bundle ...") def make_bundle(self, func: Callable, *args, **kwargs): @@ -616,7 +650,7 @@ 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): + if isinstance(self.remote_server, RStudioServer): return self details = self.server_details @@ -631,6 +665,18 @@ def check_server_capabilities(self, capability_functions): raise RSConnectException(message) return self + def upload_rstudio_bundle(self, prepare_deploy_result, bundle_size: int, contents): + 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) + @cls_logged("Deploying bundle ...") def deploy_bundle( self, @@ -665,27 +711,26 @@ def deploy_bundle( 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, + if isinstance(self.remote_server, ShinyappsServer): + shinyapps_service = ShinyappsService(self.client, self.remote_server) + prepare_deploy_result = shinyapps_service.prepare_deploy( + app_id, + deployment_name, bundle_size, - contents, + bundle_hash, ) - S3Server(upload_url).handle_bad_response(upload_result) - - self.client.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) + self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) + shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) + else: + cloud_service = CloudService(self.client, self.remote_server) + prepare_deploy_result = cloud_service.prepare_deploy( + app_id, + deployment_name, + bundle_size, + bundle_hash, + ) + self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) + cloud_service.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) @@ -795,8 +840,8 @@ def validate_app_mode(self, *args, **kwargs): 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) + elif isinstance(self.remote_server, RStudioServer): + app = get_rstudio_app_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.") @@ -942,15 +987,35 @@ def __init__(self, app_id: int, app_url: str, bundle_id: int, presigned_url: str self.presigned_checksum = presigned_checksum -class ShinyappsClient(HTTPServer): +class PrepareDeployOutputResult(PrepareDeployResult): + def __init__( + self, app_id: int, app_url: str, bundle_id: int, presigned_url: str, presigned_checksum: str, output_id: int + ): + super().__init__( + app_id=app_id, + app_url=app_url, + bundle_id=bundle_id, + presigned_url=presigned_url, + presigned_checksum=presigned_checksum, + ) + self.output_id = output_id + + +class RStudioClient(HTTPServer): + """ + An HTTP client to call the RStudio Cloud and shinyapps.io APIs. + """ _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 __init__(self, rstudio_server: RStudioServer, timeout: int = 30): + self._token = rstudio_server.token + try: + self._key = base64.b64decode(rstudio_server.secret) + except binascii.Error as e: + raise RSConnectException("Invalid secret.") from e + self._server = rstudio_server + super().__init__(rstudio_server.url, timeout=timeout) def _get_canonical_request(self, method, path, timestamp, content_hash): return "\n".join([method, path, timestamp, content_hash]) @@ -987,6 +1052,9 @@ def get_extra_headers(self, url, method, body): def get_application(self, application_id): return self.get("/v1/applications/{}".format(application_id)) + def get_content(self, content_id): + return self.get("/v1/content/{}".format(content_id)) + def create_application(self, account_id, application_name): application_data = { "account": account_id, @@ -995,6 +1063,14 @@ def create_application(self, account_id, application_name): } return self.post("/v1/applications/", body=application_data) + def create_output(self, name, project_id=None, space_id=None): + data = { + "name": name, + "space": space_id, + "project": project_id, + } + return self.post("/v1/outputs/", body=data) + def get_accounts(self): return self.get("/v1/accounts/") @@ -1032,23 +1108,53 @@ def wait_until_task_is_successful(self, task_id, timeout=180): while time.time() - start_time < timeout: task = self.get_task(task_id) self._server.handle_bad_response(task) + finished = task.json_data["finished"] status = task.json_data["status"] description = task.json_data["description"] error = task.json_data["error"] - if status == "success": + if finished: break - if status in {"failed", "error"}: - raise RSConnectException("Application deployment failed with error: {}".format(error)) - print(" {} - {}".format(status, description)) time.sleep(2) + if not finished: + raise RSConnectException("Application deployment timed out.") + + if status != "success": + raise RSConnectException("Application deployment failed with error: {}".format(error)) + 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() + 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] + + +class ShinyappsService: + """ + Encapsulates operations involving multiple API calls to shinyapps.io. + """ + + def __init__(self, rstudio_client: RStudioClient, server: ShinyappsServer): + self._rstudio_client = rstudio_client + self._server = server + + def prepare_deploy(self, app_id: typing.Optional[int], app_name: str, bundle_size: int, bundle_hash: str): + accounts = self._rstudio_client.get_accounts() self._server.handle_bad_response(accounts) account = next( filter(lambda acct: acct["name"] == self._server.account_name, accounts.json_data["accounts"]), None @@ -1060,14 +1166,14 @@ def prepare_deploy(self, app_id: typing.Optional[str], app_name: str, bundle_siz ) if app_id is None: - application = self.create_application(account["id"], app_name) + application = self._rstudio_client.create_application(account["id"], app_name) else: - application = self.get_application(app_id) + application = self._rstudio_client.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) + bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) self._server.handle_bad_response(bundle) return PrepareDeployResult( @@ -1079,28 +1185,88 @@ def prepare_deploy(self, app_id: typing.Optional[str], app_name: str, bundle_siz ) def do_deploy(self, bundle_id, app_id): - bundle_status_response = self.set_bundle_status(bundle_id, "ready") + bundle_status_response = self._rstudio_client.set_bundle_status(bundle_id, "ready") self._server.handle_bad_response(bundle_status_response) - deploy_task = self.deploy_application(bundle_id, app_id) + deploy_task = self._rstudio_client.deploy_application(bundle_id, app_id) self._server.handle_bad_response(deploy_task) - self.wait_until_task_is_successful(deploy_task.json_data["id"]) + self._rstudio_client.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 +class CloudService: + """ + Encapsulates operations involving multiple API calls to RStudio Cloud. + """ - 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"]) + def __init__(self, rstudio_client: RStudioClient, server: CloudServer): + self._rstudio_client = rstudio_client + self._server = server - return [app["name"] for app in applications] + def prepare_deploy( + self, + app_id: typing.Optional[int], + app_name: str, + bundle_size: int, + bundle_hash: str, + ): + accounts = self._rstudio_client.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: + project_application_id = os.getenv("LUCID_APPLICATION_ID") + if project_application_id is not None: + project_application = self._rstudio_client.get_application(project_application_id) + self._server.handle_bad_response(project_application) + project_id = project_application.json_data["content_id"] + project = self._rstudio_client.get_content(project_id) + self._server.handle_bad_response(project) + space_id = project.json_data["space_id"] + else: + project_id = None + space_id = None + + output = self._rstudio_client.create_output(name=app_name, project_id=project_id, space_id=space_id) + self._server.handle_bad_response(output) + app_id = output.json_data["source_id"] + application = self._rstudio_client.get_application(app_id) + self._server.handle_bad_response(application) + else: + application = self._rstudio_client.get_application(app_id) + self._server.handle_bad_response(application) + output = self._rstudio_client.get_content(application.json_data["content_id"]) + self._server.handle_bad_response(output) + + app_id_int = application.json_data["id"] + app_url = output.json_data["url"] + output_id = output.json_data["id"] + + bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) + self._server.handle_bad_response(bundle) + + return PrepareDeployOutputResult( + app_id=app_id_int, + app_url=app_url, + bundle_id=int(bundle.json_data["id"]), + presigned_url=bundle.json_data["presigned_url"], + presigned_checksum=bundle.json_data["presigned_checksum"], + output_id=output_id, + ) + + def do_deploy(self, bundle_id, app_id): + bundle_status_response = self._rstudio_client.set_bundle_status(bundle_id, "ready") + self._server.handle_bad_response(bundle_status_response) + + deploy_task = self._rstudio_client.deploy_application(bundle_id, app_id) + self._server.handle_bad_response(deploy_task) + self._rstudio_client.wait_until_task_is_successful(deploy_task.json_data["id"]) def verify_server(connect_server): @@ -1169,8 +1335,8 @@ def get_app_info(connect_server, app_id): return result -def get_shinyapp_info(server, app_id): - with ShinyappsClient(server) as client: +def get_rstudio_app_info(server, app_id): + with RStudioClient(server) as client: result = client.get_application(app_id) server.handle_bad_response(result) return result @@ -1378,9 +1544,12 @@ def find_unique_name(remote_server: TargetableServer, name: str): filters={"search": name}, mapping_function=lambda client, app: app["name"], ) - else: - client = ShinyappsClient(remote_server) + elif isinstance(remote_server, ShinyappsServer): + client = RStudioClient(remote_server) existing_names = client.get_applications_like_name(name) + else: + # non-unique names are permitted in cloud + return name if name in existing_names: suffix = 1 diff --git a/rsconnect/main.py b/rsconnect/main.py index daba93ba..96ab0c5c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -21,7 +21,7 @@ test_server, validate_quarto_engines, which_quarto, - test_shinyapps_server, + test_rstudio_server, ) from .actions_content import ( download_bundle, @@ -133,24 +133,24 @@ def wrapper(*args, **kwargs): return wrapper -def shinyapps_args(func): +def rstudio_args(func): @click.option( "--account", "-A", - envvar="SHINYAPPS_ACCOUNT", - help="The shinyapps.io account name.", + envvar=["SHINYAPPS_ACCOUNT", "RSCLOUD_ACCOUNT"], + help="The shinyapps.io/RStudio Cloud account name.", ) @click.option( "--token", "-T", - envvar="SHINYAPPS_TOKEN", - help="The shinyapps.io token.", + envvar=["SHINYAPPS_TOKEN", "RSCLOUD_TOKEN"], + help="The shinyapps.io/RStudio Cloud token.", ) @click.option( "--secret", "-S", - envvar="SHINYAPPS_SECRET", - help="The shinyapps.io token secret.", + envvar=["SHINYAPPS_SECRET", "RSCLOUD_SECRET"], + help="The shinyapps.io/RStudio Cloud token secret.", ) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -224,18 +224,21 @@ def wrapper(*args, **kwargs): @click.option("--future", "-u", is_flag=True, hidden=True, help="Enables future functionality.") def cli(future): """ - This command line tool may be used to deploy Jupyter notebooks to RStudio - Connect. Support for deploying other content types is also provided. + This command line tool may be used to deploy various types of content to RStudio + Connect, RStudio Cloud, and shinyapps.io. The tool supports the notion of a simple nickname that represents the - information needed to interact with an RStudio Connect server instance. Use - the add, list and remove commands to manage these nicknames. + information needed to interact with a deployment target. Usethe add, list and + remove commands to manage these nicknames. The information about an instance of RStudio Connect includes its URL, the API key needed to authenticate against that instance, a flag that notes whether TLS certificate/host verification should be disabled and a path to a trusted CA certificate file to use for TLS. The last two items are only relevant if the URL specifies the "https" protocol. + + For RStudio Cloud and shinyapps.io, the information needed to connect includes + the account, auth token, auth secret, and server ('rstudio.cloud' or 'shinyapps.io'). """ global future_enabled future_enabled = future @@ -273,16 +276,16 @@ 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) +def _test_rstudio_creds(server: api.RStudioServer): + with cli_feedback("Checking {} credential".format(server.remote_name)): + test_rstudio_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, RStudio Cloud, or shinyapps.io server and credential.", help=( - "Associate a simple nickname with the information needed to interact with an RStudio Connect server. " + "Associate a simple nickname with the information needed to interact with a deployment target. " "Specifying an existing nickname will cause its stored information to be replaced by what is given " "on the command line." ), @@ -292,7 +295,7 @@ def _test_shinyappsio_creds(server: api.ShinyappsServer): "--server", "-s", envvar="CONNECT_SERVER", - help="The URL for the RStudio Connect server to deploy to.", + help="The URL for the RStudio Connect server to deploy to, OR rstudio.cloud OR shinyapps.io.", ) @click.option( "--api-key", @@ -315,7 +318,7 @@ def _test_shinyappsio_creds(server: api.ShinyappsServer): help="The path to trusted TLS CA certificates.", ) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") -@shinyapps_args +@rstudio_args @click.pass_context def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, verbose): @@ -341,20 +344,24 @@ def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, ve old_server = server_store.get_by_name(name) if account: - shinyapps_server = api.ShinyappsServer(server, account, token, secret) - _test_shinyappsio_creds(shinyapps_server) + if server and "rstudio.cloud" in server: + real_server = api.CloudServer(server, account, token, secret) + else: + real_server = api.ShinyappsServer(server, account, token, secret) + + _test_rstudio_creds(real_server) server_store.set( name, - shinyapps_server.url, - account_name=shinyapps_server.account_name, - token=shinyapps_server.token, - secret=shinyapps_server.secret, + real_server.url, + account_name=real_server.account_name, + token=real_server.token, + secret=real_server.secret, ) if old_server: - click.echo('Updated shinyapps.io credential "%s".' % name) + click.echo('Updated {} credential "{}".'.format(real_server.remote_name, name)) else: - click.echo('Added shinyapps.io credential "%s".' % name) + click.echo('Added {} credential "{}".'.format(real_server.remote_name, 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) @@ -543,7 +550,7 @@ def info(file): click.echo("No saved deployment information was found for %s." % file) -@cli.group(no_args_is_help=True, help="Deploy content to RStudio Connect.") +@cli.group(no_args_is_help=True, help="Deploy content to RStudio Connect, RStudio Cloud, or shinyapps.io.") def deploy(): pass @@ -745,7 +752,7 @@ def deploy_notebook( # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="manifest", - short_help="Deploy content to RStudio Connect by manifest.", + short_help="Deploy content to RStudio Connect, RStudio Cloud, or shinyapps.io by manifest.", help=( "Deploy content to RStudio Connect using an existing manifest.json " 'file. The specified file must either be named "manifest.json" or ' @@ -754,7 +761,7 @@ def deploy_notebook( ) @server_args @content_args -@shinyapps_args +@rstudio_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @cli_exception_handler def deploy_manifest( @@ -993,23 +1000,21 @@ def deploy_html( ) -def generate_deploy_python(app_mode, alias, min_version, supported_by_shinyapps=False): - shinyapps = shinyapps_args if supported_by_shinyapps else _passthrough - +def generate_deploy_python(app_mode, alias, min_version): # noinspection SpellCheckingInspection @deploy.command( name=alias, - short_help="Deploy a {desc} to RStudio Connect [v{version}+].".format( + short_help="Deploy a {desc} to RStudio Connect [v{version}+], RStudio Cloud, or shinyapps.io.".format( desc=app_mode.desc(), version=min_version ), help=( - 'Deploy a {desc} module to RStudio Connect. The "directory" argument must refer to an ' - "existing directory that contains the application code." + "Deploy a {desc} module to RStudio Connect, RStudio Cloud, or shinyapps.io (if supported by the platform). " + 'The "directory" argument must refer to an existing directory that contains the application code.' ).format(desc=app_mode.desc()), ) @server_args @content_args - @shinyapps + @rstudio_args @click.option( "--entrypoint", "-e", @@ -1125,9 +1130,7 @@ def deploy_app( deploy_dash_app = generate_deploy_python(app_mode=AppModes.DASH_APP, alias="dash", min_version="1.8.2") deploy_streamlit_app = generate_deploy_python(app_mode=AppModes.STREAMLIT_APP, alias="streamlit", min_version="1.8.4") deploy_bokeh_app = generate_deploy_python(app_mode=AppModes.BOKEH_APP, alias="bokeh", min_version="1.8.4") -deploy_shiny = generate_deploy_python( - app_mode=AppModes.PYTHON_SHINY, alias="shiny", min_version="2022.07.0", supported_by_shinyapps=True -) +deploy_shiny = generate_deploy_python(app_mode=AppModes.PYTHON_SHINY, alias="shiny", min_version="2022.07.0") @deploy.command( diff --git a/rsconnect/validation.py b/rsconnect/validation.py index 0fdf9d18..f594238d 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -33,11 +33,13 @@ def validate_connection_options(url, api_key, insecure, cacert, account_name, to if present_connect_options and present_shinyapps_options: raise RSConnectException( - "Connect options ({}) may not be passed alongside shinyapps.io options ({}).".format( + "Connect options ({}) may not be passed alongside shinyapps.io or RStudio Cloud 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.") + raise RSConnectException( + "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io or RStudio Cloud." + ) diff --git a/tests/test_api.py b/tests/test_api.py index cbd0d5ba..925db932 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,8 +8,8 @@ class TestAPI(TestCase): def test_executor_init(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() ce = RSConnectExecutor(None, connect_server, api_key, True, None) self.assertEqual(ce.remote_server.url, connect_server) @@ -47,8 +47,8 @@ def test_to_server_check_list(self): self.assertEqual(a_list, ["scheme://no-scheme"]) def test_make_deployment_name(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() ce = RSConnectExecutor(None, connect_server, api_key, True, None) self.assertEqual(ce.make_deployment_name("title", False), "title") self.assertEqual(ce.make_deployment_name("Title", False), "title") diff --git a/tests/test_main.py b/tests/test_main.py index bed0820a..938c1f9e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,9 +3,8 @@ import shutil from os.path import join -from unittest import TestCase - import httpretty +import pytest from click.testing import CliRunner from .utils import ( @@ -36,23 +35,11 @@ def _load_json(data): return json.loads(data) -class TestMain(TestCase): - def setUp(self): +class TestMain: + def setup_method(self): shutil.rmtree("test-home", ignore_errors=True) os.environ["HOME"] = "test-home" - def require_connect(self): - connect_server = os.environ.get("CONNECT_SERVER", None) - if connect_server is None: - self.skipTest("Set CONNECT_SERVER to test this function.") - return connect_server - - def require_api_key(self): - connect_api_key = os.environ.get("CONNECT_API_KEY", None) - if connect_api_key is None: - self.skipTest("Set CONNECT_API_KEY to test this function.") - return connect_api_key - @staticmethod def optional_target(default): return os.environ.get("CONNECT_DEPLOY_TARGET", default) @@ -64,8 +51,8 @@ def optional_ca_data(default=None): # noinspection SpellCheckingInspection def create_deploy_args(self, deploy_command, target): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() cadata_file = optional_ca_data(None) args = ["deploy", deploy_command] apply_common_args(args, server=connect_server, key=api_key, cacert=cadata_file) @@ -75,32 +62,32 @@ def create_deploy_args(self, deploy_command, target): def test_version(self): runner = CliRunner() result = runner.invoke(cli, ["version"]) - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn(VERSION, result.output) + assert result.exit_code == 0, result.output + assert VERSION in result.output def test_ping(self): - connect_server = self.require_connect() + connect_server = require_connect() runner = CliRunner() result = runner.invoke(cli, ["details", "-s", connect_server]) - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("OK", result.output) + assert result.exit_code == 0, result.output + assert "OK" in result.output def test_ping_api_key(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() runner = CliRunner() args = ["details"] apply_common_args(args, server=connect_server, key=api_key) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("OK", result.output) + assert result.exit_code == 0, result.output + assert "OK" in result.output def test_deploy(self): target = optional_target(get_dir(join("pip1", "dummy.ipynb"))) runner = CliRunner() args = self.create_deploy_args("notebook", target) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0, result.output) + assert result.exit_code == 0, result.output # noinspection SpellCheckingInspection def test_deploy_manifest(self): @@ -108,7 +95,7 @@ def test_deploy_manifest(self): runner = CliRunner() args = self.create_deploy_args("manifest", target) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0, result.output) + assert result.exit_code == 0, result.output # noinspection SpellCheckingInspection @httpretty.activate(verbose=True, allow_net_connect=False) @@ -119,21 +106,21 @@ def test_deploy_manifest_shinyapps(self): httpretty.register_uri( httpretty.GET, "https://api.shinyapps.io/v1/users/me", - body=open("tests/testdata/shinyapps-responses/get-user.json", "r").read(), + body=open("tests/testdata/rstudio-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(), + body=open("tests/testdata/rstudio-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(), + body=open("tests/testdata/rstudio-responses/get-accounts.json", "r").read(), adding_headers={"Content-Type": "application/json"}, status=200, ) @@ -141,13 +128,13 @@ def test_deploy_manifest_shinyapps(self): 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"}) + assert 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(), + open("tests/testdata/rstudio-responses/create-application.json", "r").read(), ] httpretty.register_uri( @@ -162,19 +149,16 @@ def post_bundle_callback(request, uri, response_headers): del parsed_request["checksum"] del parsed_request["content_length"] try: - self.assertDictEqual( - parsed_request, - { - "application": 8442, - "content_type": "application/x-tar", - }, - ) + assert 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(), + open("tests/testdata/rstudio-responses/create-bundle.json", "r").read(), ] httpretty.register_uri( @@ -199,7 +183,7 @@ def post_bundle_callback(request, uri, response_headers): def post_bundle_status_callback(request, uri, response_headers): parsed_request = _load_json(request.body) try: - self.assertDictEqual(parsed_request, {"status": "ready"}) + assert parsed_request == {"status": "ready"} except AssertionError as e: return _error_to_response(e) return [303, {"Location": "https://api.shinyapps.io/v1/bundles/12640"}, ""] @@ -213,7 +197,7 @@ def post_bundle_status_callback(request, uri, response_headers): httpretty.register_uri( httpretty.GET, "https://api.shinyapps.io/v1/bundles/12640", - body=open("tests/testdata/shinyapps-responses/get-accounts.json", "r").read(), + body=open("tests/testdata/rstudio-responses/get-accounts.json", "r").read(), adding_headers={"Content-Type": "application/json"}, status=200, ) @@ -221,13 +205,13 @@ def post_bundle_status_callback(request, uri, response_headers): def post_deploy_callback(request, uri, response_headers): parsed_request = _load_json(request.body) try: - self.assertDictEqual(parsed_request, {"bundle": 12640, "rebuild": False}) + assert 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(), + open("tests/testdata/rstudio-responses/post-deploy.json", "r").read(), ] httpretty.register_uri( @@ -239,7 +223,198 @@ def post_deploy_callback(request, uri, response_headers): httpretty.register_uri( httpretty.GET, "https://api.shinyapps.io/v1/tasks/333", - body=open("tests/testdata/shinyapps-responses/get-task.json", "r").read(), + body=open("tests/testdata/rstudio-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) + assert 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 + + @httpretty.activate(verbose=True, allow_net_connect=False) + @pytest.mark.parametrize( + "project_application_id,project_id", + [(None, None), ("444", 555)], + ids=["without associated project", "with associated project"], + ) + def test_deploy_manifest_cloud(self, project_application_id, project_id): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + if project_application_id: + os.environ["LUCID_APPLICATION_ID"] = project_application_id + + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/users/me", + body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/applications" + "?filter=name:like:shinyapp&offset=0&count=100&use_advanced_filters=true", + body=open("tests/testdata/rstudio-responses/get-applications.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/accounts/", + body=open("tests/testdata/rstudio-responses/get-accounts.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + if project_application_id: + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/applications/444", + body=open("tests/testdata/rstudio-responses/get-project-application.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/content/555", + body=open("tests/testdata/rstudio-responses/get-content.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/content/1", + body=open("tests/testdata/rstudio-responses/create-output.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + def post_output_callback(request, uri, response_headers): + space_id = 917733 if project_application_id else None + parsed_request = _load_json(request.body) + try: + assert parsed_request == {"name": "myapp", "space": space_id, "project": project_id} + except AssertionError as e: + return _error_to_response(e) + return [ + 201, + {"Content-Type": "application/json"}, + open("tests/testdata/rstudio-responses/create-output.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/applications/8442", + body=open("tests/testdata/rstudio-responses/get-output-application.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + httpretty.register_uri( + httpretty.POST, + "https://api.rstudio.cloud/v1/outputs/", + body=post_output_callback, + ) + + def post_bundle_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + del parsed_request["checksum"] + del parsed_request["content_length"] + try: + assert 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/rstudio-responses/create-bundle.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.rstudio.cloud/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: + assert parsed_request == {"status": "ready"} + except AssertionError as e: + return _error_to_response(e) + return [303, {"Location": "https://api.rstudio.cloud/v1/bundles/12640"}, ""] + + httpretty.register_uri( + httpretty.POST, + "https://api.rstudio.cloud/v1/bundles/12640/status", + body=post_bundle_status_callback, + ) + + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/bundles/12640", + body=open("tests/testdata/rstudio-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: + assert parsed_request == {"bundle": 12640, "rebuild": False} + except AssertionError as e: + return _error_to_response(e) + return [ + 303, + {"Location": "https://api.rstudio.cloud/v1/tasks/333"}, + open("tests/testdata/rstudio-responses/post-deploy.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.rstudio.cloud/v1/applications/8442/deploy", + body=post_deploy_callback, + ) + + httpretty.register_uri( + httpretty.GET, + "https://api.rstudio.cloud/v1/tasks/333", + body=open("tests/testdata/rstudio-responses/get-task.json", "r").read(), adding_headers={"Content-Type": "application/json"}, status=200, ) @@ -249,6 +424,8 @@ def post_deploy_callback(request, uri, response_headers): "deploy", "manifest", get_manifest_path("shinyapp"), + "--server", + "rstudio.cloud", "--account", "some-account", "--token", @@ -260,27 +437,29 @@ def post_deploy_callback(request, uri, response_headers): ] try: result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0, result.output) + assert 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 + if project_application_id: + del os.environ["LUCID_APPLICATION_ID"] 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) + assert result.exit_code == 0, result.output def test_add_connect(self): - connect_server = self.require_connect() - api_key = self.require_api_key() + connect_server = require_connect() + api_key = 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) + assert result.exit_code == 0, result.output + assert "OK" in result.output @httpretty.activate(verbose=True, allow_net_connect=False) def test_add_shinyapps(self): @@ -306,8 +485,43 @@ def test_add_shinyapps(self): "c29tZVNlY3JldAo=", ], ) - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("shinyapps.io credential", result.output) + assert result.exit_code == 0, result.output + assert "shinyapps.io credential" in 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 + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_add_cloud(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.rstudio.cloud/v1/users/me", body='{"id": 1000}', status=200 + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "add", + "--account", + "some-account", + "--name", + "my-cloud", + "--token", + "someToken", + "--secret", + "c29tZVNlY3JldAo=", + "--server", + "rstudio.cloud", + ], + ) + assert result.exit_code == 0, result.output + assert "RStudio Cloud credential" in result.output finally: if original_api_key_value: @@ -330,10 +544,10 @@ def test_add_shinyapps_missing_options(self): "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.", + assert result.exit_code == 1, result.output + assert ( + str(result.exception) + == "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io or RStudio Cloud." ) finally: if original_api_key_value: diff --git a/tests/test_main_content.py b/tests/test_main_content.py index 7dea3ee8..7306b7e9 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -40,8 +40,8 @@ def test_version(self): self.assertIn(VERSION, result.output) def test_content_search(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() runner = CliRunner() args = ["content", "search"] apply_common_args(args, server=connect_server, key=api_key) @@ -52,8 +52,8 @@ def test_content_search(self): self.assertEqual(len(response), 3, result.output) def test_content_describe(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() runner = CliRunner() args = ["content", "describe", "-g", _content_guids[0], "-g", _content_guids[1]] apply_common_args(args, server=connect_server, key=api_key) @@ -66,8 +66,8 @@ def test_content_describe(self): self.assertEqual(response[1]["guid"], _content_guids[1]) def test_content_download_bundle(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() runner = CliRunner() args = ["content", "download-bundle", "-g", _content_guids[1], "-o", _bundle_download_dest] apply_common_args(args, server=connect_server, key=api_key) @@ -77,8 +77,8 @@ def test_content_download_bundle(self): self.assertIsNotNone(tgz.extractfile("manifest.json").read()) def test_build(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() runner = CliRunner() # add a content item @@ -117,8 +117,8 @@ def test_build(self): self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.COMPLETE) def test_build_rm(self): - connect_server = require_connect(self) - api_key = require_api_key(self) + connect_server = require_connect() + api_key = require_api_key() runner = CliRunner() # remove a content item diff --git a/tests/testdata/shinyapps-responses/create-application.json b/tests/testdata/rstudio-responses/create-application.json similarity index 100% rename from tests/testdata/shinyapps-responses/create-application.json rename to tests/testdata/rstudio-responses/create-application.json diff --git a/tests/testdata/shinyapps-responses/create-bundle.json b/tests/testdata/rstudio-responses/create-bundle.json similarity index 100% rename from tests/testdata/shinyapps-responses/create-bundle.json rename to tests/testdata/rstudio-responses/create-bundle.json diff --git a/tests/testdata/rstudio-responses/create-output.json b/tests/testdata/rstudio-responses/create-output.json new file mode 100644 index 00000000..b3951229 --- /dev/null +++ b/tests/testdata/rstudio-responses/create-output.json @@ -0,0 +1,71 @@ +{ + "id": 1, + "uuid": "5fd4c4c7cf584ab8b8541792f38f4495", + "name": "myapp", + "content_type": "output", + "description": null, + "author": { + "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" + }, + "author_id": 47261, + "account_id": 1, + "space_id": 32, + "output_mode": "shiny", + "visibility": "public", + "status": "pending", + "state": "active", + "url": "http://staging.rstudio.cloud/content/123", + "source": { + "id": 8442, + "name": "0a1006cc11bd4e6f8137d01440aead0a-output-source", + "uuid": "f0360a2216414e76bdacd81d52745c59", + "type": "connect", + "status": "pending", + "account_id": 82069, + "deployment_id": null, + "next_deployment_id": null, + "prev_deployment_id": null, + "clone_parent_id": 23, + "exportable": false, + "created_time": "2017-06-16T14:41:06.802874", + "updated_time": "2017-06-16T14:41:06.802874" + }, + "source_id": 8442, + "permissions": [ + "ARCHIVE_OUTPUT", + "CREATE_CLONE_OUTPUT_SESSION", + "CREATE_SOURCE_OUTPUT_SESSION", + "DELETE_OUTPUT", + "MANAGE_OUTPUT_ACCESS", + "MODIFY_OUTPUT", + "MOVE_OUTPUT", + "RESTORE_ARCHIVED_OUTPUT", + "RESTORE_TRASHED_OUTPUT", + "TRASH_OUTPUT", + "VIEW_OUTPUT" + ], + "created_time": "2017-06-16T14:41:06.802874", + "updated_time": "2017-06-16T14:41:06.802873" +} diff --git a/tests/testdata/shinyapps-responses/get-accounts.json b/tests/testdata/rstudio-responses/get-accounts.json similarity index 100% rename from tests/testdata/shinyapps-responses/get-accounts.json rename to tests/testdata/rstudio-responses/get-accounts.json diff --git a/tests/testdata/shinyapps-responses/get-applications.json b/tests/testdata/rstudio-responses/get-applications.json similarity index 100% rename from tests/testdata/shinyapps-responses/get-applications.json rename to tests/testdata/rstudio-responses/get-applications.json diff --git a/tests/testdata/shinyapps-responses/get-bundle.json b/tests/testdata/rstudio-responses/get-bundle.json similarity index 100% rename from tests/testdata/shinyapps-responses/get-bundle.json rename to tests/testdata/rstudio-responses/get-bundle.json diff --git a/tests/testdata/rstudio-responses/get-content.json b/tests/testdata/rstudio-responses/get-content.json new file mode 100644 index 00000000..0d5ee6a9 --- /dev/null +++ b/tests/testdata/rstudio-responses/get-content.json @@ -0,0 +1,63 @@ +{ + "id": 555, + "uuid": "594a15361cbf4183b67f6589725474a6", + "name": "pyshiny0", + "content_type": "project", + "visibility": "private", + "description": null, + "status": "running", + "state": "active", + "account_id": 82069, + "author_id": 47261, + "author": { + "id": 47261, + "first_name": "Matthew", + "last_name": "Lynch", + "display_name": "Matthew Lynch", + "organization": null, + "homepage": null, + "location": null, + "picture_url": "https://www.gravatar.com/avatar/e1bc03fb1393b9b1c3b3dc309cc0b4cf", + "grant": null, + "created_time": "2021-09-28T19:32:47", + "updated_time": "2022-09-28T18:31:01" + }, + "space_id": 917733, + "source_id": 1230843, + "url": "https://staging.rstudio.cloud/content/1205504", + "source": { + "id": 1230843, + "name": "21b10f198835410d8ffb340b0ae815b6-project-source", + "uuid": "5f2a944784f24c4ebf0ee9a4d1afa210", + "type": "ide", + "mode": null, + "scheduler": "kubernetes", + "status": "terminated", + "account_id": 82069, + "storage_initialized": true, + "deployment_id": 936660, + "next_deployment_id": null, + "prev_deployment_id": 928918, + "clone_parent_id": null, + "copy_parent_id": null, + "exportable": true, + "created_time": "2022-08-19T19:01:54", + "updated_time": "2022-09-01T18:33:06" + }, + "project_type": "ide", + "parent_id": null, + "parent": null, + "permissions": [ + "MOVE_OUTPUT", + "TRASH_OUTPUT", + "ARCHIVE_OUTPUT", + "VIEW_OUTPUT", + "RESTORE_TRASHED_OUTPUT", + "RESTORE_ARCHIVED_OUTPUT", + "MODIFY_OUTPUT", + "DELETE_OUTPUT", + "MANAGE_OUTPUT_ACCESS" + ], + "created_time": "2022-09-27T16:16:24", + "updated_time": "2022-09-29T15:57:35" +} diff --git a/tests/testdata/rstudio-responses/get-output-application.json b/tests/testdata/rstudio-responses/get-output-application.json new file mode 100644 index 00000000..d0559809 --- /dev/null +++ b/tests/testdata/rstudio-responses/get-output-application.json @@ -0,0 +1,39 @@ +{ + "id": 8442, + "name": "0a1006cc11bd4e6f8137d01440aead0a-output-source", + "uuid": "f0360a2216414e76bdacd81d52745c59", + "url": "https://f0360a2216414e76bdacd81d52745c59.app.rstudio.cloud", + "type": "connect", + "mode": null, + "scheduler": "kubernetes", + "status": "pending", + "account_id": 47261, + "content_id": 1, + "logplex_channel": null, + "logplex_token": null, + "storage_initialized": true, + "deployment_id": null, + "deployment": null, + "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": null, + "clone_parent_id": null, + "copy_parent_id": null, + "storage": [], + "exportable": false, + "created_time": "2018-07-20T22:46:41", + "updated_time": "2018-07-20T23:06:06" +} diff --git a/tests/testdata/rstudio-responses/get-project-application.json b/tests/testdata/rstudio-responses/get-project-application.json new file mode 100644 index 00000000..38dc4cde --- /dev/null +++ b/tests/testdata/rstudio-responses/get-project-application.json @@ -0,0 +1,143 @@ +{ + "id": 1230843, + "name": "21b10f198835410d8ffb340b0ae815b6-project-source", + "uuid": "5f2a944784f24c4ebf0ee9a4d1afa210", + "url": "https://5f2a944784f24c4ebf0ee9a4d1afa210.app.staging.rstudio.cloud/", + "type": "ide", + "mode": null, + "scheduler": "kubernetes", + "status": "sleeping", + "account_id": 82069, + "content_id": 555, + "storage_initialized": true, + "deployment_id": 936660, + "deployment": { + "id": 936660, + "application_memory_limit": 1024, + "application_cpu_limit": 1.0, + "application_timeout_minutes": 15, + "application_timeout_kill_minutes": 60, + "application_os_version": "focal", + "image": { + "id": 20368, + "app_id": null, + "active": true, + "status": "ready", + "bundle_id": 301, + "bundle": { + "id": 301, + "app_id": null, + "user_id": 1, + "status": "ready", + "name": "default-ide", + "url": "s3://lucid-uploads-staging/bundles/bare-ide.tar.gz", + "checksum": "9f50ae5883f5d9924a9247362cf25e03", + "parent_id": null, + "created_time": "2017-04-19T20:54:10", + "updated_time": "2017-07-20T20:09:54" + }, + "manifest": null, + "repository": null, + "registry": "default", + "tag": "provided", + "created_time": "2021-02-11T14:50:36", + "updated_time": "2021-02-11T14:50:36" + }, + "properties": { + "application.visibility": "private", + "application.build-pool": null, + "application.ide.image.tag": null, + "application.jupyter.image.tag": null, + "application.initialize.image.tag": null, + "application.sidecar.image.tag": null, + "application.ide.autosave.on.idle": true, + "application.instances.template": "large", + "application.instances.start": 1, + "application.instances.load.factor": 0.5, + "application.instances.idle-threshold": 15, + "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.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.sockjs.protocols.disabled": null, + "application.connect.debug.log": "", + "application.connect.version": "current", + "application.package.cache": true, + "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.connect.timeout.init": 60, + "application.connect.timeout.idle": 5, + "application.connect.timeout.conn": 900, + "application.connect.timeout.read": 3600, + "application.connect.timeout.reconnect": null, + "application.connect.scheduler.max.requests": 20, + "application.connect.scheduler.max.processes": 3, + "application.connect.scheduler.min.processes": 0, + "application.connect.scheduler.load.factor": 0.05, + "application.unmigratable": "" + }, + "environment": {}, + "user": { + "id": 47261, + "first_name": "Matthew", + "last_name": "Lynch", + "display_name": "Matthew Lynch", + "organization": null, + "homepage": null, + "location": null, + "picture_url": "https://www.gravatar.com/avatar/e1bc03fb1393b9b1c3b3dc309cc0b4cf", + "email": "matthew.lynch@rstudio.com", + "email_verified": true, + "local_auth": true, + "referral": null, + "google_auth_id": "109815155294302939290", + "github_auth_id": null, + "github_auth_token": null, + "last_login_attempt": "2022-09-01T22:10:50", + "login_attempts": 0, + "lockout_until": null, + "sso_account_id": null, + "grant": null, + "created_time": "2021-09-28T19:32:47", + "updated_time": "2022-09-01T22:10:50" + }, + "created_time": "2022-08-24T20:29:59", + "updated_time": "2022-08-24T20:29:59" + }, + "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": { + "os_version": "focal" + }, + "next_deployment_id": null, + "prev_deployment_id": 928918, + "clone_parent_id": null, + "copy_parent_id": null, + "storage": [], + "exportable": true, + "created_time": "2022-08-19T19:01:54", + "updated_time": "2022-09-01T18:33:06" +} diff --git a/tests/testdata/shinyapps-responses/get-task.json b/tests/testdata/rstudio-responses/get-task.json similarity index 100% rename from tests/testdata/shinyapps-responses/get-task.json rename to tests/testdata/rstudio-responses/get-task.json diff --git a/tests/testdata/shinyapps-responses/get-user.json b/tests/testdata/rstudio-responses/get-user.json similarity index 100% rename from tests/testdata/shinyapps-responses/get-user.json rename to tests/testdata/rstudio-responses/get-user.json diff --git a/tests/testdata/shinyapps-responses/post-deploy.json b/tests/testdata/rstudio-responses/post-deploy.json similarity index 100% rename from tests/testdata/shinyapps-responses/post-deploy.json rename to tests/testdata/rstudio-responses/post-deploy.json diff --git a/tests/utils.py b/tests/utils.py index affbe1ce..04eba896 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,8 @@ import sys import os from os.path import join, dirname, exists -from unittest import TestCase + +import pytest def apply_common_args(args: list, server=None, key=None, cacert=None, insecure=False): @@ -24,17 +25,17 @@ def optional_ca_data(default=None): return os.environ.get("CONNECT_CADATA_FILE", default) -def require_connect(tc: TestCase): +def require_connect(): connect_server = os.environ.get("CONNECT_SERVER", None) if connect_server is None: - tc.skipTest("Set CONNECT_SERVER to test this function.") + pytest.skip("Set CONNECT_SERVER to test this function.") return connect_server -def require_api_key(tc: TestCase): +def require_api_key(): connect_api_key = os.environ.get("CONNECT_API_KEY", None) if connect_api_key is None: - tc.skipTest("Set CONNECT_API_KEY to test this function.") + pytest.skip("Set CONNECT_API_KEY to test this function.") return connect_api_key