diff --git a/.gitignore b/.gitignore index 782b1122..f2abae52 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ /.local/ /.pipenv-requires /.venv/ -/.vscode/ /build/ /dist/ /docs/docs/changelog.md @@ -27,6 +26,7 @@ htmlcov test-home/ venv coverage.xml +/typings/ # Temporary dev files vetiver-testing/rsconnect_api_keys.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..9af2489c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-python.vscode-pylance", + "ms-python.black-formatter" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1055e1ec --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "editor.tabSize": 4, + "files.encoding": "utf8", + "files.eol": "\n", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.tabSize": 4, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.rulers": [120], + }, + "isort.args": ["--profile", "black"], + "files.exclude": { + "**/__pycache__": true, + "build/**": true, + "venv/**": true, + }, + "search.exclude": { + "**/__pycache__": true, + "build/**": true, + "venv/**": true, + }, +} diff --git a/Makefile b/Makefile index 21bd0b8f..495ac0fd 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,8 @@ lint-%: $(RUNNER) 'black --check --diff rsconnect/' $(RUNNER) 'flake8 rsconnect/' $(RUNNER) 'flake8 tests/' - $(RUNNER) 'mypy -p rsconnect' + # Temporarily use leading '-' so it will continue even if pyright finds issues. + -$(RUNNER) 'pyright rsconnect/' .PHONY: .lint-unsupported .lint-unsupported: @@ -70,7 +71,6 @@ lint-%: clean: $(RM) -r \ ./.coverage \ - ./.mypy_cache \ ./.pytest_cache \ ./build \ ./dist \ diff --git a/pyproject.toml b/pyproject.toml index f0756d86..f042234b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ requires-python = ">=3.8" dependencies = [ "typing-extensions>=4.10.0", - "six>=1.14.0", "pip>=10.0.0", "semver>=2.0.0,<3.0.0", "pyjwt>=2.4.0", @@ -29,16 +28,14 @@ test = [ "flake8", "httpretty", "ipykernel", - "mypy", "nbconvert", + "pyright", "pytest-cov", - "pytest-mypy", "pytest", "setuptools>=61", "setuptools_scm[toml]>=3.4", "twine", "types-Flask", - "types-six", ] [project.urls] @@ -53,7 +50,6 @@ universal = true [tool.black] line-length = 120 -target-version = ['py35'] [tool.coverage.run] omit = ["tests/*"] @@ -75,10 +71,6 @@ exclude = [".git", ".venv", ".venv2", ".venv3", "__pycache__", ".cache"] extend_ignore = ["E203", "E231", "E302"] per-file-ignores = ["tests/test_metadata.py: E501"] -[tool.mypy] -ignore_missing_imports = true -strict_optional = false - [tool.setuptools] packages = ["rsconnect"] @@ -92,4 +84,4 @@ markers = ["vetiver: tests for vetiver"] typeCheckingMode = "strict" reportPrivateUsage = "none" reportUnnecessaryIsInstance = "none" -reportUnnecessaryComparison = "none" \ No newline at end of file +reportUnnecessaryComparison = "none" diff --git a/requirements.txt b/requirements.txt index 5e9df7df..e317b35b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,10 +22,9 @@ flake8-pyproject flake8 httpretty ipykernel -mypy nbconvert +pyright pytest-cov -pytest-mypy pytest setuptools_scm[toml] twine diff --git a/rsconnect/actions.py b/rsconnect/actions.py index c3670194..d01c9975 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -7,16 +7,14 @@ import contextlib import json import logging -import os import re import shutil import subprocess import sys import traceback import typing -from os.path import abspath, basename, dirname, exists, isdir, join, relpath, splitext -from pathlib import Path -from typing import BinaryIO, Callable, Mapping, Optional, Sequence, TextIO, cast +from os.path import basename, exists +from typing import Optional, Sequence, cast from warnings import warn # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, @@ -27,34 +25,13 @@ else: from typing_extensions import NotRequired, TypedDict +from urllib.parse import urlparse + import click -from six.moves.urllib_parse import urlparse -from . import api, bundle -from .api import RSConnectExecutor, filter_out_server_info -from .bundle import ( - ManifestData, - _warn_if_environment_directory, - _warn_if_no_requirements_file, - _warn_on_ignored_manifest, - _warn_on_ignored_requirements, - create_python_environment, - default_title_from_manifest, - get_python_env_info, - make_api_bundle, - make_api_manifest, - make_html_bundle, - make_manifest_bundle, - make_notebook_html_bundle, - make_notebook_source_bundle, - make_quarto_source_bundle, - make_source_manifest, - manifest_add_buffer, - manifest_add_file, - read_manifest_app_mode, - read_manifest_file, -) -from .environment import Environment, EnvironmentException, MakeEnvironment +from . import api +from .bundle import make_quarto_source_bundle, read_manifest_file +from .environment import Environment, EnvironmentException from .exception import RSConnectException from .log import VERBOSE, logger from .models import AppMode, AppModes @@ -98,8 +75,7 @@ def failed(err: str): except EnvironmentException as exc: failed("Error: " + str(exc)) except Exception as exc: - if click.get_current_context("verbose"): - traceback.print_exc() + traceback.print_exc() failed("Internal error: " + str(exc)) finally: logger.set_in_feedback(False) @@ -118,48 +94,6 @@ def set_verbosity(verbose: int): logger.setLevel(logging.DEBUG) -def which_python(python: Optional[str], env: Mapping[str, str] = os.environ): - """Determine which python binary should be used. - - In priority order: - * --python specified on the command line - * 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 RSConnectException('The file, "%s", does not exist or is not executable.' % python) - return python - - return sys.executable - - -def inspect_environment( - python: str, - directory: str, - force_generate: bool = False, - check_output: Callable[..., bytes] = subprocess.check_output, -) -> 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. - """ - warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - flags: list[str] = [] - 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 _verify_server(connect_server: api.RSConnectServer): """ Test whether the server identified by the given full URL can be reached and is @@ -247,190 +181,6 @@ def test_api_key(connect_server: api.RSConnectServer) -> str: return api.verify_api_key(connect_server) -def gather_server_details(connect_server: api.RSConnectServer): - """ - Builds a dictionary containing the version of Posit Connect that is running - and the versions of Python installed there. - - :param connect_server: the Connect server information. - :return: a two-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. - """ - warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - - def _to_sort_key(text: str): - parts = [part.zfill(5) for part in text.split(".")] - return "".join(parts) - - server_settings = api.verify_server(connect_server) - python_settings = api.get_python_info(connect_server) - python_versions = sorted([item["version"] for item in python_settings["installations"]], key=_to_sort_key) - return { - "connect": server_settings["version"], - "python": { - "api_enabled": python_settings["api_enabled"] if "api_enabled" in python_settings else False, - "versions": python_versions, - }, - } - - -def _make_deployment_name(remote_server: api.TargetableServer, title: str, force_unique: bool) -> str: - """ - Produce a name for a deployment based on its title. It is assumed that the - title is already defaulted and validated as appropriate (meaning the title - 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 remote_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. - """ - 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, "_") - - # Now, make sure it's unique, if needed. - if force_unique: - name = api.find_unique_name(remote_server, name) - - return name - - -def _validate_title(title: str) -> None: - """ - 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. - """ - warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - if title: - if not (3 <= len(title) <= 1024): - raise RSConnectException("A title must be between 3-1024 characters long.") - - -def _default_title(file_name: str) -> str: - """ - 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 Posit - Connect. - - :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 - return basename(file_name).rsplit(".", 1)[0][:1024].rjust(3, "0") - - -def _default_title_from_manifest(the_manifest: ManifestData, manifest_file: str) -> str: - """ - 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") - 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 validate_file_is_notebook(file_name: str) -> None: - """ - 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. - """ - 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 RSConnectException("A Jupyter notebook (.ipynb) file is required here.") - - -def validate_extra_files(directory: str | Path, extra_files: Sequence[str] | None) -> list[str]: - """ - 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. - """ - warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - result: list[str] = [] - 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: str | Path) -> str: - """ - 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. - """ - 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 RSConnectException("A manifest.json file or a directory containing one is required here.") - return str(file_or_directory) - - -def get_default_entrypoint(directory: str | Path) -> str: - warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - return bundle.get_default_entrypoint(directory) - - -def validate_entry_point(entry_point: str | None, directory: str | Path) -> str: - """ - 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.. - """ - 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 RSConnectException('Entry point is not in "module:object" format.') - - return entry_point - - def which_quarto(quarto: Optional[str] = None) -> str: """ Identify a valid Quarto executable. When a Quarto location is not provided @@ -515,764 +265,13 @@ def validate_quarto_engines(inspect: QuartoInspectResult): return engines -def write_quarto_manifest_json( +def create_quarto_deployment_bundle( file_or_directory: str, - inspect: QuartoInspectResult, - app_mode: AppMode, - environment: Environment, extra_files: Sequence[str], excludes: Sequence[str], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> None: - """ - Creates and writes a manifest.json file for the given Quarto project. - - :param file_or_directory: The Quarto document or 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. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - """ - warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - bundle.write_quarto_manifest_json( - file_or_directory, - inspect, - app_mode, - environment, - extra_files, - excludes, - image, - env_management_py, - env_management_r, - ) - - -def write_manifest_json(manifest_path: str | Path, manifest: ManifestData) -> None: - """ - 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) - bundle.write_manifest_json(manifest_path, manifest) - - -def deploy_html( - connect_server: Optional[api.RSConnectServer] = None, - path: Optional[str] = None, - entrypoint: Optional[str] = None, - extra_files: Optional[Sequence[str]] = None, - excludes: Optional[Sequence[str]] = None, - title: Optional[str] = None, - env_vars: Optional[dict[str, str]] = None, - verbose: bool = False, - new: bool = False, - app_id: Optional[str] = None, - name: Optional[str] = None, - server: Optional[str] = None, - api_key: Optional[str] = None, - insecure: bool = False, - cacert: Optional[TextIO | BinaryIO] = None, -) -> 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.TargetableServer, - file_name: str, - extra_files: Sequence[str], - new: bool, - app_id: int, - title: str, - static: bool, - python: str, - force_generate: bool, - log_callback: Callable[[str], None], - hide_all_input: bool, - hide_tagged_input: bool, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> None: - """ - A function to deploy a Jupyter notebook to Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param file_name: the Jupyter notebook file to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param new: a flag indicating a new deployment, previous default = False. - :param app_id: the ID of an existing application to deploy new files for, previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :param static: a flag noting whether the notebook should be deployed as a static - HTML page or as a render-able document with sources. Previous default = False. - :param python: the optional name of a Python executable, previous default = None. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. Previous default = False. - :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. 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. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - kwargs = locals() - kwargs["extra_files"] = extra_files = validate_extra_files(dirname(file_name), extra_files) - app_mode = AppModes.JUPYTER_NOTEBOOK if not static else AppModes.STATIC - - if isinstance(connect_server, api.RSConnectServer): - kwargs.update( - dict( - url=connect_server.url, - api_key=connect_server.api_key, - insecure=connect_server.insecure, - ca_data=connect_server.ca_data, - cookies=connect_server.cookie_jar, - ) - ) - elif isinstance(connect_server, api.ShinyappsServer) or isinstance(connect_server, api.CloudServer): - kwargs.update( - dict( - url=connect_server.url, - account=connect_server.account_name, - token=connect_server.token, - secret=connect_server.secret, - ) - ) - else: - raise RSConnectException("Unable to infer Connect client.") - - base_dir = dirname(file_name) - _warn_on_ignored_manifest(base_dir) - _warn_if_no_requirements_file(base_dir) - _warn_if_environment_directory(base_dir) - python, environment = get_python_env_info(file_name, python, force_generate) - - if force_generate: - _warn_on_ignored_requirements(base_dir, environment.filename) - - 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_name, - python, - hide_all_input, - hide_tagged_input, - image=image, - env_management_py=env_management_py, - env_management_r=env_management_r, - ) - else: - ce.make_bundle( - make_notebook_source_bundle, - file_name, - environment, - extra_files, - hide_all_input, - hide_tagged_input, - image=image, - env_management_py=env_management_py, - env_management_r=env_management_r, - ) - ce.deploy_bundle().save_deployed_info().emit_task_log() - - -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. - """ - 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") - - -def deploy_app( - name: Optional[str] = None, - server: Optional[str] = None, - api_key: Optional[str] = None, - insecure: Optional[bool] = None, - cacert: Optional[TextIO | BinaryIO] = None, - ca_data: Optional[str] = None, - entry_point: Optional[str] = None, - excludes: Optional[Sequence[str]] = None, - new: bool = False, - app_id: Optional[str] = None, - title: Optional[str] = None, - python: Optional[str] = None, - force_generate: bool = False, - verbose: Optional[bool] = None, - directory: Optional[str] = None, - extra_files: Optional[Sequence[str]] = None, - env_vars: Optional[dict[str, str]] = None, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, - account: Optional[str] = None, - token: Optional[str] = None, - secret: Optional[str] = None, - app_mode: typing.Optional[AppMode] = None, - connect_server: Optional[api.TargetableServer] = None, - **kws: object -) -> None: - kwargs = locals() - kwargs["entry_point"] = entry_point = validate_entry_point(entry_point, directory) - kwargs["extra_files"] = extra_files = validate_extra_files(directory, extra_files) - - if isinstance(connect_server, api.RSConnectServer): - kwargs.update( - dict( - url=connect_server.url, - api_key=connect_server.api_key, - insecure=connect_server.insecure, - ca_data=connect_server.ca_data, - cookies=connect_server.cookie_jar, - ) - ) - elif isinstance(connect_server, api.ShinyappsServer) or isinstance(connect_server, api.CloudServer): - kwargs.update( - dict( - url=connect_server.url, - account=connect_server.account_name, - token=connect_server.token, - secret=connect_server.secret, - ) - ) - - environment = create_python_environment( - directory, - force_generate, - python, - ) - - ce = RSConnectExecutor(**kwargs) - ( - ce.validate_server() - .validate_app_mode(app_mode=app_mode) - .make_bundle( - make_api_bundle, - directory, - entry_point, - app_mode, - environment, - extra_files, - excludes, - image=image, - env_management_py=env_management_py, - env_management_r=env_management_r, - ) - .deploy_bundle() - .save_deployed_info() - .emit_task_log() - ) - - -def deploy_python_api( - connect_server: api.TargetableServer, - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - new: bool, - app_id: int, - title: str, - python: str, - force_generate: bool, - log_callback: Callable[[str], None], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.Tuple[str, typing.Union[list, None]]: - """ - A function to deploy a Python WSGi API module to Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param directory: the app directory to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. Previous default = False. - :param app_id: the ID of an existing application to deploy new files for. Previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :param python: the optional name of a Python executable. Previous default = None. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. Previous default = False. - :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. Previous default = None. - :param image: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - return deploy_app(app_mode=AppModes.PYTHON_API, **locals()) - - -def deploy_python_fastapi( - connect_server: api.TargetableServer, - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - new: bool, - app_id: int, - title: str, - python: str, - conda_mode: bool, - force_generate: bool, - log_callback: Callable[[str], None], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.Tuple[str, typing.Union[list, None]]: - """ - A function to deploy a Python ASGI API module to Posit Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param directory: the app directory to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. Previous default = False. - :param app_id: the ID of an existing application to deploy new files for. Previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :param python: the optional name of a Python executable. Previous default = None. - :param conda_mode: depricated parameter, included for compatibility. Ignored. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. Previous default = False. - :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. Previous default = None. - :param image: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - return deploy_app(app_mode=AppModes.PYTHON_FASTAPI, **locals()) - - -def deploy_python_shiny( - connect_server: api.TargetableServer, - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - new: bool = False, - app_id: Optional[int] = None, - title: Optional[str] = None, - python: Optional[str] = None, - force_generate: bool = False, - log_callback: Optional[Callable[[str], None]] = None, -): - """ - A function to deploy a Python Shiny module to Posit Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param directory: the app directory to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. - :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. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - return deploy_app(app_mode=AppModes.PYTHON_SHINY, **locals()) - - -def deploy_dash_app( - connect_server: api.TargetableServer, - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - new: bool, - app_id: int, - title: str, - python: str, - force_generate: bool, - log_callback: Callable[[str], None], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.Tuple[str, typing.Union[list, None]]: - """ - A function to deploy a Python Dash app module to Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param directory: the app directory to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. Previous default = False. - :param app_id: the ID of an existing application to deploy new files for. Previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :param python: the optional name of a Python executable. Previous default = None. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. Previous default = False. - :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. Previous default = None. - :param image: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - return deploy_app(app_mode=AppModes.DASH_APP, **locals()) - - -def deploy_streamlit_app( - connect_server: api.TargetableServer, - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - new: bool, - app_id: int, - title: str, - python: str, - force_generate: bool, - log_callback: Callable[[str], None], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.Tuple[str, typing.Union[list, None]]: - """ - A function to deploy a Python Streamlit app module to Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param directory: the app directory to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. Previous default = False. - :param app_id: the ID of an existing application to deploy new files for. Previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :param python: the optional name of a Python executable. Previous default = None. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. Previous default = False. - :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. Previous default = None. - :param image: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - return deploy_app(app_mode=AppModes.STREAMLIT_APP, **locals()) - - -def deploy_bokeh_app( - connect_server: api.TargetableServer, - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - new: bool, - app_id: int, - title: str, - python: str, - force_generate: bool, - log_callback: Callable[[str], None], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.Tuple[str, typing.Union[list, None]]: - """ - A function to deploy a Python Bokeh app module to Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param directory: the app directory to deploy. - :param extra_files: any extra files that should be included in the deploy. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. Previous default = False. - :param app_id: the ID of an existing application to deploy new files for. Previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :param python: the optional name of a Python executable. Previous default = None. - :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. Previous default = False. - :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. Previous default = None. - :param image: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - - return deploy_app(app_mode=AppModes.BOKEH_APP, **locals()) - - -def deploy_by_manifest( - connect_server: api.TargetableServer, - manifest_file_name: str, - new: bool, - app_id: int, - title: str, - log_callback: Callable[[str], None], -) -> None: - """ - A function to deploy a Jupyter notebook to Connect. Depending on the files involved - and network latency, this may take a bit of time. - - :param connect_server: the Connect server information. - :param manifest_file_name: the manifest file to deploy. - :param new: a flag to force this as a new deploy. Previous default = False. - :param app_id: the ID of an existing application to deploy new files for. Previous default = None. - :param title: an optional title for the deploy. If this is not provided, one will - be generated. Previous default = None. - :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. Previous default = None. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - kwargs = locals() - kwargs["manifest_file_name"] = manifest_file_name = validate_manifest_file(manifest_file_name) - app_mode = read_manifest_app_mode(manifest_file_name) - kwargs["title"] = title or default_title_from_manifest(manifest_file_name) - - if isinstance(connect_server, api.RSConnectServer): - kwargs.update( - dict( - url=connect_server.url, - api_key=connect_server.api_key, - insecure=connect_server.insecure, - ca_data=connect_server.ca_data, - cookies=connect_server.cookie_jar, - ) - ) - elif isinstance(connect_server, api.ShinyappsServer) or isinstance(connect_server, api.CloudServer): - kwargs.update( - dict( - url=connect_server.url, - account=connect_server.account_name, - token=connect_server.token, - secret=connect_server.secret, - ) - ) - else: - raise RSConnectException("Unable to infer Connect client.") - - ce = RSConnectExecutor(**kwargs) - ( - ce.validate_server() - .validate_app_mode(app_mode=app_mode) - .make_bundle( - make_manifest_bundle, - manifest_file_name, - ) - .deploy_bundle() - .save_deployed_info() - .emit_task_log() - ) - - -def create_notebook_deployment_bundle( - file_name: str, - extra_files: list[str], - app_mode: AppMode, - python: str, - environment: Environment, - extra_files_need_validating: bool, - hide_all_input: bool, - hide_tagged_input: bool, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.IO[bytes]: - """ - Create an in-memory bundle, ready to deploy. - - :param file_name: the Jupyter notebook being deployed. - :param extra_files: a sequence of any extra files to include in the bundle. - :param app_mode: the mode of the app being deployed. - :param python: information about the version of Python being used. - :param environment: environmental information. - :param extra_files_need_validating: a flag indicating whether the list of extra - files should be validated or not. Part of validating includes qualifying each - with the parent directory of the notebook file. If you provide False here, make - sure the names are properly qualified first. 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: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - - :return: the bundle. - """ - validate_file_is_notebook(file_name) - - if extra_files_need_validating: - extra_files = validate_extra_files(dirname(file_name), extra_files) - - if app_mode == AppModes.STATIC: - try: - return make_notebook_html_bundle( - file_name, - python, - hide_all_input, - hide_tagged_input, - image=image, - env_management_py=env_management_py, - env_management_r=env_management_r, - ) - except subprocess.CalledProcessError as exc: - # Jupyter rendering failures are often due to - # user code failing, vs. an internal failure of rsconnect-python. - raise RSConnectException(str(exc)) - else: - return make_notebook_source_bundle( - file_name, - environment, - extra_files, - hide_all_input, - hide_tagged_input, - image=image, - env_management_py=env_management_py, - env_management_r=env_management_r, - ) - - -def create_api_deployment_bundle( - directory: str, - extra_files: list[str], - excludes: list[str], - entry_point: str, - app_mode: AppMode, - environment: Environment, - extra_files_need_validating: bool, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = None, -) -> typing.IO[bytes]: - """ - Create an in-memory bundle, ready to deploy. - - :param directory: the directory that contains the code being deployed. - :param extra_files: a sequence of any extra files to include in the bundle. - :param excludes: a sequence of glob patterns that will exclude matched files. - :param entry_point: the module/executable object for the WSGi framework. - :param app_mode: the mode of the app being deployed. - :param environment: environmental information. - :param extra_files_need_validating: a flag indicating whether the list of extra - files should be validated or not. Part of validating includes qualifying each - with the specified directory. If you provide False here, make sure the names - are properly qualified first. Previous default = True. - :param image: the optional docker image to be specified for off-host execution. Default = None. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :return: the bundle. - """ - entry_point = validate_entry_point(entry_point, directory) - - if extra_files_need_validating: - extra_files = validate_extra_files(directory, extra_files) - - if app_mode is None: - app_mode = AppModes.PYTHON_API - - return make_api_bundle( - directory, entry_point, app_mode, environment, extra_files, excludes, image, env_management_py, env_management_r - ) - - -def create_quarto_deployment_bundle( - file_or_directory: str, - extra_files: list[str], - excludes: list[str], app_mode: AppMode, inspect: QuartoInspectResult, - environment: Environment, + environment: Optional[Environment], image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, @@ -1313,302 +312,6 @@ def create_quarto_deployment_bundle( ) -def deploy_bundle( - remote_server: api.TargetableServer, - app_id: int, - deployment_name: str, - title: str, - title_is_default: bool, - bundle: typing.IO[bytes], - env_vars: list[tuple[str, str]], -) -> dict[str, typing.Any]: - """ - Deploys the specified bundle. - - :param remote_server: the server information. - :param app_id: the ID of the app to deploy, if this is a redeploy. - :param deployment_name: the name for the deploy. - :param title: the title for the deploy. - :param title_is_default: a flag noting whether the title carries a defaulted value. - :param bundle: the bundle to deploy. - :param env_vars: list of (name, value) pairs for the app environment - :return: application information about the deploy. This includes the ID of the - task that may be queried for deployment progress. - """ - if isinstance(remote_server, api.RSConnectServer): - ce = RSConnectExecutor( - url=remote_server.url, - api_key=remote_server.api_key, - insecure=remote_server.insecure, - ca_data=remote_server.ca_data, - cookies=remote_server.cookie_jar, - ) - elif isinstance(remote_server, api.ShinyappsServer) or isinstance(remote_server, api.CloudServer): - ce = RSConnectExecutor( - url=remote_server.url, - account=remote_server.account_name, - token=remote_server.token, - secret=remote_server.secret, - ) - else: - raise RSConnectException("Unable to infer Connect client.") - ce.deploy_bundle( - app_id=app_id, - deployment_name=deployment_name, - title=title, - title_is_default=title_is_default, - bundle=bundle, - env_vars=env_vars, - ) - return ce.state["deployed_info"] - - -def spool_deployment_log( - connect_server: api.RSConnectServer, - app, - log_callback: Optional[Callable[[str], None]], -): - """ - Helper for spooling the deployment log for an app. - - :param connect_server: the Connect server information. - :param app: the app that was returned by the deploy_bundle function. - :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. - :return: the ultimate URL where the deployed app may be accessed and the sequence - of log lines. The log lines value will be None if a log callback was provided. - """ - return api.emit_task_log(connect_server, app["app_id"], app["task_id"], log_callback) - - -def create_notebook_manifest_and_environment_file( - entry_point_file: str, - environment: Environment, - app_mode: AppMode, - extra_files: list[str], - force: bool, - hide_all_input: bool, - hide_tagged_input: bool, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = 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. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. 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, - env_management_py, - env_management_r, - ) - 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: list[str], - hide_all_input: bool, - hide_tagged_input: bool, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = 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. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :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) - 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, env_management_py, env_management_r - ) - 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: list[str], - excludes: list[str], - force: bool, - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = 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. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. 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, - env_management_py, - env_management_r, - ) - or force - ): - write_environment_file(environment, directory) - - -def write_api_manifest_json( - directory: str, - entry_point: str, - environment: Environment, - app_mode: AppMode, - extra_files: list[str], - excludes: list[str], - image: Optional[str] = None, - env_management_py: Optional[bool] = None, - env_management_r: Optional[bool] = 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. - :param env_management_py: False prevents Connect from managing the Python environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :param env_management_r: False prevents Connect from managing the R environment for this bundle. - The server administrator is responsible for installing packages in the runtime environment. Default = None. - :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, env_management_py, env_management_r - ) - 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. - """ - 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) - - def describe_manifest(file_name: str) -> tuple[str | None, str | None]: """ Determine the entry point and/or primary file from the given manifest file. diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index c66f6bb2..1a3e7ce6 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -9,7 +9,7 @@ import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta -from typing import Iterator, Literal, Optional, Sequence +from typing import Iterator, Literal, Optional, Sequence, cast import semver @@ -17,16 +17,28 @@ from .exception import RSConnectException from .log import logger from .metadata import ContentBuildStore, ContentItemWithBuildState -from .models import BuildStatus, ContentItem, ContentGuidWithBundle, VersionSearchFilter +from .models import ( + BuildStatus, + ContentGuidWithBundle, + ContentItemV1, + VersionSearchFilter, +) -_content_build_store: ContentBuildStore = None +_content_build_store: ContentBuildStore | None = None -def init_content_build_store(connect_server: RSConnectServer): +def content_build_store() -> ContentBuildStore: + if _content_build_store is None: + raise RSConnectException("_content_build_store has not been initialized.") + return _content_build_store + + +def ensure_content_build_store(connect_server: RSConnectServer) -> ContentBuildStore: global _content_build_store if not _content_build_store: logger.info("Initializing ContentBuildStore for %s" % connect_server.url) _content_build_store = ContentBuildStore(connect_server) + return _content_build_store def build_add_content( @@ -36,8 +48,8 @@ def build_add_content( """ :param content_guids_with_bundle: Union[tuple[models.ContentGuidWithBundle], list[models.ContentGuidWithBundle]] """ - init_content_build_store(connect_server) - if _content_build_store.get_build_running(): + build_store = ensure_content_build_store(connect_server) + if build_store.get_build_running(): raise RSConnectException( "There is already a build running on this server, " + "please wait for it to finish before adding new content." @@ -53,10 +65,10 @@ def build_add_content( # always filter just in case it's a bulk add guids_to_add = list(map(lambda x: x.guid, content_guids_with_bundle)) - content_to_add = list(filter(lambda x: x["guid"] in guids_to_add, all_content)) + content_to_add_list = list(filter(lambda x: x["guid"] in guids_to_add, all_content)) # merge the provided bundle_ids if they were specified - content_to_add = {c["guid"]: c for c in content_to_add} + content_to_add = {c["guid"]: c for c in content_to_add_list} for c in content_guids_with_bundle: current_bundle_id = content_to_add[c.guid]["bundle_id"] content_to_add[c.guid]["bundle_id"] = c.bundle_id if c.bundle_id else current_bundle_id @@ -67,8 +79,8 @@ def build_add_content( "This content has never been published to this server. " + "You must specify a bundle_id for the build. Content GUID: %s" % content["guid"] ) - _content_build_store.add_content_item(content) - _content_build_store.set_content_item_build_status(content["guid"], BuildStatus.NEEDS_BUILD) + build_store.add_content_item(content) + build_store.set_content_item_build_status(content["guid"], BuildStatus.NEEDS_BUILD) def build_remove_content( @@ -80,30 +92,29 @@ def build_remove_content( """ :return: A list of guids of the content items that were removed """ - init_content_build_store(connect_server) - if _content_build_store.get_build_running(): + build_store = ensure_content_build_store(connect_server) + if build_store.get_build_running(): raise RSConnectException( "There is a build running on this server, " + "please wait for it to finish before removing content." ) guids: list[str] = [guid] if all: - guids = [c["guid"] for c in _content_build_store.get_content_items()] + guids = [c["guid"] for c in build_store.get_content_items()] for guid in guids: - _content_build_store.remove_content_item(guid, purge) + build_store.remove_content_item(guid, purge) return guids def build_list_content(connect_server: RSConnectServer, guid: str, status: Optional[str]): - init_content_build_store(connect_server) + build_store = ensure_content_build_store(connect_server) if guid: - return [_content_build_store.get_content_item(g) for g in guid] + return [build_store.get_content_item(g) for g in guid] else: - return _content_build_store.get_content_items(status=status) + return build_store.get_content_items(status=status) def build_history(connect_server: RSConnectServer, guid: str): - init_content_build_store(connect_server) - return _content_build_store.get_build_history(guid) + return ensure_content_build_store(connect_server).get_build_history(guid) def build_start( @@ -117,14 +128,14 @@ def build_start( poll_wait: int = 2, debug: bool = False, ): - init_content_build_store(connect_server) - if _content_build_store.get_build_running(): + build_store = ensure_content_build_store(connect_server) + if build_store.get_build_running(): raise RSConnectException("There is already a build running on this server: %s" % connect_server.url) # if we are re-building any already "tracked" content items, then re-add them to be safe if all: logger.info("Adding all content to build...") - all_content = _content_build_store.get_content_items() + all_content = build_store.get_content_items() all_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), all_content)) build_add_content(connect_server, all_content) else: @@ -137,23 +148,23 @@ def build_start( aborted_content = [] if aborted: logger.info("Adding ABORTED content to build...") - aborted_content = _content_build_store.get_content_items(status=BuildStatus.ABORTED) + aborted_content = build_store.get_content_items(status=BuildStatus.ABORTED) aborted_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), aborted_content)) error_content = [] if error: logger.info("Adding ERROR content to build...") - error_content = _content_build_store.get_content_items(status=BuildStatus.ERROR) + error_content = build_store.get_content_items(status=BuildStatus.ERROR) error_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), error_content)) running_content = [] if running: logger.info("Adding RUNNING content to build...") - running_content = _content_build_store.get_content_items(status=BuildStatus.RUNNING) + running_content = build_store.get_content_items(status=BuildStatus.RUNNING) running_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), running_content)) if len(aborted_content + error_content + running_content) > 0: build_add_content(connect_server, aborted_content + error_content + running_content) - content_items = _content_build_store.get_content_items(status=BuildStatus.NEEDS_BUILD) + content_items = build_store.get_content_items(status=BuildStatus.NEEDS_BUILD) if len(content_items) == 0: logger.info("Nothing to build...") logger.info("\tUse `rsconnect content build add` to mark content for build.") @@ -163,7 +174,7 @@ def build_start( content_executor = None try: logger.info("Starting content build (%s)..." % connect_server.url) - _content_build_store.set_build_running(True) + build_store.set_build_running(True) # spawn a single thread to monitor progress and report feedback to the user build_monitor = ThreadPoolExecutor(max_workers=1) @@ -188,19 +199,20 @@ def build_start( future.result() except Exception as exc: # catch any unexpected exceptions from the future thread - _content_build_store.set_content_item_build_status(guid_with_bundle.guid, BuildStatus.ERROR) + build_store.set_content_item_build_status(guid_with_bundle.guid, BuildStatus.ERROR) logger.error("%s generated an exception: %s" % (guid_with_bundle, exc)) if debug: logger.error(traceback.format_exc()) # all content builds are finished, mark the build as complete - _content_build_store.set_build_running(False) + build_store.set_build_running(False) # wait for the build_monitor thread to resolve its future try: success = summary_future.result() except Exception as exc: logger.error(exc) + success = False logger.info("Content build complete.") if not success: @@ -224,7 +236,7 @@ def build_start( # make sure that we always mark the build as complete but note # there's no guarantee that the content_executor or build_monitor # were allowed to shut down gracefully, they may have been interrupted. - _content_build_store.set_build_running(False) + build_store.set_build_running(False) if content_executor: content_executor.shutdown(wait=False) if build_monitor: @@ -235,9 +247,11 @@ def _monitor_build(connect_server: RSConnectServer, content_items: list[ContentI """ :return bool: True if the build completed without errors, False otherwise """ - init_content_build_store(connect_server) + build_store = ensure_content_build_store(connect_server) + complete = [] + error = [] start = datetime.now() - while _content_build_store.get_build_running() and not _content_build_store.aborted(): + while build_store.get_build_running() and not build_store.aborted(): time.sleep(5) complete = [item for item in content_items if item["rsconnect_build_status"] == BuildStatus.COMPLETE] error = [item for item in content_items if item["rsconnect_build_status"] == BuildStatus.ERROR] @@ -248,14 +262,14 @@ def _monitor_build(connect_server: RSConnectServer, content_items: list[ContentI % (len(running), len(pending), len(complete), len(error)) ) - if _content_build_store.aborted(): + if build_store.aborted(): logger.warn("Build interrupted!") aborted_builds = [i["guid"] for i in content_items if i["rsconnect_build_status"] == BuildStatus.RUNNING] if len(aborted_builds) > 0: logger.warn("Marking %d builds as ABORTED..." % len(aborted_builds)) for guid in aborted_builds: logger.warn("Build aborted: %s" % guid) - _content_build_store.set_content_item_build_status(guid, BuildStatus.ABORTED) + build_store.set_content_item_build_status(guid, BuildStatus.ABORTED) return False # TODO: print summary as structured json object instead of a string when @@ -274,20 +288,20 @@ def _monitor_build(connect_server: RSConnectServer, content_items: list[ContentI return True -def _build_content_item(connect_server: RSConnectServer, content: ContentItemWithBuildState, poll_wait: str): - init_content_build_store(connect_server) +def _build_content_item(connect_server: RSConnectServer, content: ContentItemWithBuildState, poll_wait: int): + build_store = ensure_content_build_store(connect_server) with RSConnectClient(connect_server) as client: # Pending futures will still try to execute when ThreadPoolExecutor.shutdown() is called # so just exit immediately if the current build has been aborted. # ThreadPoolExecutor.shutdown(cancel_futures=) isnt available until py3.9 - if _content_build_store.aborted(): + if build_store.aborted(): return guid = content["guid"] logger.info("Starting build: %s" % guid) - _content_build_store.update_content_item_last_build_time(guid) - _content_build_store.set_content_item_build_status(guid, BuildStatus.RUNNING) - _content_build_store.ensure_logs_dir(guid) + build_store.update_content_item_last_build_time(guid) + build_store.set_content_item_build_status(guid, BuildStatus.RUNNING) + build_store.ensure_logs_dir(guid) try: task_result = client.content_build(guid, content.get("bundle_id")) task_id = task_result["task_id"] @@ -295,31 +309,37 @@ def _build_content_item(connect_server: RSConnectServer, content: ContentItemWit # if we can't submit the build to connect then there is no log file # created on disk. When this happens we need to set the last_build_log # to None so its clear that we submitted a build but it never started - _content_build_store.update_content_item_last_build_log(guid, None) + build_store.update_content_item_last_build_log(guid, None) raise - log_file = _content_build_store.get_build_log(guid, task_id) + log_file = build_store.get_build_log(guid, task_id) + if log_file is None: + raise RSConnectException("Log file not found for content: %s" % guid) with open(log_file, "w") as log: + + def write_log(line: str): + log.write("%s\n" % line) + _, _, task_status = emit_task_log( connect_server, guid, task_id, - log_callback=lambda line: log.write("%s\n" % line), - abort_func=_content_build_store.aborted, + log_callback=write_log, + abort_func=build_store.aborted, poll_wait=poll_wait, raise_on_error=False, ) - _content_build_store.update_content_item_last_build_log(guid, log_file) + build_store.update_content_item_last_build_log(guid, log_file) - if _content_build_store.aborted(): + if build_store.aborted(): return - _content_build_store.set_content_item_last_build_task_result(guid, task_status) + build_store.set_content_item_last_build_task_result(guid, task_status) if task_status["code"] != 0: logger.error("Build failed: %s" % guid) - _content_build_store.set_content_item_build_status(guid, BuildStatus.ERROR) + build_store.set_content_item_build_status(guid, BuildStatus.ERROR) else: logger.info("Build succeeded: %s" % guid) - _content_build_store.set_content_item_build_status(guid, BuildStatus.COMPLETE) + build_store.set_content_item_build_status(guid, BuildStatus.COMPLETE) def emit_build_log( @@ -328,8 +348,8 @@ def emit_build_log( format: str, task_id: Optional[str] = None, ): - init_content_build_store(connect_server) - log_file = _content_build_store.get_build_log(guid, task_id) + build_store = ensure_content_build_store(connect_server) + log_file = build_store.get_build_log(guid, task_id) if log_file: with open(log_file, "r") as f: for line in f.readlines(): @@ -387,40 +407,44 @@ def search_content( result = _apply_content_filters( result, published, unpublished, content_type, r_version, py_version, title_contains ) - result = _order_content_results(result, order_by) - return list(result) + return _order_content_results(result, order_by) def _apply_content_filters( - content_list: list[ContentItem], + content_list: list[ContentItemV1], published: bool, unpublished: bool, content_type: Sequence[str], r_version: Optional[VersionSearchFilter], py_version: Optional[VersionSearchFilter], title_search: Optional[str], -) -> Iterator[ContentItem]: - def content_is_published(item: ContentItem): +) -> Iterator[ContentItemV1]: + def content_is_published(item: ContentItemV1): return item.get("bundle_id") is not None - def content_is_unpublished(item: ContentItem): + def content_is_unpublished(item: ContentItemV1): return item.get("bundle_id") is None - def title_contains(item: ContentItem): + def title_contains(item: ContentItemV1): + if title_search is None: + return True return item["title"] is not None and title_search in item["title"] - def apply_content_type_filter(item: ContentItem): + def apply_content_type_filter(item: ContentItemV1): return item["app_mode"] is not None and item["app_mode"] in content_type - def apply_version_filter(items: Iterator[ContentItem], version_filter: VersionSearchFilter): - def do_filter(item: ContentItem) -> bool: + def apply_version_filter(items: Iterator[ContentItemV1], version_filter: VersionSearchFilter): + def do_filter(item: ContentItemV1) -> bool: vers = None - if version_filter.name not in item: + if version_filter.name in item: return False else: - vers = item[version_filter.name] + vers = cast(str, item[version_filter.name]) try: - compare = semver.compare(vers, version_filter.vers) + compare = cast( + Literal[-1, 0, 1], + semver.compare(vers, version_filter.vers), # pyright: ignore[reportUnknownMemberType] + ) except (ValueError, TypeError): return False @@ -455,13 +479,13 @@ def do_filter(item: ContentItem) -> bool: def _order_content_results( - content_list: Iterator[ContentItem], + content_list: Iterator[ContentItemV1], order_by: Optional[Literal["created", "last_deployed"]], -) -> Iterator[ContentItem] | list[ContentItem]: - result = iter(content_list) +) -> list[ContentItemV1]: + result = content_list if order_by == "last_deployed": pass # do nothing, content is ordered by last_deployed by default elif order_by == "created": result = sorted(result, key=lambda c: c["created_time"], reverse=True) - return result + return list(result) diff --git a/rsconnect/api.py b/rsconnect/api.py index f5d555a2..495780e9 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -7,7 +7,6 @@ import base64 import binascii import datetime -import gc import hashlib import hmac import os @@ -17,7 +16,19 @@ import typing import webbrowser from os.path import abspath, dirname -from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Optional, TypeVar, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Callable, + List, + Literal, + Optional, + TypeVar, + Union, + cast, + overload, +) from urllib import parse from urllib.parse import urlparse from warnings import warn @@ -42,16 +53,31 @@ from .bundle import _default_title, fake_module_file_from_directory from .certificates import read_certificate_file from .exception import DeploymentFailedException, RSConnectException -from .http_support import CookieJar, HTTPResponse, HTTPServer, append_to_path +from .http_support import CookieJar, HTTPResponse, HTTPServer, JsonData, append_to_path from .log import cls_logged, connect_logger, console_logger, logger from .metadata import AppStore, ServerStore -from .models import AppMode, AppModes, ContentItem, TaskStatus +from .models import ( + AppMode, + AppModes, + AppSearchResults, + BootstrapOutputDTO, + BuildOutputDTO, + ConfigureResult, + ContentItemV0, + ContentItemV1, + DeleteInputDTO, + DeleteOutputDTO, + ListEntryOutputDTO, + PyInfo, + ServerSettings, + TaskStatusV0, + UserRecord, +) from .timeouts import get_task_timeout, get_task_timeout_help_message if TYPE_CHECKING: import logging - from .http_support import JsonData T = TypeVar("T") P = ParamSpec("P") @@ -62,7 +88,43 @@ def __init__(self, url: str, remote_name: str): self.url = url self.remote_name = remote_name - def handle_bad_response(self, response: HTTPResponse | object) -> None: + @overload + def handle_bad_response(self, response: HTTPResponse, is_httpresponse: Literal[True]) -> HTTPResponse: ... + + @overload + def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: Literal[False] = False) -> T: ... + + def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool = False) -> T | HTTPResponse: + """ + Handle a bad response from the server. + + For most requests, we expect the response to already have been converted to + JSON. This is when `is_httpresponse` has the default value False. In these + cases: + + * By the time a response object reaches this function, it should have been + converted to the JSON data contained in the original HTTPResponse object. + If the response is still an HTTPResponse object at this point, that means + that something went wrong, and it raises an exception, even if the status + was 2xx. + + However, in some cases, we expect that the input object is an HTTPResponse + that did not contain JSON. This is when `is_httpresponse` is set to True. In + these cases: + + * The response object should still be an HTTPResponse object. If it has a + 2xx status, then it will be returned. If it has any other status, then + an exceptio nwill be raised. + + :param response: The response object to check. + :param is_httpresponse: If False (the default), expect that the input object is + a JsonData object. If True, expect that the input object is a HTTPResponse + object. + :return: The response object, if it is not an HTTPResponse object. If it was + an HTTPResponse object, this function will raise an exception and + not return. + """ + if isinstance(response, HTTPResponse): if response.exception: raise RSConnectException( @@ -72,7 +134,12 @@ def handle_bad_response(self, response: HTTPResponse | object) -> None: # search page so trap that since we know we're expecting JSON from Connect. This # also catches all error conditions which we will report as "not running Connect". else: - if response.json_data and "error" in response.json_data and response.json_data["error"] is not None: + if ( + response.json_data + and isinstance(response.json_data, dict) + and "error" in response.json_data + and response.json_data["error"] is not None + ): error = "%s reported an error (calling %s): %s" % ( self.remote_name, response.full_uri, @@ -89,6 +156,21 @@ def handle_bad_response(self, response: HTTPResponse | object) -> None: response.reason, ) ) + if not is_httpresponse: + # If we got here, it was a 2xx response that contained JSON and did not + # have an error field, but for some reason the object returned from the + # prior function call was not converted from a HTTPResponse to JSON. This + # should never happen, so raise an exception. + raise RSConnectException( + "Received an unexpected response from %s (calling %s): %s %s" + % ( + self.remote_name, + response.full_uri, + response.status, + response.reason, + ) + ) + return response class PositServer(AbstractRemoteServer): @@ -160,14 +242,7 @@ def __init__(self, url: str): super().__init__(url, "S3") -class ApiAppGet(TypedDict): - id: int - guid: str - url: str - title: str | None - - -class DeployResult(TypedDict): +class RSConnectClientDeployResult(TypedDict): task_id: NotRequired[str] app_id: str app_guid: str @@ -200,39 +275,65 @@ def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse: else response ) - def me(self): - return self.get("me") + def me(self) -> UserRecord: + response = cast(Union[UserRecord, HTTPResponse], self.get("me")) + response = self._server.handle_bad_response(response) + return response - def bootstrap(self): - return self.post("v1/experimental/bootstrap") + def bootstrap(self) -> BootstrapOutputDTO | HTTPResponse: + response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/experimental/bootstrap")) + # TODO: The place where bootstrap() is called expects a JSON object if the response is successfule, and a + # HTTPResponse if it is not; then it handles the error. This is different from the other methods, and probably + # should be changed in the future. For this to work, we will _not_ call .handle_bad_response() here at present. + # response = self._server.handle_bad_response(response) + return response - def server_settings(self): - return self.get("server_settings") + def server_settings(self) -> ServerSettings: + response = cast(Union[ServerSettings, HTTPResponse], self.get("server_settings")) + response = self._server.handle_bad_response(response) + return response - def python_settings(self): - return self.get("v1/server_settings/python") + def python_settings(self) -> PyInfo: + response = cast(Union[PyInfo, HTTPResponse], self.get("v1/server_settings/python")) + response = self._server.handle_bad_response(response) + return response - def app_search(self, filters): - return self.get("applications", query_params=filters) + def app_search(self, filters: Optional[dict[str, JsonData]]) -> AppSearchResults: + response = cast(Union[AppSearchResults, HTTPResponse], self.get("applications", query_params=filters)) + response = self._server.handle_bad_response(response) + return response - def app_create(self, name: str) -> ApiAppGet: - return self.post("applications", body={"name": name}) + def app_create(self, name: str) -> ContentItemV0: + response = cast(Union[ContentItemV0, HTTPResponse], self.post("applications", body={"name": name})) + response = self._server.handle_bad_response(response) + return response - def app_get(self, app_id: str) -> ApiAppGet: - return self.get("applications/%s" % app_id) + def app_get(self, app_id: str) -> ContentItemV0: + response = cast(Union[ContentItemV0, HTTPResponse], self.get("applications/%s" % app_id)) + response = self._server.handle_bad_response(response) + return response - def app_upload(self, app_id: str, tarball: BinaryIO): - return self.post("applications/%s/upload" % app_id, body=tarball) + def app_upload(self, app_id: str, tarball: typing.IO[bytes]) -> ContentItemV0: + response = cast(Union[ContentItemV0, HTTPResponse], self.post("applications/%s/upload" % app_id, body=tarball)) + response = self._server.handle_bad_response(response) + return response - def app_update(self, app_id: str, updates: dict[str, str | None]): - return self.post("applications/%s" % app_id, body=updates) + def app_update(self, app_id: str, updates: dict[str, str | None]) -> ContentItemV0: + response = cast(Union[ContentItemV0, HTTPResponse], self.post("applications/%s" % app_id, body=updates)) + response = self._server.handle_bad_response(response) + return response def app_add_environment_vars(self, app_guid: str, env_vars: list[tuple[str, str]]): env_body = [dict(name=kv[0], value=kv[1]) for kv in env_vars] return self.patch("v1/content/%s/environment" % app_guid, body=env_body) - def app_deploy(self, app_id: str, bundle_id: Optional[str] = None) -> TaskStatus | HTTPResponse: - return self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id}) + def app_deploy(self, app_id: str, bundle_id: Optional[int] = None) -> TaskStatusV0: + response = cast( + Union[TaskStatusV0, HTTPResponse], + self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id}), + ) + response = self._server.handle_bad_response(response) + return response def app_publish(self, app_id: str, access: str): return self.post( @@ -240,8 +341,10 @@ def app_publish(self, app_id: str, access: str): body={"access_type": access, "id": app_id, "needs_config": False}, ) - def app_config(self, app_id: str): - return self.get("applications/%s/config" % app_id) + def app_config(self, app_id: str) -> ConfigureResult: + response = cast(Union[ConfigureResult, HTTPResponse], self.get("applications/%s/config" % app_id)) + response = self._server.handle_bad_response(response) + return response def is_app_failed_response(self, response: HTTPResponse | JsonData) -> bool: return isinstance(response, HTTPResponse) and response.status >= 500 @@ -259,42 +362,48 @@ def app_access(self, app_guid: str) -> None: + "Visit it in Connect to view the logs." ) - def bundle_download(self, content_guid: str, bundle_id: str): - response = self.get("v1/content/%s/bundles/%s/download" % (content_guid, bundle_id), decode_response=False) - self._server.handle_bad_response(response) + def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse: + response = cast( + HTTPResponse, + self.get("v1/content/%s/bundles/%s/download" % (content_guid, bundle_id), decode_response=False), + ) + response = self._server.handle_bad_response(response, is_httpresponse=True) return response - def content_search(self) -> list[ContentItem]: - response = self.get("v1/content") - self._server.handle_bad_response(response) - return cast(list[ContentItem], response) + def content_search(self) -> list[ContentItemV1]: + response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content")) + response = self._server.handle_bad_response(response) + return response - def content_get(self, content_guid: str) -> ContentItem: - response = self.get("v1/content/%s" % content_guid) - self._server.handle_bad_response(response) - return cast(ContentItem, response) + def content_get(self, content_guid: str) -> ContentItemV1: + response = cast(Union[ContentItemV1, HTTPResponse], self.get("v1/content/%s" % content_guid)) + response = self._server.handle_bad_response(response) + return response - def content_build(self, content_guid: str, bundle_id: Optional[str] = None): - response = self.post("v1/content/%s/build" % content_guid, body={"bundle_id": bundle_id}) - self._server.handle_bad_response(response) + def content_build(self, content_guid: str, bundle_id: Optional[str] = None) -> BuildOutputDTO: + response = cast( + Union[BuildOutputDTO, HTTPResponse], + self.post("v1/content/%s/build" % content_guid, body={"bundle_id": bundle_id}), + ) + response = self._server.handle_bad_response(response) return response - def system_caches_runtime_list(self): - response = self.get("v1/system/caches/runtime") - self._server.handle_bad_response(response) + def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]: + response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime")) + response = self._server.handle_bad_response(response) return response - def system_caches_runtime_delete(self, target: dict[str, str]): - response = self.delete("v1/system/caches/runtime", body=target) - self._server.handle_bad_response(response) + def system_caches_runtime_delete(self, target: DeleteInputDTO) -> DeleteOutputDTO: + response = cast(Union[DeleteOutputDTO, HTTPResponse], self.delete("v1/system/caches/runtime", body=target)) + response = self._server.handle_bad_response(response) return response - def task_get(self, task_id: str, first_status: Optional[int] = None) -> TaskStatus: + def task_get(self, task_id: str, first_status: Optional[int] = None) -> TaskStatusV0: params = None if first_status is not None: params = {"first_status": first_status} - response = self.get("tasks/%s" % task_id, query_params=params) - self._server.handle_bad_response(response) + response = cast(Union[TaskStatusV0, HTTPResponse], self.get("tasks/%s" % task_id, query_params=params)) + response = self._server.handle_bad_response(response) return response def deploy( @@ -303,44 +412,40 @@ def deploy( app_name: Optional[str], app_title: Optional[str], title_is_default: bool, - tarball: BinaryIO, + tarball: IO[bytes], env_vars: Optional[dict[str, str]] = None, - ) -> DeployResult: + ) -> RSConnectClientDeployResult: if app_id is None: + if app_name is None: + raise RSConnectException("An app ID or name is required to deploy an app.") # create an app if id is not provided app = self.app_create(app_name) - self._server.handle_bad_response(app) - app_id = app["id"] + app_id = str(app["id"]) # Force the title to update. title_is_default = False else: # assume app exists. if it was deleted then Connect will # raise an error - app = self.app_get(app_id) try: - self._server.handle_bad_response(app) + app = self.app_get(app_id) except RSConnectException as e: raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e app_guid = app["guid"] if env_vars: result = self.app_add_environment_vars(app_guid, list(env_vars.items())) - self._server.handle_bad_response(result) + result = self._server.handle_bad_response(result) if app["title"] != app_title and not title_is_default: - self._server.handle_bad_response(self.app_update(app_id, {"title": app_title})) + result = self.app_update(app_id, {"title": app_title}) + result = self._server.handle_bad_response(result) app["title"] = app_title app_bundle = self.app_upload(app_id, tarball) - self._server.handle_bad_response(app_bundle) - task = self.app_deploy(app_id, app_bundle["id"]) - self._server.handle_bad_response(task) - task = cast(TaskStatus, task) - return { "task_id": task["id"], "app_id": app_id, @@ -349,19 +454,16 @@ def deploy( "title": app["title"], } - def download_bundle(self, content_guid: str, bundle_id: str): + def download_bundle(self, content_guid: str, bundle_id: str) -> HTTPResponse: results = self.bundle_download(content_guid, bundle_id) - self._server.handle_bad_response(results) return results - def search_content(self) -> list[ContentItem]: + def search_content(self) -> list[ContentItemV1]: results = self.content_search() - self._server.handle_bad_response(results) return results - def get_content(self, content_guid: str) -> ContentItem: + def get_content(self, content_guid: str) -> ContentItemV1: results = self.content_get(content_guid) - self._server.handle_bad_response(results) return results def wait_for_task( @@ -372,7 +474,7 @@ def wait_for_task( timeout: int = get_task_timeout(), poll_wait: float = 0.5, raise_on_error: bool = True, - ): + ) -> tuple[list[str] | None, TaskStatusV0]: if log_callback is None: log_lines: list[str] | None = [] log_callback = log_lines.append @@ -382,7 +484,7 @@ def wait_for_task( last_status: int | None = None start_time = time.time() sleep_duration = 0.5 - time_slept = 0 + time_slept = 0.0 while True: if (time.time() - start_time) > timeout: raise RSConnectException(get_task_timeout_help_message(timeout)) @@ -398,7 +500,6 @@ def wait_for_task( else: time_slept = 0 task_status = self.task_get(task_id, last_status) - self._server.handle_bad_response(task_status) last_status = self.output_task_log(task_status, last_status, log_callback) if task_status["finished"]: result = task_status.get("result") @@ -423,7 +524,7 @@ def wait_for_task( @staticmethod def output_task_log( - task_status: TaskStatus, + task_status: TaskStatusV0, last_status: int | None, log_callback: Callable[[str], None], ): @@ -473,16 +574,47 @@ def __init__( secret: Optional[str] = None, timeout: int = 30, logger: Optional[logging.Logger] = console_logger, - **kwargs: typing.Any, + *, + path: Optional[str] = None, + server: Optional[str] = None, + exclude: Optional[tuple[str, ...]] = None, + new: Optional[bool] = None, + app_id: Optional[str] = None, + title: Optional[str] = None, + visibility: Optional[str] = None, + disable_env_management: Optional[bool] = None, + env_vars: Optional[dict[str, str]] = None, ) -> None: - self.reset() - self._d = kwargs - self.logger = logger + self.remote_server: TargetableServer + self.client: RSConnectClient | PositClient + + self.path = path or os.getcwd() + self.server = server + self.exclude = exclude + self.new = new + self.app_id = app_id + self.title = title or _default_title(self.path) + self.visibility = visibility + self.disable_env_management = disable_env_management + self.env_vars = env_vars + self.app_mode: AppMode | None = None + self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path)) + self.app_store_version: int | None = None + self.api_key_is_required: bool | None = None + self.title_is_default: bool = not title + self.deployment_name: str | None = None + + self.bundle: IO[bytes] | None = None + self.deployed_info: RSConnectClientDeployResult | None = None + self.result: DeleteOutputDTO | None = None + self.task_status: TaskStatusV0 | None = None + + self.logger: logging.Logger | None = logger self.ctx = ctx self.setup_remote_server( ctx=ctx, name=name, - url=url or kwargs.get("server"), + url=url or server, api_key=api_key, insecure=insecure, cacert=cacert, @@ -494,28 +626,50 @@ def __init__( self.setup_client(cookies) @classmethod - def fromConnectServer(cls, connect_server: RSConnectServer, **kwargs: Any): + def fromConnectServer( + cls, + connect_server: RSConnectServer, + ctx: Optional[click.Context] = None, + cookies: Optional[CookieJar] = None, + account: Optional[str] = None, + token: Optional[str] = None, + secret: Optional[str] = None, + timeout: int = 30, + logger: Optional[logging.Logger] = console_logger, + *, + path: Optional[str] = None, + server: Optional[str] = None, + exclude: Optional[tuple[str, ...]] = None, + new: Optional[bool] = None, + app_id: Optional[str] = None, + title: Optional[str] = None, + visibility: Optional[str] = None, + disable_env_management: Optional[bool] = None, + env_vars: Optional[dict[str, str]] = None, + ): return cls( + ctx=ctx, url=connect_server.url, api_key=connect_server.api_key, insecure=connect_server.insecure, ca_data=connect_server.ca_data, - **kwargs, + cookies=cookies, + account=account, + token=token, + secret=secret, + timeout=timeout, + logger=logger, + path=path, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + visibility=visibility, + disable_env_management=disable_env_management, + env_vars=env_vars, ) - def reset(self): - self._d: dict[str, Any] | None = None - self.remote_server: TargetableServer | None = None - self.client: RSConnectClient | PositClient | None = None - self.logger: logging.Logger | None = None - gc.collect() - return self - - def drop_context(self): - self._d = None - gc.collect() - return self - def output_overlap_header(self, previous: bool) -> bool: if self.logger and not previous: self.logger.warning( @@ -537,7 +691,8 @@ def output_overlap_header(self, previous: bool) -> bool: def output_overlap_details(self, cli_param: str, previous: bool): new_previous = self.output_overlap_header(previous) sourceName = validation.get_parameter_source_name_from_ctx(cli_param, self.ctx) - self.logger.warning(f">> stored {cli_param} value overrides the {cli_param} value from {sourceName}\n") + if self.logger is not None: + self.logger.warning(f">> stored {cli_param} value overrides the {cli_param} value from {sourceName}\n") return new_previous def setup_remote_server( @@ -588,6 +743,7 @@ def setup_remote_server( if header_output: self.logger.warning("\n") + # TODO: Is this logic backward? Seems like the provided value should override the stored value. api_key = server_data.api_key or api_key insecure = server_data.insecure or insecure ca_data = server_data.ca_data or ca_data @@ -607,7 +763,7 @@ def setup_remote_server( else: raise RSConnectException("Unable to infer Connect server type and setup server.") - def setup_client(self, cookies: Optional[CookieJar] = None, **kwargs: object): + def setup_client(self, cookies: Optional[CookieJar] = None): if isinstance(self.remote_server, RSConnectServer): self.client = RSConnectClient(self.remote_server, cookies) elif isinstance(self.remote_server, PositServer): @@ -615,86 +771,38 @@ def setup_client(self, cookies: Optional[CookieJar] = None, **kwargs: object): else: raise RSConnectException("Unable to infer Connect client.") - @property - def state(self): - return self._d - - def get(self, key: str, *args: Any, **kwargs: Any): - return kwargs.get(key) or self.state.get(key) - def pipe(self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs): return func(*args, **kwargs) @cls_logged("Validating server...") - def validate_server( - self, - name: Optional[str] = None, - url: Optional[str] = None, - api_key: Optional[str] = None, - insecure: bool = False, - cacert: Optional[str] = None, - api_key_is_required: bool = False, - account_name: Optional[str] = None, - token: Optional[str] = None, - secret: Optional[str] = None, - ): - if (url and api_key) or isinstance(self.remote_server, RSConnectServer): - self.validate_connect_server(name, url, api_key, insecure, cacert, api_key_is_required) - elif (url and token and secret) or isinstance(self.remote_server, PositServer): - self.validate_rstudio_server(url, account_name, token, secret) + def validate_server(self): + """ + Validate that there is enough information to talk to shinyapps.io or a Connect server. + """ + if isinstance(self.remote_server, RSConnectServer): + self.validate_connect_server() + elif isinstance(self.remote_server, PositServer): + self.validate_posit_server() else: raise RSConnectException("Unable to validate server from information provided.") return self - def validate_connect_server( - self, - name: Optional[str] = None, - url: Optional[str] = None, - api_key: Optional[str] = None, - insecure: bool = False, - cacert: Optional[str] = None, - api_key_is_required: bool = False, - **kwargs: object - ): - """ - Validate that the user gave us enough information to talk to shinyapps.io or 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 path of a CA certs file containing certificates to use. - :param api_key_is_required: a flag that notes whether the API key is required or may - be omitted. - :param token: The shinyapps.io authentication token. - :param secret: The shinyapps.io authentication secret. - """ - url = url or self.remote_server.url - api_key = api_key or self.remote_server.api_key - insecure = insecure or self.remote_server.insecure - api_key_is_required = api_key_is_required or self.get("api_key_is_required", **kwargs) + def validate_connect_server(self): + if not isinstance(self.remote_server, RSConnectServer): + raise RSConnectException("remote_server must be a Connect server.") + url = self.remote_server.url + api_key = self.remote_server.api_key + insecure = self.remote_server.insecure + api_key_is_required = self.api_key_is_required + ca_data = self.remote_server.ca_data - ca_data = None - if cacert: - ca_data = read_certificate_file(cacert) - api_key = api_key or self.remote_server.api_key - insecure = insecure or self.remote_server.insecure - if not ca_data: - ca_data = self.remote_server.ca_data - - api_key_is_required = api_key_is_required or self.get("api_key_is_required", **kwargs) - - 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.") - - server_data = ServerStore().resolve(name, url) + server_data = ServerStore().resolve(None, url) connect_server = RSConnectServer(url, None, insecure, ca_data) # If our info came from the command line, make sure the URL really works. if not server_data.from_store: - self.server_settings + self.server_settings() connect_server.api_key = api_key @@ -705,26 +813,22 @@ def validate_connect_server( # If our info came from the command line, make sure the key really works. if not server_data.from_store: - _ = self.verify_api_key(connect_server) + self.verify_api_key(connect_server) - self.remote_server: RSConnectServer = connect_server + self.remote_server = connect_server self.client = RSConnectClient(self.remote_server) return self - def validate_rstudio_server( - self, - url: Optional[str] = None, - account_name: Optional[str] = None, - token: Optional[str] = None, - secret: Optional[str] = None, - **kwargs: object, - ): + def validate_posit_server(self): + if not isinstance(self.remote_server, PositServer): + raise RSConnectException("remote_server is not a Posit server.") + remote_server: PositServer = self.remote_server - url = url or remote_server.url - account_name = account_name or remote_server.account_name - token = token or remote_server.token - secret = secret or remote_server.secret + url = remote_server.url + account_name = remote_server.account_name + token = remote_server.token + secret = remote_server.secret server = ( CloudServer(url, account_name, token, secret) if "rstudio.cloud" in url or "posit.cloud" in url @@ -734,34 +838,27 @@ def validate_rstudio_server( with PositClient(server) as client: try: result = client.get_current_user() - server.handle_bad_response(result) + result = server.handle_bad_response(result) except RSConnectException as exc: raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc)) @cls_logged("Making bundle ...") - def make_bundle(self, func: Callable[..., object], *args: object, **kwargs: object): - 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) - force_unique_name = app_id is None - d["deployment_name"] = self.make_deployment_name(d["title"], force_unique_name) + def make_bundle( + self, + func: Callable[P, IO[bytes]], + *args: P.args, + **kwargs: P.kwargs, + # These are the actual kwargs that appear to be present in practice + # image: Optional[str] = None, + # env_management_py: Optional[bool] = None, + # env_management_r: Optional[bool] = None, + # multi_notebook: Optional[bool] = None, + ): + force_unique_name = self.app_id is None + self.deployment_name = self.make_deployment_name(self.title, force_unique_name) try: - bundle = func(*args, **kwargs) + self.bundle = func(*args, **kwargs) except IOError as error: msg = "Unable to include the file %s in the bundle: %s" % ( error.filename, @@ -769,95 +866,89 @@ def make_bundle(self, func: Callable[..., object], *args: object, **kwargs: obje ) raise RSConnectException(msg) - d["bundle"] = bundle - return self - def upload_rstudio_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle_size: int, contents: bytes): + def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle_size: int, contents: bytes): upload_url = prepare_deploy_result.presigned_url parsed_upload_url = urlparse(upload_url) with S3Client("{}://{}".format(parsed_upload_url.scheme, parsed_upload_url.netloc)) as s3_client: - upload_result = s3_client.upload( - "{}?{}".format(parsed_upload_url.path, parsed_upload_url.query), - prepare_deploy_result.presigned_checksum, - bundle_size, - contents, + upload_result = cast( + HTTPResponse, + s3_client.upload( + "{}?{}".format(parsed_upload_url.path, parsed_upload_url.query), + prepare_deploy_result.presigned_checksum, + bundle_size, + contents, + ), ) - S3Server(upload_url).handle_bad_response(upload_result) + upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True) @cls_logged("Deploying bundle ...") - def deploy_bundle( - self, - app_id: Optional[str] = None, - deployment_name: Optional[str] = None, - title: Optional[str] = None, - title_is_default: bool = False, - bundle: Optional[BinaryIO] = None, - env_vars: Optional[dict[str, str]] = None, - app_mode: Optional[AppMode] = None, - visibility: Optional[str] = None, - ): - app_id = app_id or self.get("app_id") - deployment_name = deployment_name or self.get("deployment_name") - title = title or self.get("title") - title_is_default = title_is_default or self.get("title_is_default") - bundle = bundle or typing.cast(BinaryIO, self.get("bundle")) - env_vars = env_vars or self.get("env_vars") - app_mode = app_mode or self.get("app_mode") - visibility = visibility or self.get("visibility") + def deploy_bundle(self): + if self.deployment_name is None: + raise RSConnectException("A deployment name must be created before deploying a bundle.") + if self.bundle is None: + raise RSConnectException("A bundle must be created before deploying it.") if isinstance(self.remote_server, RSConnectServer): + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("client must be an RSConnectClient.") result = self.client.deploy( - app_id, - deployment_name, - title, - title_is_default, - bundle, - env_vars, + self.app_id, + self.deployment_name, + self.title, + self.title_is_default, + self.bundle, + self.env_vars, ) - self.remote_server.handle_bad_response(result) - self.state["deployed_info"] = result + self.deployed_info = result return self else: - contents = bundle.read() + contents = self.bundle.read() bundle_size = len(contents) bundle_hash = hashlib.md5(contents).hexdigest() + if not isinstance(self.client, PositClient): + raise RSConnectException("client must be a PositClient.") + if isinstance(self.remote_server, ShinyappsServer): shinyapps_service = ShinyappsService(self.client, self.remote_server) prepare_deploy_result = shinyapps_service.prepare_deploy( - typing.cast(int, app_id), - deployment_name, + self.app_id, + self.deployment_name, bundle_size, bundle_hash, - visibility, + self.visibility, ) - self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) + self.upload_posit_bundle(prepare_deploy_result, bundle_size, contents) shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) else: cloud_service = CloudService(self.client, self.remote_server, os.getenv("LUCID_APPLICATION_ID")) - app_store_version = self.get("app_store_version") + app_store_version = self.app_store_version prepare_deploy_result = cloud_service.prepare_deploy( - app_id, deployment_name, bundle_size, bundle_hash, app_mode, app_store_version + self.app_id, + self.deployment_name, + bundle_size, + bundle_hash, + self.app_mode, + app_store_version, ) - self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) + self.upload_posit_bundle(prepare_deploy_result, bundle_size, contents) cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.application_id) print("Application successfully deployed to {}".format(prepare_deploy_result.app_url)) webbrowser.open_new(prepare_deploy_result.app_url) - self.state["deployed_info"] = { + self.deployed_info = { "app_url": prepare_deploy_result.app_url, "app_id": prepare_deploy_result.app_id, "app_guid": None, - "title": title, + "title": self.title, } return self def emit_task_log( self, - app_id: Optional[int] = None, - task_id: Optional[int] = None, log_callback: logging.Logger = connect_logger, abort_func: Callable[[], bool] = lambda: False, timeout: int = get_task_timeout(), @@ -879,32 +970,32 @@ def emit_task_log( return the task_result so we can record the exit code. """ if isinstance(self.remote_server, RSConnectServer): - app_id = app_id or self.state["deployed_info"]["app_id"] - task_id = task_id or self.state["deployed_info"]["task_id"] + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To emit task log, client must be a RSConnectClient.") + log_lines, _ = self.client.wait_for_task( - task_id, log_callback.info, abort_func, timeout, poll_wait, raise_on_error + self.deployed_info["task_id"], + log_callback.info, + abort_func, + timeout, + poll_wait, + raise_on_error, ) - self.remote_server.handle_bad_response(log_lines) - app_config = self.client.app_config(app_id) - self.remote_server.handle_bad_response(app_config) + log_lines = self.remote_server.handle_bad_response(log_lines) + app_config = self.client.app_config(self.deployed_info["app_id"]) + app_config = self.remote_server.handle_bad_response(app_config) app_dashboard_url = app_config.get("config_url") log_callback.info("Deployment completed successfully.") log_callback.info("\t Dashboard content URL: %s", app_dashboard_url) - log_callback.info("\t Direct content URL: %s", self.state["deployed_info"]["app_url"]) + log_callback.info("\t Direct content URL: %s", self.deployed_info["app_url"]) return self @cls_logged("Saving deployed information...") - def save_deployed_info(self, *args: object, **kwargs: object): - 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) + def save_deployed_info(self): + app_store = self.app_store + path = self.path + deployed_info = self.deployed_info app_store.set( self.remote_server.url, @@ -913,34 +1004,30 @@ def save_deployed_info(self, *args: object, **kwargs: object): deployed_info["app_id"], deployed_info["app_guid"], deployed_info["title"], - self.state["app_mode"], + self.app_mode, ) return self @cls_logged("Verifying deployed content...") - def verify_deployment(self, *args: object, **kwargs: object): + def verify_deployment(self): if isinstance(self.remote_server, RSConnectServer): - deployed_info = self.get("deployed_info", *args, **kwargs) + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To verify deployment, client must be a RSConnectClient.") + deployed_info = self.deployed_info app_guid = deployed_info["app_guid"] self.client.app_access(app_guid) @cls_logged("Validating app mode...") - def validate_app_mode(self, *args: object, **kwargs: object): - 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) + def validate_app_mode(self, app_mode: AppMode): + path = self.path + app_store = self.app_store 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) + self.app_store = app_store = AppStore(module_file) + new = self.new + app_id = self.app_id + app_mode = app_mode or self.app_mode if new and app_id: raise RSConnectException("Specify either a new deploy or an app ID but not both.") @@ -954,7 +1041,7 @@ def validate_app_mode(self, *args: object, **kwargs: object): app_id, existing_app_mode, app_store_version = app_store.resolve( self.remote_server.url, app_id, app_mode ) - self.state["app_store_version"] = app_store_version + self.app_store_version = app_store_version logger.debug("Using app mode from app %s: %s" % (app_id, app_mode)) elif app_id is not None: @@ -963,14 +1050,17 @@ def validate_app_mode(self, *args: object, **kwargs: object): if isinstance(self.remote_server, RSConnectServer): try: app = get_app_info(self.remote_server, app_id) - existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) + # TODO: verify that this is correct. The previous code seemed + # incorrect. It passed an arg to app.get(), which would have + # been ignored. + existing_app_mode = AppModes.get_by_ordinal(app.app_mode, True) except RSConnectException as e: raise RSConnectException( f"{e} Try setting the --new flag to overwrite the previous deployment." ) from e elif isinstance(self.remote_server, PositServer): try: - app = get_rstudio_app_info(self.remote_server, app_id) + app = get_posit_app_info(self.remote_server, app_id) existing_app_mode = AppModes.get_by_cloud_name(app["mode"]) except RSConnectException as e: raise RSConnectException( @@ -986,16 +1076,16 @@ def validate_app_mode(self, *args: object, **kwargs: object): ) % (app_mode.desc(), existing_app_mode.desc()) raise RSConnectException(msg) - self.state["app_id"] = app_id - self.state["app_mode"] = app_mode - self.state["app_store_version"] = app_store_version + self.app_id = app_id + self.app_mode = app_mode + self.app_store_version = app_store_version return self - @property def server_settings(self): try: + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To get server settings, client must be a RSConnectClient.") result = self.client.server_settings() - self.remote_server.handle_bad_response(result) except SSLError as ssl_error: raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error) return result @@ -1019,8 +1109,9 @@ def verify_api_key(self, server: Optional[RSConnectServer] = None): @property def api_username(self) -> str: + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To get server settings, client must be a RSConnectClient.") result = self.client.me() - self.remote_server.handle_bad_response(result) return result["username"] @property @@ -1031,11 +1122,11 @@ def python_info(self): :return: the Python installation information from Connect. """ + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To get Python info, client must be a RSConnectClient.") result = self.client.python_settings() - self.remote_server.handle_bad_response(result) return result - @property def server_details(self) -> ServerDetails: """ Builds a dictionary containing the version of Posit Connect that is running @@ -1050,7 +1141,7 @@ def _to_sort_key(text: str): parts = [part.zfill(5) for part in text.split(".")] return "".join(parts) - server_settings = self.server_settings + 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) return { @@ -1091,29 +1182,30 @@ def make_deployment_name(self, title: str, force_unique: bool) -> str: return name @property - def runtime_caches(self): + def runtime_caches(self) -> list[ListEntryOutputDTO]: + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To delete a runtime cache, client must be a RSConnectClient.") return self.client.system_caches_runtime_list() def delete_runtime_cache(self, language: Optional[str], version: Optional[str], image_name: str, dry_run: bool): - target = {"language": language, "version": version, "image_name": image_name, "dry_run": dry_run} + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To delete a runtime cache, client must be a RSConnectClient.") + target: DeleteInputDTO = { + "language": language, + "version": version, + "image_name": image_name, + "dry_run": dry_run, + } result = self.client.system_caches_runtime_delete(target) - self.state["result"] = result + self.result = result if result["task_id"] is None: print("Dry run finished") else: - (log_lines, task_status) = self.client.wait_for_task( - result["task_id"], connect_logger.info, raise_on_error=False - ) - self.state["task_status"] = task_status + (_, task_status) = self.client.wait_for_task(result["task_id"], connect_logger.info, raise_on_error=False) + self.task_status = task_status return self -def filter_out_server_info(**kwargs: object): - 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 - - class S3Client(HTTPServer): def upload(self, path: str, presigned_checksum: str, bundle_size: int, contents: bytes): headers = { @@ -1160,6 +1252,65 @@ def __init__( self.application_id = application_id +# Placeholder types +# NOTE: These were inferred from the existing code, but they should be updated with +# the actual types from the Posit API. +class PositClientDeployTask(TypedDict): + id: str + finished: bool + status: str + description: str + error: str + + +class PositClientApp(TypedDict): + id: int + name: str + url: str + deployment: dict[str, Any] + content_id: str + + +class PositClientAppSearchResults(TypedDict): + applications: list[PositClientApp] + count: int + total: str + + +class PositClientAccountSearchResults(TypedDict): + accounts: list[PositClientAccount] + + +class PositClientAccount(TypedDict): + id: int + name: str + + +class PositClientBundle(TypedDict): + id: str + presigned_url: str + presigned_checksum: str + + +class PositClientShinyappsBuildTask(TypedDict): + id: str + + +class PositClientShinyappsBuildTaskSearchResults(TypedDict): + tasks: list[PositClientShinyappsBuildTask] + + +class PositClientCloudOutput(TypedDict): + id: int + space_id: str + source_id: int + url: str + + +class PositClientCloudOutputRevision(TypedDict): + application_id: int + + class PositClient(HTTPServer): """ An HTTP client to call the Posit Cloud and shinyapps.io APIs. @@ -1167,14 +1318,14 @@ class PositClient(HTTPServer): _TERMINAL_STATUSES = {"success", "failed", "error"} - def __init__(self, rstudio_server: PositServer): - self._token = rstudio_server.token + def __init__(self, posit_server: PositServer): + self._token = posit_server.token try: - self._key = base64.b64decode(rstudio_server.secret) + self._key = base64.b64decode(posit_server.secret) except binascii.Error as e: raise RSConnectException("Invalid secret.") from e - self._server = rstudio_server - super().__init__(rstudio_server.url) + self._server = posit_server + super().__init__(posit_server.url) def _get_canonical_request(self, method: str, path: str, timestamp: str, content_hash: str): return "\n".join([method, path, timestamp, content_hash]) @@ -1218,108 +1369,137 @@ def get_extra_headers(self, url: str, method: str, body: str | bytes): } def get_application(self, application_id: str): - response = self.get("/v1/applications/{}".format(application_id)) - self._server.handle_bad_response(response) + response = cast(Union[PositClientApp, HTTPResponse], self.get("/v1/applications/{}".format(application_id))) + response = self._server.handle_bad_response(response) return response - def update_application_property(self, application_id: int, property: str, value: str): - response = self.put("/v1/applications/{}/properties/{}".format(application_id, property), body={"value": value}) - self._server.handle_bad_response(response) + def update_application_property(self, application_id: int, property: str, value: str) -> HTTPResponse: + response = cast( + HTTPResponse, + self.put("/v1/applications/{}/properties/{}".format(application_id, property), body={"value": value}), + ) + response = self._server.handle_bad_response(response, is_httpresponse=True) return response - def get_content(self, content_id: str): - response = self.get("/v1/content/{}".format(content_id)) - self._server.handle_bad_response(response) + def get_content(self, content_id: str) -> PositClientCloudOutput: + response = cast(Union[PositClientCloudOutput, HTTPResponse], self.get("/v1/content/{}".format(content_id))) + response = self._server.handle_bad_response(response) return response - def create_application(self, account_id: int, application_name: str): + def create_application(self, account_id: int, application_name: str) -> PositClientApp: application_data = { "account": account_id, "name": application_name, "template": "shiny", } - response = self.post("/v1/applications/", body=application_data) - self._server.handle_bad_response(response) + response = cast(Union[PositClientApp, HTTPResponse], self.post("/v1/applications/", body=application_data)) + response = self._server.handle_bad_response(response) return response - def create_output(self, name: str, application_type: str, project_id=None, space_id=None, render_by=None): + def create_output( + self, + name: str, + application_type: str, + project_id: Optional[str] = None, + space_id: Optional[str] = None, + render_by: Optional[str] = None, + ) -> PositClientCloudOutput: data = {"name": name, "space": space_id, "project": project_id, "application_type": application_type} if render_by: data["render_by"] = render_by - response = self.post("/v1/outputs/", body=data) - self._server.handle_bad_response(response) + response = cast(Union[PositClientCloudOutput, HTTPResponse], self.post("/v1/outputs/", body=data)) + response = self._server.handle_bad_response(response) return response - def create_revision(self, content_id): - response = self.post("/v1/outputs/{}/revisions".format(content_id), body={}) - self._server.handle_bad_response(response) + def create_revision(self, content_id: str) -> PositClientCloudOutputRevision: + response = cast( + Union[PositClientCloudOutputRevision, HTTPResponse], + self.post("/v1/outputs/{}/revisions".format(content_id), body={}), + ) + response = self._server.handle_bad_response(response) return response - def update_output(self, output_id: int, output_data: dict): + def update_output(self, output_id: int, output_data: dict[str, str]): return self.patch("/v1/outputs/{}".format(output_id), body=output_data) - def get_accounts(self): - response = self.get("/v1/accounts/") - self._server.handle_bad_response(response) + def get_accounts(self) -> PositClientAccountSearchResults: + response = cast(Union[PositClientAccountSearchResults, HTTPResponse], self.get("/v1/accounts/")) + response = self._server.handle_bad_response(response) return response - def _get_applications_like_name_page(self, name: str, offset: int): - response = self.get( - "/v1/applications?filter=name:like:{}&offset={}&count=100&use_advanced_filters=true".format(name, offset) + def _get_applications_like_name_page(self, name: str, offset: int) -> PositClientAppSearchResults: + response = cast( + Union[PositClientAppSearchResults, HTTPResponse], + self.get( + "/v1/applications?filter=name:like:{}&offset={}&count=100&use_advanced_filters=true".format( + name, offset + ) + ), ) - self._server.handle_bad_response(response) + response = self._server.handle_bad_response(response) return response - def create_bundle(self, application_id: int, content_type: str, content_length: int, checksum: str): + def create_bundle( + self, application_id: int, content_type: str, content_length: int, checksum: str + ) -> PositClientBundle: bundle_data = { "application": application_id, "content_type": content_type, "content_length": content_length, "checksum": checksum, } - response = self.post("/v1/bundles", body=bundle_data) - self._server.handle_bad_response(response) + response = cast(Union[PositClientBundle, HTTPResponse], self.post("/v1/bundles", body=bundle_data)) + response = self._server.handle_bad_response(response) return response - def set_bundle_status(self, bundle_id: str, bundle_status): + def set_bundle_status(self, bundle_id: str, bundle_status: str): response = self.post("/v1/bundles/{}/status".format(bundle_id), body={"status": bundle_status}) - self._server.handle_bad_response(response) + response = self._server.handle_bad_response(response) return response - def deploy_application(self, bundle_id: str, app_id: str): - response = self.post("/v1/applications/{}/deploy".format(app_id), body={"bundle": bundle_id, "rebuild": False}) - self._server.handle_bad_response(response) + def deploy_application(self, bundle_id: str, app_id: str) -> PositClientDeployTask: + response = cast( + Union[PositClientDeployTask, HTTPResponse], + self.post("/v1/applications/{}/deploy".format(app_id), body={"bundle": bundle_id, "rebuild": False}), + ) + response = self._server.handle_bad_response(response) return response - def get_task(self, task_id: str) -> TaskStatus: - response = self.get("/v1/tasks/{}".format(task_id), query_params={"legacy": "true"}) - self._server.handle_bad_response(response) + def get_task(self, task_id: str) -> PositClientDeployTask: + response = cast( + Union[PositClientDeployTask, HTTPResponse], + self.get("/v1/tasks/{}".format(task_id), query_params={"legacy": "true"}), + ) + response = self._server.handle_bad_response(response) return response - def get_shinyapps_build_task(self, parent_task_id: str): - response = self.get( - "/v1/tasks", - query_params={ - "filter": [ - "parent_id:eq:{}".format(parent_task_id), - "action:eq:image-build", - ] - }, + def get_shinyapps_build_task(self, parent_task_id: str) -> PositClientShinyappsBuildTaskSearchResults: + response = cast( + Union[PositClientShinyappsBuildTaskSearchResults, HTTPResponse], + self.get( + "/v1/tasks", + query_params={ + "filter": [ + "parent_id:eq:{}".format(parent_task_id), + "action:eq:image-build", + ] + }, + ), ) - self._server.handle_bad_response(response) + response = self._server.handle_bad_response(response) return response - def get_task_logs(self, task_id: str): - response = self.get("/v1/tasks/{}/logs".format(task_id)) - self._server.handle_bad_response(response) + def get_task_logs(self, task_id: str) -> HTTPResponse: + response = cast(HTTPResponse, self.get("/v1/tasks/{}/logs".format(task_id))) + response = self._server.handle_bad_response(response, is_httpresponse=True) return response def get_current_user(self): response = self.get("/v1/users/me") - self._server.handle_bad_response(response) + response = self._server.handle_bad_response(response) return response - def wait_until_task_is_successful(self, task_id: str, timeout: int = get_task_timeout()): + def wait_until_task_is_successful(self, task_id: str, timeout: int = get_task_timeout()) -> None: print() print("Waiting for task: {}".format(task_id)) start_time = time.time() @@ -1344,16 +1524,15 @@ def wait_until_task_is_successful(self, task_id: str, timeout: int = get_task_ti print("Task done: {}".format(description)) - def get_applications_like_name(self, name: str): - applications = [] + def get_applications_like_name(self, name: str) -> list[str]: + applications: list[PositClientApp] = [] results = self._get_applications_like_name_page(name, 0) - self._server.handle_bad_response(results) + results = self._server.handle_bad_response(results) offset = 0 while len(applications) < int(results["total"]): results = self._get_applications_like_name_page(name, offset) - self._server.handle_bad_response(results) applications = results["applications"] applications.extend(applications) offset += int(results["count"]) @@ -1366,21 +1545,23 @@ class ShinyappsService: Encapsulates operations involving multiple API calls to shinyapps.io. """ - def __init__(self, rstudio_client: PositClient, server: ShinyappsServer): - self._rstudio_client = rstudio_client + def __init__(self, posit_client: PositClient, server: ShinyappsServer): + self._posit_client = posit_client self._server = server def prepare_deploy( self, - app_id: Optional[int], + app_id: Optional[str], app_name: str, bundle_size: int, bundle_hash: str, visibility: Optional[str], ): - accounts = self._rstudio_client.get_accounts() - self._server.handle_bad_response(accounts) - account = next(filter(lambda acct: acct["name"] == self._server.account_name, accounts["accounts"]), None) + accounts = self._posit_client.get_accounts() + accounts = self._server.handle_bad_response(accounts) + account: PositClientAccount = next( + filter(lambda acct: acct["name"] == self._server.account_name, accounts["accounts"]), None + ) # TODO: also check this during `add` command if account is None: raise RSConnectException( @@ -1388,29 +1569,23 @@ def prepare_deploy( ) if app_id is None: - application = self._rstudio_client.create_application(account["id"], app_name) - self._server.handle_bad_response(application) + application = self._posit_client.create_application(account["id"], app_name) if visibility is not None: - property_update = self._rstudio_client.update_application_property( - application["id"], "application.visibility", visibility - ) - self._server.handle_bad_response(property_update) + self._posit_client.update_application_property(application["id"], "application.visibility", visibility) else: - application = self._rstudio_client.get_application(app_id) - self._server.handle_bad_response(application) + application = self._posit_client.get_application(app_id) if visibility is not None: if visibility != application["deployment"]["properties"]["application.visibility"]: - self._rstudio_client.update_application_property( + self._posit_client.update_application_property( application["id"], "application.visibility", visibility ) app_id_int = application["id"] app_url = application["url"] - bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) - self._server.handle_bad_response(bundle) + bundle = self._posit_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) return PrepareDeployResult( app_id_int, @@ -1420,15 +1595,15 @@ def prepare_deploy( bundle["presigned_checksum"], ) - def do_deploy(self, bundle_id, app_id): - self._rstudio_client.set_bundle_status(bundle_id, "ready") - deploy_task = self._rstudio_client.deploy_application(bundle_id, app_id) + def do_deploy(self, bundle_id: str, app_id: str): + self._posit_client.set_bundle_status(bundle_id, "ready") + deploy_task = self._posit_client.deploy_application(bundle_id, app_id) try: - self._rstudio_client.wait_until_task_is_successful(deploy_task["id"]) + self._posit_client.wait_until_task_is_successful(deploy_task["id"]) except DeploymentFailedException as e: - build_task_result = self._rstudio_client.get_shinyapps_build_task(deploy_task["id"]) + build_task_result = self._posit_client.get_shinyapps_build_task(deploy_task["id"]) build_task = build_task_result["tasks"][0] - logs = self._rstudio_client.get_task_logs(build_task["id"]) + logs = self._posit_client.get_task_logs(build_task["id"]) logger.error("Build logs:\n{}".format(logs.response_body)) raise e @@ -1438,25 +1613,30 @@ class CloudService: Encapsulates operations involving multiple API calls to Posit Cloud. """ - def __init__(self, cloud_client: PositClient, server: CloudServer, project_application_id: typing.Optional[str]): - self._rstudio_client = cloud_client + def __init__( + self, + cloud_client: PositClient, + server: CloudServer, + project_application_id: Optional[str], + ): + self._posit_client = cloud_client self._server = server self._project_application_id = project_application_id def _get_current_project_id(self) -> str | None: if self._project_application_id is not None: - project_application = self._rstudio_client.get_application(self._project_application_id) + project_application = self._posit_client.get_application(self._project_application_id) return project_application["content_id"] return None def prepare_deploy( self, - app_id: typing.Optional[typing.Union[str, int]], + app_id: Optional[str | int], app_name: str, bundle_size: int, bundle_hash: str, app_mode: AppMode, - app_store_version: typing.Optional[int], + app_store_version: Optional[int], ) -> PrepareDeployOutputResult: application_type = "static" if app_mode in [AppModes.STATIC, AppModes.STATIC_QUARTO] else "connect" logger.debug(f"application_type: {application_type}") @@ -1469,14 +1649,14 @@ def prepare_deploy( if app_id is None: # this is a deployment of a new output if project_id is not None: - project = self._rstudio_client.get_content(project_id) + project = self._posit_client.get_content(project_id) space_id = project["space_id"] else: project_id = None space_id = None # create the new output and associate it with the current Posit Cloud project and space - output = self._rstudio_client.create_output( + output = self._posit_client.create_output( name=app_name, application_type=application_type, project_id=project_id, @@ -1488,29 +1668,29 @@ def prepare_deploy( # this is a redeployment of an existing output if app_store_version is not None: # versioned app store files store content id in app_id - output = self._rstudio_client.get_content(app_id) + output = self._posit_client.get_content(app_id) app_id_int = output["source_id"] content_id = output["id"] else: # unversioned appstore files (deployed using a prior release) store application id in app_id - application = self._rstudio_client.get_application(app_id) + application = self._posit_client.get_application(app_id) # content_id will appear on static applications as output_id content_id = application.get("content_id") or application.get("output_id") app_id_int = application["id"] - output = self._rstudio_client.get_content(content_id) + output = self._posit_client.get_content(content_id) if application_type == "static": - revision = self._rstudio_client.create_revision(content_id) + revision = self._posit_client.create_revision(content_id) app_id_int = revision["application_id"] # associate the output with the current Posit Cloud project (if any) if project_id is not None: - self._rstudio_client.update_output(output["id"], {"project": project_id}) + self._posit_client.update_output(output["id"], {"project": project_id}) app_url = output["url"] output_id = output["id"] - bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) + bundle = self._posit_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) return PrepareDeployOutputResult( app_id=output_id, @@ -1521,13 +1701,13 @@ def prepare_deploy( presigned_checksum=bundle["presigned_checksum"], ) - def do_deploy(self, bundle_id, app_id): - self._rstudio_client.set_bundle_status(bundle_id, "ready") - deploy_task = self._rstudio_client.deploy_application(bundle_id, app_id) + def do_deploy(self, bundle_id: str, app_id: str): + self._posit_client.set_bundle_status(bundle_id, "ready") + deploy_task = self._posit_client.deploy_application(bundle_id, app_id) try: - self._rstudio_client.wait_until_task_is_successful(deploy_task["id"]) + self._posit_client.wait_until_task_is_successful(deploy_task["id"]) except DeploymentFailedException as e: - logs_response = self._rstudio_client.get_task_logs(deploy_task["id"]) + logs_response = self._posit_client.get_task_logs(deploy_task["id"]) if len(logs_response.response_body) > 0: logger.error("Build logs:\n{}".format(logs_response.response_body)) raise e @@ -1546,7 +1726,7 @@ def verify_server(connect_server: RSConnectServer): try: with RSConnectClient(connect_server) as client: result = client.server_settings() - connect_server.handle_bad_response(result) + result = connect_server.handle_bad_response(result) return result except SSLError as ssl_error: raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error) @@ -1564,7 +1744,12 @@ def verify_api_key(connect_server: RSConnectServer) -> str: with RSConnectClient(connect_server) as client: result = client.me() if isinstance(result, HTTPResponse): - if result.json_data and "code" in result.json_data and result.json_data["code"] == 30: + if ( + result.json_data + and isinstance(result.json_data, dict) + 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 result["username"] @@ -1581,7 +1766,6 @@ def get_python_info(connect_server: RSConnectServer): warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) with RSConnectClient(connect_server) as client: result = client.python_settings() - connect_server.handle_bad_response(result) return result @@ -1594,12 +1778,10 @@ def get_app_info(connect_server: RSConnectServer, app_id: str): :return: the Python installation information from Connect. """ with RSConnectClient(connect_server) as client: - result = client.app_get(app_id) - connect_server.handle_bad_response(result) - return result + return client.app_get(app_id) -def get_rstudio_app_info(server: PositServer, app_id: str): +def get_posit_app_info(server: PositServer, app_id: str): with PositClient(server) as client: if isinstance(server, ShinyappsServer): return client.get_application(app_id) @@ -1619,7 +1801,7 @@ def get_app_config(connect_server: RSConnectServer, app_id: str): """ with RSConnectClient(connect_server) as client: result = client.app_config(app_id) - connect_server.handle_bad_response(result) + result = connect_server.handle_bad_response(result) return result @@ -1652,7 +1834,7 @@ def emit_task_log( """ with RSConnectClient(connect_server) as client: result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error) - connect_server.handle_bad_response(result) + result = connect_server.handle_bad_response(result) app_config = client.app_config(app_id) connect_server.handle_bad_response(app_config) app_url = app_config.get("config_url") @@ -1663,8 +1845,8 @@ def retrieve_matching_apps( connect_server: RSConnectServer, filters: Optional[dict[str, str | int]] = None, limit: Optional[int] = None, - mapping_function=None, -) -> list[str]: + mapping_function: Optional[Callable[[RSConnectClient, ContentItemV0], AbbreviatedAppItem | None]] = None, +) -> list[ContentItemV0 | AbbreviatedAppItem]: """ Retrieves all the app names that start with the given default name. The main point for this function is that it handles all the necessary paging logic. @@ -1683,7 +1865,7 @@ def retrieve_matching_apps( :return: the list of existing names that start with the proposed one. """ page_size = 100 - result: list[str] = [] + result: list[ContentItemV0 | AbbreviatedAppItem] = [] search_filters = filters.copy() if filters else {} search_filters["count"] = min(limit, page_size) if limit else page_size total_returned = 0 @@ -1693,7 +1875,6 @@ def retrieve_matching_apps( with RSConnectClient(connect_server) as client: while not finished: response = client.app_search(search_filters) - connect_server.handle_bad_response(response) if not maximum: maximum = response["total"] @@ -1728,6 +1909,15 @@ def retrieve_matching_apps( return result +class AbbreviatedAppItem(TypedDict): + id: int + name: str + title: str | None + app_mode: AppModes.Modes + url: str + config_url: str + + def override_title_search(connect_server: RSConnectServer, app_id: str, app_title: str): """ Returns a list of abbreviated app data that contains apps with a title @@ -1740,7 +1930,7 @@ def override_title_search(connect_server: RSConnectServer, app_id: str, app_titl URL and dashboard URL. """ - def map_app(app: ContentItem, config): + def map_app(app: ContentItemV0, config: ConfigureResult) -> AbbreviatedAppItem: """ Creates the abbreviated data dictionary for the specified app and config information. @@ -1758,7 +1948,7 @@ def map_app(app: ContentItem, config): "config_url": config["config_url"], } - def mapping_filter(client: RSConnectClient, app: ContentItem): + def mapping_filter(client: RSConnectClient, app: ContentItemV0) -> AbbreviatedAppItem | None: """ Mapping/filter function for retrieving apps. We only keep apps that have an app mode of static or Jupyter notebook. The data @@ -1774,7 +1964,7 @@ def mapping_filter(client: RSConnectClient, app: ContentItem): return None config = client.app_config(app["id"]) - connect_server.handle_bad_response(config) + config = connect_server.handle_bad_response(config) return map_app(app, config) @@ -1831,23 +2021,3 @@ def find_unique_name(remote_server: TargetableServer, name: str): name = test return name - - -def _to_server_check_list(url: str) -> list[str]: - """ - 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] diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 73e4b572..ce755d6a 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -18,10 +18,30 @@ from collections import defaultdict from copy import deepcopy from mimetypes import guess_type -from os.path import abspath, basename, dirname, exists, isdir, isfile, join, relpath, splitext +from os.path import ( + abspath, + basename, + dirname, + exists, + isdir, + isfile, + join, + relpath, + splitext, +) from pathlib import Path from pprint import pformat -from typing import TYPE_CHECKING, Callable, Iterator, Literal, Optional, Sequence, cast +from typing import ( + IO, + TYPE_CHECKING, + Callable, + Iterator, + Literal, + Optional, + Sequence, + Union, + cast, +) # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, # they should both come from the same typing module. @@ -113,7 +133,7 @@ class ManifestData(TypedDict): class Manifest: def __init__( self, - *args: object, + *, version: Optional[int] = None, environment: Optional[Environment] = None, app_mode: Optional[AppMode] = None, @@ -125,20 +145,19 @@ def __init__( primary_html: Optional[str] = None, metadata: Optional[ManifestDataMetadata] = None, files: Optional[dict[str, ManifestDataFile]] = None, - **kwargs: object ) -> None: self.data: ManifestData = cast(ManifestData, {}) self.buffer: dict[str, str] = {} - self._deploy_dir: str | None = None + self.deploy_dir: str | None = None self.data["version"] = version if version else 1 - if environment: + if environment and environment.locale is not None: self.data["locale"] = environment.locale if metadata is None: self.data["metadata"] = cast(ManifestDataMetadata, {}) if app_mode is None: - self.data["metadata"]["appmode"] = AppModes.UNKNOWN + self.data["metadata"]["appmode"] = AppModes.UNKNOWN.name() else: self.data["metadata"]["appmode"] = app_mode.name() else: @@ -187,14 +206,6 @@ def __init__( if files: self.data["files"] = files - @property - def deploy_dir(self): - return self._deploy_dir - - @deploy_dir.setter - def deploy_dir(self, value: str): - self._deploy_dir = value - @classmethod def from_json(cls, json_str: str): return cls(**json.loads(json_str)) @@ -306,18 +317,10 @@ def flattened_copy(self): class Bundle: - def __init__(self, *args: object, **kwargs: object) -> None: + def __init__(self) -> None: self.file_paths: set[str] = set() self.buffer: dict[str, str] = {} - self._deploy_dir: str | None = None - - @property - def deploy_dir(self) -> str | None: - return self._deploy_dir - - @deploy_dir.setter - def deploy_dir(self, value: str | None) -> None: - self._deploy_dir = value + self.deploy_dir: str | None = None def add_file(self, filepath: str) -> None: self.file_paths.add(filepath) @@ -465,7 +468,7 @@ def buffer_checksum(buf: str | bytes) -> str: def to_bytes(s: str | bytes) -> bytes: if isinstance(s, bytes): return s - elif hasattr(s, "encode"): + elif isinstance(s, str): return s.encode("utf-8") logger.warning("can't encode to bytes: %s" % type(s).__name__) return s @@ -589,7 +592,7 @@ def make_notebook_source_bundle( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, -) -> typing.IO[bytes]: +) -> IO[bytes]: """Create a bundle containing the specified notebook and python environment. Returns a file-like object containing the bundle tarball. @@ -640,7 +643,7 @@ def make_quarto_source_bundle( file_or_directory: str, inspect: QuartoInspectResult, app_mode: AppMode, - environment: Environment, + environment: Optional[Environment], extra_files: Sequence[str], excludes: Sequence[str], image: Optional[str] = None, @@ -697,6 +700,7 @@ def make_html_manifest( "appmode": "static", "primary_html": filename, }, + "files": {}, } if image or env_management_py is not None or env_management_r is not None: @@ -952,13 +956,12 @@ def make_api_manifest( def create_html_manifest( path: str, - entrypoint: str, + entrypoint: Optional[str], extra_files: Sequence[str], excludes: Sequence[str], image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, - **kwargs: object ) -> Manifest: """ Creates and writes a manifest.json file for the given path. @@ -1023,7 +1026,7 @@ def create_html_manifest( def make_html_bundle( path: str, - entrypoint: str, + entrypoint: Optional[str], extra_files: Sequence[str], excludes: Sequence[str], image: Optional[str] = None, @@ -1047,7 +1050,16 @@ def make_html_bundle( :return: a file-like object containing the bundle tarball. """ - manifest = create_html_manifest(**locals()) + manifest = create_html_manifest( + path=path, + entrypoint=entrypoint, + extra_files=extra_files, + excludes=excludes, + image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, + ) + if manifest.data.get("files") is None: raise RSConnectException("No valid files were found for the manifest.") @@ -1093,7 +1105,7 @@ def create_file_list( file_set.add(path_to_add) return sorted(file_set) - for cur_dir, sub_dirs, files in os.walk(path): + for cur_dir, _, files in os.walk(path): if Path(cur_dir) in exclude_paths: continue if any(parent in exclude_paths for parent in Path(cur_dir).parents): @@ -1135,42 +1147,53 @@ def infer_entrypoint_candidates(path: str, mimetype: str) -> list[str]: abs_path = os.path.join(path, file) if not isfile(abs_path): continue - mimetype_filelist[guess_type(file)[0]].append(abs_path) + file_type = guess_type(file)[0] + if file_type is None: + raise RSConnectException(f"Could not determine the mime type of {file}.") + mimetype_filelist[file_type].append(abs_path) if file in default_mimetype_entrypoints[mimetype]: return [abs_path] return mimetype_filelist[mimetype] or [] -def guess_deploy_dir(path: str | Path, entrypoint: str) -> str | None: +def guess_deploy_dir(path: str | Path, entrypoint: Optional[str]) -> str: if path and not exists(path): raise RSConnectException(f"Path {path} does not exist.") if entrypoint and not exists(entrypoint): raise RSConnectException(f"Entrypoint {entrypoint} does not exist.") - abs_path = abspath(path) if path else None + abs_path = abspath(path) abs_entrypoint = abspath(entrypoint) if entrypoint else None if not path and not entrypoint: raise RSConnectException("No path or entrypoint provided.") - deploy_dir = None + deploy_dir: str if path and isfile(path): if not entrypoint: deploy_dir = dirname(abs_path) - elif isfile(entrypoint) and abs_path != abs_entrypoint: - raise RSConnectException("Path and entrypoint need to match if they are both files.") - elif isfile(entrypoint) and abs_path == abs_entrypoint: - deploy_dir = dirname(abs_path) + elif isfile(entrypoint): + if abs_path == abs_entrypoint: + deploy_dir = dirname(abs_path) + else: + raise RSConnectException("Path and entrypoint need to match if they are both files.") elif isdir(entrypoint): raise RSConnectException("Entrypoint cannot be a directory while the path is a file.") + else: + raise RSConnectException("Entrypoint cannot be a special file.") + elif path and isdir(path): - if not entrypoint: + if not entrypoint or not abs_entrypoint: deploy_dir = abs_path - elif entrypoint and isdir(entrypoint): - raise RSConnectException("Path and entrypoint cannot both be directories.") - elif entrypoint: + # elif entrypoint and isdir(entrypoint): + # raise RSConnectException("Path and entrypoint cannot both be directories.") + else: + if isdir(entrypoint): + raise RSConnectException("Path and entrypoint cannot both be directories.") guess_entry_file = os.path.join(abs_path, basename(entrypoint)) if isfile(guess_entry_file): deploy_dir = dirname(guess_entry_file) elif isfile(entrypoint): deploy_dir = dirname(abs_entrypoint) + else: + raise RSConnectException("Can't find entrypoint.") elif not path and entrypoint: raise RSConnectException("A path needs to be provided.") else: @@ -1189,7 +1212,7 @@ def abs_entrypoint(path: str | Path, entrypoint: str) -> str | None: def make_voila_bundle( path: str, - entrypoint: str, + entrypoint: Optional[str], extra_files: Sequence[str], excludes: Sequence[str], force_generate: bool, @@ -1219,7 +1242,19 @@ def make_voila_bundle( :return: a file-like object containing the bundle tarball. """ - manifest = create_voila_manifest(**locals()) + manifest = create_voila_manifest( + path=path, + entrypoint=entrypoint, + extra_files=extra_files, + excludes=excludes, + force_generate=force_generate, + environment=environment, + image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, + multi_notebook=multi_notebook, + ) + if manifest.data.get("files") is None: raise RSConnectException("No valid files were found for the manifest.") @@ -1358,7 +1393,7 @@ def make_quarto_manifest( excludes = list(excludes or []) + [".quarto"] project_config = quarto_inspection.get("config", {}).get("project", {}) - output_dir = cast(str | None, project_config.get("output-dir", None)) + output_dir = cast(Union[str, None], project_config.get("output-dir", None)) if output_dir: excludes = excludes + [output_dir] @@ -1406,18 +1441,6 @@ def make_quarto_manifest( return manifest, relevant_files -def _validate_title(title: str) -> None: - """ - 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: str | Path) -> str: """ Produce a default content title from the given file path. The result is @@ -1537,7 +1560,7 @@ def validate_entry_point(entry_point: str | None, directory: str) -> str: return entry_point -def _warn_on_ignored_entrypoint(entrypoint: str) -> None: +def _warn_on_ignored_entrypoint(entrypoint: Optional[str]) -> None: if entrypoint: click.secho( " Warning: entrypoint will not be used or considered for multi-notebook mode.", @@ -1820,17 +1843,15 @@ def write_notebook_manifest_json( def create_voila_manifest( path: str, - entrypoint: str, + entrypoint: Optional[str], environment: Environment, - app_mode: AppMode = AppModes.JUPYTER_VOILA, - extra_files: Sequence[str] = None, - excludes: Sequence[str] = None, + extra_files: Sequence[str], + excludes: Sequence[str], force_generate: bool = True, image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, multi_notebook: bool = False, - **kwargs: object ) -> Manifest: """ Creates and writes a manifest.json file for the given path. @@ -1909,11 +1930,10 @@ def create_voila_manifest( def write_voila_manifest_json( path: str, - entrypoint: str, + entrypoint: Optional[str], environment: Environment, - app_mode: AppMode = AppModes.JUPYTER_VOILA, - extra_files: Sequence[str] = None, - excludes: Sequence[str] = None, + extra_files: Sequence[str], + excludes: Sequence[str], force_generate: bool = True, image: Optional[str] = None, env_management_py: Optional[bool] = None, @@ -1939,7 +1959,18 @@ def write_voila_manifest_json( The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: whether the manifest was written. """ - manifest = create_voila_manifest(**locals()) + manifest = create_voila_manifest( + path=path, + entrypoint=entrypoint, + environment=environment, + extra_files=extra_files, + excludes=excludes, + force_generate=force_generate, + image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, + multi_notebook=multi_notebook, + ) deploy_dir = dirname(manifest.entrypoint) if isfile(manifest.entrypoint) else manifest.entrypoint manifest_flattened_copy_data = manifest.flattened_copy.data if multi_notebook and "metadata" in manifest_flattened_copy_data: diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 2eec3394..1f71cd57 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -15,35 +15,43 @@ import re import subprocess import sys -from typing import Callable, NamedTuple, Optional +from dataclasses import asdict, dataclass, replace +from typing import Callable, Optional version_re = re.compile(r"\d+\.\d+(\.\d+)?") exec_dir = os.path.dirname(sys.executable) -class Environment(NamedTuple): - contents: Optional[str] - error: Optional[str] - filename: Optional[str] - locale: Optional[str] - package_manager: Optional[str] - pip: Optional[str] - python: Optional[str] - source: Optional[str] +@dataclass(frozen=True) +class Environment: + contents: str + filename: str + locale: str + package_manager: str + pip: str + python: str + source: str + error: str | None + + def _asdict(self): + return asdict(self) + + def _replace(self, **kwargs: object): + return replace(self, **kwargs) def MakeEnvironment( - contents: Optional[str] = None, + contents: str, + filename: str, + locale: str, + package_manager: str, + pip: str, + python: str, + source: str, error: Optional[str] = None, - filename: Optional[str] = None, - locale: Optional[str] = None, - package_manager: Optional[str] = None, - pip: Optional[str] = None, - python: Optional[str] = None, - source: Optional[str] = None, **kwargs: object, # provides compatibility where we no longer support some older properties ) -> Environment: - return Environment(contents, error, filename, locale, package_manager, pip, python, source) + return Environment(contents, filename, locale, package_manager, pip, python, source, error) class EnvironmentException(Exception): @@ -67,14 +75,14 @@ def detect_environment(dirname: str, force_generate: bool = False) -> Environmen result = output_file(dirname, "requirements.txt", "pip") or pip_freeze() if result is not None: - result["python"] = get_python_version(MakeEnvironment(**result)) + result["python"] = get_python_version() result["pip"] = get_version("pip") result["locale"] = get_default_locale() return MakeEnvironment(**result) -def get_python_version(environment: Environment) -> str: +def get_python_version() -> str: v = sys.version_info return "%d.%d.%d" % (v[0], v[1], v[2]) diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 774a2258..28d424a0 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -9,14 +9,12 @@ import os import socket import ssl -from typing import Any, BinaryIO, Dict, List, Mapping, Optional, Tuple, Union +from http import client as http +from http.cookies import SimpleCookie +from typing import IO, Any, Dict, List, Mapping, Optional, Tuple, Union, cast +from urllib.parse import urlencode, urljoin, urlparse from warnings import warn -from six.moves import http_client as http -from six.moves.http_cookies import SimpleCookie -from six.moves.urllib_parse import urlencode, urljoin, urlparse - - from . import VERSION from .log import logger from .timeouts import get_request_timeout @@ -41,7 +39,7 @@ def _create_plain_connection( host_name: str, port: Optional[int], disable_tls_check: bool, - ca_data: Optional[str], + ca_data: Optional[str | bytes], ): """ This function is used to create a plain HTTP connection. Note that the 3rd and 4th @@ -195,7 +193,12 @@ def __init__( self.status = response.status self.reason = response.reason self.content_type = response.getheader("Content-Type") - if self.content_type and self.content_type.startswith("application/json") and len(self.response_body) > 0: + if ( + self.content_type + and self.content_type.startswith("application/json") + and self.response_body is not None + and len(self.response_body) > 0 + ): self.json_data = json.loads(self.response_body) @@ -257,6 +260,9 @@ def _get_full_path(self, path: str): return append_to_path(self._url.path, path) def __enter__(self): + if self._url.hostname is None: + raise ValueError("The URL does not contain a hostname.") + factory = _connection_factory[self._url.scheme] self._conn = factory( self._url.hostname, @@ -266,7 +272,7 @@ def __enter__(self): ) return self - def __exit__(self, *args): + def __exit__(self, *args: object): if self._conn is not None: self._conn.close() self._conn = None @@ -276,33 +282,33 @@ def get( path: str, query_params: Optional[Mapping[str, JsonData]] = None, decode_response: bool = True, - ): + ) -> JsonData | HTTPResponse: return self.request("GET", path, query_params, decode_response=decode_response) def post( self, path: str, query_params: Optional[Mapping[str, JsonData]] = None, - body: str | bytes | BinaryIO | dict[str, Any] | list[Any] | None = None, - ): + body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, + ) -> JsonData | HTTPResponse: return self.request("POST", path, query_params, body) def patch( self, path: str, query_params: Optional[Mapping[str, JsonData]] = None, - body: str | bytes | BinaryIO | dict[str, Any] | list[Any] | None = None, - ): + body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, + ) -> JsonData | HTTPResponse: return self.request("PATCH", path, query_params, body) def put( self, path: str, query_params: Optional[Mapping[str, JsonData]] = None, - body: str | bytes | BinaryIO | dict[str, Any] | list[Any] | None = None, - headers: Optional[dict[str, str]] = None, + body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, + headers: Optional[Mapping[str, str]] = None, decode_response: bool = True, - ): + ) -> JsonData | HTTPResponse: if headers is None: headers = {} return self.request( @@ -313,9 +319,9 @@ def delete( self, path: str, query_params: Optional[Mapping[str, JsonData]] = None, - body: str | bytes | BinaryIO | dict[str, Any] | list[Any] | None = None, + body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, decode_response: bool = True, - ): + ) -> JsonData | HTTPResponse: return self.request("DELETE", path, query_params, body, decode_response=decode_response) def request( @@ -323,20 +329,20 @@ def request( method: str, path: str, query_params: Optional[Mapping[str, JsonData]] = None, - body: str | bytes | BinaryIO | dict[str, Any] | list[Any] | None = None, + body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, maximum_redirects: int = 5, decode_response: bool = True, - headers: Optional[dict[str, str]] = None, + headers: Optional[Mapping[str, str]] = None, ) -> JsonData | HTTPResponse: path = self._get_full_path(path) extra_headers = headers or {} - if isinstance(body, (dict, list)): + if isinstance(body, (Mapping, list)): body = json.dumps(body).encode("utf-8") extra_headers = {"Content-Type": "application/json; charset=utf-8"} extra_headers = {**extra_headers, **self.get_extra_headers(path, method, body)} return self._do_request(method, path, query_params, body, maximum_redirects, extra_headers, decode_response) - def get_extra_headers(self, url: str, method: str, body: str | bytes | BinaryIO | None) -> dict[str, str]: + def get_extra_headers(self, url: str, method: str, body: str | bytes | IO[bytes] | None) -> dict[str, str]: return {} def _do_request( @@ -344,9 +350,9 @@ def _do_request( method: str, path: str, query_params: Optional[Mapping[str, JsonData]], - body: str | bytes | BinaryIO | None, + body: str | bytes | IO[bytes] | None, maximum_redirects: int, - extra_headers: Optional[dict[str, str]] = None, + extra_headers: dict[str, str], decode_response: bool = True, ) -> JsonData | HTTPResponse: full_uri = path @@ -372,10 +378,13 @@ def _do_request( self.__enter__() local_connection = True + # At this point we know that self._conn is not None. + conn = cast(Union[http.HTTPConnection, http.HTTPSConnection], self._conn) + try: - self._conn.request(method, full_uri, body, headers) # type: ignore + conn.request(method, full_uri, body, headers) - response = self._conn.getresponse() # type: ignore + response = conn.getresponse() response_body = response.read() if decode_response: response_body = response_body.decode("utf-8").strip() @@ -397,6 +406,9 @@ def _do_request( location = response.getheader("Location") + if location is None: + raise http.CannotSendRequest("Redirect response missing Location header") + # Assume the redirect location will always be on the same domain. if location.startswith("http"): parsed_location = urlparse(location) diff --git a/rsconnect/json_web_token.py b/rsconnect/json_web_token.py index 4f735324..cfb564b7 100644 --- a/rsconnect/json_web_token.py +++ b/rsconnect/json_web_token.py @@ -14,6 +14,7 @@ from .exception import RSConnectException from .http_support import HTTPResponse, JsonData +from .models import BootstrapOutputDTO DEFAULT_ISSUER = "rsconnect-python" DEFAULT_AUDIENCE = "rsconnect" @@ -46,6 +47,9 @@ def read_secret_key(keypath: Optional[str]) -> bytes: except binascii.Error: raise RSConnectException("Unable to decode base64 data from environment variable: " + SECRET_KEY_ENV) + if keypath is None: + raise RSConnectException("Keypath must not be None.") + if not os.path.exists(keypath): raise RSConnectException("Keypath does not exist.") @@ -65,7 +69,7 @@ def validate_hs256_secret_key(key: bytes): raise RSConnectException("Secret key expected to be at least 32 bytes in length") -def parse_client_response(response: JsonData | HTTPResponse): +def parse_client_response(response: BootstrapOutputDTO | HTTPResponse) -> tuple[int, BootstrapOutputDTO | JsonData]: """ Helper to handle the response type from RSConnectClient, because it can have different types depending on the response @@ -91,15 +95,17 @@ def parse_client_response(response: JsonData | HTTPResponse): raise RSConnectException("Unrecognized response type: " + str(type(response))) -def produce_bootstrap_output(status: int, json_data: JsonData) -> dict[str, int | str]: +def produce_bootstrap_output(status: int, json_data: BootstrapOutputDTO | JsonData) -> dict[str, int | str]: """ Produces the expected programmatic output format from a request to the initial_admin endpoint """ # Parse the returned API key if one is provided api_key = "" - if json_data is not None and "api_key" in json_data: + if isinstance(json_data, dict) and "api_key" in json_data: api_key = json_data["api_key"] + if not isinstance(api_key, str): + raise RSConnectException("Connect returned a non-string value for api_key.") # Catch unexpected response states and error early if status == 200 and api_key == "": @@ -155,7 +161,7 @@ def new_token(self, custom_claims: dict[str, Any], exp: timedelta) -> str: for c in [standard_claims, custom_claims]: claims.update(c) - return jwt.encode(claims, self.secret, algorithm="HS256") + return jwt.encode(claims, self.secret, algorithm="HS256") # pyright: ignore[reportUnknownMemberType] # Uses a generic encoder to create JWTs with specific custom scopes / expiration times diff --git a/rsconnect/log.py b/rsconnect/log.py index d8a68fad..ada74333 100644 --- a/rsconnect/log.py +++ b/rsconnect/log.py @@ -8,7 +8,7 @@ import logging import sys from functools import partial, wraps -from typing import Any, Callable, Literal, Optional, TYPE_CHECKING, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Protocol, TypeVar if sys.version_info >= (3, 10): from typing import Concatenate, ParamSpec @@ -20,7 +20,6 @@ from collections.abc import MutableMapping import click -import six T = TypeVar("T") P = ParamSpec("P") @@ -61,7 +60,7 @@ def usesTime(self): """ return "asctime" in self.fmt_dict.values() - def formatMessage(self, record: logging.LogRecord): + def formatMessage(self, record: logging.LogRecord): # pyright: ignore[reportIncompatibleMethodOverride] """ Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. KeyError is raised if an unknown attribute is provided in the fmt_dict. @@ -95,7 +94,16 @@ def format(self, record: logging.LogRecord): return json.dumps(message_dict, default=str) -class RSLogger(logging.LoggerAdapter[logging.Logger]): +# This is a workaround for LoggerAdapter not being generic in Python<=3.10. +# See also: +# https://github.com/python/typeshed/issues/7855#issuecomment-1128857842 +if sys.version_info >= (3, 11): + _LoggerAdapter = logging.LoggerAdapter[logging.Logger] +else: + _LoggerAdapter = logging.LoggerAdapter + + +class RSLogger(_LoggerAdapter): def __init__(self): super(RSLogger, self).__init__(logging.getLogger("rsconnect"), {}) self._in_feedback = False @@ -122,7 +130,7 @@ def process(self, msg: str, kwargs: MutableMapping[str, Any]): msg, kwargs = super(RSLogger, self).process(msg, kwargs) if self._in_feedback and self.is_debugging(): if not self._have_feedback_output: - six.print_() + print() self._have_feedback_output = True msg = click.style(" %s" % msg, fg="green") return msg, kwargs diff --git a/rsconnect/main.py b/rsconnect/main.py index 4246b8fb..c03236e8 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -8,7 +8,7 @@ import traceback from functools import wraps from os.path import abspath, dirname, exists, isdir, join -from typing import Callable, ItemsView, Literal, Optional, Sequence, TypeVar, cast +from typing import Callable, ItemsView, Literal, Optional, Sequence, TypeVar import click @@ -43,7 +43,7 @@ get_content, search_content, ) -from .api import RSConnectClient, RSConnectExecutor, RSConnectServer, filter_out_server_info +from .api import RSConnectClient, RSConnectExecutor, RSConnectServer from .bundle import ( create_python_environment, default_title_from_manifest, @@ -220,14 +220,6 @@ def wrapper(*args: P.args, **kwargs: P.kwargs): return wrapper -def _passthrough(func: Callable[P, T]) -> Callable[P, T]: - @functools.wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs): - return func(*args, **kwargs) - - return wrapper - - def validate_env_vars(ctx: click.Context, param: click.Parameter, all_values: tuple[str, ...]) -> dict[str, str]: vars: dict[str, str] = {} @@ -560,7 +552,6 @@ def add( old_server = server_store.get_by_name(name) if token: - real_server: api.PositServer # This annotation seems to be necessary for mypy if server and ("rstudio.cloud" in server or "posit.cloud" in server): real_server = api.CloudServer(server, account, token, secret) else: @@ -659,7 +650,7 @@ def details( return with cli_feedback("Gathering details"): - server_details = ce.server_details + server_details = ce.server_details() connect_version = server_details["connect"] apis_allowed = server_details["python"]["api_enabled"] @@ -704,19 +695,18 @@ def remove( if name and server: raise RSConnectException("You must specify only one of -n/--name or -s/--server.") - if not (name or 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 RSConnectException('Nickname "%s" was not found.' % name) - else: # the user specified -s/--server + elif server: if server_store.remove_by_url(server): message = 'Removed URL "%s".' % server else: raise RSConnectException('URL "%s" was not found.' % server) + else: + raise RSConnectException("You must specify one of -n/--name or -s/--server.") if message: click.echo(message) @@ -751,6 +741,8 @@ def _get_names_to_check(file_or_directory: str) -> list[str]: @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) def info(file: str): with cli_feedback(""): + deployments = [] + app_store: AppStore | None = None for file_name in _get_names_to_check(file): app_store = AppStore(file_name) deployments = app_store.get_all() @@ -758,7 +750,7 @@ def info(file: str): if len(deployments) > 0: break - if len(deployments) > 0: + if len(deployments) > 0 and app_store is not None: click.echo("Loaded deployment information from %s" % abspath(app_store.get_path())) for deployment in deployments: @@ -911,7 +903,7 @@ def deploy_notebook( force_generate: bool, verbose: int, file: str, - extra_files: Sequence[str], + extra_files: tuple[str, ...], hide_all_input: bool, hide_tagged_input: bool, env_vars: dict[str, str], @@ -921,11 +913,12 @@ def deploy_notebook( env_management_r: Optional[bool], no_verify: bool = False, ): - kwargs = locals() set_verbosity(verbose) output_params(ctx, locals().items()) - kwargs["extra_files"] = extra_files = validate_extra_files(dirname(file), extra_files) + # TODO: This used to save a value in kwargs["extra_files"] which would get passed to + # the executor and stored there, but it looks like that value was never read. + extra_files_list = validate_extra_files(dirname(file), extra_files) app_mode = AppModes.JUPYTER_NOTEBOOK if not static else AppModes.STATIC base_dir = dirname(file) @@ -937,7 +930,20 @@ def deploy_notebook( if force_generate: _warn_on_ignored_requirements(base_dir, environment.filename) - ce = RSConnectExecutor(**kwargs) + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + insecure=insecure, + cacert=cacert, + server=server, + new=new, + app_id=app_id, + title=title, + disable_env_management=disable_env_management, + env_vars=env_vars, + ) + ce.validate_server().validate_app_mode(app_mode=app_mode) if app_mode == AppModes.STATIC: ce.make_bundle( @@ -955,7 +961,7 @@ def deploy_notebook( make_notebook_source_bundle, file, environment, - extra_files, + extra_files_list, hide_all_input, hide_tagged_input, image=image, @@ -1045,9 +1051,8 @@ def deploy_voila( cacert: Optional[str], multi_notebook: bool, no_verify: bool, - connect_server: Optional[api.RSConnectServer] = None, + connect_server: Optional[api.RSConnectServer] = None, # TODO: This appears to be unused ): - kwargs = locals() set_verbosity(verbose) output_params(ctx, locals().items()) app_mode = AppModes.JUPYTER_VOILA @@ -1056,7 +1061,22 @@ def deploy_voila( force_generate, python, ) - ce = RSConnectExecutor(**kwargs).validate_server().validate_app_mode(app_mode=app_mode) + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + insecure=insecure, + cacert=cacert, + server=server, + new=new, + app_id=app_id, + title=title, + disable_env_management=disable_env_management, + env_vars=env_vars, + ) + + ce.validate_server().validate_app_mode(app_mode=app_mode) ce.make_bundle( make_voila_bundle, path, @@ -1111,15 +1131,30 @@ def deploy_manifest( visibility: Optional[str], no_verify: bool, ): - kwargs = locals() set_verbosity(verbose) output_params(ctx, locals().items()) - file_name = kwargs["file"] = validate_manifest_file(file) + file_name = validate_manifest_file(file) app_mode = read_manifest_app_mode(file_name) - kwargs["title"] = title or default_title_from_manifest(file) + title = title or default_title_from_manifest(file) - ce = RSConnectExecutor(**kwargs) + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + insecure=insecure, + cacert=cacert, + account=account, + token=token, + secret=secret, + path=file_name, + server=server, + new=new, + app_id=app_id, + title=title, + visibility=visibility, + env_vars=env_vars, + ) ( ce.validate_server() .validate_app_mode(app_mode=app_mode) @@ -1214,7 +1249,6 @@ def deploy_quarto( env_management_r: bool, no_verify: bool, ): - kwargs = locals() set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1244,7 +1278,21 @@ def deploy_quarto( if force_generate: _warn_on_ignored_requirements(base_dir, environment.filename) - ce = RSConnectExecutor(**kwargs) + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + insecure=insecure, + cacert=cacert, + path=file_or_directory, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + disable_env_management=disable_env_management, + env_vars=env_vars, + ) ( ce.validate_server() .validate_app_mode(app_mode=AppModes.STATIC_QUARTO) @@ -1323,16 +1371,40 @@ def deploy_html( no_verify: bool, connect_server: Optional[api.RSConnectServer] = None, ): - kwargs = locals() set_verbosity(verbose) output_params(ctx, locals().items()) - ce = None if connect_server: - kwargs = filter_out_server_info(**kwargs) - ce = RSConnectExecutor.fromConnectServer(connect_server, **kwargs) + ce = RSConnectExecutor.fromConnectServer( + connect_server, + ctx=ctx, + account=account, + token=token, + secret=secret, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + env_vars=env_vars, + ) else: - ce = RSConnectExecutor(**kwargs) + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + insecure=insecure, + cacert=cacert, + account=account, + token=token, + secret=secret, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + env_vars=env_vars, + ) ( ce.validate_server() @@ -1455,18 +1527,6 @@ def deploy_app( if is_express_app(entrypoint + ".py", directory): entrypoint = "shiny.express.app:" + escape_to_var_name(entrypoint + ".py") - extra_args = dict( - directory=directory, - server=server, - exclude=exclude, - new=new, - app_id=app_id, - title=title, - visibility=visibility, - disable_env_management=disable_env_management, - env_vars=env_vars, - ) - ce = RSConnectExecutor( ctx=ctx, name=name, @@ -1476,7 +1536,15 @@ def deploy_app( account=account, token=token, secret=secret, - **extra_args, # type: ignore + path=directory, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + visibility=visibility, + disable_env_management=disable_env_management, + env_vars=env_vars, ) if isinstance(ce.client, RSConnectClient): @@ -1485,7 +1553,7 @@ def deploy_app( environment = fix_starlette_requirements( environment=environment, app_mode=app_mode, - connect_version_string=cast(str, ce.client.server_settings()["version"]), + connect_version_string=ce.client.server_settings()["version"], ) ce.validate_server() @@ -1733,7 +1801,6 @@ def write_manifest_voila( path, entrypoint, environment, - AppModes.JUPYTER_VOILA, extra_files, exclude, force_generate, diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 11716b83..4fc6fa6a 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -15,7 +15,7 @@ from io import BufferedWriter from os.path import abspath, basename, dirname, exists, join from threading import Lock -from typing import TYPE_CHECKING, Callable, Generic, Mapping, Optional, TypeVar +from typing import TYPE_CHECKING, Callable, Dict, Generic, Mapping, Optional, TypeVar from urllib.parse import urlparse # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, @@ -32,7 +32,7 @@ from .exception import RSConnectException from .log import logger -from .models import AppMode, AppModes, ContentItem +from .models import AppMode, AppModes, ContentItemV1, TaskStatusV0 T = TypeVar("T", bound=Mapping[str, object]) @@ -128,7 +128,7 @@ def _get_by_key(self, key: str, default: T | None = None) -> T | None: """ return self._data.get(key, default) - def _get_by_value_attr(self, attr: str, value: str) -> T | None: + def _get_by_value_attr(self, attr: str, value: T) -> T | None: """ Return a stored value by an attribute of its value. @@ -185,7 +185,7 @@ def _remove_by_key(self, key: str): return True return False - def _remove_by_value_attr(self, key_attr: str, attr: str, value: str): + def _remove_by_value_attr(self, key_attr: str, attr: str, value: T) -> bool: """ Remove a stored value by an attribute of its value. @@ -195,9 +195,9 @@ def _remove_by_value_attr(self, key_attr: str, attr: str, value: str): :param value: the value of the attribute to search for. :return: True if the associated value was removed. """ - value = self._get_by_value_attr(attr, value) - if value: - del self._data[value[key_attr]] + val = self._get_by_value_attr(attr, value) + if val: + del self._data[val[key_attr]] self.save() return True return False @@ -423,9 +423,8 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: def sha1(s: str): m = hashlib.sha1() - if hasattr(s, "encode"): - s = s.encode("utf-8") - m.update(s) + b = s.encode("utf-8") + m.update(b) return base64.urlsafe_b64encode(m.digest()).decode("utf-8").rstrip("=") @@ -545,11 +544,11 @@ def resolve(self, server: str, app_id: Optional[str], app_mode: Optional[AppMode DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build") -class ContentItemWithBuildState(ContentItem, TypedDict): +class ContentItemWithBuildState(ContentItemV1, TypedDict): rsconnect_build_status: str rsconnect_last_build_time: NotRequired[str] rsconnect_last_build_log: NotRequired[str | None] - rsconnect_build_task_result: NotRequired[dict[str, str]] + rsconnect_build_task_result: NotRequired[TaskStatusV0] class ContentBuildStoreData(TypedDict): @@ -557,7 +556,8 @@ class ContentBuildStoreData(TypedDict): rsconnect_content: dict[str, ContentItemWithBuildState] -class ContentBuildStore(DataStore[dict[str, object]]): +# Python<=3.8 needs `Dict`. After dropping 3.8 support it can be changed to `dict`. +class ContentBuildStore(DataStore[Dict[str, object]]): """ Defines a metadata store for information about content builds. @@ -647,7 +647,7 @@ def set_build_running(self, is_running: bool, defer_save: bool = False) -> None: if not defer_save: self.save() - def add_content_item(self, content: dict[str, object], defer_save: bool = False) -> None: + def add_content_item(self, content: ContentItemV1, defer_save: bool = False) -> None: """ Add an item to the tracked content store """ @@ -670,11 +670,14 @@ def add_content_item(self, content: dict[str, object], defer_save: bool = False) if not defer_save: self.save() - def get_content_item(self, guid: str) -> ContentItemWithBuildState | None: + def get_content_item(self, guid: str) -> ContentItemWithBuildState: """ Get a content item from the tracked content store by guid """ - return self._data.get("rsconnect_content", {}).get(guid) + item = self._data.get("rsconnect_content", {}).get(guid) + if item is None: + raise RSConnectException(f"Content item with guid {guid} not found.") + return item def _cleanup_content_log_dir(self, guid: str) -> None: """ @@ -722,7 +725,7 @@ def update_content_item_last_build_time(self, guid: str, defer_save: bool = Fals if not defer_save: self.save() - def update_content_item_last_build_log(self, guid: str, log_file: str, defer_save: bool = False) -> None: + def update_content_item_last_build_log(self, guid: str, log_file: str | None, defer_save: bool = False) -> None: """ Set the last_build_log filepath for a content build """ @@ -732,9 +735,7 @@ def update_content_item_last_build_log(self, guid: str, log_file: str, defer_sav if not defer_save: self.save() - def set_content_item_last_build_task_result( - self, guid: str, task: dict[str, str], defer_save: bool = False - ) -> None: + def set_content_item_last_build_task_result(self, guid: str, task: TaskStatusV0, defer_save: bool = False) -> None: """ Set the latest task_result for a content build """ diff --git a/rsconnect/models.py b/rsconnect/models.py index 231398aa..1e0a463a 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -8,11 +8,10 @@ import pathlib import re import sys -from typing import Callable, Literal, Optional, Sequence, cast +from typing import Callable, Literal, Optional, cast import click import semver -import six from click import ParamType from click.types import StringParamType @@ -208,11 +207,13 @@ def __init__(self, pattern: str): self._pattern = pattern[:-4] self.matches = self._match_with_starts_with else: + self._pattern_parts: list[str | re.Pattern[str]] + self._wildcard_index: int | None self._pattern_parts, self._wildcard_index = self._to_parts_list(pattern) self.matches = self._match_with_list_parts @staticmethod - def _to_parts_list(pattern: str): + def _to_parts_list(pattern: str) -> tuple[list[str | re.Pattern[str]], int | None]: """ Converts a glob expression into a list, with an entry for each directory level. Each entry will be either a string, in which case an equality @@ -225,16 +226,20 @@ def _to_parts_list(pattern: str): The index will be None if `**` is never found. """ # Incoming pattern is ALWAYS a Posix-style path. - parts: Sequence[str | re.Pattern[str]] = pattern.split("/") + parts_start = pattern.split("/") + parts_result: list[str | re.Pattern[str]] = [] depth_wildcard_index = None - for index, name in enumerate(parts): + for index, name in enumerate(parts_start): + value = name if name == "**": if depth_wildcard_index is not None: raise ValueError('Only one occurrence of the "**" pattern is allowed.') depth_wildcard_index = index elif any(ch in name for ch in "*?["): - parts[index] = re.compile(r"\A" + fnmatch.translate(name)) - return parts, depth_wildcard_index + value = re.compile(r"\A" + fnmatch.translate(name)) + parts_result.append(value) + + return parts_result, depth_wildcard_index def _match_with_starts_with(self, path: str | pathlib.PurePath): path = pathlib.PurePath(path).as_posix() @@ -247,9 +252,10 @@ def _match_with_list_parts(self, path: str | pathlib.PurePath): def items_match(i1: int, i2: int): if i2 >= len(parts): return False - if isinstance(self._pattern_parts[i1], six.string_types): + part1 = self._pattern_parts[i1] + if isinstance(part1, str): return self._pattern_parts[i1] == parts[i2] - return self._pattern_parts[i1].match(parts[i2]) is not None + return part1.match(parts[i2]) is not None wildcard_index = len(self._pattern_parts) if self._wildcard_index is None else self._wildcard_index @@ -304,7 +310,7 @@ def convert(self, value: str, param: Optional[click.Parameter], ctx: Optional[cl class ContentGuidWithBundle(object): - def __init__(self, guid: Optional[str] = None, bundle_id: Optional[str] = None): + def __init__(self, guid: str, bundle_id: Optional[str] = None): self.guid = guid self.bundle_id = bundle_id @@ -317,7 +323,7 @@ def __repr__(self): class ContentGuidWithBundleParamType(StrippedStringParamType): name = "ContentGuidWithBundle" - def convert( + def convert( # pyright: ignore[reportIncompatibleMethodOverride] self, value: str | ContentGuidWithBundle, param: Optional[click.Parameter], @@ -329,8 +335,7 @@ def convert( value = super(ContentGuidWithBundleParamType, self).convert(value, param, ctx) m = re.match(_content_guid_pattern, value) if m is not None: - guid_with_bundle = ContentGuidWithBundle() - guid_with_bundle.guid = m.group(1) + guid_with_bundle = ContentGuidWithBundle(m.group(1)) if len(m.groups()) == 2 and len(m.group(2)) > 0: try: int(m.group(2)) @@ -344,8 +349,58 @@ def convert( AppRole = Literal["owner", "editor", "viewer", "none"] -# From https://docs.posit.co/connect/api/#get-/v1/experimental/content/-guid- -class ContentItem(TypedDict): +# Also known as AppRecord in Connect. +class ContentItemV0(TypedDict): + id: int + guid: str + access_type: Literal["all", "logged_in", "acl"] + connection_timeout: int | None + read_timeout: int | None + init_timeout: int | None + idle_timeout: int | None + max_processes: int | None + min_processes: int | None + max_conns_per_process: int | None + load_factor: float | None + memory_request: float | None + memory_limit: int | None + cpu_request: float | None + cpu_limit: int | None + amd_gpu_limit: int | None + nvidia_gpu_limit: int | None + url: str + vanity_url: str + name: str + title: str | None + bundle_id: int | None + app_mode: AppModes.Modes + content_category: str + has_parameters: bool + created_time: str + last_deployed_time: str + build_status: int + cluster_name: str | None + image_name: str | None + default_image_name: str | None + service_account_name: str | None + r_version: str | None + py_version: str | None + quarto_version: str | None + r_environment_management: bool | None + default_r_environment_management: bool | None + py_environment_management: bool | None + default_py_environment_management: bool | None + run_as: str | None + run_as_current_user: bool + description: str + # Note: the next one is listed as "environment_json" in the AppRecord type, but + # in practice it comes in as "EnvironmentJson" from the API, so it's commented out + # here. + # environment_json: object + + +# Also known as V1 ContentOutputDTO in Connect (note: this is not V1 experimental). +class ContentItemV1(TypedDict): guid: str name: str title: str | None @@ -359,29 +414,29 @@ class ContentItem(TypedDict): min_processes: int | None max_conns_per_process: int | None load_factor: float | None - cpu_request: float | None - cpu_limit: int | None memory_request: float | None memory_limit: int | None - amd_gpu_limit: float | None - nvidia_gpu_limit: float | None + cpu_request: float | None + cpu_limit: float | None + amd_gpu_limit: int | None + nvidia_gpu_limit: int | None + service_account_name: str | None + default_image_name: str | None created_time: str last_deployed_time: str - bundle_id: str + bundle_id: str | None app_mode: AppModes.Modes content_category: str parameterized: bool cluster_name: str | None image_name: str | None - default_image_name: str | None - default_r_environment_management: bool | None - default_py_environment_management: bool | None - service_account_name: str | None r_version: str | None - r_environment_management: bool | None py_version: str | None - py_environment_management: bool | None quarto_version: str | None + r_environment_management: bool | None + default_r_environment_management: bool | None + py_environment_management: bool | None + default_py_environment_management: bool | None run_as: str | None run_as_current_user: bool owner_guid: str @@ -398,13 +453,13 @@ class ContentItem(TypedDict): class VersionSearchFilter(object): def __init__( self, - name: VersionProgramName | None = None, - comp: ComparisonOperator | None = None, - vers: str | None = None, + name: VersionProgramName, + comp: ComparisonOperator, + vers: str, ): - self.name: VersionProgramName = name - self.comp: ComparisonOperator = comp - self.vers: str = vers + self.name = name + self.comp = comp + self.vers = vers def __repr__(self): return "%s %s %s" % (self.name, self.comp, self.vers) @@ -432,9 +487,11 @@ def convert( if isinstance(value, str): m = re.match(_version_search_pattern, value) if m is not None and len(m.groups()) == 2: - version_search = VersionSearchFilter(name=self.key) - version_search.comp = cast(ComparisonOperator, m.group(1)) - version_search.vers = m.group(2) + version_search = VersionSearchFilter( + name=self.key, + comp=cast(ComparisonOperator, m.group(1)), + vers=m.group(2), + ) # default to == if no comparator was provided if not version_search.comp: @@ -444,7 +501,7 @@ def convert( self.fail("Failed to parse verison filter: %s is not a valid comparitor" % version_search.comp) try: - semver.parse(version_search.vers) + semver.parse(version_search.vers) # pyright: ignore[reportUnknownMemberType] except ValueError: self.fail("Failed to parse version info: %s" % version_search.vers) return version_search @@ -452,21 +509,101 @@ def convert( self.fail("Failed to parse version filter %s" % value) +class AppSearchResults(TypedDict): + total: int + applications: list[ContentItemV0] + count: int + continuation: int + + class TaskStatusResult(TypedDict): type: str data: object # Don't know the structure of this type yet -class TaskStatus(TypedDict): +class TaskStatusV0(TypedDict): id: str - # NOTE: The API docs say this should be "output" instead of "status". status: list[str] finished: bool code: int error: str - # Note: The API docs say this should be "last" instead of "last_status" last_status: int user_id: int - # NOTE: The API docs say this should always be a dict, but the actual response can - # be None. result: TaskStatusResult | None + + +# https://docs.posit.co/connect/api/#get-/v1/tasks/-id- +class TaskStatusV1(TypedDict): + id: str + output: list[str] + finished: bool + code: int + error: str + last: int + result: TaskStatusResult | None + + +class BootstrapOutputDTO(TypedDict): + api_key: str + + +# This not the complete specification of the server settings data structure, but it is +# sufficient for the purposes of this package. +class ServerSettings(TypedDict): + hostname: str + version: str + + +class PyInfo(TypedDict): + installations: list[PyInstallation] + api_enabled: bool + + +class PyInstallation(TypedDict): + version: str + cluster_name: str + image_name: str + + +class BuildOutputDTO(TypedDict): + task_id: str + + +class ListEntryOutputDTO(TypedDict): + language: str + version: str + image_name: str + + +class DeleteInputDTO(TypedDict): + language: str + version: str + image_name: str + dry_run: bool + + +class DeleteOutputDTO(TypedDict): + language: str + version: str + iamge_name: str + task_id: str | None + + +class ConfigureResult(TypedDict): + config_url: str + logs_url: str + + +class UserRecord(TypedDict): + email: str + username: str + first_name: str + last_name: str + password: str + created_time: str + updated_time: str + active_time: str | None + confirmed: bool + locked: bool + guid: str + preferences: dict[str, object] diff --git a/rsconnect/utils_package.py b/rsconnect/utils_package.py index b2505908..94ded49f 100644 --- a/rsconnect/utils_package.py +++ b/rsconnect/utils_package.py @@ -220,7 +220,7 @@ def fix_starlette_requirements( if not (app_mode == AppModes.PYTHON_SHINY and compare_semvers(connect_version_string, "2024.01.0") == -1): return environment - requirements_txt = cast(str, environment.contents) + requirements_txt = environment.contents reqs = parse_requirements_txt(requirements_txt) starlette_req = find_package_info("starlette", reqs) diff --git a/scripts/runtests b/scripts/runtests index 73a4cfae..3875e411 100755 --- a/scripts/runtests +++ b/scripts/runtests @@ -4,4 +4,4 @@ set -o pipefail set -o xtrace : "${PYTEST_ARGS:=-vv --cov=rsconnect --cov-report=term --cov-report=html --cov-report=xml}" -pytest ${PYTEST_ARGS} --mypy ./tests/ +pytest ${PYTEST_ARGS} ./tests/ diff --git a/tests/test_actions.py b/tests/test_actions.py index 4e9c6953..aca947cf 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,28 +1,10 @@ import os - -try: - import typing -except ImportError: - typing = None - -from os.path import join from unittest import TestCase -from rsconnect.actions import ( - _verify_server, - create_api_deployment_bundle, - create_notebook_deployment_bundle, - deploy_dash_app, - deploy_python_api, - deploy_streamlit_app, - deploy_bokeh_app, -) +from rsconnect.actions import _verify_server from rsconnect.api import RSConnectServer -from rsconnect.environment import MakeEnvironment from rsconnect.exception import RSConnectException -from .utils import get_api_path, get_dir - class TestActions(TestCase): @staticmethod @@ -41,37 +23,3 @@ def fake_cap(details): def fake_cap_with_doc(details): """A docstring.""" return False - - def test_deploy_python_api_validates(self): - directory = get_api_path("flask") - server = RSConnectServer("https://www.bogus.com", "bogus") - with self.assertRaises(RSConnectException): - deploy_python_api(server, directory, [], [], "bogus", False, None, None, None, False, False, None, None) - - def test_deploy_dash_app_docs(self): - self.assertTrue("Dash app" in deploy_dash_app.__doc__) - - def test_deploy_streamlit_app_docs(self): - self.assertTrue("Streamlit app" in deploy_streamlit_app.__doc__) - - def test_deploy_bokeh_app_docs(self): - self.assertTrue("Bokeh app" in deploy_bokeh_app.__doc__) - - def test_create_notebook_deployment_bundle_validates(self): - file_name = get_dir(join("pip1", "requirements.txt")) - with self.assertRaises(RSConnectException): - create_notebook_deployment_bundle( - file_name, [], None, None, None, True, hide_all_input=False, hide_tagged_input=False, image=None - ) - file_name = get_dir(join("pip1", "dummy.ipynb")) - with self.assertRaises(RSConnectException): - create_notebook_deployment_bundle( - file_name, ["bogus"], None, None, None, True, hide_all_input=False, hide_tagged_input=False, image=None - ) - - def test_create_api_deployment_bundle_validates(self): - directory = get_api_path("flask") - with self.assertRaises(RSConnectException): - create_api_deployment_bundle(directory, [], [], "bogus:bogus:bogus", None, None, None, None) - with self.assertRaises(RSConnectException): - create_api_deployment_bundle(directory, ["bogus"], [], "app:app", MakeEnvironment(), None, True, None) diff --git a/tests/test_api.py b/tests/test_api.py index 6a7aa3c9..fc2143f4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,30 +1,26 @@ -from unittest import TestCase -from unittest.mock import Mock, patch, call +import io import json - -import httpretty import sys -import io +from unittest import TestCase +from unittest.mock import Mock, call, patch +import httpretty import pytest -from rsconnect.exception import RSConnectException, DeploymentFailedException -from rsconnect.models import AppModes -from .utils import ( - require_api_key, - require_connect, -) from rsconnect.api import ( + CloudServer, + CloudService, + PositClient, RSConnectClient, RSConnectExecutor, RSConnectServer, - _to_server_check_list, - CloudService, - PositClient, - CloudServer, ShinyappsServer, ShinyappsService, ) +from rsconnect.exception import DeploymentFailedException, RSConnectException +from rsconnect.models import AppModes + +from .utils import require_api_key, require_connect class TestAPI(TestCase): @@ -54,19 +50,6 @@ 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() api_key = require_api_key() @@ -142,7 +125,7 @@ def test_executor_delete_runtime_cache_dry_run(self): self.assertEqual(output_lines[0], "Dry run finished") # Result expectations - self.assertDictEqual(mocked_output, ce.state["result"]) + self.assertDictEqual(mocked_output, ce.result) # RSConnectExecutor.delete_runtime_cache() wet run returns expected request # RSConnectExecutor.delete_runtime_cache() wet run prints expected messages @@ -192,7 +175,7 @@ def test_executor_delete_runtime_cache_wet_run(self): # self.assertEqual(output_lines[0], "Cache deletion finished") # Result expectations - self.assertDictEqual(mocked_task_status, ce.state["task_status"]) + self.assertDictEqual(mocked_task_status, ce.task_status) # RSConnectExecutor.delete_runtime_cache() raises the correct error @httpretty.activate(verbose=True, allow_net_connect=False) @@ -215,9 +198,8 @@ class RSConnectClientTestCase(TestCase): def test_deploy_existing_application_with_failure(self): with patch.object(RSConnectClient, "__init__", lambda _, server, cookies, timeout: None): client = RSConnectClient(Mock(), Mock(), Mock()) - client.app_get = Mock(return_value=Mock()) + client.app_get = Mock(side_effect=RSConnectException("")) client._server = Mock(spec=RSConnectServer) - client._server.handle_bad_response = Mock(side_effect=RSConnectException("")) app_id = Mock() with self.assertRaises(RSConnectException): client.deploy(app_id, app_name=None, app_title=None, title_is_default=None, tarball=None) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 89de8e29..3ffb9ea9 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -1,49 +1,49 @@ # -*- coding: utf-8 -*- import json import os -import pytest import subprocess import sys import tarfile import tempfile +from os.path import abspath, basename, dirname, join from pathlib import Path +from unittest import TestCase, mock -from os.path import dirname, join, basename, abspath -from unittest import mock, TestCase +import pytest import rsconnect.bundle from rsconnect.bundle import ( + Manifest, _default_title, _default_title_from_manifest, - _validate_title, create_html_manifest, create_python_environment, + create_voila_manifest, get_python_env_info, + guess_deploy_dir, inspect_environment, + keep_manifest_specified_file, list_files, make_api_bundle, make_api_manifest, make_html_bundle, + make_html_manifest, make_manifest_bundle, make_notebook_html_bundle, make_notebook_source_bundle, + make_quarto_manifest, make_quarto_source_bundle, - keep_manifest_specified_file, + make_source_manifest, make_voila_bundle, to_bytes, - make_source_manifest, - make_quarto_manifest, - make_html_manifest, validate_entry_point, validate_extra_files, which_python, - guess_deploy_dir, - Manifest, - create_voila_manifest, ) -from rsconnect.environment import MakeEnvironment, detect_environment, Environment +from rsconnect.environment import Environment, MakeEnvironment, detect_environment from rsconnect.exception import RSConnectException from rsconnect.models import AppModes + from .utils import get_dir, get_manifest_path @@ -636,6 +636,7 @@ def do_test_html_bundle(self, directory): "appmode": "static", "primary_html": "dummy.html", }, + "files": {}, }, ) finally: @@ -1065,6 +1066,7 @@ def test_make_html_manifest(self): "appmode": "static", "primary_html": "abc.html", }, + "files": {}, }, ) @@ -1082,6 +1084,7 @@ def test_make_html_manifest(self): "environment": { "image": "rstudio/connect:bionic", }, + "files": {}, }, ) @@ -1101,6 +1104,7 @@ def test_make_html_manifest(self): "python": False, } }, + "files": {}, }, ) @@ -1120,6 +1124,7 @@ def test_make_html_manifest(self): "r": False, } }, + "files": {}, }, ) @@ -1143,6 +1148,7 @@ def test_make_html_manifest(self): "r": False, }, }, + "files": {}, }, ) @@ -1163,16 +1169,6 @@ def test_validate_extra_files(self): ["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): # Simple cases for case in ["app", "application", "main", "api", "app-example", "app_example", "example-app", "example_app"]: @@ -1249,10 +1245,14 @@ def test_inspect_environment(self): False, sys.executable, MakeEnvironment( + contents=None, filename="requirements.txt", locale="en_US.UTF-8", package_manager="pip", + pip=None, + python=None, source="pip_freeze", + error=None, ), id="basic", ), @@ -1262,10 +1262,14 @@ def test_inspect_environment(self): False, sys.executable, MakeEnvironment( + contents=None, filename="requirements.txt", locale="en_US.UTF-8", package_manager="pip", + pip=None, + python=None, source="pip_freeze", + error=None, ), id="which_python", ), @@ -1274,7 +1278,7 @@ def test_inspect_environment(self): "argh.py", False, "unused", - MakeEnvironment(error="Could not even do things"), + MakeEnvironment(None, None, None, None, None, None, None, error="Could not even do things"), id="exploding", ), ], @@ -1353,16 +1357,10 @@ def test_is_not_executable(self): class Test_guess_deploy_dir(TestCase): def test_guess_deploy_dir(self): - with self.assertRaises(RSConnectException): - guess_deploy_dir(None, None) - with self.assertRaises(RSConnectException): - guess_deploy_dir(None, bqplot_dir) with self.assertRaises(RSConnectException): guess_deploy_dir(bqplot_dir, bqplot_dir) with self.assertRaises(RSConnectException): guess_deploy_dir(nonexistent_dir, None) - with self.assertRaises(RSConnectException): - guess_deploy_dir(None, nonexistent_file) with self.assertRaises(RSConnectException): guess_deploy_dir(nonexistent_dir, nonexistent_file) self.assertEqual(abspath(bqplot_dir), guess_deploy_dir(bqplot_dir, None)) @@ -1448,7 +1446,6 @@ def test_create_voila_manifest_1(path, entrypoint): path, entrypoint, environment, - app_mode=AppModes.JUPYTER_VOILA, extra_files=None, excludes=None, force_generate=True, @@ -1460,7 +1457,6 @@ def test_create_voila_manifest_1(path, entrypoint): path, entrypoint, environment, - app_mode=AppModes.JUPYTER_VOILA, extra_files=None, excludes=None, force_generate=True, @@ -1519,7 +1515,6 @@ def test_create_voila_manifest_2(path, entrypoint): path, entrypoint, environment, - app_mode=AppModes.JUPYTER_VOILA, extra_files=None, excludes=None, force_generate=True, @@ -1568,7 +1563,6 @@ def test_create_voila_manifest_extra(): dashboard_ipynb, None, environment, - app_mode=AppModes.JUPYTER_VOILA, extra_files=[dashboard_extra_ipynb], excludes=None, force_generate=True, @@ -1660,7 +1654,6 @@ def test_create_voila_manifest_multi_notebook(path, entrypoint): path, entrypoint, environment, - app_mode=AppModes.JUPYTER_VOILA, extra_files=None, excludes=None, force_generate=True, @@ -1672,7 +1665,6 @@ def test_create_voila_manifest_multi_notebook(path, entrypoint): path, entrypoint, environment, - app_mode=AppModes.JUPYTER_VOILA, extra_files=None, excludes=None, force_generate=True, @@ -2082,6 +2074,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( single_file_index_file, None, + extra_files=tuple(), + excludes=tuple(), ) assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) @@ -2101,6 +2095,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( single_file_index_file, None, + extra_files=tuple(), + excludes=tuple(), image="rstudio/connect:bionic", env_management_py=False, env_management_r=False, @@ -2119,6 +2115,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( single_file_index_file, None, + extra_files=tuple(), + excludes=tuple(), image="rstudio/connect:bionic", env_management_py=None, env_management_r=None, @@ -2139,6 +2137,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( single_file_index_file, None, + extra_files=tuple(), + excludes=tuple(), env_management_py=False, ) assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) @@ -2157,6 +2157,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( single_file_index_file, None, + extra_files=tuple(), + excludes=tuple(), env_management_r=False, ) assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) @@ -2174,12 +2176,16 @@ def test_create_html_manifest(): manifest = create_html_manifest( single_file_index_dir, None, + extra_files=tuple(), + excludes=tuple(), ) assert single_file_index_dir_ans == json.loads(manifest.flattened_copy.json) manifest = create_html_manifest( single_file_index_dir, single_file_index_file, + extra_files=tuple(), + excludes=tuple(), ) assert single_file_index_dir_ans == json.loads(manifest.flattened_copy.json) @@ -2192,6 +2198,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( multi_file_index_file, None, + extra_files=tuple(), + excludes=tuple(), ) assert multi_file_index_file_ans == json.loads(manifest.flattened_copy.json) @@ -2207,12 +2215,16 @@ def test_create_html_manifest(): manifest = create_html_manifest( multi_file_index_dir, None, + extra_files=tuple(), + excludes=tuple(), ) assert multi_file_index_dir_ans == json.loads(manifest.flattened_copy.json) manifest = create_html_manifest( multi_file_index_dir, multi_file_index_file, + extra_files=tuple(), + excludes=tuple(), ) assert multi_file_index_dir_ans == json.loads(manifest.flattened_copy.json) @@ -2225,6 +2237,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( multi_file_nonindex_fileb, None, + extra_files=tuple(), + excludes=tuple(), ) assert multi_file_nonindex_file_ans == json.loads(manifest.flattened_copy.json) @@ -2240,6 +2254,8 @@ def test_create_html_manifest(): manifest = create_html_manifest( multi_file_nonindex_dir, multi_file_nonindex_fileb, + extra_files=tuple(), + excludes=tuple(), ) assert multi_file_nonindex_dir_and_file_ans == json.loads(manifest.flattened_copy.json) @@ -2255,6 +2271,7 @@ def test_create_html_manifest(): multi_file_nonindex_fileb, None, extra_files=[multi_file_nonindex_filea], + excludes=tuple(), ) assert multi_file_nonindex_file_extras_ans == json.loads(manifest.flattened_copy.json) @@ -2271,6 +2288,7 @@ def test_create_html_manifest(): multi_file_index_dir, None, extra_files=[multi_file_index_file2], + excludes=tuple(), ) assert multi_file_index_dir_extras_ans == json.loads(manifest.flattened_copy.json) @@ -2293,8 +2311,8 @@ def test_make_html_bundle(): with make_html_bundle( single_file_index_file, None, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2315,8 +2333,8 @@ def test_make_html_bundle(): with make_html_bundle( single_file_index_dir, None, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2330,8 +2348,8 @@ def test_make_html_bundle(): with make_html_bundle( single_file_index_dir, single_file_index_file, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2356,8 +2374,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_index_file, None, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2377,8 +2395,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_index_dir, None, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2391,8 +2409,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_index_dir, multi_file_index_file, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2410,8 +2428,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_nonindex_fileb, None, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2431,8 +2449,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_nonindex_dir, multi_file_nonindex_fileb, - None, - None, + extra_files=tuple(), + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2455,8 +2473,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_nonindex_fileb, None, - [multi_file_nonindex_filea], - None, + extra_files=[multi_file_nonindex_filea], + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ @@ -2480,8 +2498,8 @@ def test_make_html_bundle(): with make_html_bundle( multi_file_index_dir, None, - [multi_file_index_file2], - None, + extra_files=[multi_file_index_file2], + excludes=tuple(), ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) assert names == [ diff --git a/tests/test_environment.py b/tests/test_environment.py index 5ef4f8a3..c168153e 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -1,15 +1,15 @@ import re import sys - from unittest import TestCase from rsconnect.environment import ( MakeEnvironment, detect_environment, + filter_pip_freeze_output, get_default_locale, get_python_version, - filter_pip_freeze_output, ) + from .utils import get_dir version_re = re.compile(r"\d+\.\d+(\.\d+)?") @@ -22,7 +22,7 @@ def python_version(): def test_get_python_version(self): self.assertEqual( - get_python_version(MakeEnvironment(package_manager="pip")), + get_python_version(), self.python_version(), ) diff --git a/tests/test_main.py b/tests/test_main.py index 0f87752c..499cdbf5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -4,27 +4,26 @@ from os.path import join from unittest import TestCase - +import click import httpretty import pytest -import click from click.testing import CliRunner +from rsconnect import VERSION from rsconnect.json_web_token import SECRET_KEY_ENV +from rsconnect.main import cli, env_management_callback from .utils import ( apply_common_args, - optional_ca_data, - optional_target, + get_api_path, get_dir, get_manifest_path, - get_api_path, + has_jwt_structure, + optional_ca_data, + optional_target, require_api_key, require_connect, - has_jwt_structure, ) -from rsconnect.main import cli, env_management_callback -from rsconnect import VERSION def _error_to_response(error): @@ -113,6 +112,7 @@ def test_deploy_manifest_shinyapps(self): httpretty.GET, "https://api.shinyapps.io/v1/users/me", body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( @@ -287,6 +287,7 @@ def test_redeploy_manifest_shinyapps(self): httpretty.GET, "https://api.shinyapps.io/v1/users/me", body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, status=200, ) httpretty.register_uri( @@ -329,6 +330,7 @@ def post_application_property_callback(request, uri, response_headers): httpretty.PUT, "https://api.shinyapps.io/v1/applications/8442/properties/application.visibility", body=post_application_property_callback, + adding_headers={"Content-Type": "application/json"}, status=200, ) @@ -459,6 +461,7 @@ def test_deploy_manifest_cloud(self, project_application_id, project_id): httpretty.GET, "https://api.posit.cloud/v1/users/me", body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, status=200, ) if project_application_id: @@ -664,6 +667,7 @@ def test_deploy_static_cloud(self, command, arg): httpretty.GET, "https://api.posit.cloud/v1/users/me", body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, status=200, ) @@ -840,7 +844,11 @@ def test_add_shinyapps(self): original_server_value = os.environ.pop("CONNECT_SERVER", None) try: httpretty.register_uri( - httpretty.GET, "https://api.shinyapps.io/v1/users/me", body='{"id": 1000}', status=200 + httpretty.GET, + "https://api.shinyapps.io/v1/users/me", + body='{"id": 1000}', + adding_headers={"Content-Type": "application/json"}, + status=200, ) runner = CliRunner() @@ -873,7 +881,11 @@ def test_add_cloud(self): original_server_value = os.environ.pop("CONNECT_SERVER", None) try: httpretty.register_uri( - httpretty.GET, "https://api.posit.cloud/v1/users/me", body='{"id": 1000}', status=200 + httpretty.GET, + "https://api.posit.cloud/v1/users/me", + body='{"id": 1000}', + adding_headers={"Content-Type": "application/json"}, + status=200, ) runner = CliRunner() diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 49c37d68..6b1e5342 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,12 +1,17 @@ import shutil import tempfile - -from unittest import TestCase from os.path import exists, join +from unittest import TestCase from rsconnect.api import RSConnectServer +from rsconnect.exception import RSConnectException +from rsconnect.metadata import ( + AppStore, + ContentBuildStore, + ServerStore, + _normalize_server_url, +) from rsconnect.models import BuildStatus -from rsconnect.metadata import AppStore, ServerStore, ContentBuildStore, _normalize_server_url class TestServerMetadata(TestCase): @@ -403,8 +408,10 @@ def test_add_content_item(self): def test_get_content_item(self): self.assertIsNotNone(self.build_store.get_content_item("015143da-b75f-407c-81b1-99c4a724341e")) - self.assertIsNone(self.build_store.get_content_item("not real")) - self.assertIsNone(self.build_store.get_content_item(None)) + with self.assertRaises(RSConnectException): + self.build_store.get_content_item("not real") + with self.assertRaises(RSConnectException): + self.build_store.get_content_item(None) def test_remove_content_item(self): guid = "015143da-b75f-407c-81b1-99c4a724341e"