Skip to content

Commit a9c890a

Browse files
authored
Merge pull request #191 from rstudio/mm-fastapi
FastAPI support
2 parents 5f28219 + 973f07c commit a9c890a

File tree

12 files changed

+502
-105
lines changed

12 files changed

+502
-105
lines changed

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ Content"](#deploying-r-or-other-content) for details.
1414
## Deploying Python Content to RStudio Connect
1515

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

@@ -262,9 +263,17 @@ rsconnect write-manifest notebook my-notebook.ipynb
262263
263264
### API/Application Deployment Options
264265

265-
There are a variety of options available to you when deploying a Python WSGI-style API,
266-
Dash, Streamlit, or Bokeh application. All options below apply equally to `api`,
267-
`dash`, `streamlit`, and `bokeh` sub-commands.
266+
You can deploy a variety of APIs and applications using sub-commands of the
267+
`rsconnect deploy` command.
268+
269+
* `api`: WSGI-compliant APIs such as Flask and packages based on Flask
270+
* `fastapi`: ASGI-compliant APIs (FastAPI, Quart, Sanic, and Falcon)
271+
* `dash`: Python Dash apps
272+
* `streamlit`: Streamlit apps
273+
* `bokeh`: Bokeh server apps
274+
275+
All options below apply equally to the `api`, `fastapi`, `dash`, `streamlit`,
276+
and `bokeh` sub-commands.
268277

269278
#### Including Extra Files
270279

