Skip to content

Commit

Permalink
Merge pull request #545 from rstudio/shiny-starlette-warning
Browse files Browse the repository at this point in the history
Add workaround for starlette version incompatibility with Connect<2024.01.0
  • Loading branch information
wch authored Feb 29, 2024
2 parents 34c77ea + dbd10b1 commit 18bfc5a
Show file tree
Hide file tree
Showing 10 changed files with 557 additions and 142 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Pending Next Release

### Changed
- When deploying Shiny for Python applications on servers using a version of
Connect prior to 2024.01.0, there is an incompatibility with
`starlette>=0.35.0`. When deploying to these servers, the starlette version
is now automatically set to `starlette<0.35.0`.

## [1.22.0] - 2024-01-23

### Added
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,6 @@ write_to = "rsconnect/version.py"

[tool.pytest.ini_options]
markers = ["vetiver: tests for vetiver"]

[tool.pyright]
typeCheckingMode = "strict"
74 changes: 40 additions & 34 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""
Posit Connect API client and utility functions
"""

from __future__ import annotations

import binascii
import os
from os.path import abspath, dirname
import time
from typing import IO, Callable
from typing import BinaryIO, Callable, Optional
import base64
import datetime
import hashlib
Expand All @@ -31,6 +34,9 @@
from .bundle import _default_title, fake_module_file_from_directory
from .timeouts import get_task_timeout, get_task_timeout_help_message

if typing.TYPE_CHECKING:
import logging


class AbstractRemoteServer:
def __init__(self, url: str, remote_name: str):
Expand Down Expand Up @@ -390,20 +396,20 @@ def output_task_log(task_status, last_status, log_callback):
class RSConnectExecutor:
def __init__(
self,
ctx: click.Context = None,
name: str = None,
url: str = None,
api_key: str = None,
ctx: Optional[click.Context] = None,
name: Optional[str] = None,
url: Optional[str] = None,
api_key: Optional[str] = None,
insecure: bool = False,
cacert: str = None,
ca_data: str = None,
cookies=None,
account=None,
token: str = None,
secret: str = None,
cacert: Optional[str] = None,
ca_data: Optional[str] = None,
cookies: Optional[CookieJar] = None,
account: Optional[str] = None,
token: Optional[str] = None,
secret: Optional[str] = None,
timeout: int = 30,
logger=console_logger,
**kwargs
logger: logging.Logger = console_logger,
**kwargs: typing.Any,
) -> None:
self.reset()
self._d = kwargs
Expand Down Expand Up @@ -470,16 +476,16 @@ def output_overlap_details(self, cli_param, previous):

def setup_remote_server(
self,
ctx: click.Context,
name: str = None,
url: str = None,
api_key: str = None,
ctx: Optional[click.Context],
name: Optional[str] = None,
url: Optional[str] = None,
api_key: Optional[str] = None,
insecure: bool = False,
cacert: str = None,
ca_data: str = None,
account_name: str = None,
token: str = None,
secret: str = None,
cacert: Optional[str] = None,
ca_data: Optional[str | bytes] = None,
account_name: Optional[str] = None,
token: Optional[str] = None,
secret: Optional[str] = None,
):
validation.validate_connection_options(
ctx=ctx,
Expand Down Expand Up @@ -661,7 +667,7 @@ def validate_rstudio_server(
raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc))

@cls_logged("Making bundle ...")
def make_bundle(self, func: Callable, *args, **kwargs):
def make_bundle(self, func: Callable[..., object], *args: object, **kwargs: object):
path = (
self.get("path", **kwargs)
or self.get("file", **kwargs)
Expand Down Expand Up @@ -710,20 +716,20 @@ def upload_rstudio_bundle(self, prepare_deploy_result, bundle_size: int, content
@cls_logged("Deploying bundle ...")
def deploy_bundle(
self,
app_id: typing.Union[str, int] = None,
deployment_name: str = None,
title: str = None,
app_id: Optional[str | int] = None,
deployment_name: Optional[str] = None,
title: Optional[str] = None,
title_is_default: bool = False,
bundle: IO = None,
env_vars=None,
app_mode=None,
visibility=None,
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 self.get("bundle")
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")
Expand Down Expand Up @@ -817,7 +823,7 @@ def emit_task_log(
return self

@cls_logged("Saving deployed information...")
def save_deployed_info(self, *args, **kwargs):
def save_deployed_info(self, *args: object, **kwargs: object):
app_store = self.get("app_store", *args, **kwargs)
path = (
self.get("path", **kwargs)
Expand All @@ -841,14 +847,14 @@ def save_deployed_info(self, *args, **kwargs):
return self

@cls_logged("Verifying deployed content...")
def verify_deployment(self, *args, **kwargs):
def verify_deployment(self, *args: object, **kwargs: object):
if isinstance(self.remote_server, RSConnectServer):
deployed_info = self.get("deployed_info", *args, **kwargs)
app_guid = deployed_info["app_guid"]
self.client.app_access(app_guid)

@cls_logged("Validating app mode...")
def validate_app_mode(self, *args, **kwargs):
def validate_app_mode(self, *args: object, **kwargs: object):
path = (
self.get("path", **kwargs)
or self.get("file", **kwargs)
Expand Down
78 changes: 37 additions & 41 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Manifest generation and bundling utilities
"""

