diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 4ddfe3ce..a7cfca04 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -11,19 +11,16 @@ import subprocess import sys import traceback - -try: - import typing -except ImportError: - typing = None - +from typing import IO +from warnings import warn from os.path import abspath, basename, dirname, exists, isdir, join, relpath, splitext from pprint import pformat - +from .exception import RSConnectException from . import api from .bundle import ( make_api_bundle, make_api_manifest, + make_html_bundle, make_manifest_bundle, make_notebook_html_bundle, make_notebook_source_bundle, @@ -38,10 +35,16 @@ from .log import logger from .metadata import AppStore from .models import AppModes, AppMode +from .api import RSConnectExecutor, filter_out_server_info import click from six.moves.urllib_parse import urlparse +try: + import typing +except ImportError: + typing = None + line_width = 45 _module_pattern = re.compile(r"^[A-Za-z0-9_]+:[A-Za-z0-9_]+$") _name_sub_pattern = re.compile(r"[^A-Za-z0-9_ -]+") @@ -76,7 +79,7 @@ def failed(err): try: yield passed() - except api.RSConnectException as exc: + except RSConnectException as exc: failed("Error: " + exc.message) except EnvironmentException as exc: failed("Error: " + str(exc)) @@ -107,9 +110,10 @@ def which_python(python, env=os.environ): * RETICULATE_PYTHON defined in the environment * the python binary running this script """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if python: if not (exists(python) and os.access(python, os.X_OK)): - raise api.RSConnectException('The file, "%s", does not exist or is not executable.' % python) + raise RSConnectException('The file, "%s", does not exist or is not executable.' % python) return python if "RETICULATE_PYTHON" in env: @@ -143,7 +147,7 @@ def inspect_environment( try: environment_json = check_output(args, universal_newlines=True) except subprocess.CalledProcessError as e: - raise api.RSConnectException("Error inspecting environment: %s" % e.output) + raise RSConnectException("Error inspecting environment: %s" % e.output) return MakeEnvironment(**json.loads(environment_json)) # type: ignore @@ -157,7 +161,7 @@ def _verify_server(connect_server): """ uri = urlparse(connect_server.url) if not uri.netloc: - raise api.RSConnectException('Invalid server URL: "%s"' % connect_server.url) + raise RSConnectException('Invalid server URL: "%s"' % connect_server.url) return api.verify_server(connect_server) @@ -169,6 +173,7 @@ def _to_server_check_list(url): :param url: the server URL text to start with. :return: a list of server strings to test. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) # urlparse will end up with an empty netloc in this case. if "//" not in url: items = ["https://%s", "http://%s"] @@ -191,6 +196,7 @@ def test_server(connect_server): :return: a second server object with any scheme expansions applied and the server settings from the server. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) url = connect_server.url key = connect_server.api_key insecure = connect_server.insecure @@ -201,7 +207,7 @@ def test_server(connect_server): connect_server = api.RSConnectServer(test, key, insecure, ca_data) result = _verify_server(connect_server) return connect_server, result - except api.RSConnectException as exc: + except RSConnectException as exc: failures.append(" %s - failed to verify as RStudio Connect (%s)." % (test, str(exc))) # In case the user may need https instead of http... @@ -209,7 +215,7 @@ def test_server(connect_server): failures.append(' Do you need to use "https://%s"?' % url[7:]) # If we're here, nothing worked. - raise api.RSConnectException("\n".join(failures)) + raise RSConnectException("\n".join(failures)) def test_api_key(connect_server): @@ -220,6 +226,7 @@ def test_api_key(connect_server): :param connect_server: the Connect server information. :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) return api.verify_api_key(connect_server) @@ -234,6 +241,7 @@ def gather_server_details(connect_server): strings for all the versions of Python that are installed. The key `conda` will refer to data about whether Connect is configured to support Conda environments. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) def _to_sort_key(text): parts = [part.zfill(5) for part in text.split(".")] @@ -262,6 +270,7 @@ def are_apis_supported_on_server(connect_details): :return: boolean True if the Connect server supports Python APIs or not or False if not. :error: The RStudio Connect server does not allow for Python APIs. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) return connect_details["python"]["api_enabled"] @@ -301,7 +310,7 @@ def check_server_capabilities(connect_server, capability_functions, details_sour message = function.__doc__[index + 7 :].strip() else: message = "The server does not satisfy the %s capability check." % function.__name__ - raise api.RSConnectException(message) + raise RSConnectException(message) def _make_deployment_name(connect_server, title, force_unique) -> str: @@ -321,6 +330,8 @@ def _make_deployment_name(connect_server, title, force_unique) -> str: unique. :return: a name for a deployment based on its title. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) + # First, Generate a default name from the given title. name = _name_sub_pattern.sub("", title.lower()).replace(" ", "_") name = _repeating_sub_pattern.sub("_", name)[:64].rjust(3, "_") @@ -339,9 +350,10 @@ def _validate_title(title): :param title: the title to validate. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if title: if not (3 <= len(title) <= 1024): - raise api.RSConnectException("A title must be between 3-1024 characters long.") + raise RSConnectException("A title must be between 3-1024 characters long.") def _default_title(file_name): @@ -353,6 +365,7 @@ def _default_title(file_name): :param file_name: the name from which the title will be derived. :return: the derived title. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) # Make sure we have enough of a path to derive text from. file_name = abspath(file_name) # noinspection PyTypeChecker @@ -363,6 +376,7 @@ def _default_title_from_manifest(the_manifest, manifest_file): """ Produce a default content title from the contents of a manifest. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) filename = None metadata = the_manifest.get("metadata") @@ -382,9 +396,10 @@ def validate_file_is_notebook(file_name): :param file_name: the name of the file to validate. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) file_suffix = splitext(file_name)[1].lower() if file_suffix != ".ipynb" or not exists(file_name): - raise api.RSConnectException("A Jupyter notebook (.ipynb) file is required here.") + raise RSConnectException("A Jupyter notebook (.ipynb) file is required here.") def validate_extra_files(directory, extra_files): @@ -397,6 +412,7 @@ def validate_extra_files(directory, extra_files): :param extra_files: the list of extra files to qualify and validate. :return: the extra files qualified by the directory. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) result = [] if extra_files: for extra in extra_files: @@ -404,9 +420,9 @@ def validate_extra_files(directory, extra_files): # It's an error if we have to leave the given dir to get to the extra # file. if extra_file.startswith("../"): - raise api.RSConnectException("%s must be under %s." % (extra_file, directory)) + raise RSConnectException("%s must be under %s." % (extra_file, directory)) if not exists(join(directory, extra_file)): - raise api.RSConnectException("Could not find file %s under %s" % (extra, directory)) + raise RSConnectException("Could not find file %s under %s" % (extra, directory)) result.append(extra_file) return result @@ -419,14 +435,16 @@ def validate_manifest_file(file_or_directory): :param file_or_directory: the name of the manifest file or directory that contains it. :return: the real path to the manifest file. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if isdir(file_or_directory): file_or_directory = join(file_or_directory, "manifest.json") if basename(file_or_directory) != "manifest.json" or not exists(file_or_directory): - raise api.RSConnectException("A manifest.json file or a directory containing one is required here.") + raise RSConnectException("A manifest.json file or a directory containing one is required here.") return file_or_directory def get_default_entrypoint(directory): + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) candidates = ["app", "application", "main", "api"] files = set(os.listdir(directory)) @@ -453,13 +471,14 @@ def validate_entry_point(entry_point, directory): :param entry_point: the entry point as specified by the user. :return: the fully expanded and validated entry point and the module file name.. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if not entry_point: entry_point = get_default_entrypoint(directory) parts = entry_point.split(":") if len(parts) > 2: - raise api.RSConnectException('Entry point is not in "module:object" format.') + raise RSConnectException('Entry point is not in "module:object" format.') return entry_point @@ -473,7 +492,7 @@ def which_quarto(quarto=None): if quarto: found = shutil.which(quarto) if not found: - raise api.RSConnectException('The Quarto installation, "%s", does not exist or is not executable.' % quarto) + raise RSConnectException('The Quarto installation, "%s", does not exist or is not executable.' % quarto) return found # Fallback -- try to find Quarto when one was not supplied. @@ -494,7 +513,7 @@ def which_quarto(quarto=None): found = shutil.which(each) if found: return found - raise api.RSConnectException("Unable to locate a Quarto installation.") + raise RSConnectException("Unable to locate a Quarto installation.") def quarto_inspect( @@ -514,7 +533,7 @@ def quarto_inspect( try: inspect_json = check_output(args, universal_newlines=True, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: - raise api.RSConnectException("Error inspecting target: %s" % e.output) + raise RSConnectException("Error inspecting target: %s" % e.output) return json.loads(inspect_json) @@ -526,7 +545,7 @@ def validate_quarto_engines(inspect): engines = inspect.get("engines", []) unsupported = [engine for engine in engines if engine not in supported] if unsupported: - raise api.RSConnectException("The following Quarto engine(s) are not supported: %s" % ", ".join(unsupported)) + raise RSConnectException("The following Quarto engine(s) are not supported: %s" % ", ".join(unsupported)) return engines @@ -550,6 +569,7 @@ def write_quarto_manifest_json( :param excludes: A sequence of glob patterns to exclude when enumerating files to bundle. :param image: the optional docker image to be specified for off-host execution. Default = None. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) manifest, _ = make_quarto_manifest( file_or_directory, @@ -572,11 +592,53 @@ def write_manifest_json(manifest_path, manifest): """ Write the manifest data as JSON to the named manifest.json with a trailing newline. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) with open(manifest_path, "w") as f: json.dump(manifest, f, indent=2) f.write("\n") +def deploy_html( + connect_server: api.RSConnectServer = None, + path: str = None, + entrypoint: str = None, + extra_files=None, + excludes=None, + title: str = None, + env_vars=None, + verbose: bool = False, + new: bool = False, + app_id: str = None, + name: str = None, + server: str = None, + api_key: str = None, + insecure: bool = False, + cacert: IO = None, +): + kwargs = locals() + ce = None + if connect_server: + kwargs = filter_out_server_info(**kwargs) + ce = RSConnectExecutor.fromConnectServer(connect_server, **kwargs) + else: + ce = RSConnectExecutor(**kwargs) + + ( + ce.validate_server() + .validate_app_mode(app_mode=AppModes.STATIC) + .make_bundle( + make_html_bundle, + path, + entrypoint, + extra_files, + excludes, + ) + .deploy_bundle() + .save_deployed_info() + .emit_task_log() + ) + + def deploy_jupyter_notebook( connect_server: api.RSConnectServer, file_name: str, @@ -717,6 +779,7 @@ def fake_module_file_from_directory(directory: str): :param directory: the directory to start with. :return: the directory plus the (potentially) fake module file. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) app_name = abspath(directory) app_name = dirname(app_name) if app_name.endswith(os.path.sep) else basename(app_name) return join(directory, app_name + ".py") @@ -1169,7 +1232,7 @@ def gather_basic_deployment_info_for_notebook( _validate_title(title) if new and app_id: - raise api.RSConnectException("Specify either a new deploy or an app ID but not both.") + raise RSConnectException("Specify either a new deploy or an app ID but not both.") if static: app_mode = AppModes.STATIC @@ -1194,7 +1257,7 @@ def gather_basic_deployment_info_for_notebook( + "but the existing deployment has mode '%s'.\n" + "Use the --new option to create a new deployment of the desired type." ) % (app_mode.desc(), existing_app_mode.desc()) - raise api.RSConnectException(msg) + raise RSConnectException(msg) default_title = not bool(title) title = title or _default_title(file_name) @@ -1230,7 +1293,7 @@ def gather_basic_deployment_info_for_html( """ if new and app_id: - raise api.RSConnectException("Specify either a new deploy or an app ID but not both.") + raise RSConnectException("Specify either a new deploy or an app ID but not both.") app_mode = AppModes.STATIC existing_app_mode = None @@ -1251,7 +1314,7 @@ def gather_basic_deployment_info_for_html( + "but the existing deployment has mode '%s'.\n" + "Use the --new option to create a new deployment of the desired type." ) % (app_mode.desc(), existing_app_mode.desc()) - raise api.RSConnectException(msg) + raise RSConnectException(msg) default_title = not bool(title) title = title or _default_title(path) @@ -1291,7 +1354,7 @@ def gather_basic_deployment_info_from_manifest( _validate_title(title) if new and app_id: - raise api.RSConnectException("Specify either a new deploy or an app ID but not both.") + raise RSConnectException("Specify either a new deploy or an app ID but not both.") source_manifest, _ = read_manifest_file(file_name) # noinspection SpellCheckingInspection @@ -1339,7 +1402,7 @@ def gather_basic_deployment_info_for_quarto( _validate_title(title) if new and app_id: - raise api.RSConnectException("Specify either a new deploy or an app ID but not both.") + raise RSConnectException("Specify either a new deploy or an app ID but not both.") app_mode = AppModes.STATIC_QUARTO @@ -1361,7 +1424,7 @@ def gather_basic_deployment_info_for_quarto( + "but the existing deployment has mode '%s'.\n" + "Use the --new option to create a new deployment of the desired type." ) % (app_mode.desc(), existing_app_mode.desc()) - raise api.RSConnectException(msg) + raise RSConnectException(msg) if file_or_directory[-1] == "/": file_or_directory = file_or_directory[:-1] @@ -1444,7 +1507,7 @@ def _gather_basic_deployment_info_for_framework( _validate_title(title) if new and app_id: - raise api.RSConnectException("Specify either a new deploy or an app ID but not both.") + raise RSConnectException("Specify either a new deploy or an app ID but not both.") existing_app_mode = None if not new: @@ -1464,7 +1527,7 @@ def _gather_basic_deployment_info_for_framework( + "but the existing deployment has mode '%s'.\n" + "Use the --new option to create a new deployment of the desired type." ) % (app_mode.desc(), existing_app_mode.desc()) - raise api.RSConnectException(msg) + raise RSConnectException(msg) if directory[-1] == "/": directory = directory[:-1] @@ -1499,7 +1562,7 @@ def get_python_env_info(file_name, python, conda_mode=False, force_generate=Fals logger.debug("Python: %s" % python) environment = inspect_environment(python, dirname(file_name), conda_mode=conda_mode, force_generate=force_generate) if environment.error: - raise api.RSConnectException(environment.error) + raise RSConnectException(environment.error) logger.debug("Python: %s" % python) logger.debug("Environment: %s" % pformat(environment._asdict())) @@ -1553,7 +1616,7 @@ def create_notebook_deployment_bundle( except subprocess.CalledProcessError as exc: # Jupyter rendering failures are often due to # user code failing, vs. an internal failure of rsconnect-python. - raise api.RSConnectException(str(exc)) + raise RSConnectException(str(exc)) else: return make_notebook_source_bundle( file_name, @@ -1704,6 +1767,7 @@ def create_notebook_manifest_and_environment_file( :param image: an optional docker image for off-host execution. Previous default = None. :return: """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if ( not write_notebook_manifest_json( entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, image @@ -1741,6 +1805,7 @@ def write_notebook_manifest_json( :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) extra_files = validate_extra_files(dirname(entry_point_file), extra_files) directory = dirname(entry_point_file) file_name = basename(entry_point_file) @@ -1750,7 +1815,7 @@ def write_notebook_manifest_json( _, extension = splitext(file_name) app_mode = AppModes.get_by_extension(extension, True) if app_mode == AppModes.UNKNOWN: - raise api.RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) + raise RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) manifest_data = make_source_manifest(app_mode, environment, file_name, None, image) manifest_add_file(manifest_data, file_name, directory) @@ -1791,6 +1856,7 @@ def create_api_manifest_and_environment_file( :param image: the optional docker image to be specified for off-host execution. Default = None. :return: """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if ( not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes, image) or force @@ -1823,6 +1889,7 @@ def write_api_manifest_json( :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) extra_files = validate_extra_files(directory, extra_files) manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes, image) manifest_path = join(directory, "manifest.json") @@ -1844,6 +1911,7 @@ def write_environment_file( returned by the inspect_environment() function. :param directory: the directory where the file should be written. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) environment_file_path = join(directory, environment.filename) with open(environment_file_path, "w") as f: f.write(environment.contents) @@ -1861,6 +1929,7 @@ def describe_manifest( :param file_name: the name of the manifest file to read. :return: the entry point and primary document from the manifest. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if basename(file_name) == "manifest.json" and exists(file_name): manifest, _ = read_manifest_file(file_name) metadata = manifest.get("metadata") diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index d1b797a3..509bdb57 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -10,10 +10,11 @@ import semver -from .api import RSConnect, RSConnectException, emit_task_log +from .api import RSConnect, emit_task_log from .log import logger from .models import BuildStatus, ContentGuidWithBundle from .metadata import ContentBuildStore +from .exception import RSConnectException _content_build_store = None # type: ContentBuildStore diff --git a/rsconnect/api.py b/rsconnect/api.py index 73d2532a..224f5d3e 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -2,19 +2,20 @@ RStudio Connect API client and utility functions """ +from os.path import abspath, basename import time +from typing import IO, Callable from _ssl import SSLError - +import re +from warnings import warn +from six import text_type +import gc +from .bundle import fake_module_file_from_directory from .http_support import HTTPResponse, HTTPServer, append_to_path, CookieJar -from .log import logger +from .log import logger, connect_logger, cls_logged, console_logger from .models import AppModes - - -class RSConnectException(Exception): - def __init__(self, message, cause=None): - super(RSConnectException, self).__init__(message) - self.message = message - self.cause = cause +from .metadata import ServerStore, AppStore +from .exception import RSConnectException class RSConnectServer(object): @@ -268,6 +269,450 @@ def output_task_log(task_status, last_status, log_callback): return new_last_status +class RSConnectExecutor: + def __init__( + self, + name: str = None, + url: str = None, + api_key: str = None, + insecure: bool = False, + cacert: IO = None, + ca_data: str = None, + cookies=None, + timeout: int = 30, + logger=console_logger, + **kwargs + ) -> None: + self.reset() + self._d = kwargs + self.setup_connect_server(name, url or kwargs.get("server"), api_key, insecure, cacert, ca_data) + self.setup_client(cookies, timeout) + self.logger = logger + + @classmethod + def fromConnectServer(cls, connect_server, **kwargs): + return cls( + url=connect_server.url, + api_key=connect_server.api_key, + insecure=connect_server.insecure, + ca_data=connect_server.ca_data, + **kwargs, + ) + + def reset(self): + self._d = None + self.connect_server = None + self.client = None + self.logger = None + gc.collect() + return self + + def drop_context(self): + self._d = None + gc.collect() + return self + + def setup_connect_server( + self, + name: str = None, + url: str = None, + api_key: str = None, + insecure: bool = False, + cacert: IO = None, + ca_data: str = None, + ): + 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()) + + 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): + self.client = RSConnect(self.connect_server, cookies, timeout) + + @property + def state(self): + return self._d + + def get(self, key: str, *args, **kwargs): + return kwargs.get(key) or self.state.get(key) + + def pipe(self, func, *args, **kwargs): + return 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, + **kwargs + ): + """ + 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 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. + """ + 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() + + if cacert: + ca_data = text_type(cacert.read()) + 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.") + if not name and not url: + raise RSConnectException("You must specify one of -n/--name or -s/--server.") + + 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 from_store: + self.server_settings + + 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 self + + # If our info came from the command line, make sure the key really works. + if not from_store: + _ = self.verify_api_key() + + self.connect_server = connect_server + self.client = RSConnect(self.connect_server) + + return self + + @cls_logged("Making bundle ...") + def make_bundle(self, func: Callable, *args, **kwargs): + path = ( + self.get("path", **kwargs) + or self.get("file", **kwargs) + or self.get("file_name", **kwargs) + or self.get("directory", **kwargs) + or self.get("file_or_directory", **kwargs) + ) + app_id = self.get("app_id", **kwargs) + title = self.get("title", **kwargs) + app_store = self.get("app_store", *args, **kwargs) + if not app_store: + module_file = fake_module_file_from_directory(path) + self.state["app_store"] = app_store = AppStore(module_file) + + d = self.state + d["title_is_default"] = not bool(title) + d["title"] = title or _default_title(path) + d["deployment_name"] = self.make_deployment_name(d["title"], app_id is None) + + try: + bundle = func(*args, **kwargs) + except IOError as error: + msg = "Unable to include the file %s in the bundle: %s" % ( + error.filename, + error.args[1], + ) + raise RSConnectException(msg) + + d["bundle"] = bundle + + return self + + def check_server_capabilities(self, capability_functions): + """ + Uses a sequence of functions that check for capabilities in a Connect server. The + server settings data is retrieved by the gather_server_details() function. + + Each function provided must accept one dictionary argument which will be the server + settings data returned by the gather_server_details() function. That function must + return a boolean value. It must also contain a docstring which itself must contain + an ":error:" tag as the last thing in the docstring. If the function returns False, + an exception is raised with the function's ":error:" text as its message. + + :param capability_functions: a sequence of functions that will be called. + :param details_source: the source for obtaining server details, gather_server_details(), + by default. + """ + details = self.server_details + + for function in capability_functions: + if not function(details): + index = function.__doc__.find(":error:") if function.__doc__ else -1 + if index >= 0: + message = function.__doc__[index + 7 :].strip() + else: + message = "The server does not satisfy the %s capability check." % function.__name__ + raise RSConnectException(message) + return self + + @cls_logged("Deploying bundle ...") + def deploy_bundle( + self, + app_id: int = None, + deployment_name: str = None, + title: str = None, + title_is_default: bool = False, + bundle: IO = None, + env_vars=None, + ): + 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, + app_id: int = None, + task_id: int = None, + log_callback=connect_logger, + abort_func: Callable[[], bool] = lambda: False, + timeout: int = None, + poll_wait: float = 0.5, + raise_on_error: bool = True, + ): + """ + 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 + (the default) the lines from the deployment log will be returned as a sequence. + If a log callback is provided, then None will be returned for the log lines part + of the return tuple. + :param timeout: an optional timeout for the wait operation. + :param poll_wait: how long to wait between polls of the task api for status/logs + :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. + """ + 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 + + @cls_logged("Saving deployed information...") + def save_deployed_info(self, *args, **kwargs): + app_store = self.get("app_store", *args, **kwargs) + path = ( + self.get("path", **kwargs) + or self.get("file", **kwargs) + or self.get("file_name", **kwargs) + or self.get("directory", **kwargs) + or self.get("file_or_directory", **kwargs) + ) + deployed_info = self.get("deployed_info", *args, **kwargs) + + app_store.set( + self.connect_server.url, + abspath(path), + deployed_info["app_url"], + deployed_info["app_id"], + deployed_info["app_guid"], + deployed_info["title"], + self.state["app_mode"], + ) + + return self + + @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) + or self.get("file_name", **kwargs) + or self.get("directory", **kwargs) + or self.get("file_or_directory", **kwargs) + ) + app_store = self.get("app_store", *args, **kwargs) + if not app_store: + module_file = fake_module_file_from_directory(path) + self.state["app_store"] = app_store = AppStore(module_file) + new = self.get("new", **kwargs) + app_id = self.get("app_id", **kwargs) + app_mode = self.get("app_mode", **kwargs) + + if new and app_id: + raise RSConnectException("Specify either a new deploy or an app ID but not both.") + + existing_app_mode = None + if not new: + 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(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. + 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" + + "but the existing deployment has mode '%s'.\n" + + "Use the --new option to create a new deployment of the desired type." + ) % (app_mode.desc(), existing_app_mode.desc()) + raise RSConnectException(msg) + + self.state["app_id"] = app_id + self.state["app_mode"] = app_mode + return self + + @property + def server_settings(self): + try: + result = self.client.server_settings() + 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): + """ + 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. + """ + 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.connect_server.handle_bad_response(result) + return result["username"] + + @property + def python_info(self): + """ + Return information about versions of Python that are installed on the indicated + Connect server. + + :return: the Python installation information from Connect. + """ + result = self.client.python_settings() + self.connect_server.handle_bad_response(result) + return result + + @property + def server_details(self): + """ + Builds a dictionary containing the version of RStudio Connect that is running + and the versions of Python installed there. + + :return: a three-entry dictionary. The key 'connect' will refer to the version + of Connect that was found. The key `python` will refer to a sequence of version + strings for all the versions of Python that are installed. The key `conda` will + refer to data about whether Connect is configured to support Conda environments. + """ + + def _to_sort_key(text): + parts = [part.zfill(5) for part in text.split(".")] + return "".join(parts) + + server_settings = self.server_settings + python_settings = self.python_info + python_versions = sorted([item["version"] for item in python_settings["installations"]], key=_to_sort_key) + conda_settings = { + "supported": python_settings["conda_enabled"] if "conda_enabled" in python_settings else False + } + return { + "connect": server_settings["version"], + "python": { + "api_enabled": python_settings["api_enabled"] if "api_enabled" in python_settings else False, + "versions": python_versions, + }, + "conda": conda_settings, + } + + def make_deployment_name(self, title, force_unique): + """ + 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 + isn't None or empty). + + We follow the same rules for doing this as the R rsconnect package does. See + the title.R code in https://github.com/rstudio/rsconnect/R with the exception + 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. + :return: a name for a deployment based on its title. + """ + _name_sub_pattern = re.compile(r"[^A-Za-z0-9_ -]+") + _repeating_sub_pattern = re.compile(r"_+") + + # First, Generate a default name from the given title. + name = _name_sub_pattern.sub("", title.lower()).replace(" ", "_") + name = _repeating_sub_pattern.sub("_", name)[:64].rjust(3, "_") + + # Now, make sure it's unique, if needed. + if force_unique: + name = find_unique_name(self.connect_server, name) + + return name + + +def filter_out_server_info(**kwargs): + server_fields = {"connect_server", "name", "server", "api_key", "insecure", "cacert"} + new_kwargs = {k: v for k, v in kwargs.items() if k not in server_fields} + return new_kwargs + + def verify_server(connect_server): """ Verify that the given server information represents a Connect instance that is @@ -277,6 +722,7 @@ def verify_server(connect_server): :param connect_server: the Connect server information. :return: the server settings from the Connect server. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) try: with RSConnect(connect_server) as client: result = client.server_settings() @@ -294,6 +740,8 @@ def verify_api_key(connect_server): :param connect_server: the Connect server information, including the API key to test. :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 RSConnect(connect_server) as client: result = client.me() if isinstance(result, HTTPResponse): @@ -311,6 +759,8 @@ def get_python_info(connect_server): :param connect_server: the Connect server information. :return: the Python installation information from Connect. """ + warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) + with RSConnect(connect_server) as client: result = client.python_settings() connect_server.handle_bad_response(result) @@ -562,3 +1012,38 @@ def find_unique_name(connect_server, name): name = test return name + + +def _to_server_check_list(url): + """ + Build a list of servers to check from the given one. If the specified server + appears not to have a scheme, then we'll provide https and http variants to test. + + :param url: the server URL text to start with. + :return: a list of server strings to test. + """ + # urlparse will end up with an empty netloc in this case. + if "//" not in url: + items = ["https://%s", "http://%s"] + # urlparse would parse this correctly and end up with an empty scheme. + elif url.startswith("//"): + items = ["https:%s", "http:%s"] + else: + 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/bundle.py b/rsconnect/bundle.py index 6e11c985..9aa81989 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -7,21 +7,29 @@ import json import os import subprocess +import sys import tarfile import tempfile +import re +from pprint import pformat +from collections import defaultdict +from mimetypes import guess_type +import click + try: import typing except ImportError: typing = None -from os.path import basename, dirname, exists, isdir, join, relpath, splitext, isfile +from os.path import basename, dirname, exists, isdir, join, relpath, splitext, isfile, abspath from .log import logger from .models import AppMode, AppModes, GlobSet -from .environment import Environment -from collections import defaultdict -from mimetypes import guess_type +from .environment import Environment, MakeEnvironment +from .exception import RSConnectException + +_module_pattern = re.compile(r"^[A-Za-z0-9_]+:[A-Za-z0-9_]+$") # From https://github.com/rstudio/rsconnect/blob/485e05a26041ab8183a220da7a506c9d3a41f1ff/R/bundle.R#L85-L88 # noinspection SpellCheckingInspection @@ -430,6 +438,35 @@ def keep_manifest_specified_file(relative_path): return True +def _default_title_from_manifest(the_manifest, manifest_file): + """ + Produce a default content title from the contents of a manifest. + """ + filename = None + + metadata = the_manifest.get("metadata") + if metadata: + # noinspection SpellCheckingInspection + filename = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html") + # If the manifest is for an API, revert to using the parent directory. + if filename and _module_pattern.match(filename): + filename = None + return _default_title(filename or dirname(manifest_file)) + + +def read_manifest_app_mode(file): + source_manifest, _ = read_manifest_file(file) + # noinspection SpellCheckingInspection + app_mode = AppModes.get_by_name(source_manifest["metadata"]["appmode"]) + return app_mode + + +def default_title_from_manifest(file): + source_manifest, _ = read_manifest_file(file) + title = _default_title_from_manifest(source_manifest, file) + return title + + def read_manifest_file(manifest_path): """ Read a manifest's content from its file. The content is provided as both a @@ -606,6 +643,7 @@ def make_html_bundle_content( excludes: typing.List[str], image: str = None, ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: + """ Makes a manifest for static html deployment. @@ -616,7 +654,8 @@ def make_html_bundle_content( :param image: the optional docker image to be specified for off-host execution. Default = None. :return: the manifest and a list of the files involved. """ - entrypoint = entrypoint or infer_entrypoint(path, "text/html") + extra_files = list(extra_files) if extra_files else [] + entrypoint = entrypoint or infer_entrypoint(path=path, mimetype="text/html") if path.startswith(os.curdir): path = relpath(path) @@ -865,3 +904,533 @@ def make_quarto_manifest( manifest_add_file(manifest, rel_path, base_dir) return manifest, relevant_files + + +def _validate_title(title): + """ + If the user specified a title, validate that it meets Connect's length requirements. + If the validation fails, an exception is raised. Otherwise, + + :param title: the title to validate. + """ + if title: + if not (3 <= len(title) <= 1024): + raise RSConnectException("A title must be between 3-1024 characters long.") + + +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") + + +def validate_file_is_notebook(file_name): + """ + Validate that the given file is a Jupyter Notebook. If it isn't, an exception is + thrown. A file must exist and have the '.ipynb' extension. + + :param file_name: the name of the file to validate. + """ + file_suffix = splitext(file_name)[1].lower() + if file_suffix != ".ipynb" or not exists(file_name): + raise RSConnectException("A Jupyter notebook (.ipynb) file is required here.") + + +def validate_extra_files(directory, extra_files): + """ + If the user specified a list of extra files, validate that they all exist and are + beneath the given directory and, if so, return a list of them made relative to that + directory. + + :param directory: the directory that the extra files must be relative to. + :param extra_files: the list of extra files to qualify and validate. + :return: the extra files qualified by the directory. + """ + result = [] + if extra_files: + for extra in extra_files: + extra_file = relpath(extra, directory) + # It's an error if we have to leave the given dir to get to the extra + # file. + if extra_file.startswith("../"): + raise RSConnectException("%s must be under %s." % (extra_file, directory)) + if not exists(join(directory, extra_file)): + raise RSConnectException("Could not find file %s under %s" % (extra, directory)) + result.append(extra_file) + return result + + +def validate_manifest_file(file_or_directory): + """ + Validates that the name given represents either an existing manifest.json file or + a directory that contains one. If not, an exception is raised. + + :param file_or_directory: the name of the manifest file or directory that contains it. + :return: the real path to the manifest file. + """ + if isdir(file_or_directory): + file_or_directory = join(file_or_directory, "manifest.json") + if basename(file_or_directory) != "manifest.json" or not exists(file_or_directory): + raise RSConnectException("A manifest.json file or a directory containing one is required here.") + return file_or_directory + + +def get_default_entrypoint(directory): + candidates = ["app", "application", "main", "api"] + files = set(os.listdir(directory)) + + for candidate in candidates: + filename = candidate + ".py" + if filename in files: + return candidate + + # if only one python source file, use it + python_files = list(filter(lambda s: s.endswith(".py"), files)) + if len(python_files) == 1: + return python_files[0][:-3] + + logger.warning("Can't determine entrypoint; defaulting to 'app'") + return "app" + + +def validate_entry_point(entry_point, directory): + """ + Validates the entry point specified by the user, expanding as necessary. If the + user specifies nothing, a module of "app" is assumed. If the user specifies a + module only, the object is assumed to be the same name as the module. + + :param entry_point: the entry point as specified by the user. + :return: the fully expanded and validated entry point and the module file name.. + """ + if not entry_point: + entry_point = get_default_entrypoint(directory) + + parts = entry_point.split(":") + + if len(parts) > 2: + raise RSConnectException('Entry point is not in "module:object" format.') + + return entry_point + + +def _warn_on_ignored_manifest(directory): + """ + Checks for the existence of a file called manifest.json in the given directory. + If it's there, a warning noting that it will be ignored will be printed. + + :param directory: the directory to check in. + """ + if exists(join(directory, "manifest.json")): + click.secho( + " Warning: the existing manifest.json file will not be used or considered.", + fg="yellow", + ) + + +def _warn_if_no_requirements_file(directory): + """ + Checks for the existence of a file called requirements.txt in the given directory. + If it's not there, a warning will be printed. + + :param directory: the directory to check in. + """ + if not exists(join(directory, "requirements.txt")): + click.secho( + " Warning: Capturing the environment using 'pip freeze'.\n" + " Consider creating a requirements.txt file instead.", + fg="yellow", + ) + + +def _warn_if_environment_directory(directory): + """ + Issue a warning if the deployment directory is itself a virtualenv (yikes!). + + :param directory: the directory to check in. + """ + if is_environment_dir(directory): + click.secho( + " Warning: The deployment directory appears to be a python virtual environment.\n" + " Excluding the 'bin' and 'lib' directories.", + fg="yellow", + ) + + +def _warn_on_ignored_requirements(directory, requirements_file_name): + """ + Checks for the existence of a file called manifest.json in the given directory. + If it's there, a warning noting that it will be ignored will be printed. + + :param directory: the directory to check in. + :param requirements_file_name: the name of the requirements file. + """ + if exists(join(directory, requirements_file_name)): + click.secho( + " Warning: the existing %s file will not be used or considered." % requirements_file_name, + fg="yellow", + ) + + +def fake_module_file_from_directory(directory: str): + """ + Takes a directory and invents a properly named file that though possibly fake, + can be used for other name/title derivation. + + :param directory: the directory to start with. + :return: the directory plus the (potentially) fake module file. + """ + app_name = abspath(directory) + app_name = dirname(app_name) if app_name.endswith(os.path.sep) else basename(app_name) + return join(directory, app_name + ".py") + + +def are_apis_supported_on_server(connect_details): + """ + Returns whether or not the Connect server has Python itself enabled and its license allows + for API usage. This controls whether APIs may be deployed.. + + :param connect_details: details about a Connect server as returned by gather_server_details() + :return: boolean True if the Connect server supports Python APIs or not or False if not. + :error: The RStudio Connect server does not allow for Python APIs. + """ + return connect_details["python"]["api_enabled"] + + +def which_python(python, env=os.environ): + """Determine which python binary should be used. + + In priority order: + * --python specified on the command line + * RETICULATE_PYTHON defined in the environment + * the python binary running this script + """ + if python: + if not (exists(python) and os.access(python, os.X_OK)): + raise RSConnectException('The file, "%s", does not exist or is not executable.' % python) + return python + + if "RETICULATE_PYTHON" in env: + return os.path.expanduser(env["RETICULATE_PYTHON"]) + + return sys.executable + + +def inspect_environment( + python, # type: str + directory, # type: str + conda_mode=False, # type: bool + force_generate=False, # type: bool + check_output=subprocess.check_output, # type: typing.Callable +): + # type: (...) -> Environment + """Run the environment inspector using the specified python binary. + + Returns a dictionary of information about the environment, + or containing an "error" field if an error occurred. + """ + flags = [] + if conda_mode: + flags.append("c") + if force_generate: + flags.append("f") + args = [python, "-m", "rsconnect.environment"] + if len(flags) > 0: + args.append("-" + "".join(flags)) + args.append(directory) + try: + environment_json = check_output(args, universal_newlines=True) + except subprocess.CalledProcessError as e: + raise RSConnectException("Error inspecting environment: %s" % e.output) + return MakeEnvironment(**json.loads(environment_json)) # type: ignore + + +def get_python_env_info(file_name, python, conda_mode=False, force_generate=False): + """ + Gathers the python and environment information relating to the specified file + with an eye to deploy it. + + :param file_name: the primary file being deployed. + :param python: the optional name of a Python executable. + :param conda_mode: inspect the environment assuming Conda + :param force_generate: force generating "requirements.txt" or "environment.yml", + even if it already exists. + :return: information about the version of Python in use plus some environmental + stuff. + """ + python = which_python(python) + logger.debug("Python: %s" % python) + environment = inspect_environment(python, dirname(file_name), conda_mode=conda_mode, force_generate=force_generate) + if environment.error: + raise RSConnectException(environment.error) + logger.debug("Python: %s" % python) + logger.debug("Environment: %s" % pformat(environment._asdict())) + + return python, environment + + +def create_notebook_manifest_and_environment_file( + entry_point_file: str, + environment: Environment, + app_mode: AppMode, + extra_files: typing.List[str], + force: bool, + hide_all_input: bool, + hide_tagged_input: bool, + image: str = None, +) -> None: + """ + Creates and writes a manifest.json file for the given notebook entry point file. + If the related environment file (requirements.txt, environment.yml, etc.) doesn't + exist (or force is set to True), the environment file will also be written. + + :param entry_point_file: the entry point file (Jupyter notebook, etc.) to build + the manifest for. + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param app_mode: the application mode to assume. If this is None, the extension + portion of the entry point file name will be used to derive one. Previous default = None. + :param extra_files: any extra files that should be included in the manifest. Previous default = None. + :param force: if True, forces the environment file to be written. even if it + already exists. Previous default = True. + :param hide_all_input: if True, will hide all input cells when rendering output. Previous default = False. + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag + when rendering output. Previous default = False. + :param image: an optional docker image for off-host execution. Previous default = None. + :return: + """ + if ( + not write_notebook_manifest_json( + entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, image + ) + or force + ): + write_environment_file(environment, dirname(entry_point_file)) + + +def write_notebook_manifest_json( + entry_point_file: str, + environment: Environment, + app_mode: AppMode, + extra_files: typing.List[str], + hide_all_input: bool, + hide_tagged_input: bool, + image: str = None, +) -> bool: + """ + Creates and writes a manifest.json file for the given entry point file. If + the application mode is not provided, an attempt will be made to resolve one + based on the extension portion of the entry point file. + + :param entry_point_file: the entry point file (Jupyter notebook, etc.) to build + the manifest for. + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param app_mode: the application mode to assume. If this is None, the extension + portion of the entry point file name will be used to derive one. Previous default = None. + :param extra_files: any extra files that should be included in the manifest. Previous default = None. + :param hide_all_input: if True, will hide all input cells when rendering output. Previous default = False. + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag + when rendering output. Previous default = False. + :param image: the optional docker image to be specified for off-host execution. Default = None. + :return: whether or not the environment file (requirements.txt, environment.yml, + etc.) that goes along with the manifest exists. + """ + extra_files = validate_extra_files(dirname(entry_point_file), extra_files) + directory = dirname(entry_point_file) + file_name = basename(entry_point_file) + manifest_path = join(directory, "manifest.json") + + if app_mode is None: + _, extension = splitext(file_name) + app_mode = AppModes.get_by_extension(extension, True) + if app_mode == AppModes.UNKNOWN: + raise RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) + + manifest_data = make_source_manifest(app_mode, environment, file_name, None, image) + manifest_add_file(manifest_data, file_name, directory) + manifest_add_buffer(manifest_data, environment.filename, environment.contents) + + for rel_path in extra_files: + manifest_add_file(manifest_data, rel_path, directory) + + write_manifest_json(manifest_path, manifest_data) + + return exists(join(directory, environment.filename)) + + +def create_api_manifest_and_environment_file( + directory: str, + entry_point: str, + environment: Environment, + app_mode: AppMode, + extra_files: typing.List[str], + excludes: typing.List[str], + force: bool, + image: str = None, +) -> None: + """ + Creates and writes a manifest.json file for the given Python API entry point. If + the related environment file (requirements.txt, environment.yml, etc.) doesn't + exist (or force is set to True), the environment file will also be written. + + :param directory: the root directory of the Python API. + :param entry_point: the module/executable object for the WSGi framework. + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param app_mode: the application mode to assume. Previous default = AppModes.PYTHON_API. + :param extra_files: any extra files that should be included in the manifest. Previous default = None. + :param excludes: a sequence of glob patterns that will exclude matched files. Previous default = None. + :param force: if True, forces the environment file to be written. even if it + already exists. Previous default = True. + :param image: the optional docker image to be specified for off-host execution. Default = None. + :return: + """ + if ( + not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes, image) + or force + ): + write_environment_file(environment, directory) + + +def write_api_manifest_json( + directory: str, + entry_point: str, + environment: Environment, + app_mode: AppMode, + extra_files: typing.List[str], + excludes: typing.List[str], + image: str = None, +) -> bool: + """ + Creates and writes a manifest.json file for the given entry point file. If + the application mode is not provided, an attempt will be made to resolve one + based on the extension portion of the entry point file. + + :param directory: the root directory of the Python API. + :param entry_point: the module/executable object for the WSGi framework. + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param app_mode: the application mode to assume. Previous default = AppModes.PYTHON_API. + :param extra_files: any extra files that should be included in the manifest. Previous default = None. + :param excludes: a sequence of glob patterns that will exclude matched files. Previous default = None. + :param image: the optional docker image to be specified for off-host execution. Default = None. + :return: whether or not the environment file (requirements.txt, environment.yml, + etc.) that goes along with the manifest exists. + """ + extra_files = validate_extra_files(directory, extra_files) + manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes, image) + manifest_path = join(directory, "manifest.json") + + write_manifest_json(manifest_path, manifest) + + return exists(join(directory, environment.filename)) + + +def write_environment_file( + environment: Environment, + directory: str, +) -> None: + """ + Writes the environment file (requirements.txt, environment.yml, etc.) to the + specified directory. + + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param directory: the directory where the file should be written. + """ + environment_file_path = join(directory, environment.filename) + with open(environment_file_path, "w") as f: + f.write(environment.contents) + + +def describe_manifest( + file_name: str, +) -> typing.Tuple[str, str]: + """ + Determine the entry point and/or primary file from the given manifest file. + If no entry point is recorded in the manifest, then None will be returned for + that. The same is true for the primary document. None will be returned for + both if the file doesn't exist or doesn't look like a manifest file. + + :param file_name: the name of the manifest file to read. + :return: the entry point and primary document from the manifest. + """ + if basename(file_name) == "manifest.json" and exists(file_name): + manifest, _ = read_manifest_file(file_name) + metadata = manifest.get("metadata") + if metadata: + # noinspection SpellCheckingInspection + return ( + metadata.get("entrypoint"), + metadata.get("primary_rmd") or metadata.get("primary_html"), + ) + return None, None + + +def write_quarto_manifest_json( + directory: str, + inspect: typing.Any, + app_mode: AppMode, + environment: Environment, + extra_files: typing.List[str], + excludes: typing.List[str], + image: str = None, +) -> None: + """ + Creates and writes a manifest.json file for the given Quarto project. + + :param directory: The directory containing the Quarto project. + :param inspect: The parsed JSON from a 'quarto inspect' against the project. + :param app_mode: The application mode to assume (such as AppModes.STATIC_QUARTO) + :param environment: The (optional) Python environment to use. + :param extra_files: Any extra files to include in the manifest. + :param excludes: A sequence of glob patterns to exclude when enumerating files to bundle. + :param image: the optional docker image to be specified for off-host execution. Default = None. + """ + + extra_files = validate_extra_files(directory, extra_files) + manifest, _ = make_quarto_manifest(directory, inspect, app_mode, environment, extra_files, excludes, image) + manifest_path = join(directory, "manifest.json") + + write_manifest_json(manifest_path, manifest) + + +def write_manifest_json(manifest_path, manifest): + """ + Write the manifest data as JSON to the named manifest.json with a trailing newline. + """ + with open(manifest_path, "w") as f: + json.dump(manifest, f, indent=2) + f.write("\n") + + +def create_python_environment( + directory: str = None, + force_generate: bool = False, + python: str = None, + conda: bool = False, +): + module_file = fake_module_file_from_directory(directory) + + # click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url)) + + _warn_on_ignored_manifest(directory) + _warn_if_no_requirements_file(directory) + _warn_if_environment_directory(directory) + + # with cli_feedback("Inspecting Python environment"): + _, environment = get_python_env_info(module_file, python, conda, force_generate) + + if force_generate: + _warn_on_ignored_requirements(directory, environment.filename) + + return environment diff --git a/rsconnect/exception.py b/rsconnect/exception.py new file mode 100644 index 00000000..c3b925b3 --- /dev/null +++ b/rsconnect/exception.py @@ -0,0 +1,5 @@ +class RSConnectException(Exception): + def __init__(self, message, cause=None): + super(RSConnectException, self).__init__(message) + self.message = message + self.cause = cause diff --git a/rsconnect/log.py b/rsconnect/log.py index d4e5f3cb..1bfce807 100644 --- a/rsconnect/log.py +++ b/rsconnect/log.py @@ -3,7 +3,7 @@ """ import json import logging - +from functools import partial, wraps import click import six @@ -120,3 +120,94 @@ def setLevel(self, level): logger = RSLogger() logger.addHandler(logging.StreamHandler()) logger.set_log_output_format(LogOutputFormat.DEFAULT) + + +class ConsoleFormatter(logging.Formatter): + + green = "\x1b[32;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + msg_format = "%(message)s" + reset = "\x1b[0m" + + FORMATS = { + logging.DEBUG: green + msg_format + reset, + logging.INFO: reset + msg_format + reset, + logging.WARNING: yellow + msg_format + reset, + logging.ERROR: red + msg_format + reset, + logging.CRITICAL: red + msg_format + reset, + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +console_logger = logging.getLogger("console") +console_logger.setLevel(logging.DEBUG) + +# create console handler +console_handler = logging.StreamHandler() +console_handler.terminator = "" +console_handler.setLevel(logging.DEBUG) +console_handler.setFormatter(ConsoleFormatter()) +console_logger.addHandler(console_handler) + + +def logged(logger, label): + def decorator(f): + @wraps(f) + def wrapper(*args, **kw): + logger.info(label) + result = None + try: + result = f(*args, **kw) + except Exception as exc: + logger.error(" \t[ERROR]: {}\n".format(str(exc))) + raise + logger.debug(" \t[OK]\n") + return result + + return wrapper + + return decorator + + +def cls_logged(label): # uses logger provided by a class' self.logger + def decorator(method): + @wraps(method) + def wrapper(self, *args, **kw): + logger = self.logger + if logger: + logger.info(label) + result = None + try: + result = method(self, *args, **kw) + except Exception as exc: + msg = " \t[ERROR]: {}\n" + if logger: + logger.error(msg.format(str(exc))) + else: + print(msg) + raise + if logger: + logger.debug(" \t[OK]\n") + return result + + return wrapper + + return decorator + + +console_logged = partial(logged, console_logger) + + +# generic logger +connect_logger = logging.getLogger("connect_logger") +connect_logger.setLevel(logging.DEBUG) +connect_handler = logging.StreamHandler() +connect_handler.terminator = "\n" +connect_handler.setLevel(logging.DEBUG) +connect_handler.setFormatter(ConsoleFormatter()) +connect_logger.addHandler(connect_handler) diff --git a/rsconnect/main.py b/rsconnect/main.py index 645da90d..a528334c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1,53 +1,26 @@ -import errno import functools import json import os import sys +import traceback import typing import textwrap -from os.path import abspath, dirname, exists, isdir, join - - import click from six import text_type - +from os.path import abspath, dirname, exists, isdir, join +from functools import wraps +from .environment import EnvironmentException +from .exception import RSConnectException from .actions import ( - are_apis_supported_on_server, - check_server_capabilities, cli_feedback, - create_api_deployment_bundle, - create_notebook_deployment_bundle, create_quarto_deployment_bundle, - deploy_bundle, describe_manifest, - gather_basic_deployment_info_for_api, - gather_basic_deployment_info_for_fastapi, - gather_basic_deployment_info_for_dash, - gather_basic_deployment_info_for_streamlit, - gather_basic_deployment_info_for_bokeh, - gather_basic_deployment_info_for_notebook, - gather_basic_deployment_info_for_quarto, - gather_basic_deployment_info_from_manifest, - gather_basic_deployment_info_for_html, - gather_server_details, - get_python_env_info, - is_conda_supported_on_server, quarto_inspect, set_verbosity, - spool_deployment_log, test_api_key, test_server, - validate_entry_point, - validate_extra_files, - validate_file_is_notebook, - validate_manifest_file, validate_quarto_engines, which_quarto, - write_api_manifest_json, - write_environment_file, - write_notebook_manifest_json, - write_quarto_manifest_json, - fake_module_file_from_directory, ) from .actions_content import ( download_bundle, @@ -62,10 +35,28 @@ ) from . import api, VERSION +from .api import RSConnectExecutor, filter_out_server_info from .bundle import ( + are_apis_supported_on_server, + create_python_environment, + default_title_from_manifest, is_environment_dir, make_manifest_bundle, make_html_bundle, + make_api_bundle, + make_notebook_html_bundle, + make_notebook_source_bundle, + read_manifest_app_mode, + write_notebook_manifest_json, + write_api_manifest_json, + write_environment_file, + write_quarto_manifest_json, + validate_entry_point, + validate_extra_files, + validate_file_is_notebook, + validate_manifest_file, + fake_module_file_from_directory, + get_python_env_info, ) from .log import logger, LogOutputFormat from .metadata import ServerStore, AppStore @@ -77,11 +68,34 @@ VersionSearchFilterParamType, ) - server_store = ServerStore() future_enabled = False +def cli_exception_handler(func): + @wraps(func) + def wrapper(*args, **kwargs): + def failed(err): + click.secho(str(err), fg="bright_red", err=False) + sys.exit(1) + + try: + result = func(*args, **kwargs) + except RSConnectException as exc: + failed("Error: " + exc.message) + except EnvironmentException as exc: + failed("Error: " + str(exc)) + except Exception as exc: + if click.get_current_context("verbose"): + traceback.print_exc() + failed("Internal error: " + str(exc)) + finally: + logger.set_in_feedback(False) + return result + + return wrapper + + def server_args(func): @click.option("--name", "-n", help="The nickname of the RStudio Connect server to deploy to.") @click.option( @@ -324,19 +338,19 @@ def list_servers(verbose): ), ) @server_args +@cli_exception_handler def details(name, server, api_key, insecure, cacert, verbose): set_verbosity(verbose) - with cli_feedback("Checking arguments"): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert, api_key_is_required=False) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert).validate_server() - click.echo(" RStudio Connect URL: %s" % connect_server.url) + click.echo(" RStudio Connect URL: %s" % ce.connect_server.url) - if not connect_server.api_key: + if not ce.connect_server.api_key: return with cli_feedback("Gathering details"): - server_details = gather_server_details(connect_server) + server_details = ce.server_details connect_version = server_details["connect"] apis_allowed = server_details["python"]["api_enabled"] @@ -375,21 +389,21 @@ def remove(name, server, verbose): with cli_feedback("Checking arguments"): if name and server: - raise api.RSConnectException("You must specify only one of -n/--name or -s/--server.") + raise RSConnectException("You must specify only one of -n/--name or -s/--server.") if not (name or server): - raise api.RSConnectException("You must specify one of -n/--name or -s/--server.") + raise RSConnectException("You must specify one of -n/--name or -s/--server.") if name: if server_store.remove_by_name(name): message = 'Removed nickname "%s".' % name else: - raise api.RSConnectException('Nickname "%s" was not found.' % name) + raise RSConnectException('Nickname "%s" was not found.' % name) else: # the user specified -s/--server if server_store.remove_by_url(server): message = 'Removed URL "%s".' % server else: - raise api.RSConnectException('URL "%s" was not found.' % server) + raise RSConnectException('URL "%s" was not found.' % server) if message: click.echo(message) @@ -475,14 +489,14 @@ def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_r ca_data = ca_cert and text_type(ca_cert.read()) if name and url: - raise api.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.") 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 api.RSConnectException("You must specify one of -n/--name or -s/--server.") + raise RSConnectException("You must specify one of -n/--name or -s/--server.") connect_server = api.RSConnectServer(real_server, None, insecure, ca_data) @@ -494,7 +508,7 @@ def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_r if not connect_server.api_key: if api_key_is_required: - raise api.RSConnectException('An API key must be specified for "%s".' % connect_server.url) + 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. @@ -577,69 +591,6 @@ def _warn_on_ignored_requirements(directory, requirements_file_name): ) -def _deploy_bundle( - connect_server, - app_store, - primary_path, - app_id, - app_mode, - name, - title, - title_is_default, - bundle, - env_vars, -): - """ - Does the work of uploading a prepared bundle. - - :param connect_server: the Connect server information. - :param app_store: the store where data is saved about deployments. - :param primary_path: the base path (file or directory) that's being deployed. - :param app_id: the ID of the app. - :param app_mode: the mode of the app. - :param name: the name of the app. - :param title: the title of the app. - :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 to be set as the app environment - """ - with cli_feedback("Uploading bundle"): - app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle, env_vars) - - with cli_feedback("Saving deployment data"): - # Note we are NOT saving image into the deployment record for now. - app_store.set( - connect_server.url, - abspath(primary_path), - app["app_url"], - app["app_id"], - app["app_guid"], - title, - app_mode, - ) - - with cli_feedback(""): - click.secho("\nDeployment log:") - app_url, _, _ = spool_deployment_log(connect_server, app, click.echo) - click.secho("Deployment completed successfully.") - click.secho(" Dashboard content URL: ", nl=False) - click.secho(app_url, fg="green") - click.secho(" Direct content URL: ", nl=False) - click.secho(app["app_url"], fg="green") - - # save the config URL, replacing the old app URL we got during deployment - # (which is the Open Solo URL). - app_store.set( - connect_server.url, - abspath(primary_path), - app_url, - app["app_id"], - app["app_guid"], - app["title"], - app_mode, - ) - - # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="notebook", @@ -700,78 +651,65 @@ def _deploy_bundle( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@cli_exception_handler def deploy_notebook( - name, - server, - api_key, - insecure, - cacert, - static, - new, - app_id, - title, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: typing.IO, + static: bool, + new: bool, + app_id: str, + title: str, python, conda, force_generate, - verbose, - file, + verbose: bool, + file: str, extra_files, - hide_all_input, - hide_tagged_input, - env_vars, - image: str = None, + hide_all_input: bool, + hide_tagged_input: bool, + env_vars: typing.Dict[str, str], + image: str, ): + kwargs = locals() set_verbosity(verbose) - with cli_feedback("Checking arguments"): - app_store = AppStore(file) - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - extra_files = validate_extra_files(dirname(file), extra_files) - (app_id, deployment_name, title, default_title, app_mode,) = gather_basic_deployment_info_for_notebook( - connect_server, - app_store, - file, - new, - app_id, - title, - static, - ) - - click.secho(' Deploying %s to server "%s"' % (file, connect_server.url)) + kwargs["extra_files"] = extra_files = validate_extra_files(dirname(file), extra_files) + app_mode = AppModes.JUPYTER_NOTEBOOK if not static else AppModes.STATIC base_dir = dirname(file) _warn_on_ignored_manifest(base_dir) _warn_if_no_requirements_file(base_dir) _warn_if_environment_directory(base_dir) - - with cli_feedback("Inspecting Python environment"): - python, environment = get_python_env_info(file, python, conda, force_generate) - - if environment.package_manager == "conda": - with cli_feedback("Ensuring Conda is supported"): - check_server_capabilities(connect_server, [is_conda_supported_on_server]) - else: - _warn_on_ignored_conda_env(environment) + python, environment = get_python_env_info(file, python, conda, force_generate) if force_generate: _warn_on_ignored_requirements(base_dir, environment.filename) - with cli_feedback("Creating deployment bundle"): - bundle = create_notebook_deployment_bundle( - file, extra_files, app_mode, python, environment, False, hide_all_input, hide_tagged_input, image + ce = RSConnectExecutor(**kwargs) + ce.validate_server().validate_app_mode(app_mode=app_mode) + if app_mode == AppModes.STATIC: + ce.make_bundle( + make_notebook_html_bundle, + file, + python, + hide_all_input, + hide_tagged_input, + image=image, ) - _deploy_bundle( - connect_server, - app_store, - file, - app_id, - app_mode, - deployment_name, - title, - default_title, - bundle, - env_vars, - ) + else: + ce.make_bundle( + make_notebook_source_bundle, + file, + environment, + extra_files, + hide_all_input, + hide_tagged_input, + image=image, + ) + ce.deploy_bundle().save_deployed_info().emit_task_log() # noinspection SpellCheckingInspection,DuplicatedCode @@ -787,80 +725,38 @@ def deploy_notebook( @server_args @content_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) +@cli_exception_handler def deploy_manifest( name: str, server: str, api_key: str, insecure: bool, - cacert: str, + cacert: typing.IO, new: bool, - app_id: int, + app_id: str, title: str, verbose: bool, file: str, env_vars: typing.Dict[str, str], ): + kwargs = locals() set_verbosity(verbose) - with cli_feedback("Checking arguments"): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - file = validate_manifest_file(file) - app_store = AppStore(file) - - ( - app_id, - deployment_name, - title, - default_title, - app_mode, - package_manager, - _, - ) = gather_basic_deployment_info_from_manifest( - connect_server, - app_store, - file, - new, - app_id, - title, + file_name = kwargs["file"] = validate_manifest_file(file) + app_mode = read_manifest_app_mode(file_name) + kwargs["title"] = title or default_title_from_manifest(file) + + ce = RSConnectExecutor(**kwargs) + ( + ce.validate_server() + .validate_app_mode(app_mode=app_mode) + .make_bundle( + make_manifest_bundle, + file_name, ) - - click.secho(' Deploying %s to server "%s"' % (file, connect_server.url)) - - if package_manager == "conda": - with cli_feedback("Ensuring Conda is supported"): - check_server_capabilities(connect_server, [is_conda_supported_on_server]) - - with cli_feedback("Creating deployment bundle"): - try: - bundle = make_manifest_bundle(file) - except IOError as error: - msg = "Unable to include the file %s in the bundle: %s" % ( - error.filename, - error.args[1], - ) - if error.args[0] == errno.ENOENT: - msg = "\n".join( - [ - msg, - "Since the file is missing but referenced in the manifest, " - "you will need to\nregenerate your manifest. See the help " - 'for the "write-manifest" command or,\nfor non-Python ' - 'content, run the "deploy other-content" command.', - ] - ) - raise api.RSConnectException(msg) - - _deploy_bundle( - connect_server, - app_store, - file, - app_id, - app_mode, - deployment_name, - title, - default_title, - bundle, - env_vars, + .deploy_bundle() + .save_deployed_info() + .emit_task_log() ) @@ -921,46 +817,34 @@ def deploy_manifest( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@cli_exception_handler def deploy_quarto( - name, - server, - api_key, - insecure, - cacert, - new, - app_id, - title, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: typing.IO, + new: bool, + app_id: str, + title: str, exclude, quarto, python, - force_generate, - verbose, + force_generate: bool, + verbose: bool, file_or_directory, extra_files, - env_vars, - image, + env_vars: typing.Dict[str, str], + image: str, ): + kwargs = locals() set_verbosity(verbose) base_dir = file_or_directory if not isdir(file_or_directory): base_dir = dirname(file_or_directory) - - with cli_feedback("Checking arguments"): - module_file = fake_module_file_from_directory(file_or_directory) - app_store = AppStore(module_file) - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - extra_files = validate_extra_files(base_dir, extra_files) - (app_id, deployment_name, title, default_title, app_mode) = gather_basic_deployment_info_for_quarto( - connect_server, - app_store, - file_or_directory, - new, - app_id, - title, - ) - - click.secho(' Deploying %s to server "%s"' % (file_or_directory, connect_server.url)) + module_file = fake_module_file_from_directory(file_or_directory) + extra_files = validate_extra_files(base_dir, extra_files) _warn_on_ignored_manifest(base_dir) @@ -984,22 +868,23 @@ def deploy_quarto( if force_generate: _warn_on_ignored_requirements(base_dir, environment.filename) - with cli_feedback("Creating deployment bundle"): - bundle = create_quarto_deployment_bundle( - file_or_directory, extra_files, exclude, app_mode, inspect, environment, image + ce = RSConnectExecutor(**kwargs) + ( + ce.validate_server() + .validate_app_mode(app_mode=AppModes.STATIC_QUARTO) + .make_bundle( + create_quarto_deployment_bundle, + file_or_directory, + extra_files, + exclude, + AppModes.STATIC_QUARTO, + inspect, + environment, + image=image, ) - - _deploy_bundle( - connect_server, - app_store, - file_or_directory, - app_id, - app_mode, - deployment_name, - title, - default_title, - bundle, - env_vars, + .deploy_bundle() + .save_deployed_info() + .emit_task_log() ) @@ -1032,60 +917,45 @@ def deploy_quarto( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@cli_exception_handler def deploy_html( - name, - server, - api_key, - insecure, - cacert, - new, - app_id, - title, - verbose, - path, - env_vars, - entrypoint, - extra_files, - excludes, + connect_server: api.RSConnectServer = None, + path: str = None, + entrypoint: str = None, + extra_files=None, + excludes=None, + title: str = None, + env_vars: typing.Dict[str, str] = None, + verbose: bool = False, + new: bool = False, + app_id: str = None, + name: str = None, + server: str = None, + api_key: str = None, + insecure: bool = False, + cacert: typing.IO = None, ): - set_verbosity(verbose) - - with cli_feedback("Checking arguments"): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - app_store = AppStore(path) + kwargs = locals() + ce = None + if connect_server: + kwargs = filter_out_server_info(**kwargs) + ce = RSConnectExecutor.fromConnectServer(connect_server, **kwargs) + else: + ce = RSConnectExecutor(**kwargs) - (app_id, deployment_name, title, default_title, app_mode) = gather_basic_deployment_info_for_html( - connect_server, - app_store, + ( + ce.validate_server() + .validate_app_mode(app_mode=AppModes.STATIC) + .make_bundle( + make_html_bundle, path, - new, - app_id, - title, + entrypoint, + extra_files, + excludes, ) - - click.secho(' Deploying %s to server "%s"' % (path, connect_server.url)) - - with cli_feedback("Creating deployment bundle"): - try: - bundle = make_html_bundle(path, entrypoint, extra_files, excludes, None) - except IOError as error: - msg = "Unable to include the file %s in the bundle: %s" % ( - error.filename, - error.args[1], - ) - raise api.RSConnectException(msg) - - _deploy_bundle( - connect_server, - app_store, - path, - app_id, - app_mode, - deployment_name, - title, - default_title, - bundle, - env_vars, + .deploy_bundle() + .save_deployed_info() + .emit_task_log() ) @@ -1154,52 +1024,55 @@ def generate_deploy_python(app_mode, alias, min_version): nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) + @cli_exception_handler def deploy_app( - name, - server, - api_key, - insecure, - cacert, + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: typing.IO, entrypoint, exclude, - new, - app_id, - title, + new: bool, + app_id: str, + title: str, python, conda, - force_generate, - verbose, + force_generate: bool, + verbose: bool, directory, extra_files, - env_vars, - image: str = None, + env_vars: typing.Dict[str, str], + image: str, ): - _deploy_by_framework( - name, - server, - api_key, - insecure, - cacert, - entrypoint, - exclude, - new, - app_id, - title, + kwargs = locals() + kwargs["entrypoint"] = entrypoint = validate_entry_point(entrypoint, directory) + kwargs["extra_files"] = extra_files = validate_extra_files(directory, extra_files) + environment = create_python_environment( + directory, + force_generate, python, conda, - force_generate, - verbose, - directory, - extra_files, - env_vars, - { - AppModes.PYTHON_API: gather_basic_deployment_info_for_api, - AppModes.PYTHON_FASTAPI: gather_basic_deployment_info_for_fastapi, - AppModes.DASH_APP: gather_basic_deployment_info_for_dash, - AppModes.STREAMLIT_APP: gather_basic_deployment_info_for_streamlit, - AppModes.BOKEH_APP: gather_basic_deployment_info_for_bokeh, - }[app_mode], - image, + ) + + ce = RSConnectExecutor(**kwargs) + ( + ce.validate_server() + .validate_app_mode(app_mode=app_mode) + .check_server_capabilities([are_apis_supported_on_server]) + .make_bundle( + make_api_bundle, + directory, + entrypoint, + app_mode, + environment, + extra_files, + exclude, + image=image, + ) + .deploy_bundle() + .save_deployed_info() + .emit_task_log() ) return deploy_app @@ -1207,108 +1080,13 @@ def deploy_app( deploy_api = generate_deploy_python(app_mode=AppModes.PYTHON_API, alias="api", min_version="1.8.2") # TODO: set fastapi min_version correctly +# deploy_fastapi = generate_deploy_python(app_mode=AppModes.PYTHON_FASTAPI, alias="fastapi", min_version="2021.08.0") deploy_fastapi = generate_deploy_python(app_mode=AppModes.PYTHON_FASTAPI, alias="fastapi", min_version="2021.08.0") 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") -# noinspection SpellCheckingInspection -def _deploy_by_framework( - name, - server, - api_key, - insecure, - cacert, - entrypoint, - exclude, - new, - app_id, - title, - python, - conda, - force_generate, - verbose, - directory, - extra_files, - env_vars, - gatherer, - image, -): - """ - A common function for deploying APIs, as well as Dash, Streamlit, and Bokeh apps. - - :param name: the nickname of the Connect server to use. - :param server: the URL of the Connect server to use. - :param api_key: the API key to use to authenticate with Connect. - :param insecure: a flag noting whether insecure TLS should be used. - :param cacert: a path to a CA certificates file to use with TLS. - :param entrypoint: the entry point for the thing being deployed. - :param exclude: a sequence of exclude glob patterns to exclude files - from the deploy. - :param new: a flag to force the deploy to be new. - :param app_id: the ID of the app to redeploy. - :param title: the title to use for the app. - :param python: a path to the Python executable to use. - :param conda: a flag to note whether Conda should be used/assumed.. - :param force_generate: a flag to force the generation of manifest and - requirements file. - :param verbose: a flag to produce more (debugging) output. - :param directory: the directory of the thing to deploy. - :param extra_files: any extra files that should be included. - :param gatherer: the function to use to gather basic information. - :param image: an optional docker image for off-host execution. - """ - set_verbosity(verbose) - - with cli_feedback("Checking arguments"): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - module_file = fake_module_file_from_directory(directory) - extra_files = validate_extra_files(directory, extra_files) - app_store = AppStore(module_file) - entrypoint, app_id, deployment_name, title, default_title, app_mode = gatherer( - connect_server, app_store, directory, entrypoint, new, app_id, title - ) - - click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url)) - - _warn_on_ignored_manifest(directory) - _warn_if_no_requirements_file(directory) - _warn_if_environment_directory(directory) - - with cli_feedback("Inspecting Python environment"): - _, environment = get_python_env_info(module_file, python, conda, force_generate) - - with cli_feedback("Checking server capabilities"): - checks = [are_apis_supported_on_server] - if environment.package_manager == "conda": - checks.append(is_conda_supported_on_server) - check_server_capabilities(connect_server, checks) - - _warn_on_ignored_conda_env(environment) - - if force_generate: - _warn_on_ignored_requirements(directory, environment.filename) - - with cli_feedback("Creating deployment bundle"): - bundle = create_api_deployment_bundle( - directory, extra_files, exclude, entrypoint, app_mode, environment, False, image - ) - - _deploy_bundle( - connect_server, - app_store, - directory, - app_id, - app_mode, - deployment_name, - title, - default_title, - bundle, - env_vars, - ) - - @deploy.command( name="other-content", short_help="Describe deploying other content to RStudio Connect.", @@ -1394,7 +1172,7 @@ def write_manifest_notebook( verbose, file, extra_files, - image=None, # type: str + image, hide_all_input=None, hide_tagged_input=None, ): @@ -1407,7 +1185,7 @@ def write_manifest_notebook( manifest_path = join(base_dir, "manifest.json") if exists(manifest_path) and not overwrite: - raise api.RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") + raise RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") with cli_feedback("Inspecting Python environment"): python, environment = get_python_env_info(file, python, conda, force_generate) @@ -1500,7 +1278,7 @@ def write_manifest_quarto( verbose, file_or_directory, extra_files, - image: str = None, + image, ): set_verbosity(verbose) @@ -1513,7 +1291,7 @@ def write_manifest_quarto( manifest_path = join(base_dir, "manifest.json") if exists(manifest_path) and not overwrite: - raise api.RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") + raise RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") with cli_feedback("Inspecting Quarto project"): quarto = which_quarto(quarto) @@ -1621,7 +1399,7 @@ def manifest_writer( verbose, directory, extra_files, - image: str = None, + image, ): _write_framework_manifest( overwrite, @@ -1686,7 +1464,7 @@ def _write_framework_manifest( manifest_path = join(directory, "manifest.json") if exists(manifest_path) and not overwrite: - raise api.RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") + raise RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") with cli_feedback("Inspecting Python environment"): _, environment = get_python_env_info(directory, python, conda, force_generate) @@ -1716,9 +1494,9 @@ def _write_framework_manifest( def _validate_build_rm_args(guid, all, purge): if guid and all: - raise api.RSConnectException("You must specify only one of -g/--guid or --all, not both.") + raise RSConnectException("You must specify only one of -g/--guid or --all, not both.") if not guid and not all: - raise api.RSConnectException("You must specify one of -g/--guid or --all.") + raise RSConnectException("You must specify one of -g/--guid or --all.") @cli.group(no_args_is_help=True, help="Interact with RStudio Connect's content API.") @@ -1795,6 +1573,7 @@ def content(): ) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") # todo: --format option (json, text) +@cli_exception_handler def content_search( name, server, @@ -1812,9 +1591,9 @@ def content_search( ): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() result = search_content( - connect_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) @@ -1865,8 +1644,8 @@ def content_search( def content_describe(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - result = get_content(connect_server, guid) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + result = get_content(ce.connect_server, guid) json.dump(result, sys.stdout, indent=2) @@ -1926,11 +1705,11 @@ def content_describe(name, server, api_key, insecure, cacert, guid, verbose): def content_bundle_download(name, server, api_key, insecure, cacert, guid, output, overwrite, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() if exists(output) and not overwrite: - raise api.RSConnectException("The output file already exists: %s" % output) + raise RSConnectException("The output file already exists: %s" % output) - result = download_bundle(connect_server, guid) + result = download_bundle(ce.connect_server, guid) with open(output, "wb") as f: f.write(result.response_body) @@ -1984,8 +1763,8 @@ def build(): def add_content_build(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - build_add_content(connect_server, guid) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + build_add_content(ce.connect_server, guid) if len(guid) == 1: logger.info('Added "%s".' % guid[0]) else: @@ -2048,9 +1827,9 @@ def add_content_build(name, server, api_key, insecure, cacert, guid, verbose): def remove_content_build(name, server, api_key, insecure, cacert, guid, all, purge, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() _validate_build_rm_args(guid, all, purge) - guids = build_remove_content(connect_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: @@ -2102,8 +1881,8 @@ def remove_content_build(name, server, api_key, insecure, cacert, guid, all, pur def list_content_build(name, server, api_key, insecure, cacert, status, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - result = build_list_content(connect_server, guid, status) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + result = build_list_content(ce.connect_server, guid, status) json.dump(result, sys.stdout, indent=2) @@ -2149,8 +1928,9 @@ def list_content_build(name, server, api_key, insecure, cacert, status, guid, ve def get_build_history(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - result = build_history(connect_server, guid) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert) + ce.validate_server() + result = build_history(ce.connect_server, guid) json.dump(result, sys.stdout, indent=2) @@ -2212,8 +1992,8 @@ def get_build_history(name, server, api_key, insecure, cacert, guid, verbose): def get_build_logs(name, server, api_key, insecure, cacert, guid, task_id, format, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - for line in emit_build_log(connect_server, guid, format, task_id): + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + for line in emit_build_log(ce.connect_server, guid, format, task_id): sys.stdout.write(line) @@ -2279,8 +2059,8 @@ def start_content_build( set_verbosity(verbose) logger.set_log_output_format(format) with cli_feedback("", stderr=True): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - build_start(connect_server, parallelism, aborted, error, all, poll_wait, debug) + ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() + 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 72ba7a22..d735bc77 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -13,7 +13,7 @@ import shutil from threading import Lock -from . import api +from .exception import RSConnectException from .log import logger from .models import AppMode, AppModes @@ -316,7 +316,7 @@ def resolve(self, name, url, api_key, insecure, ca_data): if name: entry = self.get_by_name(name) if not entry: - raise api.RSConnectException('The nickname, "%s", does not exist.' % name) + raise RSConnectException('The nickname, "%s", does not exist.' % name) elif url: entry = self.get_by_url(url) else: diff --git a/tests/test_actions.py b/tests/test_actions.py index 898f3764..42a034fa 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,29 +1,21 @@ import os -import shutil import subprocess import sys -import tempfile try: import typing except ImportError: typing = None -from os.path import basename, dirname, join +from os.path import basename, join from unittest import TestCase import pytest import rsconnect.actions -from rsconnect import api from rsconnect.actions import ( - _default_title, - _default_title_from_manifest, - _make_deployment_name, - _to_server_check_list, - _validate_title, _verify_server, are_apis_supported_on_server, check_server_capabilities, @@ -35,16 +27,13 @@ deploy_bokeh_app, gather_basic_deployment_info_for_api, get_python_env_info, - inspect_environment, is_conda_supported_on_server, - validate_entry_point, - validate_extra_files, - which_python, ) -from rsconnect.api import RSConnectException, RSConnectServer +from rsconnect.api import RSConnectServer from rsconnect.environment import MakeEnvironment +from rsconnect.exception import RSConnectException -from .utils import get_manifest_path, get_api_path, get_dir +from .utils import get_api_path, get_dir class TestActions(TestCase): @@ -52,36 +41,15 @@ class TestActions(TestCase): def optional_target(default): return os.environ.get("CONNECT_DEPLOY_TARGET", default) - def test_which_python(self): - with self.assertRaises(RSConnectException): - which_python("fake.file") - - self.assertEqual(which_python(sys.executable), sys.executable) - self.assertEqual(which_python(None), sys.executable) - self.assertEqual(which_python(None, {"RETICULATE_PYTHON": "fake-python"}), "fake-python") - def test_verify_server(self): with self.assertRaises(RSConnectException): _verify_server(RSConnectServer("fake-url", None)) - def test_to_server_check_list(self): - a_list = _to_server_check_list("no-scheme") - - self.assertEqual(a_list, ["https://no-scheme", "http://no-scheme"]) - - a_list = _to_server_check_list("//no-scheme") - - self.assertEqual(a_list, ["https://no-scheme", "http://no-scheme"]) - - a_list = _to_server_check_list("scheme://no-scheme") - - self.assertEqual(a_list, ["scheme://no-scheme"]) - def test_check_server_capabilities(self): no_api_support = {"python": {"api_enabled": False}} api_support = {"python": {"api_enabled": True}} - with self.assertRaises(api.RSConnectException) as context: + with self.assertRaises(RSConnectException) as context: check_server_capabilities(None, (are_apis_supported_on_server,), lambda x: no_api_support) self.assertEqual( str(context.exception), @@ -94,14 +62,14 @@ def test_check_server_capabilities(self): conda_not_supported = {"conda": {"supported": False}} conda_supported = {"conda": {"supported": True}} - with self.assertRaises(api.RSConnectException) as context: + with self.assertRaises(RSConnectException) as context: check_server_capabilities(None, (is_conda_supported_on_server,), lambda x: no_conda) self.assertEqual( str(context.exception), "Conda is not supported on the target server. " + "Try deploying without requesting Conda.", ) - with self.assertRaises(api.RSConnectException) as context: + with self.assertRaises(RSConnectException) as context: check_server_capabilities(None, (is_conda_supported_on_server,), lambda x: conda_not_supported) self.assertEqual( str(context.exception), @@ -119,101 +87,20 @@ def fake_cap_with_doc(details): """A docstring.""" return False - with self.assertRaises(api.RSConnectException) as context: + with self.assertRaises(RSConnectException) as context: check_server_capabilities(None, (fake_cap,), lambda x: None) self.assertEqual( str(context.exception), "The server does not satisfy the fake_cap capability check.", ) - with self.assertRaises(api.RSConnectException) as context: + with self.assertRaises(RSConnectException) as context: check_server_capabilities(None, (fake_cap_with_doc,), lambda x: None) self.assertEqual( str(context.exception), "The server does not satisfy the fake_cap_with_doc capability check.", ) - def test_validate_title(self): - with self.assertRaises(RSConnectException): - _validate_title("12") - - with self.assertRaises(RSConnectException): - _validate_title("1" * 1025) - - _validate_title("123") - _validate_title("1" * 1024) - - def test_validate_entry_point(self): - directory = tempfile.mkdtemp() - - try: - self.assertEqual(validate_entry_point(None, directory), "app") - self.assertEqual(validate_entry_point("app", directory), "app") - self.assertEqual(validate_entry_point("app:app", directory), "app:app") - - with self.assertRaises(RSConnectException): - validate_entry_point("x:y:z", directory) - - with open(join(directory, "onlysource.py"), "w") as f: - f.close() - self.assertEqual(validate_entry_point(None, directory), "onlysource") - - with open(join(directory, "main.py"), "w") as f: - f.close() - self.assertEqual(validate_entry_point(None, directory), "main") - finally: - shutil.rmtree(directory) - - def test_make_deployment_name(self): - self.assertEqual(_make_deployment_name(None, "title", False), "title") - self.assertEqual(_make_deployment_name(None, "Title", False), "title") - self.assertEqual(_make_deployment_name(None, "My Title", False), "my_title") - self.assertEqual(_make_deployment_name(None, "My Title", False), "my_title") - self.assertEqual(_make_deployment_name(None, "My _ Title", False), "my_title") - self.assertEqual(_make_deployment_name(None, "My-Title", False), "my-title") - # noinspection SpellCheckingInspection - self.assertEqual(_make_deployment_name(None, "M\ry\n \tT\u2103itle", False), "my_title") - self.assertEqual(_make_deployment_name(None, "\r\n\t\u2103", False), "___") - self.assertEqual(_make_deployment_name(None, "\r\n\tR\u2103", False), "__r") - - def test_default_title(self): - self.assertEqual(_default_title("testing.txt"), "testing") - self.assertEqual(_default_title("this.is.a.test.ext"), "this.is.a.test") - self.assertEqual(_default_title("1.ext"), "001") - self.assertEqual(_default_title("%s.ext" % ("n" * 2048)), "n" * 1024) - - def test_default_title_from_manifest(self): - self.assertEqual(_default_title_from_manifest({}, "dir/to/manifest.json"), "0to") - # noinspection SpellCheckingInspection - m = {"metadata": {"entrypoint": "point"}} - self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "point") - m = {"metadata": {"primary_rmd": "file.Rmd"}} - self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "file") - m = {"metadata": {"primary_html": "page.html"}} - self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "page") - m = {"metadata": {"primary_wat?": "my-cool-thing.wat"}} - self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "0to") - # noinspection SpellCheckingInspection - m = {"metadata": {"entrypoint": "module:object"}} - self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "0to") - - def test_validate_extra_files(self): - # noinspection SpellCheckingInspection - directory = dirname(get_manifest_path("shinyapp")) - - with self.assertRaises(RSConnectException): - validate_extra_files(directory, ["../other_dir/file.txt"]) - - with self.assertRaises(RSConnectException): - validate_extra_files(directory, ["not_a_file.txt"]) - - self.assertEqual(validate_extra_files(directory, None), []) - self.assertEqual(validate_extra_files(directory, []), []) - self.assertEqual( - validate_extra_files(directory, [join(directory, "index.htm")]), - ["index.htm"], - ) - def test_deploy_python_api_validates(self): directory = get_api_path("flask") server = RSConnectServer("https://www.bogus.com", "bogus") @@ -256,11 +143,6 @@ def test_create_api_deployment_bundle_validates(self): with self.assertRaises(RSConnectException): create_api_deployment_bundle(directory, ["bogus"], [], "app:app", MakeEnvironment(), None, True, None) - def test_inspect_environment(self): - environment = inspect_environment(sys.executable, get_dir("pip1")) - assert environment is not None - assert environment.python != "" - @pytest.mark.parametrize( ( @@ -354,7 +236,7 @@ def fake_inspect_environment( monkeypatch.setattr(rsconnect.actions, "which_python", fake_which_python) if expected_environment.error is not None: - with pytest.raises(api.RSConnectException): + with pytest.raises(RSConnectException): _, _ = get_python_env_info(file_name, python, conda_mode=conda_mode, force_generate=force_generate) else: python, environment = get_python_env_info( diff --git a/tests/test_api.py b/tests/test_api.py index ec12637d..f527a13e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,9 +1,18 @@ from unittest import TestCase - -from rsconnect.api import RSConnect +from .utils import ( + require_api_key, + require_connect, +) +from rsconnect.api import RSConnect, RSConnectExecutor, _to_server_check_list class TestAPI(TestCase): + 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.connect_server.url, connect_server) + def test_output_task_log(self): lines = ["line 1", "line 2", "line 3"] task_status = { @@ -23,3 +32,31 @@ def test_output_task_log(self): self.assertEqual(len(output), 4) self.assertEqual(output[3], "line 4") + + def test_to_server_check_list(self): + a_list = _to_server_check_list("no-scheme") + + self.assertEqual(a_list, ["https://no-scheme", "http://no-scheme"]) + + a_list = _to_server_check_list("//no-scheme") + + self.assertEqual(a_list, ["https://no-scheme", "http://no-scheme"]) + + a_list = _to_server_check_list("scheme://no-scheme") + + self.assertEqual(a_list, ["scheme://no-scheme"]) + + def test_make_deployment_name(self): + connect_server = require_connect(self) + api_key = require_api_key(self) + 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") + self.assertEqual(ce.make_deployment_name("My Title", False), "my_title") + self.assertEqual(ce.make_deployment_name("My Title", False), "my_title") + self.assertEqual(ce.make_deployment_name("My _ Title", False), "my_title") + self.assertEqual(ce.make_deployment_name("My-Title", False), "my-title") + # noinspection SpellCheckingInspection + self.assertEqual(ce.make_deployment_name("M\ry\n \tT\u2103itle", False), "my_title") + self.assertEqual(ce.make_deployment_name("\r\n\t\u2103", False), "___") + self.assertEqual(ce.make_deployment_name("\r\n\tR\u2103", False), "__r") diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 6967db68..d1e5868b 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +import shutil import sys import tarfile import tempfile @@ -9,6 +10,10 @@ from rsconnect.environment import detect_environment from rsconnect.bundle import ( + _default_title, + _default_title_from_manifest, + _validate_title, + inspect_environment, list_files, make_manifest_bundle, make_notebook_html_bundle, @@ -18,10 +23,14 @@ make_source_manifest, make_quarto_manifest, make_html_manifest, + validate_entry_point, + validate_extra_files, + which_python, ) +from rsconnect.exception import RSConnectException from rsconnect.models import AppModes from rsconnect.environment import Environment -from .utils import get_dir +from .utils import get_dir, get_manifest_path class TestBundle(TestCase): @@ -585,3 +594,85 @@ def test_make_html_manifest(self): "environment": {"image": "rstudio/connect:bionic"}, }, ) + + def test_validate_extra_files(self): + # noinspection SpellCheckingInspection + directory = dirname(get_manifest_path("shinyapp")) + + with self.assertRaises(RSConnectException): + validate_extra_files(directory, ["../other_dir/file.txt"]) + + with self.assertRaises(RSConnectException): + validate_extra_files(directory, ["not_a_file.txt"]) + + self.assertEqual(validate_extra_files(directory, None), []) + self.assertEqual(validate_extra_files(directory, []), []) + self.assertEqual( + validate_extra_files(directory, [join(directory, "index.htm")]), + ["index.htm"], + ) + + def test_validate_title(self): + with self.assertRaises(RSConnectException): + _validate_title("12") + + with self.assertRaises(RSConnectException): + _validate_title("1" * 1025) + + _validate_title("123") + _validate_title("1" * 1024) + + def test_validate_entry_point(self): + directory = tempfile.mkdtemp() + + try: + self.assertEqual(validate_entry_point(None, directory), "app") + self.assertEqual(validate_entry_point("app", directory), "app") + self.assertEqual(validate_entry_point("app:app", directory), "app:app") + + with self.assertRaises(RSConnectException): + validate_entry_point("x:y:z", directory) + + with open(join(directory, "onlysource.py"), "w") as f: + f.close() + self.assertEqual(validate_entry_point(None, directory), "onlysource") + + with open(join(directory, "main.py"), "w") as f: + f.close() + self.assertEqual(validate_entry_point(None, directory), "main") + finally: + shutil.rmtree(directory) + + def test_which_python(self): + with self.assertRaises(RSConnectException): + which_python("fake.file") + + self.assertEqual(which_python(sys.executable), sys.executable) + self.assertEqual(which_python(None), sys.executable) + self.assertEqual(which_python(None, {"RETICULATE_PYTHON": "fake-python"}), "fake-python") + + def test_default_title(self): + self.assertEqual(_default_title("testing.txt"), "testing") + self.assertEqual(_default_title("this.is.a.test.ext"), "this.is.a.test") + self.assertEqual(_default_title("1.ext"), "001") + self.assertEqual(_default_title("%s.ext" % ("n" * 2048)), "n" * 1024) + + def test_default_title_from_manifest(self): + self.assertEqual(_default_title_from_manifest({}, "dir/to/manifest.json"), "0to") + # noinspection SpellCheckingInspection + m = {"metadata": {"entrypoint": "point"}} + self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "point") + m = {"metadata": {"primary_rmd": "file.Rmd"}} + self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "file") + m = {"metadata": {"primary_html": "page.html"}} + self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "page") + m = {"metadata": {"primary_wat?": "my-cool-thing.wat"}} + self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "0to") + # noinspection SpellCheckingInspection + m = {"metadata": {"entrypoint": "module:object"}} + self.assertEqual(_default_title_from_manifest(m, "dir/to/manifest.json"), "0to") + + def test_inspect_environment(self): + environment = inspect_environment(sys.executable, get_dir("pip1")) + assert environment is not None + assert environment.python != "" diff --git a/tests/test_main.py b/tests/test_main.py index 9ebc2319..7828ac13 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -14,7 +14,7 @@ require_api_key, require_connect, ) -from rsconnect.api import RSConnectException +from rsconnect.exception import RSConnectException from rsconnect.main import cli, _validate_deploy_to_args, server_store from rsconnect import VERSION @@ -95,7 +95,6 @@ def test_deploy(self): args = self.create_deploy_args("notebook", target) result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("OK", result.output) # noinspection SpellCheckingInspection def test_deploy_manifest(self): @@ -104,7 +103,6 @@ def test_deploy_manifest(self): args = self.create_deploy_args("manifest", target) result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("OK", result.output) def test_deploy_api(self): target = optional_target(get_api_path("flask")) @@ -112,4 +110,3 @@ def test_deploy_api(self): args = self.create_deploy_args("api", target) result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("OK", result.output)