From b8cc1453653139ba7fa69faa10fcdf87de46f88d Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 12 May 2026 12:17:50 +0200 Subject: [PATCH 01/26] Probe driven development plan --- rsconnect/main.py | 105 ++++ rsconnect/models.py | 20 + rsconnect/pyproject.py | 32 ++ rsconnect/quickstart.py | 385 +++++++++++++ rsconnect/quickstart_templates/__init__.py | 32 ++ tests/smoke_boot_harness.py | 33 ++ tests/test_deploy_pyproject.py | 418 ++++++++++++++ tests/test_quickstart.py | 605 +++++++++++++++++++++ 8 files changed, 1630 insertions(+) create mode 100644 rsconnect/quickstart.py create mode 100644 rsconnect/quickstart_templates/__init__.py create mode 100644 tests/smoke_boot_harness.py create mode 100644 tests/test_deploy_pyproject.py create mode 100644 tests/test_quickstart.py diff --git a/rsconnect/main.py b/rsconnect/main.py index b2dc0dcc..d8e8d4df 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1020,6 +1020,46 @@ def info(file: str): click.echo("No saved deployment information was found for %s." % file) +# TODO(EVO-010): Register the ``rsconnect quickstart`` Click command. +# Scope: quickstart +# Why: SPEC §2 fixes the command shape +# ``rsconnect quickstart `` with positional +# (not optional) arguments, a ``--static`` flag for the +# ``jupyter`` type (§2.1), and a ``--shiny`` flag for the +# ``quarto`` type (§2.1). Wiring the CLI up front makes +# later evolutions purely about implementation, not +# command registration. +# Done: Tests ``test_quickstart_command_is_registered``, +# ``test_quickstart_requires_type_and_name``, and the +# ``--help`` assertion in ``tests/test_quickstart.py`` +# pass. The command delegates to +# :func:`rsconnect.quickstart.run_quickstart`. +# Non-Goals: Do not add ``--deploy``, ``--force``, or any +# interactive prompts (§1.1 / §16). Do not wire +# server credential flags - quickstart does not +# talk to Connect. +@cli.command( + name="quickstart", + short_help="Scaffold a deployable Posit Connect project.", + help=( + "Create a new Posit Connect project of the given type in .//. " + "Writes a pyproject.toml with a [tool.rsconnect] section, creates a " + "uv-managed virtualenv, and prints the local-run and deploy commands. " + "See SPEC_QUICKSTART.md for the full contract." + ), + no_args_is_help=True, +) +@click.argument("app_type", metavar="TYPE") +@click.argument("name", metavar="NAME") +@click.option("--static", is_flag=True, help="(jupyter only) emit jupyter-static instead of jupyter-notebook.") +@click.option("--shiny", is_flag=True, help="(quarto only) emit quarto-shiny instead of quarto-static.") +@cli_exception_handler +def quickstart(app_type: str, name: str, static: bool, shiny: bool): + from .quickstart import run_quickstart + + run_quickstart(app_type=app_type, name=name, static=static, shiny=shiny) + + @cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.") def deploy(): pass @@ -1484,6 +1524,71 @@ def deploy_manifest( ce.verify_deployment() +# TODO(EVO-020): Register ``rsconnect deploy pyproject `` command. +# Scope: deploy-pyproject +# Why: SPEC §13.1 defines the command parallel to +# ``rsconnect deploy manifest ``: ```` points +# to a directory containing ``pyproject.toml``. The +# command reads ``[tool.rsconnect]`` and dispatches to +# the per-type deploy code path, reusing existing bundling +# and environment resolution. +# Done: Tests ``test_deploy_pyproject_command_is_registered`` +# and ``test_deploy_pyproject_requires_path`` in +# ``tests/test_deploy_pyproject.py`` pass. The command +# accepts the full set of ``server_args`` / ``spcs_args`` +# / ``content_args`` decorators that ``deploy manifest`` +# accepts, so existing credential mechanisms apply. +# Non-Goals: Do not duplicate the per-type deploy logic here; +# delegate to the dispatcher evolution below. Do +# not default ```` to ``.`` silently in v1 - +# keep the positional required to mirror +# ``deploy manifest``. + + +# TODO(EVO-030): Dispatch by app_mode in deploy pyproject. +# Scope: deploy-pyproject +# Why: SPEC §13.2 step 3-5: after reading ``[tool.rsconnect]``, +# the command must route to the per-type deploy code path, +# override the entrypoint with the pyproject value, and +# set the Connect title from the table - bypassing the +# per-type entrypoint-guessing logic that the existing +# ``deploy notebook`` / ``deploy api`` / ``deploy quarto`` +# commands carry. Reusing the bundling/environment +# resolution introduced in PR 764 is the whole reason +# the companion command exists. +# Done: Tests +# ``test_deploy_pyproject_dispatches_streamlit``, +# ``test_deploy_pyproject_dispatches_fastapi``, +# ``test_deploy_pyproject_dispatches_notebook_static``, +# ``test_deploy_pyproject_dispatches_quarto_shiny`` +# (etc., one per supported app_mode) pass. Each test +# asserts that the correct bundle builder is called with +# the entrypoint from ``[tool.rsconnect]``. +# Non-Goals: Do not re-implement bundling; reuse +# ``make_api_bundle``, ``make_notebook_source_bundle``, +# etc. Do not invent new ``app_mode`` values; the +# dispatch table is just the §8.2 vocabulary. + + +# TODO(EVO-040): Hard-error when [tool.rsconnect] is missing or incomplete. +# Scope: deploy-pyproject +# Why: SPEC §13.3 forbids inference: if the section or any of +# ``app_mode``/``entrypoint`` is missing, the command +# must exit non-zero with a message that (a) says what +# is missing, (b) quotes the minimum valid snippet, and +# (c) mentions ``rsconnect quickstart --help``. This is +# the most user-visible guardrail of the whole feature. +# Done: Tests +# ``test_deploy_pyproject_errors_on_missing_section``, +# ``test_deploy_pyproject_errors_on_missing_app_mode``, +# ``test_deploy_pyproject_errors_on_missing_entrypoint``, +# and ``test_deploy_pyproject_error_message_mentions_quickstart`` +# in ``tests/test_deploy_pyproject.py`` pass. +# Non-Goals: Do not attempt to recover by reading +# ``manifest.json`` if it happens to exist; §13.3 +# rules out fallback entirely. + + # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="quarto", diff --git a/rsconnect/models.py b/rsconnect/models.py index fc23f02b..1eec33a3 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -88,6 +88,26 @@ class AppModes: PLUMBER = AppMode(5, "api", "API") TENSORFLOW = AppMode(6, "tensorflow-saved-model", "TensorFlow Model") JUPYTER_NOTEBOOK = AppMode(7, "jupyter-static", "Jupyter Notebook", ".ipynb") + # TODO(EVO-050): Add ``jupyter-notebook`` app_mode and thread it through typing. + # Scope: shared + # Why: SPEC §4 / §8.2 introduce ``jupyter-notebook`` as the + # default ``[tool.rsconnect].app_mode`` for the + # ``notebook`` quickstart type (``jupyter-static`` is + # now the ``--static`` variant). Today ``AppModes`` + # only has ``jupyter-static``, and + # ``AppModes.Modes`` (the ``Literal`` used by + # pyright-strict code) does not include it. Both the + # quickstart writer and the ``deploy pyproject`` + # dispatcher depend on this new value being valid. + # Done: ``AppModes.get_by_name("jupyter-notebook")`` returns + # a non-unknown mode; ``AppModes.Modes`` accepts the + # literal; test ``test_models_has_jupyter_notebook_mode`` + # in ``tests/test_quickstart.py`` (or a small addition + # to ``tests/test_models.py``) passes. + # Non-Goals: Do not rename ``JUPYTER_NOTEBOOK`` - that + # attribute name is load-bearing elsewhere. Do + # not change ``jupyter-static``'s existing + # ordinal or description. PYTHON_API = AppMode(8, "python-api", "Python API") DASH_APP = AppMode(9, "python-dash", "Dash Application") STREAMLIT_APP = AppMode(10, "python-streamlit", "Streamlit Application") diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 6252ddd9..a391e379 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -159,3 +159,35 @@ def _adapt_contraint(constraints: typing.List[str]) -> typing.Generator[str, Non class InvalidVersionConstraintError(ValueError): pass + + +# TODO(EVO-060): Read [tool.rsconnect] from pyproject.toml. +# Scope: deploy-pyproject +# Why: SPEC §3 + §13.2 require ``rsconnect deploy pyproject`` +# to consume ``app_mode``, ``entrypoint``, and ``title`` +# from a ``[tool.rsconnect]`` table and hard-error when it +# is missing or incomplete (§13.3). Putting the reader +# here keeps it next to the existing pyproject helpers +# (``parse_pyproject_python_requires``) which already own +# the tomllib import path. The public deploy command calls +# this single function to resolve its config. +# Done: Tests in ``tests/test_deploy_pyproject.py`` named +# ``test_read_tool_rsconnect_*`` pass: valid tables +# return a value with ``app_mode``/``entrypoint``/ +# ``title``; missing section raises a clear exception; +# missing ``app_mode`` or ``entrypoint`` raises the same +# clear exception carrying the minimum-valid-snippet text +# required by §13.3. +# Non-Goals: Do not accept alternative names +# (no ``.rsconnect.toml`` fallback per §1.1). Do not +# infer ``app_mode`` from file extensions - §13.3 +# forbids inference. Do not validate the canonical +# ``app_mode`` vocabulary here beyond "non-empty +# string"; the deploy dispatcher owns that mapping. +def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, str]: + """Placeholder for the ``[tool.rsconnect]`` reader. + + Raises NotImplementedError until the evolution above lands; the ATDD tests + in ``tests/test_deploy_pyproject.py`` are structured around that fact. + """ + raise NotImplementedError("read_tool_rsconnect is not yet implemented; see TODO(EVO-...) in rsconnect/pyproject.py") diff --git a/rsconnect/quickstart.py b/rsconnect/quickstart.py new file mode 100644 index 00000000..3127c7b9 --- /dev/null +++ b/rsconnect/quickstart.py @@ -0,0 +1,385 @@ +""" +rsconnect quickstart: scaffold a deployable Posit Connect project. + +This module is the deep boundary for the ``rsconnect quickstart`` command. +It owns the whole scaffolding flow: pre-flight checks, template rendering, +``pyproject.toml`` generation, ``uv``-based venv population, atomic rollback +on failure, and the post-scaffold console output. + +Public entrypoint: :func:`run_quickstart`. Callers (the Click command in +``rsconnect/main.py``) should not need to import anything else from this +module. + +See ``SPEC_QUICKSTART.md`` at the repository root for the full product +contract. +""" + +from __future__ import annotations + +import pathlib +import typing + +# TODO(EVO-070): Define the public QuickstartRequest type or simple call contract. +# Scope: quickstart +# Why: Establish a single validated value that downstream phases +# (pre-flight, scaffold, post-scaffold output) consume so the +# capability is understandable through one public entrypoint +# downward. Carrying , , and the type-specific +# flag state (jupyter --static, quarto --shiny) as one value +# keeps the CLI layer thin and the deep module self-contained. +# Done: A value (or plain kwargs on ``run_quickstart``) captures +# the validated CLI inputs; the tests in +# ``tests/test_quickstart.py`` that exercise name/type +# validation via CliRunner pass for the error branches +# without needing any scaffolding work. +# Non-Goals: Do not introduce a public ``QuickstartOptions`` / +# ``QuickstartContext`` passive data bag; do not add +# dependency injection; keep this tight to the real +# product concept. + + +def run_quickstart( + app_type: str, + name: str, + *, + static: bool = False, + shiny: bool = False, + cwd: typing.Optional[pathlib.Path] = None, +) -> pathlib.Path: + """Scaffold a new Connect project of ``app_type`` named ``name``. + + Returns the absolute path to the created project directory on success. + Raises :class:`rsconnect.exception.RSConnectException` on any pre-flight + or scaffold failure; rollback of the partially-created directory is the + caller-visible invariant defined in SPEC §11. + + This is currently a probe stub: it raises NotImplementedError so that the + CLI command registers and help text renders, while the ATDD test suite in + ``tests/test_quickstart.py`` fails in the expected way until each + evolution below is applied. + """ + # TODO(EVO-080): Implement the quickstart pipeline. + # Scope: quickstart + # Why: This is the public entrypoint the Click command + # delegates to. Keeping the full flow visible here + # (pre-flight -> scaffold -> venv -> post-output -> + # rollback-on-failure) is the "contract before detail" + # shape the reviewer should see from this module alone. + # Done: Calling ``run_quickstart`` with valid inputs creates + # the directory tree per SPEC §5/§6, writes a + # pyproject.toml per §3/§8.2, runs ``uv venv`` + ``uv + # sync``, and returns the project path. The ATDD tests + # in ``tests/test_quickstart.py`` named + # ``test_quickstart_creates_*`` pass. + # Non-Goals: Do not implement framework-specific templates + # here (that is separate per-mode evolutions); + # do not implement the ``deploy pyproject`` + # command (it has its own evolutions); do not add + # interactive prompts or a ``--deploy`` flag. + raise NotImplementedError( + "rsconnect quickstart is not yet implemented; see SPEC_QUICKSTART.md and " + "TODO(EVO-...) markers in rsconnect/quickstart.py" + ) + + +# --------------------------------------------------------------------------- +# Pre-flight checks (SPEC §10) +# --------------------------------------------------------------------------- +# +# Below are the five ordered pre-flight checks the spec requires. They live +# together because they are phases of one capability ("can we scaffold?") and +# should run in the documented order before any filesystem mutation. Each +# check has its own evolution so the implementer can land them one at a time +# and the ATDD tests for each failure branch can graduate independently. + + +# TODO(EVO-090): Pre-flight check 1 - require uv on PATH. +# Scope: quickstart +# Why: SPEC §7 and §10 make ``uv`` the sole dependency-manager +# path. Detecting its absence up front gives an actionable +# message before any work starts and keeps the rest of the +# flow from having to re-check. The install hint is part +# of the user-visible contract. +# Done: Tests ``test_quickstart_requires_uv_on_path`` and +# ``test_quickstart_uv_missing_message_names_install`` in +# ``tests/test_quickstart.py`` pass. Exit code is +# non-zero; stderr names ``uv`` and the install command. +# Non-Goals: Do not add a fallback to ``python -m venv`` + pip. +# Do not probe ``uv --version`` compatibility; mere +# presence on PATH is sufficient for v1. + + +# TODO(EVO-100): Pre-flight check 2 - validate against supported list. +# Scope: quickstart +# Why: SPEC §2.3 + §4 enumerate the eight v1 CLI type values +# (streamlit, shiny, fastapi, api, flask, notebook, voila, +# quarto). Unknown types must exit with a message listing +# the supported ones so the user can self-correct without +# reading docs. +# Done: Test ``test_quickstart_unknown_type_lists_supported`` +# in ``tests/test_quickstart.py`` passes: the error lists +# every supported CLI type, and ``flask`` is accepted as +# an alias for ``api``. +# Non-Goals: Do not advertise the four deferred modes (dash, +# gradio, panel, bokeh) - they are intentionally not +# in v1 per §4.1. + + +# TODO(EVO-110): Pre-flight check 3 - validate against PEP 508 subset. +# Scope: quickstart +# Why: SPEC §2.2 restricts names to +# ``^[a-z][a-z0-9-]*[a-z0-9]$`` (lowercase start, lowercase +# alphanumerics and hyphens, no trailing hyphen). Enforcing +# this before scaffolding prevents generating a project +# whose pyproject.toml would be invalid. +# Done: Tests ``test_quickstart_rejects_invalid_name_*`` in +# ``tests/test_quickstart.py`` pass for uppercase, +# leading-digit, underscore, trailing-hyphen, and empty +# name inputs. Error message states the rule verbatim. +# Non-Goals: Do not allow underscores (they are valid in PEP 508 +# but the spec narrows to hyphens for distribution +# friendliness); do not normalize (no auto-lowercase). + + +# TODO(EVO-120): Pre-flight check 4 - target directory must not exist. +# Scope: quickstart +# Why: SPEC §2 forbids in-place scaffolding and §10 lists this +# as a fatal pre-flight check. Catching it before any +# template work preserves the atomicity invariant (§11/I8) +# trivially: there is nothing to roll back. +# Done: Test ``test_quickstart_fails_when_directory_exists`` in +# ``tests/test_quickstart.py`` passes; the directory the +# user already had is untouched; the error suggests a +# different name or removing the existing directory. +# Non-Goals: Do not add a ``--force`` flag; the spec explicitly +# rejects overwriting. + + +# TODO(EVO-130): Pre-flight check 5 - current working directory is writable. +# Scope: quickstart +# Why: SPEC §10 step 5 requires a fail-fast permission check so +# readonly-cwd users see a clear error rather than a +# partial ``mkdir`` failure midway through scaffolding. +# Done: Test ``test_quickstart_requires_writable_cwd`` in +# ``tests/test_quickstart.py`` passes by asserting a +# non-zero exit and an actionable stderr when the current +# directory is read-only (the test creates a readonly +# temp dir and invokes the CLI from it). +# Non-Goals: Do not attempt fancy capability probing; a write +# attempt (or ``os.access(os.W_OK)``) is sufficient. + + +# --------------------------------------------------------------------------- +# Scaffolding phases (SPEC §5 / §6 / §9) +# --------------------------------------------------------------------------- +# +# After pre-flight succeeds, the scaffold phase creates the directory, +# materializes the template for the chosen mode, writes pyproject.toml with a +# valid [tool.rsconnect] section, seeds .python-version / .gitignore / +# README.md, then runs uv to populate .venv/. Each of these is a separate +# evolution so the devteam can land them in order and the ATDD suite has +# meaningful per-evolution acceptance signals. + + +# TODO(EVO-140): Create the project directory and always-present files. +# Scope: quickstart +# Why: SPEC §5.1 fixes a uniform "always present" set - +# pyproject.toml, .python-version, .gitignore, README.md - +# across every mode. Landing the mode-agnostic skeleton +# first makes later per-mode evolutions a pure "add source +# files" change. +# Done: Tests +# ``test_quickstart_generates_always_present_files`` and +# ``test_quickstart_gitignore_covers_rsconnect_dirs`` in +# ``tests/test_quickstart.py`` pass: the four files exist +# with the expected baseline content (including the +# rsconnect-specific ``.gitignore`` entries from §5.1). +# Non-Goals: Do not write mode-specific source files here; do +# not run ``uv`` yet; do not generate a +# ``manifest.json`` (I6). + + +# TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml. +# Scope: quickstart +# Why: SPEC §3 makes ``[tool.rsconnect]`` the sole configuration +# surface for ``deploy pyproject``. Writing ``app_mode``, +# ``entrypoint``, and ``title`` with the canonical values +# from §8.2 is the invariant that links quickstart output +# to the companion deploy command. +# Done: Tests ``test_quickstart_pyproject_has_tool_rsconnect``, +# ``test_quickstart_app_mode_for_``, and +# ``test_quickstart_does_not_duplicate_deps_in_tool_rsconnect`` +# pass. The generated table contains exactly the three +# required fields (no ``dependencies``, no +# ``requires-python`` duplication). +# Non-Goals: Do not add ``[tool.rsconnect.files]`` entries; +# §3.2 reserves the name for later. Do not encode +# server credentials. + + +# TODO(EVO-160): Register the streamlit template (script-style). +# Scope: quickstart +# Why: SPEC §4 / §6.2 / §12 define the streamlit template: +# one ``app.py`` with ``st.write("Hello world")``, no +# ``__connect__.py``, no ``__main__.py``; entrypoint +# ``"app.py"``; local-run ``uv run streamlit run app.py``. +# Land one script-style mode first so the scaffolding +# framework is proven end to end. +# Done: Test ``test_quickstart_streamlit_file_set`` passes +# (only the expected files exist) and +# ``test_quickstart_streamlit_post_scaffold_output`` +# asserts the post-scaffold stdout quotes the documented +# local-run and deploy commands verbatim. +# Non-Goals: Do not delegate to ``streamlit create`` - templates +# are owned per §9.1. + + +# TODO(EVO-170): Register the shiny template (script-style). +# Scope: quickstart +# Why: SPEC §4 / §6.2 define the Python Shiny template: a +# single ``app.py`` with a Shiny Express or Core +# hello-world; entrypoint ``"app.py"``; local-run +# ``uv run shiny run app.py``; app_mode ``python-shiny``. +# Done: Test ``test_quickstart_shiny_file_set`` and +# ``test_quickstart_shiny_post_scaffold_output`` pass. +# Non-Goals: Do not pick between Shiny Express and Core via a +# flag - pick one idiomatic hello-world per §9.2 +# and document it. + + +# TODO(EVO-180): Register the fastapi template (module-style). +# Scope: quickstart +# Why: SPEC §6.1 defines the module-style shape: ``app.py`` +# with a ``create_app()`` factory, ``__connect__.py`` +# exposing ``app = create_app()``, ``__main__.py`` that +# runs uvicorn locally. Entrypoint is ``__connect__:app``; +# local-run is ``uv run python -m ``. Landing one +# module-style mode proves the shim pattern. +# Done: Tests ``test_quickstart_fastapi_file_set``, +# ``test_quickstart_fastapi_entrypoint_is_connect_app``, +# and ``test_quickstart_fastapi_main_runs_uvicorn`` pass. +# Non-Goals: Do not inline uvicorn as a runtime dependency in +# ``app.py``; it belongs behind ``__main__.py`` so +# ``app.py`` stays framework-idiomatic. + + +# TODO(EVO-190): Register the api / flask template (module-style, alias-aware). +# Scope: quickstart +# Why: SPEC §4 lists ``api`` with alias ``flask``; both produce +# the same scaffold and app_mode ``python-api``. Module +# shape mirrors fastapi: ``app.py`` factory, +# ``__connect__.py`` shim, ``__main__.py`` runs Flask's +# built-in server. +# Done: Tests +# ``test_quickstart_flask_alias_maps_to_api_mode``, +# ``test_quickstart_api_file_set``, and +# ``test_quickstart_api_main_runs_flask_dev_server`` pass. +# Non-Goals: Do not use a production WSGI server in +# ``__main__.py`` - it is explicitly the dev server. + + +# TODO(EVO-200): Register the notebook template (jupyter, --static flag aware). +# Scope: quickstart +# Why: SPEC §2.1 + §4 + §6.3: ``jupyter`` accepts ``--static`` +# which flips app_mode between ``jupyter-notebook`` +# (default) and ``jupyter-static``. The template generates +# ``notebook.ipynb`` with a couple of cells; entrypoint is +# ``notebook.ipynb``. This introduces a *new* app_mode +# name (``jupyter-notebook``) that does not currently exist +# in ``rsconnect/models.py::AppModes``. +# Done: Tests ``test_quickstart_notebook_default_app_mode``, +# ``test_quickstart_notebook_static_flag_sets_mode``, and +# ``test_quickstart_notebook_file_set`` pass. +# Non-Goals: Do not render the notebook at scaffold time; the +# local-run command handles rendering. + + +# TODO(EVO-210): Register the voila template (jupyter-voila). +# Scope: quickstart +# Why: SPEC §4 + §6.3 + §12: voila reuses ``notebook.ipynb`` as +# the entrypoint but with app_mode ``jupyter-voila`` and +# local-run ``uv run voila notebook.ipynb``. +# Done: Tests ``test_quickstart_voila_file_set`` and +# ``test_quickstart_voila_app_mode`` pass. +# Non-Goals: Do not duplicate the notebook template file - share +# it via the template-registry layout. + + +# TODO(EVO-220): Register the quarto template (--shiny flag aware). +# Scope: quickstart +# Why: SPEC §2.1 + §4 + §6.3: ``quarto`` defaults to static +# (app_mode ``quarto-static``); ``--shiny`` flips to +# ``quarto-shiny``. Both variants generate ``report.qmd`` +# with a minimal Quarto document. Local-run is +# ``uv run quarto preview report.qmd`` either way. +# Done: Tests ``test_quickstart_quarto_default_static``, +# ``test_quickstart_quarto_shiny_flag_sets_mode``, and +# ``test_quickstart_quarto_file_set`` pass. +# Non-Goals: Do not shell out to ``quarto create-project`` +# (§9.1 - templates are owned). + + +# TODO(EVO-230): Define the template registry layout and extension contract. +# Scope: quickstart +# Why: SPEC §4.1 requires adding the four deferred modes (dash, +# gradio, panel, bokeh) to reduce to "drop a template +# directory plus one registration line." The registry is +# the shared shape - how a template declares its files, +# its app_mode, its entrypoint form, and its local-run +# command - so per-mode evolutions above can all plug in. +# Done: The per-mode evolutions above each consume the +# registry; adding a hypothetical ninth mode in +# ``tests/test_quickstart.py::test_quickstart_registry_accepts_new_mode`` +# (an in-test registry insertion) works without touching +# non-registry code. +# Non-Goals: Do not ship the four deferred modes in v1; the +# registry exists so *future* work is small, not so +# this PR is big. + + +# TODO(EVO-240): Run ``uv venv`` + ``uv sync`` inside the scaffolded directory. +# Scope: quickstart +# Why: SPEC §5.1 / §7 / I5 require a populated ``.venv/`` so the +# documented local-run command works immediately without +# any extra setup step. This is what makes the project +# actually "ready-to-deploy." +# Done: Test ``test_quickstart_creates_populated_venv`` in +# ``tests/test_quickstart.py`` passes: ``.venv/`` exists +# and the declared dependencies are importable from it. +# Failure from ``uv`` triggers the rollback evolution. +# Non-Goals: Do not reimplement venv creation; shell out to +# ``uv``. Do not gate on Python-version availability - +# §10 delegates that to uv's own output. + + +# TODO(EVO-250): Implement atomic rollback of .// on any failure. +# Scope: quickstart +# Why: SPEC §11 + I8 require that any failure after directory +# creation leaves no partial project behind. Keeping this +# in one place (the public entrypoint's try/finally frame) +# preserves the "one deep module" shape - callers do not +# have to know about rollback. +# Done: Test +# ``test_quickstart_rolls_back_directory_on_uv_failure`` +# in ``tests/test_quickstart.py`` passes (a forced uv +# failure leaves no ``.//``). Ancestor directories +# and uv cache state are untouched per §11. +# Non-Goals: Do not roll back uv cache writes or ancestor +# directories; do not catch and swallow the error +# (I9 requires non-zero exit). + + +# TODO(EVO-260): Emit the post-scaffold confirmation and command lines. +# Scope: quickstart +# Why: SPEC §12 + I7 require three stdout lines: confirmation, +# local-run command, deploy command - verbatim per the §12 +# table. The generated README.md must carry the same two +# commands. +# Done: Tests ``test_quickstart__post_scaffold_output`` +# (one per mode) and +# ``test_quickstart_readme_matches_post_scaffold_output`` +# pass. The exit code is zero only when these lines have +# been printed. +# Non-Goals: Do not colorize aggressively; do not add a +# "next steps" multi-paragraph block - §12 caps the +# output at three lines. diff --git a/rsconnect/quickstart_templates/__init__.py b/rsconnect/quickstart_templates/__init__.py new file mode 100644 index 00000000..9a5a8094 --- /dev/null +++ b/rsconnect/quickstart_templates/__init__.py @@ -0,0 +1,32 @@ +""" +Template data for :mod:`rsconnect.quickstart`. + +This package hosts the on-disk template files for every supported app mode. +It is deliberately a package (not a single module) so each mode can live in +its own subdirectory and the registry stays "drop in a directory to add a +mode" per SPEC_QUICKSTART.md §4.1. The package is internal to +``rsconnect.quickstart``; callers should not import from it directly. + +See ``rsconnect/quickstart.py`` for the public entrypoint and the evolution +marker that defines the registry contract. +""" + +# TODO(EVO-270): Decide template storage format and ship the v1 templates. +# Scope: quickstart +# Why: SPEC §17.5 leaves the choice open (plain copy with +# string substitution, Jinja2, Tempita, ...). v1 needs one +# concrete choice plus the eight supported-mode templates +# (streamlit, shiny, fastapi, api/flask, notebook, voila, +# quarto-static, quarto-shiny). Templates must be +# discoverable at runtime (either as ``package_data`` or +# via ``importlib.resources``) so they survive wheel +# install. +# Done: Every per-mode evolution in ``rsconnect/quickstart.py`` +# (``Register the template ...``) has its +# template files materialized here; the ATDD tests in +# ``tests/test_quickstart.py`` that assert on generated +# file contents pass. +# Non-Goals: Do not introduce a template engine when plain +# string substitution suffices; do not add build +# steps; do not mix R or Node templates in (v1 is +# Python-only per §16). diff --git a/tests/smoke_boot_harness.py b/tests/smoke_boot_harness.py new file mode 100644 index 00000000..5145a314 --- /dev/null +++ b/tests/smoke_boot_harness.py @@ -0,0 +1,33 @@ +""" +Placeholder module for the per-mode boot smoke test harness (SPEC §14.1). + +The harness will: + +1. Run ``rsconnect quickstart `` into a temp directory. +2. Invoke the documented local-run command per §12 as a subprocess. +3. Assert a mode-appropriate readiness signal (HTTP GET for Streamlit / Shiny / + FastAPI / Flask / Voila; artifact existence for notebook / Quarto). +4. Terminate the subprocess and clean up. + +This module is intentionally empty today; the ATDD tests in +``tests/test_quickstart.py`` under ``test_quickstart_per_mode_boot_smoke`` are +skipped until this harness exists. +""" + +# TODO(EVO-280): Build the per-mode boot smoke test harness. +# Scope: quickstart +# Why: SPEC §14.1 makes the per-mode boot test part of v1 +# scope. Without it, no test proves I4 ("Locally +# runnable") or that any given template actually boots - +# regressions from framework releases (§14.2) would go +# unnoticed. The harness owns subprocess management, +# port selection / HTTP poll, and artifact assertions so +# the per-mode tests stay short. +# Done: Tests ``test_quickstart_per_mode_boot_smoke`` in +# ``tests/test_quickstart.py`` stop being skipped and +# pass on CI for every supported mode. Failures from a +# framework release break CI on the next run per §14.2. +# Non-Goals: Do not pin framework versions (§14.2). Do not add +# integration tests against a real Connect server +# (§14.3 defers that). Do not add golden-file diffs +# (§14.3 defers those too). diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py new file mode 100644 index 00000000..e8154be7 --- /dev/null +++ b/tests/test_deploy_pyproject.py @@ -0,0 +1,418 @@ +# probedev: ignore-file +# The xfail/skip reasons below cite ``TODO(EVO-###)`` markers by text. The real +# evolution markers live in ``rsconnect/``; the strings here are pointers, not +# new markers. +""" +Acceptance tests for ``rsconnect deploy pyproject`` (SPEC_QUICKSTART.md §13). + +Tests exercise the CLI via ``click.testing.CliRunner`` and the pure reader +(:func:`rsconnect.pyproject.read_tool_rsconnect`) directly. They follow the +shape of ``tests/test_pyproject.py`` (fixture/parametrize-driven) and +``tests/test_main.py`` (Click invocation). + +Every test is marked ``xfail`` with a pointer to the evolution that unblocks +it; the feature is not yet implemented. +""" + +from __future__ import annotations + +import pathlib +import textwrap +import typing + +import pytest +from click.testing import CliRunner + +from rsconnect.main import cli + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def project_dir(tmp_path: pathlib.Path) -> pathlib.Path: + """A fresh directory; tests populate ``pyproject.toml`` as they need.""" + project = tmp_path / "hello-app" + project.mkdir() + return project + + +def _write_pyproject(project: pathlib.Path, body: str) -> None: + (project / "pyproject.toml").write_text(textwrap.dedent(body)) + + +# --------------------------------------------------------------------------- +# Command shape (SPEC §13.1) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-020): Register ``rsconnect deploy pyproject `` command.", +) +def test_deploy_pyproject_command_is_registered(runner: CliRunner): + result = runner.invoke(cli, ["deploy", "pyproject", "--help"]) + assert result.exit_code == 0, result.output + assert "pyproject" in result.output.lower() + + +def test_deploy_pyproject_requires_path(runner: CliRunner): + """The positional is required, matching ``deploy manifest ``. + + This passes today because the missing command also exits non-zero; once + EVO-020 lands the same assertion still holds for the real command. + """ + result = runner.invoke(cli, ["deploy", "pyproject"]) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# [tool.rsconnect] reader (SPEC §3 / §13.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-060): Read [tool.rsconnect] from pyproject.toml.", +) +def test_read_tool_rsconnect_returns_three_fields(project_dir: pathlib.Path): + from rsconnect.pyproject import read_tool_rsconnect + + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + dependencies = ["streamlit"] + + [tool.rsconnect] + app_mode = "python-streamlit" + entrypoint = "app.py" + title = "My Hello App" + """, + ) + config = read_tool_rsconnect(project_dir / "pyproject.toml") + assert config["app_mode"] == "python-streamlit" + assert config["entrypoint"] == "app.py" + assert config["title"] == "My Hello App" + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-060): Reader raises on missing [tool.rsconnect] section (paired with EVO-040 CLI handling).", +) +def test_read_tool_rsconnect_missing_section_raises(project_dir: pathlib.Path): + from rsconnect.pyproject import read_tool_rsconnect + + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + """, + ) + with pytest.raises(Exception) as excinfo: + read_tool_rsconnect(project_dir / "pyproject.toml") + assert "tool.rsconnect" in str(excinfo.value).lower() or "rsconnect" in str(excinfo.value).lower() + + +@pytest.mark.parametrize( + "missing_field,body", + [ + ( + "app_mode", + """ + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + entrypoint = "app.py" + """, + ), + ( + "entrypoint", + """ + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + app_mode = "python-streamlit" + """, + ), + ], + ids=["missing-app_mode", "missing-entrypoint"], +) +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-060): Reader raises on missing required field (paired with EVO-040).", +) +def test_read_tool_rsconnect_missing_required_field_raises(project_dir: pathlib.Path, missing_field: str, body: str): + from rsconnect.pyproject import read_tool_rsconnect + + _write_pyproject(project_dir, body) + with pytest.raises(Exception) as excinfo: + read_tool_rsconnect(project_dir / "pyproject.toml") + assert missing_field in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# CLI behavior on missing / invalid config (SPEC §13.3) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-040): Hard-error CLI surface when [tool.rsconnect] is missing.", +) +def test_deploy_pyproject_errors_on_missing_section(runner: CliRunner, project_dir: pathlib.Path): + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + """, + ) + result = runner.invoke(cli, ["deploy", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "[tool.rsconnect]" in combined + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-040): Hard-error CLI surface when app_mode is missing.", +) +def test_deploy_pyproject_errors_on_missing_app_mode(runner: CliRunner, project_dir: pathlib.Path): + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + entrypoint = "app.py" + """, + ) + result = runner.invoke(cli, ["deploy", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "app_mode" in combined + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-040): Hard-error CLI surface when entrypoint is missing.", +) +def test_deploy_pyproject_errors_on_missing_entrypoint(runner: CliRunner, project_dir: pathlib.Path): + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + app_mode = "python-streamlit" + """, + ) + result = runner.invoke(cli, ["deploy", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "entrypoint" in combined + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-040): Hard-error message mentions rsconnect quickstart (SPEC §13.3).", +) +def test_deploy_pyproject_error_message_mentions_quickstart(runner: CliRunner, project_dir: pathlib.Path): + """SPEC §13.3 requires the error to reference ``rsconnect quickstart --help``.""" + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + """, + ) + result = runner.invoke(cli, ["deploy", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "quickstart" in combined.lower() + + +# --------------------------------------------------------------------------- +# Dispatch by app_mode (SPEC §13.2) +# --------------------------------------------------------------------------- + + +DISPATCH_MATRIX: list[typing.Any] = [ + pytest.param("python-streamlit", "app.py", id="streamlit"), + pytest.param("python-shiny", "app.py", id="shiny"), + pytest.param("python-fastapi", "__connect__:app", id="fastapi"), + pytest.param("python-api", "__connect__:app", id="api"), + pytest.param("jupyter-notebook", "notebook.ipynb", id="jupyter-notebook"), + pytest.param("jupyter-static", "notebook.ipynb", id="jupyter-static"), + pytest.param("jupyter-voila", "notebook.ipynb", id="voila"), + pytest.param("quarto-static", "report.qmd", id="quarto-static"), + pytest.param("quarto-shiny", "report.qmd", id="quarto-shiny"), +] + + +@pytest.mark.parametrize("app_mode,entrypoint", DISPATCH_MATRIX) +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-030): Dispatch by app_mode in deploy pyproject.", +) +def test_deploy_pyproject_dispatches_by_app_mode( + runner: CliRunner, project_dir: pathlib.Path, app_mode: str, entrypoint: str, monkeypatch: pytest.MonkeyPatch +): + """Each app_mode must reach the matching deploy code path. + + The implementer chooses how to prove dispatch: patching the bundle builder, + observing the RSConnectExecutor call, or similar. This test asserts that + the command does not short-circuit to the wrong branch by using a + deliberately bad server URL and asserting the error surfaces from the + deploy path rather than from config parsing (i.e. we got past the reader). + """ + _write_pyproject( + project_dir, + f""" + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + app_mode = "{app_mode}" + entrypoint = "{entrypoint}" + title = "Dispatch Test" + """, + ) + # Deliberately unreachable server; the failure mode we care about is + # "tried to contact Connect" rather than "could not parse config". + result = runner.invoke( + cli, + [ + "deploy", + "pyproject", + str(project_dir), + "-s", + "http://127.0.0.1:1/unused", + "-k", + "fake-key", + ], + ) + combined = result.output + (result.stderr if result.stderr_bytes else "") + # Parse-time errors would mention tool.rsconnect / missing fields. Dispatch + # success means we got past the reader into deploy territory. + assert "[tool.rsconnect]" not in combined + assert result.exit_code != 0 # unreachable server guarantees non-zero + + +# --------------------------------------------------------------------------- +# Title / entrypoint override (SPEC §13.2 steps 4-5) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-030): Dispatch by app_mode in deploy pyproject (title).", +) +def test_deploy_pyproject_uses_title_from_tool_rsconnect( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """The Connect content title must come from ``[tool.rsconnect].title`` (§13.2 step 5).""" + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + app_mode = "python-streamlit" + entrypoint = "app.py" + title = "A Readable Title" + """, + ) + seen: dict[str, typing.Any] = {} + + # The implementer may structure this differently; the invariant is that + # the title flows from pyproject to the executor. Tests can patch the + # bundle builder or the executor constructor. A best-effort observation: + from rsconnect import api as api_mod + + real_init = api_mod.RSConnectExecutor.__init__ + + def spy_init(self: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> None: + seen["title"] = kwargs.get("title") + real_init(self, *args, **kwargs) + + monkeypatch.setattr(api_mod.RSConnectExecutor, "__init__", spy_init) + runner.invoke( + cli, + [ + "deploy", + "pyproject", + str(project_dir), + "-s", + "http://127.0.0.1:1/unused", + "-k", + "fake-key", + ], + ) + assert seen.get("title") == "A Readable Title" + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-030): Dispatch by app_mode in deploy pyproject (entrypoint override).", +) +def test_deploy_pyproject_uses_entrypoint_from_tool_rsconnect(runner: CliRunner, project_dir: pathlib.Path): + """Entrypoint in pyproject must bypass per-type guessing (§13.2 step 4). + + Observable signal: a module-style entrypoint ``__connect__:app`` is used + even though the project contains no ``app.py`` the guesser would pick up. + """ + _write_pyproject( + project_dir, + """ + [project] + name = "hello-app" + version = "0.0.1" + + [tool.rsconnect] + app_mode = "python-fastapi" + entrypoint = "custom_module:create_app" + title = "hello-app" + """, + ) + (project_dir / "custom_module.py").write_text("def create_app():\n return None\n") + result = runner.invoke( + cli, + [ + "deploy", + "pyproject", + str(project_dir), + "-s", + "http://127.0.0.1:1/unused", + "-k", + "fake-key", + ], + ) + combined = result.output + (result.stderr if result.stderr_bytes else "") + # We should not see an entrypoint-guessing error pointing to app.py. + assert "app.py" not in combined or "custom_module" in combined diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py new file mode 100644 index 00000000..afa72c86 --- /dev/null +++ b/tests/test_quickstart.py @@ -0,0 +1,605 @@ +# probedev: ignore-file +# The xfail/skip reasons below cite ``TODO(EVO-###)`` markers by text. The real +# evolution markers live in ``rsconnect/`` and ``tests/smoke_boot_harness.py``; +# the strings here are pointers, not new markers. This pragma keeps +# ``probedev list`` focused on the real plan. +""" +Acceptance tests for ``rsconnect quickstart`` (SPEC_QUICKSTART.md §§ 2-12, 14-15). + +Tests are written against the CLI using ``click.testing.CliRunner`` and inspect +externally observable behavior per SPEC §17.3: exit code, filesystem tree, +``pyproject.toml`` AST, stdout/stderr, and the populated ``.venv/``. They are +expected to fail today because the feature is not yet implemented; each test +cites the evolution marker that unblocks it via ``@pytest.mark.xfail``. + +Test layout mirrors ``tests/test_main.py`` (CliRunner) and ``tests/test_pyproject.py`` +(fixture- and parametrize-driven). +""" + +from __future__ import annotations + +import os +import pathlib +import re +import stat +import subprocess +import sys +import typing + +import pytest + +try: + import tomllib +except ImportError: # pragma: no cover - Python 3.10 fallback + import toml as tomllib # type: ignore[no-redef] + +from click.testing import CliRunner + +from rsconnect.main import cli + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def in_tmp_cwd(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: + """Run the CLI with ``tmp_path`` as the current working directory. + + Quickstart writes to ``.//`` in the CWD, so tests need a clean, + isolated directory for every invocation. + """ + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _invoke_quickstart(runner: CliRunner, *args: str): + return runner.invoke(cli, ["quickstart", *args], catch_exceptions=False) + + +def _read_pyproject(project_dir: pathlib.Path) -> typing.Mapping[str, typing.Any]: + return tomllib.loads((project_dir / "pyproject.toml").read_text()) + + +# --------------------------------------------------------------------------- +# Command shape (SPEC §2, §2.1) +# --------------------------------------------------------------------------- + + +def test_quickstart_command_is_registered(runner: CliRunner): + """The ``quickstart`` subcommand exists and has help text.""" + result = runner.invoke(cli, ["quickstart", "--help"]) + assert result.exit_code == 0, result.output + assert "quickstart" in result.output.lower() + assert "TYPE" in result.output + assert "NAME" in result.output + + +def test_quickstart_requires_type_and_name(runner: CliRunner, in_tmp_cwd: pathlib.Path): + """Both positional args are required; invoking with none prints help.""" + result = runner.invoke(cli, ["quickstart"]) + # Click's ``no_args_is_help`` yields exit code 0 or 2 depending on version; + # the important invariant is that it did not silently scaffold. + assert not (in_tmp_cwd / "unnamed").exists() + # Invoking with only the type must also fail loudly. + result = runner.invoke(cli, ["quickstart", "streamlit"]) + assert result.exit_code != 0 + + +def test_quickstart_help_exposes_static_and_shiny_flags(runner: CliRunner): + result = runner.invoke(cli, ["quickstart", "--help"]) + assert result.exit_code == 0, result.output + assert "--static" in result.output + assert "--shiny" in result.output + + +# --------------------------------------------------------------------------- +# Pre-flight checks (SPEC §10) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-090): Pre-flight check 1 - require uv on PATH.", +) +def test_quickstart_requires_uv_on_path(runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + """Pre-flight check 1: absent ``uv`` must produce a clear, actionable error.""" + monkeypatch.setenv("PATH", str(in_tmp_cwd)) # empty PATH so uv cannot be found + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "uv" in combined.lower() + assert not (in_tmp_cwd / "hello-app").exists() # I8: no partial dir on pre-flight failure + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-090): Pre-flight check 1 - require uv on PATH (install hint).", +) +def test_quickstart_uv_missing_message_names_install( + runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """The error message should include the recommended install command (SPEC §7).""" + monkeypatch.setenv("PATH", str(in_tmp_cwd)) + result = _invoke_quickstart(runner, "streamlit", "hello-app") + combined = result.output + (result.stderr if result.stderr_bytes else "") + # "install" or "astral" or the canonical install URL - any of these proves + # the message is actionable rather than a bare "not found". + assert re.search(r"install|astral|github\.com/astral-sh/uv", combined, re.IGNORECASE) + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-100): Pre-flight check 2 - validate against supported list.", +) +def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "nonesuch", "hello-app") + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + for expected in ("streamlit", "shiny", "fastapi", "api", "notebook", "voila", "quarto"): + assert expected in combined, f"{expected!r} missing from error output: {combined!r}" + assert not (in_tmp_cwd / "hello-app").exists() + + +@pytest.mark.parametrize( + "bad_name", + [ + "Hello", # uppercase + "1hello", # leading digit + "hello_world", # underscore + "hello-", # trailing hyphen + "-hello", # leading hyphen + "", # empty + "hello world", # whitespace + ], +) +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-110): Pre-flight check 3 - validate against PEP 508 subset.", +) +def test_quickstart_rejects_invalid_name(runner: CliRunner, in_tmp_cwd: pathlib.Path, bad_name: str): + result = _invoke_quickstart(runner, "streamlit", bad_name) + assert result.exit_code != 0 + assert not (in_tmp_cwd / bad_name).exists() + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-120): Pre-flight check 4 - target directory must not exist.", +) +def test_quickstart_fails_when_directory_exists(runner: CliRunner, in_tmp_cwd: pathlib.Path): + (in_tmp_cwd / "hello-app").mkdir() + (in_tmp_cwd / "hello-app" / "existing-file.txt").write_text("keep me") + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code != 0 + # The pre-existing file must be untouched (SPEC §11 Atomicity). + assert (in_tmp_cwd / "hello-app" / "existing-file.txt").read_text() == "keep me" + + +@pytest.mark.skipif(sys.platform == "win32", reason="chmod read-only semantics differ on Windows") +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-130): Pre-flight check 5 - current working directory is writable.", +) +def test_quickstart_requires_writable_cwd(runner: CliRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + readonly = tmp_path / "readonly" + readonly.mkdir() + readonly.chmod(stat.S_IRUSR | stat.S_IXUSR) + try: + monkeypatch.chdir(readonly) + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code != 0 + assert not (readonly / "hello-app").exists() + finally: + readonly.chmod(stat.S_IRWXU) + + +# --------------------------------------------------------------------------- +# Always-present generated files (SPEC §5.1) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-140): Create the project directory and always-present files.", +) +def test_quickstart_generates_always_present_files(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + project = in_tmp_cwd / "hello-app" + for name in ("pyproject.toml", ".python-version", ".gitignore", "README.md"): + assert (project / name).is_file(), f"{name} missing from {list(project.iterdir())}" + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-140): Create the project directory and always-present files (gitignore).", +) +def test_quickstart_gitignore_covers_rsconnect_dirs(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + gitignore = (in_tmp_cwd / "hello-app" / ".gitignore").read_text() + for expected in ("__pycache__", ".venv", "rsconnect-python", ".env"): + assert expected in gitignore, f"{expected} missing from .gitignore" + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-140): Invariant I6 - no manifest.json on scaffold.", +) +def test_quickstart_does_not_create_manifest_json(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + assert not (in_tmp_cwd / "hello-app" / "manifest.json").exists() + + +# --------------------------------------------------------------------------- +# pyproject.toml contents (SPEC §3 + §8.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml.", +) +def test_quickstart_pyproject_has_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + data = _read_pyproject(in_tmp_cwd / "hello-app") + assert data["project"]["name"] == "hello-app" + assert data["project"]["version"] == "0.0.1" + tool_rsconnect = data["tool"]["rsconnect"] + assert tool_rsconnect["app_mode"] == "python-streamlit" + assert tool_rsconnect["entrypoint"] == "app.py" + assert tool_rsconnect["title"] == "hello-app" + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml (no duplication).", +) +def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): + """SPEC §3.2: dependencies and requires-python live in [project], not in [tool.rsconnect].""" + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"] + assert "dependencies" not in tool_rsconnect + assert "requires-python" not in tool_rsconnect + assert "requires_python" not in tool_rsconnect + assert set(tool_rsconnect.keys()) == {"app_mode", "entrypoint", "title"} + + +# --------------------------------------------------------------------------- +# Per-mode app_mode matrix (SPEC §4 / §8.2) +# --------------------------------------------------------------------------- + + +APP_MODE_MATRIX = [ + pytest.param(("streamlit",), "python-streamlit", id="streamlit"), + pytest.param(("shiny",), "python-shiny", id="shiny"), + pytest.param(("fastapi",), "python-fastapi", id="fastapi"), + pytest.param(("api",), "python-api", id="api"), + pytest.param(("flask",), "python-api", id="flask-alias"), + pytest.param(("notebook",), "jupyter-notebook", id="notebook-default"), + pytest.param(("notebook", "--static"), "jupyter-static", id="notebook-static"), + pytest.param(("voila",), "jupyter-voila", id="voila"), + pytest.param(("quarto",), "quarto-static", id="quarto-default"), + pytest.param(("quarto", "--shiny"), "quarto-shiny", id="quarto-shiny"), +] + + +@pytest.mark.parametrize("cli_args,expected_mode", APP_MODE_MATRIX) +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml (per-mode app_mode).", +) +def test_quickstart_app_mode_for_each_type( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + cli_args: tuple[str, ...], + expected_mode: str, +): + # Put flags before NAME per Click convention. + args = [cli_args[0], *cli_args[1:], "hello-app"] + result = _invoke_quickstart(runner, *args) + assert result.exit_code == 0, result.output + assert _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"]["app_mode"] == expected_mode + + +# --------------------------------------------------------------------------- +# Per-category file sets (SPEC §6) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "app_type,expected_files,forbidden_files", + [ + ("streamlit", {"app.py"}, {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}), + ("shiny", {"app.py"}, {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}), + ("fastapi", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}), + ("api", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}), + ("notebook", {"notebook.ipynb"}, {"app.py", "__connect__.py", "__main__.py", "report.qmd"}), + ("voila", {"notebook.ipynb"}, {"app.py", "__connect__.py", "__main__.py", "report.qmd"}), + ("quarto", {"report.qmd"}, {"app.py", "__connect__.py", "__main__.py", "notebook.ipynb"}), + ], +) +@pytest.mark.xfail( + strict=False, + reason=( + "TODO(EVO-160..220): Register the per-mode templates " "(file set covered by EVO-160..220 - one per app mode)." + ), +) +def test_quickstart_mode_file_set( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + app_type: str, + expected_files: set[str], + forbidden_files: set[str], +): + result = _invoke_quickstart(runner, app_type, "hello-app") + assert result.exit_code == 0, result.output + project = in_tmp_cwd / "hello-app" + present = {p.name for p in project.iterdir() if p.is_file()} + for name in expected_files: + assert name in present, f"{name} missing; got {present}" + for name in forbidden_files: + assert name not in present, f"{name} unexpectedly present; SPEC §6 forbids it for {app_type}" + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-180): Register the fastapi template (module-style, entrypoint).", +) +def test_quickstart_fastapi_entrypoint_is_connect_app(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "fastapi", "hello-app") + assert result.exit_code == 0, result.output + tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"] + assert tool_rsconnect["entrypoint"] == "__connect__:app" + connect_py = (in_tmp_cwd / "hello-app" / "__connect__.py").read_text() + assert "create_app" in connect_py + assert "app = " in connect_py + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-180): Register the fastapi template (module-style, __main__).", +) +def test_quickstart_fastapi_main_runs_uvicorn(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "fastapi", "hello-app") + assert result.exit_code == 0, result.output + main_py = (in_tmp_cwd / "hello-app" / "__main__.py").read_text() + assert "uvicorn" in main_py + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-190): Register the api / flask template.", +) +def test_quickstart_api_main_runs_flask_dev_server(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "api", "hello-app") + assert result.exit_code == 0, result.output + main_py = (in_tmp_cwd / "hello-app" / "__main__.py").read_text() + assert "flask" in main_py.lower() or "app.run(" in main_py + + +# --------------------------------------------------------------------------- +# Venv population (SPEC §5.1, §7, I5) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-240): Run uv venv + uv sync inside the scaffolded directory.", +) +def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + project = in_tmp_cwd / "hello-app" + assert (project / ".venv").is_dir() + # A populated venv has a site-packages or pyvenv.cfg. + assert (project / ".venv" / "pyvenv.cfg").is_file() + + +# --------------------------------------------------------------------------- +# Atomicity on failure (SPEC §11, I8) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-250): Implement atomic rollback of .// on any failure.", +) +def test_quickstart_rolls_back_directory_on_uv_failure( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +): + """Force ``uv`` to fail and assert the project directory is removed.""" + fake_uv_dir = in_tmp_cwd / "fake-bin" + fake_uv_dir.mkdir() + fake_uv = fake_uv_dir / "uv" + fake_uv.write_text("#!/usr/bin/env bash\nexit 1\n") + fake_uv.chmod(0o755) + monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") + + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code != 0 + assert not (in_tmp_cwd / "hello-app").exists() # I8: all or nothing + + +# --------------------------------------------------------------------------- +# Post-scaffold output (SPEC §12, I7) +# --------------------------------------------------------------------------- + + +POST_SCAFFOLD_COMMANDS = [ + pytest.param("streamlit", (), "uv run streamlit run app.py", id="streamlit"), + pytest.param("shiny", (), "uv run shiny run app.py", id="shiny"), + pytest.param("fastapi", (), "uv run python -m hello-app", id="fastapi"), + pytest.param("api", (), "uv run python -m hello-app", id="api"), + pytest.param("flask", (), "uv run python -m hello-app", id="flask-alias"), + pytest.param("notebook", (), "uv run jupyter lab notebook.ipynb", id="notebook"), + pytest.param("voila", (), "uv run voila notebook.ipynb", id="voila"), + pytest.param("quarto", (), "uv run quarto preview report.qmd", id="quarto-default"), + pytest.param("quarto", ("--shiny",), "uv run quarto preview report.qmd", id="quarto-shiny"), +] + + +@pytest.mark.parametrize("app_type,extra_flags,local_run", POST_SCAFFOLD_COMMANDS) +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-260): Emit the post-scaffold confirmation and command lines.", +) +def test_quickstart_post_scaffold_output( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + app_type: str, + extra_flags: tuple[str, ...], + local_run: str, +): + result = _invoke_quickstart(runner, app_type, *extra_flags, "hello-app") + assert result.exit_code == 0, result.output + assert "hello-app" in result.output # confirmation line + assert local_run in result.output + assert "rsconnect deploy pyproject hello-app" in result.output + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-260): Emit the post-scaffold confirmation and command lines (README parity).", +) +def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + readme = (in_tmp_cwd / "hello-app" / "README.md").read_text() + assert "uv run streamlit run app.py" in readme + assert "rsconnect deploy pyproject hello-app" in readme + + +# --------------------------------------------------------------------------- +# Invariants (SPEC §15, I1-I10) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-080): Invariants I1-I2 - directory exists and pyproject is valid (covered by the full pipeline).", +) +def test_invariant_I1_I2_directory_and_pyproject(runner: CliRunner, in_tmp_cwd: pathlib.Path): + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code == 0, result.output + project = in_tmp_cwd / "hello-app" + assert project.is_dir() + data = _read_pyproject(project) + assert data["project"]["name"] == "hello-app" + for required in ("app_mode", "entrypoint", "title"): + assert required in data["tool"]["rsconnect"] + + +@pytest.mark.xfail( + strict=False, + reason=( + "TODO(EVO-080): Invariants I9-I10 - non-zero exit and actionable " + "stderr on failure (pipeline error translation)." + ), +) +def test_invariant_I9_I10_failure_exit_and_message(runner: CliRunner, in_tmp_cwd: pathlib.Path): + (in_tmp_cwd / "hello-app").mkdir() + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code != 0 # I9 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "hello-app" in combined # I10 - message names the failing check + + +# --------------------------------------------------------------------------- +# Template registry extensibility (SPEC §4.1) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-230): Define the template registry layout and extension contract.", +) +def test_quickstart_registry_accepts_new_mode( + runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A future template can be registered by inserting into the registry alone. + + This test is aspirational - it documents the extensibility invariant from + SPEC §4.1. The implementer decides the exact registry shape; what matters + is that inserting a ninth mode does not require touching pre-flight, + pyproject writing, or post-scaffold output modules. + """ + # Import here so the test collection does not fail before the module exists. + import rsconnect.quickstart as quickstart_mod # noqa: F401 + + # The implementer provides a registry accessor; the test asserts extension + # works without other code changes. Exact API is left to the evolution. + assert hasattr(quickstart_mod, "run_quickstart") + + +# --------------------------------------------------------------------------- +# New jupyter-notebook app_mode (SPEC §4, §8.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=False, + reason="TODO(EVO-050): Add jupyter-notebook app_mode and thread it through typing.", +) +def test_models_has_jupyter_notebook_mode(): + from rsconnect.models import AppModes + + mode = AppModes.get_by_name("jupyter-notebook") + assert mode is not None + assert str(mode) == "jupyter-notebook" + + +# --------------------------------------------------------------------------- +# Per-mode boot smoke tests (SPEC §14.1) +# --------------------------------------------------------------------------- + + +BOOT_SMOKE_MATRIX = [ + pytest.param("streamlit", ("streamlit", "run", "app.py"), "http", id="streamlit"), + pytest.param("shiny", ("shiny", "run", "app.py"), "http", id="shiny"), + pytest.param("fastapi", ("python", "-m", "hello-app"), "http", id="fastapi"), + pytest.param("api", ("python", "-m", "hello-app"), "http", id="api"), + pytest.param("voila", ("voila", "notebook.ipynb"), "http", id="voila"), + pytest.param("notebook", ("jupyter", "nbconvert", "--execute", "notebook.ipynb"), "artifact", id="notebook"), + pytest.param("quarto", ("quarto", "render", "report.qmd"), "artifact", id="quarto"), +] + + +@pytest.mark.parametrize("app_type,local_cmd,readiness", BOOT_SMOKE_MATRIX) +@pytest.mark.skip( + reason="TODO(EVO-280): Per-mode boot smoke test harness (SPEC §14.1).", +) +def test_quickstart_per_mode_boot_smoke( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + app_type: str, + local_cmd: tuple[str, ...], + readiness: str, +): + """Boot smoke test per SPEC §14.1. + + Implementation note: the evolution that graduates this test must add a + harness that (1) runs quickstart, (2) runs the documented local-run + command via ``uv run ...``, (3) asserts readiness - HTTP GET for web + modes, artifact existence for notebook/quarto - and (4) cleans up. Until + that harness exists, the tests stay skipped. + """ + result = _invoke_quickstart(runner, app_type, "hello-app") + assert result.exit_code == 0 + proc = subprocess.Popen(["uv", "run", *local_cmd], cwd=in_tmp_cwd / "hello-app") + try: + assert proc.poll() is None + finally: + proc.terminate() From 3127d446b998f0e95ca67f003b58d209c4150311 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 12 May 2026 15:45:33 +0200 Subject: [PATCH 02/26] rsconnect quickstart stub handler --- rsconnect/main.py | 18 ------------------ tests/test_quickstart.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index d8e8d4df..c9605113 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1020,24 +1020,6 @@ def info(file: str): click.echo("No saved deployment information was found for %s." % file) -# TODO(EVO-010): Register the ``rsconnect quickstart`` Click command. -# Scope: quickstart -# Why: SPEC §2 fixes the command shape -# ``rsconnect quickstart `` with positional -# (not optional) arguments, a ``--static`` flag for the -# ``jupyter`` type (§2.1), and a ``--shiny`` flag for the -# ``quarto`` type (§2.1). Wiring the CLI up front makes -# later evolutions purely about implementation, not -# command registration. -# Done: Tests ``test_quickstart_command_is_registered``, -# ``test_quickstart_requires_type_and_name``, and the -# ``--help`` assertion in ``tests/test_quickstart.py`` -# pass. The command delegates to -# :func:`rsconnect.quickstart.run_quickstart`. -# Non-Goals: Do not add ``--deploy``, ``--force``, or any -# interactive prompts (§1.1 / §16). Do not wire -# server credential flags - quickstart does not -# talk to Connect. @cli.command( name="quickstart", short_help="Scaffold a deployable Posit Connect project.", diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index afa72c86..b2325836 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -25,6 +25,7 @@ import subprocess import sys import typing +from unittest import mock import pytest @@ -99,6 +100,38 @@ def test_quickstart_help_exposes_static_and_shiny_flags(runner: CliRunner): assert "--shiny" in result.output +@pytest.mark.parametrize( + "args,expected", + [ + ( + ["streamlit", "hello-app"], + {"app_type": "streamlit", "name": "hello-app", "static": False, "shiny": False}, + ), + ( + ["notebook", "--static", "hello-notebook"], + {"app_type": "notebook", "name": "hello-notebook", "static": True, "shiny": False}, + ), + ( + ["quarto", "--shiny", "hello-quarto"], + {"app_type": "quarto", "name": "hello-quarto", "static": False, "shiny": True}, + ), + ], +) +def test_quickstart_delegates_to_run_quickstart( + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + args: typing.List[str], + expected: typing.Mapping[str, typing.Any], +): + run_quickstart = mock.Mock() + monkeypatch.setattr("rsconnect.quickstart.run_quickstart", run_quickstart) + + result = runner.invoke(cli, ["quickstart", *args]) + + assert result.exit_code == 0, result.output + run_quickstart.assert_called_once_with(**expected) + + # --------------------------------------------------------------------------- # Pre-flight checks (SPEC §10) # --------------------------------------------------------------------------- From 41da31fe7255bd7259feadd648bb8db533c739d0 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 12 May 2026 16:46:14 +0200 Subject: [PATCH 03/26] expose deploy pyproject command --- rsconnect/main.py | 70 +++++++++++++++++++++++++--------- tests/test_deploy_pyproject.py | 28 ++++++++++---- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index c9605113..586bf9de 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -11,6 +11,7 @@ import tempfile from functools import wraps from os.path import abspath, dirname, exists, isdir, join +from pathlib import Path from typing import ( Any, Callable, @@ -117,6 +118,7 @@ VersionSearchFilter, VersionSearchFilterParamType, ) +from .pyproject import read_tool_rsconnect from .environment import PackageInstaller from .shiny_express import escape_to_var_name, is_express_app from .utils_package import fix_starlette_requirements @@ -1506,25 +1508,55 @@ def deploy_manifest( ce.verify_deployment() -# TODO(EVO-020): Register ``rsconnect deploy pyproject `` command. -# Scope: deploy-pyproject -# Why: SPEC §13.1 defines the command parallel to -# ``rsconnect deploy manifest ``: ```` points -# to a directory containing ``pyproject.toml``. The -# command reads ``[tool.rsconnect]`` and dispatches to -# the per-type deploy code path, reusing existing bundling -# and environment resolution. -# Done: Tests ``test_deploy_pyproject_command_is_registered`` -# and ``test_deploy_pyproject_requires_path`` in -# ``tests/test_deploy_pyproject.py`` pass. The command -# accepts the full set of ``server_args`` / ``spcs_args`` -# / ``content_args`` decorators that ``deploy manifest`` -# accepts, so existing credential mechanisms apply. -# Non-Goals: Do not duplicate the per-type deploy logic here; -# delegate to the dispatcher evolution below. Do -# not default ```` to ``.`` silently in v1 - -# keep the positional required to mirror -# ``deploy manifest``. +@deploy.command( + name="pyproject", + short_help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io by pyproject.", + help=( + "Deploy content to Posit Connect, Posit Cloud, or shinyapps.io using a pyproject.toml " + "file. The specified directory must contain pyproject.toml. " + "See SPEC_QUICKSTART.md §13 for the full contract." + ), + no_args_is_help=True, +) +@server_args +@spcs_args +@content_args +@cloud_shinyapps_args +@click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@shinyapps_deploy_args +@cli_exception_handler +@click.pass_context +def deploy_pyproject( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + account: Optional[str], + token: Optional[str], + secret: Optional[str], + new: bool, + app_id: Optional[str], + title: Optional[str], + verbose: int, + directory: str, + env_vars: dict[str, str], + visibility: Optional[str], + no_verify: bool, + draft: bool, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + pyproject_path = Path(directory) / "pyproject.toml" + read_tool_rsconnect(pyproject_path) + raise NotImplementedError( + "deploy pyproject dispatch is not yet implemented; see TODO(EVO-...) markers in rsconnect/main.py" + ) # TODO(EVO-030): Dispatch by app_mode in deploy pyproject. diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index e8154be7..063ebcff 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -20,6 +20,7 @@ import textwrap import typing +import click import pytest from click.testing import CliRunner @@ -53,10 +54,6 @@ def _write_pyproject(project: pathlib.Path, body: str) -> None: # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-020): Register ``rsconnect deploy pyproject `` command.", -) def test_deploy_pyproject_command_is_registered(runner: CliRunner): result = runner.invoke(cli, ["deploy", "pyproject", "--help"]) assert result.exit_code == 0, result.output @@ -64,13 +61,30 @@ def test_deploy_pyproject_command_is_registered(runner: CliRunner): def test_deploy_pyproject_requires_path(runner: CliRunner): - """The positional is required, matching ``deploy manifest ``. + """The positional directory is required (SPEC §13.1: no silent default to '.'). - This passes today because the missing command also exits non-zero; once - EVO-020 lands the same assertion still holds for the real command. + Distinguishes 'command exists and demands the positional' from the prior + 'command does not exist' state - the assertions below would behave + differently in those two cases. """ result = runner.invoke(cli, ["deploy", "pyproject"]) assert result.exit_code != 0 + assert "No such command" not in result.output + # `no_args_is_help=True` makes Click render the usage block on missing args. + assert "Usage:" in result.output + assert "DIRECTORY" in result.output # required positional metavar (after rename) + assert "[DIRECTORY]" not in result.output # required, not optional + + +def test_deploy_pyproject_option_surface_matches_deploy_manifest(): + """``deploy pyproject`` must expose the same Click option surface as + ``deploy manifest`` so existing credential mechanisms (SPEC §13.1) apply + identically. + """ + deploy_group = typing.cast(click.Group, cli.commands["deploy"]) + manifest_options = {p.name for p in deploy_group.commands["manifest"].params if isinstance(p, click.Option)} + pyproject_options = {p.name for p in deploy_group.commands["pyproject"].params if isinstance(p, click.Option)} + assert pyproject_options == manifest_options # --------------------------------------------------------------------------- From 6a10b18f0a5967ad4a06763a4479b53c2b1af444 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 12 May 2026 19:03:19 +0200 Subject: [PATCH 04/26] Implement deploy pyproject --- rsconnect/main.py | 166 +++++++++++++++++++++--------- rsconnect/models.py | 20 ---- rsconnect/pyproject.py | 87 ++++++++++------ rsconnect/quickstart.py | 7 +- tests/test_deploy_pyproject.py | 179 ++++++++++++++++++--------------- tests/test_quickstart.py | 19 +--- 6 files changed, 278 insertions(+), 200 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 586bf9de..d326aaa0 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -118,7 +118,7 @@ VersionSearchFilter, VersionSearchFilterParamType, ) -from .pyproject import read_tool_rsconnect +from .pyproject import InvalidPyprojectConfigError, TOMLDecodeError, read_tool_rsconnect from .environment import PackageInstaller from .shiny_express import escape_to_var_name, is_express_app from .utils_package import fix_starlette_requirements @@ -1035,7 +1035,7 @@ def info(file: str): ) @click.argument("app_type", metavar="TYPE") @click.argument("name", metavar="NAME") -@click.option("--static", is_flag=True, help="(jupyter only) emit jupyter-static instead of jupyter-notebook.") +@click.option("--static", is_flag=True, help="(jupyter only) emit jupyter-static app mode.") @click.option("--shiny", is_flag=True, help="(quarto only) emit quarto-shiny instead of quarto-static.") @cli_exception_handler def quickstart(app_type: str, name: str, static: bool, shiny: bool): @@ -1552,55 +1552,127 @@ def deploy_pyproject( set_verbosity(verbose) output_params(ctx, locals().items()) + def quickstart_hint() -> str: + return "To create a new project with this section already populated, run: rsconnect quickstart --help" + pyproject_path = Path(directory) / "pyproject.toml" - read_tool_rsconnect(pyproject_path) - raise NotImplementedError( - "deploy pyproject dispatch is not yet implemented; see TODO(EVO-...) markers in rsconnect/main.py" + try: + config = read_tool_rsconnect(pyproject_path) + except InvalidPyprojectConfigError as err: + raise RSConnectException(f"{err}\n\n{quickstart_hint()}") from err + except FileNotFoundError as err: + raise RSConnectException(f"pyproject.toml not found at {pyproject_path}.\n\n{quickstart_hint()}") from err + except TOMLDecodeError as err: + raise RSConnectException(f"pyproject.toml could not be parsed: {err}\n\n{quickstart_hint()}") from err + + configured_app_mode = cast(str, config["app_mode"]) + app_mode = AppModes.get_by_name(configured_app_mode, return_unknown=True) + if app_mode == AppModes.UNKNOWN: + raise RSConnectException(f"Unsupported app_mode '{configured_app_mode}' in [tool.rsconnect]") + + entrypoint = cast(str, config["entrypoint"]) + effective_title = cast(Optional[str], config.get("title") or title or name) + extra_files: tuple[str, ...] = tuple() + excludes: tuple[str, ...] = tuple() + bundle_builder: Callable[..., Any] + bundle_args: tuple[Any, ...] + bundle_kwargs: dict[str, Any] = {} + path = directory + + if app_mode in (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API): + environment = Environment.create_python_environment( + directory, + requirements_file=None, + override_python_version=None, + ) + bundle_builder = make_api_bundle + bundle_args = (directory, entrypoint, app_mode, environment, extra_files, excludes) + bundle_kwargs = {"image": None, "env_management_py": None, "env_management_r": None} + elif app_mode == AppModes.JUPYTER_NOTEBOOK: # This is "jupyter-static" + path = str(Path(directory) / entrypoint) + environment = Environment.create_python_environment( + directory, + requirements_file=None, + override_python_version=None, + ) + bundle_builder = make_notebook_source_bundle + # Legacy app mode - no need to override the bundle builder default + bundle_args = (path, environment, extra_files, False, False) + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + } + elif app_mode == AppModes.JUPYTER_VOILA: + environment = Environment.create_python_environment( + directory, + requirements_file=None, + override_python_version=None, + ) + bundle_builder = make_voila_bundle + bundle_args = (directory, entrypoint, extra_files, excludes, True, environment) + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "multi_notebook": False, + } + elif app_mode in (AppModes.STATIC_QUARTO, AppModes.SHINY_QUARTO): + path = str(Path(directory) / entrypoint) + with cli_feedback("Inspecting Quarto project"): + quarto = which_quarto(None) + logger.debug("Quarto: %s" % quarto) + inspect = quarto_inspect(quarto, path) + engines = validate_quarto_engines(inspect) + + environment = None + if "jupyter" in engines: + with cli_feedback("Inspecting Python environment"): + environment = Environment.create_python_environment( + directory, + requirements_file=None, + override_python_version=None, + ) + bundle_builder = create_quarto_deployment_bundle + bundle_args = (path, extra_files, excludes, app_mode, inspect, environment) + bundle_kwargs = {"image": None, "env_management_py": None, "env_management_r": None} + else: + raise RSConnectException(f"Unsupported app_mode '{configured_app_mode}' in [tool.rsconnect]") + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + account=account, + token=token, + secret=secret, + path=path, + server=server, + new=new, + app_id=app_id, + title=effective_title, + visibility=visibility, + env_vars=env_vars, ) + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + ce.metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) -# TODO(EVO-030): Dispatch by app_mode in deploy pyproject. -# Scope: deploy-pyproject -# Why: SPEC §13.2 step 3-5: after reading ``[tool.rsconnect]``, -# the command must route to the per-type deploy code path, -# override the entrypoint with the pyproject value, and -# set the Connect title from the table - bypassing the -# per-type entrypoint-guessing logic that the existing -# ``deploy notebook`` / ``deploy api`` / ``deploy quarto`` -# commands carry. Reusing the bundling/environment -# resolution introduced in PR 764 is the whole reason -# the companion command exists. -# Done: Tests -# ``test_deploy_pyproject_dispatches_streamlit``, -# ``test_deploy_pyproject_dispatches_fastapi``, -# ``test_deploy_pyproject_dispatches_notebook_static``, -# ``test_deploy_pyproject_dispatches_quarto_shiny`` -# (etc., one per supported app_mode) pass. Each test -# asserts that the correct bundle builder is called with -# the entrypoint from ``[tool.rsconnect]``. -# Non-Goals: Do not re-implement bundling; reuse -# ``make_api_bundle``, ``make_notebook_source_bundle``, -# etc. Do not invent new ``app_mode`` values; the -# dispatch table is just the §8.2 vocabulary. - - -# TODO(EVO-040): Hard-error when [tool.rsconnect] is missing or incomplete. -# Scope: deploy-pyproject -# Why: SPEC §13.3 forbids inference: if the section or any of -# ``app_mode``/``entrypoint`` is missing, the command -# must exit non-zero with a message that (a) says what -# is missing, (b) quotes the minimum valid snippet, and -# (c) mentions ``rsconnect quickstart --help``. This is -# the most user-visible guardrail of the whole feature. -# Done: Tests -# ``test_deploy_pyproject_errors_on_missing_section``, -# ``test_deploy_pyproject_errors_on_missing_app_mode``, -# ``test_deploy_pyproject_errors_on_missing_entrypoint``, -# and ``test_deploy_pyproject_error_message_mentions_quickstart`` -# in ``tests/test_deploy_pyproject.py`` pass. -# Non-Goals: Do not attempt to recover by reading -# ``manifest.json`` if it happens to exist; §13.3 -# rules out fallback entirely. + ( + ce.validate_server() + .validate_app_mode(app_mode=app_mode) + .make_bundle(bundle_builder, *bundle_args, **bundle_kwargs) + .deploy_bundle(activate=not draft) + .save_deployed_info() + .emit_task_log() + ) + if not no_verify: + ce.verify_deployment() # noinspection SpellCheckingInspection,DuplicatedCode diff --git a/rsconnect/models.py b/rsconnect/models.py index 1eec33a3..fc23f02b 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -88,26 +88,6 @@ class AppModes: PLUMBER = AppMode(5, "api", "API") TENSORFLOW = AppMode(6, "tensorflow-saved-model", "TensorFlow Model") JUPYTER_NOTEBOOK = AppMode(7, "jupyter-static", "Jupyter Notebook", ".ipynb") - # TODO(EVO-050): Add ``jupyter-notebook`` app_mode and thread it through typing. - # Scope: shared - # Why: SPEC §4 / §8.2 introduce ``jupyter-notebook`` as the - # default ``[tool.rsconnect].app_mode`` for the - # ``notebook`` quickstart type (``jupyter-static`` is - # now the ``--static`` variant). Today ``AppModes`` - # only has ``jupyter-static``, and - # ``AppModes.Modes`` (the ``Literal`` used by - # pyright-strict code) does not include it. Both the - # quickstart writer and the ``deploy pyproject`` - # dispatcher depend on this new value being valid. - # Done: ``AppModes.get_by_name("jupyter-notebook")`` returns - # a non-unknown mode; ``AppModes.Modes`` accepts the - # literal; test ``test_models_has_jupyter_notebook_mode`` - # in ``tests/test_quickstart.py`` (or a small addition - # to ``tests/test_models.py``) passes. - # Non-Goals: Do not rename ``JUPYTER_NOTEBOOK`` - that - # attribute name is load-bearing elsewhere. Do - # not change ``jupyter-static``'s existing - # ordinal or description. PYTHON_API = AppMode(8, "python-api", "Python API") DASH_APP = AppMode(9, "python-dash", "Dash Application") STREAMLIT_APP = AppMode(10, "python-streamlit", "Streamlit Application") diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index a391e379..9d8d5ac2 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -9,14 +9,20 @@ import pathlib import re import typing +from collections.abc import Mapping +from .log import logger + +TOMLDecodeError: type[Exception] try: import tomllib + + TOMLDecodeError = tomllib.TOMLDecodeError except ImportError: # Python 3.11+ has tomllib in the standard library import toml as tomllib # type: ignore[no-redef] -from .log import logger + TOMLDecodeError = tomllib.TomlDecodeError PEP440_OPERATORS_REGEX = r"(===|==|!=|<=|>=|<|>|~=)" @@ -161,33 +167,54 @@ class InvalidVersionConstraintError(ValueError): pass -# TODO(EVO-060): Read [tool.rsconnect] from pyproject.toml. -# Scope: deploy-pyproject -# Why: SPEC §3 + §13.2 require ``rsconnect deploy pyproject`` -# to consume ``app_mode``, ``entrypoint``, and ``title`` -# from a ``[tool.rsconnect]`` table and hard-error when it -# is missing or incomplete (§13.3). Putting the reader -# here keeps it next to the existing pyproject helpers -# (``parse_pyproject_python_requires``) which already own -# the tomllib import path. The public deploy command calls -# this single function to resolve its config. -# Done: Tests in ``tests/test_deploy_pyproject.py`` named -# ``test_read_tool_rsconnect_*`` pass: valid tables -# return a value with ``app_mode``/``entrypoint``/ -# ``title``; missing section raises a clear exception; -# missing ``app_mode`` or ``entrypoint`` raises the same -# clear exception carrying the minimum-valid-snippet text -# required by §13.3. -# Non-Goals: Do not accept alternative names -# (no ``.rsconnect.toml`` fallback per §1.1). Do not -# infer ``app_mode`` from file extensions - §13.3 -# forbids inference. Do not validate the canonical -# ``app_mode`` vocabulary here beyond "non-empty -# string"; the deploy dispatcher owns that mapping. -def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, str]: - """Placeholder for the ``[tool.rsconnect]`` reader. - - Raises NotImplementedError until the evolution above lands; the ATDD tests - in ``tests/test_deploy_pyproject.py`` are structured around that fact. +class InvalidPyprojectConfigError(ValueError): + """Raised when ``[tool.rsconnect]`` is missing or incomplete.""" + + +_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET = '''[tool.rsconnect] +app_mode = "python-streamlit" +entrypoint = "app.py"''' + + +def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, typing.Any]: + """Read the ``[tool.rsconnect]`` deployment config from pyproject.toml. + + Returns the section mapping unchanged for forward-compatible fields (SPEC + §3.1, §3.2). Raises ``InvalidPyprojectConfigError`` when the section is + missing or when required ``app_mode`` / ``entrypoint`` fields are absent or + not non-empty strings (SPEC §13.3). """ - raise NotImplementedError("read_tool_rsconnect is not yet implemented; see TODO(EVO-...) in rsconnect/pyproject.py") + content = pyproject_file.read_text() + pyproject = tomllib.loads(content) + + tool = pyproject.get("tool") + if tool is None: + raise InvalidPyprojectConfigError( + f"The [tool.rsconnect] section is missing. Add at least:\n\n{_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET}" + ) + if not isinstance(tool, Mapping): + raise InvalidPyprojectConfigError( + f"[tool.rsconnect] is not a TOML table. Add at least:\n\n{_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET}" + ) + tool = typing.cast(typing.Mapping[str, typing.Any], tool) + + tool_rsconnect = tool.get("rsconnect") + if tool_rsconnect is None: + raise InvalidPyprojectConfigError( + f"The [tool.rsconnect] section is missing. Add at least:\n\n{_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET}" + ) + if not isinstance(tool_rsconnect, Mapping): + raise InvalidPyprojectConfigError( + f"[tool.rsconnect] is not a TOML table. Add at least:\n\n{_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET}" + ) + tool_rsconnect = typing.cast(typing.Mapping[str, typing.Any], tool_rsconnect) + + for field in ("app_mode", "entrypoint"): + value = tool_rsconnect.get(field) + if not isinstance(value, str) or not value: + raise InvalidPyprojectConfigError( + f"The [tool.rsconnect] field {field} must be a non-empty string. Add at least:\n\n" + f"{_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET}" + ) + + return tool_rsconnect diff --git a/rsconnect/quickstart.py b/rsconnect/quickstart.py index 3127c7b9..fcb1f24e 100644 --- a/rsconnect/quickstart.py +++ b/rsconnect/quickstart.py @@ -281,12 +281,11 @@ def run_quickstart( # TODO(EVO-200): Register the notebook template (jupyter, --static flag aware). # Scope: quickstart # Why: SPEC §2.1 + §4 + §6.3: ``jupyter`` accepts ``--static`` -# which flips app_mode between ``jupyter-notebook`` +# which flips app_mode between ``jupyter-static`` # (default) and ``jupyter-static``. The template generates # ``notebook.ipynb`` with a couple of cells; entrypoint is -# ``notebook.ipynb``. This introduces a *new* app_mode -# name (``jupyter-notebook``) that does not currently exist -# in ``rsconnect/models.py::AppModes``. +# ``notebook.ipynb``. This uses the existing +# ``jupyter-static`` mode in ``rsconnect/models.py::AppModes``. # Done: Tests ``test_quickstart_notebook_default_app_mode``, # ``test_quickstart_notebook_static_flag_sets_mode``, and # ``test_quickstart_notebook_file_set`` pass. diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index 063ebcff..1fbea64e 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -1,7 +1,4 @@ # probedev: ignore-file -# The xfail/skip reasons below cite ``TODO(EVO-###)`` markers by text. The real -# evolution markers live in ``rsconnect/``; the strings here are pointers, not -# new markers. """ Acceptance tests for ``rsconnect deploy pyproject`` (SPEC_QUICKSTART.md §13). @@ -10,14 +7,15 @@ shape of ``tests/test_pyproject.py`` (fixture/parametrize-driven) and ``tests/test_main.py`` (Click invocation). -Every test is marked ``xfail`` with a pointer to the evolution that unblocks -it; the feature is not yet implemented. +The file keeps deploy-pyproject coverage at the CLI boundary and verifies the +reader directly where malformed configuration needs precise diagnostics. """ from __future__ import annotations import pathlib import textwrap +import types import typing import click @@ -92,10 +90,6 @@ def test_deploy_pyproject_option_surface_matches_deploy_manifest(): # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-060): Read [tool.rsconnect] from pyproject.toml.", -) def test_read_tool_rsconnect_returns_three_fields(project_dir: pathlib.Path): from rsconnect.pyproject import read_tool_rsconnect @@ -119,10 +113,6 @@ def test_read_tool_rsconnect_returns_three_fields(project_dir: pathlib.Path): assert config["title"] == "My Hello App" -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-060): Reader raises on missing [tool.rsconnect] section (paired with EVO-040 CLI handling).", -) def test_read_tool_rsconnect_missing_section_raises(project_dir: pathlib.Path): from rsconnect.pyproject import read_tool_rsconnect @@ -136,7 +126,40 @@ def test_read_tool_rsconnect_missing_section_raises(project_dir: pathlib.Path): ) with pytest.raises(Exception) as excinfo: read_tool_rsconnect(project_dir / "pyproject.toml") - assert "tool.rsconnect" in str(excinfo.value).lower() or "rsconnect" in str(excinfo.value).lower() + # Exception must carry the SPEC §13.3 minimum valid snippet as a + # copy-pasteable TOML block, not just prose. Anchor on the section + # header plus both required-field TOML string-valued key=value forms + # (``key = "``); a prose 'required fields: ...' message would not + # incidentally produce that shape. + message = str(excinfo.value) + assert "tool.rsconnect" in message.lower() or "rsconnect" in message.lower() + assert "[tool.rsconnect]" in message + assert 'app_mode = "' in message + assert 'entrypoint = "' in message + + +@pytest.mark.parametrize( + "body", + [ + 'tool = "not-a-table"', + """ + [tool] + rsconnect = "not-a-table" + """, + ], + ids=["tool-not-table", "rsconnect-not-table"], +) +def test_read_tool_rsconnect_non_table_raises(project_dir: pathlib.Path, body: str): + from rsconnect.pyproject import read_tool_rsconnect + + _write_pyproject(project_dir, body) + with pytest.raises(Exception) as excinfo: + read_tool_rsconnect(project_dir / "pyproject.toml") + message = str(excinfo.value) + assert "not a TOML table" in message + assert "[tool.rsconnect]" in message + assert 'app_mode = "' in message + assert 'entrypoint = "' in message @pytest.mark.parametrize( @@ -167,17 +190,19 @@ def test_read_tool_rsconnect_missing_section_raises(project_dir: pathlib.Path): ], ids=["missing-app_mode", "missing-entrypoint"], ) -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-060): Reader raises on missing required field (paired with EVO-040).", -) def test_read_tool_rsconnect_missing_required_field_raises(project_dir: pathlib.Path, missing_field: str, body: str): from rsconnect.pyproject import read_tool_rsconnect _write_pyproject(project_dir, body) with pytest.raises(Exception) as excinfo: read_tool_rsconnect(project_dir / "pyproject.toml") - assert missing_field in str(excinfo.value) + # Same snippet-shape contract as missing-section: exception must carry + # the TOML snippet, not just name the missing field. + message = str(excinfo.value) + assert missing_field in message + assert "[tool.rsconnect]" in message + assert 'app_mode = "' in message + assert 'entrypoint = "' in message # --------------------------------------------------------------------------- @@ -185,10 +210,6 @@ def test_read_tool_rsconnect_missing_required_field_raises(project_dir: pathlib. # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-040): Hard-error CLI surface when [tool.rsconnect] is missing.", -) def test_deploy_pyproject_errors_on_missing_section(runner: CliRunner, project_dir: pathlib.Path): _write_pyproject( project_dir, @@ -204,10 +225,6 @@ def test_deploy_pyproject_errors_on_missing_section(runner: CliRunner, project_d assert "[tool.rsconnect]" in combined -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-040): Hard-error CLI surface when app_mode is missing.", -) def test_deploy_pyproject_errors_on_missing_app_mode(runner: CliRunner, project_dir: pathlib.Path): _write_pyproject( project_dir, @@ -226,10 +243,6 @@ def test_deploy_pyproject_errors_on_missing_app_mode(runner: CliRunner, project_ assert "app_mode" in combined -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-040): Hard-error CLI surface when entrypoint is missing.", -) def test_deploy_pyproject_errors_on_missing_entrypoint(runner: CliRunner, project_dir: pathlib.Path): _write_pyproject( project_dir, @@ -248,10 +261,6 @@ def test_deploy_pyproject_errors_on_missing_entrypoint(runner: CliRunner, projec assert "entrypoint" in combined -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-040): Hard-error message mentions rsconnect quickstart (SPEC §13.3).", -) def test_deploy_pyproject_error_message_mentions_quickstart(runner: CliRunner, project_dir: pathlib.Path): """SPEC §13.3 requires the error to reference ``rsconnect quickstart --help``.""" _write_pyproject( @@ -274,34 +283,57 @@ def test_deploy_pyproject_error_message_mentions_quickstart(runner: CliRunner, p DISPATCH_MATRIX: list[typing.Any] = [ - pytest.param("python-streamlit", "app.py", id="streamlit"), - pytest.param("python-shiny", "app.py", id="shiny"), - pytest.param("python-fastapi", "__connect__:app", id="fastapi"), - pytest.param("python-api", "__connect__:app", id="api"), - pytest.param("jupyter-notebook", "notebook.ipynb", id="jupyter-notebook"), - pytest.param("jupyter-static", "notebook.ipynb", id="jupyter-static"), - pytest.param("jupyter-voila", "notebook.ipynb", id="voila"), - pytest.param("quarto-static", "report.qmd", id="quarto-static"), - pytest.param("quarto-shiny", "report.qmd", id="quarto-shiny"), + pytest.param("python-streamlit", "app.py", "make_api_bundle", id="streamlit"), + pytest.param("python-shiny", "app.py", "make_api_bundle", id="shiny"), + pytest.param("python-fastapi", "__connect__:app", "make_api_bundle", id="fastapi"), + pytest.param("python-api", "__connect__:app", "make_api_bundle", id="api"), + pytest.param("jupyter-static", "notebook.ipynb", "make_notebook_source_bundle", id="jupyter-static"), + pytest.param("jupyter-voila", "notebook.ipynb", "make_voila_bundle", id="voila"), + pytest.param("quarto-static", "report.qmd", "create_quarto_deployment_bundle", id="quarto-static"), + pytest.param("quarto-shiny", "report.qmd", "create_quarto_deployment_bundle", id="quarto-shiny"), ] -@pytest.mark.parametrize("app_mode,entrypoint", DISPATCH_MATRIX) -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-030): Dispatch by app_mode in deploy pyproject.", -) +@pytest.mark.parametrize("app_mode,entrypoint,expected_builder_name", DISPATCH_MATRIX) def test_deploy_pyproject_dispatches_by_app_mode( - runner: CliRunner, project_dir: pathlib.Path, app_mode: str, entrypoint: str, monkeypatch: pytest.MonkeyPatch + runner: CliRunner, + project_dir: pathlib.Path, + app_mode: str, + entrypoint: str, + expected_builder_name: str, + monkeypatch: pytest.MonkeyPatch, ): - """Each app_mode must reach the matching deploy code path. + """Each [tool.rsconnect].app_mode routes to its matching bundle builder (SPEC §13.2 step 3, §8.2).""" + captured: dict[str, typing.Any] = {} + + class _StopDispatch(Exception): + """Sentinel to short-circuit before any network call.""" + + def spy_make_bundle( + self: typing.Any, builder: typing.Callable[..., typing.Any], *args: typing.Any, **kwargs: typing.Any + ): + captured["builder"] = builder.__name__ + captured["args"] = args + captured["kwargs"] = kwargs + raise _StopDispatch() + + from rsconnect import api as api_mod + from rsconnect import main as main_mod + + fake_environment = types.SimpleNamespace(python="python") + monkeypatch.setattr( + main_mod.Environment, + "create_python_environment", + classmethod(lambda cls, *args, **kwargs: fake_environment), + ) + monkeypatch.setattr(main_mod, "which_quarto", lambda quarto=None: "quarto") + monkeypatch.setattr(main_mod, "quarto_inspect", lambda quarto, path: {"engines": []}) + monkeypatch.setattr(main_mod, "validate_quarto_engines", lambda inspect: []) + monkeypatch.setattr(api_mod.RSConnectClient, "server_settings", lambda self: {}) + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_server", lambda self: self) + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_app_mode", lambda self, app_mode: self) + monkeypatch.setattr(api_mod.RSConnectExecutor, "make_bundle", spy_make_bundle) - The implementer chooses how to prove dispatch: patching the bundle builder, - observing the RSConnectExecutor call, or similar. This test asserts that - the command does not short-circuit to the wrong branch by using a - deliberately bad server URL and asserting the error surfaces from the - deploy path rather than from config parsing (i.e. we got past the reader). - """ _write_pyproject( project_dir, f""" @@ -315,25 +347,18 @@ def test_deploy_pyproject_dispatches_by_app_mode( title = "Dispatch Test" """, ) - # Deliberately unreachable server; the failure mode we care about is - # "tried to contact Connect" rather than "could not parse config". + if ":" not in entrypoint: + (project_dir / entrypoint).touch() + result = runner.invoke( cli, - [ - "deploy", - "pyproject", - str(project_dir), - "-s", - "http://127.0.0.1:1/unused", - "-k", - "fake-key", - ], + ["deploy", "pyproject", str(project_dir), "-s", "http://example.invalid", "-k", "fake-key"], ) - combined = result.output + (result.stderr if result.stderr_bytes else "") - # Parse-time errors would mention tool.rsconnect / missing fields. Dispatch - # success means we got past the reader into deploy territory. - assert "[tool.rsconnect]" not in combined - assert result.exit_code != 0 # unreachable server guarantees non-zero + + assert captured.get("builder") == expected_builder_name, result.output + if app_mode == "jupyter-static": + # Legacy app mode - no need to override the bundle builder default + pass # --------------------------------------------------------------------------- @@ -341,10 +366,6 @@ def test_deploy_pyproject_dispatches_by_app_mode( # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-030): Dispatch by app_mode in deploy pyproject (title).", -) def test_deploy_pyproject_uses_title_from_tool_rsconnect( runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): @@ -391,10 +412,6 @@ def spy_init(self: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> None: assert seen.get("title") == "A Readable Title" -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-030): Dispatch by app_mode in deploy pyproject (entrypoint override).", -) def test_deploy_pyproject_uses_entrypoint_from_tool_rsconnect(runner: CliRunner, project_dir: pathlib.Path): """Entrypoint in pyproject must bypass per-type guessing (§13.2 step 4). diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index b2325836..12a8ef2c 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -319,7 +319,7 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, pytest.param(("fastapi",), "python-fastapi", id="fastapi"), pytest.param(("api",), "python-api", id="api"), pytest.param(("flask",), "python-api", id="flask-alias"), - pytest.param(("notebook",), "jupyter-notebook", id="notebook-default"), + pytest.param(("notebook",), "jupyter-static", id="notebook-default"), pytest.param(("notebook", "--static"), "jupyter-static", id="notebook-static"), pytest.param(("voila",), "jupyter-voila", id="voila"), pytest.param(("quarto",), "quarto-static", id="quarto-default"), @@ -577,23 +577,6 @@ def test_quickstart_registry_accepts_new_mode( assert hasattr(quickstart_mod, "run_quickstart") -# --------------------------------------------------------------------------- -# New jupyter-notebook app_mode (SPEC §4, §8.2) -# --------------------------------------------------------------------------- - - -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-050): Add jupyter-notebook app_mode and thread it through typing.", -) -def test_models_has_jupyter_notebook_mode(): - from rsconnect.models import AppModes - - mode = AppModes.get_by_name("jupyter-notebook") - assert mode is not None - assert str(mode) == "jupyter-notebook" - - # --------------------------------------------------------------------------- # Per-mode boot smoke tests (SPEC §14.1) # --------------------------------------------------------------------------- From d9eb6ea5d91cbcf498b724015a5249437b65159f Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 13 May 2026 18:55:41 +0200 Subject: [PATCH 05/26] setup the quickstart command infrastructure --- rsconnect/quickstart.py | 222 ++++++++++++++++++--------------------- tests/test_quickstart.py | 67 +++++++----- 2 files changed, 144 insertions(+), 145 deletions(-) diff --git a/rsconnect/quickstart.py b/rsconnect/quickstart.py index fcb1f24e..15b2d1fc 100644 --- a/rsconnect/quickstart.py +++ b/rsconnect/quickstart.py @@ -16,26 +16,40 @@ from __future__ import annotations +import os import pathlib +import re +import shutil import typing -# TODO(EVO-070): Define the public QuickstartRequest type or simple call contract. -# Scope: quickstart -# Why: Establish a single validated value that downstream phases -# (pre-flight, scaffold, post-scaffold output) consume so the -# capability is understandable through one public entrypoint -# downward. Carrying , , and the type-specific -# flag state (jupyter --static, quarto --shiny) as one value -# keeps the CLI layer thin and the deep module self-contained. -# Done: A value (or plain kwargs on ``run_quickstart``) captures -# the validated CLI inputs; the tests in -# ``tests/test_quickstart.py`` that exercise name/type -# validation via CliRunner pass for the error branches -# without needing any scaffolding work. -# Non-Goals: Do not introduce a public ``QuickstartOptions`` / -# ``QuickstartContext`` passive data bag; do not add -# dependency injection; keep this tight to the real -# product concept. +from .exception import RSConnectException + + +# Supported CLI ```` values per SPEC §4. ``flask`` is an alias for +# ``api``; both share the same scaffold and ``python-api`` app mode. The +# deferred modes from §4.1 (dash, gradio, panel, bokeh) are intentionally +# absent. Kept as a module-level constant so error messages and future +# template registration share one source of truth. +SUPPORTED_APP_TYPES: typing.Tuple[str, ...] = ( + "streamlit", + "shiny", + "fastapi", + "api", + "flask", + "notebook", + "voila", + "quarto", +) + + +# SPEC §2.2: lowercase ASCII letter start, only lowercase letters / digits / +# hyphens, no trailing hyphen. The optional middle-and-end group keeps the +# rule satisfiable by single-letter names such as ``"a"``. +_project_name_pattern = re.compile(r"^[a-z]([a-z0-9-]*[a-z0-9])?$") +_PROJECT_NAME_RULE = ( + "Project name must start with a lowercase ASCII letter, contain only " + "lowercase letters, digits, and hyphens, and not end with a hyphen." +) def run_quickstart( @@ -53,120 +67,94 @@ def run_quickstart( or scaffold failure; rollback of the partially-created directory is the caller-visible invariant defined in SPEC §11. - This is currently a probe stub: it raises NotImplementedError so that the - CLI command registers and help text renders, while the ATDD test suite in - ``tests/test_quickstart.py`` fails in the expected way until each - evolution below is applied. + :param str app_type: one of the supported CLI types in SPEC §4. + :param str name: project name; must satisfy SPEC §2.2. + :param bool static: jupyter-only flag; selects ``jupyter-static``. + :param bool shiny: quarto-only flag; selects ``quarto-shiny``. + :param pathlib.Path cwd: override the working directory (testing hook); + defaults to :func:`pathlib.Path.cwd`. """ - # TODO(EVO-080): Implement the quickstart pipeline. + cwd = (cwd or pathlib.Path.cwd()).resolve() + + # SPEC §10 pre-flight order. Each helper raises ``RSConnectException`` + # with an actionable message; nothing on disk is mutated until every + # check has passed. + _require_uv_on_path() + _validate_app_type(app_type) + _validate_project_name(name) + target = cwd / name + _require_target_does_not_exist(target) + _require_cwd_writable(cwd) + + # TODO(EVO-080): Implement the scaffold + venv + post-output phases. # Scope: quickstart - # Why: This is the public entrypoint the Click command - # delegates to. Keeping the full flow visible here - # (pre-flight -> scaffold -> venv -> post-output -> - # rollback-on-failure) is the "contract before detail" - # shape the reviewer should see from this module alone. - # Done: Calling ``run_quickstart`` with valid inputs creates - # the directory tree per SPEC §5/§6, writes a - # pyproject.toml per §3/§8.2, runs ``uv venv`` + ``uv - # sync``, and returns the project path. The ATDD tests - # in ``tests/test_quickstart.py`` named - # ``test_quickstart_creates_*`` pass. + # Why: Pre-flight passes (SPEC §10) are landed. The + # remaining flow (template rendering, pyproject.toml + # writing, ``uv venv`` + ``uv sync``, post-scaffold + # stdout per §12, and rollback per §11) still needs + # to live behind this public entrypoint so the + # capability stays understandable through one + # module boundary. + # Done: Calling ``run_quickstart`` with valid inputs + # creates the directory tree per SPEC §5/§6, writes + # a pyproject.toml per §3/§8.2, runs ``uv venv`` + + # ``uv sync``, prints the §12 lines, and returns + # the project path. The ATDD tests in + # ``tests/test_quickstart.py`` named + # ``test_quickstart_creates_*`` and + # ``test_quickstart_*_post_scaffold_output`` pass. # Non-Goals: Do not implement framework-specific templates # here (that is separate per-mode evolutions); # do not implement the ``deploy pyproject`` - # command (it has its own evolutions); do not add - # interactive prompts or a ``--deploy`` flag. + # command; do not add interactive prompts or a + # ``--deploy`` flag. raise NotImplementedError( - "rsconnect quickstart is not yet implemented; see SPEC_QUICKSTART.md and " - "TODO(EVO-...) markers in rsconnect/quickstart.py" + "rsconnect quickstart scaffolding is not yet implemented; " + "see SPEC_QUICKSTART.md and the TODO markers in rsconnect/quickstart.py" ) # --------------------------------------------------------------------------- # Pre-flight checks (SPEC §10) # --------------------------------------------------------------------------- -# -# Below are the five ordered pre-flight checks the spec requires. They live -# together because they are phases of one capability ("can we scaffold?") and -# should run in the documented order before any filesystem mutation. Each -# check has its own evolution so the implementer can land them one at a time -# and the ATDD tests for each failure branch can graduate independently. -# TODO(EVO-090): Pre-flight check 1 - require uv on PATH. -# Scope: quickstart -# Why: SPEC §7 and §10 make ``uv`` the sole dependency-manager -# path. Detecting its absence up front gives an actionable -# message before any work starts and keeps the rest of the -# flow from having to re-check. The install hint is part -# of the user-visible contract. -# Done: Tests ``test_quickstart_requires_uv_on_path`` and -# ``test_quickstart_uv_missing_message_names_install`` in -# ``tests/test_quickstart.py`` pass. Exit code is -# non-zero; stderr names ``uv`` and the install command. -# Non-Goals: Do not add a fallback to ``python -m venv`` + pip. -# Do not probe ``uv --version`` compatibility; mere -# presence on PATH is sufficient for v1. - - -# TODO(EVO-100): Pre-flight check 2 - validate against supported list. -# Scope: quickstart -# Why: SPEC §2.3 + §4 enumerate the eight v1 CLI type values -# (streamlit, shiny, fastapi, api, flask, notebook, voila, -# quarto). Unknown types must exit with a message listing -# the supported ones so the user can self-correct without -# reading docs. -# Done: Test ``test_quickstart_unknown_type_lists_supported`` -# in ``tests/test_quickstart.py`` passes: the error lists -# every supported CLI type, and ``flask`` is accepted as -# an alias for ``api``. -# Non-Goals: Do not advertise the four deferred modes (dash, -# gradio, panel, bokeh) - they are intentionally not -# in v1 per §4.1. - - -# TODO(EVO-110): Pre-flight check 3 - validate against PEP 508 subset. -# Scope: quickstart -# Why: SPEC §2.2 restricts names to -# ``^[a-z][a-z0-9-]*[a-z0-9]$`` (lowercase start, lowercase -# alphanumerics and hyphens, no trailing hyphen). Enforcing -# this before scaffolding prevents generating a project -# whose pyproject.toml would be invalid. -# Done: Tests ``test_quickstart_rejects_invalid_name_*`` in -# ``tests/test_quickstart.py`` pass for uppercase, -# leading-digit, underscore, trailing-hyphen, and empty -# name inputs. Error message states the rule verbatim. -# Non-Goals: Do not allow underscores (they are valid in PEP 508 -# but the spec narrows to hyphens for distribution -# friendliness); do not normalize (no auto-lowercase). - - -# TODO(EVO-120): Pre-flight check 4 - target directory must not exist. -# Scope: quickstart -# Why: SPEC §2 forbids in-place scaffolding and §10 lists this -# as a fatal pre-flight check. Catching it before any -# template work preserves the atomicity invariant (§11/I8) -# trivially: there is nothing to roll back. -# Done: Test ``test_quickstart_fails_when_directory_exists`` in -# ``tests/test_quickstart.py`` passes; the directory the -# user already had is untouched; the error suggests a -# different name or removing the existing directory. -# Non-Goals: Do not add a ``--force`` flag; the spec explicitly -# rejects overwriting. - - -# TODO(EVO-130): Pre-flight check 5 - current working directory is writable. -# Scope: quickstart -# Why: SPEC §10 step 5 requires a fail-fast permission check so -# readonly-cwd users see a clear error rather than a -# partial ``mkdir`` failure midway through scaffolding. -# Done: Test ``test_quickstart_requires_writable_cwd`` in -# ``tests/test_quickstart.py`` passes by asserting a -# non-zero exit and an actionable stderr when the current -# directory is read-only (the test creates a readonly -# temp dir and invokes the CLI from it). -# Non-Goals: Do not attempt fancy capability probing; a write -# attempt (or ``os.access(os.W_OK)``) is sufficient. +def _require_uv_on_path() -> None: + if shutil.which("uv") is None: + # ``uv>=0.9.0`` is a declared dependency of rsconnect-python, so a + # missing ``uv`` on PATH typically means the install environment is + # broken. The message names both fixes a user can take. + raise RSConnectException( + "'uv' was not found on PATH. It ships with rsconnect-python; " + "try reinstalling (pip install --force-reinstall rsconnect-python) " + "or install uv manually from https://github.com/astral-sh/uv" + ) + + +def _validate_app_type(app_type: str) -> None: + if app_type not in SUPPORTED_APP_TYPES: + supported = ", ".join(SUPPORTED_APP_TYPES) + raise RSConnectException(f"Unsupported project type {app_type!r}. Supported types: {supported}.") + + +def _validate_project_name(name: str) -> None: + if not _project_name_pattern.match(name): + raise RSConnectException(f"Invalid project name {name!r}. {_PROJECT_NAME_RULE}") + + +def _require_target_does_not_exist(target: pathlib.Path) -> None: + if target.exists(): + raise RSConnectException( + f"Target directory {target} already exists. Use a different name or remove the existing directory." + ) + + +def _require_cwd_writable(cwd: pathlib.Path) -> None: + if not os.access(cwd, os.W_OK): + raise RSConnectException( + f"Current working directory {cwd} is not writable. " + "Change to a writable directory or adjust its permissions." + ) # --------------------------------------------------------------------------- diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 12a8ef2c..10c44882 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -137,10 +137,6 @@ def test_quickstart_delegates_to_run_quickstart( # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-090): Pre-flight check 1 - require uv on PATH.", -) def test_quickstart_requires_uv_on_path(runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch): """Pre-flight check 1: absent ``uv`` must produce a clear, actionable error.""" monkeypatch.setenv("PATH", str(in_tmp_cwd)) # empty PATH so uv cannot be found @@ -151,10 +147,6 @@ def test_quickstart_requires_uv_on_path(runner: CliRunner, in_tmp_cwd: pathlib.P assert not (in_tmp_cwd / "hello-app").exists() # I8: no partial dir on pre-flight failure -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-090): Pre-flight check 1 - require uv on PATH (install hint).", -) def test_quickstart_uv_missing_message_names_install( runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): @@ -167,15 +159,11 @@ def test_quickstart_uv_missing_message_names_install( assert re.search(r"install|astral|github\.com/astral-sh/uv", combined, re.IGNORECASE) -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-100): Pre-flight check 2 - validate against supported list.", -) def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "nonesuch", "hello-app") assert result.exit_code != 0 combined = result.output + (result.stderr if result.stderr_bytes else "") - for expected in ("streamlit", "shiny", "fastapi", "api", "notebook", "voila", "quarto"): + for expected in ("streamlit", "shiny", "fastapi", "api", "flask", "notebook", "voila", "quarto"): assert expected in combined, f"{expected!r} missing from error output: {combined!r}" assert not (in_tmp_cwd / "hello-app").exists() @@ -192,20 +180,15 @@ def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: "hello world", # whitespace ], ) -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-110): Pre-flight check 3 - validate against PEP 508 subset.", -) def test_quickstart_rejects_invalid_name(runner: CliRunner, in_tmp_cwd: pathlib.Path, bad_name: str): result = _invoke_quickstart(runner, "streamlit", bad_name) assert result.exit_code != 0 - assert not (in_tmp_cwd / bad_name).exists() + # Empty-name case resolves to the cwd itself, which always exists; + # for every other invalid name, no partial directory may be left behind. + if bad_name: + assert not (in_tmp_cwd / bad_name).exists() -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-120): Pre-flight check 4 - target directory must not exist.", -) def test_quickstart_fails_when_directory_exists(runner: CliRunner, in_tmp_cwd: pathlib.Path): (in_tmp_cwd / "hello-app").mkdir() (in_tmp_cwd / "hello-app" / "existing-file.txt").write_text("keep me") @@ -216,10 +199,6 @@ def test_quickstart_fails_when_directory_exists(runner: CliRunner, in_tmp_cwd: p @pytest.mark.skipif(sys.platform == "win32", reason="chmod read-only semantics differ on Windows") -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-130): Pre-flight check 5 - current working directory is writable.", -) def test_quickstart_requires_writable_cwd(runner: CliRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): readonly = tmp_path / "readonly" readonly.mkdir() @@ -233,6 +212,30 @@ def test_quickstart_requires_writable_cwd(runner: CliRunner, tmp_path: pathlib.P readonly.chmod(stat.S_IRWXU) +def test_quickstart_flask_alias_passes_type_validation(runner: CliRunner, in_tmp_cwd: pathlib.Path): + """SPEC §4: 'flask' is accepted as an alias for 'api' at pre-flight.""" + result = _invoke_quickstart(runner, "flask", "hello-app") + combined = result.output + (result.stderr if result.stderr_bytes else "") + # The type-validation gate does not reject 'flask'. The command still + # fails downstream (scaffolding is not implemented yet); that failure + # must not look like a type-rejection message. + # `flask` should NOT appear in a "supported types" error listing. + assert "Unsupported" not in combined and "supported types" not in combined.lower() + + +def test_quickstart_preflight_order_uv_before_type( + runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """SPEC §10: uv-presence is checked before type validation.""" + monkeypatch.setenv("PATH", str(in_tmp_cwd)) # uv unavailable + result = _invoke_quickstart(runner, "nonesuch", "hello-app") + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "uv" in combined.lower() + # If the type check had run, the message would name 'nonesuch'. + assert "nonesuch" not in combined.lower() + + # --------------------------------------------------------------------------- # Always-present generated files (SPEC §5.1) # --------------------------------------------------------------------------- @@ -444,8 +447,12 @@ def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathli # --------------------------------------------------------------------------- +# strict=True: today this would XPASS for the wrong reason (no rollback runs +# because the scaffold phase that would invoke the fake uv is not implemented +# yet, so nothing is ever created to roll back). Flip to xfail-non-strict and +# remove the decorator once the real rollback path lands. @pytest.mark.xfail( - strict=False, + strict=True, reason="TODO(EVO-250): Implement atomic rollback of .// on any failure.", ) def test_quickstart_rolls_back_directory_on_uv_failure( @@ -535,8 +542,12 @@ def test_invariant_I1_I2_directory_and_pyproject(runner: CliRunner, in_tmp_cwd: assert required in data["tool"]["rsconnect"] +# strict=True: today this XPASSes via the directory-must-not-exist pre-flight, +# but the test is intended to prove pipeline-level failure translation, not the +# pre-flight short-circuit. Remove the decorator once the real pipeline path +# raises and the message-quality assertions exercise that translation. @pytest.mark.xfail( - strict=False, + strict=True, reason=( "TODO(EVO-080): Invariants I9-I10 - non-zero exit and actionable " "stderr on failure (pipeline error translation)." From 0a7154cdf573e5fdc3f1df531240967a081caf3a Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 14 May 2026 13:16:07 +0200 Subject: [PATCH 06/26] basic templating infrastructure --- rsconnect/quickstart.py | 340 ++++++++++++++++----- rsconnect/quickstart_templates/__init__.py | 34 ++- tests/test_quickstart.py | 82 +++-- 3 files changed, 316 insertions(+), 140 deletions(-) diff --git a/rsconnect/quickstart.py b/rsconnect/quickstart.py index 15b2d1fc..0e5c81f7 100644 --- a/rsconnect/quickstart.py +++ b/rsconnect/quickstart.py @@ -16,6 +16,7 @@ from __future__ import annotations +import dataclasses import os import pathlib import re @@ -86,32 +87,42 @@ def run_quickstart( _require_target_does_not_exist(target) _require_cwd_writable(cwd) - # TODO(EVO-080): Implement the scaffold + venv + post-output phases. + # SPEC §4/§6: resolve the per-mode template once. Pre-flight already + # validated ``app_type``; ``lookup_template`` is defensive against + # impossible flag combinations only. + spec = lookup_template(app_type, static=static, shiny=shiny) + + # SPEC §5.1: create the project directory and the always-present files. + # Mode-specific source files, venv population, post-scaffold output, and + # rollback land in subsequent evolutions (see TODO(EVO-080) below). + target.mkdir() + _write_always_present_files(target, name=name, spec=spec) + + # TODO(EVO-080): Finish the scaffold + venv + post-output phases. # Scope: quickstart - # Why: Pre-flight passes (SPEC §10) are landed. The - # remaining flow (template rendering, pyproject.toml - # writing, ``uv venv`` + ``uv sync``, post-scaffold - # stdout per §12, and rollback per §11) still needs - # to live behind this public entrypoint so the - # capability stays understandable through one - # module boundary. + # Why: Pre-flight (SPEC §10), directory creation, and the + # always-present files (SPEC §5.1) are landed. The + # remaining flow (mode-specific source files, + # ``uv venv`` + ``uv sync``, post-scaffold stdout per + # §12, and rollback per §11) still needs to live + # behind this public entrypoint so the capability + # stays understandable through one module boundary. # Done: Calling ``run_quickstart`` with valid inputs - # creates the directory tree per SPEC §5/§6, writes - # a pyproject.toml per §3/§8.2, runs ``uv venv`` + - # ``uv sync``, prints the §12 lines, and returns - # the project path. The ATDD tests in - # ``tests/test_quickstart.py`` named - # ``test_quickstart_creates_*`` and - # ``test_quickstart_*_post_scaffold_output`` pass. - # Non-Goals: Do not implement framework-specific templates - # here (that is separate per-mode evolutions); - # do not implement the ``deploy pyproject`` + # writes the per-mode source files (§6), runs + # ``uv venv`` + ``uv sync``, prints the §12 lines, + # and rolls back ``.//`` on any failure. The + # ATDD tests still marked ``xfail`` in + # ``tests/test_quickstart.py`` (per-mode file sets, + # ``creates_populated_venv``, + # ``post_scaffold_output``, + # ``rolls_back_directory_on_uv_failure``, + # ``invariant_I9_I10_failure_exit_and_message``) + # pass without ``xfail``. + # Non-Goals: Do not implement the ``deploy pyproject`` # command; do not add interactive prompts or a # ``--deploy`` flag. - raise NotImplementedError( - "rsconnect quickstart scaffolding is not yet implemented; " - "see SPEC_QUICKSTART.md and the TODO markers in rsconnect/quickstart.py" - ) + + return target # --------------------------------------------------------------------------- @@ -158,51 +169,240 @@ def _require_cwd_writable(cwd: pathlib.Path) -> None: # --------------------------------------------------------------------------- -# Scaffolding phases (SPEC §5 / §6 / §9) +# Template registry (SPEC §4 / §6 / §8.2 / §12) # --------------------------------------------------------------------------- # -# After pre-flight succeeds, the scaffold phase creates the directory, -# materializes the template for the chosen mode, writes pyproject.toml with a -# valid [tool.rsconnect] section, seeds .python-version / .gitignore / -# README.md, then runs uv to populate .venv/. Each of these is a separate -# evolution so the devteam can land them in order and the ATDD suite has -# meaningful per-evolution acceptance signals. +# The registry is the single source of truth that ties together what each +# supported mode produces: the canonical Connect ``app_mode`` written to +# ``[tool.rsconnect]``, the entrypoint form per §6, the local-run command +# documented in §12 and the README, the minimum dependencies for the +# hello-world, and the source files the per-mode template materializes. +# +# Adding a future supported mode is a registry insertion plus dropping a +# directory under ``rsconnect/quickstart_templates//``; no pre-flight, +# pyproject-writer, or post-output code needs to change. -# TODO(EVO-140): Create the project directory and always-present files. -# Scope: quickstart -# Why: SPEC §5.1 fixes a uniform "always present" set - -# pyproject.toml, .python-version, .gitignore, README.md - -# across every mode. Landing the mode-agnostic skeleton -# first makes later per-mode evolutions a pure "add source -# files" change. -# Done: Tests -# ``test_quickstart_generates_always_present_files`` and -# ``test_quickstart_gitignore_covers_rsconnect_dirs`` in -# ``tests/test_quickstart.py`` pass: the four files exist -# with the expected baseline content (including the -# rsconnect-specific ``.gitignore`` entries from §5.1). -# Non-Goals: Do not write mode-specific source files here; do -# not run ``uv`` yet; do not generate a -# ``manifest.json`` (I6). +@dataclasses.dataclass(frozen=True) +class FileSpec: + """One per-mode template file to materialize in the scaffolded project. + :param str name: filename relative to the project root. + :param str template: path to the template body under + ``rsconnect/quickstart_templates/``, discovered via + :mod:`importlib.resources`. + """ -# TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml. -# Scope: quickstart -# Why: SPEC §3 makes ``[tool.rsconnect]`` the sole configuration -# surface for ``deploy pyproject``. Writing ``app_mode``, -# ``entrypoint``, and ``title`` with the canonical values -# from §8.2 is the invariant that links quickstart output -# to the companion deploy command. -# Done: Tests ``test_quickstart_pyproject_has_tool_rsconnect``, -# ``test_quickstart_app_mode_for_``, and -# ``test_quickstart_does_not_duplicate_deps_in_tool_rsconnect`` -# pass. The generated table contains exactly the three -# required fields (no ``dependencies``, no -# ``requires-python`` duplication). -# Non-Goals: Do not add ``[tool.rsconnect.files]`` entries; -# §3.2 reserves the name for later. Do not encode -# server credentials. + name: str + template: str + + +@dataclasses.dataclass(frozen=True) +class TemplateSpec: + """Per-resolved-mode scaffold contract. + + Resolved means the ``(app_type, static, shiny)`` flag triple has already + been mapped to one entry; the dataclass itself does not know about CLI + aliases or flags. + + :param str app_mode: canonical Connect app mode per SPEC §8.2. + :param str entrypoint: entrypoint string written to + ``[tool.rsconnect].entrypoint`` per SPEC §6. + :param tuple local_run_command: argv form of the documented local-run + command per SPEC §12. The literal token ``"{name}"`` (if present) is + substituted with the project name at scaffold time. + :param tuple dependencies: minimum runtime dependencies for the + hello-world, written to ``[project.dependencies]``. + :param tuple source_files: per-mode template files to materialize. + Empty for modes whose templates have not landed yet; populated by + the per-mode evolutions below. + """ + + app_mode: str + entrypoint: str + local_run_command: typing.Tuple[str, ...] + dependencies: typing.Tuple[str, ...] + source_files: typing.Tuple[FileSpec, ...] + + +# Registry key: ``(resolved_type, static, shiny)``. The ``flask`` alias +# resolves to ``api`` before lookup (see :func:`lookup_template`); the v1 +# deferred modes from SPEC §4.1 (dash, gradio, panel, bokeh) are intentionally +# absent. +_REGISTRY: typing.Mapping[typing.Tuple[str, bool, bool], TemplateSpec] = { + ("streamlit", False, False): TemplateSpec( + app_mode="python-streamlit", + entrypoint="app.py", + local_run_command=("uv", "run", "streamlit", "run", "app.py"), + dependencies=("streamlit",), + source_files=(), + ), + ("shiny", False, False): TemplateSpec( + app_mode="python-shiny", + entrypoint="app.py", + local_run_command=("uv", "run", "shiny", "run", "app.py"), + dependencies=("shiny",), + source_files=(), + ), + ("fastapi", False, False): TemplateSpec( + app_mode="python-fastapi", + entrypoint="__connect__:app", + local_run_command=("uv", "run", "python", "-m", "{name}"), + dependencies=("fastapi", "uvicorn"), + source_files=(), + ), + ("api", False, False): TemplateSpec( + app_mode="python-api", + entrypoint="__connect__:app", + local_run_command=("uv", "run", "python", "-m", "{name}"), + dependencies=("flask",), + source_files=(), + ), + ("notebook", False, False): TemplateSpec( + app_mode="jupyter-static", + entrypoint="notebook.ipynb", + local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), + dependencies=("jupyter",), + source_files=(), + ), + ("notebook", True, False): TemplateSpec( + app_mode="jupyter-static", + entrypoint="notebook.ipynb", + local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), + dependencies=("jupyter",), + source_files=(), + ), + ("voila", False, False): TemplateSpec( + app_mode="jupyter-voila", + entrypoint="notebook.ipynb", + local_run_command=("uv", "run", "voila", "notebook.ipynb"), + dependencies=("voila", "jupyter"), + source_files=(), + ), + ("quarto", False, False): TemplateSpec( + app_mode="quarto-static", + entrypoint="report.qmd", + local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), + dependencies=(), + source_files=(), + ), + ("quarto", False, True): TemplateSpec( + app_mode="quarto-shiny", + entrypoint="report.qmd", + local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), + dependencies=("shiny",), + source_files=(), + ), +} + + +def lookup_template(app_type: str, *, static: bool = False, shiny: bool = False) -> TemplateSpec: + """Resolve the :class:`TemplateSpec` for ``(app_type, static, shiny)``. + + ``flask`` is an alias for ``api`` and shares the same scaffold; both + resolve to the same key. Other CLI-level flag combinations have already + been narrowed by pre-flight, so this lookup is defensive only. + + :param str app_type: CLI ```` value per SPEC §4. + :param bool static: jupyter-only flag. + :param bool shiny: quarto-only flag. + """ + resolved_type = "api" if app_type == "flask" else app_type + key = (resolved_type, static, shiny) + if key not in _REGISTRY: + raise RSConnectException( + f"No scaffold template is registered for type {app_type!r} " + f"with --static={static}, --shiny={shiny}. Re-run without the " + f"unsupported flag combination." + ) + return _REGISTRY[key] + + +# --------------------------------------------------------------------------- +# Always-present files (SPEC §5.1 / §3) +# --------------------------------------------------------------------------- + + +def _write_always_present_files(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: + """Materialize the four files SPEC §5.1 requires in every scaffold. + + The pyproject.toml carries both ``[project]`` (name, version, + requires-python, dependencies) and the SPEC §3 ``[tool.rsconnect]`` + table with exactly three keys. The README and post-scaffold output + share the same two commands derived from ``spec`` so the two stay in + sync without a separate source of truth. + """ + (target / "pyproject.toml").write_text(_render_pyproject(name=name, spec=spec), encoding="utf-8") + (target / ".python-version").write_text(f"{_PYTHON_VERSION}\n", encoding="utf-8") + (target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8") + (target / "README.md").write_text(_render_readme(name=name, spec=spec), encoding="utf-8") + + +# SPEC-pinned literals: kept as separate constants because they encode two +# distinct concerns (the .python-version pin vs. the requires-python floor) +# that happen to share a Python-version shape but evolve independently. +_PYTHON_VERSION = "3.11" # value written to .python-version +_REQUIRES_PYTHON = ">=3.9" # value written to [project].requires-python +_GITIGNORE_BODY = "__pycache__/\n*.pyc\n.venv/\n*.egg-info/\nrsconnect-python/\n.env\n" + + +def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: + # Build the TOML by direct string concatenation rather than pulling in a + # writer dependency. SPEC §3.2 forbids duplicating ``dependencies`` or + # ``requires-python`` in ``[tool.rsconnect]``; the table holds exactly + # ``app_mode``, ``entrypoint``, and ``title``. + if spec.dependencies: + deps_block = "[\n" + "".join(f' "{dep}",\n' for dep in spec.dependencies) + "]" + else: + deps_block = "[]" + return ( + "[project]\n" + f'name = "{name}"\n' + 'version = "0.0.1"\n' + f'requires-python = "{_REQUIRES_PYTHON}"\n' + f"dependencies = {deps_block}\n" + "\n" + "[tool.rsconnect]\n" + f'app_mode = "{spec.app_mode}"\n' + f'entrypoint = "{spec.entrypoint}"\n' + f'title = "{name}"\n' + ) + + +def _render_readme(*, name: str, spec: TemplateSpec) -> str: + local_run = _format_local_run(spec, name=name) + deploy_cmd = f"rsconnect deploy pyproject {name}" + return ( + f"# {name}\n" + "\n" + "A Posit Connect project scaffolded by `rsconnect quickstart`.\n" + "\n" + "## Run locally\n" + "\n" + f"```\n{local_run}\n```\n" + "\n" + "## Deploy to Posit Connect\n" + "\n" + f"```\n{deploy_cmd}\n```\n" + ) + + +def _format_local_run(spec: TemplateSpec, *, name: str) -> str: + # The registry stores the local-run argv with ``"{name}"`` as a literal + # placeholder for module-style modes. Substitute once at scaffold time so + # README and post-scaffold stdout share one rendering path. + return " ".join(token.replace("{name}", name) for token in spec.local_run_command) + + +# --------------------------------------------------------------------------- +# Per-mode template registrations (SPEC §6 / §9) +# --------------------------------------------------------------------------- +# +# Each evolution below fills the ``source_files`` tuple for one registry +# entry above and adds the corresponding template files under +# ``rsconnect/quickstart_templates/``. The pyproject / .python-version / +# .gitignore / README writer above is mode-agnostic and does not change as +# modes are added. # TODO(EVO-160): Register the streamlit template (script-style). @@ -306,24 +506,6 @@ def _require_cwd_writable(cwd: pathlib.Path) -> None: # (§9.1 - templates are owned). -# TODO(EVO-230): Define the template registry layout and extension contract. -# Scope: quickstart -# Why: SPEC §4.1 requires adding the four deferred modes (dash, -# gradio, panel, bokeh) to reduce to "drop a template -# directory plus one registration line." The registry is -# the shared shape - how a template declares its files, -# its app_mode, its entrypoint form, and its local-run -# command - so per-mode evolutions above can all plug in. -# Done: The per-mode evolutions above each consume the -# registry; adding a hypothetical ninth mode in -# ``tests/test_quickstart.py::test_quickstart_registry_accepts_new_mode`` -# (an in-test registry insertion) works without touching -# non-registry code. -# Non-Goals: Do not ship the four deferred modes in v1; the -# registry exists so *future* work is small, not so -# this PR is big. - - # TODO(EVO-240): Run ``uv venv`` + ``uv sync`` inside the scaffolded directory. # Scope: quickstart # Why: SPEC §5.1 / §7 / I5 require a populated ``.venv/`` so the diff --git a/rsconnect/quickstart_templates/__init__.py b/rsconnect/quickstart_templates/__init__.py index 9a5a8094..c56e6a1e 100644 --- a/rsconnect/quickstart_templates/__init__.py +++ b/rsconnect/quickstart_templates/__init__.py @@ -11,22 +11,28 @@ marker that defines the registry contract. """ -# TODO(EVO-270): Decide template storage format and ship the v1 templates. +# TODO(EVO-270): Ship the per-mode template files under this package. # Scope: quickstart -# Why: SPEC §17.5 leaves the choice open (plain copy with -# string substitution, Jinja2, Tempita, ...). v1 needs one -# concrete choice plus the eight supported-mode templates -# (streamlit, shiny, fastapi, api/flask, notebook, voila, -# quarto-static, quarto-shiny). Templates must be -# discoverable at runtime (either as ``package_data`` or -# via ``importlib.resources``) so they survive wheel -# install. +# Why: The storage format is locked: stdlib ``str.format`` +# substitution on plain text files discovered at runtime +# via :func:`importlib.resources.files`, laid out as +# ``rsconnect/quickstart_templates//`` so +# they survive wheel install. What remains is the +# per-mode content (streamlit, shiny, fastapi, api/flask, +# notebook, voila, quarto-static, quarto-shiny) referenced +# by each :class:`rsconnect.quickstart.FileSpec`. # Done: Every per-mode evolution in ``rsconnect/quickstart.py`` # (``Register the template ...``) has its -# template files materialized here; the ATDD tests in +# ``source_files`` tuple populated and a matching file +# laid down under this package; the ATDD tests in # ``tests/test_quickstart.py`` that assert on generated # file contents pass. -# Non-Goals: Do not introduce a template engine when plain -# string substitution suffices; do not add build -# steps; do not mix R or Node templates in (v1 is -# Python-only per §16). +# Non-Goals: Do not introduce a template engine - stdlib +# ``str.format`` is the chosen format. Do not add +# build steps; do not mix R or Node templates in +# (v1 is Python-only per §16). +# Caveat: :func:`importlib.resources.files` is Python 3.9+; +# ``rsconnect-python`` advertises ``requires-python +# >= 3.8``. When this marker lands, either use the +# ``importlib_resources`` backport on 3.8 or coordinate +# a floor bump first. diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 10c44882..ec37f44c 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -241,22 +241,15 @@ def test_quickstart_preflight_order_uv_before_type( # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-140): Create the project directory and always-present files.", -) def test_quickstart_generates_always_present_files(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output project = in_tmp_cwd / "hello-app" for name in ("pyproject.toml", ".python-version", ".gitignore", "README.md"): assert (project / name).is_file(), f"{name} missing from {list(project.iterdir())}" + assert (project / ".python-version").read_text().strip() == "3.11" -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-140): Create the project directory and always-present files (gitignore).", -) def test_quickstart_gitignore_covers_rsconnect_dirs(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output @@ -265,10 +258,6 @@ def test_quickstart_gitignore_covers_rsconnect_dirs(runner: CliRunner, in_tmp_cw assert expected in gitignore, f"{expected} missing from .gitignore" -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-140): Invariant I6 - no manifest.json on scaffold.", -) def test_quickstart_does_not_create_manifest_json(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output @@ -280,26 +269,20 @@ def test_quickstart_does_not_create_manifest_json(runner: CliRunner, in_tmp_cwd: # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml.", -) def test_quickstart_pyproject_has_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output data = _read_pyproject(in_tmp_cwd / "hello-app") assert data["project"]["name"] == "hello-app" assert data["project"]["version"] == "0.0.1" + assert data["project"]["requires-python"] == ">=3.9" + assert data["project"]["dependencies"] == ["streamlit"] tool_rsconnect = data["tool"]["rsconnect"] assert tool_rsconnect["app_mode"] == "python-streamlit" assert tool_rsconnect["entrypoint"] == "app.py" assert tool_rsconnect["title"] == "hello-app" -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml (no duplication).", -) def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): """SPEC §3.2: dependencies and requires-python live in [project], not in [tool.rsconnect].""" result = _invoke_quickstart(runner, "streamlit", "hello-app") @@ -331,10 +314,6 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, @pytest.mark.parametrize("cli_args,expected_mode", APP_MODE_MATRIX) -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-150): Write the [tool.rsconnect] table to pyproject.toml (per-mode app_mode).", -) def test_quickstart_app_mode_for_each_type( runner: CliRunner, in_tmp_cwd: pathlib.Path, @@ -345,7 +324,11 @@ def test_quickstart_app_mode_for_each_type( args = [cli_args[0], *cli_args[1:], "hello-app"] result = _invoke_quickstart(runner, *args) assert result.exit_code == 0, result.output - assert _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"]["app_mode"] == expected_mode + tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"] + assert tool_rsconnect["app_mode"] == expected_mode + if expected_mode == "python-api": + # Flask aliases to api: both module-style modes share entrypoint. + assert tool_rsconnect["entrypoint"] == "__connect__:app" # --------------------------------------------------------------------------- @@ -510,10 +493,6 @@ def test_quickstart_post_scaffold_output( assert "rsconnect deploy pyproject hello-app" in result.output -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-260): Emit the post-scaffold confirmation and command lines (README parity).", -) def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output @@ -527,10 +506,6 @@ def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tm # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-080): Invariants I1-I2 - directory exists and pyproject is valid (covered by the full pipeline).", -) def test_invariant_I1_I2_directory_and_pyproject(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output @@ -546,6 +521,8 @@ def test_invariant_I1_I2_directory_and_pyproject(runner: CliRunner, in_tmp_cwd: # but the test is intended to prove pipeline-level failure translation, not the # pre-flight short-circuit. Remove the decorator once the real pipeline path # raises and the message-quality assertions exercise that translation. +# Persistent RED in CI until the TODO(EVO-080) work lands and this test is +# rewritten to inject a pipeline-level failure (e.g., mock a uv subprocess error). @pytest.mark.xfail( strict=True, reason=( @@ -566,26 +543,37 @@ def test_invariant_I9_I10_failure_exit_and_message(runner: CliRunner, in_tmp_cwd # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-230): Define the template registry layout and extension contract.", -) def test_quickstart_registry_accepts_new_mode( runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): - """A future template can be registered by inserting into the registry alone. + """SPEC §4.1: adding a mode is "drop a template directory plus register it." - This test is aspirational - it documents the extensibility invariant from - SPEC §4.1. The implementer decides the exact registry shape; what matters - is that inserting a ninth mode does not require touching pre-flight, - pyproject writing, or post-scaffold output modules. + The test proves the extensibility contract: inserting a new row into + ``_REGISTRY`` (plus a corresponding ``SUPPORTED_APP_TYPES`` entry) yields a + working scaffold without touching the pre-flight, pyproject writer, or + post-scaffold output modules. """ - # Import here so the test collection does not fail before the module exists. - import rsconnect.quickstart as quickstart_mod # noqa: F401 + from rsconnect import quickstart as qs + + new_spec = qs.TemplateSpec( + app_mode="python-newmode", + entrypoint="app.py", + local_run_command=("uv", "run", "newtool", "app.py"), + dependencies=("newtool",), + source_files=(), + ) + extended_registry = dict(qs._REGISTRY) + extended_registry[("newmode", False, False)] = new_spec + monkeypatch.setattr(qs, "_REGISTRY", extended_registry) + monkeypatch.setattr(qs, "SUPPORTED_APP_TYPES", qs.SUPPORTED_APP_TYPES + ("newmode",)) + + result = _invoke_quickstart(runner, "newmode", "hello-app") - # The implementer provides a registry accessor; the test asserts extension - # works without other code changes. Exact API is left to the evolution. - assert hasattr(quickstart_mod, "run_quickstart") + assert result.exit_code == 0, result.output + data = _read_pyproject(in_tmp_cwd / "hello-app") + assert data["tool"]["rsconnect"]["app_mode"] == "python-newmode" + assert data["tool"]["rsconnect"]["entrypoint"] == "app.py" + assert data["project"]["dependencies"] == ["newtool"] # --------------------------------------------------------------------------- From 3205a39e1a10148e66707eea345c1fff587f2ed5 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 14 May 2026 16:32:16 +0200 Subject: [PATCH 07/26] encapsulate templates --- pyproject.toml | 7 +- rsconnect/main.py | 2 +- rsconnect/quickstart/__init__.py | 5 + rsconnect/{ => quickstart}/quickstart.py | 223 ++++++------------ rsconnect/quickstart/templates/__init__.py | 15 ++ .../templates/api/__connect__.py.tmpl | 3 + .../quickstart/templates/api/__main__.py.tmpl | 10 + .../quickstart/templates/api/app.py.tmpl | 11 + .../templates/fastapi/__connect__.py.tmpl | 3 + .../templates/fastapi/__main__.py.tmpl | 11 + .../quickstart/templates/fastapi/app.py.tmpl | 11 + .../templates/notebook/notebook.ipynb.tmpl | 32 +++ .../templates/quarto/report.qmd.tmpl | 8 + .../quickstart/templates/shiny/app.py.tmpl | 3 + .../templates/streamlit/app.py.tmpl | 3 + rsconnect/quickstart_templates/__init__.py | 38 --- tests/test_quickstart.py | 109 ++++++--- 17 files changed, 274 insertions(+), 220 deletions(-) create mode 100644 rsconnect/quickstart/__init__.py rename rsconnect/{ => quickstart}/quickstart.py (65%) create mode 100644 rsconnect/quickstart/templates/__init__.py create mode 100644 rsconnect/quickstart/templates/api/__connect__.py.tmpl create mode 100644 rsconnect/quickstart/templates/api/__main__.py.tmpl create mode 100644 rsconnect/quickstart/templates/api/app.py.tmpl create mode 100644 rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl create mode 100644 rsconnect/quickstart/templates/fastapi/__main__.py.tmpl create mode 100644 rsconnect/quickstart/templates/fastapi/app.py.tmpl create mode 100644 rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl create mode 100644 rsconnect/quickstart/templates/quarto/report.qmd.tmpl create mode 100644 rsconnect/quickstart/templates/shiny/app.py.tmpl create mode 100644 rsconnect/quickstart/templates/streamlit/app.py.tmpl delete mode 100644 rsconnect/quickstart_templates/__init__.py diff --git a/pyproject.toml b/pyproject.toml index d4a5dd98..6e300fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,13 +83,18 @@ extend_ignore = ["E203", "E231", "E302"] per-file-ignores = ["tests/test_metadata.py: E501"] [tool.setuptools] -packages = ["rsconnect"] +packages = ["rsconnect", "rsconnect.quickstart", "rsconnect.quickstart.templates"] [tool.setuptools_scm] write_to = "rsconnect/version.py" [tool.setuptools.package-data] rsconnect = ["py.typed"] +# Per-mode quickstart templates are shipped as package data so +# pkgutil.get_data() can read them from a wheel install. The glob covers +# every subdirectory (streamlit/, shiny/, fastapi/, api/, notebook/, quarto/) +# and every extension (.py, .ipynb, .qmd) without enumerating each one. +"rsconnect.quickstart.templates" = ["**/*"] [tool.pytest.ini_options] markers = ["vetiver: tests for vetiver"] diff --git a/rsconnect/main.py b/rsconnect/main.py index d326aaa0..7c7d52e5 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1039,7 +1039,7 @@ def info(file: str): @click.option("--shiny", is_flag=True, help="(quarto only) emit quarto-shiny instead of quarto-static.") @cli_exception_handler def quickstart(app_type: str, name: str, static: bool, shiny: bool): - from .quickstart import run_quickstart + from .quickstart.quickstart import run_quickstart run_quickstart(app_type=app_type, name=name, static=static, shiny=shiny) diff --git a/rsconnect/quickstart/__init__.py b/rsconnect/quickstart/__init__.py new file mode 100644 index 00000000..b091aac7 --- /dev/null +++ b/rsconnect/quickstart/__init__.py @@ -0,0 +1,5 @@ +"""``rsconnect quickstart`` package. + +The scaffolding implementation lives in :mod:`rsconnect.quickstart.quickstart`. +Templates ship as package data under :mod:`rsconnect.quickstart.templates`. +""" diff --git a/rsconnect/quickstart.py b/rsconnect/quickstart/quickstart.py similarity index 65% rename from rsconnect/quickstart.py rename to rsconnect/quickstart/quickstart.py index 0e5c81f7..182cb44b 100644 --- a/rsconnect/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -19,11 +19,12 @@ import dataclasses import os import pathlib +import pkgutil import re import shutil import typing -from .exception import RSConnectException +from ..exception import RSConnectException # Supported CLI ```` values per SPEC §4. ``flask`` is an alias for @@ -92,28 +93,27 @@ def run_quickstart( # impossible flag combinations only. spec = lookup_template(app_type, static=static, shiny=shiny) - # SPEC §5.1: create the project directory and the always-present files. - # Mode-specific source files, venv population, post-scaffold output, and - # rollback land in subsequent evolutions (see TODO(EVO-080) below). - target.mkdir() - _write_always_present_files(target, name=name, spec=spec) + # SPEC §5.1 + §6: create the project directory and all of its files in + # one filesystem-generation phase. Venv population, post-scaffold + # output, and rollback land in subsequent evolutions (see TODO(EVO-080) + # below) and plug in around this step, not inside it. + _scaffold(target, name=name, spec=spec) - # TODO(EVO-080): Finish the scaffold + venv + post-output phases. + # TODO(EVO-080): Finish the venv + post-output + rollback phases. # Scope: quickstart - # Why: Pre-flight (SPEC §10), directory creation, and the - # always-present files (SPEC §5.1) are landed. The - # remaining flow (mode-specific source files, - # ``uv venv`` + ``uv sync``, post-scaffold stdout per - # §12, and rollback per §11) still needs to live - # behind this public entrypoint so the capability - # stays understandable through one module boundary. - # Done: Calling ``run_quickstart`` with valid inputs - # writes the per-mode source files (§6), runs + # Why: Pre-flight (SPEC §10) and filesystem generation + # (SPEC §5.1 + §6, via ``_scaffold``) are landed. + # The remaining flow (``uv venv`` + ``uv sync``, + # post-scaffold stdout per §12, and rollback per + # §11) still needs to live behind this public + # entrypoint so the capability stays understandable + # through one module boundary. + # Done: Calling ``run_quickstart`` with valid inputs runs # ``uv venv`` + ``uv sync``, prints the §12 lines, # and rolls back ``.//`` on any failure. The # ATDD tests still marked ``xfail`` in - # ``tests/test_quickstart.py`` (per-mode file sets, - # ``creates_populated_venv``, + # ``tests/test_quickstart.py`` + # (``creates_populated_venv``, # ``post_scaffold_output``, # ``rolls_back_directory_on_uv_failure``, # ``invariant_I9_I10_failure_exit_and_message``) @@ -179,7 +179,7 @@ def _require_cwd_writable(cwd: pathlib.Path) -> None: # hello-world, and the source files the per-mode template materializes. # # Adding a future supported mode is a registry insertion plus dropping a -# directory under ``rsconnect/quickstart_templates//``; no pre-flight, +# directory under ``rsconnect/quickstart/templates//``; no pre-flight, # pyproject-writer, or post-output code needs to change. @@ -189,8 +189,15 @@ class FileSpec: :param str name: filename relative to the project root. :param str template: path to the template body under - ``rsconnect/quickstart_templates/``, discovered via - :mod:`importlib.resources`. + ``rsconnect/quickstart/templates/``, loaded via + :func:`pkgutil.get_data`. Template files use the ``.tmpl`` suffix + to signal "needs substitution before becoming a usable artifact" + and to prevent accidental Python import of files that may not be + valid source on their own. The single token ``{name}`` in the body + is substituted with the project name via + ``str.replace("{name}", name)``; no other interpolation runs, so + templates carrying literal braces (e.g. ``notebook.ipynb`` JSON) + are unaffected. """ name: str @@ -214,8 +221,10 @@ class TemplateSpec: :param tuple dependencies: minimum runtime dependencies for the hello-world, written to ``[project.dependencies]``. :param tuple source_files: per-mode template files to materialize. - Empty for modes whose templates have not landed yet; populated by - the per-mode evolutions below. + Each entry's body is loaded from + ``rsconnect/quickstart/templates/`` and run through + ``str.replace("{name}", name)`` at scaffold time. Empty only for + modes whose templates have not landed yet. """ app_mode: str @@ -235,63 +244,74 @@ class TemplateSpec: entrypoint="app.py", local_run_command=("uv", "run", "streamlit", "run", "app.py"), dependencies=("streamlit",), - source_files=(), + source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),), ), ("shiny", False, False): TemplateSpec( app_mode="python-shiny", entrypoint="app.py", local_run_command=("uv", "run", "shiny", "run", "app.py"), dependencies=("shiny",), - source_files=(), + source_files=(FileSpec(name="app.py", template="shiny/app.py.tmpl"),), ), ("fastapi", False, False): TemplateSpec( app_mode="python-fastapi", entrypoint="__connect__:app", local_run_command=("uv", "run", "python", "-m", "{name}"), dependencies=("fastapi", "uvicorn"), - source_files=(), + source_files=( + FileSpec(name="app.py", template="fastapi/app.py.tmpl"), + FileSpec(name="__connect__.py", template="fastapi/__connect__.py.tmpl"), + FileSpec(name="__main__.py", template="fastapi/__main__.py.tmpl"), + ), ), ("api", False, False): TemplateSpec( app_mode="python-api", entrypoint="__connect__:app", local_run_command=("uv", "run", "python", "-m", "{name}"), dependencies=("flask",), - source_files=(), + source_files=( + FileSpec(name="app.py", template="api/app.py.tmpl"), + FileSpec(name="__connect__.py", template="api/__connect__.py.tmpl"), + FileSpec(name="__main__.py", template="api/__main__.py.tmpl"), + ), ), + # Both the default and --static notebook variants share one template; + # the registry distinguishes them only by ``app_mode`` (see SPEC §6.3). + # The voila entry below reuses the same template file too. ("notebook", False, False): TemplateSpec( app_mode="jupyter-static", entrypoint="notebook.ipynb", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), dependencies=("jupyter",), - source_files=(), + source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("notebook", True, False): TemplateSpec( app_mode="jupyter-static", entrypoint="notebook.ipynb", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), dependencies=("jupyter",), - source_files=(), + source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("voila", False, False): TemplateSpec( app_mode="jupyter-voila", entrypoint="notebook.ipynb", local_run_command=("uv", "run", "voila", "notebook.ipynb"), dependencies=("voila", "jupyter"), - source_files=(), + source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("quarto", False, False): TemplateSpec( app_mode="quarto-static", entrypoint="report.qmd", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), dependencies=(), - source_files=(), + source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), ), ("quarto", False, True): TemplateSpec( app_mode="quarto-shiny", entrypoint="report.qmd", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), dependencies=("shiny",), - source_files=(), + source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), ), } @@ -319,23 +339,34 @@ def lookup_template(app_type: str, *, static: bool = False, shiny: bool = False) # --------------------------------------------------------------------------- -# Always-present files (SPEC §5.1 / §3) +# Filesystem generation (SPEC §5.1 / §6 / §3) # --------------------------------------------------------------------------- -def _write_always_present_files(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: - """Materialize the four files SPEC §5.1 requires in every scaffold. +def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: + """Create the project directory and every file it should contain. - The pyproject.toml carries both ``[project]`` (name, version, - requires-python, dependencies) and the SPEC §3 ``[tool.rsconnect]`` - table with exactly three keys. The README and post-scaffold output - share the same two commands derived from ``spec`` so the two stay in - sync without a separate source of truth. + This is the SPEC §5.1 + §6 filesystem-generation phase: the project + directory, the four always-present files (``pyproject.toml``, + ``.python-version``, ``.gitignore``, ``README.md``), and the per-mode + source files materialized from ``spec.source_files``. The remaining + scaffold scope tracked by the ``TODO`` markers below in this module + (``uv`` venv population, post-scaffold stdout, and rollback) plugs in + around this step, not inside it. """ + target.mkdir() (target / "pyproject.toml").write_text(_render_pyproject(name=name, spec=spec), encoding="utf-8") (target / ".python-version").write_text(f"{_PYTHON_VERSION}\n", encoding="utf-8") (target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8") (target / "README.md").write_text(_render_readme(name=name, spec=spec), encoding="utf-8") + for file_spec in spec.source_files: + # ``pkgutil.get_data`` is stdlib since Python 3.0 and works under + # wheel install, unlike ``importlib.resources.files`` which is 3.9+. + data = pkgutil.get_data("rsconnect.quickstart.templates", file_spec.template) + if data is None: + raise RSConnectException(f"Template not found: {file_spec.template}") + body = data.decode("utf-8").replace("{name}", name) + (target / file_spec.name).write_text(body, encoding="utf-8") # SPEC-pinned literals: kept as separate constants because they encode two @@ -394,118 +425,6 @@ def _format_local_run(spec: TemplateSpec, *, name: str) -> str: return " ".join(token.replace("{name}", name) for token in spec.local_run_command) -# --------------------------------------------------------------------------- -# Per-mode template registrations (SPEC §6 / §9) -# --------------------------------------------------------------------------- -# -# Each evolution below fills the ``source_files`` tuple for one registry -# entry above and adds the corresponding template files under -# ``rsconnect/quickstart_templates/``. The pyproject / .python-version / -# .gitignore / README writer above is mode-agnostic and does not change as -# modes are added. - - -# TODO(EVO-160): Register the streamlit template (script-style). -# Scope: quickstart -# Why: SPEC §4 / §6.2 / §12 define the streamlit template: -# one ``app.py`` with ``st.write("Hello world")``, no -# ``__connect__.py``, no ``__main__.py``; entrypoint -# ``"app.py"``; local-run ``uv run streamlit run app.py``. -# Land one script-style mode first so the scaffolding -# framework is proven end to end. -# Done: Test ``test_quickstart_streamlit_file_set`` passes -# (only the expected files exist) and -# ``test_quickstart_streamlit_post_scaffold_output`` -# asserts the post-scaffold stdout quotes the documented -# local-run and deploy commands verbatim. -# Non-Goals: Do not delegate to ``streamlit create`` - templates -# are owned per §9.1. - - -# TODO(EVO-170): Register the shiny template (script-style). -# Scope: quickstart -# Why: SPEC §4 / §6.2 define the Python Shiny template: a -# single ``app.py`` with a Shiny Express or Core -# hello-world; entrypoint ``"app.py"``; local-run -# ``uv run shiny run app.py``; app_mode ``python-shiny``. -# Done: Test ``test_quickstart_shiny_file_set`` and -# ``test_quickstart_shiny_post_scaffold_output`` pass. -# Non-Goals: Do not pick between Shiny Express and Core via a -# flag - pick one idiomatic hello-world per §9.2 -# and document it. - - -# TODO(EVO-180): Register the fastapi template (module-style). -# Scope: quickstart -# Why: SPEC §6.1 defines the module-style shape: ``app.py`` -# with a ``create_app()`` factory, ``__connect__.py`` -# exposing ``app = create_app()``, ``__main__.py`` that -# runs uvicorn locally. Entrypoint is ``__connect__:app``; -# local-run is ``uv run python -m ``. Landing one -# module-style mode proves the shim pattern. -# Done: Tests ``test_quickstart_fastapi_file_set``, -# ``test_quickstart_fastapi_entrypoint_is_connect_app``, -# and ``test_quickstart_fastapi_main_runs_uvicorn`` pass. -# Non-Goals: Do not inline uvicorn as a runtime dependency in -# ``app.py``; it belongs behind ``__main__.py`` so -# ``app.py`` stays framework-idiomatic. - - -# TODO(EVO-190): Register the api / flask template (module-style, alias-aware). -# Scope: quickstart -# Why: SPEC §4 lists ``api`` with alias ``flask``; both produce -# the same scaffold and app_mode ``python-api``. Module -# shape mirrors fastapi: ``app.py`` factory, -# ``__connect__.py`` shim, ``__main__.py`` runs Flask's -# built-in server. -# Done: Tests -# ``test_quickstart_flask_alias_maps_to_api_mode``, -# ``test_quickstart_api_file_set``, and -# ``test_quickstart_api_main_runs_flask_dev_server`` pass. -# Non-Goals: Do not use a production WSGI server in -# ``__main__.py`` - it is explicitly the dev server. - - -# TODO(EVO-200): Register the notebook template (jupyter, --static flag aware). -# Scope: quickstart -# Why: SPEC §2.1 + §4 + §6.3: ``jupyter`` accepts ``--static`` -# which flips app_mode between ``jupyter-static`` -# (default) and ``jupyter-static``. The template generates -# ``notebook.ipynb`` with a couple of cells; entrypoint is -# ``notebook.ipynb``. This uses the existing -# ``jupyter-static`` mode in ``rsconnect/models.py::AppModes``. -# Done: Tests ``test_quickstart_notebook_default_app_mode``, -# ``test_quickstart_notebook_static_flag_sets_mode``, and -# ``test_quickstart_notebook_file_set`` pass. -# Non-Goals: Do not render the notebook at scaffold time; the -# local-run command handles rendering. - - -# TODO(EVO-210): Register the voila template (jupyter-voila). -# Scope: quickstart -# Why: SPEC §4 + §6.3 + §12: voila reuses ``notebook.ipynb`` as -# the entrypoint but with app_mode ``jupyter-voila`` and -# local-run ``uv run voila notebook.ipynb``. -# Done: Tests ``test_quickstart_voila_file_set`` and -# ``test_quickstart_voila_app_mode`` pass. -# Non-Goals: Do not duplicate the notebook template file - share -# it via the template-registry layout. - - -# TODO(EVO-220): Register the quarto template (--shiny flag aware). -# Scope: quickstart -# Why: SPEC §2.1 + §4 + §6.3: ``quarto`` defaults to static -# (app_mode ``quarto-static``); ``--shiny`` flips to -# ``quarto-shiny``. Both variants generate ``report.qmd`` -# with a minimal Quarto document. Local-run is -# ``uv run quarto preview report.qmd`` either way. -# Done: Tests ``test_quickstart_quarto_default_static``, -# ``test_quickstart_quarto_shiny_flag_sets_mode``, and -# ``test_quickstart_quarto_file_set`` pass. -# Non-Goals: Do not shell out to ``quarto create-project`` -# (§9.1 - templates are owned). - - # TODO(EVO-240): Run ``uv venv`` + ``uv sync`` inside the scaffolded directory. # Scope: quickstart # Why: SPEC §5.1 / §7 / I5 require a populated ``.venv/`` so the diff --git a/rsconnect/quickstart/templates/__init__.py b/rsconnect/quickstart/templates/__init__.py new file mode 100644 index 00000000..77f54035 --- /dev/null +++ b/rsconnect/quickstart/templates/__init__.py @@ -0,0 +1,15 @@ +""" +Template data for :mod:`rsconnect.quickstart.quickstart`. + +This package hosts the on-disk template files for every supported app mode. +It is deliberately a package (not a single module) so each mode can live in +its own subdirectory and the registry stays "drop in a directory to add a +mode" per SPEC_QUICKSTART.md §4.1. The package is internal to +``rsconnect.quickstart``; callers should not import from it directly. + +Template bodies are loaded at scaffold time via :func:`pkgutil.get_data` +and run through ``str.replace("{name}", name)`` for the single supported +substitution token. ``str.format`` is deliberately avoided so templates +carrying literal braces (e.g. ``notebook.ipynb`` JSON) pass through +unchanged. +""" diff --git a/rsconnect/quickstart/templates/api/__connect__.py.tmpl b/rsconnect/quickstart/templates/api/__connect__.py.tmpl new file mode 100644 index 00000000..793f52dc --- /dev/null +++ b/rsconnect/quickstart/templates/api/__connect__.py.tmpl @@ -0,0 +1,3 @@ +from .app import create_app + +app = create_app() diff --git a/rsconnect/quickstart/templates/api/__main__.py.tmpl b/rsconnect/quickstart/templates/api/__main__.py.tmpl new file mode 100644 index 00000000..91bb52c7 --- /dev/null +++ b/rsconnect/quickstart/templates/api/__main__.py.tmpl @@ -0,0 +1,10 @@ +from .app import create_app + + +def main() -> None: + app = create_app() + app.run(host="127.0.0.1", port=5000) + + +if __name__ == "__main__": + main() diff --git a/rsconnect/quickstart/templates/api/app.py.tmpl b/rsconnect/quickstart/templates/api/app.py.tmpl new file mode 100644 index 00000000..3212f99f --- /dev/null +++ b/rsconnect/quickstart/templates/api/app.py.tmpl @@ -0,0 +1,11 @@ +from flask import Flask + + +def create_app() -> Flask: + app = Flask(__name__) + + @app.route("/") + def hello() -> str: + return "Hello from {name}!" + + return app diff --git a/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl b/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl new file mode 100644 index 00000000..793f52dc --- /dev/null +++ b/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl @@ -0,0 +1,3 @@ +from .app import create_app + +app = create_app() diff --git a/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl b/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl new file mode 100644 index 00000000..9d694868 --- /dev/null +++ b/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl @@ -0,0 +1,11 @@ +import uvicorn + +from .app import create_app + + +def main() -> None: + uvicorn.run(create_app(), host="127.0.0.1", port=8000) + + +if __name__ == "__main__": + main() diff --git a/rsconnect/quickstart/templates/fastapi/app.py.tmpl b/rsconnect/quickstart/templates/fastapi/app.py.tmpl new file mode 100644 index 00000000..401459eb --- /dev/null +++ b/rsconnect/quickstart/templates/fastapi/app.py.tmpl @@ -0,0 +1,11 @@ +from fastapi import FastAPI + + +def create_app() -> FastAPI: + app = FastAPI() + + @app.get("/") + def hello() -> dict: + return {"message": "Hello from {name}!"} + + return app diff --git a/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl b/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl new file mode 100644 index 00000000..4bbafcea --- /dev/null +++ b/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl @@ -0,0 +1,32 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hello from {name}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Hello from {name}!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/rsconnect/quickstart/templates/quarto/report.qmd.tmpl b/rsconnect/quickstart/templates/quarto/report.qmd.tmpl new file mode 100644 index 00000000..7ed3ceab --- /dev/null +++ b/rsconnect/quickstart/templates/quarto/report.qmd.tmpl @@ -0,0 +1,8 @@ +--- +title: "{name}" +format: html +--- + +# Hello from {name} + +This is a minimal Quarto document. diff --git a/rsconnect/quickstart/templates/shiny/app.py.tmpl b/rsconnect/quickstart/templates/shiny/app.py.tmpl new file mode 100644 index 00000000..5a923709 --- /dev/null +++ b/rsconnect/quickstart/templates/shiny/app.py.tmpl @@ -0,0 +1,3 @@ +from shiny.express import ui + +ui.h1("Hello from {name}!") diff --git a/rsconnect/quickstart/templates/streamlit/app.py.tmpl b/rsconnect/quickstart/templates/streamlit/app.py.tmpl new file mode 100644 index 00000000..25c41563 --- /dev/null +++ b/rsconnect/quickstart/templates/streamlit/app.py.tmpl @@ -0,0 +1,3 @@ +import streamlit as st + +st.write("Hello from {name}!") diff --git a/rsconnect/quickstart_templates/__init__.py b/rsconnect/quickstart_templates/__init__.py deleted file mode 100644 index c56e6a1e..00000000 --- a/rsconnect/quickstart_templates/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Template data for :mod:`rsconnect.quickstart`. - -This package hosts the on-disk template files for every supported app mode. -It is deliberately a package (not a single module) so each mode can live in -its own subdirectory and the registry stays "drop in a directory to add a -mode" per SPEC_QUICKSTART.md §4.1. The package is internal to -``rsconnect.quickstart``; callers should not import from it directly. - -See ``rsconnect/quickstart.py`` for the public entrypoint and the evolution -marker that defines the registry contract. -""" - -# TODO(EVO-270): Ship the per-mode template files under this package. -# Scope: quickstart -# Why: The storage format is locked: stdlib ``str.format`` -# substitution on plain text files discovered at runtime -# via :func:`importlib.resources.files`, laid out as -# ``rsconnect/quickstart_templates//`` so -# they survive wheel install. What remains is the -# per-mode content (streamlit, shiny, fastapi, api/flask, -# notebook, voila, quarto-static, quarto-shiny) referenced -# by each :class:`rsconnect.quickstart.FileSpec`. -# Done: Every per-mode evolution in ``rsconnect/quickstart.py`` -# (``Register the template ...``) has its -# ``source_files`` tuple populated and a matching file -# laid down under this package; the ATDD tests in -# ``tests/test_quickstart.py`` that assert on generated -# file contents pass. -# Non-Goals: Do not introduce a template engine - stdlib -# ``str.format`` is the chosen format. Do not add -# build steps; do not mix R or Node templates in -# (v1 is Python-only per §16). -# Caveat: :func:`importlib.resources.files` is Python 3.9+; -# ``rsconnect-python`` advertises ``requires-python -# >= 3.8``. When this marker lands, either use the -# ``importlib_resources`` backport on 3.8 or coordinate -# a floor bump first. diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index ec37f44c..b0dd94a3 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -18,9 +18,11 @@ from __future__ import annotations +import json import os import pathlib import re +import shutil import stat import subprocess import sys @@ -124,7 +126,7 @@ def test_quickstart_delegates_to_run_quickstart( expected: typing.Mapping[str, typing.Any], ): run_quickstart = mock.Mock() - monkeypatch.setattr("rsconnect.quickstart.run_quickstart", run_quickstart) + monkeypatch.setattr("rsconnect.quickstart.quickstart.run_quickstart", run_quickstart) result = runner.invoke(cli, ["quickstart", *args]) @@ -337,29 +339,50 @@ def test_quickstart_app_mode_for_each_type( @pytest.mark.parametrize( - "app_type,expected_files,forbidden_files", + "app_type,expected_files,forbidden_files,content_sentinels", [ - ("streamlit", {"app.py"}, {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}), - ("shiny", {"app.py"}, {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}), - ("fastapi", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}), - ("api", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}), - ("notebook", {"notebook.ipynb"}, {"app.py", "__connect__.py", "__main__.py", "report.qmd"}), - ("voila", {"notebook.ipynb"}, {"app.py", "__connect__.py", "__main__.py", "report.qmd"}), - ("quarto", {"report.qmd"}, {"app.py", "__connect__.py", "__main__.py", "notebook.ipynb"}), + ( + "streamlit", + {"app.py"}, + {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}, + {"app.py": "streamlit"}, + ), + ("shiny", {"app.py"}, {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}, {"app.py": "shiny"}), + ( + "fastapi", + {"app.py", "__connect__.py", "__main__.py"}, + {"notebook.ipynb", "report.qmd"}, + {"app.py": "FastAPI"}, + ), + ("api", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}, {"app.py": "Flask"}), + ("flask", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}, {"app.py": "Flask"}), + ( + "notebook", + {"notebook.ipynb"}, + {"app.py", "__connect__.py", "__main__.py", "report.qmd"}, + {"notebook.ipynb": "cells"}, + ), + ( + "voila", + {"notebook.ipynb"}, + {"app.py", "__connect__.py", "__main__.py", "report.qmd"}, + {"notebook.ipynb": "cells"}, + ), + ( + "quarto", + {"report.qmd"}, + {"app.py", "__connect__.py", "__main__.py", "notebook.ipynb"}, + {"report.qmd": "title"}, + ), ], ) -@pytest.mark.xfail( - strict=False, - reason=( - "TODO(EVO-160..220): Register the per-mode templates " "(file set covered by EVO-160..220 - one per app mode)." - ), -) def test_quickstart_mode_file_set( runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str, expected_files: set[str], forbidden_files: set[str], + content_sentinels: typing.Mapping[str, str], ): result = _invoke_quickstart(runner, app_type, "hello-app") assert result.exit_code == 0, result.output @@ -369,12 +392,12 @@ def test_quickstart_mode_file_set( assert name in present, f"{name} missing; got {present}" for name in forbidden_files: assert name not in present, f"{name} unexpectedly present; SPEC §6 forbids it for {app_type}" + # Sentinel substrings guard against an empty/placeholder template body slipping through. + for filename, sentinel in content_sentinels.items(): + body = (project / filename).read_text() + assert sentinel in body, f"{filename} missing sentinel {sentinel!r}; got {body!r}" -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-180): Register the fastapi template (module-style, entrypoint).", -) def test_quickstart_fastapi_entrypoint_is_connect_app(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "fastapi", "hello-app") assert result.exit_code == 0, result.output @@ -385,10 +408,6 @@ def test_quickstart_fastapi_entrypoint_is_connect_app(runner: CliRunner, in_tmp_ assert "app = " in connect_py -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-180): Register the fastapi template (module-style, __main__).", -) def test_quickstart_fastapi_main_runs_uvicorn(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "fastapi", "hello-app") assert result.exit_code == 0, result.output @@ -396,10 +415,6 @@ def test_quickstart_fastapi_main_runs_uvicorn(runner: CliRunner, in_tmp_cwd: pat assert "uvicorn" in main_py -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-190): Register the api / flask template.", -) def test_quickstart_api_main_runs_flask_dev_server(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "api", "hello-app") assert result.exit_code == 0, result.output @@ -407,6 +422,44 @@ def test_quickstart_api_main_runs_flask_dev_server(runner: CliRunner, in_tmp_cwd assert "flask" in main_py.lower() or "app.run(" in main_py +@pytest.mark.parametrize( + "app_type,filename", + [ + ("streamlit", "app.py"), + ("quarto", "report.qmd"), + ("notebook", "notebook.ipynb"), + ], +) +def test_quickstart_substitutes_name_in_template_body( + runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str, filename: str +): + """Verify ``{name}`` substitution actually runs on template content.""" + result = _invoke_quickstart(runner, app_type, "alt-project") + assert result.exit_code == 0, result.output + body = (in_tmp_cwd / "alt-project" / filename).read_text() + assert "alt-project" in body, body + + +def test_quickstart_notebook_is_valid_json(runner: CliRunner, in_tmp_cwd: pathlib.Path): + """The generated notebook.ipynb must parse as JSON after name substitution.""" + result = _invoke_quickstart(runner, "notebook", "hello-app") + assert result.exit_code == 0, result.output + body = (in_tmp_cwd / "hello-app" / "notebook.ipynb").read_text() + data = json.loads(body) + assert data["nbformat"] >= 4 + assert isinstance(data["cells"], list) and len(data["cells"]) >= 1 + + +def test_quickstart_voila_and_notebook_share_template(runner: CliRunner, in_tmp_cwd: pathlib.Path): + """SPEC §6.3: voila reuses the notebook template rather than duplicating it.""" + _invoke_quickstart(runner, "notebook", "hello-app") + notebook_body = (in_tmp_cwd / "hello-app" / "notebook.ipynb").read_text() + shutil.rmtree(in_tmp_cwd / "hello-app") + _invoke_quickstart(runner, "voila", "hello-app") + voila_body = (in_tmp_cwd / "hello-app" / "notebook.ipynb").read_text() + assert notebook_body == voila_body + + # --------------------------------------------------------------------------- # Venv population (SPEC §5.1, §7, I5) # --------------------------------------------------------------------------- @@ -553,7 +606,7 @@ def test_quickstart_registry_accepts_new_mode( working scaffold without touching the pre-flight, pyproject writer, or post-scaffold output modules. """ - from rsconnect import quickstart as qs + from rsconnect.quickstart import quickstart as qs new_spec = qs.TemplateSpec( app_mode="python-newmode", From b4ecf6258513aa8f35fecde351e63531125d0499 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 18 May 2026 16:11:26 +0200 Subject: [PATCH 08/26] Quickstart command in place and working --- rsconnect/quickstart/quickstart.py | 152 +++++++++++++---------------- tests/test_quickstart.py | 94 ++++++++++-------- 2 files changed, 121 insertions(+), 125 deletions(-) diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 182cb44b..b4591dcb 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -22,8 +22,11 @@ import pkgutil import re import shutil +import subprocess import typing +import click + from ..exception import RSConnectException @@ -93,35 +96,23 @@ def run_quickstart( # impossible flag combinations only. spec = lookup_template(app_type, static=static, shiny=shiny) - # SPEC §5.1 + §6: create the project directory and all of its files in - # one filesystem-generation phase. Venv population, post-scaffold - # output, and rollback land in subsequent evolutions (see TODO(EVO-080) - # below) and plug in around this step, not inside it. - _scaffold(target, name=name, spec=spec) - - # TODO(EVO-080): Finish the venv + post-output + rollback phases. - # Scope: quickstart - # Why: Pre-flight (SPEC §10) and filesystem generation - # (SPEC §5.1 + §6, via ``_scaffold``) are landed. - # The remaining flow (``uv venv`` + ``uv sync``, - # post-scaffold stdout per §12, and rollback per - # §11) still needs to live behind this public - # entrypoint so the capability stays understandable - # through one module boundary. - # Done: Calling ``run_quickstart`` with valid inputs runs - # ``uv venv`` + ``uv sync``, prints the §12 lines, - # and rolls back ``.//`` on any failure. The - # ATDD tests still marked ``xfail`` in - # ``tests/test_quickstart.py`` - # (``creates_populated_venv``, - # ``post_scaffold_output``, - # ``rolls_back_directory_on_uv_failure``, - # ``invariant_I9_I10_failure_exit_and_message``) - # pass without ``xfail``. - # Non-Goals: Do not implement the ``deploy pyproject`` - # command; do not add interactive prompts or a - # ``--deploy`` flag. - + # SPEC §11 + I8: after ``mkdir`` succeeds, any failure in the rest of + # the pipeline must remove ``.//`` so the user sees "all or + # nothing." ``BaseException`` catches ``KeyboardInterrupt`` too (a + # Ctrl-C mid-``uv sync`` is the most likely real-world failure mode). + target.mkdir() + try: + _scaffold(target, name=name, spec=spec) + _install_venv(target) + except BaseException: + shutil.rmtree(target, ignore_errors=True) + raise + + # Summary runs after success - cosmetic stdout failures (e.g. a + # BrokenPipeError when piping to ``head``) must not invalidate the + # on-disk project. The README carries the same two commands, so the + # user can recover them even if this echo fails. + _emit_summary(target, name=name, spec=spec) return target @@ -344,17 +335,14 @@ def lookup_template(app_type: str, *, static: bool = False, shiny: bool = False) def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: - """Create the project directory and every file it should contain. - - This is the SPEC §5.1 + §6 filesystem-generation phase: the project - directory, the four always-present files (``pyproject.toml``, - ``.python-version``, ``.gitignore``, ``README.md``), and the per-mode - source files materialized from ``spec.source_files``. The remaining - scaffold scope tracked by the ``TODO`` markers below in this module - (``uv`` venv population, post-scaffold stdout, and rollback) plugs in - around this step, not inside it. + """Write every file the scaffolded project should contain. + + This is the SPEC §5.1 + §6 filesystem-generation phase: the four + always-present files (``pyproject.toml``, ``.python-version``, + ``.gitignore``, ``README.md``) and the per-mode source files + materialized from ``spec.source_files``. The caller owns ``target``'s + creation and rollback, so this helper writes into an existing directory. """ - target.mkdir() (target / "pyproject.toml").write_text(_render_pyproject(name=name, spec=spec), encoding="utf-8") (target / ".python-version").write_text(f"{_PYTHON_VERSION}\n", encoding="utf-8") (target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8") @@ -425,49 +413,43 @@ def _format_local_run(spec: TemplateSpec, *, name: str) -> str: return " ".join(token.replace("{name}", name) for token in spec.local_run_command) -# TODO(EVO-240): Run ``uv venv`` + ``uv sync`` inside the scaffolded directory. -# Scope: quickstart -# Why: SPEC §5.1 / §7 / I5 require a populated ``.venv/`` so the -# documented local-run command works immediately without -# any extra setup step. This is what makes the project -# actually "ready-to-deploy." -# Done: Test ``test_quickstart_creates_populated_venv`` in -# ``tests/test_quickstart.py`` passes: ``.venv/`` exists -# and the declared dependencies are importable from it. -# Failure from ``uv`` triggers the rollback evolution. -# Non-Goals: Do not reimplement venv creation; shell out to -# ``uv``. Do not gate on Python-version availability - -# §10 delegates that to uv's own output. - - -# TODO(EVO-250): Implement atomic rollback of .// on any failure. -# Scope: quickstart -# Why: SPEC §11 + I8 require that any failure after directory -# creation leaves no partial project behind. Keeping this -# in one place (the public entrypoint's try/finally frame) -# preserves the "one deep module" shape - callers do not -# have to know about rollback. -# Done: Test -# ``test_quickstart_rolls_back_directory_on_uv_failure`` -# in ``tests/test_quickstart.py`` passes (a forced uv -# failure leaves no ``.//``). Ancestor directories -# and uv cache state are untouched per §11. -# Non-Goals: Do not roll back uv cache writes or ancestor -# directories; do not catch and swallow the error -# (I9 requires non-zero exit). - - -# TODO(EVO-260): Emit the post-scaffold confirmation and command lines. -# Scope: quickstart -# Why: SPEC §12 + I7 require three stdout lines: confirmation, -# local-run command, deploy command - verbatim per the §12 -# table. The generated README.md must carry the same two -# commands. -# Done: Tests ``test_quickstart__post_scaffold_output`` -# (one per mode) and -# ``test_quickstart_readme_matches_post_scaffold_output`` -# pass. The exit code is zero only when these lines have -# been printed. -# Non-Goals: Do not colorize aggressively; do not add a -# "next steps" multi-paragraph block - §12 caps the -# output at three lines. +# --------------------------------------------------------------------------- +# Venv population (SPEC §5.1 / §7 / I5) +# --------------------------------------------------------------------------- + + +def _install_venv(target: pathlib.Path) -> None: + """Populate ``.venv/`` via ``uv venv`` + ``uv sync`` per SPEC §5.1 + §7. + + stdout/stderr are inherited from the parent process so users see uv's + own progress output in real time ("Creating environment...", "Resolving + dependencies..."). A non-zero exit raises ``RSConnectException``, which + the caller translates into the SPEC §11 rollback. + """ + # ``uv venv`` first so ``uv sync`` reads the freshly-created ``.venv``; + # if the first step fails there is no point continuing. + for command in (("uv", "venv"), ("uv", "sync")): + result = subprocess.run(list(command), cwd=target) + if result.returncode != 0: + joined = " ".join(command) + raise RSConnectException( + f"`{joined}` failed in {target} (exit code {result.returncode}). " + "Inspect the output above and try again." + ) + + +# --------------------------------------------------------------------------- +# Post-scaffold output (SPEC §12 / I7) +# --------------------------------------------------------------------------- + + +def _emit_summary(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: + """Print the SPEC §12 three lines: confirmation, local-run, deploy. + + Uses :func:`click.echo` for consistency with the rest of the CLI; the + same two commands are written into the project's ``README.md`` by + :func:`_render_readme` so stdout and on-disk docs agree. + """ + click.echo(f"Created {target.name}/") + click.echo(_format_local_run(spec, name=name)) + click.echo(f"rsconnect deploy pyproject {name}") diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index b0dd94a3..1604172a 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -8,9 +8,12 @@ Tests are written against the CLI using ``click.testing.CliRunner`` and inspect externally observable behavior per SPEC §17.3: exit code, filesystem tree, -``pyproject.toml`` AST, stdout/stderr, and the populated ``.venv/``. They are -expected to fail today because the feature is not yet implemented; each test -cites the evolution marker that unblocks it via ``@pytest.mark.xfail``. +``pyproject.toml`` AST, stdout/stderr, and the populated ``.venv/``. Real +``uv venv`` + ``uv sync`` subprocesses run as part of the end-to-end coverage, +so some tests incur a short network round-trip. + +The boot-smoke matrix (``test_quickstart_per_mode_boot_smoke``) is currently +skipped pending the harness in ``tests/smoke_boot_harness.py``. Test layout mirrors ``tests/test_main.py`` (CliRunner) and ``tests/test_pyproject.py`` (fixture- and parametrize-driven). @@ -465,10 +468,6 @@ def test_quickstart_voila_and_notebook_share_template(runner: CliRunner, in_tmp_ # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-240): Run uv venv + uv sync inside the scaffolded directory.", -) def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathlib.Path): result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code == 0, result.output @@ -476,6 +475,9 @@ def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathli assert (project / ".venv").is_dir() # A populated venv has a site-packages or pyvenv.cfg. assert (project / ".venv" / "pyvenv.cfg").is_file() + # uv sync writes uv.lock; ``uv venv`` alone does not. Catches a future + # regression that creates the venv but skips dependency resolution. + assert (project / "uv.lock").is_file() # --------------------------------------------------------------------------- @@ -483,14 +485,6 @@ def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathli # --------------------------------------------------------------------------- -# strict=True: today this would XPASS for the wrong reason (no rollback runs -# because the scaffold phase that would invoke the fake uv is not implemented -# yet, so nothing is ever created to roll back). Flip to xfail-non-strict and -# remove the decorator once the real rollback path lands. -@pytest.mark.xfail( - strict=True, - reason="TODO(EVO-250): Implement atomic rollback of .// on any failure.", -) def test_quickstart_rolls_back_directory_on_uv_failure( runner: CliRunner, in_tmp_cwd: pathlib.Path, @@ -509,6 +503,27 @@ def test_quickstart_rolls_back_directory_on_uv_failure( assert not (in_tmp_cwd / "hello-app").exists() # I8: all or nothing +def test_quickstart_rolls_back_on_keyboard_interrupt( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +): + """Ctrl-C mid-pipeline triggers the same rollback path as a uv failure. + + The production ``except BaseException`` exists precisely so a user who + aborts ``uv sync`` does not end up with a half-built project. This test + pins that guarantee hermetically by raising ``KeyboardInterrupt`` from + the venv-install phase; no real ``uv`` runs. + """ + from rsconnect.quickstart import quickstart as qs + + monkeypatch.setattr(qs, "_install_venv", mock.MagicMock(side_effect=KeyboardInterrupt)) + + result = _invoke_quickstart(runner, "streamlit", "hello-app") + assert result.exit_code != 0 + assert not (in_tmp_cwd / "hello-app").exists() + + # --------------------------------------------------------------------------- # Post-scaffold output (SPEC §12, I7) # --------------------------------------------------------------------------- @@ -528,10 +543,6 @@ def test_quickstart_rolls_back_directory_on_uv_failure( @pytest.mark.parametrize("app_type,extra_flags,local_run", POST_SCAFFOLD_COMMANDS) -@pytest.mark.xfail( - strict=False, - reason="TODO(EVO-260): Emit the post-scaffold confirmation and command lines.", -) def test_quickstart_post_scaffold_output( runner: CliRunner, in_tmp_cwd: pathlib.Path, @@ -541,9 +552,14 @@ def test_quickstart_post_scaffold_output( ): result = _invoke_quickstart(runner, app_type, *extra_flags, "hello-app") assert result.exit_code == 0, result.output - assert "hello-app" in result.output # confirmation line - assert local_run in result.output - assert "rsconnect deploy pyproject hello-app" in result.output + # SPEC §12 pins both the wording and the order of the three lines; a + # substring check would tolerate extra debug output or reordering. + lines = [line for line in result.output.splitlines() if line.strip()] + assert lines == [ + "Created hello-app/", + local_run, + "rsconnect deploy pyproject hello-app", + ], result.output def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tmp_cwd: pathlib.Path): @@ -570,25 +586,23 @@ def test_invariant_I1_I2_directory_and_pyproject(runner: CliRunner, in_tmp_cwd: assert required in data["tool"]["rsconnect"] -# strict=True: today this XPASSes via the directory-must-not-exist pre-flight, -# but the test is intended to prove pipeline-level failure translation, not the -# pre-flight short-circuit. Remove the decorator once the real pipeline path -# raises and the message-quality assertions exercise that translation. -# Persistent RED in CI until the TODO(EVO-080) work lands and this test is -# rewritten to inject a pipeline-level failure (e.g., mock a uv subprocess error). -@pytest.mark.xfail( - strict=True, - reason=( - "TODO(EVO-080): Invariants I9-I10 - non-zero exit and actionable " - "stderr on failure (pipeline error translation)." - ), -) -def test_invariant_I9_I10_failure_exit_and_message(runner: CliRunner, in_tmp_cwd: pathlib.Path): - (in_tmp_cwd / "hello-app").mkdir() +def test_invariant_I9_I10_failure_exit_and_message( + runner: CliRunner, + in_tmp_cwd: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +): + """SPEC §15 I9-I10: pipeline failure produces non-zero exit and actionable stderr.""" + fake_uv_dir = in_tmp_cwd / "fake-bin" + fake_uv_dir.mkdir() + fake_uv = fake_uv_dir / "uv" + fake_uv.write_text("#!/usr/bin/env bash\nexit 1\n") + fake_uv.chmod(0o755) + monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") + result = _invoke_quickstart(runner, "streamlit", "hello-app") assert result.exit_code != 0 # I9 combined = result.output + (result.stderr if result.stderr_bytes else "") - assert "hello-app" in combined # I10 - message names the failing check + assert "uv" in combined.lower() # I10 - message names the failing tool # --------------------------------------------------------------------------- @@ -612,7 +626,7 @@ def test_quickstart_registry_accepts_new_mode( app_mode="python-newmode", entrypoint="app.py", local_run_command=("uv", "run", "newtool", "app.py"), - dependencies=("newtool",), + dependencies=(), source_files=(), ) extended_registry = dict(qs._REGISTRY) @@ -626,7 +640,7 @@ def test_quickstart_registry_accepts_new_mode( data = _read_pyproject(in_tmp_cwd / "hello-app") assert data["tool"]["rsconnect"]["app_mode"] == "python-newmode" assert data["tool"]["rsconnect"]["entrypoint"] == "app.py" - assert data["project"]["dependencies"] == ["newtool"] + assert data["project"]["dependencies"] == [] # --------------------------------------------------------------------------- From 304a737d11599ffee6a6bd9f5c8613cee964c93c Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 18 May 2026 17:59:26 +0200 Subject: [PATCH 09/26] quickstart templates for quarto, fastapi, jupyter, shiny --- pyproject.toml | 2 +- rsconnect/api.py | 2 +- rsconnect/main.py | 24 +- rsconnect/quickstart/__init__.py | 15 +- rsconnect/quickstart/quickstart.py | 160 +++++--- .../quickstart/templates/api/__init__.py.tmpl | 1 + .../templates/fastapi/__init__.py.tmpl | 1 + .../templates/notebook/notebook.ipynb.tmpl | 2 + .../templates/quarto/report_shiny.qmd.tmpl | 18 + tests/test_deploy_pyproject.py | 26 +- tests/test_quickstart.py | 386 ++++++++++-------- 11 files changed, 384 insertions(+), 253 deletions(-) create mode 100644 rsconnect/quickstart/templates/api/__init__.py.tmpl create mode 100644 rsconnect/quickstart/templates/fastapi/__init__.py.tmpl create mode 100644 rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl diff --git a/pyproject.toml b/pyproject.toml index 6e300fab..871ecc73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", - "click>=8.0.0", + "click>=8.2.0", "toml>=0.10; python_version < '3.11'", ] diff --git a/rsconnect/api.py b/rsconnect/api.py index cf1fb93e..950b09c5 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -139,7 +139,7 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool if isinstance(response, HTTPResponse): if response.exception: raise RSConnectException( - "Exception trying to connect to %s - %s" % (self.url, response.exception), cause=response.exception + "Could not connect to %s - %s" % (self.url, response.exception), cause=response.exception ) # Sometimes an ISP will respond to an unknown server name by returning a friendly # search page so trap that since we know we're expecting JSON from Connect. This diff --git a/rsconnect/main.py b/rsconnect/main.py index 7c7d52e5..d836b2bc 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -108,6 +108,7 @@ ) from .log import VERBOSE, LogOutputFormat, logger from .metadata import AppStore, ServerStore +from .quickstart import SUPPORTED_APP_TYPES from .models import ( AppMode, AppModes, @@ -1026,22 +1027,29 @@ def info(file: str): name="quickstart", short_help="Scaffold a deployable Posit Connect project.", help=( - "Create a new Posit Connect project of the given type in .//. " - "Writes a pyproject.toml with a [tool.rsconnect] section, creates a " - "uv-managed virtualenv, and prints the local-run and deploy commands. " - "See SPEC_QUICKSTART.md for the full contract." + "Create a new Posit Connect project of the given TYPE in .//. " + "Supported TYPE values: streamlit, shiny, fastapi, api, flask, " + "notebook, voila, quarto. Writes a pyproject.toml with a " + "[tool.rsconnect] section, creates a uv-managed virtualenv, and " + "prints the local-run and deploy commands." ), no_args_is_help=True, ) -@click.argument("app_type", metavar="TYPE") +@click.argument( + "app_type", + metavar="TYPE", + type=click.Choice(SUPPORTED_APP_TYPES), +) @click.argument("name", metavar="NAME") -@click.option("--static", is_flag=True, help="(jupyter only) emit jupyter-static app mode.") @click.option("--shiny", is_flag=True, help="(quarto only) emit quarto-shiny instead of quarto-static.") @cli_exception_handler -def quickstart(app_type: str, name: str, static: bool, shiny: bool): +def quickstart(app_type: str, name: str, shiny: bool): + # Resolve ``run_quickstart`` through the module at call time so tests can + # monkeypatch ``rsconnect.quickstart.quickstart.run_quickstart`` without + # binding a stale reference into ``main``'s namespace at import time. from .quickstart.quickstart import run_quickstart - run_quickstart(app_type=app_type, name=name, static=static, shiny=shiny) + run_quickstart(app_type=app_type, name=name, shiny=shiny) @cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.") diff --git a/rsconnect/quickstart/__init__.py b/rsconnect/quickstart/__init__.py index b091aac7..aa9a7763 100644 --- a/rsconnect/quickstart/__init__.py +++ b/rsconnect/quickstart/__init__.py @@ -1,5 +1,16 @@ """``rsconnect quickstart`` package. -The scaffolding implementation lives in :mod:`rsconnect.quickstart.quickstart`. -Templates ship as package data under :mod:`rsconnect.quickstart.templates`. +Public API: + +- :func:`run_quickstart` — scaffold a new project. +- :data:`SUPPORTED_APP_TYPES` — supported quickstart type vocabulary. + +Internal pieces (``TemplateSpec``, ``_REGISTRY``, etc.) live in +:mod:`rsconnect.quickstart.quickstart` and are not re-exported here. +Tests that need them (registry-extensibility) import the inner module +directly. """ + +from .quickstart import SUPPORTED_APP_TYPES, run_quickstart + +__all__ = ["SUPPORTED_APP_TYPES", "run_quickstart"] diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index b4591dcb..49fdee79 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -48,12 +48,15 @@ # SPEC §2.2: lowercase ASCII letter start, only lowercase letters / digits / -# hyphens, no trailing hyphen. The optional middle-and-end group keeps the -# rule satisfiable by single-letter names such as ``"a"``. -_project_name_pattern = re.compile(r"^[a-z]([a-z0-9-]*[a-z0-9])?$") +# underscores, no trailing underscore. Underscores (not hyphens) so the name +# is a valid Python package identifier — fastapi/api scaffolds materialize a +# ``//__init__.py`` package, which only works with importable +# names. The optional middle-and-end group keeps the rule satisfiable by +# single-letter names such as ``"a"``. +_project_name_pattern = re.compile(r"^[a-z]([a-z0-9_]*[a-z0-9])?$") _PROJECT_NAME_RULE = ( "Project name must start with a lowercase ASCII letter, contain only " - "lowercase letters, digits, and hyphens, and not end with a hyphen." + "lowercase letters, digits, and underscores, and not end with an underscore." ) @@ -61,7 +64,6 @@ def run_quickstart( app_type: str, name: str, *, - static: bool = False, shiny: bool = False, cwd: typing.Optional[pathlib.Path] = None, ) -> pathlib.Path: @@ -74,7 +76,6 @@ def run_quickstart( :param str app_type: one of the supported CLI types in SPEC §4. :param str name: project name; must satisfy SPEC §2.2. - :param bool static: jupyter-only flag; selects ``jupyter-static``. :param bool shiny: quarto-only flag; selects ``quarto-shiny``. :param pathlib.Path cwd: override the working directory (testing hook); defaults to :func:`pathlib.Path.cwd`. @@ -83,9 +84,10 @@ def run_quickstart( # SPEC §10 pre-flight order. Each helper raises ``RSConnectException`` # with an actionable message; nothing on disk is mutated until every - # check has passed. + # check has passed. Type validation now lives in Click's argument + # parser (see ``rsconnect/main.py``), so it has already passed before + # we get here. _require_uv_on_path() - _validate_app_type(app_type) _validate_project_name(name) target = cwd / name _require_target_does_not_exist(target) @@ -94,7 +96,7 @@ def run_quickstart( # SPEC §4/§6: resolve the per-mode template once. Pre-flight already # validated ``app_type``; ``lookup_template`` is defensive against # impossible flag combinations only. - spec = lookup_template(app_type, static=static, shiny=shiny) + spec = lookup_template(app_type, shiny=shiny) # SPEC §11 + I8: after ``mkdir`` succeeds, any failure in the rest of # the pipeline must remove ``.//`` so the user sees "all or @@ -133,12 +135,6 @@ def _require_uv_on_path() -> None: ) -def _validate_app_type(app_type: str) -> None: - if app_type not in SUPPORTED_APP_TYPES: - supported = ", ".join(SUPPORTED_APP_TYPES) - raise RSConnectException(f"Unsupported project type {app_type!r}. Supported types: {supported}.") - - def _validate_project_name(name: str) -> None: if not _project_name_pattern.match(name): raise RSConnectException(f"Invalid project name {name!r}. {_PROJECT_NAME_RULE}") @@ -178,7 +174,10 @@ def _require_cwd_writable(cwd: pathlib.Path) -> None: class FileSpec: """One per-mode template file to materialize in the scaffolded project. - :param str name: filename relative to the project root. + :param str name: filename relative to the project root. The literal + token ``{name}`` (if present) is substituted with the project name + at scaffold time, which is how fastapi/api modes produce a nested + ``//`` Python package layout. :param str template: path to the template body under ``rsconnect/quickstart/templates/``, loaded via :func:`pkgutil.get_data`. Template files use the ``.tmpl`` suffix @@ -199,13 +198,15 @@ class FileSpec: class TemplateSpec: """Per-resolved-mode scaffold contract. - Resolved means the ``(app_type, static, shiny)`` flag triple has already - been mapped to one entry; the dataclass itself does not know about CLI + Resolved means the ``(app_type, shiny)`` flag pair has already been + mapped to one entry; the dataclass itself does not know about CLI aliases or flags. :param str app_mode: canonical Connect app mode per SPEC §8.2. :param str entrypoint: entrypoint string written to - ``[tool.rsconnect].entrypoint`` per SPEC §6. + ``[tool.rsconnect].entrypoint`` per SPEC §6. The literal token + ``{name}`` (if present) is substituted with the project name at + scaffold time so fastapi/api can name their nested package. :param tuple local_run_command: argv form of the documented local-run command per SPEC §12. The literal token ``"{name}"`` (if present) is substituted with the project name at scaffold time. @@ -214,8 +215,11 @@ class TemplateSpec: :param tuple source_files: per-mode template files to materialize. Each entry's body is loaded from ``rsconnect/quickstart/templates/`` and run through - ``str.replace("{name}", name)`` at scaffold time. Empty only for - modes whose templates have not landed yet. + ``str.replace("{name}", name)`` at scaffold time. + :param tuple notes: optional user-facing trailing lines for the + post-scaffold output and README (e.g. "Quarto must be installed + separately"). Empty for modes whose hello-world has no external + tooling prerequisite. """ app_mode: str @@ -223,108 +227,107 @@ class TemplateSpec: local_run_command: typing.Tuple[str, ...] dependencies: typing.Tuple[str, ...] source_files: typing.Tuple[FileSpec, ...] + notes: typing.Tuple[str, ...] = () + +# Registry key: ``(resolved_type, shiny)``. The ``flask`` alias resolves to +# ``api`` before lookup (see :func:`lookup_template`); the v1 deferred modes +# from SPEC §4.1 (dash, gradio, panel, bokeh) are intentionally absent. +_QUARTO_INSTALL_NOTE = "Quarto must be installed separately: https://quarto.org" -# Registry key: ``(resolved_type, static, shiny)``. The ``flask`` alias -# resolves to ``api`` before lookup (see :func:`lookup_template`); the v1 -# deferred modes from SPEC §4.1 (dash, gradio, panel, bokeh) are intentionally -# absent. -_REGISTRY: typing.Mapping[typing.Tuple[str, bool, bool], TemplateSpec] = { - ("streamlit", False, False): TemplateSpec( +_REGISTRY: typing.Mapping[typing.Tuple[str, bool], TemplateSpec] = { + ("streamlit", False): TemplateSpec( app_mode="python-streamlit", entrypoint="app.py", local_run_command=("uv", "run", "streamlit", "run", "app.py"), dependencies=("streamlit",), source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),), ), - ("shiny", False, False): TemplateSpec( + ("shiny", False): TemplateSpec( app_mode="python-shiny", entrypoint="app.py", local_run_command=("uv", "run", "shiny", "run", "app.py"), dependencies=("shiny",), source_files=(FileSpec(name="app.py", template="shiny/app.py.tmpl"),), ), - ("fastapi", False, False): TemplateSpec( + # fastapi/api produce a nested ``//`` package so the + # documented ``python -m `` local-run command resolves cleanly + # and ``from .app import create_app`` relative imports work. + ("fastapi", False): TemplateSpec( app_mode="python-fastapi", - entrypoint="__connect__:app", + entrypoint="{name}.__connect__:app", local_run_command=("uv", "run", "python", "-m", "{name}"), dependencies=("fastapi", "uvicorn"), source_files=( - FileSpec(name="app.py", template="fastapi/app.py.tmpl"), - FileSpec(name="__connect__.py", template="fastapi/__connect__.py.tmpl"), - FileSpec(name="__main__.py", template="fastapi/__main__.py.tmpl"), + FileSpec(name="{name}/__init__.py", template="fastapi/__init__.py.tmpl"), + FileSpec(name="{name}/__main__.py", template="fastapi/__main__.py.tmpl"), + FileSpec(name="{name}/__connect__.py", template="fastapi/__connect__.py.tmpl"), + FileSpec(name="{name}/app.py", template="fastapi/app.py.tmpl"), ), ), - ("api", False, False): TemplateSpec( + ("api", False): TemplateSpec( app_mode="python-api", - entrypoint="__connect__:app", + entrypoint="{name}.__connect__:app", local_run_command=("uv", "run", "python", "-m", "{name}"), dependencies=("flask",), source_files=( - FileSpec(name="app.py", template="api/app.py.tmpl"), - FileSpec(name="__connect__.py", template="api/__connect__.py.tmpl"), - FileSpec(name="__main__.py", template="api/__main__.py.tmpl"), + FileSpec(name="{name}/__init__.py", template="api/__init__.py.tmpl"), + FileSpec(name="{name}/__main__.py", template="api/__main__.py.tmpl"), + FileSpec(name="{name}/__connect__.py", template="api/__connect__.py.tmpl"), + FileSpec(name="{name}/app.py", template="api/app.py.tmpl"), ), ), - # Both the default and --static notebook variants share one template; - # the registry distinguishes them only by ``app_mode`` (see SPEC §6.3). - # The voila entry below reuses the same template file too. - ("notebook", False, False): TemplateSpec( + # notebook and voila share the same template body; they differ only in + # ``app_mode`` and the documented local-run command. + ("notebook", False): TemplateSpec( app_mode="jupyter-static", entrypoint="notebook.ipynb", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), dependencies=("jupyter",), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), - ("notebook", True, False): TemplateSpec( - app_mode="jupyter-static", - entrypoint="notebook.ipynb", - local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), - dependencies=("jupyter",), - source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), - ), - ("voila", False, False): TemplateSpec( + ("voila", False): TemplateSpec( app_mode="jupyter-voila", entrypoint="notebook.ipynb", local_run_command=("uv", "run", "voila", "notebook.ipynb"), dependencies=("voila", "jupyter"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), - ("quarto", False, False): TemplateSpec( + ("quarto", False): TemplateSpec( app_mode="quarto-static", entrypoint="report.qmd", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), dependencies=(), source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), + notes=(_QUARTO_INSTALL_NOTE,), ), - ("quarto", False, True): TemplateSpec( + ("quarto", True): TemplateSpec( app_mode="quarto-shiny", entrypoint="report.qmd", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), dependencies=("shiny",), - source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), + source_files=(FileSpec(name="report.qmd", template="quarto/report_shiny.qmd.tmpl"),), + notes=(_QUARTO_INSTALL_NOTE,), ), } -def lookup_template(app_type: str, *, static: bool = False, shiny: bool = False) -> TemplateSpec: - """Resolve the :class:`TemplateSpec` for ``(app_type, static, shiny)``. +def lookup_template(app_type: str, *, shiny: bool = False) -> TemplateSpec: + """Resolve the :class:`TemplateSpec` for ``(app_type, shiny)``. ``flask`` is an alias for ``api`` and shares the same scaffold; both resolve to the same key. Other CLI-level flag combinations have already been narrowed by pre-flight, so this lookup is defensive only. :param str app_type: CLI ```` value per SPEC §4. - :param bool static: jupyter-only flag. :param bool shiny: quarto-only flag. """ resolved_type = "api" if app_type == "flask" else app_type - key = (resolved_type, static, shiny) + key = (resolved_type, shiny) if key not in _REGISTRY: raise RSConnectException( f"No scaffold template is registered for type {app_type!r} " - f"with --static={static}, --shiny={shiny}. Re-run without the " - f"unsupported flag combination." + f"with --shiny={shiny}. Re-run without the unsupported flag." ) return _REGISTRY[key] @@ -354,7 +357,12 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: if data is None: raise RSConnectException(f"Template not found: {file_spec.template}") body = data.decode("utf-8").replace("{name}", name) - (target / file_spec.name).write_text(body, encoding="utf-8") + # ``{name}`` substitution in ``file_spec.name`` plus mkdir lets the + # registry describe nested package layouts (fastapi/api) without + # special-casing them here. + dest = target / file_spec.name.replace("{name}", name) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body, encoding="utf-8") # SPEC-pinned literals: kept as separate constants because they encode two @@ -374,6 +382,10 @@ def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: deps_block = "[\n" + "".join(f' "{dep}",\n' for dep in spec.dependencies) + "]" else: deps_block = "[]" + # Entrypoints for fastapi/api carry a literal ``{name}`` so the + # documented value matches the nested package layout this scaffold + # produces. + entrypoint = spec.entrypoint.replace("{name}", name) return ( "[project]\n" f'name = "{name}"\n' @@ -383,7 +395,7 @@ def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: "\n" "[tool.rsconnect]\n" f'app_mode = "{spec.app_mode}"\n' - f'entrypoint = "{spec.entrypoint}"\n' + f'entrypoint = "{entrypoint}"\n' f'title = "{name}"\n' ) @@ -391,7 +403,7 @@ def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: def _render_readme(*, name: str, spec: TemplateSpec) -> str: local_run = _format_local_run(spec, name=name) deploy_cmd = f"rsconnect deploy pyproject {name}" - return ( + body = ( f"# {name}\n" "\n" "A Posit Connect project scaffolded by `rsconnect quickstart`.\n" @@ -404,6 +416,10 @@ def _render_readme(*, name: str, spec: TemplateSpec) -> str: "\n" f"```\n{deploy_cmd}\n```\n" ) + if spec.notes: + notes_block = "\n## Notes\n\n" + "".join(f"- {note}\n" for note in spec.notes) + body += notes_block + return body def _format_local_run(spec: TemplateSpec, *, name: str) -> str: @@ -426,10 +442,16 @@ def _install_venv(target: pathlib.Path) -> None: dependencies..."). A non-zero exit raises ``RSConnectException``, which the caller translates into the SPEC §11 rollback. """ + # ``VIRTUAL_ENV`` is removed because uv otherwise warns that the + # developer's currently-activated venv does not match the scaffolded + # project's ``.venv/``. The user expects uv to operate on the new + # project, not the shell's active environment. + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) # ``uv venv`` first so ``uv sync`` reads the freshly-created ``.venv``; # if the first step fails there is no point continuing. for command in (("uv", "venv"), ("uv", "sync")): - result = subprocess.run(list(command), cwd=target) + result = subprocess.run(list(command), cwd=target, env=env) if result.returncode != 0: joined = " ".join(command) raise RSConnectException( @@ -444,12 +466,14 @@ def _install_venv(target: pathlib.Path) -> None: def _emit_summary(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: - """Print the SPEC §12 three lines: confirmation, local-run, deploy. + """Print the SPEC §12 confirmation, local-run, deploy, and notes lines. Uses :func:`click.echo` for consistency with the rest of the CLI; the - same two commands are written into the project's ``README.md`` by + same commands are written into the project's ``README.md`` by :func:`_render_readme` so stdout and on-disk docs agree. """ - click.echo(f"Created {target.name}/") - click.echo(_format_local_run(spec, name=name)) - click.echo(f"rsconnect deploy pyproject {name}") + click.echo(f"Project {target.name}/ created.") + click.echo(f"To run locally: {_format_local_run(spec, name=name)}") + click.echo(f"To deploy: rsconnect deploy pyproject {name}") + for note in spec.notes: + click.echo(f"Note: {note}") diff --git a/rsconnect/quickstart/templates/api/__init__.py.tmpl b/rsconnect/quickstart/templates/api/__init__.py.tmpl new file mode 100644 index 00000000..10bd457c --- /dev/null +++ b/rsconnect/quickstart/templates/api/__init__.py.tmpl @@ -0,0 +1 @@ +"""{name} package.""" diff --git a/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl b/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl new file mode 100644 index 00000000..10bd457c --- /dev/null +++ b/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl @@ -0,0 +1 @@ +"""{name} package.""" diff --git a/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl b/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl index 4bbafcea..9fb90fba 100644 --- a/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +++ b/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "metadata": {}, "source": [ "# Hello from {name}" @@ -9,6 +10,7 @@ }, { "cell_type": "code", + "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "execution_count": null, "metadata": {}, "outputs": [], diff --git a/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl b/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl new file mode 100644 index 00000000..b1df9eef --- /dev/null +++ b/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl @@ -0,0 +1,18 @@ +--- +title: "{name}" +server: shiny +format: html +--- + +# Hello from {name} + +```{python} +from shiny.express import ui, render, input + +ui.h1("Hello world") +ui.input_slider("n", "N", 1, 100, 50) + +@render.text +def show_n(): + return f"You picked {input.n()}" +``` diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index 1fbea64e..d70ea606 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -38,7 +38,7 @@ def runner() -> CliRunner: @pytest.fixture def project_dir(tmp_path: pathlib.Path) -> pathlib.Path: """A fresh directory; tests populate ``pyproject.toml`` as they need.""" - project = tmp_path / "hello-app" + project = tmp_path / "hello_app" project.mkdir() return project @@ -97,7 +97,7 @@ def test_read_tool_rsconnect_returns_three_fields(project_dir: pathlib.Path): project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" dependencies = ["streamlit"] @@ -120,7 +120,7 @@ def test_read_tool_rsconnect_missing_section_raises(project_dir: pathlib.Path): project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" """, ) @@ -169,7 +169,7 @@ def test_read_tool_rsconnect_non_table_raises(project_dir: pathlib.Path, body: s "app_mode", """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] @@ -180,7 +180,7 @@ def test_read_tool_rsconnect_non_table_raises(project_dir: pathlib.Path, body: s "entrypoint", """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] @@ -215,7 +215,7 @@ def test_deploy_pyproject_errors_on_missing_section(runner: CliRunner, project_d project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" """, ) @@ -230,7 +230,7 @@ def test_deploy_pyproject_errors_on_missing_app_mode(runner: CliRunner, project_ project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] @@ -248,7 +248,7 @@ def test_deploy_pyproject_errors_on_missing_entrypoint(runner: CliRunner, projec project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] @@ -267,7 +267,7 @@ def test_deploy_pyproject_error_message_mentions_quickstart(runner: CliRunner, p project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" """, ) @@ -338,7 +338,7 @@ def spy_make_bundle( project_dir, f""" [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] @@ -374,7 +374,7 @@ def test_deploy_pyproject_uses_title_from_tool_rsconnect( project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] @@ -422,13 +422,13 @@ def test_deploy_pyproject_uses_entrypoint_from_tool_rsconnect(runner: CliRunner, project_dir, """ [project] - name = "hello-app" + name = "hello_app" version = "0.0.1" [tool.rsconnect] app_mode = "python-fastapi" entrypoint = "custom_module:create_app" - title = "hello-app" + title = "hello_app" """, ) (project_dir / "custom_module.py").write_text("def create_app():\n return None\n") diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 1604172a..1a7d8924 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -98,27 +98,29 @@ def test_quickstart_requires_type_and_name(runner: CliRunner, in_tmp_cwd: pathli assert result.exit_code != 0 -def test_quickstart_help_exposes_static_and_shiny_flags(runner: CliRunner): +def test_quickstart_help_exposes_shiny_flag(runner: CliRunner): result = runner.invoke(cli, ["quickstart", "--help"]) assert result.exit_code == 0, result.output - assert "--static" in result.output assert "--shiny" in result.output + # ``--static`` was the original pre-shiny flag; SPEC §4.1 replaced it + # with ``--shiny`` (default static), so the old flag must not resurface. + assert "--static" not in result.output @pytest.mark.parametrize( "args,expected", [ ( - ["streamlit", "hello-app"], - {"app_type": "streamlit", "name": "hello-app", "static": False, "shiny": False}, + ["streamlit", "hello_app"], + {"app_type": "streamlit", "name": "hello_app", "shiny": False}, ), ( - ["notebook", "--static", "hello-notebook"], - {"app_type": "notebook", "name": "hello-notebook", "static": True, "shiny": False}, + ["notebook", "hello_notebook"], + {"app_type": "notebook", "name": "hello_notebook", "shiny": False}, ), ( - ["quarto", "--shiny", "hello-quarto"], - {"app_type": "quarto", "name": "hello-quarto", "static": False, "shiny": True}, + ["quarto", "--shiny", "hello_quarto"], + {"app_type": "quarto", "name": "hello_quarto", "shiny": True}, ), ], ) @@ -145,11 +147,11 @@ def test_quickstart_delegates_to_run_quickstart( def test_quickstart_requires_uv_on_path(runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch): """Pre-flight check 1: absent ``uv`` must produce a clear, actionable error.""" monkeypatch.setenv("PATH", str(in_tmp_cwd)) # empty PATH so uv cannot be found - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 combined = result.output + (result.stderr if result.stderr_bytes else "") assert "uv" in combined.lower() - assert not (in_tmp_cwd / "hello-app").exists() # I8: no partial dir on pre-flight failure + assert not (in_tmp_cwd / "hello_app").exists() # I8: no partial dir on pre-flight failure def test_quickstart_uv_missing_message_names_install( @@ -157,7 +159,7 @@ def test_quickstart_uv_missing_message_names_install( ): """The error message should include the recommended install command (SPEC §7).""" monkeypatch.setenv("PATH", str(in_tmp_cwd)) - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") combined = result.output + (result.stderr if result.stderr_bytes else "") # "install" or "astral" or the canonical install URL - any of these proves # the message is actionable rather than a bare "not found". @@ -165,12 +167,12 @@ def test_quickstart_uv_missing_message_names_install( def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "nonesuch", "hello-app") + result = _invoke_quickstart(runner, "nonesuch", "hello_app") assert result.exit_code != 0 combined = result.output + (result.stderr if result.stderr_bytes else "") for expected in ("streamlit", "shiny", "fastapi", "api", "flask", "notebook", "voila", "quarto"): assert expected in combined, f"{expected!r} missing from error output: {combined!r}" - assert not (in_tmp_cwd / "hello-app").exists() + assert not (in_tmp_cwd / "hello_app").exists() @pytest.mark.parametrize( @@ -178,9 +180,10 @@ def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: [ "Hello", # uppercase "1hello", # leading digit - "hello_world", # underscore - "hello-", # trailing hyphen - "-hello", # leading hyphen + "hello-app", # hyphen (not a valid Python identifier) + "hello-world", # hyphen + "hello_", # trailing underscore + "_hello", # leading underscore "", # empty "hello world", # whitespace ], @@ -195,12 +198,12 @@ def test_quickstart_rejects_invalid_name(runner: CliRunner, in_tmp_cwd: pathlib. def test_quickstart_fails_when_directory_exists(runner: CliRunner, in_tmp_cwd: pathlib.Path): - (in_tmp_cwd / "hello-app").mkdir() - (in_tmp_cwd / "hello-app" / "existing-file.txt").write_text("keep me") - result = _invoke_quickstart(runner, "streamlit", "hello-app") + (in_tmp_cwd / "hello_app").mkdir() + (in_tmp_cwd / "hello_app" / "existing-file.txt").write_text("keep me") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 # The pre-existing file must be untouched (SPEC §11 Atomicity). - assert (in_tmp_cwd / "hello-app" / "existing-file.txt").read_text() == "keep me" + assert (in_tmp_cwd / "hello_app" / "existing-file.txt").read_text() == "keep me" @pytest.mark.skipif(sys.platform == "win32", reason="chmod read-only semantics differ on Windows") @@ -210,16 +213,16 @@ def test_quickstart_requires_writable_cwd(runner: CliRunner, tmp_path: pathlib.P readonly.chmod(stat.S_IRUSR | stat.S_IXUSR) try: monkeypatch.chdir(readonly) - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 - assert not (readonly / "hello-app").exists() + assert not (readonly / "hello_app").exists() finally: readonly.chmod(stat.S_IRWXU) def test_quickstart_flask_alias_passes_type_validation(runner: CliRunner, in_tmp_cwd: pathlib.Path): """SPEC §4: 'flask' is accepted as an alias for 'api' at pre-flight.""" - result = _invoke_quickstart(runner, "flask", "hello-app") + result = _invoke_quickstart(runner, "flask", "hello_app") combined = result.output + (result.stderr if result.stderr_bytes else "") # The type-validation gate does not reject 'flask'. The command still # fails downstream (scaffolding is not implemented yet); that failure @@ -228,45 +231,32 @@ def test_quickstart_flask_alias_passes_type_validation(runner: CliRunner, in_tmp assert "Unsupported" not in combined and "supported types" not in combined.lower() -def test_quickstart_preflight_order_uv_before_type( - runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch -): - """SPEC §10: uv-presence is checked before type validation.""" - monkeypatch.setenv("PATH", str(in_tmp_cwd)) # uv unavailable - result = _invoke_quickstart(runner, "nonesuch", "hello-app") - assert result.exit_code != 0 - combined = result.output + (result.stderr if result.stderr_bytes else "") - assert "uv" in combined.lower() - # If the type check had run, the message would name 'nonesuch'. - assert "nonesuch" not in combined.lower() - - # --------------------------------------------------------------------------- # Always-present generated files (SPEC §5.1) # --------------------------------------------------------------------------- def test_quickstart_generates_always_present_files(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - project = in_tmp_cwd / "hello-app" + project = in_tmp_cwd / "hello_app" for name in ("pyproject.toml", ".python-version", ".gitignore", "README.md"): assert (project / name).is_file(), f"{name} missing from {list(project.iterdir())}" assert (project / ".python-version").read_text().strip() == "3.11" def test_quickstart_gitignore_covers_rsconnect_dirs(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - gitignore = (in_tmp_cwd / "hello-app" / ".gitignore").read_text() + gitignore = (in_tmp_cwd / "hello_app" / ".gitignore").read_text() for expected in ("__pycache__", ".venv", "rsconnect-python", ".env"): assert expected in gitignore, f"{expected} missing from .gitignore" def test_quickstart_does_not_create_manifest_json(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - assert not (in_tmp_cwd / "hello-app" / "manifest.json").exists() + assert not (in_tmp_cwd / "hello_app" / "manifest.json").exists() # --------------------------------------------------------------------------- @@ -275,24 +265,24 @@ def test_quickstart_does_not_create_manifest_json(runner: CliRunner, in_tmp_cwd: def test_quickstart_pyproject_has_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - data = _read_pyproject(in_tmp_cwd / "hello-app") - assert data["project"]["name"] == "hello-app" + data = _read_pyproject(in_tmp_cwd / "hello_app") + assert data["project"]["name"] == "hello_app" assert data["project"]["version"] == "0.0.1" assert data["project"]["requires-python"] == ">=3.9" assert data["project"]["dependencies"] == ["streamlit"] tool_rsconnect = data["tool"]["rsconnect"] assert tool_rsconnect["app_mode"] == "python-streamlit" assert tool_rsconnect["entrypoint"] == "app.py" - assert tool_rsconnect["title"] == "hello-app" + assert tool_rsconnect["title"] == "hello_app" def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): """SPEC §3.2: dependencies and requires-python live in [project], not in [tool.rsconnect].""" - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"] + tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello_app")["tool"]["rsconnect"] assert "dependencies" not in tool_rsconnect assert "requires-python" not in tool_rsconnect assert "requires_python" not in tool_rsconnect @@ -311,7 +301,6 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, pytest.param(("api",), "python-api", id="api"), pytest.param(("flask",), "python-api", id="flask-alias"), pytest.param(("notebook",), "jupyter-static", id="notebook-default"), - pytest.param(("notebook", "--static"), "jupyter-static", id="notebook-static"), pytest.param(("voila",), "jupyter-voila", id="voila"), pytest.param(("quarto",), "quarto-static", id="quarto-default"), pytest.param(("quarto", "--shiny"), "quarto-shiny", id="quarto-shiny"), @@ -326,14 +315,14 @@ def test_quickstart_app_mode_for_each_type( expected_mode: str, ): # Put flags before NAME per Click convention. - args = [cli_args[0], *cli_args[1:], "hello-app"] + args = [cli_args[0], *cli_args[1:], "hello_app"] result = _invoke_quickstart(runner, *args) assert result.exit_code == 0, result.output - tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"] + tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello_app")["tool"]["rsconnect"] assert tool_rsconnect["app_mode"] == expected_mode if expected_mode == "python-api": # Flask aliases to api: both module-style modes share entrypoint. - assert tool_rsconnect["entrypoint"] == "__connect__:app" + assert tool_rsconnect["entrypoint"] == "hello_app.__connect__:app" # --------------------------------------------------------------------------- @@ -341,87 +330,107 @@ def test_quickstart_app_mode_for_each_type( # --------------------------------------------------------------------------- -@pytest.mark.parametrize( - "app_type,expected_files,forbidden_files,content_sentinels", - [ - ( - "streamlit", - {"app.py"}, - {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}, - {"app.py": "streamlit"}, - ), - ("shiny", {"app.py"}, {"__connect__.py", "__main__.py", "notebook.ipynb", "report.qmd"}, {"app.py": "shiny"}), - ( - "fastapi", - {"app.py", "__connect__.py", "__main__.py"}, - {"notebook.ipynb", "report.qmd"}, - {"app.py": "FastAPI"}, - ), - ("api", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}, {"app.py": "Flask"}), - ("flask", {"app.py", "__connect__.py", "__main__.py"}, {"notebook.ipynb", "report.qmd"}, {"app.py": "Flask"}), - ( - "notebook", - {"notebook.ipynb"}, - {"app.py", "__connect__.py", "__main__.py", "report.qmd"}, - {"notebook.ipynb": "cells"}, - ), - ( - "voila", - {"notebook.ipynb"}, - {"app.py", "__connect__.py", "__main__.py", "report.qmd"}, - {"notebook.ipynb": "cells"}, - ), - ( - "quarto", - {"report.qmd"}, - {"app.py", "__connect__.py", "__main__.py", "notebook.ipynb"}, - {"report.qmd": "title"}, - ), - ], -) +# Expected per-mode source files: ``path → content_sentinel``. Empty +# sentinel means "must exist; body not asserted". The four always-present +# files are tested separately by ``_ALWAYS_PRESENT``. +EXPECTED_FILES = [ + pytest.param("streamlit", {"app.py": "streamlit"}, id="streamlit"), + pytest.param("shiny", {"app.py": "shiny"}, id="shiny"), + pytest.param( + "fastapi", + { + "hello_app/__init__.py": "", + "hello_app/__main__.py": "uvicorn", + "hello_app/__connect__.py": "create_app", + "hello_app/app.py": "FastAPI", + }, + id="fastapi", + ), + pytest.param( + "api", + { + "hello_app/__init__.py": "", + "hello_app/__main__.py": "app.run(", + "hello_app/__connect__.py": "create_app", + "hello_app/app.py": "Flask", + }, + id="api", + ), + pytest.param( + "flask", + { + "hello_app/__init__.py": "", + "hello_app/__main__.py": "app.run(", + "hello_app/__connect__.py": "create_app", + "hello_app/app.py": "Flask", + }, + id="flask-alias", + ), + pytest.param("notebook", {"notebook.ipynb": "cells"}, id="notebook"), + pytest.param("voila", {"notebook.ipynb": "cells"}, id="voila"), + pytest.param("quarto", {"report.qmd": "title"}, id="quarto"), +] + +_ALWAYS_PRESENT = {"pyproject.toml", ".python-version", ".gitignore", "README.md"} +_IGNORED_PARTS = {".venv", "__pycache__"} +_IGNORED_FILES = {"uv.lock"} + + +@pytest.mark.parametrize("app_type,expected_sources", EXPECTED_FILES) def test_quickstart_mode_file_set( runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str, - expected_files: set[str], - forbidden_files: set[str], - content_sentinels: typing.Mapping[str, str], + expected_sources: typing.Mapping[str, str], ): - result = _invoke_quickstart(runner, app_type, "hello-app") + """Each mode writes EXACTLY the always-present files plus its expected sources.""" + result = _invoke_quickstart(runner, app_type, "hello_app") assert result.exit_code == 0, result.output - project = in_tmp_cwd / "hello-app" - present = {p.name for p in project.iterdir() if p.is_file()} - for name in expected_files: - assert name in present, f"{name} missing; got {present}" - for name in forbidden_files: - assert name not in present, f"{name} unexpectedly present; SPEC §6 forbids it for {app_type}" - # Sentinel substrings guard against an empty/placeholder template body slipping through. - for filename, sentinel in content_sentinels.items(): - body = (project / filename).read_text() - assert sentinel in body, f"{filename} missing sentinel {sentinel!r}; got {body!r}" + project = in_tmp_cwd / "hello_app" + + actual: set[str] = set() + for p in project.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(project) + if any(part in _IGNORED_PARTS for part in rel.parts): + continue + if rel.name in _IGNORED_FILES: + continue + actual.add(str(rel)) + + expected = _ALWAYS_PRESENT | set(expected_sources.keys()) + assert actual == expected, ( + f"file set mismatch:\n" f" extra: {sorted(actual - expected)}\n" f" missing: {sorted(expected - actual)}" + ) + + for path, sentinel in expected_sources.items(): + if sentinel: + body = (project / path).read_text() + assert sentinel in body, f"{path}: missing sentinel {sentinel!r}\n{body}" def test_quickstart_fastapi_entrypoint_is_connect_app(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "fastapi", "hello-app") + result = _invoke_quickstart(runner, "fastapi", "hello_app") assert result.exit_code == 0, result.output - tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello-app")["tool"]["rsconnect"] - assert tool_rsconnect["entrypoint"] == "__connect__:app" - connect_py = (in_tmp_cwd / "hello-app" / "__connect__.py").read_text() + tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello_app")["tool"]["rsconnect"] + assert tool_rsconnect["entrypoint"] == "hello_app.__connect__:app" + connect_py = (in_tmp_cwd / "hello_app" / "hello_app" / "__connect__.py").read_text() assert "create_app" in connect_py assert "app = " in connect_py def test_quickstart_fastapi_main_runs_uvicorn(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "fastapi", "hello-app") + result = _invoke_quickstart(runner, "fastapi", "hello_app") assert result.exit_code == 0, result.output - main_py = (in_tmp_cwd / "hello-app" / "__main__.py").read_text() + main_py = (in_tmp_cwd / "hello_app" / "hello_app" / "__main__.py").read_text() assert "uvicorn" in main_py def test_quickstart_api_main_runs_flask_dev_server(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "api", "hello-app") + result = _invoke_quickstart(runner, "api", "hello_app") assert result.exit_code == 0, result.output - main_py = (in_tmp_cwd / "hello-app" / "__main__.py").read_text() + main_py = (in_tmp_cwd / "hello_app" / "hello_app" / "__main__.py").read_text() assert "flask" in main_py.lower() or "app.run(" in main_py @@ -431,23 +440,28 @@ def test_quickstart_api_main_runs_flask_dev_server(runner: CliRunner, in_tmp_cwd ("streamlit", "app.py"), ("quarto", "report.qmd"), ("notebook", "notebook.ipynb"), + # ``fastapi`` and ``api`` materialize ``//__init__.py``; + # asserting the body proves substitution runs on BOTH the rendered + # path and the file content for the nested-package layout. + ("fastapi", "alt_project/__init__.py"), + ("api", "alt_project/__init__.py"), ], ) def test_quickstart_substitutes_name_in_template_body( runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str, filename: str ): """Verify ``{name}`` substitution actually runs on template content.""" - result = _invoke_quickstart(runner, app_type, "alt-project") + result = _invoke_quickstart(runner, app_type, "alt_project") assert result.exit_code == 0, result.output - body = (in_tmp_cwd / "alt-project" / filename).read_text() - assert "alt-project" in body, body + body = (in_tmp_cwd / "alt_project" / filename).read_text() + assert "alt_project" in body, body def test_quickstart_notebook_is_valid_json(runner: CliRunner, in_tmp_cwd: pathlib.Path): """The generated notebook.ipynb must parse as JSON after name substitution.""" - result = _invoke_quickstart(runner, "notebook", "hello-app") + result = _invoke_quickstart(runner, "notebook", "hello_app") assert result.exit_code == 0, result.output - body = (in_tmp_cwd / "hello-app" / "notebook.ipynb").read_text() + body = (in_tmp_cwd / "hello_app" / "notebook.ipynb").read_text() data = json.loads(body) assert data["nbformat"] >= 4 assert isinstance(data["cells"], list) and len(data["cells"]) >= 1 @@ -455,11 +469,11 @@ def test_quickstart_notebook_is_valid_json(runner: CliRunner, in_tmp_cwd: pathli def test_quickstart_voila_and_notebook_share_template(runner: CliRunner, in_tmp_cwd: pathlib.Path): """SPEC §6.3: voila reuses the notebook template rather than duplicating it.""" - _invoke_quickstart(runner, "notebook", "hello-app") - notebook_body = (in_tmp_cwd / "hello-app" / "notebook.ipynb").read_text() - shutil.rmtree(in_tmp_cwd / "hello-app") - _invoke_quickstart(runner, "voila", "hello-app") - voila_body = (in_tmp_cwd / "hello-app" / "notebook.ipynb").read_text() + _invoke_quickstart(runner, "notebook", "hello_app") + notebook_body = (in_tmp_cwd / "hello_app" / "notebook.ipynb").read_text() + shutil.rmtree(in_tmp_cwd / "hello_app") + _invoke_quickstart(runner, "voila", "hello_app") + voila_body = (in_tmp_cwd / "hello_app" / "notebook.ipynb").read_text() assert notebook_body == voila_body @@ -468,10 +482,29 @@ def test_quickstart_voila_and_notebook_share_template(runner: CliRunner, in_tmp_ # --------------------------------------------------------------------------- +def test_install_venv_clears_virtual_env(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): + """``uv`` must not see the developer's activated venv (avoids the venv-mismatch warning).""" + monkeypatch.setenv("VIRTUAL_ENV", "/some/parent/.venv") + captured_envs: typing.List[typing.Mapping[str, str]] = [] + + def fake_run(cmd, cwd, env): + captured_envs.append(env) + return mock.Mock(returncode=0) + + monkeypatch.setattr("rsconnect.quickstart.quickstart.subprocess.run", fake_run) + from rsconnect.quickstart.quickstart import _install_venv + + _install_venv(tmp_path) + + assert len(captured_envs) == 2 # uv venv + uv sync + for env in captured_envs: + assert "VIRTUAL_ENV" not in env, env + + def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - project = in_tmp_cwd / "hello-app" + project = in_tmp_cwd / "hello_app" assert (project / ".venv").is_dir() # A populated venv has a site-packages or pyvenv.cfg. assert (project / ".venv" / "pyvenv.cfg").is_file() @@ -498,9 +531,9 @@ def test_quickstart_rolls_back_directory_on_uv_failure( fake_uv.chmod(0o755) monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 - assert not (in_tmp_cwd / "hello-app").exists() # I8: all or nothing + assert not (in_tmp_cwd / "hello_app").exists() # I8: all or nothing def test_quickstart_rolls_back_on_keyboard_interrupt( @@ -519,9 +552,9 @@ def test_quickstart_rolls_back_on_keyboard_interrupt( monkeypatch.setattr(qs, "_install_venv", mock.MagicMock(side_effect=KeyboardInterrupt)) - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 - assert not (in_tmp_cwd / "hello-app").exists() + assert not (in_tmp_cwd / "hello_app").exists() # --------------------------------------------------------------------------- @@ -530,44 +563,72 @@ def test_quickstart_rolls_back_on_keyboard_interrupt( POST_SCAFFOLD_COMMANDS = [ - pytest.param("streamlit", (), "uv run streamlit run app.py", id="streamlit"), - pytest.param("shiny", (), "uv run shiny run app.py", id="shiny"), - pytest.param("fastapi", (), "uv run python -m hello-app", id="fastapi"), - pytest.param("api", (), "uv run python -m hello-app", id="api"), - pytest.param("flask", (), "uv run python -m hello-app", id="flask-alias"), - pytest.param("notebook", (), "uv run jupyter lab notebook.ipynb", id="notebook"), - pytest.param("voila", (), "uv run voila notebook.ipynb", id="voila"), - pytest.param("quarto", (), "uv run quarto preview report.qmd", id="quarto-default"), - pytest.param("quarto", ("--shiny",), "uv run quarto preview report.qmd", id="quarto-shiny"), + pytest.param("streamlit", (), "uv run streamlit run app.py", (), id="streamlit"), + pytest.param("shiny", (), "uv run shiny run app.py", (), id="shiny"), + pytest.param("fastapi", (), "uv run python -m hello_app", (), id="fastapi"), + pytest.param("api", (), "uv run python -m hello_app", (), id="api"), + pytest.param("flask", (), "uv run python -m hello_app", (), id="flask-alias"), + pytest.param("notebook", (), "uv run jupyter lab notebook.ipynb", (), id="notebook"), + pytest.param("voila", (), "uv run voila notebook.ipynb", (), id="voila"), + pytest.param( + "quarto", + (), + "uv run quarto preview report.qmd", + ("Note: Quarto must be installed separately: https://quarto.org",), + id="quarto-default", + ), + pytest.param( + "quarto", + ("--shiny",), + "uv run quarto preview report.qmd", + ("Note: Quarto must be installed separately: https://quarto.org",), + id="quarto-shiny", + ), ] -@pytest.mark.parametrize("app_type,extra_flags,local_run", POST_SCAFFOLD_COMMANDS) +@pytest.mark.parametrize("app_type,extra_flags,local_run,extra_lines", POST_SCAFFOLD_COMMANDS) def test_quickstart_post_scaffold_output( runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str, extra_flags: tuple[str, ...], local_run: str, + extra_lines: tuple[str, ...], ): - result = _invoke_quickstart(runner, app_type, *extra_flags, "hello-app") + result = _invoke_quickstart(runner, app_type, *extra_flags, "hello_app") assert result.exit_code == 0, result.output - # SPEC §12 pins both the wording and the order of the three lines; a - # substring check would tolerate extra debug output or reordering. + # SPEC §12 pins the wording and order of the summary lines; a substring + # check would tolerate extra debug output or reordering. lines = [line for line in result.output.splitlines() if line.strip()] - assert lines == [ - "Created hello-app/", - local_run, - "rsconnect deploy pyproject hello-app", - ], result.output + expected = [ + "Project hello_app/ created.", + f"To run locally: {local_run}", + "To deploy: rsconnect deploy pyproject hello_app", + *extra_lines, + ] + assert lines == expected, result.output def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - readme = (in_tmp_cwd / "hello-app" / "README.md").read_text() + readme = (in_tmp_cwd / "hello_app" / "README.md").read_text() + # The README and stdout agree on the two commands the user needs. assert "uv run streamlit run app.py" in readme - assert "rsconnect deploy pyproject hello-app" in readme + assert "rsconnect deploy pyproject hello_app" in readme + + +def test_quickstart_quarto_readme_includes_install_note(runner: CliRunner, in_tmp_cwd: pathlib.Path): + """SPEC §12: per-mode notes appear in both stdout and README for quarto.""" + result = _invoke_quickstart(runner, "quarto", "hello_app") + assert result.exit_code == 0, result.output + readme = (in_tmp_cwd / "hello_app" / "README.md").read_text() + assert "## Notes" in readme + assert "Quarto must be installed separately" in readme + # Pin the install URL so stdout (asserted in test_quickstart_post_scaffold_output) + # and the on-disk README stay in agreement on the actionable link. + assert "https://quarto.org" in readme # --------------------------------------------------------------------------- @@ -576,12 +637,12 @@ def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tm def test_invariant_I1_I2_directory_and_pyproject(runner: CliRunner, in_tmp_cwd: pathlib.Path): - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output - project = in_tmp_cwd / "hello-app" + project = in_tmp_cwd / "hello_app" assert project.is_dir() data = _read_pyproject(project) - assert data["project"]["name"] == "hello-app" + assert data["project"]["name"] == "hello_app" for required in ("app_mode", "entrypoint", "title"): assert required in data["tool"]["rsconnect"] @@ -599,7 +660,7 @@ def test_invariant_I9_I10_failure_exit_and_message( fake_uv.chmod(0o755) monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") - result = _invoke_quickstart(runner, "streamlit", "hello-app") + result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 # I9 combined = result.output + (result.stderr if result.stderr_bytes else "") assert "uv" in combined.lower() # I10 - message names the failing tool @@ -630,14 +691,19 @@ def test_quickstart_registry_accepts_new_mode( source_files=(), ) extended_registry = dict(qs._REGISTRY) - extended_registry[("newmode", False, False)] = new_spec + extended_registry[("newmode", False)] = new_spec monkeypatch.setattr(qs, "_REGISTRY", extended_registry) + # Click's argument type was bound at decorator time, so injecting a new + # supported type means widening the choice list on the live command. + quickstart_cmd = cli.commands["quickstart"] + type_param = next(p for p in quickstart_cmd.params if p.name == "app_type") + monkeypatch.setattr(type_param, "type", type(type_param.type)(qs.SUPPORTED_APP_TYPES + ("newmode",))) monkeypatch.setattr(qs, "SUPPORTED_APP_TYPES", qs.SUPPORTED_APP_TYPES + ("newmode",)) - result = _invoke_quickstart(runner, "newmode", "hello-app") + result = _invoke_quickstart(runner, "newmode", "hello_app") assert result.exit_code == 0, result.output - data = _read_pyproject(in_tmp_cwd / "hello-app") + data = _read_pyproject(in_tmp_cwd / "hello_app") assert data["tool"]["rsconnect"]["app_mode"] == "python-newmode" assert data["tool"]["rsconnect"]["entrypoint"] == "app.py" assert data["project"]["dependencies"] == [] @@ -651,8 +717,8 @@ def test_quickstart_registry_accepts_new_mode( BOOT_SMOKE_MATRIX = [ pytest.param("streamlit", ("streamlit", "run", "app.py"), "http", id="streamlit"), pytest.param("shiny", ("shiny", "run", "app.py"), "http", id="shiny"), - pytest.param("fastapi", ("python", "-m", "hello-app"), "http", id="fastapi"), - pytest.param("api", ("python", "-m", "hello-app"), "http", id="api"), + pytest.param("fastapi", ("python", "-m", "hello_app"), "http", id="fastapi"), + pytest.param("api", ("python", "-m", "hello_app"), "http", id="api"), pytest.param("voila", ("voila", "notebook.ipynb"), "http", id="voila"), pytest.param("notebook", ("jupyter", "nbconvert", "--execute", "notebook.ipynb"), "artifact", id="notebook"), pytest.param("quarto", ("quarto", "render", "report.qmd"), "artifact", id="quarto"), @@ -678,9 +744,9 @@ def test_quickstart_per_mode_boot_smoke( modes, artifact existence for notebook/quarto - and (4) cleans up. Until that harness exists, the tests stay skipped. """ - result = _invoke_quickstart(runner, app_type, "hello-app") + result = _invoke_quickstart(runner, app_type, "hello_app") assert result.exit_code == 0 - proc = subprocess.Popen(["uv", "run", *local_cmd], cwd=in_tmp_cwd / "hello-app") + proc = subprocess.Popen(["uv", "run", *local_cmd], cwd=in_tmp_cwd / "hello_app") try: assert proc.poll() is None finally: From a599925c6e8f2252c93b4aa128712404a3827711 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 19 May 2026 16:39:15 +0200 Subject: [PATCH 10/26] documentation of quickstart command --- docs/commands/quickstart.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/commands/quickstart.md diff --git a/docs/commands/quickstart.md b/docs/commands/quickstart.md new file mode 100644 index 00000000..5e1671b4 --- /dev/null +++ b/docs/commands/quickstart.md @@ -0,0 +1,3 @@ +::: mkdocs-click + :module: rsconnect.main + :command: quickstart From f54d71901309e34d3ba0d831508f6518b2f2fa62 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 19 May 2026 17:43:28 +0200 Subject: [PATCH 11/26] quickstart command docs with testing infrastructure --- docs/CHANGELOG.md | 20 ++ docs/deploying.md | 46 ++++ mkdocs.yml | 1 + rsconnect/main.py | 6 +- rsconnect/quickstart/quickstart.py | 18 +- .../quickstart/templates/api/__main__.py.tmpl | 6 +- .../templates/fastapi/__main__.py.tmpl | 7 +- tests/_local_run.py | 214 ++++++++++++++++++ tests/smoke_boot_harness.py | 33 --- tests/test_quickstart.py | 83 ++++--- 10 files changed, 358 insertions(+), 76 deletions(-) create mode 100644 tests/_local_run.py delete mode 100644 tests/smoke_boot_harness.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8c539980..3696e28a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `rsconnect quickstart` command for scaffolding a new Connect-ready project. + Supported types: `streamlit`, `shiny`, `fastapi`, `api`, `flask`, `notebook`, + `voila`, `quarto`. Creates a uv-managed virtualenv and prints the local-run + and deploy commands. +- `rsconnect deploy pyproject` command for deploying a project described by + `pyproject.toml` with a `[tool.rsconnect]` table containing `app_mode` and + `entrypoint`. Designed as the deploy partner for projects scaffolded by + `rsconnect quickstart` but works with any conforming `pyproject.toml`. + +### Changed + +- Bumped the `click` dependency floor to `>=8.2.0`. The newer `click.Choice` + generic support is used by `rsconnect quickstart` to validate the project + type argument. +- Added `uv>=0.9.0` as a runtime dependency. `rsconnect quickstart` invokes + `uv venv` and `uv sync` to populate the scaffolded project's virtualenv. + `uv` installs as a self-contained wheel from PyPI alongside `rsconnect`. + ## [1.29.0] - 2026-04-29 - Added `rsconnect deploy nodejs` command for deploying Node.js applications diff --git a/docs/deploying.md b/docs/deploying.md index ce91b1d2..7db7ba6f 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -283,6 +283,52 @@ library(rsconnect) ?rsconnect::writeManifest ``` +### Deploying from a pyproject.toml + +`rsconnect deploy pyproject` reads a `[tool.rsconnect]` table from a project's +`pyproject.toml` instead of taking the app mode and entrypoint as CLI arguments. +It is designed as the deploy partner for projects scaffolded by +`rsconnect quickstart`, but works with any project whose `pyproject.toml` +contains the required keys. + +The `[tool.rsconnect]` table has two required keys: + +- `app_mode` — the Connect app mode the deployment uses. Supported values + are `python-streamlit`, `python-shiny`, `python-fastapi`, `python-api`, + `jupyter-voila`, `jupyter-static`, `quarto-static`, and `quarto-shiny`. +- `entrypoint` — the file or importable path Connect runs. The expected form + depends on `app_mode`: a script filename such as `app.py` for Streamlit and + Shiny, a `module:object` reference such as `my_app.__connect__:app` for + FastAPI or WSGI APIs, a `report.qmd` for Quarto, and a `notebook.ipynb` for + Voila or static Jupyter content. + +A minimal Streamlit project looks like this: + +```toml +[project] +name = "my_app" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = ["streamlit"] + +[tool.rsconnect] +app_mode = "python-streamlit" +entrypoint = "app.py" +``` + +Projects scaffolded by `rsconnect quickstart` already contain this table. +From the parent directory of the project, deploy it with: + +```bash +rsconnect deploy pyproject my_app/ +``` + +The directory passed to `deploy pyproject` must contain `pyproject.toml`. +Dependencies follow the same resolution rules as the other deploy commands: +`[project.dependencies]` from `pyproject.toml` provides the dependency +snapshot, and `uv.lock` or `requirements.txt` may be supplied via +`--requirements-file` when a pinned environment is needed. + ### Options for All Types of Deployments These options apply to any type of content deployment. diff --git a/mkdocs.yml b/mkdocs.yml index 541da548..1695be78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - details: commands/details.md - info: commands/info.md - list: commands/list.md + - quickstart: commands/quickstart.md - remove: commands/remove.md - system: commands/system.md - version: commands/version.md diff --git a/rsconnect/main.py b/rsconnect/main.py index d836b2bc..9e7fbcd7 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1520,9 +1520,9 @@ def deploy_manifest( name="pyproject", short_help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io by pyproject.", help=( - "Deploy content to Posit Connect, Posit Cloud, or shinyapps.io using a pyproject.toml " - "file. The specified directory must contain pyproject.toml. " - "See SPEC_QUICKSTART.md §13 for the full contract." + "Deploy content described by a project's pyproject.toml. The given directory must contain " + "a pyproject.toml with a [tool.rsconnect] table specifying app_mode and entrypoint. " + "Designed as the deploy partner for projects scaffolded by 'rsconnect quickstart'." ), no_args_is_help=True, ) diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 49fdee79..0c7a266a 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -30,11 +30,13 @@ from ..exception import RSConnectException -# Supported CLI ```` values per SPEC §4. ``flask`` is an alias for -# ``api``; both share the same scaffold and ``python-api`` app mode. The -# deferred modes from §4.1 (dash, gradio, panel, bokeh) are intentionally -# absent. Kept as a module-level constant so error messages and future -# template registration share one source of truth. +# Supported CLI ```` values. Each entry MUST match an existing +# ``rsconnect deploy `` subcommand: a project scaffolded by +# ``rsconnect quickstart `` must be deployable with either +# ``rsconnect deploy `` or ``rsconnect deploy pyproject``. Adding a +# type here without a matching ``deploy`` subcommand breaks that promise. +# ``flask`` is an alias for ``api``; both share the same scaffold and the +# ``python-api`` app mode. SUPPORTED_APP_TYPES: typing.Tuple[str, ...] = ( "streamlit", "shiny", @@ -325,9 +327,11 @@ def lookup_template(app_type: str, *, shiny: bool = False) -> TemplateSpec: resolved_type = "api" if app_type == "flask" else app_type key = (resolved_type, shiny) if key not in _REGISTRY: + # The only reachable case is ``--shiny`` combined with a non-quarto + # type; every other (type, shiny) pair is covered by the registry. raise RSConnectException( - f"No scaffold template is registered for type {app_type!r} " - f"with --shiny={shiny}. Re-run without the unsupported flag." + f"The --shiny flag is only supported with type 'quarto', not {app_type!r}. " + "Re-run without --shiny, or use 'quarto' as the project type." ) return _REGISTRY[key] diff --git a/rsconnect/quickstart/templates/api/__main__.py.tmpl b/rsconnect/quickstart/templates/api/__main__.py.tmpl index 91bb52c7..6e4d6484 100644 --- a/rsconnect/quickstart/templates/api/__main__.py.tmpl +++ b/rsconnect/quickstart/templates/api/__main__.py.tmpl @@ -1,9 +1,13 @@ +import os + from .app import create_app def main() -> None: app = create_app() - app.run(host="127.0.0.1", port=5000) + # ``or`` rather than dict default so PORT="" (common in CI scripts) + # still falls back to the production port instead of crashing on int(""). + app.run(host="127.0.0.1", port=int(os.environ.get("PORT") or "5000")) if __name__ == "__main__": diff --git a/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl b/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl index 9d694868..e1b38554 100644 --- a/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +++ b/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl @@ -1,10 +1,15 @@ +import os + import uvicorn from .app import create_app def main() -> None: - uvicorn.run(create_app(), host="127.0.0.1", port=8000) + # ``or`` rather than dict default so PORT="" (common in CI scripts) + # still falls back to the production port instead of crashing on int(""). + port = int(os.environ.get("PORT") or "8000") + uvicorn.run(create_app(), host="127.0.0.1", port=port) if __name__ == "__main__": diff --git a/tests/_local_run.py b/tests/_local_run.py new file mode 100644 index 00000000..5850138a --- /dev/null +++ b/tests/_local_run.py @@ -0,0 +1,214 @@ +"""Run a scaffolded quickstart project locally and probe whether it boots. + +Test-internal helpers used by ``test_quickstart_per_mode_boot_smoke`` to +verify that the local-run command documented for each mode (the one +``rsconnect quickstart`` prints under "To run locally:") actually starts +the project. Owns: + +- free-port allocation for HTTP modes +- subprocess spawn under ``uv run`` with POSIX process-group teardown +- HTTP readiness polling (4xx accepted; child liveness short-circuit) +- artifact-existence checks for render modes (notebook, quarto) +- the per-mode command/env table that maps an app type to its launch shape + +Callers in the smoke test stay short delegations: pick the readiness +shape from the matrix, derive the command from the helpers, spawn, +probe. +""" + +from __future__ import annotations + +import contextlib +import os +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Iterator, Mapping, Optional, Sequence, Tuple + + +def find_free_port() -> int: + """Bind a transient socket on loopback and return the OS-assigned port. + + The port is released the instant the socket closes, so there is a + short window in which another process could grab it. The boot-smoke + matrix runs serially and the spawned child binds within ~1-2s, so + the race is acceptable in practice. A future move to ``pytest-xdist`` + would invalidate that assumption and require port-hold-until-spawn or + a per-worker port range. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@contextlib.contextmanager +def spawn( + cmd: Sequence[str], + *, + cwd: Path, + extra_env: Optional[Mapping[str, str]] = None, +) -> Iterator[subprocess.Popen]: + """Run ``uv run `` in ``cwd`` and tear down the whole process group on exit. + + ``uv run`` forks the framework worker (uvicorn, streamlit, ...) as a + child. Sending SIGTERM to ``uv`` alone leaves the worker orphaned and + keeps the port bound, so the child is started in its own process + group (``start_new_session=True``) and the group is signaled as a + unit on context exit. + + :param Sequence[str] cmd: Argv tail passed to ``uv run``; the harness prepends ``uv run``. + :param Path cwd: Project directory containing ``pyproject.toml`` and ``.venv``. + :param Mapping[str, str] extra_env: Extra environment overrides merged into ``os.environ``. + """ + env = os.environ.copy() + if extra_env: + env.update(extra_env) + proc = subprocess.Popen( + ["uv", "run", *cmd], + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + yield proc + finally: + if proc.poll() is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except ProcessLookupError: + pass + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + # Swallow a stuck second wait: the OS will reap the pid + # when the test process exits. A propagated TimeoutExpired + # here would mask the original test failure. + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=5) + + +def wait_for_http( + port: int, + *, + proc: Optional[subprocess.Popen] = None, + timeout: float = 60.0, +) -> None: + """Poll ``http://127.0.0.1:/`` until any non-5xx response arrives. + + 4xx counts as success: it means the framework is bound and serving, + just without a route at ``/`` (FastAPI without a root handler, Voila + without a notebook list). Only 5xx and transport errors keep polling. + + When ``proc`` is supplied, each poll first checks that the child is + still alive; an early exit short-circuits the wait with a useful + error instead of waiting for the full timeout. This also closes the + free-port race window: if some unrelated process happens to bind the + allocated port between :func:`find_free_port` and the child's bind, + the liveness check still requires our child to be running. + """ + deadline = time.monotonic() + timeout + last_error: Optional[BaseException] = None + url = f"http://127.0.0.1:{port}/" + while time.monotonic() < deadline: + if proc is not None and proc.poll() is not None: + raise AssertionError(f"child exited with code {proc.returncode} before {url} was ready") + try: + with urllib.request.urlopen(url, timeout=2.0) as resp: + if resp.status < 500: + return + last_error = AssertionError(f"server responded {resp.status}") + except urllib.error.HTTPError as err: + if err.code < 500: + return + last_error = err + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as err: + last_error = err + time.sleep(0.5) + raise AssertionError(f"timed out waiting for {url}: {last_error!r}") + + +def wait_for_artifact( + proc: subprocess.Popen, + artifact: Path, + *, + timeout: float = 120.0, +) -> None: + """Wait for a render command to exit cleanly and assert its output exists. + + On timeout the process group is killed so the test does not leak a + runaway renderer, and any captured stdout is included in the failure + message. + """ + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + output = _drain_stdout(proc) + raise AssertionError(f"render timed out after {timeout}s; output:\n{output}") + if proc.returncode != 0: + output = _drain_stdout(proc) + raise AssertionError(f"render exited {proc.returncode}; output:\n{output}") + assert artifact.exists(), f"expected artifact missing: {artifact}" + assert artifact.stat().st_size > 0, f"expected artifact empty: {artifact}" + + +def http_command(app_type: str, port: int) -> Tuple[Sequence[str], Mapping[str, str]]: + """Return the argv tail and env overrides that boot ``app_type`` on ``port``. + + Different frameworks accept their bind port through different channels: + streamlit/shiny/voila take a ``--port``-style CLI flag, while fastapi + and api receive the port through the ``PORT`` env var because their + template ``__main__.py`` reads it (and falls back to a production + default when unset). Returning both pieces from one call keeps callers + free of per-mode env-vs-flag branching. + """ + if app_type == "streamlit": + return ("streamlit", "run", "app.py", f"--server.port={port}", "--server.headless=true"), {} + if app_type == "shiny": + return ("shiny", "run", "app.py", "--port", str(port)), {} + if app_type == "voila": + return ("voila", "notebook.ipynb", f"--port={port}", "--no-browser"), {} + if app_type in ("fastapi", "api"): + return ("python", "-m", "hello_app"), {"PORT": str(port)} + raise ValueError(f"no http command for app_type={app_type!r}") + + +def artifact_command(app_type: str) -> Tuple[str, ...]: + """Return the argv tail (after ``uv run``) that renders ``app_type`` to disk.""" + if app_type == "notebook": + # ``--to notebook`` is required by recent nbconvert versions and yields + # the default ``notebook.nbconvert.ipynb`` output suffix. + return ("jupyter", "nbconvert", "--to", "notebook", "--execute", "notebook.ipynb") + if app_type == "quarto": + return ("quarto", "render", "report.qmd") + raise ValueError(f"no artifact command for app_type={app_type!r}") + + +def artifact_path(app_type: str, project_dir: Path) -> Path: + """Return the on-disk path produced by ``artifact_command(app_type)``.""" + if app_type == "notebook": + # jupyter nbconvert default: ``.nbconvert.ipynb`` next to source. + return project_dir / "notebook.nbconvert.ipynb" + if app_type == "quarto": + # quarto render default for a single .qmd: ``.html`` next to source. + return project_dir / "report.html" + raise ValueError(f"no artifact path for app_type={app_type!r}") + + +def _drain_stdout(proc: subprocess.Popen) -> str: + if proc.stdout is None: + return "" + try: + return proc.stdout.read() or "" + except (ValueError, OSError): + return "" diff --git a/tests/smoke_boot_harness.py b/tests/smoke_boot_harness.py deleted file mode 100644 index 5145a314..00000000 --- a/tests/smoke_boot_harness.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Placeholder module for the per-mode boot smoke test harness (SPEC §14.1). - -The harness will: - -1. Run ``rsconnect quickstart `` into a temp directory. -2. Invoke the documented local-run command per §12 as a subprocess. -3. Assert a mode-appropriate readiness signal (HTTP GET for Streamlit / Shiny / - FastAPI / Flask / Voila; artifact existence for notebook / Quarto). -4. Terminate the subprocess and clean up. - -This module is intentionally empty today; the ATDD tests in -``tests/test_quickstart.py`` under ``test_quickstart_per_mode_boot_smoke`` are -skipped until this harness exists. -""" - -# TODO(EVO-280): Build the per-mode boot smoke test harness. -# Scope: quickstart -# Why: SPEC §14.1 makes the per-mode boot test part of v1 -# scope. Without it, no test proves I4 ("Locally -# runnable") or that any given template actually boots - -# regressions from framework releases (§14.2) would go -# unnoticed. The harness owns subprocess management, -# port selection / HTTP poll, and artifact assertions so -# the per-mode tests stay short. -# Done: Tests ``test_quickstart_per_mode_boot_smoke`` in -# ``tests/test_quickstart.py`` stop being skipped and -# pass on CI for every supported mode. Failures from a -# framework release break CI on the next run per §14.2. -# Non-Goals: Do not pin framework versions (§14.2). Do not add -# integration tests against a real Connect server -# (§14.3 defers that). Do not add golden-file diffs -# (§14.3 defers those too). diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 1a7d8924..0bd41c59 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -1,8 +1,4 @@ # probedev: ignore-file -# The xfail/skip reasons below cite ``TODO(EVO-###)`` markers by text. The real -# evolution markers live in ``rsconnect/`` and ``tests/smoke_boot_harness.py``; -# the strings here are pointers, not new markers. This pragma keeps -# ``probedev list`` focused on the real plan. """ Acceptance tests for ``rsconnect quickstart`` (SPEC_QUICKSTART.md §§ 2-12, 14-15). @@ -12,8 +8,10 @@ ``uv venv`` + ``uv sync`` subprocesses run as part of the end-to-end coverage, so some tests incur a short network round-trip. -The boot-smoke matrix (``test_quickstart_per_mode_boot_smoke``) is currently -skipped pending the harness in ``tests/smoke_boot_harness.py``. +The boot-smoke matrix (``test_quickstart_per_mode_boot_smoke``) drives the +helpers in ``tests/_local_run.py``: it scaffolds each mode, launches the +documented local-run command under ``uv run``, and asserts readiness +(HTTP probe for web modes; artifact existence for notebook/quarto). Test layout mirrors ``tests/test_main.py`` (CliRunner) and ``tests/test_pyproject.py`` (fixture- and parametrize-driven). @@ -27,7 +25,6 @@ import re import shutil import stat -import subprocess import sys import typing from unittest import mock @@ -42,6 +39,7 @@ from click.testing import CliRunner from rsconnect.main import cli +from tests import _local_run # --------------------------------------------------------------------------- @@ -175,6 +173,23 @@ def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: assert not (in_tmp_cwd / "hello_app").exists() +@pytest.mark.parametrize("app_type", ["streamlit", "notebook", "api"]) +def test_quickstart_shiny_rejected_with_non_quarto_type(runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str): + """``--shiny`` is a quarto-only flag; combining it with any other type + must fail before any directory is created. + + Locks the two load-bearing tokens of the user-recoverable error + surface (the flag name and the only supported type) without pinning + the full sentence, so prose can drift but the contract cannot. + """ + result = _invoke_quickstart(runner, app_type, "--shiny", "hello_app") + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert "--shiny" in combined, combined + assert "quarto" in combined, combined + assert not (in_tmp_cwd / "hello_app").exists() + + @pytest.mark.parametrize( "bad_name", [ @@ -715,39 +730,45 @@ def test_quickstart_registry_accepts_new_mode( BOOT_SMOKE_MATRIX = [ - pytest.param("streamlit", ("streamlit", "run", "app.py"), "http", id="streamlit"), - pytest.param("shiny", ("shiny", "run", "app.py"), "http", id="shiny"), - pytest.param("fastapi", ("python", "-m", "hello_app"), "http", id="fastapi"), - pytest.param("api", ("python", "-m", "hello_app"), "http", id="api"), - pytest.param("voila", ("voila", "notebook.ipynb"), "http", id="voila"), - pytest.param("notebook", ("jupyter", "nbconvert", "--execute", "notebook.ipynb"), "artifact", id="notebook"), - pytest.param("quarto", ("quarto", "render", "report.qmd"), "artifact", id="quarto"), + pytest.param("streamlit", "http", id="streamlit"), + pytest.param("shiny", "http", id="shiny"), + pytest.param("fastapi", "http", id="fastapi"), + pytest.param("api", "http", id="api"), + pytest.param("voila", "http", id="voila"), + pytest.param("notebook", "artifact", id="notebook"), + pytest.param("quarto", "artifact", id="quarto"), ] -@pytest.mark.parametrize("app_type,local_cmd,readiness", BOOT_SMOKE_MATRIX) -@pytest.mark.skip( - reason="TODO(EVO-280): Per-mode boot smoke test harness (SPEC §14.1).", -) +@pytest.mark.skipif(sys.platform == "win32", reason="boot smoke uses POSIX process groups") +@pytest.mark.parametrize("app_type,readiness", BOOT_SMOKE_MATRIX) def test_quickstart_per_mode_boot_smoke( runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str, - local_cmd: tuple[str, ...], readiness: str, ): - """Boot smoke test per SPEC §14.1. + """Each scaffolded project boots cleanly. - Implementation note: the evolution that graduates this test must add a - harness that (1) runs quickstart, (2) runs the documented local-run - command via ``uv run ...``, (3) asserts readiness - HTTP GET for web - modes, artifact existence for notebook/quarto - and (4) cleans up. Until - that harness exists, the tests stay skipped. + HTTP modes bind a probe-allocated port and respond to GET ``/`` with a + non-5xx status; artifact modes render to a non-empty output file and + exit 0. Failures from a framework release (renamed flag, broken + default) make this test red. """ + if app_type == "quarto" and shutil.which("quarto") is None: + pytest.skip("quarto CLI not installed") + result = _invoke_quickstart(runner, app_type, "hello_app") - assert result.exit_code == 0 - proc = subprocess.Popen(["uv", "run", *local_cmd], cwd=in_tmp_cwd / "hello_app") - try: - assert proc.poll() is None - finally: - proc.terminate() + assert result.exit_code == 0, result.output + project_dir = in_tmp_cwd / "hello_app" + + if readiness == "http": + port = _local_run.find_free_port() + cmd, extra_env = _local_run.http_command(app_type, port) + with _local_run.spawn(cmd, cwd=project_dir, extra_env=extra_env) as proc: + _local_run.wait_for_http(port, proc=proc) + else: + cmd = _local_run.artifact_command(app_type) + target = _local_run.artifact_path(app_type, project_dir) + with _local_run.spawn(cmd, cwd=project_dir) as proc: + _local_run.wait_for_artifact(proc, target) From dca2b14f57036e3a85bce4ab0c86c17cfa437864 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 19 May 2026 18:11:23 +0200 Subject: [PATCH 12/26] note about the known risk in tests, it's acceptable --- tests/_local_run.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/_local_run.py b/tests/_local_run.py index 5850138a..3acd3fac 100644 --- a/tests/_local_run.py +++ b/tests/_local_run.py @@ -67,6 +67,15 @@ def spawn( env = os.environ.copy() if extra_env: env.update(extra_env) + # stdout is captured via PIPE (kernel buffer ~64KB on Linux). On the + # artifact path ``wait_for_artifact`` drains it; on the HTTP path the + # buffer is never read until teardown, so a child that emits more than + # ~64KB during the readiness window would block on ``write()`` and look + # like a boot timeout. The current frameworks (streamlit, shiny, + # uvicorn, flask, voila) emit only a few hundred bytes of startup + # banner, so we accept the risk for the simpler ``PIPE`` shape and + # keep stdout available for failure dumps. If a chattier framework + # joins the matrix, swap to a spooled tempfile or a drain thread. proc = subprocess.Popen( ["uv", "run", *cmd], cwd=str(cwd), From 79362e98e8f59cbae3a847e965f3905b028a40af Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 19 May 2026 18:28:58 +0200 Subject: [PATCH 13/26] Preparing for graduation --- rsconnect/pyproject.py | 6 +- rsconnect/quickstart/quickstart.py | 88 +++++++++++----------- rsconnect/quickstart/templates/__init__.py | 4 +- tests/test_deploy_pyproject.py | 27 ++++--- tests/test_quickstart.py | 67 ++++++++-------- 5 files changed, 95 insertions(+), 97 deletions(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 9d8d5ac2..c35fc0f9 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -179,10 +179,10 @@ class InvalidPyprojectConfigError(ValueError): def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, typing.Any]: """Read the ``[tool.rsconnect]`` deployment config from pyproject.toml. - Returns the section mapping unchanged for forward-compatible fields (SPEC - §3.1, §3.2). Raises ``InvalidPyprojectConfigError`` when the section is + Returns the section mapping unchanged so forward-compatible fields pass + through. Raises ``InvalidPyprojectConfigError`` when the section is missing or when required ``app_mode`` / ``entrypoint`` fields are absent or - not non-empty strings (SPEC §13.3). + not non-empty strings. """ content = pyproject_file.read_text() pyproject = tomllib.loads(content) diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 0c7a266a..e6ae6259 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -10,8 +10,7 @@ ``rsconnect/main.py``) should not need to import anything else from this module. -See ``SPEC_QUICKSTART.md`` at the repository root for the full product -contract. +See ``docs/commands/quickstart.md`` for the user-facing command reference. """ from __future__ import annotations @@ -49,8 +48,8 @@ ) -# SPEC §2.2: lowercase ASCII letter start, only lowercase letters / digits / -# underscores, no trailing underscore. Underscores (not hyphens) so the name +# Project name rule: lowercase ASCII letter start, only lowercase letters / +# digits / underscores, no trailing underscore. Underscores (not hyphens) so the name # is a valid Python package identifier — fastapi/api scaffolds materialize a # ``//__init__.py`` package, which only works with importable # names. The optional middle-and-end group keeps the rule satisfiable by @@ -73,18 +72,18 @@ def run_quickstart( Returns the absolute path to the created project directory on success. Raises :class:`rsconnect.exception.RSConnectException` on any pre-flight - or scaffold failure; rollback of the partially-created directory is the - caller-visible invariant defined in SPEC §11. + or scaffold failure. On failure the partially-created directory is + removed so the caller sees "all or nothing." - :param str app_type: one of the supported CLI types in SPEC §4. - :param str name: project name; must satisfy SPEC §2.2. + :param str app_type: one of the supported CLI types. + :param str name: project name; must satisfy the project-name rule above. :param bool shiny: quarto-only flag; selects ``quarto-shiny``. :param pathlib.Path cwd: override the working directory (testing hook); defaults to :func:`pathlib.Path.cwd`. """ cwd = (cwd or pathlib.Path.cwd()).resolve() - # SPEC §10 pre-flight order. Each helper raises ``RSConnectException`` + # Pre-flight checks. Each helper raises ``RSConnectException`` # with an actionable message; nothing on disk is mutated until every # check has passed. Type validation now lives in Click's argument # parser (see ``rsconnect/main.py``), so it has already passed before @@ -95,15 +94,15 @@ def run_quickstart( _require_target_does_not_exist(target) _require_cwd_writable(cwd) - # SPEC §4/§6: resolve the per-mode template once. Pre-flight already - # validated ``app_type``; ``lookup_template`` is defensive against - # impossible flag combinations only. + # Resolve the per-mode template once. Pre-flight already validated + # ``app_type``; ``lookup_template`` is defensive against impossible + # flag combinations only. spec = lookup_template(app_type, shiny=shiny) - # SPEC §11 + I8: after ``mkdir`` succeeds, any failure in the rest of - # the pipeline must remove ``.//`` so the user sees "all or - # nothing." ``BaseException`` catches ``KeyboardInterrupt`` too (a - # Ctrl-C mid-``uv sync`` is the most likely real-world failure mode). + # Atomicity: after ``mkdir`` succeeds, any failure in the rest of the + # pipeline must remove ``.//`` so the user sees "all or nothing." + # ``BaseException`` catches ``KeyboardInterrupt`` too (a Ctrl-C + # mid-``uv sync`` is the most likely real-world failure mode). target.mkdir() try: _scaffold(target, name=name, spec=spec) @@ -121,7 +120,7 @@ def run_quickstart( # --------------------------------------------------------------------------- -# Pre-flight checks (SPEC §10) +# Pre-flight checks # --------------------------------------------------------------------------- @@ -158,14 +157,15 @@ def _require_cwd_writable(cwd: pathlib.Path) -> None: # --------------------------------------------------------------------------- -# Template registry (SPEC §4 / §6 / §8.2 / §12) +# Template registry # --------------------------------------------------------------------------- # # The registry is the single source of truth that ties together what each # supported mode produces: the canonical Connect ``app_mode`` written to -# ``[tool.rsconnect]``, the entrypoint form per §6, the local-run command -# documented in §12 and the README, the minimum dependencies for the -# hello-world, and the source files the per-mode template materializes. +# ``[tool.rsconnect]``, the entrypoint form, the local-run command +# documented in the post-scaffold stdout and the README, the minimum +# dependencies for the hello-world, and the source files the per-mode +# template materializes. # # Adding a future supported mode is a registry insertion plus dropping a # directory under ``rsconnect/quickstart/templates//``; no pre-flight, @@ -204,14 +204,14 @@ class TemplateSpec: mapped to one entry; the dataclass itself does not know about CLI aliases or flags. - :param str app_mode: canonical Connect app mode per SPEC §8.2. + :param str app_mode: canonical Connect app mode. :param str entrypoint: entrypoint string written to - ``[tool.rsconnect].entrypoint`` per SPEC §6. The literal token - ``{name}`` (if present) is substituted with the project name at - scaffold time so fastapi/api can name their nested package. + ``[tool.rsconnect].entrypoint``. The literal token ``{name}`` (if + present) is substituted with the project name at scaffold time so + fastapi/api can name their nested package. :param tuple local_run_command: argv form of the documented local-run - command per SPEC §12. The literal token ``"{name}"`` (if present) is - substituted with the project name at scaffold time. + command. The literal token ``"{name}"`` (if present) is substituted + with the project name at scaffold time. :param tuple dependencies: minimum runtime dependencies for the hello-world, written to ``[project.dependencies]``. :param tuple source_files: per-mode template files to materialize. @@ -233,8 +233,8 @@ class TemplateSpec: # Registry key: ``(resolved_type, shiny)``. The ``flask`` alias resolves to -# ``api`` before lookup (see :func:`lookup_template`); the v1 deferred modes -# from SPEC §4.1 (dash, gradio, panel, bokeh) are intentionally absent. +# ``api`` before lookup (see :func:`lookup_template`); deferred modes +# (dash, gradio, panel, bokeh) are intentionally absent. _QUARTO_INSTALL_NOTE = "Quarto must be installed separately: https://quarto.org" _REGISTRY: typing.Mapping[typing.Tuple[str, bool], TemplateSpec] = { @@ -321,7 +321,7 @@ def lookup_template(app_type: str, *, shiny: bool = False) -> TemplateSpec: resolve to the same key. Other CLI-level flag combinations have already been narrowed by pre-flight, so this lookup is defensive only. - :param str app_type: CLI ```` value per SPEC §4. + :param str app_type: CLI ```` value. :param bool shiny: quarto-only flag. """ resolved_type = "api" if app_type == "flask" else app_type @@ -337,15 +337,15 @@ def lookup_template(app_type: str, *, shiny: bool = False) -> TemplateSpec: # --------------------------------------------------------------------------- -# Filesystem generation (SPEC §5.1 / §6 / §3) +# Filesystem generation # --------------------------------------------------------------------------- def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: """Write every file the scaffolded project should contain. - This is the SPEC §5.1 + §6 filesystem-generation phase: the four - always-present files (``pyproject.toml``, ``.python-version``, + Filesystem-generation phase: the four always-present files + (``pyproject.toml``, ``.python-version``, ``.gitignore``, ``README.md``) and the per-mode source files materialized from ``spec.source_files``. The caller owns ``target``'s creation and rollback, so this helper writes into an existing directory. @@ -369,9 +369,9 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: dest.write_text(body, encoding="utf-8") -# SPEC-pinned literals: kept as separate constants because they encode two -# distinct concerns (the .python-version pin vs. the requires-python floor) -# that happen to share a Python-version shape but evolve independently. +# Kept as separate constants because they encode two distinct concerns +# (the .python-version pin vs. the requires-python floor) that happen to +# share a Python-version shape but evolve independently. _PYTHON_VERSION = "3.11" # value written to .python-version _REQUIRES_PYTHON = ">=3.9" # value written to [project].requires-python _GITIGNORE_BODY = "__pycache__/\n*.pyc\n.venv/\n*.egg-info/\nrsconnect-python/\n.env\n" @@ -379,9 +379,9 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: # Build the TOML by direct string concatenation rather than pulling in a - # writer dependency. SPEC §3.2 forbids duplicating ``dependencies`` or - # ``requires-python`` in ``[tool.rsconnect]``; the table holds exactly - # ``app_mode``, ``entrypoint``, and ``title``. + # writer dependency. ``dependencies`` and ``requires-python`` live + # in ``[project]`` and must not be duplicated in ``[tool.rsconnect]``; + # the table holds exactly ``app_mode``, ``entrypoint``, and ``title``. if spec.dependencies: deps_block = "[\n" + "".join(f' "{dep}",\n' for dep in spec.dependencies) + "]" else: @@ -434,17 +434,17 @@ def _format_local_run(spec: TemplateSpec, *, name: str) -> str: # --------------------------------------------------------------------------- -# Venv population (SPEC §5.1 / §7 / I5) +# Venv population # --------------------------------------------------------------------------- def _install_venv(target: pathlib.Path) -> None: - """Populate ``.venv/`` via ``uv venv`` + ``uv sync`` per SPEC §5.1 + §7. + """Populate ``.venv/`` via ``uv venv`` + ``uv sync``. stdout/stderr are inherited from the parent process so users see uv's own progress output in real time ("Creating environment...", "Resolving dependencies..."). A non-zero exit raises ``RSConnectException``, which - the caller translates into the SPEC §11 rollback. + the caller translates into the rollback of the partially-created project. """ # ``VIRTUAL_ENV`` is removed because uv otherwise warns that the # developer's currently-activated venv does not match the scaffolded @@ -465,12 +465,12 @@ def _install_venv(target: pathlib.Path) -> None: # --------------------------------------------------------------------------- -# Post-scaffold output (SPEC §12 / I7) +# Post-scaffold output # --------------------------------------------------------------------------- def _emit_summary(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: - """Print the SPEC §12 confirmation, local-run, deploy, and notes lines. + """Print the confirmation, local-run, deploy, and notes lines. Uses :func:`click.echo` for consistency with the rest of the CLI; the same commands are written into the project's ``README.md`` by diff --git a/rsconnect/quickstart/templates/__init__.py b/rsconnect/quickstart/templates/__init__.py index 77f54035..caa25854 100644 --- a/rsconnect/quickstart/templates/__init__.py +++ b/rsconnect/quickstart/templates/__init__.py @@ -4,8 +4,8 @@ This package hosts the on-disk template files for every supported app mode. It is deliberately a package (not a single module) so each mode can live in its own subdirectory and the registry stays "drop in a directory to add a -mode" per SPEC_QUICKSTART.md §4.1. The package is internal to -``rsconnect.quickstart``; callers should not import from it directly. +mode". The package is internal to ``rsconnect.quickstart``; callers should +not import from it directly. Template bodies are loaded at scaffold time via :func:`pkgutil.get_data` and run through ``str.replace("{name}", name)`` for the single supported diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index d70ea606..a2c9ffeb 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -1,6 +1,5 @@ -# probedev: ignore-file """ -Acceptance tests for ``rsconnect deploy pyproject`` (SPEC_QUICKSTART.md §13). +Acceptance tests for ``rsconnect deploy pyproject``. Tests exercise the CLI via ``click.testing.CliRunner`` and the pure reader (:func:`rsconnect.pyproject.read_tool_rsconnect`) directly. They follow the @@ -48,7 +47,7 @@ def _write_pyproject(project: pathlib.Path, body: str) -> None: # --------------------------------------------------------------------------- -# Command shape (SPEC §13.1) +# Command shape # --------------------------------------------------------------------------- @@ -59,7 +58,8 @@ def test_deploy_pyproject_command_is_registered(runner: CliRunner): def test_deploy_pyproject_requires_path(runner: CliRunner): - """The positional directory is required (SPEC §13.1: no silent default to '.'). + """The positional directory is required (no silent default to '.'). + Distinguishes 'command exists and demands the positional' from the prior 'command does not exist' state - the assertions below would behave @@ -76,8 +76,7 @@ def test_deploy_pyproject_requires_path(runner: CliRunner): def test_deploy_pyproject_option_surface_matches_deploy_manifest(): """``deploy pyproject`` must expose the same Click option surface as - ``deploy manifest`` so existing credential mechanisms (SPEC §13.1) apply - identically. + ``deploy manifest`` so existing credential mechanisms apply identically. """ deploy_group = typing.cast(click.Group, cli.commands["deploy"]) manifest_options = {p.name for p in deploy_group.commands["manifest"].params if isinstance(p, click.Option)} @@ -86,7 +85,7 @@ def test_deploy_pyproject_option_surface_matches_deploy_manifest(): # --------------------------------------------------------------------------- -# [tool.rsconnect] reader (SPEC §3 / §13.2) +# [tool.rsconnect] reader # --------------------------------------------------------------------------- @@ -126,8 +125,8 @@ def test_read_tool_rsconnect_missing_section_raises(project_dir: pathlib.Path): ) with pytest.raises(Exception) as excinfo: read_tool_rsconnect(project_dir / "pyproject.toml") - # Exception must carry the SPEC §13.3 minimum valid snippet as a - # copy-pasteable TOML block, not just prose. Anchor on the section + # Exception must carry the minimum valid snippet as a copy-pasteable + # TOML block, not just prose. Anchor on the section # header plus both required-field TOML string-valued key=value forms # (``key = "``); a prose 'required fields: ...' message would not # incidentally produce that shape. @@ -206,7 +205,7 @@ def test_read_tool_rsconnect_missing_required_field_raises(project_dir: pathlib. # --------------------------------------------------------------------------- -# CLI behavior on missing / invalid config (SPEC §13.3) +# CLI behavior on missing / invalid config # --------------------------------------------------------------------------- @@ -262,7 +261,7 @@ def test_deploy_pyproject_errors_on_missing_entrypoint(runner: CliRunner, projec def test_deploy_pyproject_error_message_mentions_quickstart(runner: CliRunner, project_dir: pathlib.Path): - """SPEC §13.3 requires the error to reference ``rsconnect quickstart --help``.""" + """The error must reference ``rsconnect quickstart --help``.""" _write_pyproject( project_dir, """ @@ -278,7 +277,7 @@ def test_deploy_pyproject_error_message_mentions_quickstart(runner: CliRunner, p # --------------------------------------------------------------------------- -# Dispatch by app_mode (SPEC §13.2) +# Dispatch by app_mode # --------------------------------------------------------------------------- @@ -303,7 +302,7 @@ def test_deploy_pyproject_dispatches_by_app_mode( expected_builder_name: str, monkeypatch: pytest.MonkeyPatch, ): - """Each [tool.rsconnect].app_mode routes to its matching bundle builder (SPEC §13.2 step 3, §8.2).""" + """Each ``[tool.rsconnect].app_mode`` routes to its matching bundle builder.""" captured: dict[str, typing.Any] = {} class _StopDispatch(Exception): @@ -362,7 +361,7 @@ def spy_make_bundle( # --------------------------------------------------------------------------- -# Title / entrypoint override (SPEC §13.2 steps 4-5) +# Title / entrypoint override # --------------------------------------------------------------------------- diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 0bd41c59..703509a0 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -1,12 +1,11 @@ -# probedev: ignore-file """ -Acceptance tests for ``rsconnect quickstart`` (SPEC_QUICKSTART.md §§ 2-12, 14-15). +Acceptance tests for ``rsconnect quickstart``. Tests are written against the CLI using ``click.testing.CliRunner`` and inspect -externally observable behavior per SPEC §17.3: exit code, filesystem tree, -``pyproject.toml`` AST, stdout/stderr, and the populated ``.venv/``. Real -``uv venv`` + ``uv sync`` subprocesses run as part of the end-to-end coverage, -so some tests incur a short network round-trip. +externally observable behavior: exit code, filesystem tree, ``pyproject.toml`` +AST, stdout/stderr, and the populated ``.venv/``. Real ``uv venv`` + ``uv sync`` +subprocesses run as part of the end-to-end coverage, so some tests incur a +short network round-trip. The boot-smoke matrix (``test_quickstart_per_mode_boot_smoke``) drives the helpers in ``tests/_local_run.py``: it scaffolds each mode, launches the @@ -72,7 +71,7 @@ def _read_pyproject(project_dir: pathlib.Path) -> typing.Mapping[str, typing.Any # --------------------------------------------------------------------------- -# Command shape (SPEC §2, §2.1) +# Command shape # --------------------------------------------------------------------------- @@ -100,8 +99,8 @@ def test_quickstart_help_exposes_shiny_flag(runner: CliRunner): result = runner.invoke(cli, ["quickstart", "--help"]) assert result.exit_code == 0, result.output assert "--shiny" in result.output - # ``--static`` was the original pre-shiny flag; SPEC §4.1 replaced it - # with ``--shiny`` (default static), so the old flag must not resurface. + # ``--static`` was the original pre-shiny flag, since replaced by + # ``--shiny`` (default static), so the old flag must not resurface. assert "--static" not in result.output @@ -138,7 +137,7 @@ def test_quickstart_delegates_to_run_quickstart( # --------------------------------------------------------------------------- -# Pre-flight checks (SPEC §10) +# Pre-flight checks # --------------------------------------------------------------------------- @@ -149,13 +148,13 @@ def test_quickstart_requires_uv_on_path(runner: CliRunner, in_tmp_cwd: pathlib.P assert result.exit_code != 0 combined = result.output + (result.stderr if result.stderr_bytes else "") assert "uv" in combined.lower() - assert not (in_tmp_cwd / "hello_app").exists() # I8: no partial dir on pre-flight failure + assert not (in_tmp_cwd / "hello_app").exists() # no partial dir on pre-flight failure def test_quickstart_uv_missing_message_names_install( runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): - """The error message should include the recommended install command (SPEC §7).""" + """The error message should include the recommended install command.""" monkeypatch.setenv("PATH", str(in_tmp_cwd)) result = _invoke_quickstart(runner, "streamlit", "hello_app") combined = result.output + (result.stderr if result.stderr_bytes else "") @@ -217,7 +216,7 @@ def test_quickstart_fails_when_directory_exists(runner: CliRunner, in_tmp_cwd: p (in_tmp_cwd / "hello_app" / "existing-file.txt").write_text("keep me") result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 - # The pre-existing file must be untouched (SPEC §11 Atomicity). + # The pre-existing file must be untouched (atomicity). assert (in_tmp_cwd / "hello_app" / "existing-file.txt").read_text() == "keep me" @@ -236,7 +235,7 @@ def test_quickstart_requires_writable_cwd(runner: CliRunner, tmp_path: pathlib.P def test_quickstart_flask_alias_passes_type_validation(runner: CliRunner, in_tmp_cwd: pathlib.Path): - """SPEC §4: 'flask' is accepted as an alias for 'api' at pre-flight.""" + """'flask' is accepted as an alias for 'api' at pre-flight.""" result = _invoke_quickstart(runner, "flask", "hello_app") combined = result.output + (result.stderr if result.stderr_bytes else "") # The type-validation gate does not reject 'flask'. The command still @@ -247,7 +246,7 @@ def test_quickstart_flask_alias_passes_type_validation(runner: CliRunner, in_tmp # --------------------------------------------------------------------------- -# Always-present generated files (SPEC §5.1) +# Always-present generated files # --------------------------------------------------------------------------- @@ -275,7 +274,7 @@ def test_quickstart_does_not_create_manifest_json(runner: CliRunner, in_tmp_cwd: # --------------------------------------------------------------------------- -# pyproject.toml contents (SPEC §3 + §8.2) +# pyproject.toml contents # --------------------------------------------------------------------------- @@ -294,7 +293,7 @@ def test_quickstart_pyproject_has_tool_rsconnect(runner: CliRunner, in_tmp_cwd: def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): - """SPEC §3.2: dependencies and requires-python live in [project], not in [tool.rsconnect].""" + """``dependencies`` and ``requires-python`` live in ``[project]``, not in ``[tool.rsconnect]``.""" result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output tool_rsconnect = _read_pyproject(in_tmp_cwd / "hello_app")["tool"]["rsconnect"] @@ -305,7 +304,7 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, # --------------------------------------------------------------------------- -# Per-mode app_mode matrix (SPEC §4 / §8.2) +# Per-mode app_mode matrix # --------------------------------------------------------------------------- @@ -341,7 +340,7 @@ def test_quickstart_app_mode_for_each_type( # --------------------------------------------------------------------------- -# Per-category file sets (SPEC §6) +# Per-category file sets # --------------------------------------------------------------------------- @@ -483,7 +482,7 @@ def test_quickstart_notebook_is_valid_json(runner: CliRunner, in_tmp_cwd: pathli def test_quickstart_voila_and_notebook_share_template(runner: CliRunner, in_tmp_cwd: pathlib.Path): - """SPEC §6.3: voila reuses the notebook template rather than duplicating it.""" + """voila reuses the notebook template rather than duplicating it.""" _invoke_quickstart(runner, "notebook", "hello_app") notebook_body = (in_tmp_cwd / "hello_app" / "notebook.ipynb").read_text() shutil.rmtree(in_tmp_cwd / "hello_app") @@ -493,7 +492,7 @@ def test_quickstart_voila_and_notebook_share_template(runner: CliRunner, in_tmp_ # --------------------------------------------------------------------------- -# Venv population (SPEC §5.1, §7, I5) +# Venv population # --------------------------------------------------------------------------- @@ -529,7 +528,7 @@ def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathli # --------------------------------------------------------------------------- -# Atomicity on failure (SPEC §11, I8) +# Atomicity on failure # --------------------------------------------------------------------------- @@ -548,7 +547,7 @@ def test_quickstart_rolls_back_directory_on_uv_failure( result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 - assert not (in_tmp_cwd / "hello_app").exists() # I8: all or nothing + assert not (in_tmp_cwd / "hello_app").exists() # all or nothing def test_quickstart_rolls_back_on_keyboard_interrupt( @@ -573,7 +572,7 @@ def test_quickstart_rolls_back_on_keyboard_interrupt( # --------------------------------------------------------------------------- -# Post-scaffold output (SPEC §12, I7) +# Post-scaffold output # --------------------------------------------------------------------------- @@ -613,8 +612,8 @@ def test_quickstart_post_scaffold_output( ): result = _invoke_quickstart(runner, app_type, *extra_flags, "hello_app") assert result.exit_code == 0, result.output - # SPEC §12 pins the wording and order of the summary lines; a substring - # check would tolerate extra debug output or reordering. + # The wording and order of the summary lines are part of the user-visible + # contract; a substring check would tolerate extra debug output or reordering. lines = [line for line in result.output.splitlines() if line.strip()] expected = [ "Project hello_app/ created.", @@ -635,7 +634,7 @@ def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tm def test_quickstart_quarto_readme_includes_install_note(runner: CliRunner, in_tmp_cwd: pathlib.Path): - """SPEC §12: per-mode notes appear in both stdout and README for quarto.""" + """Per-mode notes appear in both stdout and README for quarto.""" result = _invoke_quickstart(runner, "quarto", "hello_app") assert result.exit_code == 0, result.output readme = (in_tmp_cwd / "hello_app" / "README.md").read_text() @@ -647,7 +646,7 @@ def test_quickstart_quarto_readme_includes_install_note(runner: CliRunner, in_tm # --------------------------------------------------------------------------- -# Invariants (SPEC §15, I1-I10) +# End-to-end invariants # --------------------------------------------------------------------------- @@ -667,7 +666,7 @@ def test_invariant_I9_I10_failure_exit_and_message( in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ): - """SPEC §15 I9-I10: pipeline failure produces non-zero exit and actionable stderr.""" + """Pipeline failure produces non-zero exit and actionable stderr.""" fake_uv_dir = in_tmp_cwd / "fake-bin" fake_uv_dir.mkdir() fake_uv = fake_uv_dir / "uv" @@ -676,20 +675,20 @@ def test_invariant_I9_I10_failure_exit_and_message( monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") result = _invoke_quickstart(runner, "streamlit", "hello_app") - assert result.exit_code != 0 # I9 + assert result.exit_code != 0 combined = result.output + (result.stderr if result.stderr_bytes else "") - assert "uv" in combined.lower() # I10 - message names the failing tool + assert "uv" in combined.lower() # message names the failing tool # --------------------------------------------------------------------------- -# Template registry extensibility (SPEC §4.1) +# Template registry extensibility # --------------------------------------------------------------------------- def test_quickstart_registry_accepts_new_mode( runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): - """SPEC §4.1: adding a mode is "drop a template directory plus register it." + """Adding a mode is "drop a template directory plus register it." The test proves the extensibility contract: inserting a new row into ``_REGISTRY`` (plus a corresponding ``SUPPORTED_APP_TYPES`` entry) yields a @@ -725,7 +724,7 @@ def test_quickstart_registry_accepts_new_mode( # --------------------------------------------------------------------------- -# Per-mode boot smoke tests (SPEC §14.1) +# Per-mode boot smoke tests # --------------------------------------------------------------------------- From 749ef8b3a4b6dd595a94a198f9bef02a14f4e730 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 10:39:55 +0200 Subject: [PATCH 14/26] use older click version --- pyproject.toml | 2 +- tests/test_deploy_pyproject.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 871ecc73..6e300fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", - "click>=8.2.0", + "click>=8.0.0", "toml>=0.10; python_version < '3.11'", ] diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index a2c9ffeb..a3c0cbc2 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -66,11 +66,9 @@ def test_deploy_pyproject_requires_path(runner: CliRunner): differently in those two cases. """ result = runner.invoke(cli, ["deploy", "pyproject"]) - assert result.exit_code != 0 assert "No such command" not in result.output - # `no_args_is_help=True` makes Click render the usage block on missing args. assert "Usage:" in result.output - assert "DIRECTORY" in result.output # required positional metavar (after rename) + assert "DIRECTORY" in result.output # required positional metavar assert "[DIRECTORY]" not in result.output # required, not optional From 3b54bc04f6e229236082fb2b75b653089404896f Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 13:16:05 +0200 Subject: [PATCH 15/26] backward compatibility older python --- rsconnect/pyproject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index c35fc0f9..1679499c 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -13,7 +13,7 @@ from .log import logger -TOMLDecodeError: type[Exception] +TOMLDecodeError: typing.Type[Exception] try: import tomllib From e6db5280e40dcb8af646fde02754cfeb199d8c5c Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 13:59:09 +0200 Subject: [PATCH 16/26] Isolate from host global conf --- tests/test_main.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index e841f5a2..5c4deb17 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -43,9 +43,21 @@ def _load_json(data): class TestMain: def setup_method(self): + # Isolate from any real ``~/.rsconnect-python/`` on the host. + # ``teardown_method`` restores ``HOME`` so the relative path does not + # leak into later tests that invoke ``uv`` and would otherwise create + # ``/test-home/.cache/uv/`` inside their working directory. + self._saved_home = os.environ.get("HOME") shutil.rmtree("test-home", ignore_errors=True) os.environ["HOME"] = "test-home" + def teardown_method(self): + if self._saved_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._saved_home + shutil.rmtree("test-home", ignore_errors=True) + @staticmethod def optional_target(default): return os.environ.get("CONNECT_DEPLOY_TARGET", default) From 7ae1bf151f2a4af44f81c2523725814bf833bcf2 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 14:26:17 +0200 Subject: [PATCH 17/26] fixes for windows --- tests/test_quickstart.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 703509a0..8549d466 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -19,11 +19,11 @@ from __future__ import annotations import json -import os import pathlib import re import shutil import stat +import subprocess import sys import typing from unittest import mock @@ -411,7 +411,9 @@ def test_quickstart_mode_file_set( continue if rel.name in _IGNORED_FILES: continue - actual.add(str(rel)) + # ``as_posix`` normalizes the separator so the expected set (always + # written with ``/``) matches on Windows where ``str(rel)`` uses ``\``. + actual.add(rel.as_posix()) expected = _ALWAYS_PRESENT | set(expected_sources.keys()) assert actual == expected, ( @@ -532,18 +534,26 @@ def test_quickstart_creates_populated_venv(runner: CliRunner, in_tmp_cwd: pathli # --------------------------------------------------------------------------- +def _force_uv_to_fail(monkeypatch: pytest.MonkeyPatch) -> None: + """Make ``subprocess.run`` return ``returncode=1`` for the ``uv`` invocations + inside the scaffold pipeline. Cross-platform: avoids the brittle + "fake binary on PATH" trick which cannot execute extension-less shell + scripts under ``CreateProcessW`` on Windows. + """ + + def fake_run(cmd, *args, **kwargs): + return subprocess.CompletedProcess(args=cmd, returncode=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + + def test_quickstart_rolls_back_directory_on_uv_failure( runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ): """Force ``uv`` to fail and assert the project directory is removed.""" - fake_uv_dir = in_tmp_cwd / "fake-bin" - fake_uv_dir.mkdir() - fake_uv = fake_uv_dir / "uv" - fake_uv.write_text("#!/usr/bin/env bash\nexit 1\n") - fake_uv.chmod(0o755) - monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") + _force_uv_to_fail(monkeypatch) result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 @@ -667,12 +677,7 @@ def test_invariant_I9_I10_failure_exit_and_message( monkeypatch: pytest.MonkeyPatch, ): """Pipeline failure produces non-zero exit and actionable stderr.""" - fake_uv_dir = in_tmp_cwd / "fake-bin" - fake_uv_dir.mkdir() - fake_uv = fake_uv_dir / "uv" - fake_uv.write_text("#!/usr/bin/env bash\nexit 1\n") - fake_uv.chmod(0o755) - monkeypatch.setenv("PATH", f"{fake_uv_dir}{os.pathsep}{os.environ['PATH']}") + _force_uv_to_fail(monkeypatch) result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code != 0 From 3fb426d60433cf7926051f0ed9b400d53d1d80ae Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 16:08:02 +0200 Subject: [PATCH 18/26] Simplify python version logic --- rsconnect/quickstart/quickstart.py | 24 +++++++++++++----------- tests/test_quickstart.py | 12 ++++++++---- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index e6ae6259..5ed7af6e 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -22,6 +22,7 @@ import re import shutil import subprocess +import sys import typing import click @@ -344,14 +345,13 @@ def lookup_template(app_type: str, *, shiny: bool = False) -> TemplateSpec: def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: """Write every file the scaffolded project should contain. - Filesystem-generation phase: the four always-present files - (``pyproject.toml``, ``.python-version``, - ``.gitignore``, ``README.md``) and the per-mode source files - materialized from ``spec.source_files``. The caller owns ``target``'s - creation and rollback, so this helper writes into an existing directory. + Filesystem-generation phase: the three always-present files + (``pyproject.toml``, ``.gitignore``, ``README.md``) and the per-mode + source files materialized from ``spec.source_files``. The caller owns + ``target``'s creation and rollback, so this helper writes into an + existing directory. """ (target / "pyproject.toml").write_text(_render_pyproject(name=name, spec=spec), encoding="utf-8") - (target / ".python-version").write_text(f"{_PYTHON_VERSION}\n", encoding="utf-8") (target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8") (target / "README.md").write_text(_render_readme(name=name, spec=spec), encoding="utf-8") for file_spec in spec.source_files: @@ -369,11 +369,13 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: dest.write_text(body, encoding="utf-8") -# Kept as separate constants because they encode two distinct concerns -# (the .python-version pin vs. the requires-python floor) that happen to -# share a Python-version shape but evolve independently. -_PYTHON_VERSION = "3.11" # value written to .python-version -_REQUIRES_PYTHON = ">=3.9" # value written to [project].requires-python +# ``requires-python`` is the single source of truth for the scaffold's Python +# requirement: ``rsconnect deploy pyproject`` reads ``pyproject.toml``, so +# emitting a separate ``.python-version`` pin would only duplicate this value. +# The floor tracks the interpreter that ran ``rsconnect quickstart`` so the +# scaffold matches the developer's working environment without committing the +# project to any version older than what its author has actually used. +_REQUIRES_PYTHON = ">={}.{}".format(*sys.version_info[:2]) _GITIGNORE_BODY = "__pycache__/\n*.pyc\n.venv/\n*.egg-info/\nrsconnect-python/\n.env\n" diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 8549d466..81514965 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -254,9 +254,11 @@ def test_quickstart_generates_always_present_files(runner: CliRunner, in_tmp_cwd result = _invoke_quickstart(runner, "streamlit", "hello_app") assert result.exit_code == 0, result.output project = in_tmp_cwd / "hello_app" - for name in ("pyproject.toml", ".python-version", ".gitignore", "README.md"): + for name in ("pyproject.toml", ".gitignore", "README.md"): assert (project / name).is_file(), f"{name} missing from {list(project.iterdir())}" - assert (project / ".python-version").read_text().strip() == "3.11" + # No separate ``.python-version`` is emitted: ``requires-python`` in + # ``pyproject.toml`` is the single source of truth for the Python pin. + assert not (project / ".python-version").exists() def test_quickstart_gitignore_covers_rsconnect_dirs(runner: CliRunner, in_tmp_cwd: pathlib.Path): @@ -284,7 +286,9 @@ def test_quickstart_pyproject_has_tool_rsconnect(runner: CliRunner, in_tmp_cwd: data = _read_pyproject(in_tmp_cwd / "hello_app") assert data["project"]["name"] == "hello_app" assert data["project"]["version"] == "0.0.1" - assert data["project"]["requires-python"] == ">=3.9" + # ``requires-python`` tracks the interpreter that ran ``rsconnect quickstart``. + expected_requires = ">={}.{}".format(*sys.version_info[:2]) + assert data["project"]["requires-python"] == expected_requires assert data["project"]["dependencies"] == ["streamlit"] tool_rsconnect = data["tool"]["rsconnect"] assert tool_rsconnect["app_mode"] == "python-streamlit" @@ -385,7 +389,7 @@ def test_quickstart_app_mode_for_each_type( pytest.param("quarto", {"report.qmd": "title"}, id="quarto"), ] -_ALWAYS_PRESENT = {"pyproject.toml", ".python-version", ".gitignore", "README.md"} +_ALWAYS_PRESENT = {"pyproject.toml", ".gitignore", "README.md"} _IGNORED_PARTS = {".venv", "__pycache__"} _IGNORED_FILES = {"uv.lock"} From 55d064cd3d25ed8752b090b4731f79f0045daa16 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 16:27:24 +0200 Subject: [PATCH 19/26] Make pyproject a template file --- rsconnect/quickstart/quickstart.py | 104 +++++++----------- .../templates/api/pyproject.toml.tmpl | 12 ++ .../templates/fastapi/pyproject.toml.tmpl | 13 +++ .../templates/notebook/pyproject.toml.tmpl | 12 ++ .../templates/quarto/pyproject.toml.tmpl | 10 ++ .../quarto/pyproject_shiny.toml.tmpl | 12 ++ .../templates/shiny/pyproject.toml.tmpl | 12 ++ .../templates/streamlit/pyproject.toml.tmpl | 12 ++ .../templates/voila/pyproject.toml.tmpl | 13 +++ tests/test_quickstart.py | 31 +++++- 10 files changed, 165 insertions(+), 66 deletions(-) create mode 100644 rsconnect/quickstart/templates/api/pyproject.toml.tmpl create mode 100644 rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl create mode 100644 rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl create mode 100644 rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl create mode 100644 rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl create mode 100644 rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl create mode 100644 rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl create mode 100644 rsconnect/quickstart/templates/voila/pyproject.toml.tmpl diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 5ed7af6e..8d502daa 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -21,6 +21,7 @@ import pkgutil import re import shutil +import string import subprocess import sys import typing @@ -205,16 +206,17 @@ class TemplateSpec: mapped to one entry; the dataclass itself does not know about CLI aliases or flags. - :param str app_mode: canonical Connect app mode. - :param str entrypoint: entrypoint string written to - ``[tool.rsconnect].entrypoint``. The literal token ``{name}`` (if - present) is substituted with the project name at scaffold time so - fastapi/api can name their nested package. + The mode's Connect identity (``app_mode``, ``entrypoint``, runtime + ``dependencies``) lives in ``pyproject_template`` rather than as + dataclass fields: that template file is the single source of truth + for what ends up in the generated ``pyproject.toml``. + + :param str pyproject_template: path under ``rsconnect/quickstart/templates/`` + of the per-mode ``pyproject.toml`` template. Substituted with + :class:`string.Template` against ``$name`` and ``$requires_python``. :param tuple local_run_command: argv form of the documented local-run command. The literal token ``"{name}"`` (if present) is substituted with the project name at scaffold time. - :param tuple dependencies: minimum runtime dependencies for the - hello-world, written to ``[project.dependencies]``. :param tuple source_files: per-mode template files to materialize. Each entry's body is loaded from ``rsconnect/quickstart/templates/`` and run through @@ -225,10 +227,8 @@ class TemplateSpec: tooling prerequisite. """ - app_mode: str - entrypoint: str + pyproject_template: str local_run_command: typing.Tuple[str, ...] - dependencies: typing.Tuple[str, ...] source_files: typing.Tuple[FileSpec, ...] notes: typing.Tuple[str, ...] = () @@ -240,27 +240,21 @@ class TemplateSpec: _REGISTRY: typing.Mapping[typing.Tuple[str, bool], TemplateSpec] = { ("streamlit", False): TemplateSpec( - app_mode="python-streamlit", - entrypoint="app.py", + pyproject_template="streamlit/pyproject.toml.tmpl", local_run_command=("uv", "run", "streamlit", "run", "app.py"), - dependencies=("streamlit",), source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),), ), ("shiny", False): TemplateSpec( - app_mode="python-shiny", - entrypoint="app.py", + pyproject_template="shiny/pyproject.toml.tmpl", local_run_command=("uv", "run", "shiny", "run", "app.py"), - dependencies=("shiny",), source_files=(FileSpec(name="app.py", template="shiny/app.py.tmpl"),), ), # fastapi/api produce a nested ``//`` package so the # documented ``python -m `` local-run command resolves cleanly # and ``from .app import create_app`` relative imports work. ("fastapi", False): TemplateSpec( - app_mode="python-fastapi", - entrypoint="{name}.__connect__:app", + pyproject_template="fastapi/pyproject.toml.tmpl", local_run_command=("uv", "run", "python", "-m", "{name}"), - dependencies=("fastapi", "uvicorn"), source_files=( FileSpec(name="{name}/__init__.py", template="fastapi/__init__.py.tmpl"), FileSpec(name="{name}/__main__.py", template="fastapi/__main__.py.tmpl"), @@ -269,10 +263,8 @@ class TemplateSpec: ), ), ("api", False): TemplateSpec( - app_mode="python-api", - entrypoint="{name}.__connect__:app", + pyproject_template="api/pyproject.toml.tmpl", local_run_command=("uv", "run", "python", "-m", "{name}"), - dependencies=("flask",), source_files=( FileSpec(name="{name}/__init__.py", template="api/__init__.py.tmpl"), FileSpec(name="{name}/__main__.py", template="api/__main__.py.tmpl"), @@ -280,35 +272,27 @@ class TemplateSpec: FileSpec(name="{name}/app.py", template="api/app.py.tmpl"), ), ), - # notebook and voila share the same template body; they differ only in - # ``app_mode`` and the documented local-run command. + # notebook and voila share the same notebook body; they differ in + # ``pyproject_template`` (app_mode + dependencies) and local-run command. ("notebook", False): TemplateSpec( - app_mode="jupyter-static", - entrypoint="notebook.ipynb", + pyproject_template="notebook/pyproject.toml.tmpl", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), - dependencies=("jupyter",), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("voila", False): TemplateSpec( - app_mode="jupyter-voila", - entrypoint="notebook.ipynb", + pyproject_template="voila/pyproject.toml.tmpl", local_run_command=("uv", "run", "voila", "notebook.ipynb"), - dependencies=("voila", "jupyter"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("quarto", False): TemplateSpec( - app_mode="quarto-static", - entrypoint="report.qmd", + pyproject_template="quarto/pyproject.toml.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), - dependencies=(), source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), ), ("quarto", True): TemplateSpec( - app_mode="quarto-shiny", - entrypoint="report.qmd", + pyproject_template="quarto/pyproject_shiny.toml.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), - dependencies=("shiny",), source_files=(FileSpec(name="report.qmd", template="quarto/report_shiny.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), ), @@ -376,36 +360,30 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: # scaffold matches the developer's working environment without committing the # project to any version older than what its author has actually used. _REQUIRES_PYTHON = ">={}.{}".format(*sys.version_info[:2]) -_GITIGNORE_BODY = "__pycache__/\n*.pyc\n.venv/\n*.egg-info/\nrsconnect-python/\n.env\n" - -def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: - # Build the TOML by direct string concatenation rather than pulling in a - # writer dependency. ``dependencies`` and ``requires-python`` live - # in ``[project]`` and must not be duplicated in ``[tool.rsconnect]``; - # the table holds exactly ``app_mode``, ``entrypoint``, and ``title``. - if spec.dependencies: - deps_block = "[\n" + "".join(f' "{dep}",\n' for dep in spec.dependencies) + "]" - else: - deps_block = "[]" - # Entrypoints for fastapi/api carry a literal ``{name}`` so the - # documented value matches the nested package layout this scaffold - # produces. - entrypoint = spec.entrypoint.replace("{name}", name) - return ( - "[project]\n" - f'name = "{name}"\n' - 'version = "0.0.1"\n' - f'requires-python = "{_REQUIRES_PYTHON}"\n' - f"dependencies = {deps_block}\n" - "\n" - "[tool.rsconnect]\n" - f'app_mode = "{spec.app_mode}"\n' - f'entrypoint = "{entrypoint}"\n' - f'title = "{name}"\n' - ) +_GITIGNORE_BODY = """\ +__pycache__/ +*.pyc +.venv/ +*.egg-info/ +rsconnect-python/ +.env +""" +def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: + # The per-mode template owns the literal TOML, including ``app_mode``, + # ``entrypoint`` and the dependency list. Only ``$name`` (project name) + # and ``$requires_python`` (computed from the running interpreter) vary + # at scaffold time, so ``string.Template`` is enough — no writer dep + # and no risk of mangling literal braces in TOML inline tables. + data = pkgutil.get_data("rsconnect.quickstart.templates", spec.pyproject_template) + if data is None: + raise RSConnectException(f"Template not found: {spec.pyproject_template}") + return string.Template(data.decode("utf-8")).substitute(name=name, requires_python=_REQUIRES_PYTHON) + + +# REVIEW: Same for readme, it should be a template file, also if local_run and deploy_cmd are hardcoded per project type, we can just make one README ready per project type instead of generating it. def _render_readme(*, name: str, spec: TemplateSpec) -> str: local_run = _format_local_run(spec, name=name) deploy_cmd = f"rsconnect deploy pyproject {name}" diff --git a/rsconnect/quickstart/templates/api/pyproject.toml.tmpl b/rsconnect/quickstart/templates/api/pyproject.toml.tmpl new file mode 100644 index 00000000..6129f76b --- /dev/null +++ b/rsconnect/quickstart/templates/api/pyproject.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "flask", +] + +[tool.rsconnect] +app_mode = "python-api" +entrypoint = "$name.__connect__:app" +title = "$name" diff --git a/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl b/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl new file mode 100644 index 00000000..255c1d25 --- /dev/null +++ b/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl @@ -0,0 +1,13 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "fastapi", + "uvicorn", +] + +[tool.rsconnect] +app_mode = "python-fastapi" +entrypoint = "$name.__connect__:app" +title = "$name" diff --git a/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl b/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl new file mode 100644 index 00000000..1b083b2b --- /dev/null +++ b/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "jupyter", +] + +[tool.rsconnect] +app_mode = "jupyter-static" +entrypoint = "notebook.ipynb" +title = "$name" diff --git a/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl b/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl new file mode 100644 index 00000000..3408745a --- /dev/null +++ b/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl @@ -0,0 +1,10 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [] + +[tool.rsconnect] +app_mode = "quarto-static" +entrypoint = "report.qmd" +title = "$name" diff --git a/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl b/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl new file mode 100644 index 00000000..78cf7921 --- /dev/null +++ b/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "shiny", +] + +[tool.rsconnect] +app_mode = "quarto-shiny" +entrypoint = "report.qmd" +title = "$name" diff --git a/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl b/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl new file mode 100644 index 00000000..de93581d --- /dev/null +++ b/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "shiny", +] + +[tool.rsconnect] +app_mode = "python-shiny" +entrypoint = "app.py" +title = "$name" diff --git a/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl b/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl new file mode 100644 index 00000000..3b4f9414 --- /dev/null +++ b/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl @@ -0,0 +1,12 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "streamlit", +] + +[tool.rsconnect] +app_mode = "python-streamlit" +entrypoint = "app.py" +title = "$name" diff --git a/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl b/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl new file mode 100644 index 00000000..b4e5f3ac --- /dev/null +++ b/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl @@ -0,0 +1,13 @@ +[project] +name = "$name" +version = "0.0.1" +requires-python = "$requires_python" +dependencies = [ + "voila", + "jupyter", +] + +[tool.rsconnect] +app_mode = "jupyter-voila" +entrypoint = "notebook.ipynb" +title = "$name" diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 81514965..36856afa 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -704,13 +704,38 @@ def test_quickstart_registry_accepts_new_mode( working scaffold without touching the pre-flight, pyproject writer, or post-scaffold output modules. """ + import pkgutil + from rsconnect.quickstart import quickstart as qs + # Stand in for ``rsconnect/quickstart/templates/newmode/pyproject.toml.tmpl``: + # the contract is "drop a template file under the package", so we hook + # ``pkgutil.get_data`` to materialize the body without touching the + # installed package on disk. + new_pyproject_body = ( + "[project]\n" + 'name = "$name"\n' + 'version = "0.0.1"\n' + 'requires-python = "$requires_python"\n' + "dependencies = []\n" + "\n" + "[tool.rsconnect]\n" + 'app_mode = "python-newmode"\n' + 'entrypoint = "app.py"\n' + 'title = "$name"\n' + ) + real_get_data = pkgutil.get_data + + def fake_get_data(package: str, resource: str): + if resource == "newmode/pyproject.toml.tmpl": + return new_pyproject_body.encode("utf-8") + return real_get_data(package, resource) + + monkeypatch.setattr(pkgutil, "get_data", fake_get_data) + new_spec = qs.TemplateSpec( - app_mode="python-newmode", - entrypoint="app.py", + pyproject_template="newmode/pyproject.toml.tmpl", local_run_command=("uv", "run", "newtool", "app.py"), - dependencies=(), source_files=(), ) extended_registry = dict(qs._REGISTRY) From c7d522d46a88ff36a702a3b29551151e6c3ddf84 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 17:10:16 +0200 Subject: [PATCH 20/26] Move READMEs to their own template files --- rsconnect/quickstart/quickstart.py | 117 +++++++++--------- rsconnect/quickstart/templates/__init__.py | 9 +- .../quickstart/templates/api/README.md.tmpl | 15 +++ .../quickstart/templates/api/__init__.py.tmpl | 2 +- .../quickstart/templates/api/app.py.tmpl | 2 +- .../templates/fastapi/README.md.tmpl | 15 +++ .../templates/fastapi/__init__.py.tmpl | 2 +- .../quickstart/templates/fastapi/app.py.tmpl | 2 +- .../templates/notebook/README.md.tmpl | 15 +++ .../templates/notebook/notebook.ipynb.tmpl | 4 +- .../templates/quarto/README.md.tmpl | 19 +++ .../templates/quarto/README_shiny.md.tmpl | 19 +++ .../templates/quarto/report.qmd.tmpl | 4 +- .../templates/quarto/report_shiny.qmd.tmpl | 4 +- .../quickstart/templates/shiny/README.md.tmpl | 15 +++ .../quickstart/templates/shiny/app.py.tmpl | 2 +- .../templates/streamlit/README.md.tmpl | 15 +++ .../templates/streamlit/app.py.tmpl | 2 +- .../quickstart/templates/voila/README.md.tmpl | 15 +++ tests/test_quickstart.py | 10 +- 20 files changed, 212 insertions(+), 76 deletions(-) create mode 100644 rsconnect/quickstart/templates/api/README.md.tmpl create mode 100644 rsconnect/quickstart/templates/fastapi/README.md.tmpl create mode 100644 rsconnect/quickstart/templates/notebook/README.md.tmpl create mode 100644 rsconnect/quickstart/templates/quarto/README.md.tmpl create mode 100644 rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl create mode 100644 rsconnect/quickstart/templates/shiny/README.md.tmpl create mode 100644 rsconnect/quickstart/templates/streamlit/README.md.tmpl create mode 100644 rsconnect/quickstart/templates/voila/README.md.tmpl diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 8d502daa..21846ba9 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -212,22 +212,26 @@ class TemplateSpec: for what ends up in the generated ``pyproject.toml``. :param str pyproject_template: path under ``rsconnect/quickstart/templates/`` - of the per-mode ``pyproject.toml`` template. Substituted with + of the per-mode ``pyproject.toml`` template. Substituted via :class:`string.Template` against ``$name`` and ``$requires_python``. + :param str readme_template: path under ``rsconnect/quickstart/templates/`` + of the per-mode ``README.md`` template. Substituted via + :class:`string.Template` against ``$name``. :param tuple local_run_command: argv form of the documented local-run - command. The literal token ``"{name}"`` (if present) is substituted - with the project name at scaffold time. - :param tuple source_files: per-mode template files to materialize. - Each entry's body is loaded from - ``rsconnect/quickstart/templates/`` and run through - ``str.replace("{name}", name)`` at scaffold time. + command, used by the post-scaffold stdout summary. Tokens containing + ``"$name"`` are substituted at scaffold time. + :param tuple source_files: per-mode template files to materialize. Each + entry's body and ``name`` are loaded and substituted via + :class:`string.Template` against ``$name``. :param tuple notes: optional user-facing trailing lines for the - post-scaffold output and README (e.g. "Quarto must be installed + post-scaffold stdout output (e.g. "Quarto must be installed separately"). Empty for modes whose hello-world has no external - tooling prerequisite. + tooling prerequisite. The README template owns its own copy of any + notes a user should see on disk. """ pyproject_template: str + readme_template: str local_run_command: typing.Tuple[str, ...] source_files: typing.Tuple[FileSpec, ...] notes: typing.Tuple[str, ...] = () @@ -241,11 +245,13 @@ class TemplateSpec: _REGISTRY: typing.Mapping[typing.Tuple[str, bool], TemplateSpec] = { ("streamlit", False): TemplateSpec( pyproject_template="streamlit/pyproject.toml.tmpl", + readme_template="streamlit/README.md.tmpl", local_run_command=("uv", "run", "streamlit", "run", "app.py"), source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),), ), ("shiny", False): TemplateSpec( pyproject_template="shiny/pyproject.toml.tmpl", + readme_template="shiny/README.md.tmpl", local_run_command=("uv", "run", "shiny", "run", "app.py"), source_files=(FileSpec(name="app.py", template="shiny/app.py.tmpl"),), ), @@ -254,44 +260,50 @@ class TemplateSpec: # and ``from .app import create_app`` relative imports work. ("fastapi", False): TemplateSpec( pyproject_template="fastapi/pyproject.toml.tmpl", - local_run_command=("uv", "run", "python", "-m", "{name}"), + readme_template="fastapi/README.md.tmpl", + local_run_command=("uv", "run", "python", "-m", "$name"), source_files=( - FileSpec(name="{name}/__init__.py", template="fastapi/__init__.py.tmpl"), - FileSpec(name="{name}/__main__.py", template="fastapi/__main__.py.tmpl"), - FileSpec(name="{name}/__connect__.py", template="fastapi/__connect__.py.tmpl"), - FileSpec(name="{name}/app.py", template="fastapi/app.py.tmpl"), + FileSpec(name="$name/__init__.py", template="fastapi/__init__.py.tmpl"), + FileSpec(name="$name/__main__.py", template="fastapi/__main__.py.tmpl"), + FileSpec(name="$name/__connect__.py", template="fastapi/__connect__.py.tmpl"), + FileSpec(name="$name/app.py", template="fastapi/app.py.tmpl"), ), ), ("api", False): TemplateSpec( pyproject_template="api/pyproject.toml.tmpl", - local_run_command=("uv", "run", "python", "-m", "{name}"), + readme_template="api/README.md.tmpl", + local_run_command=("uv", "run", "python", "-m", "$name"), source_files=( - FileSpec(name="{name}/__init__.py", template="api/__init__.py.tmpl"), - FileSpec(name="{name}/__main__.py", template="api/__main__.py.tmpl"), - FileSpec(name="{name}/__connect__.py", template="api/__connect__.py.tmpl"), - FileSpec(name="{name}/app.py", template="api/app.py.tmpl"), + FileSpec(name="$name/__init__.py", template="api/__init__.py.tmpl"), + FileSpec(name="$name/__main__.py", template="api/__main__.py.tmpl"), + FileSpec(name="$name/__connect__.py", template="api/__connect__.py.tmpl"), + FileSpec(name="$name/app.py", template="api/app.py.tmpl"), ), ), # notebook and voila share the same notebook body; they differ in # ``pyproject_template`` (app_mode + dependencies) and local-run command. ("notebook", False): TemplateSpec( pyproject_template="notebook/pyproject.toml.tmpl", + readme_template="notebook/README.md.tmpl", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("voila", False): TemplateSpec( pyproject_template="voila/pyproject.toml.tmpl", + readme_template="voila/README.md.tmpl", local_run_command=("uv", "run", "voila", "notebook.ipynb"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), ("quarto", False): TemplateSpec( pyproject_template="quarto/pyproject.toml.tmpl", + readme_template="quarto/README.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), ), ("quarto", True): TemplateSpec( pyproject_template="quarto/pyproject_shiny.toml.tmpl", + readme_template="quarto/README_shiny.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), source_files=(FileSpec(name="report.qmd", template="quarto/report_shiny.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), @@ -339,18 +351,25 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None: (target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8") (target / "README.md").write_text(_render_readme(name=name, spec=spec), encoding="utf-8") for file_spec in spec.source_files: - # ``pkgutil.get_data`` is stdlib since Python 3.0 and works under - # wheel install, unlike ``importlib.resources.files`` which is 3.9+. - data = pkgutil.get_data("rsconnect.quickstart.templates", file_spec.template) - if data is None: - raise RSConnectException(f"Template not found: {file_spec.template}") - body = data.decode("utf-8").replace("{name}", name) - # ``{name}`` substitution in ``file_spec.name`` plus mkdir lets the + body = _load_template(file_spec.template) + # ``$name`` substitution in ``file_spec.name`` plus mkdir lets the # registry describe nested package layouts (fastapi/api) without # special-casing them here. - dest = target / file_spec.name.replace("{name}", name) + dest = target / string.Template(file_spec.name).substitute(name=name) dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(body, encoding="utf-8") + dest.write_text(string.Template(body).substitute(name=name), encoding="utf-8") + + +def _load_template(path: str) -> str: + """Read a template file from the ``rsconnect.quickstart.templates`` package. + + ``pkgutil.get_data`` is stdlib since Python 3.0 and works under wheel + install, unlike ``importlib.resources.files`` which is 3.9+. + """ + data = pkgutil.get_data("rsconnect.quickstart.templates", path) + if data is None: + raise RSConnectException(f"Template not found: {path}") + return data.decode("utf-8") # ``requires-python`` is the single source of truth for the scaffold's Python @@ -375,42 +394,24 @@ def _render_pyproject(*, name: str, spec: TemplateSpec) -> str: # The per-mode template owns the literal TOML, including ``app_mode``, # ``entrypoint`` and the dependency list. Only ``$name`` (project name) # and ``$requires_python`` (computed from the running interpreter) vary - # at scaffold time, so ``string.Template`` is enough — no writer dep - # and no risk of mangling literal braces in TOML inline tables. - data = pkgutil.get_data("rsconnect.quickstart.templates", spec.pyproject_template) - if data is None: - raise RSConnectException(f"Template not found: {spec.pyproject_template}") - return string.Template(data.decode("utf-8")).substitute(name=name, requires_python=_REQUIRES_PYTHON) + # at scaffold time. + return string.Template(_load_template(spec.pyproject_template)).substitute( + name=name, requires_python=_REQUIRES_PYTHON + ) -# REVIEW: Same for readme, it should be a template file, also if local_run and deploy_cmd are hardcoded per project type, we can just make one README ready per project type instead of generating it. def _render_readme(*, name: str, spec: TemplateSpec) -> str: - local_run = _format_local_run(spec, name=name) - deploy_cmd = f"rsconnect deploy pyproject {name}" - body = ( - f"# {name}\n" - "\n" - "A Posit Connect project scaffolded by `rsconnect quickstart`.\n" - "\n" - "## Run locally\n" - "\n" - f"```\n{local_run}\n```\n" - "\n" - "## Deploy to Posit Connect\n" - "\n" - f"```\n{deploy_cmd}\n```\n" - ) - if spec.notes: - notes_block = "\n## Notes\n\n" + "".join(f"- {note}\n" for note in spec.notes) - body += notes_block - return body + # The per-mode template owns every literal line of the README, including + # the mode's local-run command, the deploy command, and any notes. Only + # ``$name`` varies at scaffold time. + return string.Template(_load_template(spec.readme_template)).substitute(name=name) def _format_local_run(spec: TemplateSpec, *, name: str) -> str: - # The registry stores the local-run argv with ``"{name}"`` as a literal - # placeholder for module-style modes. Substitute once at scaffold time so - # README and post-scaffold stdout share one rendering path. - return " ".join(token.replace("{name}", name) for token in spec.local_run_command) + # The registry stores the local-run argv with ``"$name"`` as a literal + # placeholder for module-style modes (fastapi/api). Substitute once + # here so the post-scaffold stdout line renders cleanly. + return " ".join(string.Template(token).substitute(name=name) for token in spec.local_run_command) # --------------------------------------------------------------------------- diff --git a/rsconnect/quickstart/templates/__init__.py b/rsconnect/quickstart/templates/__init__.py index caa25854..0c8e086a 100644 --- a/rsconnect/quickstart/templates/__init__.py +++ b/rsconnect/quickstart/templates/__init__.py @@ -8,8 +8,9 @@ not import from it directly. Template bodies are loaded at scaffold time via :func:`pkgutil.get_data` -and run through ``str.replace("{name}", name)`` for the single supported -substitution token. ``str.format`` is deliberately avoided so templates -carrying literal braces (e.g. ``notebook.ipynb`` JSON) pass through -unchanged. +and substituted with :class:`string.Template`, which uses ``$identifier`` +syntax. The ``$``-syntax sidesteps the literal-brace concern that JSON +templates (``notebook.ipynb.tmpl``) and TOML inline tables would raise +under :meth:`str.format`. A literal ``$`` in any template must be escaped +as ``$$``. """ diff --git a/rsconnect/quickstart/templates/api/README.md.tmpl b/rsconnect/quickstart/templates/api/README.md.tmpl new file mode 100644 index 00000000..adb8ab18 --- /dev/null +++ b/rsconnect/quickstart/templates/api/README.md.tmpl @@ -0,0 +1,15 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run python -m $name +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` diff --git a/rsconnect/quickstart/templates/api/__init__.py.tmpl b/rsconnect/quickstart/templates/api/__init__.py.tmpl index 10bd457c..257455cb 100644 --- a/rsconnect/quickstart/templates/api/__init__.py.tmpl +++ b/rsconnect/quickstart/templates/api/__init__.py.tmpl @@ -1 +1 @@ -"""{name} package.""" +"""$name package.""" diff --git a/rsconnect/quickstart/templates/api/app.py.tmpl b/rsconnect/quickstart/templates/api/app.py.tmpl index 3212f99f..322029c9 100644 --- a/rsconnect/quickstart/templates/api/app.py.tmpl +++ b/rsconnect/quickstart/templates/api/app.py.tmpl @@ -6,6 +6,6 @@ def create_app() -> Flask: @app.route("/") def hello() -> str: - return "Hello from {name}!" + return "Hello from $name!" return app diff --git a/rsconnect/quickstart/templates/fastapi/README.md.tmpl b/rsconnect/quickstart/templates/fastapi/README.md.tmpl new file mode 100644 index 00000000..adb8ab18 --- /dev/null +++ b/rsconnect/quickstart/templates/fastapi/README.md.tmpl @@ -0,0 +1,15 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run python -m $name +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` diff --git a/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl b/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl index 10bd457c..257455cb 100644 --- a/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +++ b/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl @@ -1 +1 @@ -"""{name} package.""" +"""$name package.""" diff --git a/rsconnect/quickstart/templates/fastapi/app.py.tmpl b/rsconnect/quickstart/templates/fastapi/app.py.tmpl index 401459eb..62ce256d 100644 --- a/rsconnect/quickstart/templates/fastapi/app.py.tmpl +++ b/rsconnect/quickstart/templates/fastapi/app.py.tmpl @@ -6,6 +6,6 @@ def create_app() -> FastAPI: @app.get("/") def hello() -> dict: - return {"message": "Hello from {name}!"} + return {"message": "Hello from $name!"} return app diff --git a/rsconnect/quickstart/templates/notebook/README.md.tmpl b/rsconnect/quickstart/templates/notebook/README.md.tmpl new file mode 100644 index 00000000..3a423f10 --- /dev/null +++ b/rsconnect/quickstart/templates/notebook/README.md.tmpl @@ -0,0 +1,15 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run jupyter lab notebook.ipynb +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` diff --git a/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl b/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl index 9fb90fba..c0d37c80 100644 --- a/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +++ b/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl @@ -5,7 +5,7 @@ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "metadata": {}, "source": [ - "# Hello from {name}" + "# Hello from $name" ] }, { @@ -15,7 +15,7 @@ "metadata": {}, "outputs": [], "source": [ - "print('Hello from {name}!')" + "print('Hello from $name!')" ] } ], diff --git a/rsconnect/quickstart/templates/quarto/README.md.tmpl b/rsconnect/quickstart/templates/quarto/README.md.tmpl new file mode 100644 index 00000000..c79daa90 --- /dev/null +++ b/rsconnect/quickstart/templates/quarto/README.md.tmpl @@ -0,0 +1,19 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run quarto preview report.qmd +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` + +## Notes + +- Quarto must be installed separately: https://quarto.org diff --git a/rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl b/rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl new file mode 100644 index 00000000..c79daa90 --- /dev/null +++ b/rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl @@ -0,0 +1,19 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run quarto preview report.qmd +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` + +## Notes + +- Quarto must be installed separately: https://quarto.org diff --git a/rsconnect/quickstart/templates/quarto/report.qmd.tmpl b/rsconnect/quickstart/templates/quarto/report.qmd.tmpl index 7ed3ceab..b3779d85 100644 --- a/rsconnect/quickstart/templates/quarto/report.qmd.tmpl +++ b/rsconnect/quickstart/templates/quarto/report.qmd.tmpl @@ -1,8 +1,8 @@ --- -title: "{name}" +title: "$name" format: html --- -# Hello from {name} +# Hello from $name This is a minimal Quarto document. diff --git a/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl b/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl index b1df9eef..404fd437 100644 --- a/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl +++ b/rsconnect/quickstart/templates/quarto/report_shiny.qmd.tmpl @@ -1,10 +1,10 @@ --- -title: "{name}" +title: "$name" server: shiny format: html --- -# Hello from {name} +# Hello from $name ```{python} from shiny.express import ui, render, input diff --git a/rsconnect/quickstart/templates/shiny/README.md.tmpl b/rsconnect/quickstart/templates/shiny/README.md.tmpl new file mode 100644 index 00000000..13dcb0d2 --- /dev/null +++ b/rsconnect/quickstart/templates/shiny/README.md.tmpl @@ -0,0 +1,15 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run shiny run app.py +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` diff --git a/rsconnect/quickstart/templates/shiny/app.py.tmpl b/rsconnect/quickstart/templates/shiny/app.py.tmpl index 5a923709..1e7fb9b5 100644 --- a/rsconnect/quickstart/templates/shiny/app.py.tmpl +++ b/rsconnect/quickstart/templates/shiny/app.py.tmpl @@ -1,3 +1,3 @@ from shiny.express import ui -ui.h1("Hello from {name}!") +ui.h1("Hello from $name!") diff --git a/rsconnect/quickstart/templates/streamlit/README.md.tmpl b/rsconnect/quickstart/templates/streamlit/README.md.tmpl new file mode 100644 index 00000000..fb3b834c --- /dev/null +++ b/rsconnect/quickstart/templates/streamlit/README.md.tmpl @@ -0,0 +1,15 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run streamlit run app.py +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` diff --git a/rsconnect/quickstart/templates/streamlit/app.py.tmpl b/rsconnect/quickstart/templates/streamlit/app.py.tmpl index 25c41563..fd04cbfd 100644 --- a/rsconnect/quickstart/templates/streamlit/app.py.tmpl +++ b/rsconnect/quickstart/templates/streamlit/app.py.tmpl @@ -1,3 +1,3 @@ import streamlit as st -st.write("Hello from {name}!") +st.write("Hello from $name!") diff --git a/rsconnect/quickstart/templates/voila/README.md.tmpl b/rsconnect/quickstart/templates/voila/README.md.tmpl new file mode 100644 index 00000000..a27672c3 --- /dev/null +++ b/rsconnect/quickstart/templates/voila/README.md.tmpl @@ -0,0 +1,15 @@ +# $name + +A Posit Connect project scaffolded by `rsconnect quickstart`. + +## Run locally + +``` +uv run voila notebook.ipynb +``` + +## Deploy to Posit Connect + +``` +rsconnect deploy pyproject $name +``` diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 36856afa..2857e4b1 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -724,17 +724,23 @@ def test_quickstart_registry_accepts_new_mode( 'entrypoint = "app.py"\n' 'title = "$name"\n' ) + new_readme_body = "# $name\n\nNew mode scaffold.\n" + fake_templates = { + "newmode/pyproject.toml.tmpl": new_pyproject_body, + "newmode/README.md.tmpl": new_readme_body, + } real_get_data = pkgutil.get_data def fake_get_data(package: str, resource: str): - if resource == "newmode/pyproject.toml.tmpl": - return new_pyproject_body.encode("utf-8") + if resource in fake_templates: + return fake_templates[resource].encode("utf-8") return real_get_data(package, resource) monkeypatch.setattr(pkgutil, "get_data", fake_get_data) new_spec = qs.TemplateSpec( pyproject_template="newmode/pyproject.toml.tmpl", + readme_template="newmode/README.md.tmpl", local_run_command=("uv", "run", "newtool", "app.py"), source_files=(), ) From 68f7329181c7afc6aa3d08b9ef9ed35dad15f40c Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 17:34:35 +0200 Subject: [PATCH 21/26] Migrate from quarto --shiny to quarto-shiny --- docs/CHANGELOG.md | 4 +- rsconnect/main.py | 11 +++--- rsconnect/pyproject.py | 7 ++-- rsconnect/quickstart/quickstart.py | 61 +++++++++++++++--------------- tests/test_quickstart.py | 45 +++++++--------------- 5 files changed, 55 insertions(+), 73 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c6e2d8ab..d48cd3ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `rsconnect quickstart` command for scaffolding a new Connect-ready project. Supported types: `streamlit`, `shiny`, `fastapi`, `api`, `flask`, `notebook`, - `voila`, `quarto`. Creates a uv-managed virtualenv and prints the local-run - and deploy commands. + `voila`, `quarto`, `quarto-shiny`. Creates a uv-managed virtualenv and prints + the local-run and deploy commands. - `rsconnect deploy pyproject` command for deploying a project described by `pyproject.toml` with a `[tool.rsconnect]` table containing `app_mode` and `entrypoint`. Designed as the deploy partner for projects scaffolded by diff --git a/rsconnect/main.py b/rsconnect/main.py index 4bdd5887..2ad85143 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1029,9 +1029,9 @@ def info(file: str): help=( "Create a new Posit Connect project of the given TYPE in .//. " "Supported TYPE values: streamlit, shiny, fastapi, api, flask, " - "notebook, voila, quarto. Writes a pyproject.toml with a " - "[tool.rsconnect] section, creates a uv-managed virtualenv, and " - "prints the local-run and deploy commands." + "notebook, voila, quarto, quarto-shiny. Writes a pyproject.toml " + "with a [tool.rsconnect] section, creates a uv-managed virtualenv, " + "and prints the local-run and deploy commands." ), no_args_is_help=True, ) @@ -1041,15 +1041,14 @@ def info(file: str): type=click.Choice(SUPPORTED_APP_TYPES), ) @click.argument("name", metavar="NAME") -@click.option("--shiny", is_flag=True, help="(quarto only) emit quarto-shiny instead of quarto-static.") @cli_exception_handler -def quickstart(app_type: str, name: str, shiny: bool): +def quickstart(app_type: str, name: str): # Resolve ``run_quickstart`` through the module at call time so tests can # monkeypatch ``rsconnect.quickstart.quickstart.run_quickstart`` without # binding a stale reference into ``main``'s namespace at import time. from .quickstart.quickstart import run_quickstart - run_quickstart(app_type=app_type, name=name, shiny=shiny) + run_quickstart(app_type=app_type, name=name) @cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.") diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 1679499c..57b1bc31 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -171,9 +171,10 @@ class InvalidPyprojectConfigError(ValueError): """Raised when ``[tool.rsconnect]`` is missing or incomplete.""" -_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET = '''[tool.rsconnect] -app_mode = "python-streamlit" -entrypoint = "app.py"''' +_MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET = """[tool.rsconnect] +# e.g. python-streamlit, python-shiny, python-fastapi, jupyter-static, quarto-shiny +app_mode = "" +entrypoint = "" # e.g. app.py""" def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, typing.Any]: diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 21846ba9..27aecdb8 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -38,6 +38,10 @@ # type here without a matching ``deploy`` subcommand breaks that promise. # ``flask`` is an alias for ``api``; both share the same scaffold and the # ``python-api`` app mode. +# ``quarto`` selects ``quarto-static``; ``quarto-shiny`` is exposed as its +# own CLI type so the user-visible vocabulary matches Connect's two distinct +# app modes (``quarto-static`` and ``quarto-shiny``) rather than splitting +# one app-mode dimension into a separate flag. SUPPORTED_APP_TYPES: typing.Tuple[str, ...] = ( "streamlit", "shiny", @@ -47,6 +51,7 @@ "notebook", "voila", "quarto", + "quarto-shiny", ) @@ -67,7 +72,6 @@ def run_quickstart( app_type: str, name: str, *, - shiny: bool = False, cwd: typing.Optional[pathlib.Path] = None, ) -> pathlib.Path: """Scaffold a new Connect project of ``app_type`` named ``name``. @@ -79,7 +83,6 @@ def run_quickstart( :param str app_type: one of the supported CLI types. :param str name: project name; must satisfy the project-name rule above. - :param bool shiny: quarto-only flag; selects ``quarto-shiny``. :param pathlib.Path cwd: override the working directory (testing hook); defaults to :func:`pathlib.Path.cwd`. """ @@ -97,9 +100,9 @@ def run_quickstart( _require_cwd_writable(cwd) # Resolve the per-mode template once. Pre-flight already validated - # ``app_type``; ``lookup_template`` is defensive against impossible - # flag combinations only. - spec = lookup_template(app_type, shiny=shiny) + # ``app_type`` via Click's ``Choice``; ``lookup_template`` is defensive + # for direct API callers only. + spec = lookup_template(app_type) # Atomicity: after ``mkdir`` succeeds, any failure in the rest of the # pipeline must remove ``.//`` so the user sees "all or nothing." @@ -202,9 +205,9 @@ class FileSpec: class TemplateSpec: """Per-resolved-mode scaffold contract. - Resolved means the ``(app_type, shiny)`` flag pair has already been - mapped to one entry; the dataclass itself does not know about CLI - aliases or flags. + Resolved means the CLI ``app_type`` has already been mapped to one + entry; the dataclass itself does not know about CLI aliases (e.g. + ``flask`` -> ``api``). The mode's Connect identity (``app_mode``, ``entrypoint``, runtime ``dependencies``) lives in ``pyproject_template`` rather than as @@ -237,19 +240,19 @@ class TemplateSpec: notes: typing.Tuple[str, ...] = () -# Registry key: ``(resolved_type, shiny)``. The ``flask`` alias resolves to -# ``api`` before lookup (see :func:`lookup_template`); deferred modes +# Registry key: the resolved CLI ``app_type`` (``flask`` resolves to +# ``api`` before lookup; see :func:`lookup_template`). Deferred modes # (dash, gradio, panel, bokeh) are intentionally absent. _QUARTO_INSTALL_NOTE = "Quarto must be installed separately: https://quarto.org" -_REGISTRY: typing.Mapping[typing.Tuple[str, bool], TemplateSpec] = { - ("streamlit", False): TemplateSpec( +_REGISTRY: typing.Mapping[str, TemplateSpec] = { + "streamlit": TemplateSpec( pyproject_template="streamlit/pyproject.toml.tmpl", readme_template="streamlit/README.md.tmpl", local_run_command=("uv", "run", "streamlit", "run", "app.py"), source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),), ), - ("shiny", False): TemplateSpec( + "shiny": TemplateSpec( pyproject_template="shiny/pyproject.toml.tmpl", readme_template="shiny/README.md.tmpl", local_run_command=("uv", "run", "shiny", "run", "app.py"), @@ -258,7 +261,7 @@ class TemplateSpec: # fastapi/api produce a nested ``//`` package so the # documented ``python -m `` local-run command resolves cleanly # and ``from .app import create_app`` relative imports work. - ("fastapi", False): TemplateSpec( + "fastapi": TemplateSpec( pyproject_template="fastapi/pyproject.toml.tmpl", readme_template="fastapi/README.md.tmpl", local_run_command=("uv", "run", "python", "-m", "$name"), @@ -269,7 +272,7 @@ class TemplateSpec: FileSpec(name="$name/app.py", template="fastapi/app.py.tmpl"), ), ), - ("api", False): TemplateSpec( + "api": TemplateSpec( pyproject_template="api/pyproject.toml.tmpl", readme_template="api/README.md.tmpl", local_run_command=("uv", "run", "python", "-m", "$name"), @@ -282,26 +285,26 @@ class TemplateSpec: ), # notebook and voila share the same notebook body; they differ in # ``pyproject_template`` (app_mode + dependencies) and local-run command. - ("notebook", False): TemplateSpec( + "notebook": TemplateSpec( pyproject_template="notebook/pyproject.toml.tmpl", readme_template="notebook/README.md.tmpl", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), - ("voila", False): TemplateSpec( + "voila": TemplateSpec( pyproject_template="voila/pyproject.toml.tmpl", readme_template="voila/README.md.tmpl", local_run_command=("uv", "run", "voila", "notebook.ipynb"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), - ("quarto", False): TemplateSpec( + "quarto": TemplateSpec( pyproject_template="quarto/pyproject.toml.tmpl", readme_template="quarto/README.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), ), - ("quarto", True): TemplateSpec( + "quarto-shiny": TemplateSpec( pyproject_template="quarto/pyproject_shiny.toml.tmpl", readme_template="quarto/README_shiny.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), @@ -311,26 +314,22 @@ class TemplateSpec: } -def lookup_template(app_type: str, *, shiny: bool = False) -> TemplateSpec: - """Resolve the :class:`TemplateSpec` for ``(app_type, shiny)``. +def lookup_template(app_type: str) -> TemplateSpec: + """Resolve the :class:`TemplateSpec` for ``app_type``. ``flask`` is an alias for ``api`` and shares the same scaffold; both - resolve to the same key. Other CLI-level flag combinations have already - been narrowed by pre-flight, so this lookup is defensive only. + resolve to the same registry entry. Other CLI-level types have already + been narrowed by Click's ``Choice``, so this lookup is defensive only + (e.g. direct API callers passing an unknown type). :param str app_type: CLI ```` value. - :param bool shiny: quarto-only flag. """ resolved_type = "api" if app_type == "flask" else app_type - key = (resolved_type, shiny) - if key not in _REGISTRY: - # The only reachable case is ``--shiny`` combined with a non-quarto - # type; every other (type, shiny) pair is covered by the registry. + if resolved_type not in _REGISTRY: raise RSConnectException( - f"The --shiny flag is only supported with type 'quarto', not {app_type!r}. " - "Re-run without --shiny, or use 'quarto' as the project type." + f"Unknown project type {app_type!r}. Supported types: " + ", ".join(SUPPORTED_APP_TYPES) ) - return _REGISTRY[key] + return _REGISTRY[resolved_type] # --------------------------------------------------------------------------- diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 2857e4b1..1324c357 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -95,13 +95,13 @@ def test_quickstart_requires_type_and_name(runner: CliRunner, in_tmp_cwd: pathli assert result.exit_code != 0 -def test_quickstart_help_exposes_shiny_flag(runner: CliRunner): +def test_quickstart_help_lists_quarto_shiny(runner: CliRunner): + """``quarto-shiny`` is a first-class TYPE, not a flag on ``quarto``.""" result = runner.invoke(cli, ["quickstart", "--help"]) assert result.exit_code == 0, result.output - assert "--shiny" in result.output - # ``--static`` was the original pre-shiny flag, since replaced by - # ``--shiny`` (default static), so the old flag must not resurface. - assert "--static" not in result.output + assert "quarto-shiny" in result.output + # The legacy ``--shiny`` flag was removed in favor of the explicit type. + assert "--shiny" not in result.output @pytest.mark.parametrize( @@ -109,15 +109,15 @@ def test_quickstart_help_exposes_shiny_flag(runner: CliRunner): [ ( ["streamlit", "hello_app"], - {"app_type": "streamlit", "name": "hello_app", "shiny": False}, + {"app_type": "streamlit", "name": "hello_app"}, ), ( ["notebook", "hello_notebook"], - {"app_type": "notebook", "name": "hello_notebook", "shiny": False}, + {"app_type": "notebook", "name": "hello_notebook"}, ), ( - ["quarto", "--shiny", "hello_quarto"], - {"app_type": "quarto", "name": "hello_quarto", "shiny": True}, + ["quarto-shiny", "hello_quarto"], + {"app_type": "quarto-shiny", "name": "hello_quarto"}, ), ], ) @@ -167,28 +167,11 @@ def test_quickstart_unknown_type_lists_supported(runner: CliRunner, in_tmp_cwd: result = _invoke_quickstart(runner, "nonesuch", "hello_app") assert result.exit_code != 0 combined = result.output + (result.stderr if result.stderr_bytes else "") - for expected in ("streamlit", "shiny", "fastapi", "api", "flask", "notebook", "voila", "quarto"): + for expected in ("streamlit", "shiny", "fastapi", "api", "flask", "notebook", "voila", "quarto", "quarto-shiny"): assert expected in combined, f"{expected!r} missing from error output: {combined!r}" assert not (in_tmp_cwd / "hello_app").exists() -@pytest.mark.parametrize("app_type", ["streamlit", "notebook", "api"]) -def test_quickstart_shiny_rejected_with_non_quarto_type(runner: CliRunner, in_tmp_cwd: pathlib.Path, app_type: str): - """``--shiny`` is a quarto-only flag; combining it with any other type - must fail before any directory is created. - - Locks the two load-bearing tokens of the user-recoverable error - surface (the flag name and the only supported type) without pinning - the full sentence, so prose can drift but the contract cannot. - """ - result = _invoke_quickstart(runner, app_type, "--shiny", "hello_app") - assert result.exit_code != 0 - combined = result.output + (result.stderr if result.stderr_bytes else "") - assert "--shiny" in combined, combined - assert "quarto" in combined, combined - assert not (in_tmp_cwd / "hello_app").exists() - - @pytest.mark.parametrize( "bad_name", [ @@ -321,7 +304,7 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, pytest.param(("notebook",), "jupyter-static", id="notebook-default"), pytest.param(("voila",), "jupyter-voila", id="voila"), pytest.param(("quarto",), "quarto-static", id="quarto-default"), - pytest.param(("quarto", "--shiny"), "quarto-shiny", id="quarto-shiny"), + pytest.param(("quarto-shiny",), "quarto-shiny", id="quarto-shiny"), ] @@ -606,8 +589,8 @@ def test_quickstart_rolls_back_on_keyboard_interrupt( id="quarto-default", ), pytest.param( - "quarto", - ("--shiny",), + "quarto-shiny", + (), "uv run quarto preview report.qmd", ("Note: Quarto must be installed separately: https://quarto.org",), id="quarto-shiny", @@ -745,7 +728,7 @@ def fake_get_data(package: str, resource: str): source_files=(), ) extended_registry = dict(qs._REGISTRY) - extended_registry[("newmode", False)] = new_spec + extended_registry["newmode"] = new_spec monkeypatch.setattr(qs, "_REGISTRY", extended_registry) # Click's argument type was bound at decorator time, so injecting a new # supported type means widening the choice list on the live command. From 31f823de8861ff39fcb437aa03d87d4d6336739e Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 18:10:45 +0200 Subject: [PATCH 22/26] Remove unecessay README --- rsconnect/quickstart/quickstart.py | 4 +++- .../templates/quarto/README_shiny.md.tmpl | 19 ------------------- 2 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 27aecdb8..f84c598e 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -304,9 +304,11 @@ class TemplateSpec: source_files=(FileSpec(name="report.qmd", template="quarto/report.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), ), + # quarto-shiny shares the README with quarto-static: same local-run + # command, same deploy command, same quarto-install note. "quarto-shiny": TemplateSpec( pyproject_template="quarto/pyproject_shiny.toml.tmpl", - readme_template="quarto/README_shiny.md.tmpl", + readme_template="quarto/README.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), source_files=(FileSpec(name="report.qmd", template="quarto/report_shiny.qmd.tmpl"),), notes=(_QUARTO_INSTALL_NOTE,), diff --git a/rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl b/rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl deleted file mode 100644 index c79daa90..00000000 --- a/rsconnect/quickstart/templates/quarto/README_shiny.md.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -# $name - -A Posit Connect project scaffolded by `rsconnect quickstart`. - -## Run locally - -``` -uv run quarto preview report.qmd -``` - -## Deploy to Posit Connect - -``` -rsconnect deploy pyproject $name -``` - -## Notes - -- Quarto must be installed separately: https://quarto.org From e74a11b34d4d68c9046e8e7cccd8311841556a02 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 18:59:28 +0200 Subject: [PATCH 23/26] Uniform toward AppModes --- rsconnect/main.py | 7 ++- rsconnect/models.py | 40 +++++++++++++ rsconnect/quickstart/__init__.py | 8 +-- rsconnect/quickstart/quickstart.py | 94 ++++++++++++++++-------------- tests/test_quickstart.py | 24 +++++--- 5 files changed, 114 insertions(+), 59 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 2ad85143..e63872d5 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -108,7 +108,6 @@ ) from .log import VERBOSE, LogOutputFormat, logger from .metadata import AppStore, ServerStore -from .quickstart import SUPPORTED_APP_TYPES from .models import ( AppMode, AppModes, @@ -1038,7 +1037,11 @@ def info(file: str): @click.argument( "app_type", metavar="TYPE", - type=click.Choice(SUPPORTED_APP_TYPES), + # Click accepts the full CLI alias vocabulary; ``run_quickstart`` + # rejects aliases that map to a mode without a scaffold template with + # a distinct "not yet supported" error, matching the deploy CLI's + # vocabulary. + type=click.Choice(AppModes.cli_aliases()), ) @click.argument("name", metavar="NAME") @cli_exception_handler diff --git a/rsconnect/models.py b/rsconnect/models.py index fc23f02b..710c88be 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -159,6 +159,30 @@ class AppModes: "bokeh": BOKEH_APP, } + # CLI alias vocabulary used by ``rsconnect deploy `` and + # ``rsconnect quickstart ``. Many-to-one is allowed: ``api`` and + # ``flask`` both resolve to ``PYTHON_API``. NB: ``shiny`` here means + # ``PYTHON_SHINY``, which differs from cloud-name ``shiny`` (R Shiny in + # ``_cloud_to_connect_modes``); the two namespaces are independent. + _cli_aliases: dict[str, AppMode] = { + "api": PYTHON_API, + "flask": PYTHON_API, + "fastapi": PYTHON_FASTAPI, + "dash": DASH_APP, + "streamlit": STREAMLIT_APP, + "bokeh": BOKEH_APP, + "shiny": PYTHON_SHINY, + "gradio": PYTHON_GRADIO, + "panel": PYTHON_PANEL, + "notebook": JUPYTER_NOTEBOOK, + "voila": JUPYTER_VOILA, + "quarto": STATIC_QUARTO, + "quarto-shiny": SHINY_QUARTO, + "tensorflow": TENSORFLOW, + "html": STATIC, + "nodejs": NODE_JS, + } + @classmethod def get_by_ordinal(cls, ordinal: int, return_unknown: bool = False) -> AppMode: """Get an AppMode by its associated ordinal (integer)""" @@ -200,6 +224,22 @@ def get_by_extension(cls, extension: Optional[str], return_unknown: bool = False def get_by_cloud_name(cls, name: str) -> AppMode: return cls._cloud_to_connect_modes.get(name, cls.UNKNOWN) + @classmethod + def get_by_cli_alias(cls, alias: str) -> AppMode: + """Resolve a CLI alias to its canonical :class:`AppMode`. + + Returns :attr:`UNKNOWN` for aliases not in :data:`_cli_aliases`. + Subcommands that accept only a subset of modes (e.g. ``rsconnect + quickstart``) check membership in their own registry after resolving + the alias here. + """ + return cls._cli_aliases.get(alias, cls.UNKNOWN) + + @classmethod + def cli_aliases(cls) -> tuple[str, ...]: + """All CLI aliases declared in :data:`_cli_aliases`, in declaration order.""" + return tuple(cls._cli_aliases.keys()) + @classmethod def _find_by(cls, predicate: Callable[[AppMode], bool], message: str, return_unknown: bool) -> AppMode: for mode in cls._modes: diff --git a/rsconnect/quickstart/__init__.py b/rsconnect/quickstart/__init__.py index aa9a7763..cf506524 100644 --- a/rsconnect/quickstart/__init__.py +++ b/rsconnect/quickstart/__init__.py @@ -3,14 +3,14 @@ Public API: - :func:`run_quickstart` — scaffold a new project. -- :data:`SUPPORTED_APP_TYPES` — supported quickstart type vocabulary. Internal pieces (``TemplateSpec``, ``_REGISTRY``, etc.) live in :mod:`rsconnect.quickstart.quickstart` and are not re-exported here. Tests that need them (registry-extensibility) import the inner module -directly. +directly. The CLI alias vocabulary lives on :class:`rsconnect.models.AppModes` +(see :meth:`rsconnect.models.AppModes.cli_aliases`). """ -from .quickstart import SUPPORTED_APP_TYPES, run_quickstart +from .quickstart import run_quickstart -__all__ = ["SUPPORTED_APP_TYPES", "run_quickstart"] +__all__ = ["run_quickstart"] diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index f84c598e..57a0dc39 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -29,30 +29,7 @@ import click from ..exception import RSConnectException - - -# Supported CLI ```` values. Each entry MUST match an existing -# ``rsconnect deploy `` subcommand: a project scaffolded by -# ``rsconnect quickstart `` must be deployable with either -# ``rsconnect deploy `` or ``rsconnect deploy pyproject``. Adding a -# type here without a matching ``deploy`` subcommand breaks that promise. -# ``flask`` is an alias for ``api``; both share the same scaffold and the -# ``python-api`` app mode. -# ``quarto`` selects ``quarto-static``; ``quarto-shiny`` is exposed as its -# own CLI type so the user-visible vocabulary matches Connect's two distinct -# app modes (``quarto-static`` and ``quarto-shiny``) rather than splitting -# one app-mode dimension into a separate flag. -SUPPORTED_APP_TYPES: typing.Tuple[str, ...] = ( - "streamlit", - "shiny", - "fastapi", - "api", - "flask", - "notebook", - "voila", - "quarto", - "quarto-shiny", -) +from ..models import AppMode, AppModes # Project name rule: lowercase ASCII letter start, only lowercase letters / @@ -240,19 +217,25 @@ class TemplateSpec: notes: typing.Tuple[str, ...] = () -# Registry key: the resolved CLI ``app_type`` (``flask`` resolves to -# ``api`` before lookup; see :func:`lookup_template`). Deferred modes -# (dash, gradio, panel, bokeh) are intentionally absent. +# Registry key: the canonical :class:`AppMode` singleton. The CLI alias +# (``streamlit``, ``flask`` etc.) is resolved through +# :meth:`AppModes.get_by_cli_alias` before lookup, which collapses +# many-to-one aliases (``api`` and ``flask`` both resolve to +# ``PYTHON_API``) and centralizes the alias vocabulary in ``models.py``. +# Modes present in :data:`AppModes._cli_aliases` but absent from this +# registry (e.g. ``dash``, ``bokeh``, ``gradio``) are CLI-accepted but not +# yet scaffolded by ``quickstart``; :func:`lookup_template` raises a +# distinct "not yet supported" error for them. _QUARTO_INSTALL_NOTE = "Quarto must be installed separately: https://quarto.org" -_REGISTRY: typing.Mapping[str, TemplateSpec] = { - "streamlit": TemplateSpec( +_REGISTRY: typing.Mapping[AppMode, TemplateSpec] = { + AppModes.STREAMLIT_APP: TemplateSpec( pyproject_template="streamlit/pyproject.toml.tmpl", readme_template="streamlit/README.md.tmpl", local_run_command=("uv", "run", "streamlit", "run", "app.py"), source_files=(FileSpec(name="app.py", template="streamlit/app.py.tmpl"),), ), - "shiny": TemplateSpec( + AppModes.PYTHON_SHINY: TemplateSpec( pyproject_template="shiny/pyproject.toml.tmpl", readme_template="shiny/README.md.tmpl", local_run_command=("uv", "run", "shiny", "run", "app.py"), @@ -261,7 +244,7 @@ class TemplateSpec: # fastapi/api produce a nested ``//`` package so the # documented ``python -m `` local-run command resolves cleanly # and ``from .app import create_app`` relative imports work. - "fastapi": TemplateSpec( + AppModes.PYTHON_FASTAPI: TemplateSpec( pyproject_template="fastapi/pyproject.toml.tmpl", readme_template="fastapi/README.md.tmpl", local_run_command=("uv", "run", "python", "-m", "$name"), @@ -272,7 +255,7 @@ class TemplateSpec: FileSpec(name="$name/app.py", template="fastapi/app.py.tmpl"), ), ), - "api": TemplateSpec( + AppModes.PYTHON_API: TemplateSpec( pyproject_template="api/pyproject.toml.tmpl", readme_template="api/README.md.tmpl", local_run_command=("uv", "run", "python", "-m", "$name"), @@ -285,19 +268,19 @@ class TemplateSpec: ), # notebook and voila share the same notebook body; they differ in # ``pyproject_template`` (app_mode + dependencies) and local-run command. - "notebook": TemplateSpec( + AppModes.JUPYTER_NOTEBOOK: TemplateSpec( pyproject_template="notebook/pyproject.toml.tmpl", readme_template="notebook/README.md.tmpl", local_run_command=("uv", "run", "jupyter", "lab", "notebook.ipynb"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), - "voila": TemplateSpec( + AppModes.JUPYTER_VOILA: TemplateSpec( pyproject_template="voila/pyproject.toml.tmpl", readme_template="voila/README.md.tmpl", local_run_command=("uv", "run", "voila", "notebook.ipynb"), source_files=(FileSpec(name="notebook.ipynb", template="notebook/notebook.ipynb.tmpl"),), ), - "quarto": TemplateSpec( + AppModes.STATIC_QUARTO: TemplateSpec( pyproject_template="quarto/pyproject.toml.tmpl", readme_template="quarto/README.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), @@ -306,7 +289,7 @@ class TemplateSpec: ), # quarto-shiny shares the README with quarto-static: same local-run # command, same deploy command, same quarto-install note. - "quarto-shiny": TemplateSpec( + AppModes.SHINY_QUARTO: TemplateSpec( pyproject_template="quarto/pyproject_shiny.toml.tmpl", readme_template="quarto/README.md.tmpl", local_run_command=("uv", "run", "quarto", "preview", "report.qmd"), @@ -316,22 +299,43 @@ class TemplateSpec: } +def _supported_aliases() -> typing.Tuple[str, ...]: + """CLI aliases whose :class:`AppMode` has a quickstart template. + + Derived from :data:`AppModes._cli_aliases` and :data:`_REGISTRY`: an + alias is "supported" iff its resolved ``AppMode`` is a registry key. + Used only for user-facing error messages, so a small per-call traversal + is fine. + """ + return tuple(alias for alias in AppModes.cli_aliases() if AppModes.get_by_cli_alias(alias) in _REGISTRY) + + def lookup_template(app_type: str) -> TemplateSpec: - """Resolve the :class:`TemplateSpec` for ``app_type``. + """Resolve the :class:`TemplateSpec` for the CLI alias ``app_type``. + + The alias is mapped to its canonical :class:`AppMode` via + :meth:`AppModes.get_by_cli_alias`, which collapses many-to-one aliases + (``api`` and ``flask`` both -> ``PYTHON_API``). Two distinct error + surfaces: - ``flask`` is an alias for ``api`` and shares the same scaffold; both - resolve to the same registry entry. Other CLI-level types have already - been narrowed by Click's ``Choice``, so this lookup is defensive only - (e.g. direct API callers passing an unknown type). + * Unknown alias (not in ``AppModes._cli_aliases``): the user typed + something Connect doesn't recognize at all. + * Known alias but no template (mode not in :data:`_REGISTRY`): + quickstart doesn't yet scaffold this mode (e.g. ``dash``, ``bokeh``). :param str app_type: CLI ```` value. """ - resolved_type = "api" if app_type == "flask" else app_type - if resolved_type not in _REGISTRY: + app_mode = AppModes.get_by_cli_alias(app_type) + if app_mode is AppModes.UNKNOWN: + raise RSConnectException( + f"Unknown project type {app_type!r}. Supported types: " + ", ".join(_supported_aliases()) + ) + if app_mode not in _REGISTRY: raise RSConnectException( - f"Unknown project type {app_type!r}. Supported types: " + ", ".join(SUPPORTED_APP_TYPES) + f"`rsconnect quickstart` does not yet support {app_type!r}. " + f"Supported types: " + ", ".join(_supported_aliases()) ) - return _REGISTRY[resolved_type] + return _REGISTRY[app_mode] # --------------------------------------------------------------------------- diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 1324c357..a0683c49 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -680,15 +680,17 @@ def test_invariant_I9_I10_failure_exit_and_message( def test_quickstart_registry_accepts_new_mode( runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): - """Adding a mode is "drop a template directory plus register it." + """Adding a mode is "register an AppMode + alias + template directory." - The test proves the extensibility contract: inserting a new row into - ``_REGISTRY`` (plus a corresponding ``SUPPORTED_APP_TYPES`` entry) yields a - working scaffold without touching the pre-flight, pyproject writer, or - post-scaffold output modules. + The test proves the extensibility contract: registering a new + :class:`AppMode` + CLI alias in :class:`AppModes` and adding the + corresponding ``_REGISTRY`` entry yields a working scaffold without + touching the pre-flight, pyproject writer, or post-scaffold output + modules. """ import pkgutil + from rsconnect.models import AppMode, AppModes from rsconnect.quickstart import quickstart as qs # Stand in for ``rsconnect/quickstart/templates/newmode/pyproject.toml.tmpl``: @@ -727,15 +729,21 @@ def fake_get_data(package: str, resource: str): local_run_command=("uv", "run", "newtool", "app.py"), source_files=(), ) + # Mint a fresh AppMode singleton and register the alias on AppModes. + # An ordinal of 999 is safely outside the declared range; the registry + # entry only requires a hashable identity. + new_app_mode = AppMode(999, "python-newmode", "New Mode App") + extended_cli_aliases = dict(AppModes._cli_aliases) + extended_cli_aliases["newmode"] = new_app_mode + monkeypatch.setattr(AppModes, "_cli_aliases", extended_cli_aliases) extended_registry = dict(qs._REGISTRY) - extended_registry["newmode"] = new_spec + extended_registry[new_app_mode] = new_spec monkeypatch.setattr(qs, "_REGISTRY", extended_registry) # Click's argument type was bound at decorator time, so injecting a new # supported type means widening the choice list on the live command. quickstart_cmd = cli.commands["quickstart"] type_param = next(p for p in quickstart_cmd.params if p.name == "app_type") - monkeypatch.setattr(type_param, "type", type(type_param.type)(qs.SUPPORTED_APP_TYPES + ("newmode",))) - monkeypatch.setattr(qs, "SUPPORTED_APP_TYPES", qs.SUPPORTED_APP_TYPES + ("newmode",)) + monkeypatch.setattr(type_param, "type", type(type_param.type)(AppModes.cli_aliases())) result = _invoke_quickstart(runner, "newmode", "hello_app") From 10001c90c2713debdf0fd3500ff0270f554162e7 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 19:10:18 +0200 Subject: [PATCH 24/26] uniform toward AppModes all commands --- rsconnect/main.py | 55 +++++++++++++++++++++++++++++--------------- rsconnect/models.py | 13 +++++++++++ tests/test_models.py | 28 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index e63872d5..fad7c8d4 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2110,7 +2110,18 @@ def resolve_requirements_file(directory: str, requirements_file: Optional[str], return requirements_file or "requirements.txt" -def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc: Optional[str] = None): +def generate_deploy_python( + app_mode: AppMode, + min_version: str, + alias: Optional[str] = None, + desc: Optional[str] = None, +): + # ``alias`` defaults to the mode's primary CLI alias (declared in + # ``AppModes._cli_aliases``); callers pass it explicitly only for + # secondary aliases (e.g. ``flask`` -> ``PYTHON_API`` alongside ``api``). + # The bidirectional ``_cli_aliases`` invariant is tested in + # ``tests/test_models.py``; the factory trusts it. + alias = alias or app_mode.cli_alias() if desc is None: desc = app_mode.desc() @@ -2317,15 +2328,15 @@ def deploy_app( return deploy_app -generate_deploy_python(app_mode=AppModes.PYTHON_API, alias="api", min_version="1.8.2") -generate_deploy_python(app_mode=AppModes.PYTHON_API, alias="flask", min_version="1.8.2", desc="Flask API") -generate_deploy_python(app_mode=AppModes.PYTHON_FASTAPI, alias="fastapi", min_version="2021.08.0") -generate_deploy_python(app_mode=AppModes.DASH_APP, alias="dash", min_version="1.8.2") -generate_deploy_python(app_mode=AppModes.STREAMLIT_APP, alias="streamlit", min_version="1.8.4") -generate_deploy_python(app_mode=AppModes.BOKEH_APP, alias="bokeh", min_version="1.8.4") -generate_deploy_python(app_mode=AppModes.PYTHON_SHINY, alias="shiny", min_version="2022.07.0") -generate_deploy_python(app_mode=AppModes.PYTHON_GRADIO, alias="gradio", min_version="2024.12.0") -generate_deploy_python(app_mode=AppModes.PYTHON_PANEL, alias="panel", min_version="2025.10.0") +generate_deploy_python(app_mode=AppModes.PYTHON_API, min_version="1.8.2") +generate_deploy_python(app_mode=AppModes.PYTHON_API, min_version="1.8.2", alias="flask", desc="Flask API") +generate_deploy_python(app_mode=AppModes.PYTHON_FASTAPI, min_version="2021.08.0") +generate_deploy_python(app_mode=AppModes.DASH_APP, min_version="1.8.2") +generate_deploy_python(app_mode=AppModes.STREAMLIT_APP, min_version="1.8.4") +generate_deploy_python(app_mode=AppModes.BOKEH_APP, min_version="1.8.4") +generate_deploy_python(app_mode=AppModes.PYTHON_SHINY, min_version="2022.07.0") +generate_deploy_python(app_mode=AppModes.PYTHON_GRADIO, min_version="2024.12.0") +generate_deploy_python(app_mode=AppModes.PYTHON_PANEL, min_version="2025.10.0") # noinspection SpellCheckingInspection @@ -2977,7 +2988,13 @@ def write_manifest_tensorflow( ) -def generate_write_manifest_python(app_mode: AppMode, alias: str, desc: Optional[str] = None): +def generate_write_manifest_python( + app_mode: AppMode, + alias: Optional[str] = None, + desc: Optional[str] = None, +): + # See :func:`generate_deploy_python` for the alias-resolution contract. + alias = alias or app_mode.cli_alias() if desc is None: desc = app_mode.desc() @@ -3094,15 +3111,15 @@ def manifest_writer( return manifest_writer -generate_write_manifest_python(AppModes.BOKEH_APP, alias="bokeh") -generate_write_manifest_python(AppModes.DASH_APP, alias="dash") -generate_write_manifest_python(AppModes.PYTHON_API, alias="api") +generate_write_manifest_python(AppModes.BOKEH_APP) +generate_write_manifest_python(AppModes.DASH_APP) +generate_write_manifest_python(AppModes.PYTHON_API) generate_write_manifest_python(AppModes.PYTHON_API, alias="flask", desc="Flask API") -generate_write_manifest_python(AppModes.PYTHON_FASTAPI, alias="fastapi") -generate_write_manifest_python(AppModes.PYTHON_SHINY, alias="shiny") -generate_write_manifest_python(AppModes.STREAMLIT_APP, alias="streamlit") -generate_write_manifest_python(AppModes.PYTHON_GRADIO, alias="gradio") -generate_write_manifest_python(AppModes.PYTHON_PANEL, alias="panel") +generate_write_manifest_python(AppModes.PYTHON_FASTAPI) +generate_write_manifest_python(AppModes.PYTHON_SHINY) +generate_write_manifest_python(AppModes.STREAMLIT_APP) +generate_write_manifest_python(AppModes.PYTHON_GRADIO) +generate_write_manifest_python(AppModes.PYTHON_PANEL) # noinspection SpellCheckingInspection diff --git a/rsconnect/models.py b/rsconnect/models.py index 710c88be..9b7ac010 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -67,6 +67,19 @@ def desc(self): def extension(self): return self._ext + def cli_alias(self) -> Optional[str]: + """Return the primary CLI alias for this mode, or ``None`` if absent. + + "Primary" is the first key declared in :data:`AppModes._cli_aliases` + that maps to this mode; secondary aliases (e.g. ``flask`` for + ``PYTHON_API``) are still resolvable via + :meth:`AppModes.get_by_cli_alias` but are not returned here. + """ + for alias, mode in AppModes._cli_aliases.items(): + if mode is self: + return alias + return None + def __str__(self): return self.name() diff --git a/tests/test_models.py b/tests/test_models.py index 2755dac7..31319927 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -73,6 +73,34 @@ def test_get_by_name(self): with self.assertRaises(ValueError): AppModes.get_by_name(None) + def test_cli_aliases_bidirectional(self): + """Every CLI alias resolves to a real AppMode, and every aliased + mode round-trips back through ``cli_alias()``. + + Owns the contract the deploy/quickstart factories trust: ``alias + or app_mode.cli_alias()`` will always yield a non-empty alias that + :meth:`AppModes.get_by_cli_alias` maps back to the same mode. + """ + # Forward: every alias points at a real AppMode (catches typos and + # stale entries from a renamed mode). + for alias, mode in AppModes._cli_aliases.items(): + self.assertIn(mode, AppModes._modes, f"alias {alias!r} points at non-mode {mode!r}") + + # Reverse: for every aliased mode, ``cli_alias()`` returns SOME + # alias that resolves back to that mode (catches the case where a + # primary-alias lookup would silently return None). + for mode in set(AppModes._cli_aliases.values()): + primary = mode.cli_alias() + self.assertIsNotNone(primary, f"{mode!r} has aliases but cli_alias() returned None") + self.assertIs( + AppModes.get_by_cli_alias(primary), + mode, + f"{mode!r}.cli_alias() = {primary!r} does not round-trip", + ) + + # Modes without aliases (R modes, UNKNOWN, etc.) report None. + self.assertIsNone(AppModes.UNKNOWN.cli_alias()) + def test_get_by_extension(self): self.assertIs(AppModes.get_by_extension(".R"), AppModes.SHINY) self.assertIs(AppModes.get_by_extension(".bad-ext", True), AppModes.UNKNOWN) From 021771abf0998b578d4405db7f8c8aa5e7eaf950 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 20 May 2026 19:41:21 +0200 Subject: [PATCH 25/26] handle text decoding --- rsconnect/quickstart/quickstart.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rsconnect/quickstart/quickstart.py b/rsconnect/quickstart/quickstart.py index 57a0dc39..3c21c41a 100644 --- a/rsconnect/quickstart/quickstart.py +++ b/rsconnect/quickstart/quickstart.py @@ -16,6 +16,7 @@ from __future__ import annotations import dataclasses +import io import os import pathlib import pkgutil @@ -369,12 +370,14 @@ def _load_template(path: str) -> str: """Read a template file from the ``rsconnect.quickstart.templates`` package. ``pkgutil.get_data`` is stdlib since Python 3.0 and works under wheel - install, unlike ``importlib.resources.files`` which is 3.9+. + install, unlike ``importlib.resources.files`` which is 3.9+. It returns + raw bytes, so we wrap the buffer in :class:`io.TextIOWrapper` to get the + same universal-newlines decoding as ``open(path, 'rt')``. """ data = pkgutil.get_data("rsconnect.quickstart.templates", path) if data is None: raise RSConnectException(f"Template not found: {path}") - return data.decode("utf-8") + return io.TextIOWrapper(io.BytesIO(data), encoding="utf-8").read() # ``requires-python`` is the single source of truth for the scaffold's Python From fecb0b0462b876195ba87167328bcfc9ed75135c Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 21 May 2026 11:20:50 +0200 Subject: [PATCH 26/26] Add --requirements-file option and requirements_file pyproject.toml option --- rsconnect/main.py | 28 +++- .../templates/api/pyproject.toml.tmpl | 1 + .../templates/fastapi/pyproject.toml.tmpl | 1 + .../templates/notebook/pyproject.toml.tmpl | 1 + .../templates/quarto/pyproject.toml.tmpl | 1 + .../quarto/pyproject_shiny.toml.tmpl | 1 + .../templates/shiny/pyproject.toml.tmpl | 1 + .../templates/streamlit/pyproject.toml.tmpl | 1 + .../templates/voila/pyproject.toml.tmpl | 1 + tests/test_deploy_pyproject.py | 156 ++++++++++++++++-- tests/test_quickstart.py | 5 +- 11 files changed, 181 insertions(+), 16 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index fad7c8d4..7e15b668 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1532,6 +1532,18 @@ def deploy_manifest( @spcs_args @content_args @cloud_shinyapps_args +@click.option( + "--requirements-file", + "-r", + type=click.Path(dir_okay=False), + default=None, + help=( + "Path to the requirements source, relative to the project directory. " + "Overrides ``[tool.rsconnect].requirements_file`` in pyproject.toml; " + "defaults to ``pyproject.toml`` (the project's declared dependencies). " + "Pass ``uv.lock`` for a fully resolved deploy, or any ``requirements.txt``-compatible file." + ), +) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @shinyapps_deploy_args @cli_exception_handler @@ -1552,6 +1564,7 @@ def deploy_pyproject( title: Optional[str], verbose: int, directory: str, + requirements_file: Optional[str], env_vars: dict[str, str], visibility: Optional[str], no_verify: bool, @@ -1589,10 +1602,17 @@ def quickstart_hint() -> str: bundle_kwargs: dict[str, Any] = {} path = directory + # Requirements source precedence: ``-r`` flag > ``[tool.rsconnect].requirements_file`` + # > built-in default ``pyproject.toml`` (top-level deps; Connect resolves transitive). + # Without an explicit source the inspector would silently ``pip freeze`` the caller's + # interpreter — the bug ``deploy pyproject`` exists to avoid. Malformed TOML values + # (wrong type, missing file) are surfaced by the inspector / file existence check. + requirements_file = requirements_file or config.get("requirements_file") or "pyproject.toml" + if app_mode in (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API): environment = Environment.create_python_environment( directory, - requirements_file=None, + requirements_file=requirements_file, override_python_version=None, ) bundle_builder = make_api_bundle @@ -1602,7 +1622,7 @@ def quickstart_hint() -> str: path = str(Path(directory) / entrypoint) environment = Environment.create_python_environment( directory, - requirements_file=None, + requirements_file=requirements_file, override_python_version=None, ) bundle_builder = make_notebook_source_bundle @@ -1616,7 +1636,7 @@ def quickstart_hint() -> str: elif app_mode == AppModes.JUPYTER_VOILA: environment = Environment.create_python_environment( directory, - requirements_file=None, + requirements_file=requirements_file, override_python_version=None, ) bundle_builder = make_voila_bundle @@ -1640,7 +1660,7 @@ def quickstart_hint() -> str: with cli_feedback("Inspecting Python environment"): environment = Environment.create_python_environment( directory, - requirements_file=None, + requirements_file=requirements_file, override_python_version=None, ) bundle_builder = create_quarto_deployment_bundle diff --git a/rsconnect/quickstart/templates/api/pyproject.toml.tmpl b/rsconnect/quickstart/templates/api/pyproject.toml.tmpl index 6129f76b..8932dda3 100644 --- a/rsconnect/quickstart/templates/api/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/api/pyproject.toml.tmpl @@ -10,3 +10,4 @@ dependencies = [ app_mode = "python-api" entrypoint = "$name.__connect__:app" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl b/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl index 255c1d25..7a5b2899 100644 --- a/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl @@ -11,3 +11,4 @@ dependencies = [ app_mode = "python-fastapi" entrypoint = "$name.__connect__:app" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl b/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl index 1b083b2b..75d49375 100644 --- a/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl @@ -10,3 +10,4 @@ dependencies = [ app_mode = "jupyter-static" entrypoint = "notebook.ipynb" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl b/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl index 3408745a..d5965c1b 100644 --- a/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl @@ -8,3 +8,4 @@ dependencies = [] app_mode = "quarto-static" entrypoint = "report.qmd" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl b/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl index 78cf7921..5c40f866 100644 --- a/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl +++ b/rsconnect/quickstart/templates/quarto/pyproject_shiny.toml.tmpl @@ -10,3 +10,4 @@ dependencies = [ app_mode = "quarto-shiny" entrypoint = "report.qmd" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl b/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl index de93581d..91cc70f7 100644 --- a/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl @@ -10,3 +10,4 @@ dependencies = [ app_mode = "python-shiny" entrypoint = "app.py" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl b/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl index 3b4f9414..bd3dbb41 100644 --- a/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl @@ -10,3 +10,4 @@ dependencies = [ app_mode = "python-streamlit" entrypoint = "app.py" title = "$name" +requirements_file = "uv.lock" diff --git a/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl b/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl index b4e5f3ac..775e21d5 100644 --- a/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +++ b/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl @@ -11,3 +11,4 @@ dependencies = [ app_mode = "jupyter-voila" entrypoint = "notebook.ipynb" title = "$name" +requirements_file = "uv.lock" diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index a3c0cbc2..270bbe51 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -17,7 +17,6 @@ import types import typing -import click import pytest from click.testing import CliRunner @@ -72,16 +71,6 @@ def test_deploy_pyproject_requires_path(runner: CliRunner): assert "[DIRECTORY]" not in result.output # required, not optional -def test_deploy_pyproject_option_surface_matches_deploy_manifest(): - """``deploy pyproject`` must expose the same Click option surface as - ``deploy manifest`` so existing credential mechanisms apply identically. - """ - deploy_group = typing.cast(click.Group, cli.commands["deploy"]) - manifest_options = {p.name for p in deploy_group.commands["manifest"].params if isinstance(p, click.Option)} - pyproject_options = {p.name for p in deploy_group.commands["pyproject"].params if isinstance(p, click.Option)} - assert pyproject_options == manifest_options - - # --------------------------------------------------------------------------- # [tool.rsconnect] reader # --------------------------------------------------------------------------- @@ -444,3 +433,148 @@ def test_deploy_pyproject_uses_entrypoint_from_tool_rsconnect(runner: CliRunner, combined = result.output + (result.stderr if result.stderr_bytes else "") # We should not see an entrypoint-guessing error pointing to app.py. assert "app.py" not in combined or "custom_module" in combined + + +# --------------------------------------------------------------------------- +# Requirements source +# --------------------------------------------------------------------------- + + +def _capture_environment(monkeypatch: pytest.MonkeyPatch) -> dict[str, typing.Any]: + """Wire up monkeypatches that short-circuit the deploy and capture the + ``requirements_file`` argument handed to ``create_python_environment``.""" + captured: dict[str, typing.Any] = {} + + class _StopDispatch(Exception): + pass + + from rsconnect import api as api_mod + from rsconnect import main as main_mod + + def spy_create(cls: typing.Any, directory: str, **kwargs: typing.Any): + captured["directory"] = directory + captured["requirements_file"] = kwargs.get("requirements_file") + raise _StopDispatch() + + monkeypatch.setattr(main_mod.Environment, "create_python_environment", classmethod(spy_create)) + monkeypatch.setattr(api_mod.RSConnectClient, "server_settings", lambda self: {}) + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_server", lambda self: self) + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_app_mode", lambda self, app_mode: self) + return captured + + +_REQ_PYPROJECT = """ +[project] +name = "hello_app" +version = "0.0.1" +dependencies = ["flask"] + +[tool.rsconnect] +app_mode = "python-api" +entrypoint = "app:app" +title = "hello_app" +""" + + +def test_deploy_pyproject_defaults_requirements_to_pyproject_toml( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """Default requirements source is ``pyproject.toml``. + + Regression for a bug where the command passed ``requirements_file=None``, + causing the inspector to fall back to ``pip freeze`` of the caller's + interpreter and polluting the bundle with whatever happened to be + installed alongside ``rsconnect-python`` instead of the project's + declared dependencies. + """ + captured = _capture_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + runner.invoke(cli, ["deploy", "pyproject", str(project_dir), "-s", "http://x", "-k", "k"]) + assert captured["requirements_file"] == "pyproject.toml" + + +def test_deploy_pyproject_ignores_uv_lock_unless_requested( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A ``uv.lock`` next to ``pyproject.toml`` does NOT auto-take over. + + Implicitly preferring a lockfile would silently deploy stale pins if the + user edited ``pyproject.toml`` without re-running ``uv lock``. Users who + want lockfile reproducibility must opt in with ``-r uv.lock``. + """ + captured = _capture_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "uv.lock").write_text("# placeholder; presence must not change the default\n") + runner.invoke(cli, ["deploy", "pyproject", str(project_dir), "-s", "http://x", "-k", "k"]) + assert captured["requirements_file"] == "pyproject.toml" + + +def test_deploy_pyproject_requirements_file_flag_overrides_default( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """``-r uv.lock`` opts into a fully resolved deploy.""" + captured = _capture_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "uv.lock").write_text("# placeholder\n") + runner.invoke( + cli, + ["deploy", "pyproject", "-r", "uv.lock", str(project_dir), "-s", "http://x", "-k", "k"], + ) + assert captured["requirements_file"] == "uv.lock" + + +def test_deploy_pyproject_honors_requirements_file_from_tool_rsconnect( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """``[tool.rsconnect].requirements_file`` becomes the default for this project. + + Lets users who maintain a ``uv.lock`` (or any other requirements source) + bake that choice into the project once, instead of remembering ``-r`` on + every deploy. + """ + captured = _capture_environment(monkeypatch) + _write_pyproject( + project_dir, + """ + [project] + name = "hello_app" + version = "0.0.1" + dependencies = ["flask"] + + [tool.rsconnect] + app_mode = "python-api" + entrypoint = "app:app" + title = "hello_app" + requirements_file = "uv.lock" + """, + ) + (project_dir / "uv.lock").write_text("# placeholder\n") + runner.invoke(cli, ["deploy", "pyproject", str(project_dir), "-s", "http://x", "-k", "k"]) + assert captured["requirements_file"] == "uv.lock" + + +def test_deploy_pyproject_flag_overrides_tool_rsconnect_requirements_file( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """CLI ``-r`` wins over ``[tool.rsconnect].requirements_file``.""" + captured = _capture_environment(monkeypatch) + _write_pyproject( + project_dir, + """ + [project] + name = "hello_app" + version = "0.0.1" + dependencies = ["flask"] + + [tool.rsconnect] + app_mode = "python-api" + entrypoint = "app:app" + title = "hello_app" + requirements_file = "uv.lock" + """, + ) + runner.invoke( + cli, + ["deploy", "pyproject", "-r", "pyproject.toml", str(project_dir), "-s", "http://x", "-k", "k"], + ) + assert captured["requirements_file"] == "pyproject.toml" diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index a0683c49..cf723906 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -277,6 +277,9 @@ def test_quickstart_pyproject_has_tool_rsconnect(runner: CliRunner, in_tmp_cwd: assert tool_rsconnect["app_mode"] == "python-streamlit" assert tool_rsconnect["entrypoint"] == "app.py" assert tool_rsconnect["title"] == "hello_app" + # Scaffolded projects ship a uv.lock from ``uv sync``; default deploys + # use it so ``rsconnect deploy pyproject`` is reproducible out of the box. + assert tool_rsconnect["requirements_file"] == "uv.lock" def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, in_tmp_cwd: pathlib.Path): @@ -287,7 +290,7 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner, assert "dependencies" not in tool_rsconnect assert "requires-python" not in tool_rsconnect assert "requires_python" not in tool_rsconnect - assert set(tool_rsconnect.keys()) == {"app_mode", "entrypoint", "title"} + assert set(tool_rsconnect.keys()) == {"app_mode", "entrypoint", "title", "requirements_file"} # ---------------------------------------------------------------------------