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.
Files changed (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. 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,15 @@
1
+ # $name
2
+
3
+ A Posit Connect project scaffolded by `rsconnect quickstart`.
4
+
5
+ ## Run locally
6
+
7
+ ```
8
+ uv run python -m $name
9
+ ```
10
+
11
+ ## Deploy to Posit Connect
12
+
13
+ ```
14
+ rsconnect deploy pyproject .
15
+ ```
@@ -0,0 +1,3 @@
1
+ from .app import create_app
2
+
3
+ app = create_app()
@@ -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,11 @@
1
+ from flask import Flask
2
+
3
+
4
+ def create_app() -> Flask:
5
+ app = Flask(__name__)
6
+
7
+ @app.route("/")
8
+ def hello() -> str:
9
+ return "Hello from $name!"
10
+
11
+ return app
@@ -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,15 @@
1
+ # $name
2
+
3
+ A Posit Connect project scaffolded by `rsconnect quickstart`.
4
+
5
+ ## Run locally
6
+
7
+ ```
8
+ uv run python -m $name
9
+ ```
10
+
11
+ ## Deploy to Posit Connect
12
+
13
+ ```
14
+ rsconnect deploy pyproject .
15
+ ```
@@ -0,0 +1,3 @@
1
+ from .app import create_app
2
+
3
+ app = create_app()
@@ -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,11 @@
1
+ from fastapi import FastAPI
2
+
3
+
4
+ def create_app() -> FastAPI:
5
+ app = FastAPI()
6
+
7
+ @app.get("/")
8
+ def hello() -> dict:
9
+ return {"message": "Hello from $name!"}
10
+
11
+ return app
@@ -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"
@@ -0,0 +1,15 @@
1
+ # $name
2
+
3
+ A Posit Connect project scaffolded by `rsconnect quickstart`.
4
+
5
+ ## Run locally
6
+
7
+ ```
8
+ uv run jupyter lab notebook.ipynb
9
+ ```
10
+
11
+ ## Deploy to Posit Connect
12
+
13
+ ```
14
+ rsconnect deploy pyproject .
15
+ ```