Skip to content

Commit 4f2bf3e

Browse files
committed
quality of life tweaks and --python option
1 parent 2960bd1 commit 4f2bf3e

10 files changed

Lines changed: 109 additions & 24 deletions

File tree

rsconnect/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,14 +1277,27 @@ def info(file: str):
12771277
type=click.Choice(AppModes.cli_aliases()),
12781278
)
12791279
@click.argument("name", metavar="NAME")
1280+
@click.option(
1281+
"--python",
1282+
"python_version",
1283+
default=None,
1284+
metavar="VERSION",
1285+
help=(
1286+
"Python version for 'requires-python' in the generated pyproject.toml. "
1287+
"A bare 'major.minor' like '3.10' means any 3.10.x; a full '3.11.14' is "
1288+
"exact; pass an operator for full control (e.g. '>=3.11' or "
1289+
"'>=3.11,<3.14'). Defaults to '>=<major.minor>' of the interpreter "
1290+
"running rsconnect."
1291+
),
1292+
)
12801293
@cli_exception_handler
1281-
def quickstart(app_type: str, name: str):
1294+
def quickstart(app_type: str, name: str, python_version: Optional[str]):
12821295
# Resolve ``run_quickstart`` through the module at call time so tests can
12831296
# monkeypatch ``rsconnect.quickstart.quickstart.run_quickstart`` without
12841297
# binding a stale reference into ``main``'s namespace at import time.
12851298
from .quickstart.quickstart import run_quickstart
12861299

1287-
run_quickstart(app_type=app_type, name=name)
1300+
run_quickstart(app_type=app_type, name=name, python_version=python_version)
12881301

12891302

12901303
@cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.")