@@ -389,8 +398,8 @@ this, use the `--title` option:
389398
rsconnect deploy notebook --title "My Notebook" my-notebook.ipynb
390399
```
391400

392-
When using `rsconnect deploy api`, `rsconnect deploy dash`, `rsconnect deploy
393-
streamlit`, or `rsconnect deploy bokeh`, the title is derived from the directory
401+
When using `rsconnect deploy api`, `rsconnect deploy fastapi`, `rsconnect deploy dash`,
402+
`rsconnect deploy streamlit`, or `rsconnect deploy bokeh`, the title is derived from the directory
394403
containing the API or application.
395404

396405
When using `rsconnect deploy manifest`, the title is derived from the primary

mock_connect/mock_connect/http_helpers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ def _make_json_ready(thing):
4343

4444

4545
def endpoint(
46-
authenticated: bool = False, auth_optional: bool = False, cls=None, writes_json: bool = False,
46+
authenticated: bool = False,
47+
auth_optional: bool = False,
48+
cls=None,
49+
writes_json: bool = False,
4750
):
4851
def decorator(function):
4952
@wraps(function)

rsconnect/actions.py

Lines changed: 148 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -504,10 +504,19 @@ def deploy_jupyter_notebook(
504504
of log lines. The log lines value will be None if a log callback was provided.
505505
"""
506506
app_store = AppStore(file_name)
507-
(app_id, deployment_name, deployment_title, default_title, app_mode,) = gather_basic_deployment_info_for_notebook(
508-
connect_server, app_store, file_name, new, app_id, title, static
507+
(
508+
app_id,
509+
deployment_name,
510+
deployment_title,
511+
default_title,
512+
app_mode,
513+
) = gather_basic_deployment_info_for_notebook(connect_server, app_store, file_name, new, app_id, title, static)
514+
python, environment = get_python_env_info(
515+
file_name,
516+
python,
517+
conda_mode=conda_mode,
518+
force_generate=force_generate,
509519
)
510-
python, environment = get_python_env_info(file_name, python, conda_mode=conda_mode, force_generate=force_generate,)
511520
bundle = create_notebook_deployment_bundle(
512521
file_name, extra_files, app_mode, python, environment, hide_all_input, hide_tagged_input
513522
)
@@ -526,7 +535,16 @@ def deploy_jupyter_notebook(
526535

527536

528537
def _finalize_deploy(
529-
connect_server, app_store, file_name, app_id, app_mode, name, title, title_is_default, bundle, log_callback,
538+
connect_server,
539+
app_store,
540+
file_name,
541+
app_id,
542+
app_mode,
543+
name,
544+
title,
545+
title_is_default,
546+
bundle,
547+
log_callback,
530548
):
531549
"""
532550
A common function to finish up the deploy process once all the data (bundle
@@ -551,7 +569,13 @@ def _finalize_deploy(
551569
app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle)
552570
app_url, log_lines = spool_deployment_log(connect_server, app, log_callback)
553571
app_store.set(
554-
connect_server.url, abspath(file_name), app_url, app["app_id"], app["app_guid"], title, app_mode,
572+
connect_server.url,
573+
abspath(file_name),
574+
app_url,
575+
app["app_id"],
576+
app["app_guid"],
577+
title,
578+
app_mode,
555579
)
556580
return app_url, log_lines
557581

@@ -625,6 +649,62 @@ def deploy_python_api(
625649
)
626650

627651

652+
def deploy_python_fastapi(
653+
connect_server,
654+
directory,
655+
extra_files,
656+
excludes,
657+
entry_point,
658+
new=False,
659+
app_id=None,
660+
title=None,
661+
python=None,
662+
conda_mode=False,
663+
force_generate=False,
664+
log_callback=None,
665+
):
666+
"""
667+
A function to deploy a Python ASGI API module to RStudio Connect. Depending on the files involved
668+
and network latency, this may take a bit of time.
669+
670+
:param connect_server: the Connect server information.
671+
:param directory: the app directory to deploy.
672+
:param extra_files: any extra files that should be included in the deploy.
673+
:param excludes: a sequence of glob patterns that will exclude matched files.
674+
:param entry_point: the module/executable object for the WSGi framework.
675+
:param new: a flag to force this as a new deploy.
676+
:param app_id: the ID of an existing application to deploy new files for.
677+
:param title: an optional title for the deploy. If this is not provided, ne will
678+
be generated.
679+
:param python: the optional name of a Python executable.
680+
:param conda_mode: use conda to build an environment.yml
681+
instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0).
682+
:param force_generate: force generating "requirements.txt" or "environment.yml",
683+
even if it already exists.
684+
:param log_callback: the callback to use to write the log to. If this is None
685+
(the default) the lines from the deployment log will be returned as a sequence.
686+
If a log callback is provided, then None will be returned for the log lines part
687+
of the return tuple.
688+
:return: the ultimate URL where the deployed app may be accessed and the sequence
689+
of log lines. The log lines value will be None if a log callback was provided.
690+
"""
691+
return _deploy_by_python_framework(
692+
connect_server,
693+
directory,
694+
extra_files,
695+
excludes,
696+
entry_point,
697+
gather_basic_deployment_info_for_fastapi,
698+
new,
699+
app_id,
700+
title,
701+
python,
702+
conda_mode,
703+
force_generate,
704+
log_callback,
705+
)
706+
707+
628708
def deploy_dash_app(
629709
connect_server,
630710
directory,
@@ -836,10 +916,20 @@ def _deploy_by_python_framework(
836916
"""
837917
module_file = fake_module_file_from_directory(directory)
838918
app_store = AppStore(module_file)
839-
(entry_point, app_id, deployment_name, deployment_title, default_title, app_mode,) = gatherer(
840-
connect_server, app_store, directory, entry_point, new, app_id, title
919+
(
920+
entry_point,
921+
app_id,
922+
deployment_name,
923+
deployment_title,
924+
default_title,
925+
app_mode,
926+
) = gatherer(connect_server, app_store, directory, entry_point, new, app_id, title)
927+
_, environment = get_python_env_info(
928+
directory,
929+
python,
930+
conda_mode=conda_mode,
931+
force_generate=force_generate,
841932
)
842-
_, environment = get_python_env_info(directory, python, conda_mode=conda_mode, force_generate=force_generate,)
843933
bundle = create_api_deployment_bundle(directory, extra_files, excludes, entry_point, app_mode, environment)
844934
return _finalize_deploy(
845935
connect_server,
@@ -856,7 +946,12 @@ def _deploy_by_python_framework(
856946

857947

858948
def deploy_by_manifest(
859-
connect_server, manifest_file_name, new=False, app_id=None, title=None, log_callback=None,
949+
connect_server,
950+
manifest_file_name,
951+
new=False,
952+
app_id=None,
953+
title=None,
954+
log_callback=None,
860955
):
861956
"""
862957
A function to deploy a Jupyter notebook to Connect. Depending on the files involved
@@ -1003,13 +1098,21 @@ def _generate_gather_basic_deployment_info_for_python(app_mode):
10031098

10041099
def gatherer(connect_server, app_store, directory, entry_point, new, app_id, title):
10051100
return _gather_basic_deployment_info_for_framework(
1006-
connect_server, app_store, directory, entry_point, new, app_id, app_mode, title,
1101+
connect_server,
1102+
app_store,
1103+
directory,
1104+
entry_point,
1105+
new,
1106+
app_id,
1107+
app_mode,
1108+
title,
10071109
)
10081110

10091111
return gatherer
10101112

10111113

10121114
gather_basic_deployment_info_for_api = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_API)
1115+
gather_basic_deployment_info_for_fastapi = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_FASTAPI)
10131116
gather_basic_deployment_info_for_dash = _generate_gather_basic_deployment_info_for_python(AppModes.DASH_APP)
10141117
gather_basic_deployment_info_for_streamlit = _generate_gather_basic_deployment_info_for_python(AppModes.STREAMLIT_APP)
10151118
gather_basic_deployment_info_for_bokeh = _generate_gather_basic_deployment_info_for_python(AppModes.BOKEH_APP)
@@ -1102,7 +1205,12 @@ def get_python_env_info(file_name, python, conda_mode=False, force_generate=Fals
11021205

11031206

11041207
def create_notebook_deployment_bundle(
1105-
file_name, extra_files, app_mode, python, environment, extra_files_need_validating=True,
1208+
file_name,
1209+
extra_files,
1210+
app_mode,
1211+
python,
1212+
environment,
1213+
extra_files_need_validating=True,
11061214
hide_all_input=None,
11071215
hide_tagged_input=None,
11081216
):
@@ -1116,7 +1224,7 @@ def create_notebook_deployment_bundle(
11161224
:param environment: environmental information.
11171225
:param extra_files_need_validating: a flag indicating whether the list of extra
11181226
:param hide_all_input: if True, will hide all input cells when rendering output
1119-
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
1227+
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
11201228
files should be validated or not. Part of validating includes qualifying each
11211229
with the parent directory of the notebook file. If you provide False here, make
11221230
sure the names are properly qualified first.
@@ -1139,7 +1247,13 @@ def create_notebook_deployment_bundle(
11391247

11401248

11411249
def create_api_deployment_bundle(
1142-
directory, extra_files, excludes, entry_point, app_mode, environment, extra_files_need_validating=True,
1250+
directory,
1251+
extra_files,
1252+
excludes,
1253+
entry_point,
1254+
app_mode,
1255+
environment,
1256+
extra_files_need_validating=True,
11431257
):
11441258
"""
11451259
Create an in-memory bundle, ready to deploy.
@@ -1223,17 +1337,21 @@ def create_notebook_manifest_and_environment_file(
12231337
:param force: if True, forces the environment file to be written. even if it
12241338
already exists.
12251339
:param hide_all_input: if True, will hide all input cells when rendering output
1226-
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
1340+
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
12271341
:return:
12281342
"""
12291343
if (
1230-
not write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input)
1344+
not write_notebook_manifest_json(
1345+
entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input
1346+
)
12311347
or force
12321348
):
12331349
write_environment_file(environment, dirname(entry_point_file))
12341350

12351351

1236-
def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input):
1352+
def write_notebook_manifest_json(
1353+
entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input
1354+
):
12371355
"""
12381356
Creates and writes a manifest.json file for the given entry point file. If
12391357
the application mode is not provided, an attempt will be made to resolve one
@@ -1247,7 +1365,7 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_
12471365
portion of the entry point file name will be used to derive one.
12481366
:param extra_files: any extra files that should be included in the manifest.
12491367
:param hide_all_input: if True, will hide all input cells when rendering output
1250-
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
1368+
:param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output
12511369
:return: whether or not the environment file (requirements.txt, environment.yml,
12521370
etc.) that goes along with the manifest exists.
12531371
"""
@@ -1276,7 +1394,13 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_
12761394

12771395

12781396
def create_api_manifest_and_environment_file(
1279-
directory, entry_point, environment, app_mode=AppModes.PYTHON_API, extra_files=None, excludes=None, force=True,
1397+
directory,
1398+
entry_point,
1399+
environment,
1400+
app_mode=AppModes.PYTHON_API,
1401+
extra_files=None,
1402+
excludes=None,
1403+
force=True,
12801404
):
12811405
"""
12821406
Creates and writes a manifest.json file for the given Python API entry point. If
@@ -1299,7 +1423,12 @@ def create_api_manifest_and_environment_file(
12991423

13001424

13011425
def write_api_manifest_json(
1302-
directory, entry_point, environment, app_mode=AppModes.PYTHON_API, extra_files=None, excludes=None,
1426+
directory,
1427+
entry_point,
1428+
environment,
1429+
app_mode=AppModes.PYTHON_API,
1430+
extra_files=None,
1431+
excludes=None,
13031432
):
13041433
"""
13051434
Creates and writes a manifest.json file for the given entry point file. If

rsconnect/api.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ def __init__(self, server, cookies=None, timeout=30):
6262
if cookies is None:
6363
cookies = server.cookie_jar
6464
super(RSConnect, self).__init__(
65-
append_to_path(server.url, "__api__"), server.insecure, server.ca_data, cookies, timeout,
65+
append_to_path(server.url, "__api__"),
66+
server.insecure,
67+
server.ca_data,
68+
cookies,
69+
timeout,
6670
)
6771
self._server = server
6872

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

102106
def app_publish(self, app_id, access):
103-
return self.post("applications/%s" % app_id, body={"access_type": access, "id": app_id, "needs_config": False},)
107+
return self.post(
108+
"applications/%s" % app_id,
109+
body={"access_type": access, "id": app_id, "needs_config": False},
110+
)
104111

105112
def app_config(self, app_id):
106113
return self.get("applications/%s/config" % app_id)
@@ -458,7 +465,9 @@ def find_unique_name(connect_server, name):
458465
:return: the name, potentially with a suffixed number to guarantee uniqueness.
459466
"""
460467
existing_names = retrieve_matching_apps(
461-
connect_server, filters={"search": name}, mapping_function=lambda client, app: app["name"],
468+
connect_server,
469+
filters={"search": name},
470+
mapping_function=lambda client, app: app["name"],
462471
)
463472

464473
if name in existing_names:

0 commit comments

Comments
 (0)