from __future__ import annotations

import hashlib
import io
import json
Expand All @@ -11,21 +13,16 @@
import sys
import tarfile
import tempfile
import typing
import re
from pprint import pformat
from collections import defaultdict
from mimetypes import guess_type
from pathlib import Path
from copy import deepcopy
from typing import List
from typing import Optional, Any
import click


try:
import typing
except ImportError:
typing = None

from os.path import basename, dirname, exists, isdir, join, relpath, splitext, isfile, abspath

from .log import logger, VERBOSE
Expand Down Expand Up @@ -302,14 +299,14 @@ def make_source_manifest(
app_mode: AppMode,
environment: Environment,
entrypoint: str,
quarto_inspection: typing.Dict[str, typing.Any],
image: str = None,
env_management_py: bool = None,
env_management_r: bool = None,
) -> typing.Dict[str, typing.Any]:
manifest = {
quarto_inspection: Optional[dict[str, Any]],
image: Optional[str] = None,
env_management_py: Optional[bool] = None,
env_management_r: Optional[bool] = None,
) -> dict[str, Any]:
manifest: dict[str, Any] = {
"version": 1,
} # type: typing.Dict[str, typing.Any]
}

# When adding locale, add it early so it is ordered immediately after
# version.
Expand Down Expand Up @@ -820,7 +817,7 @@ def create_glob_set(directory, excludes):
return GlobSet(work)


def is_environment_dir(directory):
def is_environment_dir(directory: str | Path):
"""Detect whether `directory` is a virtualenv"""

# A virtualenv will have Python at ./bin/python
Expand All @@ -847,11 +844,11 @@ def make_api_manifest(
entry_point: str,
app_mode: AppMode,
environment: Environment,
extra_files: typing.List[str],
excludes: typing.List[str],
image: str = None,
env_management_py: bool = None,
env_management_r: bool = None,
extra_files: list[str],
excludes: list[str],
image: Optional[str] = None,
env_management_py: Optional[bool] = None,
env_management_r: Optional[bool] = None,
) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]:
"""
Makes a manifest for an API.
Expand Down Expand Up @@ -1063,7 +1060,7 @@ def infer_entrypoint(path, mimetype):
return candidates.pop() if len(candidates) == 1 else None


def infer_entrypoint_candidates(path, mimetype) -> List:
def infer_entrypoint_candidates(path, mimetype) -> list[str]:
if not path:
return []
if isfile(path):
Expand Down Expand Up @@ -1384,7 +1381,7 @@ def validate_file_is_notebook(file_name):
raise RSConnectException("A Jupyter notebook (.ipynb) file is required here.")


def validate_extra_files(directory, extra_files, use_abspath=False):
def validate_extra_files(directory: str, extra_files: typing.Sequence[str], use_abspath: bool = False) -> 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
Expand All @@ -1394,7 +1391,7 @@ def validate_extra_files(directory, extra_files, use_abspath=False):
:param extra_files: the list of extra files to qualify and validate.
:return: the extra files qualified by the directory.
"""
result = []
result: list[str] = []
if extra_files:
for extra in extra_files:
extra_file = relpath(extra, directory)
Expand Down Expand Up @@ -1428,7 +1425,7 @@ def validate_manifest_file(file_or_directory):
re_app_suffix = re.compile(r".+[-_]app\.py$")