rsconnect/quickstart/quickstart.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def run_quickstart(
5050
app_type: str,
5151
name: str,
5252
*,
53+
python_version: typing.Optional[str] = None,
5354
cwd: typing.Optional[pathlib.Path] = None,
5455
) -> pathlib.Path:
5556
"""Scaffold a new Connect project of ``app_type`` named ``name``.
@@ -61,6 +62,12 @@ def run_quickstart(
6162
6263
:param str app_type: one of the supported CLI types.
6364
:param str name: project name; must satisfy the project-name rule above.
65+
:param str python_version: optional ``requires-python`` control. A value
66+
that begins with a specifier operator (e.g. ``>=3.11`` or
67+
``>=3.11,<3.14``) is used verbatim. A bare version is padded to at
68+
most three segments: ``3.10`` -> ``==3.10.*`` (any 3.10.x) and
69+
``3.11.14`` -> ``==3.11.14`` (exact). Defaults to ``>=<major.minor>``
70+
of the interpreter running ``rsconnect``.
6471
:param pathlib.Path cwd: override the working directory (testing hook);
6572
defaults to :func:`pathlib.Path.cwd`.
6673
"""
@@ -82,13 +89,26 @@ def run_quickstart(
8289
# for direct API callers only.
8390
spec = lookup_template(app_type)
8491

92+
# ``--python`` controls ``requires-python``. A value that already starts
93+
# with a specifier operator (``>=3.11``, ``>=3.11,<3.14``, ...) is used
94+
# verbatim. A bare version is padded to at most three segments so the
95+
# ``.*`` wildcard appears only when a patch level is omitted: ``3.10`` ->
96+
# ``==3.10.*`` (any 3.10.x), ``3.11.14`` -> ``==3.11.14`` (exact).
97+
# Without ``--python`` we track the running interpreter's ``major.minor``.
98+
if python_version is None:
99+
requires_python = _REQUIRES_PYTHON
100+
elif python_version[:1] in {"=", "<", ">", "!", "~"}:
101+
requires_python = python_version
102+
else:
103+
requires_python = "==" + ".".join((python_version.split(".") + ["*"])[:3])
104+
85105
# Atomicity: after ``mkdir`` succeeds, any failure in the rest of the
86106
# pipeline must remove ``./<name>/`` so the user sees "all or nothing."
87107
# ``BaseException`` catches ``KeyboardInterrupt`` too (a Ctrl-C
88108
# mid-``uv sync`` is the most likely real-world failure mode).
89109
target.mkdir()
90110
try:
91-
_scaffold(target, name=name, spec=spec)
111+
_scaffold(target, name=name, spec=spec, requires_python=requires_python)
92112
_install_venv(target)
93113
except BaseException:
94114
shutil.rmtree(target, ignore_errors=True)
@@ -335,7 +355,7 @@ def lookup_template(app_type: str) -> TemplateSpec:
335355
# ---------------------------------------------------------------------------
336356

337357

338-
def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None:
358+
def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec, requires_python: str) -> None:
339359
"""Write every file the scaffolded project should contain.
340360
341361
Filesystem-generation phase: the three always-present files
@@ -344,7 +364,9 @@ def _scaffold(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None:
344364
``target``'s creation and rollback, so this helper writes into an
345365
existing directory.
346366
"""
347-
(target / "pyproject.toml").write_text(_render_pyproject(name=name, spec=spec), encoding="utf-8")
367+
(target / "pyproject.toml").write_text(
368+
_render_pyproject(name=name, spec=spec, requires_python=requires_python), encoding="utf-8"
369+
)
348370
(target / ".gitignore").write_text(_GITIGNORE_BODY, encoding="utf-8")
349371
(target / "README.md").write_text(_render_readme(name=name, spec=spec), encoding="utf-8")
350372
for file_spec in spec.source_files:
@@ -389,13 +411,13 @@ def _load_template(path: str) -> str:
389411
"""
390412

391413

392-
def _render_pyproject(*, name: str, spec: TemplateSpec) -> str:
414+
def _render_pyproject(*, name: str, spec: TemplateSpec, requires_python: str) -> str:
393415
# The per-mode template owns the literal TOML, including ``app_mode``,
394416
# ``entrypoint`` and the dependency list. Only ``$name`` (project name)
395-
# and ``$requires_python`` (computed from the running interpreter) vary
396-
# at scaffold time.
417+
# and ``$requires_python`` (from ``--python`` or the running interpreter)
418+
# vary at scaffold time.
397419
return string.Template(_load_template(spec.pyproject_template)).substitute(
398-
name=name, requires_python=_REQUIRES_PYTHON
420+
name=name, requires_python=requires_python
399421
)
400422

401423

@@ -450,14 +472,15 @@ def _install_venv(target: pathlib.Path) -> None:
450472

451473

452474
def _emit_summary(target: pathlib.Path, *, name: str, spec: TemplateSpec) -> None:
453-
"""Print the confirmation, local-run, deploy, and notes lines.
475+
"""Print the confirmation, cd, local-run, deploy, and notes lines.
454476
455477
Uses :func:`click.echo` for consistency with the rest of the CLI; the
456478
same commands are written into the project's ``README.md`` by
457479
:func:`_render_readme` so stdout and on-disk docs agree.
458480
"""
459-
click.echo(f"Project {target.name}/ created.")
481+
click.echo(f"\nProject {target.name}/ created.")
482+
click.echo(f"To get started: cd {name}")
460483
click.echo(f"To run locally: {_format_local_run(spec, name=name)}")
461-
click.echo(f"To deploy: rsconnect deploy pyproject {name}")
484+
click.echo("To deploy: rsconnect deploy pyproject .")
462485
for note in spec.notes:
463486
click.echo(f"Note: {note}")

rsconnect/quickstart/templates/api/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ uv run python -m $name
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```

rsconnect/quickstart/templates/fastapi/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ uv run python -m $name
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```

rsconnect/quickstart/templates/notebook/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ uv run jupyter lab notebook.ipynb
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```

rsconnect/quickstart/templates/quarto/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ uv run quarto preview report.qmd
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```
1616

1717
## Notes

rsconnect/quickstart/templates/shiny/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ uv run shiny run app.py
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```

rsconnect/quickstart/templates/streamlit/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ uv run streamlit run app.py
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```

rsconnect/quickstart/templates/voila/README.md.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ uv run voila notebook.ipynb
1111
## Deploy to Posit Connect
1212

1313
```
14-
rsconnect deploy pyproject $name
14+
rsconnect deploy pyproject .
1515
```

