Skip to content
Merged
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Content"](#deploying-r-or-other-content) for details.
## Deploying Python Content to RStudio Connect

RStudio Connect supports the deployment of Jupyter notebooks, Python APIs (such as
`flask`-based) and apps (such as Dash, Streamlit, and Bokeh apps). Much like deploying R
those based on Flask or FastAPI) and apps (such as Dash, Streamlit, and Bokeh apps).
Much like deploying R
content to RStudio Connect, there are some caveats to understand when replicating your
environment on the RStudio Connect server:

Expand Down Expand Up @@ -262,9 +263,17 @@ rsconnect write-manifest notebook my-notebook.ipynb

### API/Application Deployment Options

There are a variety of options available to you when deploying a Python WSGI-style API,
Dash, Streamlit, or Bokeh application. All options below apply equally to `api`,
`dash`, `streamlit`, and `bokeh` sub-commands.
You can deploy a variety of APIs and applications using sub-commands of the
`rsconnect deploy` command.

* `api`: WSGI-compliant APIs such as Flask and packages based on Flask
* `fastapi`: ASGI-compliant APIs (FastAPI, Quart, Sanic, and Falcon)
* `dash`: Python Dash apps
* `streamlit`: Streamlit apps
* `bokeh`: Bokeh server apps

All options below apply equally to the `api`, `fastapi`, `dash`, `streamlit`,
and `bokeh` sub-commands.

#### Including Extra Files

