Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add workaround for starlette version incompatibility with Connect<2024.01.0 #545

Merged
merged 23 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
wch marked this conversation as resolved.
Show resolved Hide resolved
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
wch marked this conversation as resolved.
Show resolved Hide resolved

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,
wch marked this conversation as resolved.
Show resolved Hide resolved
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):
wch marked this conversation as resolved.
Show resolved Hide resolved
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,
wch marked this conversation as resolved.
Show resolved Hide resolved
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
76 changes: 36 additions & 40 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 @@ -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):
wch marked this conversation as resolved.
Show resolved Hide resolved
"""
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
4 changes: 3 additions & 1 deletion rsconnect/certificates.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

from pathlib import Path

BINARY_ENCODED_FILETYPES = [".cer", ".der"]
TEXT_ENCODED_FILETYPES = [".ca-bundle", ".crt", ".key", ".pem"]


def read_certificate_file(location: str):
def read_certificate_file(location: str) -> str | bytes:
"""Reads a certificate file from disk.

The file type (suffix) is used to determine the file encoding.
Expand Down
Loading
Loading