tests/test_quickstart.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,19 @@ def test_quickstart_quarto_shiny_not_supported(runner: CliRunner, in_tmp_cwd: pa
125125
[
126126
(
127127
["streamlit", "hello_app"],
128-
{"app_type": "streamlit", "name": "hello_app"},
128+
{"app_type": "streamlit", "name": "hello_app", "python_version": None},
129129
),
130130
(
131131
["notebook", "hello_notebook"],
132-
{"app_type": "notebook", "name": "hello_notebook"},
132+
{"app_type": "notebook", "name": "hello_notebook", "python_version": None},
133133
),
134134
(
135135
["quarto-shiny", "hello_quarto"],
136-
{"app_type": "quarto-shiny", "name": "hello_quarto"},
136+
{"app_type": "quarto-shiny", "name": "hello_quarto", "python_version": None},
137+
),
138+
(
139+
["streamlit", "hello_app", "--python", ">=3.11"],
140+
{"app_type": "streamlit", "name": "hello_app", "python_version": ">=3.11"},
137141
),
138142
],
139143
)
@@ -309,6 +313,50 @@ def test_quickstart_does_not_duplicate_deps_in_tool_rsconnect(runner: CliRunner,
309313
assert set(tool_rsconnect.keys()) == {"app_mode", "entrypoint", "title", "requirements_file"}
310314

311315

316+
def test_quickstart_python_option_sets_requires_python(
317+
runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch
318+
):
319+
"""``--python`` overrides the detected interpreter version in ``requires-python``."""
320+
monkeypatch.setattr("rsconnect.quickstart.quickstart._install_venv", lambda target: None)
321+
result = _invoke_quickstart(runner, "streamlit", "--python", ">=3.10", "hello_app")
322+
assert result.exit_code == 0, result.output
323+
data = _read_pyproject(in_tmp_cwd / "hello_app")
324+
assert data["project"]["requires-python"] == ">=3.10"
325+
326+
327+
def test_quickstart_python_option_used_verbatim(
328+
runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch
329+
):
330+
"""A value starting with an operator (incl. a range) passes through unchanged."""
331+
monkeypatch.setattr("rsconnect.quickstart.quickstart._install_venv", lambda target: None)
332+
result = _invoke_quickstart(runner, "streamlit", "--python", ">=3.11,<3.14", "hello_app")
333+
assert result.exit_code == 0, result.output
334+
data = _read_pyproject(in_tmp_cwd / "hello_app")
335+
assert data["project"]["requires-python"] == ">=3.11,<3.14"
336+
337+
338+
def test_quickstart_python_option_bare_version_means_any_patch(
339+
runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch
340+
):
341+
"""A bare ``major.minor`` becomes ``==3.10.*`` so any 3.10.x satisfies it."""
342+
monkeypatch.setattr("rsconnect.quickstart.quickstart._install_venv", lambda target: None)
343+
result = _invoke_quickstart(runner, "streamlit", "--python", "3.10", "hello_app")
344+
assert result.exit_code == 0, result.output
345+
data = _read_pyproject(in_tmp_cwd / "hello_app")
346+
assert data["project"]["requires-python"] == "==3.10.*"
347+
348+
349+
def test_quickstart_python_option_full_version_is_exact(
350+
runner: CliRunner, in_tmp_cwd: pathlib.Path, monkeypatch: pytest.MonkeyPatch
351+
):
352+
"""A full ``major.minor.patch`` becomes ``==3.11.14`` (exact, no trailing ``.*``)."""
353+
monkeypatch.setattr("rsconnect.quickstart.quickstart._install_venv", lambda target: None)
354+
result = _invoke_quickstart(runner, "streamlit", "--python", "3.11.14", "hello_app")
355+
assert result.exit_code == 0, result.output
356+
data = _read_pyproject(in_tmp_cwd / "hello_app")
357+
assert data["project"]["requires-python"] == "==3.11.14"
358+
359+
312360
# ---------------------------------------------------------------------------
313361
# Per-mode app_mode matrix
314362
# ---------------------------------------------------------------------------
@@ -625,8 +673,9 @@ def test_quickstart_post_scaffold_output(
625673
lines = [line for line in result.output.splitlines() if line.strip()]
626674
expected = [
627675
"Project hello_app/ created.",
676+
"To get started: cd hello_app",
628677
f"To run locally: {local_run}",
629-
"To deploy: rsconnect deploy pyproject hello_app",
678+
"To deploy: rsconnect deploy pyproject .",
630679
*extra_lines,
631680
]
632681
assert lines == expected, result.output
@@ -638,7 +687,7 @@ def test_quickstart_readme_matches_post_scaffold_output(runner: CliRunner, in_tm
638687
readme = (in_tmp_cwd / "hello_app" / "README.md").read_text()
639688
# The README and stdout agree on the two commands the user needs.
640689
assert "uv run streamlit run app.py" in readme
641-
assert "rsconnect deploy pyproject hello_app" in readme
690+
assert "rsconnect deploy pyproject ." in readme
642691

643692

644693
def test_quickstart_quarto_readme_includes_install_note(runner: CliRunner, in_tmp_cwd: pathlib.Path):

0 commit comments

Comments
 (0)