From 5581695d88c24e568d2a26ad15594248df669760 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 13:19:25 -0400 Subject: [PATCH 1/9] feat: add connect run command --- README.md | 29 ++ hello.R | 1 + hello.py | 1 + skills/posit-cli/SKILL.md | 31 +- src/posit_cli/connect/__init__.py | 2 + src/posit_cli/connect/run.py | 522 ++++++++++++++++++++++++++++++ tests/test_cli.py | 1 + tests/test_run.py | 228 +++++++++++++ 8 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 hello.R create mode 100644 hello.py create mode 100644 src/posit_cli/connect/run.py create mode 100644 tests/test_run.py diff --git a/README.md b/README.md index b92fd2a..6327643 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A friendly command-line interface for Posit products, in the spirit of [`gh`](ht $ posit connect login https://connect.example.com # OAuth, tokens in your OS keyring $ posit connect api v1/user -q .username # gh-api-style raw request $ posit connect deploy streamlit ./my-app # everything rsconnect can do +$ posit connect run hello.py # run a Python program +$ posit connect run hello.R # run an R program ``` This project is in early-stage development and so far only supports Posit Connect's APIs. @@ -61,6 +63,33 @@ $ posit connect deploy streamlit ./my-app That's it — from here, explore `posit connect --help` for the full command set. +## `posit connect run` + +The initial proof of concept accepts one Python or R source file and runs it +through Connect's existing content APIs. Python uses a zero-dependency WSGI +adapter; R uses a temporary Plumber API. Both are deployed, invoked once, +printed, and removed: + +```console +$ posit connect run hello.py +hello from Connect +$ posit connect run hello.R +Hello, world! +``` + +Arguments after `--` are passed to the program. Use `--detach` to deploy the +temporary API and print its URL without invoking or removing it: + +```console +$ posit connect run hello.py -- --name Ada +https://connect.example.com/content/... +``` + +This compatibility path supports only the `standard` profile. R programs use +the local R major/minor version by default; `--runtime r4.5` can override the +version constraint. The adapters are deliberately temporary until Connect +exposes a native execution API. + ## Authentication `posit connect login` runs an OAuth 2.1 flow and stores tokens in your OS diff --git a/hello.R b/hello.R new file mode 100644 index 0000000..1706343 --- /dev/null +++ b/hello.R @@ -0,0 +1 @@ +cat("Hello, world!\n") diff --git a/hello.py b/hello.py new file mode 100644 index 0000000..f7cf60e --- /dev/null +++ b/hello.py @@ -0,0 +1 @@ +print("Hello, world!") diff --git a/skills/posit-cli/SKILL.md b/skills/posit-cli/SKILL.md index b5084b7..3b40d7e 100644 --- a/skills/posit-cli/SKILL.md +++ b/skills/posit-cli/SKILL.md @@ -3,7 +3,8 @@ name: posit-cli description: >- Use the `posit` CLI to work with Posit Connect — logging in, making raw authenticated calls to the Connect REST API (`posit connect api`, a - gh-api-style client), and deploying or managing content. Use this whenever the + gh-api-style client), executing Python and R programs (`posit connect run`), and + deploying or managing content. Use this whenever the user mentions `posit`, posit-cli, Posit Connect, the Connect API, or deploying apps/notebooks/APIs to Connect (Streamlit, Shiny, FastAPI, Flask, Dash, Quarto, Bokeh, Gradio, Panel, Voila, etc.), or managing Connect content, users, groups, @@ -21,6 +22,10 @@ which has two halves: - **`posit connect api `** — a `gh api`-style raw REST client for the Connect API. This is your primary tool for anything that isn't a deploy: reading and writing content, users, groups, tags, environments, audit logs, etc. +- **`posit connect run `** — execute one Python or R source file through a + temporary Connect API. The compatibility implementation supports the + `standard` profile, optional runtime selection, script arguments, and + `--detach`. - **The full `rsconnect` command set** (`login`, `deploy`, `content`, `system`, `add`, `list`, ...) is mounted under `posit connect`, so those come for free and track [rsconnect-python](https://github.com/posit-dev/rsconnect-python) upstream. @@ -39,7 +44,8 @@ posit connect deploy --help # the deploy subcommands (streamlit, shiny, .. ``` `posit connect api --help` and the rest of this skill cover the `api` command, -which is owned by this project and documented in full below. +while `posit connect run --help` is the source of truth for the compatibility +command. ## Authentication @@ -67,6 +73,27 @@ server), `-s/--server` (env `CONNECT_SERVER`), `-k/--api-key` (env `CONNECT_API_KEY`), `--no-tls-verify` (env `CONNECT_INSECURE`; note: rsconnect commands spell this `-i/--insecure`), `-c/--cacert `. +## `posit connect run` + +The initial compatibility client accepts one Python or R source file and waits +for its temporary API invocation to finish: + +```console +posit connect run hello.py +posit connect run hello.py -- --name Ada +posit connect run hello.py --runtime python3.12 --detach +posit connect run hello.R --runtime r4.5 +``` + +It creates content with `POST /v1/content`, uploads either a zero-dependency +Python WSGI API bundle or an R Plumber API bundle with its Packrat dependency +metadata to +`/v1/content/{guid}/bundles`, deploys it with +`POST /v1/content/{guid}/deploy`, invokes the content URL, prints the captured +output, and deletes the temporary content. `--detach` leaves the deployed +content in place and prints its URL. Directories, native batch jobs, resource +overrides, and artifact pulling are not implemented yet. + ## `posit connect api` — the raw REST client ```console diff --git a/src/posit_cli/connect/__init__.py b/src/posit_cli/connect/__init__.py index a7cafad..138eaf1 100644 --- a/src/posit_cli/connect/__init__.py +++ b/src/posit_cli/connect/__init__.py @@ -9,6 +9,7 @@ from rsconnect.main import cli as rsconnect_cli from .api import api as api_cmd +from .run import run as run_cmd _epilog = ( @@ -29,3 +30,4 @@ def connect() -> None: connect.add_command(_cmd, name=_name) connect.add_command(api_cmd, name="api") +connect.add_command(run_cmd, name="run") diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py new file mode 100644 index 0000000..d6c474f --- /dev/null +++ b/src/posit_cli/connect/run.py @@ -0,0 +1,522 @@ +"""``posit connect run`` -- execute a Python or R program through Connect content.""" + +from __future__ import annotations + +import io +import json +import shutil +import subprocess +import tarfile +import tempfile +import uuid +from pathlib import Path +from typing import Any, BinaryIO, Dict, Optional, Tuple + +import click +from rsconnect.api import RSConnectException, RSConnectExecutor +from rsconnect.bundle import buffer_checksum, make_api_bundle +from rsconnect.environment import Environment +from rsconnect.http_support import HTTPResponse, HTTPServer +from rsconnect.models import AppModes + + +_R_PACKAGE_REPOSITORY = "https://cran.rstudio.com/" + +# Connect's legacy R builder converts manifest packages into a Packrat lockfile +# before launching Plumber. Keep this small dependency closure in the bundle +# until the native run API can accept an execution artifact directly. +_R_PACKAGE_SPECS = { + "cli": ("3.6.6", {"Depends": "R (>= 3.4)", "Imports": "utils"}), + "crayon": ("1.5.3", {"Imports": "grDevices, methods, utils"}), + "curl": ("7.1.0", {"Depends": "R (>= 3.0.0)"}), + "fastmap": ("1.2.0", {}), + "httpuv": ( + "1.6.17", + { + "Depends": "R (>= 2.15.1)", + "Imports": "later (>= 0.8.0), promises, R6, Rcpp (>= 1.0.7), utils", + "LinkingTo": "later, Rcpp", + }, + ), + "jsonlite": ("2.0.0", {"Imports": "methods"}), + "later": ( + "1.4.8", + {"Imports": "Rcpp (>= 1.0.10), rlang", "LinkingTo": "Rcpp"}, + ), + "lifecycle": ( + "1.0.5", + {"Depends": "R (>= 3.6)", "Imports": "cli (>= 3.4.0), rlang (>= 1.1.0)"}, + ), + "magrittr": ("2.0.5", {"Depends": "R (>= 3.4.0)"}), + "mime": ("0.13", {"Imports": "tools"}), + "otel": ("0.2.0", {"Depends": "R (>= 3.6.0)"}), + "plumber": ( + "1.3.3", + { + "Depends": "R (>= 3.0.0)", + "Imports": ( + "crayon, httpuv (>= 1.5.5), jsonlite (>= 0.9.16), " + "lifecycle (>= 1.0.0), magrittr, mime, promises (>= 1.1.0), " + "R6 (>= 2.0.0), rlang (>= 1.0.0), sodium, stringi (>= 0.3.0), " + "swagger (>= 3.33.0), webutils (>= 1.1)" + ), + }, + ), + "promises": ( + "1.5.0", + { + "Depends": "R (>= 4.1.0)", + "Imports": ( + "fastmap (>= 1.1.0), later, lifecycle, magrittr (>= 1.5), " + "otel (>= 0.2.0), R6, rlang" + ), + }, + ), + "R6": ("2.6.1", {"Depends": "R (>= 3.6)"}), + "Rcpp": ("1.1.2", {"Depends": "R (>= 3.5.0)", "Imports": "methods, utils"}), + "rlang": ("1.3.0", {"Depends": "R (>= 4.0.0)", "Imports": "utils"}), + "sodium": ("1.4.0", {}), + "stringi": ( + "1.8.7", + {"Depends": "R (>= 3.4)", "Imports": "tools, utils, stats"}, + ), + "swagger": ("5.32.1", {}), + "webutils": ("1.2.3", {"Imports": "curl (>= 2.5), jsonlite"}), +} + + +def _r_package_manifest() -> Dict[str, Dict[str, Any]]: + return { + name: { + "Source": "CRAN", + "Repository": _R_PACKAGE_REPOSITORY, + "description": { + "Package": name, + "Version": version, + **description, + }, + } + for name, (version, description) in _R_PACKAGE_SPECS.items() + } + + +def _python_runtime_version(runtime: Optional[str]) -> Optional[str]: + """Translate ``python`` or ``pythonX.Y`` into a manifest version.""" + if runtime is None or runtime == "python": + return None + if not runtime.startswith("python"): + raise click.BadParameter( + "runtime must be 'python' or a Python version such as 'python3.12'", + param_hint="--runtime", + ) + + version = runtime[len("python") :] + if not version or any(not (character.isdigit() or character == ".") for character in version): + raise click.BadParameter( + "runtime must be 'python' or a Python version such as 'python3.12'", + param_hint="--runtime", + ) + return version + + +def _r_runtime_version(runtime: Optional[str]) -> Optional[str]: + """Translate ``r`` or ``rX.Y`` into an R manifest version.""" + if runtime is None or runtime.lower() == "r": + return None + if not runtime.lower().startswith("r"): + raise click.BadParameter( + "runtime must be 'r' or an R version such as 'r4.5'", + param_hint="--runtime", + ) + + version = runtime[1:] + if not version or any(not (character.isdigit() or character == ".") for character in version): + raise click.BadParameter( + "runtime must be 'r' or an R version such as 'r4.5'", + param_hint="--runtime", + ) + return version + + +def _local_r_runtime_version() -> str: + executable = shutil.which("Rscript") + if executable is None: + raise click.ClickException( + "Rscript is required to infer the local R version; pass --runtime rX.Y." + ) + + result = subprocess.run( + [ + executable, + "--vanilla", + "-e", + "cat(paste(R.version$major, R.version$minor, sep = '.'))", + ], + capture_output=True, + text=True, + check=False, + ) + version = result.stdout.strip() + if ( + result.returncode != 0 + or not version + or any(not (character.isdigit() or character == ".") for character in version) + ): + detail = result.stderr.strip() or "Rscript did not report a version" + raise click.ClickException(f"unable to determine the local R version: {detail}") + return version + + +def _r_manifest_version(runtime: Optional[str]) -> str: + return _r_runtime_version(runtime) or _local_r_runtime_version() + + +def _wrapper_source(program_args: Tuple[str, ...]) -> str: + """Create the small WSGI adapter used by the legacy content API.""" + encoded_args = json.dumps(list(program_args)) + return f"""\ +import os +import subprocess +import sys + +PROGRAM = "__posit_connect_run_program.py" +PROGRAM_ARGS = {encoded_args} + + +def app(_environ, start_response): + result = subprocess.run( + [sys.executable, PROGRAM, *PROGRAM_ARGS], + cwd=os.path.dirname(__file__), + capture_output=True, + text=True, + errors="replace", + ) + output = (result.stdout + result.stderr).encode("utf-8") + status = "200 OK" if result.returncode == 0 else "500 Internal Server Error" + start_response( + status, + [ + ("Content-Type", "text/plain; charset=utf-8"), + ("Content-Length", str(len(output))), + ], + ) + return [output] +""" + + +def _r_string_vector(values: Tuple[str, ...]) -> str: + if not values: + return "character()" + return "c(" + ", ".join(json.dumps(value) for value in values) + ")" + + +def _r_wrapper_source(program_args: Tuple[str, ...]) -> str: + """Create the small Plumber adapter used by the legacy content API.""" + encoded_args = _r_string_vector(program_args) + return f"""\ +PROGRAM <- "__posit_connect_run_program.R" +PROGRAM_ARGS <- {encoded_args} + + +#* @get / +run_program <- function(res) {{ + res$serializer <- plumber::serializer_text() + output <- suppressWarnings(system2( + file.path(R.home("bin"), "Rscript"), + c("--vanilla", PROGRAM, PROGRAM_ARGS), + stdout = TRUE, + stderr = TRUE + )) + exit_code <- attr(output, "status", exact = TRUE) + if (is.null(exit_code)) exit_code <- 0L + if (length(output) == 0) output <- "" + if (exit_code != 0) res$status <- 500L + paste(output, collapse = "\\n") +}} +""" + + +def _add_bundle_file(archive: tarfile.TarFile, name: str, content: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.mode = 0o644 + info.mtime = 0 + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + + +def _build_python_bundle( + path: Path, + program_args: Tuple[str, ...], + runtime: Optional[str], +) -> BinaryIO: + """Build a normal Python API bundle containing the program and adapter.""" + with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: + root = Path(directory) + (root / "__posit_connect_run_program.py").write_bytes(path.read_bytes()) + (root / "runner.py").write_text(_wrapper_source(program_args), encoding="utf-8") + (root / "requirements.txt").write_text("", encoding="utf-8") + + environment = Environment.create_python_environment( + directory, + override_python_version=_python_runtime_version(runtime), + ) + return make_api_bundle( + directory, + "runner:app", + AppModes.PYTHON_API, + environment, + extra_files=[], + excludes=[], + ) + + +def _build_r_bundle(path: Path, program_args: Tuple[str, ...], runtime: Optional[str]) -> BinaryIO: + """Build a minimal Plumber API bundle containing the R program and adapter.""" + with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: + root = Path(directory) + (root / "__posit_connect_run_program.R").write_bytes(path.read_bytes()) + (root / "plumber.R").write_text(_r_wrapper_source(program_args), encoding="utf-8") + + program_bytes = (root / "__posit_connect_run_program.R").read_bytes() + wrapper_bytes = (root / "plumber.R").read_bytes() + manifest: Dict[str, Any] = { + "version": 1, + "locale": "en_US", + "metadata": { + "appmode": "api", + "primary_rmd": None, + "primary_html": None, + "content_category": None, + "has_parameters": False, + }, + "files": { + "__posit_connect_run_program.R": {"checksum": buffer_checksum(program_bytes)}, + "plumber.R": {"checksum": buffer_checksum(wrapper_bytes)}, + }, + } + manifest["platform"] = _r_manifest_version(runtime) + manifest["packages"] = _r_package_manifest() + + manifest_bytes = json.dumps(manifest, indent=2).encode("utf-8") + bundle = io.BytesIO() + with tarfile.open(fileobj=bundle, mode="w:gz") as archive: + _add_bundle_file(archive, "manifest.json", manifest_bytes) + _add_bundle_file(archive, "__posit_connect_run_program.R", program_bytes) + _add_bundle_file(archive, "plumber.R", wrapper_bytes) + bundle.seek(0) + return bundle + + +def _build_bundle(path: Path, program_args: Tuple[str, ...], runtime: Optional[str]) -> BinaryIO: + if path.suffix.lower() == ".r": + return _build_r_bundle(path, program_args, runtime) + return _build_python_bundle(path, program_args, runtime) + + +def _content_name(path: Path, job_name: Optional[str]) -> str: + if job_name: + return job_name + return f"posit-connect-run-{path.stem}-{uuid.uuid4().hex[:12]}" + + +def _wait_for_deployment(client: Any, deployment: Any) -> None: + if not isinstance(deployment, dict): + raise click.ClickException("Connect returned an invalid deployment response.") + + task_id = deployment.get("task_id") + if not isinstance(task_id, str) or not task_id: + raise click.ClickException("Connect returned no deployment task ID.") + + _, task = client.wait_for_task(task_id, log_callback=None, raise_on_error=False) + if not isinstance(task, dict): + raise click.ClickException("Connect returned an invalid deployment task.") + + code = task.get("code", 0) + if code not in (None, 0): + detail = task.get("error") or f"deployment exited with status {code}" + raise click.ClickException(f"deployment failed: {detail}") + + +def _content_response(client: Any, content_url: str) -> HTTPResponse: + """Invoke the deployed content URL with the same auth and TLS settings.""" + app_server = HTTPServer( + content_url, + disable_tls_check=getattr(client, "_disable_tls_check", False), + ca_data=getattr(client, "_ca_data", None), + cookies=getattr(client, "_cookies", None), + ) + app_server._headers.update(getattr(client, "_headers", {})) + response = app_server.get("", decode_response=False) + if not isinstance(response, HTTPResponse): + raise click.ClickException("Connect returned an invalid content response.") + return response + + +def _response_text(response: HTTPResponse) -> str: + value = response.response_body + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value or "") + + +def _emit_response(response: HTTPResponse) -> None: + text = _response_text(response) + if text: + click.echo(text, nl=not text.endswith("\n")) + + +def _delete_content(client: Any, content_guid: str) -> None: + response = client.delete(f"v1/content/{content_guid}", decode_response=False) + if isinstance(response, HTTPResponse): + if response.exception: + raise RSConnectException(str(response.exception)) + if not 200 <= response.status < 300: + raise RSConnectException(f"HTTP {response.status} {response.reason}".rstrip()) + + +@click.command( + "run", + short_help="Run a Python or R program through a temporary Connect API.", + context_settings={"help_option_names": ["-h", "--help"]}, +) +@click.argument( + "path", + type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), +) +@click.argument("program_args", nargs=-1, type=click.UNPROCESSED) +@click.option( + "--profile", + default="standard", + show_default=True, + help="Dispatch profile. The legacy compatibility path supports standard only.", +) +@click.option( + "--runtime", + default=None, + metavar="NAME", + help="Runtime, for example python3.12 or r4.5. Defaults to the server runtime.", +) +@click.option("--job-name", default=None, help="Optional name for the temporary content item.") +@click.option( + "--detach", + is_flag=True, + help="Deploy the temporary API and print its URL without invoking or deleting it.", +) +# Credential selection mirrors `posit connect api`. +@click.option("--name", "-n", "server_name", default=None, help="Nickname of a saved server.") +@click.option( + "--server", + "-s", + default=None, + envvar="CONNECT_SERVER", + help="Connect server URL [env: CONNECT_SERVER].", +) +@click.option( + "--api-key", + "-k", + default=None, + envvar="CONNECT_API_KEY", + help="Connect API key [env: CONNECT_API_KEY].", +) +@click.option( + "--no-tls-verify", + "insecure", + is_flag=True, + default=False, + envvar="CONNECT_INSECURE", + help="Skip TLS certificate verification (still uses TLS) [env: CONNECT_INSECURE].", +) +@click.option( + "--cacert", + "-c", + type=click.Path(exists=True, dir_okay=False), + default=None, + envvar="CONNECT_CA_CERTIFICATE", + help="Path to trusted TLS CA certificate.", +) +def run( + path: Path, + program_args: Tuple[str, ...], + profile: str, + runtime: Optional[str], + job_name: Optional[str], + detach: bool, + server_name: Optional[str], + server: Optional[str], + api_key: Optional[str], + insecure: bool, + cacert: Optional[str], +) -> None: + """Run PATH as a Python or R program using Connect's existing content APIs. + + PATH must be a single ``.py`` or ``.R`` source file. Arguments after ``--`` + are passed to the submitted program. + """ + suffix = path.suffix.lower() + if suffix not in {".py", ".r"}: + raise click.BadParameter( + "PATH must be a Python or R file ending in .py or .R", + param_hint="PATH", + ) + if profile != "standard": + raise click.BadParameter( + "the legacy compatibility path supports only the 'standard' profile", + param_hint="--profile", + ) + if suffix == ".py": + _python_runtime_version(runtime) + else: + _r_runtime_version(runtime) + + executor: Optional[RSConnectExecutor] = None + content_guid: Optional[str] = None + try: + executor = RSConnectExecutor( + ctx=None, + name=server_name, + url=server, + api_key=api_key, + insecure=insecure, + cacert=cacert, + ) + executor.setup_client() + + client = executor.client + content = client.content_create(_content_name(path, job_name)) + content_guid = content.get("guid") if isinstance(content, dict) else None + content_url = content.get("content_url") if isinstance(content, dict) else None + if not isinstance(content_guid, str) or not content_guid: + raise click.ClickException("Connect returned no content GUID.") + if not isinstance(content_url, str) or not content_url: + raise click.ClickException("Connect returned no content URL.") + + bundle = _build_bundle(path, program_args, runtime) + uploaded = client.upload_bundle(content_guid, bundle) + bundle_id = uploaded.get("id") if isinstance(uploaded, dict) else None + if not isinstance(bundle_id, str) or not bundle_id: + raise click.ClickException("Connect returned no bundle ID.") + + deployment = client.content_deploy(content_guid, bundle_id=bundle_id) + _wait_for_deployment(client, deployment) + + if detach: + click.echo(content_url) + return + + response = _content_response(client, content_url) + _emit_response(response) + if response.exception: + raise click.ClickException(f"running content failed: {response.exception}") + if not 200 <= response.status < 300: + raise click.exceptions.Exit(1) + except RSConnectException as exc: + raise click.ClickException(str(exc)) from exc + finally: + if content_guid and not detach and executor is not None: + try: + _delete_content(executor.client, content_guid) + except Exception as exc: + click.echo( + f"Warning: unable to delete temporary content {content_guid}: {exc}", + err=True, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e152ba..328634a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,6 +21,7 @@ def test_connect_mounts_api_command(runner): result = runner.invoke(cli, ["connect", "--help"]) assert result.exit_code == 0 assert "api" in result.output + assert "run" in result.output # rsconnect commands we expect to re-expose under `posit connect`. diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..784ced6 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,228 @@ +"""Unit tests for the legacy-API `posit connect run` command.""" + +import io +import json +import tarfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from posit_cli.__main__ import cli +from posit_cli.connect.run import ( + _build_bundle, + _build_r_bundle, + _r_wrapper_source, + _wrapper_source, +) +from rsconnect.models import AppModes + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _app_response(body: bytes, status: int = 200): + return SimpleNamespace(response_body=body, status=status, exception=None) + + +def _mock_executor(): + executor = MagicMock() + client = executor.client + client.content_create.return_value = { + "guid": "content-123", + "content_url": "https://connect.example.com/content/content-123/", + } + client.upload_bundle.return_value = {"id": "bundle-123"} + client.content_deploy.return_value = {"task_id": "task-123"} + client.wait_for_task.return_value = ([], {"code": 0}) + client.delete.return_value = None + executor.client = client + return executor + + +def test_run_creates_deploys_invokes_and_deletes_temporary_content(runner, tmp_path): + script = tmp_path / "hello.py" + script.write_text("print('hello from Connect')\n", encoding="utf-8") + executor = _mock_executor() + response = _app_response(b"hello from Connect\n") + + with patch("posit_cli.connect.run.RSConnectExecutor", return_value=executor), patch( + "posit_cli.connect.run._build_bundle", return_value=io.BytesIO(b"bundle") + ) as build_bundle, patch( + "posit_cli.connect.run._content_response", return_value=response + ) as content_response: + result = runner.invoke(cli, ["connect", "run", str(script), "--", "one", "--two"]) + + assert result.exit_code == 0, result.output + assert result.output == "hello from Connect\n" + build_bundle.assert_called_once_with(script, ("one", "--two"), None) + content_response.assert_called_once_with( + executor.client, "https://connect.example.com/content/content-123/" + ) + executor.client.content_create.assert_called_once() + executor.client.upload_bundle.assert_called_once() + executor.client.content_deploy.assert_called_once_with("content-123", bundle_id="bundle-123") + executor.client.wait_for_task.assert_called_once_with( + "task-123", + log_callback=None, + raise_on_error=False, + ) + executor.client.delete.assert_called_once_with("v1/content/content-123", decode_response=False) + + +def test_run_detach_prints_content_url_and_keeps_content(runner, tmp_path): + script = tmp_path / "hello.py" + script.write_text("print('hello')\n", encoding="utf-8") + executor = _mock_executor() + + with patch("posit_cli.connect.run.RSConnectExecutor", return_value=executor), patch( + "posit_cli.connect.run._build_bundle", return_value=io.BytesIO(b"bundle") + ), patch("posit_cli.connect.run._content_response") as content_response: + result = runner.invoke(cli, ["connect", "run", str(script), "--detach"]) + + assert result.exit_code == 0, result.output + assert result.output == "https://connect.example.com/content/content-123/\n" + content_response.assert_not_called() + + +def test_run_returns_nonzero_for_content_failure_and_still_deletes(runner, tmp_path): + script = tmp_path / "hello.py" + script.write_text("raise SystemExit(3)\n", encoding="utf-8") + executor = _mock_executor() + + with patch("posit_cli.connect.run.RSConnectExecutor", return_value=executor), patch( + "posit_cli.connect.run._build_bundle", return_value=io.BytesIO(b"bundle") + ), patch( + "posit_cli.connect.run._content_response", + return_value=_app_response(b"failed\n", status=500), + ): + result = runner.invoke(cli, ["connect", "run", str(script)]) + + assert result.exit_code == 1 + assert result.output == "failed\n" + + +def test_run_accepts_r_scripts(runner, tmp_path): + script = tmp_path / "hello.R" + script.write_text('cat("hello from R\\n")\n', encoding="utf-8") + executor = _mock_executor() + + with patch("posit_cli.connect.run.RSConnectExecutor", return_value=executor), patch( + "posit_cli.connect.run._build_bundle", return_value=io.BytesIO(b"bundle") + ) as build_bundle, patch( + "posit_cli.connect.run._content_response", return_value=_app_response(b"hello from R\n") + ): + result = runner.invoke(cli, ["connect", "run", str(script)]) + + assert result.exit_code == 0, result.output + assert result.output == "hello from R\n" + build_bundle.assert_called_once_with(script, (), None) + + +def test_run_rejects_unsupported_profile(runner, tmp_path): + script = tmp_path / "hello.py" + script.write_text("print('hello')\n", encoding="utf-8") + + result = runner.invoke(cli, ["connect", "run", str(script), "--profile", "large"]) + + assert result.exit_code != 0 + assert "supports only the 'standard' profile" in result.output + + +def test_run_rejects_unsupported_files(runner, tmp_path): + program = tmp_path / "hello.txt" + program.write_text("hello\n", encoding="utf-8") + + result = runner.invoke(cli, ["connect", "run", str(program)]) + + assert result.exit_code != 0 + assert "PATH must be a Python or R file ending in .py or .R" in result.output + + +def test_wrapper_uses_json_encoded_program_arguments(): + source = _wrapper_source(("Ada Lovelace", 'quote "this"')) + + assert 'PROGRAM_ARGS = ["Ada Lovelace", "quote \\"this\\""]' in source + assert "from flask" not in source + assert "def app(_environ, start_response):" in source + assert "subprocess.run(" in source + + +def test_r_wrapper_uses_rscript_and_encoded_program_arguments(): + source = _r_wrapper_source(("Ada Lovelace", 'quote "this"')) + + assert 'PROGRAM_ARGS <- c("Ada Lovelace", "quote \\"this\\"")' in source + assert 'file.path(R.home("bin"), "Rscript")' in source + assert "plumber::serializer_text()" in source + assert "#* @get /" in source + + +def test_build_bundle_uses_standard_python_api_manifest(tmp_path): + script = tmp_path / "hello.py" + script.write_text("print('hello')\n", encoding="utf-8") + observed = {} + + def fake_make_api_bundle(directory, entrypoint, app_mode, environment, extra_files, excludes): + root = Path(directory) + observed["files"] = sorted(path.name for path in root.iterdir()) + observed["requirements"] = (root / "requirements.txt").read_text(encoding="utf-8") + observed["wrapper"] = (root / "runner.py").read_text(encoding="utf-8") + observed["directory"] = directory + observed["entrypoint"] = entrypoint + observed["app_mode"] = app_mode + observed["environment"] = environment + observed["extra_files"] = extra_files + observed["excludes"] = excludes + return io.BytesIO(b"bundle") + + with patch( + "posit_cli.connect.run.Environment.create_python_environment", + return_value="environment", + ), patch("posit_cli.connect.run.make_api_bundle", side_effect=fake_make_api_bundle): + bundle = _build_bundle(script, ("arg",), "python3.12") + + assert bundle.read() == b"bundle" + assert observed["entrypoint"] == "runner:app" + assert observed["app_mode"] is AppModes.PYTHON_API + assert observed["environment"] == "environment" + assert observed["extra_files"] == [] + assert observed["excludes"] == [] + assert observed["requirements"] == "" + assert "from flask" not in observed["wrapper"] + + +def test_build_r_bundle_uses_plumber_manifest(tmp_path): + script = tmp_path / "hello.R" + script.write_text('cat("hello from R\\n")\n', encoding="utf-8") + + bundle = _build_r_bundle(script, ("arg",), "r4.5") + with tarfile.open(fileobj=bundle, mode="r:gz") as archive: + manifest = json.load(archive.extractfile("manifest.json")) + assert sorted(archive.getnames()) == [ + "__posit_connect_run_program.R", + "manifest.json", + "plumber.R", + ] + assert manifest["metadata"]["appmode"] == "api" + assert manifest["platform"] == "4.5" + assert manifest["packages"]["plumber"]["Source"] == "CRAN" + assert manifest["packages"]["plumber"]["description"]["Version"] == "1.3.3" + assert "httpuv" in manifest["packages"]["plumber"]["description"]["Imports"] + assert 'PROGRAM_ARGS <- c("arg")' in archive.extractfile("plumber.R").read().decode() + + +def test_build_r_bundle_defaults_to_local_r_version(tmp_path): + script = tmp_path / "hello.R" + script.write_text('cat("hello from R\\n")\n', encoding="utf-8") + + with patch("posit_cli.connect.run._local_r_runtime_version", return_value="4.5"): + bundle = _build_r_bundle(script, (), None) + + with tarfile.open(fileobj=bundle, mode="r:gz") as archive: + manifest = json.load(archive.extractfile("manifest.json")) + + assert manifest["platform"] == "4.5" From 42d7e033b8f65deddeae19599fa1a5efe3e3d854 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 13:52:10 -0400 Subject: [PATCH 2/9] feat: run R content through rpy2 --- README.md | 10 +- skills/posit-cli/SKILL.md | 8 +- src/posit_cli/connect/run.py | 229 +++++++++++++---------------------- tests/test_run.py | 38 +++--- 4 files changed, 117 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 6327643..92908f2 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,9 @@ That's it — from here, explore `posit connect --help` for the full command set The initial proof of concept accepts one Python or R source file and runs it through Connect's existing content APIs. Python uses a zero-dependency WSGI -adapter; R uses a temporary Plumber API. Both are deployed, invoked once, -printed, and removed: +adapter; R uses a temporary Python API backed by +[rpy2](https://rpy2.github.io/). Both are deployed, invoked once, printed, and +removed: ```console $ posit connect run hello.py @@ -87,8 +88,9 @@ https://connect.example.com/content/... This compatibility path supports only the `standard` profile. R programs use the local R major/minor version by default; `--runtime r4.5` can override the -version constraint. The adapters are deliberately temporary until Connect -exposes a native execution API. +version constraint. The Connect administrator must enable +`[Python] Flag = rpy2-cffi-mode-auto` for rpy2 content. The adapters are +deliberately temporary until Connect exposes a native execution API. ## Authentication diff --git a/skills/posit-cli/SKILL.md b/skills/posit-cli/SKILL.md index 3b40d7e..145cae1 100644 --- a/skills/posit-cli/SKILL.md +++ b/skills/posit-cli/SKILL.md @@ -86,12 +86,14 @@ posit connect run hello.R --runtime r4.5 ``` It creates content with `POST /v1/content`, uploads either a zero-dependency -Python WSGI API bundle or an R Plumber API bundle with its Packrat dependency -metadata to +Python WSGI API bundle or a Python API bundle that runs R through `rpy2` with +its R runtime metadata to `/v1/content/{guid}/bundles`, deploys it with `POST /v1/content/{guid}/deploy`, invokes the content URL, prints the captured output, and deletes the temporary content. `--detach` leaves the deployed -content in place and prints its URL. Directories, native batch jobs, resource +content in place and prints its URL. R programs are submitted as Python API +content using `rpy2`; the Connect administrator must enable +`[Python] Flag = rpy2-cffi-mode-auto`. Directories, native batch jobs, resource overrides, and artifact pulling are not implemented yet. ## `posit connect api` — the raw REST client diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py index d6c474f..d998b94 100644 --- a/src/posit_cli/connect/run.py +++ b/src/posit_cli/connect/run.py @@ -2,102 +2,30 @@ from __future__ import annotations -import io import json import shutil import subprocess -import tarfile import tempfile import uuid from pathlib import Path -from typing import Any, BinaryIO, Dict, Optional, Tuple +from typing import Any, BinaryIO, Optional, Tuple import click from rsconnect.api import RSConnectException, RSConnectExecutor -from rsconnect.bundle import buffer_checksum, make_api_bundle +from rsconnect.bundle import make_api_bundle from rsconnect.environment import Environment +from rsconnect.environment_r import REnvironment from rsconnect.http_support import HTTPResponse, HTTPServer from rsconnect.models import AppModes -_R_PACKAGE_REPOSITORY = "https://cran.rstudio.com/" - -# Connect's legacy R builder converts manifest packages into a Packrat lockfile -# before launching Plumber. Keep this small dependency closure in the bundle -# until the native run API can accept an execution artifact directly. -_R_PACKAGE_SPECS = { - "cli": ("3.6.6", {"Depends": "R (>= 3.4)", "Imports": "utils"}), - "crayon": ("1.5.3", {"Imports": "grDevices, methods, utils"}), - "curl": ("7.1.0", {"Depends": "R (>= 3.0.0)"}), - "fastmap": ("1.2.0", {}), - "httpuv": ( - "1.6.17", - { - "Depends": "R (>= 2.15.1)", - "Imports": "later (>= 0.8.0), promises, R6, Rcpp (>= 1.0.7), utils", - "LinkingTo": "later, Rcpp", - }, - ), - "jsonlite": ("2.0.0", {"Imports": "methods"}), - "later": ( - "1.4.8", - {"Imports": "Rcpp (>= 1.0.10), rlang", "LinkingTo": "Rcpp"}, - ), - "lifecycle": ( - "1.0.5", - {"Depends": "R (>= 3.6)", "Imports": "cli (>= 3.4.0), rlang (>= 1.1.0)"}, - ), - "magrittr": ("2.0.5", {"Depends": "R (>= 3.4.0)"}), - "mime": ("0.13", {"Imports": "tools"}), - "otel": ("0.2.0", {"Depends": "R (>= 3.6.0)"}), - "plumber": ( - "1.3.3", - { - "Depends": "R (>= 3.0.0)", - "Imports": ( - "crayon, httpuv (>= 1.5.5), jsonlite (>= 0.9.16), " - "lifecycle (>= 1.0.0), magrittr, mime, promises (>= 1.1.0), " - "R6 (>= 2.0.0), rlang (>= 1.0.0), sodium, stringi (>= 0.3.0), " - "swagger (>= 3.33.0), webutils (>= 1.1)" - ), - }, - ), - "promises": ( - "1.5.0", - { - "Depends": "R (>= 4.1.0)", - "Imports": ( - "fastmap (>= 1.1.0), later, lifecycle, magrittr (>= 1.5), " - "otel (>= 0.2.0), R6, rlang" - ), - }, - ), - "R6": ("2.6.1", {"Depends": "R (>= 3.6)"}), - "Rcpp": ("1.1.2", {"Depends": "R (>= 3.5.0)", "Imports": "methods, utils"}), - "rlang": ("1.3.0", {"Depends": "R (>= 4.0.0)", "Imports": "utils"}), - "sodium": ("1.4.0", {}), - "stringi": ( - "1.8.7", - {"Depends": "R (>= 3.4)", "Imports": "tools, utils, stats"}, - ), - "swagger": ("5.32.1", {}), - "webutils": ("1.2.3", {"Imports": "curl (>= 2.5), jsonlite"}), -} - - -def _r_package_manifest() -> Dict[str, Dict[str, Any]]: - return { - name: { - "Source": "CRAN", - "Repository": _R_PACKAGE_REPOSITORY, - "description": { - "Package": name, - "Version": version, - **description, - }, - } - for name, (version, description) in _R_PACKAGE_SPECS.items() - } +_RPY2_REQUIREMENT = "rpy2" +_PYTHON_PROJECT_METADATA = """\ +[project] +name = "posit-connect-run" +version = "0.0.0" +requires-python = ">=3.8" +""" def _python_runtime_version(runtime: Optional[str]) -> Optional[str]: @@ -204,44 +132,65 @@ def app(_environ, start_response): """ -def _r_string_vector(values: Tuple[str, ...]) -> str: - if not values: - return "character()" - return "c(" + ", ".join(json.dumps(value) for value in values) + ")" +def _rpy2_wrapper_source(program_args: Tuple[str, ...]) -> str: + """Create the WSGI adapter that evaluates the R program through rpy2.""" + encoded_args = json.dumps(list(program_args)) + return f'''\ +import contextlib +import io +PROGRAM = "__posit_connect_run_program.R" +PROGRAM_ARGS = {encoded_args} -def _r_wrapper_source(program_args: Tuple[str, ...]) -> str: - """Create the small Plumber adapter used by the legacy content API.""" - encoded_args = _r_string_vector(program_args) - return f"""\ -PROGRAM <- "__posit_connect_run_program.R" -PROGRAM_ARGS <- {encoded_args} - - -#* @get / -run_program <- function(res) {{ - res$serializer <- plumber::serializer_text() - output <- suppressWarnings(system2( - file.path(R.home("bin"), "Rscript"), - c("--vanilla", PROGRAM, PROGRAM_ARGS), - stdout = TRUE, - stderr = TRUE - )) - exit_code <- attr(output, "status", exact = TRUE) - if (is.null(exit_code)) exit_code <- 0L - if (length(output) == 0) output <- "" - if (exit_code != 0) res$status <- 500L - paste(output, collapse = "\\n") -}} -""" +def _run_program(): + import logging -def _add_bundle_file(archive: tarfile.TarFile, name: str, content: bytes) -> None: - info = tarfile.TarInfo(name=name) - info.mode = 0o644 - info.mtime = 0 - info.size = len(content) - archive.addfile(info, io.BytesIO(content)) + previous_logging_disable = logging.root.manager.disable + logging.disable(logging.CRITICAL) + try: + import rpy2.robjects as robjects + + run_file = robjects.r( + """ + function(path, args) {{ + execution_env <- new.env(parent = globalenv()) + execution_env[["commandArgs"]] <- function(trailingOnly = FALSE) {{ + if (trailingOnly) args else c("R", "--args", args) + }} + oldwd <- getwd() + on.exit(setwd(oldwd), add = TRUE) + setwd(dirname(path)) + sys.source(basename(path), envir = execution_env) + }} + """ + ) + run_file(PROGRAM, robjects.StrVector(PROGRAM_ARGS)) + finally: + logging.disable(previous_logging_disable) + + +def app(_environ, start_response): + stdout = io.StringIO() + stderr = io.StringIO() + status = "200 OK" + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + _run_program() + except Exception as error: + status = "500 Internal Server Error" + stderr.write(f"{{type(error).__name__}}: {{error}}\\n") + + output = (stdout.getvalue() + stderr.getvalue()).encode("utf-8", errors="replace") + start_response( + status, + [ + ("Content-Type", "text/plain; charset=utf-8"), + ("Content-Length", str(len(output))), + ], + ) + return [output] +''' def _build_python_bundle( @@ -254,6 +203,7 @@ def _build_python_bundle( root = Path(directory) (root / "__posit_connect_run_program.py").write_bytes(path.read_bytes()) (root / "runner.py").write_text(_wrapper_source(program_args), encoding="utf-8") + (root / "pyproject.toml").write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") (root / "requirements.txt").write_text("", encoding="utf-8") environment = Environment.create_python_environment( @@ -271,40 +221,25 @@ def _build_python_bundle( def _build_r_bundle(path: Path, program_args: Tuple[str, ...], runtime: Optional[str]) -> BinaryIO: - """Build a minimal Plumber API bundle containing the R program and adapter.""" + """Build a Python API bundle that runs the R program through rpy2.""" with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: root = Path(directory) (root / "__posit_connect_run_program.R").write_bytes(path.read_bytes()) - (root / "plumber.R").write_text(_r_wrapper_source(program_args), encoding="utf-8") - - program_bytes = (root / "__posit_connect_run_program.R").read_bytes() - wrapper_bytes = (root / "plumber.R").read_bytes() - manifest: Dict[str, Any] = { - "version": 1, - "locale": "en_US", - "metadata": { - "appmode": "api", - "primary_rmd": None, - "primary_html": None, - "content_category": None, - "has_parameters": False, - }, - "files": { - "__posit_connect_run_program.R": {"checksum": buffer_checksum(program_bytes)}, - "plumber.R": {"checksum": buffer_checksum(wrapper_bytes)}, - }, - } - manifest["platform"] = _r_manifest_version(runtime) - manifest["packages"] = _r_package_manifest() - - manifest_bytes = json.dumps(manifest, indent=2).encode("utf-8") - bundle = io.BytesIO() - with tarfile.open(fileobj=bundle, mode="w:gz") as archive: - _add_bundle_file(archive, "manifest.json", manifest_bytes) - _add_bundle_file(archive, "__posit_connect_run_program.R", program_bytes) - _add_bundle_file(archive, "plumber.R", wrapper_bytes) - bundle.seek(0) - return bundle + (root / "runner.py").write_text(_rpy2_wrapper_source(program_args), encoding="utf-8") + (root / "pyproject.toml").write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") + (root / "requirements.txt").write_text(f"{_RPY2_REQUIREMENT}\n", encoding="utf-8") + + environment = Environment.create_python_environment(directory) + r_environment = REnvironment(r_version=_r_manifest_version(runtime), packages={}) + return make_api_bundle( + directory, + "runner:app", + AppModes.PYTHON_API, + environment, + extra_files=[], + excludes=[], + r_environment=r_environment, + ) def _build_bundle(path: Path, program_args: Tuple[str, ...], runtime: Optional[str]) -> BinaryIO: diff --git a/tests/test_run.py b/tests/test_run.py index 784ced6..30d7712 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -14,7 +14,7 @@ from posit_cli.connect.run import ( _build_bundle, _build_r_bundle, - _r_wrapper_source, + _rpy2_wrapper_source, _wrapper_source, ) from rsconnect.models import AppModes @@ -152,13 +152,16 @@ def test_wrapper_uses_json_encoded_program_arguments(): assert "subprocess.run(" in source -def test_r_wrapper_uses_rscript_and_encoded_program_arguments(): - source = _r_wrapper_source(("Ada Lovelace", 'quote "this"')) +def test_r_wrapper_uses_rpy2_and_encoded_program_arguments(): + source = _rpy2_wrapper_source(("Ada Lovelace", 'quote "this"')) - assert 'PROGRAM_ARGS <- c("Ada Lovelace", "quote \\"this\\"")' in source - assert 'file.path(R.home("bin"), "Rscript")' in source - assert "plumber::serializer_text()" in source - assert "#* @get /" in source + assert 'PROGRAM_ARGS = ["Ada Lovelace", "quote \\"this\\""]' in source + assert "import rpy2.robjects as robjects" in source + assert "sys.source(basename(path), envir = execution_env)" in source + assert "commandArgs" in source + assert "logging.disable(logging.CRITICAL)" in source + assert "logging.disable(previous_logging_disable)" in source + assert "plumber" not in source def test_build_bundle_uses_standard_python_api_manifest(tmp_path): @@ -195,7 +198,7 @@ def fake_make_api_bundle(directory, entrypoint, app_mode, environment, extra_fil assert "from flask" not in observed["wrapper"] -def test_build_r_bundle_uses_plumber_manifest(tmp_path): +def test_build_r_bundle_uses_rpy2_python_manifest(tmp_path): script = tmp_path / "hello.R" script.write_text('cat("hello from R\\n")\n', encoding="utf-8") @@ -205,14 +208,21 @@ def test_build_r_bundle_uses_plumber_manifest(tmp_path): assert sorted(archive.getnames()) == [ "__posit_connect_run_program.R", "manifest.json", - "plumber.R", + "pyproject.toml", + "requirements.txt", + "runner.py", ] - assert manifest["metadata"]["appmode"] == "api" + assert manifest["metadata"]["appmode"] == "python-api" + assert manifest["metadata"]["entrypoint"] == "runner:app" assert manifest["platform"] == "4.5" - assert manifest["packages"]["plumber"]["Source"] == "CRAN" - assert manifest["packages"]["plumber"]["description"]["Version"] == "1.3.3" - assert "httpuv" in manifest["packages"]["plumber"]["description"]["Imports"] - assert 'PROGRAM_ARGS <- c("arg")' in archive.extractfile("plumber.R").read().decode() + assert manifest["packages"] == {} + assert manifest["environment"]["python"]["requires"] == ">=3.8" + assert ( + archive.extractfile("pyproject.toml").read().decode() + == '[project]\nname = "posit-connect-run"\nversion = "0.0.0"\nrequires-python = ">=3.8"\n' + ) + assert archive.extractfile("requirements.txt").read().decode() == "rpy2\n" + assert 'PROGRAM_ARGS = ["arg"]' in archive.extractfile("runner.py").read().decode() def test_build_r_bundle_defaults_to_local_r_version(tmp_path): From d43338d3579686c0dcc5b055349209011a21eeba Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 14:04:34 -0400 Subject: [PATCH 3/9] refactor: decouple connect run execution --- src/posit_cli/connect/run.py | 447 ++++++++++++++++++++++++----------- 1 file changed, 308 insertions(+), 139 deletions(-) diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py index d998b94..5a72f2e 100644 --- a/src/posit_cli/connect/run.py +++ b/src/posit_cli/connect/run.py @@ -7,8 +7,9 @@ import subprocess import tempfile import uuid +from dataclasses import dataclass from pathlib import Path -from typing import Any, BinaryIO, Optional, Tuple +from typing import BinaryIO, Callable, Optional, Protocol, Tuple import click from rsconnect.api import RSConnectException, RSConnectExecutor @@ -20,6 +21,9 @@ _RPY2_REQUIREMENT = "rpy2" +_PYTHON_PROGRAM_FILENAME = "__posit_connect_run_program.py" +_R_PROGRAM_FILENAME = "__posit_connect_run_program.R" +_RUNNER_ENTRYPOINT = "runner:app" _PYTHON_PROJECT_METADATA = """\ [project] name = "posit-connect-run" @@ -27,43 +31,107 @@ requires-python = ">=3.8" """ +ProgramArguments = Tuple[str, ...] -def _python_runtime_version(runtime: Optional[str]) -> Optional[str]: - """Translate ``python`` or ``pythonX.Y`` into a manifest version.""" - if runtime is None or runtime == "python": + +class _ConnectClient(Protocol): + """The subset of the Connect client used by the run operation.""" + + def content_create(self, name: str) -> object: ... + + def upload_bundle(self, content_guid: str, bundle: BinaryIO) -> object: ... + + def content_deploy(self, content_guid: str, *, bundle_id: str) -> object: ... + + def wait_for_task( + self, + task_id: str, + *, + log_callback: Optional[Callable[..., object]], + raise_on_error: bool, + ) -> Tuple[object, object]: ... + + def delete(self, path: str, *, decode_response: bool) -> object: ... + + +class _ConnectExecutor(Protocol): + client: _ConnectClient + + def setup_client(self) -> None: ... + + +BundleBuilder = Callable[[Path, ProgramArguments, Optional[str]], BinaryIO] +ExecutorFactory = Callable[..., _ConnectExecutor] +ContentInvoker = Callable[[_ConnectClient, str], HTTPResponse] +ContentDeleter = Callable[[_ConnectClient, str], None] + + +@dataclass(frozen=True) +class _RunRequest: + path: Path + program_args: ProgramArguments + runtime: Optional[str] + job_name: Optional[str] + detach: bool + server_name: Optional[str] + server: Optional[str] + api_key: Optional[str] + insecure: bool + cacert: Optional[str] + + +@dataclass(frozen=True) +class _RunDependencies: + executor_factory: ExecutorFactory + bundle_builder: BundleBuilder + content_invoker: ContentInvoker + content_deleter: ContentDeleter + + +def _is_version(value: str) -> bool: + return bool(value) and all(character.isdigit() or character == "." for character in value) + + +def _runtime_version( + runtime: Optional[str], + *, + prefix: str, + description: str, + case_sensitive: bool, +) -> Optional[str]: + if runtime is None: return None - if not runtime.startswith("python"): - raise click.BadParameter( - "runtime must be 'python' or a Python version such as 'python3.12'", - param_hint="--runtime", - ) - version = runtime[len("python") :] - if not version or any(not (character.isdigit() or character == ".") for character in version): - raise click.BadParameter( - "runtime must be 'python' or a Python version such as 'python3.12'", - param_hint="--runtime", - ) + normalized_runtime = runtime if case_sensitive else runtime.lower() + if normalized_runtime == prefix: + return None + if not normalized_runtime.startswith(prefix): + raise click.BadParameter(description, param_hint="--runtime") + + version = runtime[len(prefix) :] + if not _is_version(version): + raise click.BadParameter(description, param_hint="--runtime") return version +def _python_runtime_version(runtime: Optional[str]) -> Optional[str]: + """Translate ``python`` or ``pythonX.Y`` into a manifest version.""" + return _runtime_version( + runtime, + prefix="python", + description="runtime must be 'python' or a Python version such as 'python3.12'", + case_sensitive=True, + ) + + def _r_runtime_version(runtime: Optional[str]) -> Optional[str]: """Translate ``r`` or ``rX.Y`` into an R manifest version.""" - if runtime is None or runtime.lower() == "r": - return None - if not runtime.lower().startswith("r"): - raise click.BadParameter( - "runtime must be 'r' or an R version such as 'r4.5'", - param_hint="--runtime", - ) - - version = runtime[1:] - if not version or any(not (character.isdigit() or character == ".") for character in version): - raise click.BadParameter( - "runtime must be 'r' or an R version such as 'r4.5'", - param_hint="--runtime", - ) - return version + return _runtime_version( + runtime, + prefix="r", + description="runtime must be 'r' or an R version such as 'r4.5'", + case_sensitive=False, + ) def _local_r_runtime_version() -> str: @@ -99,7 +167,7 @@ def _r_manifest_version(runtime: Optional[str]) -> str: return _r_runtime_version(runtime) or _local_r_runtime_version() -def _wrapper_source(program_args: Tuple[str, ...]) -> str: +def _wrapper_source(program_args: ProgramArguments) -> str: """Create the small WSGI adapter used by the legacy content API.""" encoded_args = json.dumps(list(program_args)) return f"""\ @@ -107,7 +175,7 @@ def _wrapper_source(program_args: Tuple[str, ...]) -> str: import subprocess import sys -PROGRAM = "__posit_connect_run_program.py" +PROGRAM = "{_PYTHON_PROGRAM_FILENAME}" PROGRAM_ARGS = {encoded_args} @@ -132,14 +200,14 @@ def app(_environ, start_response): """ -def _rpy2_wrapper_source(program_args: Tuple[str, ...]) -> str: +def _rpy2_wrapper_source(program_args: ProgramArguments) -> str: """Create the WSGI adapter that evaluates the R program through rpy2.""" encoded_args = json.dumps(list(program_args)) return f'''\ import contextlib import io -PROGRAM = "__posit_connect_run_program.R" +PROGRAM = "{_R_PROGRAM_FILENAME}" PROGRAM_ARGS = {encoded_args} @@ -193,68 +261,118 @@ def app(_environ, start_response): ''' +def _write_bundle_files( + root: Path, + source_path: Path, + program_filename: str, + wrapper_source: str, + requirements: str, +) -> None: + (root / program_filename).write_bytes(source_path.read_bytes()) + (root / "runner.py").write_text(wrapper_source, encoding="utf-8") + (root / "pyproject.toml").write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") + (root / "requirements.txt").write_text(requirements, encoding="utf-8") + + +def _make_api_bundle( + directory: str, + environment: object, + r_environment: Optional[object] = None, +) -> BinaryIO: + bundle_options = { + "extra_files": [], + "excludes": [], + } + if r_environment is not None: + bundle_options["r_environment"] = r_environment + + return make_api_bundle( + directory, + _RUNNER_ENTRYPOINT, + AppModes.PYTHON_API, + environment, + **bundle_options, + ) + + def _build_python_bundle( path: Path, - program_args: Tuple[str, ...], + program_args: ProgramArguments, runtime: Optional[str], ) -> BinaryIO: """Build a normal Python API bundle containing the program and adapter.""" with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: root = Path(directory) - (root / "__posit_connect_run_program.py").write_bytes(path.read_bytes()) - (root / "runner.py").write_text(_wrapper_source(program_args), encoding="utf-8") - (root / "pyproject.toml").write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") - (root / "requirements.txt").write_text("", encoding="utf-8") + _write_bundle_files( + root, + path, + _PYTHON_PROGRAM_FILENAME, + _wrapper_source(program_args), + "", + ) environment = Environment.create_python_environment( directory, override_python_version=_python_runtime_version(runtime), ) - return make_api_bundle( - directory, - "runner:app", - AppModes.PYTHON_API, - environment, - extra_files=[], - excludes=[], - ) + return _make_api_bundle(directory, environment) -def _build_r_bundle(path: Path, program_args: Tuple[str, ...], runtime: Optional[str]) -> BinaryIO: +def _build_r_bundle( + path: Path, + program_args: ProgramArguments, + runtime: Optional[str], +) -> BinaryIO: """Build a Python API bundle that runs the R program through rpy2.""" with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: root = Path(directory) - (root / "__posit_connect_run_program.R").write_bytes(path.read_bytes()) - (root / "runner.py").write_text(_rpy2_wrapper_source(program_args), encoding="utf-8") - (root / "pyproject.toml").write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") - (root / "requirements.txt").write_text(f"{_RPY2_REQUIREMENT}\n", encoding="utf-8") + _write_bundle_files( + root, + path, + _R_PROGRAM_FILENAME, + _rpy2_wrapper_source(program_args), + f"{_RPY2_REQUIREMENT}\n", + ) environment = Environment.create_python_environment(directory) r_environment = REnvironment(r_version=_r_manifest_version(runtime), packages={}) - return make_api_bundle( - directory, - "runner:app", - AppModes.PYTHON_API, - environment, - extra_files=[], - excludes=[], - r_environment=r_environment, - ) + return _make_api_bundle(directory, environment, r_environment) -def _build_bundle(path: Path, program_args: Tuple[str, ...], runtime: Optional[str]) -> BinaryIO: +def _build_bundle(path: Path, program_args: ProgramArguments, runtime: Optional[str]) -> BinaryIO: if path.suffix.lower() == ".r": return _build_r_bundle(path, program_args, runtime) return _build_python_bundle(path, program_args, runtime) def _content_name(path: Path, job_name: Optional[str]) -> str: - if job_name: - return job_name - return f"posit-connect-run-{path.stem}-{uuid.uuid4().hex[:12]}" + return job_name or f"posit-connect-run-{path.stem}-{uuid.uuid4().hex[:12]}" + + +def _content_field(content: object, field: str, error_message: str) -> str: + if not isinstance(content, dict): + raise click.ClickException(error_message) + + value = content.get(field) + if not isinstance(value, str) or not value: + raise click.ClickException(error_message) + return value -def _wait_for_deployment(client: Any, deployment: Any) -> None: +def _deploy_content( + client: _ConnectClient, + request: _RunRequest, + content_guid: str, + bundle_builder: BundleBuilder, +) -> None: + bundle = bundle_builder(request.path, request.program_args, request.runtime) + uploaded = client.upload_bundle(content_guid, bundle) + bundle_id = _content_field(uploaded, "id", "Connect returned no bundle ID.") + deployment = client.content_deploy(content_guid, bundle_id=bundle_id) + _wait_for_deployment(client, deployment) + + +def _wait_for_deployment(client: _ConnectClient, deployment: object) -> None: if not isinstance(deployment, dict): raise click.ClickException("Connect returned an invalid deployment response.") @@ -272,7 +390,7 @@ def _wait_for_deployment(client: Any, deployment: Any) -> None: raise click.ClickException(f"deployment failed: {detail}") -def _content_response(client: Any, content_url: str) -> HTTPResponse: +def _content_response(client: _ConnectClient, content_url: str) -> HTTPResponse: """Invoke the deployed content URL with the same auth and TLS settings.""" app_server = HTTPServer( content_url, @@ -300,13 +418,118 @@ def _emit_response(response: HTTPResponse) -> None: click.echo(text, nl=not text.endswith("\n")) -def _delete_content(client: Any, content_guid: str) -> None: +def _ensure_successful_response(response: HTTPResponse) -> None: + if response.exception: + raise click.ClickException(f"running content failed: {response.exception}") + + status = getattr(response, "status", None) + if not isinstance(status, int) or not 200 <= status < 300: + raise click.exceptions.Exit(1) + + +def _delete_content(client: _ConnectClient, content_guid: str) -> None: response = client.delete(f"v1/content/{content_guid}", decode_response=False) if isinstance(response, HTTPResponse): if response.exception: raise RSConnectException(str(response.exception)) - if not 200 <= response.status < 300: - raise RSConnectException(f"HTTP {response.status} {response.reason}".rstrip()) + status = getattr(response, "status", None) + if not isinstance(status, int) or not 200 <= status < 300: + reason = getattr(response, "reason", "") + raise RSConnectException(f"HTTP {status} {reason}".rstrip()) + + +def _cleanup_content( + executor: Optional[_ConnectExecutor], + content_guid: Optional[str], + detach: bool, + content_deleter: ContentDeleter, +) -> None: + if executor is None or content_guid is None or detach: + return + + try: + content_deleter(executor.client, content_guid) + except Exception as exc: + # Cleanup is best effort and must not hide the command's result. + click.echo( + f"Warning: unable to delete temporary content {content_guid}: {exc}", + err=True, + ) + + +def _validate_run_options(path: Path, profile: str, runtime: Optional[str]) -> None: + suffix = path.suffix.lower() + if suffix not in {".py", ".r"}: + raise click.BadParameter( + "PATH must be a Python or R file ending in .py or .R", + param_hint="PATH", + ) + if profile != "standard": + raise click.BadParameter( + "the legacy compatibility path supports only the 'standard' profile", + param_hint="--profile", + ) + if suffix == ".py": + _python_runtime_version(runtime) + else: + _r_runtime_version(runtime) + + +def _default_run_dependencies() -> _RunDependencies: + """Assemble concrete adapters at the CLI composition root.""" + return _RunDependencies( + executor_factory=RSConnectExecutor, + bundle_builder=_build_bundle, + content_invoker=_content_response, + content_deleter=_delete_content, + ) + + +def _execute_run(request: _RunRequest, dependencies: _RunDependencies) -> None: + executor: Optional[_ConnectExecutor] = None + content_guid: Optional[str] = None + try: + executor = dependencies.executor_factory( + ctx=None, + name=request.server_name, + url=request.server, + api_key=request.api_key, + insecure=request.insecure, + cacert=request.cacert, + ) + executor.setup_client() + + client = executor.client + content = client.content_create(_content_name(request.path, request.job_name)) + content_guid = _content_field( + content, + "guid", + "Connect returned no content GUID.", + ) + content_url = _content_field( + content, + "content_url", + "Connect returned no content URL.", + ) + + _deploy_content(client, request, content_guid, dependencies.bundle_builder) + + if request.detach: + click.echo(content_url) + return + + response = dependencies.content_invoker(client, content_url) + _emit_response(response) + _ensure_successful_response(response) + except RSConnectException as exc: + raise click.ClickException(str(exc)) from exc + finally: + _cleanup_content( + executor, + content_guid, + request.detach, + dependencies.content_deleter, + ) @click.command( @@ -371,7 +594,7 @@ def _delete_content(client: Any, content_guid: str) -> None: ) def run( path: Path, - program_args: Tuple[str, ...], + program_args: ProgramArguments, profile: str, runtime: Optional[str], job_name: Optional[str], @@ -387,71 +610,17 @@ def run( PATH must be a single ``.py`` or ``.R`` source file. Arguments after ``--`` are passed to the submitted program. """ - suffix = path.suffix.lower() - if suffix not in {".py", ".r"}: - raise click.BadParameter( - "PATH must be a Python or R file ending in .py or .R", - param_hint="PATH", - ) - if profile != "standard": - raise click.BadParameter( - "the legacy compatibility path supports only the 'standard' profile", - param_hint="--profile", - ) - if suffix == ".py": - _python_runtime_version(runtime) - else: - _r_runtime_version(runtime) - - executor: Optional[RSConnectExecutor] = None - content_guid: Optional[str] = None - try: - executor = RSConnectExecutor( - ctx=None, - name=server_name, - url=server, - api_key=api_key, - insecure=insecure, - cacert=cacert, - ) - executor.setup_client() - - client = executor.client - content = client.content_create(_content_name(path, job_name)) - content_guid = content.get("guid") if isinstance(content, dict) else None - content_url = content.get("content_url") if isinstance(content, dict) else None - if not isinstance(content_guid, str) or not content_guid: - raise click.ClickException("Connect returned no content GUID.") - if not isinstance(content_url, str) or not content_url: - raise click.ClickException("Connect returned no content URL.") - - bundle = _build_bundle(path, program_args, runtime) - uploaded = client.upload_bundle(content_guid, bundle) - bundle_id = uploaded.get("id") if isinstance(uploaded, dict) else None - if not isinstance(bundle_id, str) or not bundle_id: - raise click.ClickException("Connect returned no bundle ID.") - - deployment = client.content_deploy(content_guid, bundle_id=bundle_id) - _wait_for_deployment(client, deployment) - - if detach: - click.echo(content_url) - return - - response = _content_response(client, content_url) - _emit_response(response) - if response.exception: - raise click.ClickException(f"running content failed: {response.exception}") - if not 200 <= response.status < 300: - raise click.exceptions.Exit(1) - except RSConnectException as exc: - raise click.ClickException(str(exc)) from exc - finally: - if content_guid and not detach and executor is not None: - try: - _delete_content(executor.client, content_guid) - except Exception as exc: - click.echo( - f"Warning: unable to delete temporary content {content_guid}: {exc}", - err=True, - ) + _validate_run_options(path, profile, runtime) + request = _RunRequest( + path=path, + program_args=program_args, + runtime=runtime, + job_name=job_name, + detach=detach, + server_name=server_name, + server=server, + api_key=api_key, + insecure=insecure, + cacert=cacert, + ) + _execute_run(request, _default_run_dependencies()) From 74691fc93bd9fd88cd6d2900872d4d743967d3b4 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 14:07:49 -0400 Subject: [PATCH 4/9] refactor: remove unused run profile option --- README.md | 5 ++--- skills/posit-cli/SKILL.md | 5 ++--- src/posit_cli/connect/run.py | 16 ++-------------- tests/test_run.py | 10 ---------- 4 files changed, 6 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 92908f2..0694552 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,8 @@ $ posit connect run hello.py -- --name Ada https://connect.example.com/content/... ``` -This compatibility path supports only the `standard` profile. R programs use -the local R major/minor version by default; `--runtime r4.5` can override the -version constraint. The Connect administrator must enable +R programs use the local R major/minor version by default; `--runtime r4.5` can +override the version constraint. The Connect administrator must enable `[Python] Flag = rpy2-cffi-mode-auto` for rpy2 content. The adapters are deliberately temporary until Connect exposes a native execution API. diff --git a/skills/posit-cli/SKILL.md b/skills/posit-cli/SKILL.md index 145cae1..8a22630 100644 --- a/skills/posit-cli/SKILL.md +++ b/skills/posit-cli/SKILL.md @@ -23,9 +23,8 @@ which has two halves: Connect API. This is your primary tool for anything that isn't a deploy: reading and writing content, users, groups, tags, environments, audit logs, etc. - **`posit connect run `** — execute one Python or R source file through a - temporary Connect API. The compatibility implementation supports the - `standard` profile, optional runtime selection, script arguments, and - `--detach`. + temporary Connect API. The compatibility implementation supports optional + runtime selection, script arguments, and `--detach`. - **The full `rsconnect` command set** (`login`, `deploy`, `content`, `system`, `add`, `list`, ...) is mounted under `posit connect`, so those come for free and track [rsconnect-python](https://github.com/posit-dev/rsconnect-python) upstream. diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py index 5a72f2e..e840542 100644 --- a/src/posit_cli/connect/run.py +++ b/src/posit_cli/connect/run.py @@ -457,18 +457,13 @@ def _cleanup_content( ) -def _validate_run_options(path: Path, profile: str, runtime: Optional[str]) -> None: +def _validate_run_options(path: Path, runtime: Optional[str]) -> None: suffix = path.suffix.lower() if suffix not in {".py", ".r"}: raise click.BadParameter( "PATH must be a Python or R file ending in .py or .R", param_hint="PATH", ) - if profile != "standard": - raise click.BadParameter( - "the legacy compatibility path supports only the 'standard' profile", - param_hint="--profile", - ) if suffix == ".py": _python_runtime_version(runtime) else: @@ -542,12 +537,6 @@ def _execute_run(request: _RunRequest, dependencies: _RunDependencies) -> None: type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), ) @click.argument("program_args", nargs=-1, type=click.UNPROCESSED) -@click.option( - "--profile", - default="standard", - show_default=True, - help="Dispatch profile. The legacy compatibility path supports standard only.", -) @click.option( "--runtime", default=None, @@ -595,7 +584,6 @@ def _execute_run(request: _RunRequest, dependencies: _RunDependencies) -> None: def run( path: Path, program_args: ProgramArguments, - profile: str, runtime: Optional[str], job_name: Optional[str], detach: bool, @@ -610,7 +598,7 @@ def run( PATH must be a single ``.py`` or ``.R`` source file. Arguments after ``--`` are passed to the submitted program. """ - _validate_run_options(path, profile, runtime) + _validate_run_options(path, runtime) request = _RunRequest( path=path, program_args=program_args, diff --git a/tests/test_run.py b/tests/test_run.py index 30d7712..14de97c 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -123,16 +123,6 @@ def test_run_accepts_r_scripts(runner, tmp_path): build_bundle.assert_called_once_with(script, (), None) -def test_run_rejects_unsupported_profile(runner, tmp_path): - script = tmp_path / "hello.py" - script.write_text("print('hello')\n", encoding="utf-8") - - result = runner.invoke(cli, ["connect", "run", str(script), "--profile", "large"]) - - assert result.exit_code != 0 - assert "supports only the 'standard' profile" in result.output - - def test_run_rejects_unsupported_files(runner, tmp_path): program = tmp_path / "hello.txt" program.write_text("hello\n", encoding="utf-8") From 47d034ad09883e13606e677d6e443951ee59d267 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 14:19:35 -0400 Subject: [PATCH 5/9] feat: support directory run inputs --- README.md | 12 +- hello/app.py | 5 + hello/hello.txt | 1 + skills/posit-cli/SKILL.md | 21 ++-- src/posit_cli/connect/run.py | 235 ++++++++++++++++++++++++++++++----- tests/test_run.py | 141 +++++++++++++++++++++ 6 files changed, 371 insertions(+), 44 deletions(-) create mode 100644 hello/app.py create mode 100644 hello/hello.txt diff --git a/README.md b/README.md index 0694552..72a2989 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ $ posit connect login https://connect.example.com # OAuth, tokens in $ posit connect api v1/user -q .username # gh-api-style raw request $ posit connect deploy streamlit ./my-app # everything rsconnect can do $ posit connect run hello.py # run a Python program +$ posit connect run ./hello # run a directory project $ posit connect run hello.R # run an R program ``` @@ -65,15 +66,20 @@ That's it — from here, explore `posit connect --help` for the full command set ## `posit connect run` -The initial proof of concept accepts one Python or R source file and runs it -through Connect's existing content APIs. Python uses a zero-dependency WSGI -adapter; R uses a temporary Python API backed by +The initial proof of concept accepts one Python or R source file, or a directory +containing a runnable source file, and runs it through Connect's existing content +APIs. Directory contents are bundled as-is. The command selects +`__main__.py`, `main.py`, `app.py`, `main.R`, or `app.R` automatically; a +directory with exactly one Python or R source file may use any filename. +Python uses a zero-dependency WSGI adapter; R uses a temporary Python API backed by [rpy2](https://rpy2.github.io/). Both are deployed, invoked once, printed, and removed: ```console $ posit connect run hello.py hello from Connect +$ posit connect run ./hello +hello from Connect $ posit connect run hello.R Hello, world! ``` diff --git a/hello/app.py b/hello/app.py new file mode 100644 index 0000000..8d3209d --- /dev/null +++ b/hello/app.py @@ -0,0 +1,5 @@ +from pathlib import Path + + +message = (Path(__file__).parent / "hello.txt").read_text(encoding="utf-8").strip() +print(message) diff --git a/hello/hello.txt b/hello/hello.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/hello/hello.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/skills/posit-cli/SKILL.md b/skills/posit-cli/SKILL.md index 8a22630..dc5e248 100644 --- a/skills/posit-cli/SKILL.md +++ b/skills/posit-cli/SKILL.md @@ -22,9 +22,10 @@ which has two halves: - **`posit connect api `** — a `gh api`-style raw REST client for the Connect API. This is your primary tool for anything that isn't a deploy: reading and writing content, users, groups, tags, environments, audit logs, etc. -- **`posit connect run `** — execute one Python or R source file through a - temporary Connect API. The compatibility implementation supports optional - runtime selection, script arguments, and `--detach`. +- **`posit connect run `** — execute one Python or R source file, or a + directory containing a runnable source file, through a temporary Connect API. + The compatibility implementation supports optional runtime selection, script + arguments, and `--detach`. - **The full `rsconnect` command set** (`login`, `deploy`, `content`, `system`, `add`, `list`, ...) is mounted under `posit connect`, so those come for free and track [rsconnect-python](https://github.com/posit-dev/rsconnect-python) upstream. @@ -74,16 +75,22 @@ commands spell this `-i/--insecure`), `-c/--cacert `. ## `posit connect run` -The initial compatibility client accepts one Python or R source file and waits -for its temporary API invocation to finish: +The initial compatibility client accepts one Python or R source file, or a +directory containing a runnable source file, and waits for its temporary API +invocation to finish: ```console posit connect run hello.py +posit connect run ./hello posit connect run hello.py -- --name Ada posit connect run hello.py --runtime python3.12 --detach posit connect run hello.R --runtime r4.5 ``` +For directory inputs, `__main__.py`, `main.py`, `app.py`, `main.R`, and `app.R` +are recognized automatically. A directory with exactly one Python or R source +file may use any filename. Directory contents are included in the bundle. + It creates content with `POST /v1/content`, uploads either a zero-dependency Python WSGI API bundle or a Python API bundle that runs R through `rpy2` with its R runtime metadata to @@ -92,8 +99,8 @@ its R runtime metadata to output, and deletes the temporary content. `--detach` leaves the deployed content in place and prints its URL. R programs are submitted as Python API content using `rpy2`; the Connect administrator must enable -`[Python] Flag = rpy2-cffi-mode-auto`. Directories, native batch jobs, resource -overrides, and artifact pulling are not implemented yet. +`[Python] Flag = rpy2-cffi-mode-auto`. Native batch jobs, resource overrides, and +artifact pulling are not implemented yet. ## `posit connect api` — the raw REST client diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py index e840542..abef080 100644 --- a/src/posit_cli/connect/run.py +++ b/src/posit_cli/connect/run.py @@ -23,13 +23,18 @@ _RPY2_REQUIREMENT = "rpy2" _PYTHON_PROGRAM_FILENAME = "__posit_connect_run_program.py" _R_PROGRAM_FILENAME = "__posit_connect_run_program.R" +_RUNNER_FILENAME = "runner.py" _RUNNER_ENTRYPOINT = "runner:app" +_DIRECTORY_RUNNER_FILENAME = "__posit_connect_run_runner.py" _PYTHON_PROJECT_METADATA = """\ [project] name = "posit-connect-run" version = "0.0.0" requires-python = ">=3.8" """ +_PYTHON_ENTRYPOINT_NAMES = {"__main__.py", "main.py", "app.py"} +_R_ENTRYPOINT_NAMES = {"main.r", "app.r"} +_SUPPORTED_SOURCE_SUFFIXES = {".py", ".r"} ProgramArguments = Tuple[str, ...] @@ -167,15 +172,19 @@ def _r_manifest_version(runtime: Optional[str]) -> str: return _r_runtime_version(runtime) or _local_r_runtime_version() -def _wrapper_source(program_args: ProgramArguments) -> str: +def _wrapper_source( + program_args: ProgramArguments, + program: str = _PYTHON_PROGRAM_FILENAME, +) -> str: """Create the small WSGI adapter used by the legacy content API.""" encoded_args = json.dumps(list(program_args)) + encoded_program = json.dumps(program) return f"""\ import os import subprocess import sys -PROGRAM = "{_PYTHON_PROGRAM_FILENAME}" +PROGRAM = {encoded_program} PROGRAM_ARGS = {encoded_args} @@ -200,14 +209,18 @@ def app(_environ, start_response): """ -def _rpy2_wrapper_source(program_args: ProgramArguments) -> str: +def _rpy2_wrapper_source( + program_args: ProgramArguments, + program: str = _R_PROGRAM_FILENAME, +) -> str: """Create the WSGI adapter that evaluates the R program through rpy2.""" encoded_args = json.dumps(list(program_args)) + encoded_program = json.dumps(program) return f'''\ import contextlib import io -PROGRAM = "{_R_PROGRAM_FILENAME}" +PROGRAM = {encoded_program} PROGRAM_ARGS = {encoded_args} @@ -261,6 +274,89 @@ def app(_environ, start_response): ''' +def _runtime_language(runtime: Optional[str]) -> Optional[str]: + if runtime is None: + return None + if runtime.startswith("python"): + _python_runtime_version(runtime) + return "python" + if runtime.lower().startswith("r"): + _r_runtime_version(runtime) + return "r" + raise click.BadParameter( + "runtime must be a Python or R runtime such as 'python3.12' or 'r4.5'", + param_hint="--runtime", + ) + + +def _directory_entrypoint(path: Path, runtime: Optional[str]) -> Path: + language = _runtime_language(runtime) + suffixes = {".py", ".r"} if language is None else {".py" if language == "python" else ".r"} + candidates = sorted( + ( + candidate + for candidate in path.rglob("*") + if candidate.is_file() and candidate.suffix.lower() in suffixes + ), + key=lambda candidate: candidate.as_posix(), + ) + if not candidates: + raise click.BadParameter( + "PATH directory must contain a runnable Python or R source file.", + param_hint="PATH", + ) + + entrypoint_names = _PYTHON_ENTRYPOINT_NAMES | _R_ENTRYPOINT_NAMES + preferred = [ + candidate for candidate in candidates if candidate.name.lower() in entrypoint_names + ] + if len(preferred) == 1: + return preferred[0] + if len(preferred) > 1: + raise click.BadParameter( + "PATH directory contains multiple possible entrypoints; use --runtime " + "or leave only one of __main__.py, main.py, app.py, main.R, or app.R.", + param_hint="PATH", + ) + if len(candidates) == 1: + return candidates[0] + + raise click.BadParameter( + "PATH directory must contain one runnable source file or a conventional " + "entrypoint named __main__.py, main.py, app.py, main.R, or app.R.", + param_hint="PATH", + ) + + +def _write_bundle_support_files( + root: Path, + wrapper_source: str, + requirements: str, + runner_filename: str = _RUNNER_FILENAME, +) -> None: + (root / runner_filename).write_text(wrapper_source, encoding="utf-8") + pyproject = root / "pyproject.toml" + if not pyproject.exists(): + pyproject.write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") + + requirements_file = root / "requirements.txt" + if not requirements_file.exists(): + requirements_file.write_text(requirements, encoding="utf-8") + elif requirements: + existing = requirements_file.read_text(encoding="utf-8") + additions = [ + line + for line in requirements.splitlines() + if line and line not in existing.splitlines() + ] + if additions: + separator = "" if existing.endswith("\n") else "\n" + requirements_file.write_text( + existing + separator + "\n".join(additions) + "\n", + encoding="utf-8", + ) + + def _write_bundle_files( root: Path, source_path: Path, @@ -269,15 +365,35 @@ def _write_bundle_files( requirements: str, ) -> None: (root / program_filename).write_bytes(source_path.read_bytes()) - (root / "runner.py").write_text(wrapper_source, encoding="utf-8") - (root / "pyproject.toml").write_text(_PYTHON_PROJECT_METADATA, encoding="utf-8") - (root / "requirements.txt").write_text(requirements, encoding="utf-8") + _write_bundle_support_files(root, wrapper_source, requirements) + + +def _copy_program_files( + root: Path, + path: Path, + program_filename: str, + runtime: Optional[str], +) -> str: + if not path.is_dir(): + (root / program_filename).write_bytes(path.read_bytes()) + return program_filename + + program_path = _directory_entrypoint(path, runtime) + shutil.copytree(path, root, dirs_exist_ok=True) + return program_path.relative_to(path).as_posix() + + +def _directory_runner_filename(root: Path) -> str: + if not (root / _RUNNER_FILENAME).exists(): + return _RUNNER_FILENAME + return _DIRECTORY_RUNNER_FILENAME def _make_api_bundle( directory: str, environment: object, r_environment: Optional[object] = None, + entrypoint: str = _RUNNER_ENTRYPOINT, ) -> BinaryIO: bundle_options = { "extra_files": [], @@ -288,7 +404,7 @@ def _make_api_bundle( return make_api_bundle( directory, - _RUNNER_ENTRYPOINT, + entrypoint, AppModes.PYTHON_API, environment, **bundle_options, @@ -303,19 +419,45 @@ def _build_python_bundle( """Build a normal Python API bundle containing the program and adapter.""" with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: root = Path(directory) - _write_bundle_files( - root, - path, - _PYTHON_PROGRAM_FILENAME, - _wrapper_source(program_args), - "", - ) - - environment = Environment.create_python_environment( - directory, - override_python_version=_python_runtime_version(runtime), - ) - return _make_api_bundle(directory, environment) + runner_filename = _RUNNER_FILENAME + if path.is_dir(): + program = _copy_program_files(root, path, _PYTHON_PROGRAM_FILENAME, runtime) + runner_filename = _directory_runner_filename(root) + requirements_file = ( + "pyproject.toml" + if (root / "pyproject.toml").is_file() + and not (root / "requirements.txt").is_file() + else "requirements.txt" + ) + _write_bundle_support_files( + root, + _wrapper_source(program_args, program), + "", + runner_filename, + ) + else: + requirements_file = "requirements.txt" + _write_bundle_files( + root, + path, + _PYTHON_PROGRAM_FILENAME, + _wrapper_source(program_args), + "", + ) + + if requirements_file == "pyproject.toml": + environment = Environment.create_python_environment( + directory, + requirements_file=requirements_file, + override_python_version=_python_runtime_version(runtime), + ) + else: + environment = Environment.create_python_environment( + directory, + override_python_version=_python_runtime_version(runtime), + ) + entrypoint = f"{Path(runner_filename).stem}:app" + return _make_api_bundle(directory, environment, entrypoint=entrypoint) def _build_r_bundle( @@ -326,21 +468,34 @@ def _build_r_bundle( """Build a Python API bundle that runs the R program through rpy2.""" with tempfile.TemporaryDirectory(prefix="posit-connect-run-") as directory: root = Path(directory) - _write_bundle_files( - root, - path, - _R_PROGRAM_FILENAME, - _rpy2_wrapper_source(program_args), - f"{_RPY2_REQUIREMENT}\n", - ) + runner_filename = _RUNNER_FILENAME + if path.is_dir(): + program = _copy_program_files(root, path, _R_PROGRAM_FILENAME, runtime) + runner_filename = _directory_runner_filename(root) + _write_bundle_support_files( + root, + _rpy2_wrapper_source(program_args, program), + f"{_RPY2_REQUIREMENT}\n", + runner_filename, + ) + else: + _write_bundle_files( + root, + path, + _R_PROGRAM_FILENAME, + _rpy2_wrapper_source(program_args), + f"{_RPY2_REQUIREMENT}\n", + ) environment = Environment.create_python_environment(directory) r_environment = REnvironment(r_version=_r_manifest_version(runtime), packages={}) - return _make_api_bundle(directory, environment, r_environment) + entrypoint = f"{Path(runner_filename).stem}:app" + return _make_api_bundle(directory, environment, r_environment, entrypoint) def _build_bundle(path: Path, program_args: ProgramArguments, runtime: Optional[str]) -> BinaryIO: - if path.suffix.lower() == ".r": + program_path = path if path.is_file() else _directory_entrypoint(path, runtime) + if program_path.suffix.lower() == ".r": return _build_r_bundle(path, program_args, runtime) return _build_python_bundle(path, program_args, runtime) @@ -458,8 +613,12 @@ def _cleanup_content( def _validate_run_options(path: Path, runtime: Optional[str]) -> None: + if path.is_dir(): + _directory_entrypoint(path, runtime) + return + suffix = path.suffix.lower() - if suffix not in {".py", ".r"}: + if suffix not in _SUPPORTED_SOURCE_SUFFIXES: raise click.BadParameter( "PATH must be a Python or R file ending in .py or .R", param_hint="PATH", @@ -534,7 +693,13 @@ def _execute_run(request: _RunRequest, dependencies: _RunDependencies) -> None: ) @click.argument( "path", - type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), + type=click.Path( + exists=True, + file_okay=True, + dir_okay=True, + readable=True, + path_type=Path, + ), ) @click.argument("program_args", nargs=-1, type=click.UNPROCESSED) @click.option( @@ -595,8 +760,10 @@ def run( ) -> None: """Run PATH as a Python or R program using Connect's existing content APIs. - PATH must be a single ``.py`` or ``.R`` source file. Arguments after ``--`` - are passed to the submitted program. + PATH may be a single ``.py`` or ``.R`` source file, or a directory containing + one runnable source file. Directory entrypoints named ``__main__.py``, + ``main.py``, ``app.py``, ``main.R``, or ``app.R`` are selected automatically. + Arguments after ``--`` are passed to the submitted program. """ _validate_run_options(path, runtime) request = _RunRequest( diff --git a/tests/test_run.py b/tests/test_run.py index 14de97c..17338ef 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -74,6 +74,25 @@ def test_run_creates_deploys_invokes_and_deletes_temporary_content(runner, tmp_p executor.client.delete.assert_called_once_with("v1/content/content-123", decode_response=False) +def test_run_accepts_directory(runner, tmp_path): + project = tmp_path / "hello-world" + project.mkdir() + (project / "app.py").write_text("print('hello from Connect')\n", encoding="utf-8") + executor = _mock_executor() + + with patch("posit_cli.connect.run.RSConnectExecutor", return_value=executor), patch( + "posit_cli.connect.run._build_bundle", return_value=io.BytesIO(b"bundle") + ) as build_bundle, patch( + "posit_cli.connect.run._content_response", + return_value=_app_response(b"hello from Connect\n"), + ): + result = runner.invoke(cli, ["connect", "run", str(project), "--", "one"]) + + assert result.exit_code == 0, result.output + assert result.output == "hello from Connect\n" + build_bundle.assert_called_once_with(project, ("one",), None) + + def test_run_detach_prints_content_url_and_keeps_content(runner, tmp_path): script = tmp_path / "hello.py" script.write_text("print('hello')\n", encoding="utf-8") @@ -133,6 +152,29 @@ def test_run_rejects_unsupported_files(runner, tmp_path): assert "PATH must be a Python or R file ending in .py or .R" in result.output +def test_run_rejects_directory_without_entrypoint(runner, tmp_path): + project = tmp_path / "hello-world" + project.mkdir() + (project / "README.md").write_text("hello\n", encoding="utf-8") + + result = runner.invoke(cli, ["connect", "run", str(project)]) + + assert result.exit_code != 0 + assert "PATH directory must contain a runnable Python or R source file" in result.output + + +def test_run_rejects_directory_with_ambiguous_entrypoints(runner, tmp_path): + project = tmp_path / "hello-world" + project.mkdir() + (project / "first.py").write_text("print('first')\n", encoding="utf-8") + (project / "second.py").write_text("print('second')\n", encoding="utf-8") + + result = runner.invoke(cli, ["connect", "run", str(project)]) + + assert result.exit_code != 0 + assert "PATH directory must contain one runnable source file" in result.output + + def test_wrapper_uses_json_encoded_program_arguments(): source = _wrapper_source(("Ada Lovelace", 'quote "this"')) @@ -142,6 +184,105 @@ def test_wrapper_uses_json_encoded_program_arguments(): assert "subprocess.run(" in source +def test_build_bundle_preserves_directory_files_and_uses_app_entrypoint(tmp_path): + project = tmp_path / "hello-world" + project.mkdir() + (project / "app.py").write_text("print('hello')\n", encoding="utf-8") + (project / "data.txt").write_text("fixture\n", encoding="utf-8") + (project / "lib").mkdir() + (project / "lib" / "helper.py").write_text("VALUE = 1\n", encoding="utf-8") + observed = {} + + def fake_make_api_bundle(directory, entrypoint, app_mode, environment, extra_files, excludes): + root = Path(directory) + observed["files"] = sorted( + path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file() + ) + observed["wrapper"] = (root / "runner.py").read_text(encoding="utf-8") + observed["requirements"] = (root / "requirements.txt").read_text(encoding="utf-8") + observed["entrypoint"] = entrypoint + observed["app_mode"] = app_mode + observed["environment"] = environment + observed["extra_files"] = extra_files + observed["excludes"] = excludes + return io.BytesIO(b"bundle") + + with patch( + "posit_cli.connect.run.Environment.create_python_environment", + return_value="environment", + ), patch("posit_cli.connect.run.make_api_bundle", side_effect=fake_make_api_bundle): + bundle = _build_bundle(project, ("arg",), "python3.12") + + assert bundle.read() == b"bundle" + assert observed["files"] == [ + "app.py", + "data.txt", + "lib/helper.py", + "pyproject.toml", + "requirements.txt", + "runner.py", + ] + assert 'PROGRAM = "app.py"' in observed["wrapper"] + assert 'PROGRAM_ARGS = ["arg"]' in observed["wrapper"] + assert observed["entrypoint"] == "runner:app" + assert observed["app_mode"] is AppModes.PYTHON_API + assert observed["environment"] == "environment" + assert observed["extra_files"] == [] + assert observed["excludes"] == [] + + +def test_build_r_bundle_accepts_directory(tmp_path): + project = tmp_path / "hello-world" + project.mkdir() + (project / "app.R").write_text('cat("hello\\n")\n', encoding="utf-8") + observed = {} + + def fake_make_api_bundle( + directory, + entrypoint, + app_mode, + environment, + extra_files, + excludes, + r_environment, + ): + root = Path(directory) + observed["files"] = sorted( + path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file() + ) + observed["wrapper"] = (root / "runner.py").read_text(encoding="utf-8") + observed["requirements"] = (root / "requirements.txt").read_text(encoding="utf-8") + observed["entrypoint"] = entrypoint + observed["app_mode"] = app_mode + observed["environment"] = environment + observed["extra_files"] = extra_files + observed["excludes"] = excludes + observed["r_environment"] = r_environment + return io.BytesIO(b"bundle") + + with patch( + "posit_cli.connect.run.Environment.create_python_environment", + return_value="environment", + ), patch("posit_cli.connect.run.make_api_bundle", side_effect=fake_make_api_bundle): + bundle = _build_bundle(project, (), "r4.5") + + assert bundle.read() == b"bundle" + assert observed["files"] == [ + "app.R", + "pyproject.toml", + "requirements.txt", + "runner.py", + ] + assert 'PROGRAM = "app.R"' in observed["wrapper"] + assert observed["requirements"] == "rpy2\n" + assert observed["entrypoint"] == "runner:app" + assert observed["app_mode"] is AppModes.PYTHON_API + assert observed["environment"] == "environment" + assert observed["extra_files"] == [] + assert observed["excludes"] == [] + assert observed["r_environment"].r_version == "4.5" + + def test_r_wrapper_uses_rpy2_and_encoded_program_arguments(): source = _rpy2_wrapper_source(("Ada Lovelace", 'quote "this"')) From 081cb38ef4cc119c66572dfb7d9f97167a5244f7 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 14:25:46 -0400 Subject: [PATCH 6/9] perf: reuse Connect run HTTP connection --- src/posit_cli/connect/run.py | 78 ++++++++++++++++------------- tests/test_run.py | 95 ++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 34 deletions(-) diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py index abef080..f4e111c 100644 --- a/src/posit_cli/connect/run.py +++ b/src/posit_cli/connect/run.py @@ -7,9 +7,10 @@ import subprocess import tempfile import uuid +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import BinaryIO, Callable, Optional, Protocol, Tuple +from typing import BinaryIO, Callable, Iterator, Optional, Protocol, Tuple import click from rsconnect.api import RSConnectException, RSConnectExecutor @@ -62,9 +63,6 @@ def delete(self, path: str, *, decode_response: bool) -> object: ... class _ConnectExecutor(Protocol): client: _ConnectClient - def setup_client(self) -> None: ... - - BundleBuilder = Callable[[Path, ProgramArguments, Optional[str]], BinaryIO] ExecutorFactory = Callable[..., _ConnectExecutor] ContentInvoker = Callable[[_ConnectClient, str], HTTPResponse] @@ -93,6 +91,18 @@ class _RunDependencies: content_deleter: ContentDeleter +@contextmanager +def _client_connection(client: _ConnectClient) -> Iterator[_ConnectClient]: + """Reuse rsconnect's HTTP connection across the run lifecycle.""" + if isinstance(client, HTTPServer): + with client: + yield client + return + + # Keep lightweight test doubles and alternate clients usable. + yield client + + def _is_version(value: str) -> bool: return bool(value) and all(character.isdigit() or character == "." for character in value) @@ -651,39 +661,39 @@ def _execute_run(request: _RunRequest, dependencies: _RunDependencies) -> None: insecure=request.insecure, cacert=request.cacert, ) - executor.setup_client() - client = executor.client - content = client.content_create(_content_name(request.path, request.job_name)) - content_guid = _content_field( - content, - "guid", - "Connect returned no content GUID.", - ) - content_url = _content_field( - content, - "content_url", - "Connect returned no content URL.", - ) - - _deploy_content(client, request, content_guid, dependencies.bundle_builder) - - if request.detach: - click.echo(content_url) - return - - response = dependencies.content_invoker(client, content_url) - _emit_response(response) - _ensure_successful_response(response) + with _client_connection(client): + try: + content = client.content_create(_content_name(request.path, request.job_name)) + content_guid = _content_field( + content, + "guid", + "Connect returned no content GUID.", + ) + content_url = _content_field( + content, + "content_url", + "Connect returned no content URL.", + ) + + _deploy_content(client, request, content_guid, dependencies.bundle_builder) + + if request.detach: + click.echo(content_url) + return + + response = dependencies.content_invoker(client, content_url) + _emit_response(response) + _ensure_successful_response(response) + finally: + _cleanup_content( + executor, + content_guid, + request.detach, + dependencies.content_deleter, + ) except RSConnectException as exc: raise click.ClickException(str(exc)) from exc - finally: - _cleanup_content( - executor, - content_guid, - request.detach, - dependencies.content_deleter, - ) @click.command( diff --git a/tests/test_run.py b/tests/test_run.py index 17338ef..f6584f8 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -12,11 +12,16 @@ from posit_cli.__main__ import cli from posit_cli.connect.run import ( + _RunDependencies, + _RunRequest, _build_bundle, _build_r_bundle, + _client_connection, + _execute_run, _rpy2_wrapper_source, _wrapper_source, ) +from rsconnect.http_support import HTTPServer from rsconnect.models import AppModes @@ -72,6 +77,96 @@ def test_run_creates_deploys_invokes_and_deletes_temporary_content(runner, tmp_p raise_on_error=False, ) executor.client.delete.assert_called_once_with("v1/content/content-123", decode_response=False) + executor.setup_client.assert_not_called() + + +class _TrackingClient(HTTPServer): + def __init__(self, events): + super().__init__("http://127.0.0.1") + self.events = events + + def __enter__(self): + self.events.append("enter") + return self + + def __exit__(self, *args): + self.events.append("exit") + + def content_create(self, name): + self.events.append("create") + return {"guid": "content-123", "content_url": "https://connect.example.com/content-123"} + + def upload_bundle(self, content_guid, bundle): + self.events.append("upload") + return {"id": "bundle-123"} + + def content_deploy(self, content_guid, *, bundle_id): + self.events.append("deploy") + return {"task_id": "task-123"} + + def wait_for_task(self, task_id, *, log_callback, raise_on_error): + self.events.append("wait") + return ([], {"code": 0}) + + +def test_run_reuses_client_connection_through_cleanup(tmp_path): + script = tmp_path / "hello.py" + script.write_text("print('hello')\n", encoding="utf-8") + events = [] + client = _TrackingClient(events) + executor = SimpleNamespace(client=client) + response = _app_response(b"hello\n") + + def build_bundle(path, program_args, runtime): + events.append("build") + return io.BytesIO(b"bundle") + + def invoke_content(content_client, content_url): + events.append("invoke") + return response + + def delete_content(content_client, content_guid): + events.append("delete") + + dependencies = _RunDependencies( + executor_factory=lambda **kwargs: executor, + bundle_builder=build_bundle, + content_invoker=invoke_content, + content_deleter=delete_content, + ) + request = _RunRequest( + path=script, + program_args=(), + runtime=None, + job_name=None, + detach=False, + server_name=None, + server=None, + api_key=None, + insecure=False, + cacert=None, + ) + + _execute_run(request, dependencies) + + assert events == [ + "enter", + "create", + "build", + "upload", + "deploy", + "wait", + "invoke", + "delete", + "exit", + ] + + +def test_client_connection_leaves_alternate_clients_untouched(): + client = object() + + with _client_connection(client) as connected: + assert connected is client def test_run_accepts_directory(runner, tmp_path): From fb5bdb576ae1cc3bf40282bcb5d778bbde52d259 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 14:28:53 -0400 Subject: [PATCH 7/9] formatting --- src/posit_cli/connect/run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/posit_cli/connect/run.py b/src/posit_cli/connect/run.py index f4e111c..4e78533 100644 --- a/src/posit_cli/connect/run.py +++ b/src/posit_cli/connect/run.py @@ -63,6 +63,7 @@ def delete(self, path: str, *, decode_response: bool) -> object: ... class _ConnectExecutor(Protocol): client: _ConnectClient + BundleBuilder = Callable[[Path, ProgramArguments, Optional[str]], BinaryIO] ExecutorFactory = Callable[..., _ConnectExecutor] ContentInvoker = Callable[[_ConnectClient, str], HTTPResponse] From c5b9b13ad8fd072612923a5ef23fbfdf5ec9d0f5 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 14:32:03 -0400 Subject: [PATCH 8/9] chore: move run examples under examples directory --- README.md | 14 +++++++------- hello.R => examples/hello.R | 0 hello.py => examples/hello.py | 0 {hello => examples/hello}/app.py | 0 {hello => examples/hello}/hello.txt | 0 skills/posit-cli/SKILL.md | 10 +++++----- 6 files changed, 12 insertions(+), 12 deletions(-) rename hello.R => examples/hello.R (100%) rename hello.py => examples/hello.py (100%) rename {hello => examples/hello}/app.py (100%) rename {hello => examples/hello}/hello.txt (100%) diff --git a/README.md b/README.md index 72a2989..24f4541 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A friendly command-line interface for Posit products, in the spirit of [`gh`](ht $ posit connect login https://connect.example.com # OAuth, tokens in your OS keyring $ posit connect api v1/user -q .username # gh-api-style raw request $ posit connect deploy streamlit ./my-app # everything rsconnect can do -$ posit connect run hello.py # run a Python program -$ posit connect run ./hello # run a directory project -$ posit connect run hello.R # run an R program +$ posit connect run examples/hello.py # run a Python program +$ posit connect run examples/hello # run a directory project +$ posit connect run examples/hello.R # run an R program ``` This project is in early-stage development and so far only supports Posit Connect's APIs. @@ -76,11 +76,11 @@ Python uses a zero-dependency WSGI adapter; R uses a temporary Python API backed removed: ```console -$ posit connect run hello.py +$ posit connect run examples/hello.py hello from Connect -$ posit connect run ./hello +$ posit connect run examples/hello hello from Connect -$ posit connect run hello.R +$ posit connect run examples/hello.R Hello, world! ``` @@ -88,7 +88,7 @@ Arguments after `--` are passed to the program. Use `--detach` to deploy the temporary API and print its URL without invoking or removing it: ```console -$ posit connect run hello.py -- --name Ada +$ posit connect run examples/hello.py -- --name Ada https://connect.example.com/content/... ``` diff --git a/hello.R b/examples/hello.R similarity index 100% rename from hello.R rename to examples/hello.R diff --git a/hello.py b/examples/hello.py similarity index 100% rename from hello.py rename to examples/hello.py diff --git a/hello/app.py b/examples/hello/app.py similarity index 100% rename from hello/app.py rename to examples/hello/app.py diff --git a/hello/hello.txt b/examples/hello/hello.txt similarity index 100% rename from hello/hello.txt rename to examples/hello/hello.txt diff --git a/skills/posit-cli/SKILL.md b/skills/posit-cli/SKILL.md index dc5e248..34d6c38 100644 --- a/skills/posit-cli/SKILL.md +++ b/skills/posit-cli/SKILL.md @@ -80,11 +80,11 @@ directory containing a runnable source file, and waits for its temporary API invocation to finish: ```console -posit connect run hello.py -posit connect run ./hello -posit connect run hello.py -- --name Ada -posit connect run hello.py --runtime python3.12 --detach -posit connect run hello.R --runtime r4.5 +posit connect run examples/hello.py +posit connect run examples/hello +posit connect run examples/hello.py -- --name Ada +posit connect run examples/hello.py --runtime python3.12 --detach +posit connect run examples/hello.R --runtime r4.5 ``` For directory inputs, `__main__.py`, `main.py`, `app.py`, `main.R`, and `app.R` From 82a026cc2c5fdb856987c6e2e9e227e9f05b2728 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 20 Aug 2026 15:06:49 -0400 Subject: [PATCH 9/9] docs: sync CLI docs with main --- README.md | 36 ------------------------------------ skills/posit-cli/SKILL.md | 39 ++------------------------------------- 2 files changed, 2 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 24f4541..b92fd2a 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ A friendly command-line interface for Posit products, in the spirit of [`gh`](ht $ posit connect login https://connect.example.com # OAuth, tokens in your OS keyring $ posit connect api v1/user -q .username # gh-api-style raw request $ posit connect deploy streamlit ./my-app # everything rsconnect can do -$ posit connect run examples/hello.py # run a Python program -$ posit connect run examples/hello # run a directory project -$ posit connect run examples/hello.R # run an R program ``` This project is in early-stage development and so far only supports Posit Connect's APIs. @@ -64,39 +61,6 @@ $ posit connect deploy streamlit ./my-app That's it — from here, explore `posit connect --help` for the full command set. -## `posit connect run` - -The initial proof of concept accepts one Python or R source file, or a directory -containing a runnable source file, and runs it through Connect's existing content -APIs. Directory contents are bundled as-is. The command selects -`__main__.py`, `main.py`, `app.py`, `main.R`, or `app.R` automatically; a -directory with exactly one Python or R source file may use any filename. -Python uses a zero-dependency WSGI adapter; R uses a temporary Python API backed by -[rpy2](https://rpy2.github.io/). Both are deployed, invoked once, printed, and -removed: - -```console -$ posit connect run examples/hello.py -hello from Connect -$ posit connect run examples/hello -hello from Connect -$ posit connect run examples/hello.R -Hello, world! -``` - -Arguments after `--` are passed to the program. Use `--detach` to deploy the -temporary API and print its URL without invoking or removing it: - -```console -$ posit connect run examples/hello.py -- --name Ada -https://connect.example.com/content/... -``` - -R programs use the local R major/minor version by default; `--runtime r4.5` can -override the version constraint. The Connect administrator must enable -`[Python] Flag = rpy2-cffi-mode-auto` for rpy2 content. The adapters are -deliberately temporary until Connect exposes a native execution API. - ## Authentication `posit connect login` runs an OAuth 2.1 flow and stores tokens in your OS diff --git a/skills/posit-cli/SKILL.md b/skills/posit-cli/SKILL.md index 34d6c38..b5084b7 100644 --- a/skills/posit-cli/SKILL.md +++ b/skills/posit-cli/SKILL.md @@ -3,8 +3,7 @@ name: posit-cli description: >- Use the `posit` CLI to work with Posit Connect — logging in, making raw authenticated calls to the Connect REST API (`posit connect api`, a - gh-api-style client), executing Python and R programs (`posit connect run`), and - deploying or managing content. Use this whenever the + gh-api-style client), and deploying or managing content. Use this whenever the user mentions `posit`, posit-cli, Posit Connect, the Connect API, or deploying apps/notebooks/APIs to Connect (Streamlit, Shiny, FastAPI, Flask, Dash, Quarto, Bokeh, Gradio, Panel, Voila, etc.), or managing Connect content, users, groups, @@ -22,10 +21,6 @@ which has two halves: - **`posit connect api `** — a `gh api`-style raw REST client for the Connect API. This is your primary tool for anything that isn't a deploy: reading and writing content, users, groups, tags, environments, audit logs, etc. -- **`posit connect run `** — execute one Python or R source file, or a - directory containing a runnable source file, through a temporary Connect API. - The compatibility implementation supports optional runtime selection, script - arguments, and `--detach`. - **The full `rsconnect` command set** (`login`, `deploy`, `content`, `system`, `add`, `list`, ...) is mounted under `posit connect`, so those come for free and track [rsconnect-python](https://github.com/posit-dev/rsconnect-python) upstream. @@ -44,8 +39,7 @@ posit connect deploy --help # the deploy subcommands (streamlit, shiny, .. ``` `posit connect api --help` and the rest of this skill cover the `api` command, -while `posit connect run --help` is the source of truth for the compatibility -command. +which is owned by this project and documented in full below. ## Authentication @@ -73,35 +67,6 @@ server), `-s/--server` (env `CONNECT_SERVER`), `-k/--api-key` (env `CONNECT_API_KEY`), `--no-tls-verify` (env `CONNECT_INSECURE`; note: rsconnect commands spell this `-i/--insecure`), `-c/--cacert `. -## `posit connect run` - -The initial compatibility client accepts one Python or R source file, or a -directory containing a runnable source file, and waits for its temporary API -invocation to finish: - -```console -posit connect run examples/hello.py -posit connect run examples/hello -posit connect run examples/hello.py -- --name Ada -posit connect run examples/hello.py --runtime python3.12 --detach -posit connect run examples/hello.R --runtime r4.5 -``` - -For directory inputs, `__main__.py`, `main.py`, `app.py`, `main.R`, and `app.R` -are recognized automatically. A directory with exactly one Python or R source -file may use any filename. Directory contents are included in the bundle. - -It creates content with `POST /v1/content`, uploads either a zero-dependency -Python WSGI API bundle or a Python API bundle that runs R through `rpy2` with -its R runtime metadata to -`/v1/content/{guid}/bundles`, deploys it with -`POST /v1/content/{guid}/deploy`, invokes the content URL, prints the captured -output, and deletes the temporary content. `--detach` leaves the deployed -content in place and prints its URL. R programs are submitted as Python API -content using `rpy2`; the Connect administrator must enable -`[Python] Flag = rpy2-cffi-mode-auto`. Native batch jobs, resource overrides, and -artifact pulling are not implemented yet. - ## `posit connect api` — the raw REST client ```console