rsconnect-python 1.30.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rsconnect quickstart: scaffold a deployable Posit Connect project.
|
|
3
|
+
|
|
4
|
+
This module is the deep boundary for the ``rsconnect quickstart`` command.
|
|
5
|
+
It owns the whole scaffolding flow: pre-flight checks, template rendering,
|
|
6
|
+
``pyproject.toml`` generation, ``uv``-based venv population, atomic rollback
|
|
7
|
+
on failure, and the post-scaffold console output.
|
|
8
|
+
|
|
9
|
+
Public entrypoint: :func:`run_quickstart`. Callers (the Click command in
|
|
10
|
+
``rsconnect/main.py``) should not need to import anything else from this
|
|
11
|
+
module.
|
|
12
|
+
|
|
13
|
+
See ``docs/commands/quickstart.md`` for the user-facing command reference.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import io
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import pkgutil
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import string
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import typing
|
|
29
|
+
|
|
30
|
+
import click
|
|
31
|
+
|
|
32
|
+
from ..exception import RSConnectException
|
|
33
|
+
from ..models import AppMode, AppModes
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Project name rule: lowercase ASCII letter start, only lowercase letters /
|
|
37
|
+
# digits / underscores, no trailing underscore. Underscores (not hyphens) so the name
|
|
38
|
+
# is a valid Python package identifier — fastapi/api scaffolds materialize a
|
|
39
|
+
# ``<name>/<name>/__init__.py`` package, which only works with importable
|
|
40
|
+
# names. The optional middle-and-end group keeps the rule satisfiable by
|
|
41
|
+
# single-letter names such as ``"a"``.
|
|
42
|
+
_project_name_pattern = re.compile(r"^[a-z]([a-z0-9_]*[a-z0-9])?$")
|
|
43
|
+
_PROJECT_NAME_RULE = (
|
|
44
|
+
"Project name must start with a lowercase ASCII letter, contain only "
|
|
45
|
+
"lowercase letters, digits, and underscores, and not end with an underscore."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_quickstart(
|
|
50
|
+
app_type: str,
|
|
51
|
+
name: str,
|
|
52
|
+
*,
|
|
53
|
+
python_version: typing.Optional[str] = None,
|
|
54
|
+
cwd: typing.Optional[pathlib.Path] = None,
|
|
55
|
+
) -> pathlib.Path:
|
|
56
|
+
"""Scaffold a new Connect project of ``app_type`` named ``name``.
|
|
57
|
+
|
|
58
|
+
Returns the absolute path to the created project directory on success.
|
|
59
|
+
Raises :class:`rsconnect.exception.RSConnectException` on any pre-flight
|
|
60
|
+
or scaffold failure. On failure the partially-created directory is
|
|
61
|
+
removed so the caller sees "all or nothing."
|
|
62
|
+
|
|
63
|
+
:param str app_type: one of the supported CLI types.
|
|
64
|
+
:param str name: project name; must satisfy the project-name rule above.
|
|
65
|
+
:param str python_version: optional ``requires-python`` control. A value
|
|
66
|
+
that begins with a specifier operator (e.g. ``>=3.11`` or
|
|
67
|
+
``>=3.11,<3.14``) is used verbatim. A bare version is padded to at
|
|
68
|
+
most three segments: ``3.10`` -> ``==3.10.*`` (any 3.10.x) and
|
|
69
|
+
``3.11.14`` -> ``==3.11.14`` (exact). Defaults to ``>=<major.minor>``
|
|
70
|
+
of the interpreter running ``rsconnect``.
|
|
71
|
+
:param pathlib.Path cwd: override the working directory (testing hook);
|
|
72
|
+
defaults to :func:`pathlib.Path.cwd`.
|
|
73
|
+
"""
|
|
74
|
+
cwd = (cwd or pathlib.Path.cwd()).resolve()
|
|
75
|
+
|
|
76
|
+
# Pre-flight checks. Each helper raises ``RSConnectException``
|
|
77
|
+
# with an actionable message; nothing on disk is mutated until every
|
|
78
|
+
# check has passed. Type validation lives in Click's argument
|
|
79
|
+
# parser (see ``rsconnect/main.py``), so it has already passed before
|
|
80
|
+
# we get here.
|
|
81
|
+
_require_uv_on_path()
|
|
82
|
+
_validate_project_name(name)
|
|
83
|
+
target = cwd / name
|
|
84
|
+
_require_target_does_not_exist(target)
|
|
85
|
+
_require_cwd_writable(cwd)
|
|
86
|
+
|
|
87
|
+
# Resolve the per-mode template once. Pre-flight already validated
|
|
88
|
+
# ``app_type`` via Click's ``Choice``; ``lookup_template`` is defensive
|
|
89
|
+
# for direct API callers only.
|
|
90
|
+
spec = lookup_template(app_type)
|
|
91
|
+
|
|
92
|
+
# ``--python`` controls ``requires-python``. A value that already starts
|
|
93
|
+
# with a specifier operator (``>=3.11``, ``>=3.11,<3.14``, ...) is used
|
|
94
|
+
# verbatim. A bare version is padded to at most three segments so the
|
|
95
|
+
# ``.*`` wildcard appears only when a patch level is omitted: ``3.10`` ->
|
|
96
|
+
# ``==3.10.*`` (any 3.10.x), ``3.11.14`` -> ``==3.11.14`` (exact).
|
|
97
|
+
# Without ``--python`` we track the running interpreter's ``major.minor``.
|
|
98
|
+
if python_version is None:
|
|
99
|
+
requires_python = _REQUIRES_PYTHON
|
|
100
|
+
elif python_version[:1] in {"=", "<", ">", "!", "~"}:
|
|
101
|
+
requires_python = python_version
|
|
102
|
+
else:
|
|
103
|
+
requires_python = "==" + ".".join((python_version.split(".") + ["*"])[:3])
|
|
104
|
+
|
|
105
|
+
# Atomicity: after ``mkdir`` succeeds, any failure in the rest of the
|
|
106
|
+
# pipeline must remove ``./<name>/`` so the user sees "all or nothing."
|
|
107
|
+
# ``BaseException`` catches ``KeyboardInterrupt`` too (a Ctrl-C
|
|
108
|
+
# mid-``uv sync`` is the most likely real-world failure mode).
|
|
109
|
+
target.mkdir()
|
|
110
|
+
try:
|
|
111
|
+
_scaffold(target, name=name, spec=spec, requires_python=requires_python)
|
|
112
|
+
_install_venv(target)
|
|
113
|
+
except BaseException:
|
|
114
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
# Summary runs after success - cosmetic stdout failures (e.g. a
|
|
118
|
+
# BrokenPipeError when piping to ``head``) must not invalidate the
|
|
119
|
+
# on-disk project. The README carries the same two commands, so the
|
|
120
|
+
# user can recover them even if this echo fails.
|
|
121
|
+
_emit_summary(target, name=name, spec=spec)
|
|
122
|
+
return target
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Pre-flight checks
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _require_uv_on_path() -> None:
|
|
131
|
+
if shutil.which("uv") is None:
|
|
132
|
+
# ``uv>=0.9.0`` is a declared dependency of rsconnect-python, so a
|
|
133
|
+
# missing ``uv`` on PATH typically means the install environment is
|
|
134
|
+
# broken. The message names both fixes a user can take.
|
|
135
|
+
raise RSConnectException(
|
|
136
|
+
"'uv' was not found on PATH. It ships with rsconnect-python; "
|
|
137
|
+
"try reinstalling (pip install --force-reinstall rsconnect-python) "
|
|
138
|
+
"or install uv manually from https://github.com/astral-sh/uv"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _validate_project_name(name: str) -> None:
|
|
143
|
+
if not _project_name_pattern.match(name):
|
|
144
|
+
raise RSConnectException(f"Invalid project name {name!r}. {_PROJECT_NAME_RULE}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _require_target_does_not_exist(target: pathlib.Path) -> None:
|
|
148
|
+
if target.exists():
|
|
149
|
+
raise RSConnectException(
|
|
150
|
+
f"Target directory {target} already exists. Use a different name or remove the existing directory."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _require_cwd_writable(cwd: pathlib.Path) -> None:
|
|
155
|
+
if not os.access(cwd, os.W_OK):
|
|
156
|
+
raise RSConnectException(
|
|
157
|
+
f"Current working directory {cwd} is not writable. "
|
|
158
|
+
"Change to a writable directory or adjust its permissions."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Template registry
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
#
|
|
166
|
+
# The registry is the single source of truth that ties together what each
|
|
167
|
+
# supported mode produces: the canonical Connect ``app_mode`` written to
|
|
168
|
+
# ``[tool.rsconnect]``, the entrypoint form, the local-run command
|
|
169
|
+
# documented in the post-scaffold stdout and the README, the minimum
|
|
170
|
+
# dependencies for the hello-world, and the source files the per-mode
|
|
171
|
+
# template materializes.
|
|
172
|
+
#
|
|
173
|
+
# Adding a future supported mode is a registry insertion plus dropping a
|
|
174
|
+
# directory under ``rsconnect/quickstart/templates/<mode>/``; no pre-flight,
|
|
175
|
+
# pyproject-writer, or post-output code needs to change.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclasses.dataclass(frozen=True)
|
|
179
|
+
class FileSpec:
|
|
180
|
+
"""One per-mode template file to materialize in the scaffolded project.
|
|
181
|
+
|
|
182
|
+
:param str name: filename relative to the project root. The literal
|
|
183
|
+
token ``{name}`` (if present) is substituted with the project name
|
|
184
|
+
at scaffold time, which is how fastapi/api modes produce a nested
|
|
185
|
+
``<name>/<name>/`` Python package layout.
|
|
186
|
+
:param str template: path to the template body under
|
|
187
|
+
``rsconnect/quickstart/templates/``, loaded via
|
|
188
|
+
:func:`pkgutil.get_data`. Template files use the ``.tmpl`` suffix
|
|
189
|
+
to signal "needs substitution before becoming a usable artifact"
|
|
190
|
+
and to prevent accidental Python import of files that may not be
|
|
191
|
+
valid source on their own. The single token ``{name}`` in the body
|
|
192
|
+
is substituted with the project name via
|
|
193
|
+
``str.replace("{name}", name)``; no other interpolation runs, so
|
|
194
|
+
templates carrying literal braces (e.g. ``notebook.ipynb`` JSON)
|
|
195
|
+
are unaffected.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
name: str
|
|
199
|
+
template: str
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclasses.dataclass(frozen=True)
|
|
203
|
+
class TemplateSpec:
|
|
204
|
+
"""Per-resolved-mode scaffold contract.
|
|
205
|
+
|
|
206
|
+
Resolved means the CLI ``app_type`` has already been mapped to one
|
|
207
|
+
entry; the dataclass itself does not know about CLI aliases (e.g.
|
|
208
|
+
``flask`` -> ``api``).
|
|
209
|
+
|
|
210
|
+
The mode's Connect identity (``app_mode``, ``entrypoint``, runtime
|
|
211
|
+
``dependencies``) lives in ``pyproject_template`` rather than as
|
|
212
|
+
dataclass fields: that template file is the single source of truth
|
|
213
|
+
for what ends up in the generated ``pyproject.toml``.
|
|
214
|
+
|
|
215
|
+
:param str pyproject_template: path under ``rsconnect/quickstart/templates/``
|
|
216
|
+
of the per-mode ``pyproject.toml`` template. Substituted via
|
|
217
|
+
:class:`string.Template` against ``$name`` and ``$requires_python``.
|
|
218
|
+
:param str readme_template: path under ``rsconnect/quickstart/templates/``
|
|
219
|
+
of the per-mode ``README.md`` template. Substituted via
|
|
220
|
+
:class:`string.Template` against ``$name``.
|
|
221
|
+
:param tuple local_run_command: argv form of the documented local-run
|
|
222
|
+
command, used by the post-scaffold stdout summary. Tokens containing
|
|
223
|
+
``"$name"`` are substituted at scaffold time.
|
|
224
|
+
:param tuple source_files: per-mode template files to materialize. Each
|
|
225
|
+
entry's body and ``name`` are loaded and substituted via
|
|
226
|
+
:class:`string.Template` against ``$name``.
|
|
227
|
+
:param tuple notes: optional user-facing trailing lines for the
|
|
228
|
+
post-scaffold stdout output (e.g. "Quarto must be installed
|
|
229
|
+
separately"). Empty for modes whose hello-world has no external
|
|
230
|
+
tooling prerequisite. The README template owns its own copy of any
|
|
231
|
+
notes a user should see on disk.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
pyproject_template: str
|
|
235
|
+
readme_template: str
|
|
236
|
+
local_run_command: typing.Tuple[str, ...]
|
|
237
|
+
source_files: typing.Tuple[FileSpec, ...]
|
|
238
|
+
notes: typing.Tuple[str, ...] = ()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Registry key: the canonical :class:`AppMode` singleton. The CLI alias
|
|
242
|
+
# (``streamlit``, ``flask`` etc.) is resolved through
|
|
243
|
+
# :meth:`AppModes.get_by_cli_alias` before lookup, which collapses
|
|
244
|
+
# many-to-one aliases (``api`` and ``flask`` both resolve to
|
|
245
|
+
# ``PYTHON_API``) and centralizes the alias vocabulary in ``models.py``.
|
|
246
|
+
# Modes present in :data:`AppModes._cli_aliases` but absent from this
|
|
247
|
+
# registry (e.g. ``dash``, ``bokeh``, ``gradio``) are CLI-accepted but not
|
|
248
|
+
# yet scaffolded by ``quickstart``; :func:`lookup_template` raises a
|
|
249
|
+
# distinct "not yet supported" error for them.
|
|
250
|
+
_QUARTO_INSTALL_NOTE = "Quarto must be installed separately: https://quarto.org"
|
|
251
|
+
|
|
252
|
+
_REGISTRY: typing.Mapping[AppMode, TemplateSpec] = {
|
|
253
|
+
AppModes.STREAMLIT_APP: TemplateSpec(
|
|
254
|
+
pyproject_template="streamlit/pyproject.toml.tmpl",
|
|
255
|
+
readme_template="streamlit/README.md.tmpl",
|
|
256
|
+
local_run_command=("uv", "run", "streamlit", "run", "app.py"),
|
|
257
|
+
source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),),
|
|
258
|
+
),
|
|
259
|
+
AppModes.PYTHON_SHINY: TemplateSpec(
|
|
260
|
+
pyproject_template="shiny/pyproject.toml.tmpl",
|
|
261
|
+
readme_template="shiny/README.md.tmpl",
|
|
262
|
+
local_run_command=("uv", "run", "shiny", "run", "app.py"),
|
|
263
|
+
source_files=(FileSpec(name="app.py", template="shiny/app.py.tmpl"),),
|
|
264
|
+
),
|
|
265
|
+
# fastapi/api produce a nested ``<name>/<name>/`` package so the
|
|
266
|
+
# documented ``python -m <name>`` local-run command resolves cleanly
|
|
267
|
+
# and ``from .app import create_app`` relative imports work.
|
|
268
|
+
AppModes.PYTHON_FASTAPI: TemplateSpec(
|
|
269
|
+
pyproject_template="fastapi/pyproject.toml.tmpl",
|
|
270
|
+
readme_template="fastapi/README.md.tmpl",
|
|
271
|
+
local_run_command=("uv", "run", "python", "-m", "$name"),
|
|
272
|
+
source_files=(
|
|
273
|
+
FileSpec(name="$name/__init__.py", template="fastapi/__init__.py.tmpl"),
|
|
274
|
+
FileSpec(name="$name/__main__.py", template="fastapi/__main__.py.tmpl"),
|
|
275
|
+
FileSpec(name="$name/__connect__.py", template="fastapi/__connect__.py.tmpl"),
|
|
276
|
+
FileSpec(name="$name/app.py", template="fastapi/app.py.tmpl"),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
AppModes.PYTHON_API: TemplateSpec(
|
|
280
|
+
pyproject_template="api/pyproject.toml.tmpl",
|
|
281
|
+
readme_template="api/README.md.tmpl",
|
|
282
|
+
local_run_command=("uv", "run", "python", "-m", "$name"),
|
|
283
|
+
source_files=(
|
|
284
|
+
FileSpec(name="$name/__init__.py", template="api/__init__.py.tmpl"),
|
|
285
|
+
FileSpec(name="$name/__main__.py", template="api/__main__.py.tmpl"),
|
|
286
|
+
FileSpec(name="$name/__connect__.py", template="api/__connect__.py.tmpl"),
|
|
287
|
+
FileSpec(name="$name/app.py", template="api/app.py.tmpl"),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
# notebook and voila share the same notebook body; they differ in
|
|
291
|
+
# ``pyproject_template`` (app_mode + dependencies) and local-run command.
|
|
292
|
+
AppModes.JUPYTER_NOTEBOOK: TemplateSpec(
|
|
293
|
+
pyproject_template="notebook/pyproject.toml.tmpl",
|
|
294
|
+
readme_template="notebook/README.md.tmpl",
|
|
295
|
+
local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"),
|
|
296
|
+
source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),),
|
|
297
|
+
),
|
|
298
|
+
AppModes.JUPYTER_VOILA: TemplateSpec(
|
|
299
|
+
pyproject_template="voila/pyproject.toml.tmpl",
|
|
300
|
+
readme_template="voila/README.md.tmpl",
|
|
301
|
+
local_run_command=("uv", "run", "voila", "notebook.ipynb"),
|
|
302
|
+
source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),),
|
|
303
|
+
),
|
|
304
|
+
AppModes.STATIC_QUARTO: TemplateSpec(
|
|
305
|
+
pyproject_template="quarto/pyproject.toml.tmpl",
|
|
306
|
+
readme_template="quarto/README.md.tmpl",
|
|
307
|
+
local_run_command=("uv", "run", "quarto", "preview", "report.qmd"),
|
|
308
|
+
source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),),
|
|
309
|
+
notes=(_QUARTO_INSTALL_NOTE,),
|
|
310
|
+
),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _supported_aliases() -> typing.Tuple[str, ...]:
|
|
315
|
+
"""CLI aliases whose :class:`AppMode` has a quickstart template.
|
|
316
|
+
|
|
317
|
+
Derived from :data:`AppModes._cli_aliases` and :data:`_REGISTRY`: an
|
|
318
|
+
alias is "supported" iff its resolved ``AppMode`` is a registry key.
|
|
319
|
+
Used only for user-facing error messages, so a small per-call traversal
|
|
320
|
+
is fine.
|
|
321
|
+
"""
|
|
322
|
+
return tuple(alias for alias in AppModes.cli_aliases() if AppModes.get_by_cli_alias(alias) in _REGISTRY)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def lookup_template(app_type: str) -> TemplateSpec:
|
|
326
|
+
"""Resolve the :class:`TemplateSpec` for the CLI alias ``app_type``.
|
|
327
|
+
|
|
328
|
+
The alias is mapped to its canonical :class:`AppMode` via
|
|
329
|
+
:meth:`AppModes.get_by_cli_alias`, which collapses many-to-one aliases
|
|
330
|
+
(``api`` and ``flask`` both -> ``PYTHON_API``). Two distinct error
|
|
331
|
+
surfaces:
|
|
332
|
+
|
|
333
|
+
* Unknown alias (not in ``AppModes._cli_aliases``): the user typed
|
|
334
|
+
something Connect doesn't recognize at all.
|
|
335
|
+
* Known alias but no template (mode not in :data:`_REGISTRY`):
|
|
336
|
+
quickstart doesn't yet scaffold this mode (e.g. ``dash``, ``bokeh``).
|
|
337
|
+
|
|
338
|
+
:param str app_type: CLI ``<type>`` value.
|
|
339
|
+
"""
|
|
340
|
+
app_mode = AppModes.get_by_cli_alias(app_type)
|
|
341
|
+
if app_mode is AppModes.UNKNOWN:
|
|
342
|
+
raise RSConnectException(
|
|
343
|
+
f"Unknown project type {app_type!r}. Supported types: " + ", ".join(_supported_aliases())
|
|
344
|
+
)
|
|
345
|
+
if app_mode not in _REGISTRY:
|
|
346
|
+
raise RSConnectException(
|
|
347
|
+
f"`rsconnect quickstart` does not yet support {app_type!r}. "
|
|
348
|
+
f"Supported types: " + ", ".join(_supported_aliases())
|
|
349
|
+
)
|
|
350
|
+
return _REGISTRY[app_mode]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Filesystem generation
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec, requires_python: str) -> None:
|
|
359
|
+
"""Write every file the scaffolded project should contain.
|
|
360
|
+
|
|
361
|
+
Filesystem-generation phase: the three always-present files
|
|
362
|
+
(``pyproject.toml``, ``.gitignore``, ``README.md``) and the per-mode
|
|
363
|
+
source files materialized from ``spec.source_files``. The caller owns
|
|
364
|
+
``target``'s creation and rollback, so this helper writes into an
|
|
365
|
+
existing directory.
|
|
366
|
+
"""
|
|
367
|
+
(target / "pyproject.toml").write_text(
|
|
368
|
+
_render_pyproject(name=name, spec=spec, requires_python=requires_python), encoding="utf-8"
|
|
369
|
+
)
|
|
370
|
+
(target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8")
|
|
371
|
+
(target / "README.md").write_text(_render_readme(name=name, spec=spec), encoding="utf-8")
|
|
372
|
+
for file_spec in spec.source_files:
|
|
373
|
+
body = _load_template(file_spec.template)
|
|
374
|
+
# ``$name`` substitution in ``file_spec.name`` plus mkdir lets the
|
|
375
|
+
# registry describe nested package layouts (fastapi/api) without
|
|
376
|
+
# special-casing them here.
|
|
377
|
+
dest = target / string.Template(file_spec.name).substitute(name=name)
|
|
378
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
dest.write_text(string.Template(body).substitute(name=name), encoding="utf-8")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _load_template(path: str) -> str:
|
|
383
|
+
"""Read a template file from the ``rsconnect.quickstart.templates`` package.
|
|
384
|
+
|
|
385
|
+
``pkgutil.get_data`` is stdlib since Python 3.0 and works under wheel
|
|
386
|
+
install, unlike ``importlib.resources.files`` which is 3.9+. It returns
|
|
387
|
+
raw bytes, so we wrap the buffer in :class:`io.TextIOWrapper` to get the
|
|
388
|
+
same universal-newlines decoding as ``open(path, 'rt')``.
|
|
389
|
+
"""
|
|
390
|
+
data = pkgutil.get_data("rsconnect.quickstart.templates", path)
|
|
391
|
+
if data is None:
|
|
392
|
+
raise RSConnectException(f"Template not found: {path}")
|
|
393
|
+
return io.TextIOWrapper(io.BytesIO(data), encoding="utf-8").read()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ``requires-python`` is the single source of truth for the scaffold's Python
|
|
397
|
+
# requirement: ``rsconnect deploy pyproject`` reads ``pyproject.toml``, so
|
|
398
|
+
# emitting a separate ``.python-version`` pin would only duplicate this value.
|
|
399
|
+
# The floor tracks the interpreter that ran ``rsconnect quickstart`` so the
|
|
400
|
+
# scaffold matches the developer's working environment without committing the
|
|
401
|
+
# project to any version older than what its author has actually used.
|
|
402
|
+
_REQUIRES_PYTHON = ">={}.{}".format(*sys.version_info[:2])
|
|
403
|
+
|
|
404
|
+
_GITIGNORE_BODY = """\
|
|
405
|
+
__pycache__/
|
|
406
|
+
*.pyc
|
|
407
|
+
.venv/
|
|
408
|
+
*.egg-info/
|
|
409
|
+
rsconnect-python/
|
|
410
|
+
.env
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _render_pyproject(*, name: str, spec: TemplateSpec, requires_python: str) -> str:
|
|
415
|
+
# The per-mode template owns the literal TOML, including ``app_mode``,
|
|
416
|
+
# ``entrypoint`` and the dependency list. Only ``$name`` (project name)
|
|
417
|
+
# and ``$requires_python`` (from ``--python`` or the running interpreter)
|
|
418
|
+
# vary at scaffold time.
|
|
419
|
+
return string.Template(_load_template(spec.pyproject_template)).substitute(
|
|
420
|
+
name=name, requires_python=requires_python
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _render_readme(*, name: str, spec: TemplateSpec) -> str:
|
|
425
|
+
# The per-mode template owns every literal line of the README, including
|
|
426
|
+
# the mode's local-run command, the deploy command, and any notes. Only
|
|
427
|
+
# ``$name`` varies at scaffold time.
|
|
428
|
+
return string.Template(_load_template(spec.readme_template)).substitute(name=name)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _format_local_run(spec: TemplateSpec, *, name: str) -> str:
|
|
432
|
+
# The registry stores the local-run argv with ``"$name"`` as a literal
|
|
433
|
+
# placeholder for module-style modes (fastapi/api). Substitute once
|
|
434
|
+
# here so the post-scaffold stdout line renders cleanly.
|
|
435
|
+
return " ".join(string.Template(token).substitute(name=name) for token in spec.local_run_command)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# Venv population
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _install_venv(target: pathlib.Path) -> None:
|
|
444
|
+
"""Populate ``.venv/`` via ``uv venv`` + ``uv sync``.
|
|
445
|
+
|
|
446
|
+
stdout/stderr are inherited from the parent process so users see uv's
|
|
447
|
+
own progress output in real time ("Creating environment...", "Resolving
|
|
448
|
+
dependencies..."). A non-zero exit raises ``RSConnectException``, which
|
|
449
|
+
the caller translates into the rollback of the partially-created project.
|
|
450
|
+
"""
|
|
451
|
+
# ``VIRTUAL_ENV`` is removed because uv otherwise warns that the
|
|
452
|
+
# developer's currently-activated venv does not match the scaffolded
|
|
453
|
+
# project's ``.venv/``. The user expects uv to operate on the new
|
|
454
|
+
# project, not the shell's active environment.
|
|
455
|
+
env = os.environ.copy()
|
|
456
|
+
env.pop("VIRTUAL_ENV", None)
|
|
457
|
+
# ``uv venv`` first so ``uv sync`` reads the freshly-created ``.venv``;
|
|
458
|
+
# if the first step fails there is no point continuing.
|
|
459
|
+
for command in (("uv", "venv"), ("uv", "sync")):
|
|
460
|
+
result = subprocess.run(list(command), cwd=target, env=env)
|
|
461
|
+
if result.returncode != 0:
|
|
462
|
+
joined = " ".join(command)
|
|
463
|
+
raise RSConnectException(
|
|
464
|
+
f"`{joined}` failed in {target} (exit code {result.returncode}). "
|
|
465
|
+
"Inspect the output above and try again."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
# Post-scaffold output
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _emit_summary(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None:
|
|
475
|
+
"""Print the confirmation, cd, local-run, deploy, and notes lines.
|
|
476
|
+
|
|
477
|
+
Uses :func:`click.echo` for consistency with the rest of the CLI; the
|
|
478
|
+
same commands are written into the project's ``README.md`` by
|
|
479
|
+
:func:`_render_readme` so stdout and on-disk docs agree.
|
|
480
|
+
"""
|
|
481
|
+
click.echo(f"\nProject {target.name}/ created.")
|
|
482
|
+
click.echo(f"To get started: cd {name}")
|
|
483
|
+
click.echo(f"To run locally: {_format_local_run(spec, name=name)}")
|
|
484
|
+
click.echo("To deploy: rsconnect deploy pyproject .")
|
|
485
|
+
for note in spec.notes:
|
|
486
|
+
click.echo(f"Note: {note}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Template data for :mod:`rsconnect.quickstart.quickstart`.
|
|
3
|
+
|
|
4
|
+
This package hosts the on-disk template files for every supported app mode.
|
|
5
|
+
It is deliberately a package (not a single module) so each mode can live in
|
|
6
|
+
its own subdirectory and the registry stays "drop in a directory to add a
|
|
7
|
+
mode". The package is internal to ``rsconnect.quickstart``; callers should
|
|
8
|
+
not import from it directly.
|
|
9
|
+
|
|
10
|
+
Template bodies are loaded at scaffold time via :func:`pkgutil.get_data`
|
|
11
|
+
and substituted with :class:`string.Template`, which uses ``$identifier``
|
|
12
|
+
syntax. The ``$``-syntax sidesteps the literal-brace concern that JSON
|
|
13
|
+
templates (``notebook.ipynb.tmpl``) and TOML inline tables would raise
|
|
14
|
+
under :meth:`str.format`. A literal ``$`` in any template must be escaped
|
|
15
|
+
as ``$$``.
|
|
16
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""$name package."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from .app import create_app
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
app = create_app()
|
|
8
|
+
# ``or`` rather than dict default so PORT="" (common in CI scripts)
|
|
9
|
+
# still falls back to the production port instead of crashing on int("").
|
|
10
|
+
app.run(host="127.0.0.1", port=int(os.environ.get("PORT") or "5000"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "$name"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
requires-python = "$requires_python"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"flask",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
[tool.rsconnect]
|
|
10
|
+
app_mode = "python-api"
|
|
11
|
+
entrypoint = "$name.__connect__:app"
|
|
12
|
+
title = "$name"
|
|
13
|
+
requirements_file = "uv.lock"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""$name package."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from .app import create_app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
# ``or`` rather than dict default so PORT="" (common in CI scripts)
|
|
10
|
+
# still falls back to the production port instead of crashing on int("").
|
|
11
|
+
port = int(os.environ.get("PORT") or "8000")
|
|
12
|
+
uvicorn.run(create_app(), host="127.0.0.1", port=port)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "$name"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
requires-python = "$requires_python"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"fastapi",
|
|
7
|
+
"uvicorn",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[tool.rsconnect]
|
|
11
|
+
app_mode = "python-fastapi"
|
|
12
|
+
entrypoint = "$name.__connect__:app"
|
|
13
|
+
title = "$name"
|
|
14
|
+
requirements_file = "uv.lock"
|