def get_default_entrypoint(directory):
def get_default_entrypoint(directory: str):
candidates = ["app", "application", "main", "api"]
files = set(os.listdir(directory))

Expand All @@ -1451,7 +1448,7 @@ def get_default_entrypoint(directory):
raise RSConnectException(f"Could not determine default entrypoint file in directory '{directory}'")


def validate_entry_point(entry_point, directory):
def validate_entry_point(entry_point: str | None, directory: 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
Expand Down Expand Up @@ -1480,7 +1477,7 @@ def _warn_on_ignored_entrypoint(entrypoint):
)


def _warn_on_ignored_manifest(directory):
def _warn_on_ignored_manifest(directory: str):
"""
Checks for the existence of a file called manifest.json in the given directory.
If it's there, a warning noting that it will be ignored will be printed.
Expand All @@ -1494,7 +1491,7 @@ def _warn_on_ignored_manifest(directory):
)


def _warn_if_no_requirements_file(directory):
def _warn_if_no_requirements_file(directory: str):
"""
Checks for the existence of a file called requirements.txt in the given directory.
If it's not there, a warning will be printed.
Expand All @@ -1509,7 +1506,7 @@ def _warn_if_no_requirements_file(directory):
)


def _warn_if_environment_directory(directory):
def _warn_if_environment_directory(directory: str | Path):
"""
Issue a warning if the deployment directory is itself a virtualenv (yikes!).
Expand All @@ -1523,7 +1520,7 @@ def _warn_if_environment_directory(directory):
)


def _warn_on_ignored_requirements(directory, requirements_file_name):
def _warn_on_ignored_requirements(directory: str, requirements_file_name: str):
"""
Checks for the existence of a file called manifest.json in the given directory.
If it's there, a warning noting that it will be ignored will be printed.
Expand Down Expand Up @@ -1551,7 +1548,7 @@ def fake_module_file_from_directory(directory: str):
return join(directory, app_name + ".py")


def which_python(python: typing.Optional[str] = None):
def which_python(python: Optional[str] = None):
"""Determines which Python executable to use.
If the :param python: is provided, then validation is performed to check if the path is an executable file. If
Expand All @@ -1572,12 +1569,11 @@ def which_python(python: typing.Optional[str] = None):


def inspect_environment(
python, # type: str
directory, # type: str
force_generate=False, # type: bool
check_output=subprocess.check_output, # type: typing.Callable
):
# type: (...) -> Environment
python: str,
directory: str,
force_generate: bool = False,
check_output: typing.Callable = subprocess.check_output,
) -> Environment:
"""Run the environment inspector using the specified python binary.
Returns a dictionary of information about the environment,
Expand All @@ -1595,10 +1591,10 @@ def inspect_environment(
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
return MakeEnvironment(**json.loads(environment_json))


def get_python_env_info(file_name, python, force_generate=False):
def get_python_env_info(file_name: str, python: str | None, force_generate=False):
"""
Gathers the python and environment information relating to the specified file
with an eye to deploy it.
Expand Down Expand Up @@ -2068,10 +2064,10 @@ def write_manifest_json(manifest_path, manifest):


def create_python_environment(
directory: str = None,
directory: str,
force_generate: bool = False,
python: str = None,
):
python: Optional[str] = None,
) -> Environment:
module_file = fake_module_file_from_directory(directory)

# click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url))
Expand Down
Loading

0 comments on commit 18bfc5a

Please sign in to comment.