Expand Down Expand Up @@ -389,8 +398,8 @@ this, use the `--title` option:
rsconnect deploy notebook --title "My Notebook" my-notebook.ipynb
```

When using `rsconnect deploy api`, `rsconnect deploy dash`, `rsconnect deploy
streamlit`, or `rsconnect deploy bokeh`, the title is derived from the directory
When using `rsconnect deploy api`, `rsconnect deploy fastapi`, `rsconnect deploy dash`,
`rsconnect deploy streamlit`, or `rsconnect deploy bokeh`, the title is derived from the directory
containing the API or application.

When using `rsconnect deploy manifest`, the title is derived from the primary
Expand Down
5 changes: 4 additions & 1 deletion mock_connect/mock_connect/http_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ def _make_json_ready(thing):


def endpoint(
authenticated: bool = False, auth_optional: bool = False, cls=None, writes_json: bool = False,
authenticated: bool = False,
auth_optional: bool = False,
cls=None,
writes_json: bool = False,
):
def decorator(function):
@wraps(function)
Expand Down
167 changes: 148 additions & 19 deletions rsconnect/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,19 @@ def deploy_jupyter_notebook(
of log lines. The log lines value will be None if a log callback was provided.
"""
app_store = AppStore(file_name)
(app_id, deployment_name, deployment_title, default_title, app_mode,) = gather_basic_deployment_info_for_notebook(
connect_server, app_store, file_name, new, app_id, title, static
(
app_id,
deployment_name,
deployment_title,
default_title,
app_mode,
) = gather_basic_deployment_info_for_notebook(connect_server, app_store, file_name, new, app_id, title, static)
python, environment = get_python_env_info(
file_name,
python,
conda_mode=conda_mode,
force_generate=force_generate,
)
python, environment = get_python_env_info(file_name, python, conda_mode=conda_mode, force_generate=force_generate,)
bundle = create_notebook_deployment_bundle(
file_name, extra_files, app_mode, python, environment, hide_all_input, hide_tagged_input
)
Expand All @@ -526,7 +535,16 @@ def deploy_jupyter_notebook(


def _finalize_deploy(
connect_server, app_store, file_name, app_id, app_mode, name, title, title_is_default, bundle, log_callback,
connect_server,
app_store,
file_name,
app_id,
app_mode,
name,
title,
title_is_default,
bundle,
log_callback,
):
"""
A common function to finish up the deploy process once all the data (bundle
Expand All @@ -551,7 +569,13 @@ def _finalize_deploy(
app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle)
app_url, log_lines = spool_deployment_log(connect_server, app, log_callback)
app_store.set(
connect_server.url, abspath(file_name), app_url, app["app_id"], app["app_guid"], title, app_mode,
connect_server.url,
abspath(file_name),
app_url,
app["app_id"],
app["app_guid"],
title,
app_mode,
)
return app_url, log_lines

Expand Down Expand Up @@ -625,6 +649,62 @@ def deploy_python_api(
)


def deploy_python_fastapi(
connect_server,
directory,
extra_files,
excludes,
entry_point,
new=False,
app_id=None,
title=None,
python=None,
conda_mode=False,
force_generate=False,
log_callback=None,
):
"""
A function to deploy a Python ASGI API module to RStudio 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 conda_mode: use conda to build an environment.yml
instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0).
: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_by_python_framework(
connect_server,
directory,
extra_files,
excludes,
entry_point,
gather_basic_deployment_info_for_fastapi,
new,
app_id,
title,
python,
conda_mode,
force_generate,
log_callback,
)


def deploy_dash_app(
connect_server,
directory,
Expand Down Expand Up @@ -836,10 +916,20 @@ def _deploy_by_python_framework(
"""
module_file = fake_module_file_from_directory(directory)
app_store = AppStore(module_file)
(entry_point, app_id, deployment_name, deployment_title, default_title, app_mode,) = gatherer(
connect_server, app_store, directory, entry_point, new, app_id, title
(
entry_point,
app_id,
deployment_name,
deployment_title,
default_title,
app_mode,
) = gatherer(connect_server, app_store, directory, entry_point, new, app_id, title)
_, environment = get_python_env_info(
directory,
python,
conda_mode=conda_mode,
force_generate=force_generate,
)
_, environment = get_python_env_info(directory, python, conda_mode=conda_mode, force_generate=force_generate,)
bundle = create_api_deployment_bundle(directory, extra_files, excludes, entry_point, app_mode, environment)
return _finalize_deploy(
connect_server,
Expand All @@ -856,7 +946,12 @@ def _deploy_by_python_framework(


def deploy_by_manifest(
connect_server, manifest_file_name, new=False, app_id=None, title=None, log_callback=None,
connect_server,
manifest_file_name,
new=False,
app_id=None,
title=None,
log_callback=None,
):
"""
A function to deploy a Jupyter notebook to Connect. Depending on the files involved
Expand Down Expand Up @@ -1003,13 +1098,21 @@ def _generate_gather_basic_deployment_info_for_python(app_mode):

def gatherer(connect_server, app_store, directory, entry_point, new, app_id, title):
return _gather_basic_deployment_info_for_framework(
connect_server, app_store, directory, entry_point, new, app_id, app_mode, title,
connect_server,
app_store,
directory,
entry_point,
new,
app_id,
app_mode,
title,
)

return gatherer


gather_basic_deployment_info_for_api = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_API)
gather_basic_deployment_info_for_fastapi = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_FASTAPI)
gather_basic_deployment_info_for_dash = _generate_gather_basic_deployment_info_for_python(AppModes.DASH_APP)
gather_basic_deployment_info_for_streamlit = _generate_gather_basic_deployment_info_for_python(AppModes.STREAMLIT_APP)
gather_basic_deployment_info_for_bokeh = _generate_gather_basic_deployment_info_for_python(AppModes.BOKEH_APP)
Expand Down Expand Up @@ -1102,7 +1205,12 @@ def get_python_env_info(file_name, python, conda_mode=False, force_generate=Fals


def create_notebook_deployment_bundle(
file_name, extra_files, app_mode, python, environment, extra_files_need_validating=True,
file_name,
extra_files,
app_mode,
python,
environment,
extra_files_need_validating=True,
hide_all_input=None,
hide_tagged_input=None,
):
Expand All @@ -1116,7 +1224,7 @@ def create_notebook_deployment_bundle(
:param environment: environmental information.
:param extra_files_need_validating: a flag indicating whether the list of extra
:param hide_all_input: if True, will hide all input cells when rendering output
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
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.
Expand All @@ -1139,7 +1247,13 @@ def create_notebook_deployment_bundle(


def create_api_deployment_bundle(
directory, extra_files, excludes, entry_point, app_mode, environment, extra_files_need_validating=True,
directory,
extra_files,
excludes,
entry_point,
app_mode,
environment,
extra_files_need_validating=True,
):
"""
Create an in-memory bundle, ready to deploy.
Expand Down Expand Up @@ -1223,17 +1337,21 @@ def create_notebook_manifest_and_environment_file(
:param force: if True, forces the environment file to be written. even if it
already exists.
:param hide_all_input: if True, will hide all input cells when rendering output
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
:return:
"""
if (
not write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input)
not write_notebook_manifest_json(
entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input
)
or force
):
write_environment_file(environment, dirname(entry_point_file))


def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input):
def write_notebook_manifest_json(
entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input
):
"""
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
Expand All @@ -1247,7 +1365,7 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_
portion of the entry point file name will be used to derive one.
:param extra_files: any extra files that should be included in the manifest.
:param hide_all_input: if True, will hide all input cells when rendering output
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
:return: whether or not the environment file (requirements.txt, environment.yml,
etc.) that goes along with the manifest exists.
"""
Expand Down Expand Up @@ -1276,7 +1394,13 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_


def create_api_manifest_and_environment_file(
directory, entry_point, environment, app_mode=AppModes.PYTHON_API, extra_files=None, excludes=None, force=True,
directory,
entry_point,
environment,
app_mode=AppModes.PYTHON_API,
extra_files=None,
excludes=None,
force=True,
):
"""
Creates and writes a manifest.json file for the given Python API entry point. If
Expand All @@ -1299,7 +1423,12 @@ def create_api_manifest_and_environment_file(


def write_api_manifest_json(
directory, entry_point, environment, app_mode=AppModes.PYTHON_API, extra_files=None, excludes=None,
directory,
entry_point,
environment,
app_mode=AppModes.PYTHON_API,
extra_files=None,
excludes=None,
):
"""
Creates and writes a manifest.json file for the given entry point file. If
Expand Down
15 changes: 12 additions & 3 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ def __init__(self, server, cookies=None, timeout=30):
if cookies is None:
cookies = server.cookie_jar
super(RSConnect, self).__init__(
append_to_path(server.url, "__api__"), server.insecure, server.ca_data, cookies, timeout,
append_to_path(server.url, "__api__"),
server.insecure,
server.ca_data,
cookies,
timeout,
)
self._server = server

Expand Down Expand Up @@ -100,7 +104,10 @@ def app_deploy(self, app_id, bundle_id=None):
return self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id})

def app_publish(self, app_id, access):
return self.post("applications/%s" % app_id, body={"access_type": access, "id": app_id, "needs_config": False},)
return self.post(
"applications/%s" % app_id,
body={"access_type": access, "id": app_id, "needs_config": False},
)

def app_config(self, app_id):
return self.get("applications/%s/config" % app_id)
Expand Down Expand Up @@ -458,7 +465,9 @@ def find_unique_name(connect_server, name):
:return: the name, potentially with a suffixed number to guarantee uniqueness.
"""
existing_names = retrieve_matching_apps(
connect_server, filters={"search": name}, mapping_function=lambda client, app: app["name"],
connect_server,
filters={"search": name},
mapping_function=lambda client, app: app["name"],
)

if name in existing_names:
Expand Down
Loading