From 19717e0eeaeb0073f6522a3999f77e6aaf6e3eba Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 15:08:04 -0400 Subject: [PATCH 01/14] Add Publisher init and publish commands --- README.md | 46 ++++- pyproject.toml | 7 +- src/posit_cli/connect/__init__.py | 11 +- src/posit_cli/connect/init.py | 225 +++++++++++++++++++++++ src/posit_cli/connect/publish.py | 116 ++++++++++++ tests/test_cli.py | 2 + tests/test_publisher_cli.py | 289 ++++++++++++++++++++++++++++++ tests/test_rsconnect_contract.py | 29 +++ uv.lock | 66 ++++++- 9 files changed, 773 insertions(+), 18 deletions(-) create mode 100644 src/posit_cli/connect/init.py create mode 100644 src/posit_cli/connect/publish.py create mode 100644 tests/test_publisher_cli.py diff --git a/README.md b/README.md index b92fd2a..0b0a848 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A friendly command-line interface for Posit products, in the spirit of [`gh`](ht ```console $ 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 init --type python-fastapi --entrypoint app.py:app +$ posit connect publish . --server https://connect.example.com ``` This project is in early-stage development and so far only supports Posit Connect's APIs. @@ -53,13 +54,24 @@ $ posit connect login https://connect.example.com $ posit connect api v1/user -q .username ``` -**3. Deploy something.** Anything `rsconnect` can deploy, `posit` can too: +**3. Initialize and publish.** Create a Publisher-compatible +`.posit/publish` configuration, then publish it: ```console -$ posit connect deploy streamlit ./my-app +$ cd my-app +$ posit connect init +$ posit connect publish . --server https://connect.example.com ``` -That's it — from here, explore `posit connect --help` for the full command set. +The server is needed only for the first publish. Later publishes reuse the +saved deployment record: + +```console +$ posit connect publish . +``` + +Anything `rsconnect` can deploy remains available under `posit connect deploy`. +Explore `posit connect --help` for the full command set. ## Authentication @@ -114,6 +126,32 @@ $ posit connect api v1/users -X GET -f page_size=5 # or force GET $ posit connect api v1/content -f name=my-app # POST body (creates content) ``` +## `posit connect init` and `publish` + +Run `posit connect init` in a terminal for an interactive setup wizard, or pass +the required content type and entrypoint explicitly: + +```console +$ posit connect init +$ posit connect init --type python-fastapi --entrypoint app.py:app --title "Sales API" +``` + +The command does not prompt when stdin is non-interactive. Automation must pass +`--type` and `--entrypoint`; Quarto content also requires `--quarto-version`. + +Publish the initialized project to a URL or saved server name: + +```console +$ posit connect publish . --server https://connect.example.com +$ posit connect publish . --name production +$ posit connect publish . --config sales-api +$ posit connect publish . --deployment production +``` + +`--config` selects an exact `.posit/publish` configuration and `--deployment` +selects an exact deployment record. Successful publishes print the content URL +to stdout. + ## `posit connect *` `posit` wraps [`rsconnect-python`](https://github.com/posit-dev/rsconnect-python): diff --git a/pyproject.toml b/pyproject.toml index b8076fc..a2bcbcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,9 @@ dependencies = [ # posit-cli reuses rsconnect-python's *internal* API (RSConnectExecutor, # RSConnectClient). These have no stability contract, so re-verify the internals # on bumps. See tests/test_rsconnect_contract.py. - "rsconnect-python>=1.30,<2", + # Publisher APIs used by `connect init` and `connect publish`. Pin the exact + # PR 830 commit until the API is available in an rsconnect-python release. + "rsconnect-python @ git+https://github.com/posit-dev/rsconnect-python.git@c92d8f4ac033a5b1bb4f67bbaae33bf8e0b143e5", # rsconnect imports `keyring` optionally; we depend on it directly so OAuth # tokens land in the OS keyring. "keyring>=23.0", @@ -35,6 +37,9 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" +[tool.hatch.metadata] +allow-direct-references = true + # Write the resolved version into the package. An sdist has no git metadata, so a # wheel built from an sdist reads this file instead of asking git. [tool.hatch.build.hooks.vcs] diff --git a/src/posit_cli/connect/__init__.py b/src/posit_cli/connect/__init__.py index a7cafad..e63c41f 100644 --- a/src/posit_cli/connect/__init__.py +++ b/src/posit_cli/connect/__init__.py @@ -1,14 +1,11 @@ -"""The ``posit connect`` command group. - -Mounts the entire ``rsconnect`` CLI (login, deploy, add, list, ...) under -``posit connect`` so those commands come for free and track rsconnect-python -upstream, then layers on a ``gh api``-style ``posit connect api`` command. -""" +"""The ``posit connect`` command group.""" import click from rsconnect.main import cli as rsconnect_cli from .api import api as api_cmd +from .init import init as init_cmd +from .publish import publish as publish_cmd _epilog = ( @@ -29,3 +26,5 @@ def connect() -> None: connect.add_command(_cmd, name=_name) connect.add_command(api_cmd, name="api") +connect.add_command(init_cmd, name="init") +connect.add_command(publish_cmd, name="publish") diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py new file mode 100644 index 0000000..fa6f530 --- /dev/null +++ b/src/posit_cli/connect/init.py @@ -0,0 +1,225 @@ +"""Initialize a project for the Posit Publisher workflow.""" + +import os +import sys +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +import click +from rsconnect.exception import RSConnectException +from rsconnect.publisher import CONTENT_TYPES, InitRequest, initialize_project + + +_CONTENT_TYPES_BY_NAME = {spec.type: spec for spec in CONTENT_TYPES} +_CONTENT_TYPE_NAMES = tuple(_CONTENT_TYPES_BY_NAME) +_PYTHON_PACKAGE_MANAGERS = ("pip", "uv", "none") + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _default_package_settings(project_dir: str) -> Tuple[str, str]: + if os.path.exists(os.path.join(project_dir, "pyproject.toml")): + manager = "uv" if os.path.exists(os.path.join(project_dir, "uv.lock")) else "pip" + return "pyproject.toml", manager + return "requirements.txt", "pip" + + +def _default_title(project_dir: str, entrypoint: str) -> str: + project_name = os.path.basename(os.path.abspath(project_dir)) + return project_name or Path(entrypoint.split(":", 1)[0]).stem + + +def collect_init_answers(project_dir: str) -> Dict[str, Any]: + """Prompt for initialization values without performing initialization.""" + click.echo("Content types:") + for spec in CONTENT_TYPES: + click.echo(" {:<18} {}".format(spec.type, spec.label)) + + content_type = click.prompt( + "Content type", + type=click.Choice(_CONTENT_TYPE_NAMES, case_sensitive=False), + ) + if content_type.startswith("quarto-"): + mode = click.prompt( + "Quarto mode", + type=click.Choice(("static", "shiny"), case_sensitive=False), + default=content_type[len("quarto-") :], + show_choices=True, + ) + content_type = "quarto-" + mode + + spec = _CONTENT_TYPES_BY_NAME[content_type] + entrypoint = click.prompt("Entrypoint", default=spec.entrypoint_example) + title = click.prompt("Title", default=_default_title(project_dir, entrypoint)) + + python: Optional[Dict[str, str]] = None + if spec.language == "python": + package_file, package_manager = _default_package_settings(project_dir) + package_file = click.prompt("Python package file", default=package_file) + package_manager = click.prompt( + "Python package manager", + type=click.Choice(_PYTHON_PACKAGE_MANAGERS, case_sensitive=False), + default=package_manager, + show_choices=True, + ) + python = { + "package_file": package_file, + "package_manager": package_manager, + } + + quarto: Optional[Dict[str, str]] = None + if content_type.startswith("quarto-"): + quarto = {"version": click.prompt("Quarto version")} + + files = ("*",) + if not click.confirm( + "Use detected file patterns ({})?".format(", ".join(files)), default=True + ): + raise click.Abort() + + return { + "content_type": content_type, + "entrypoint": entrypoint, + "title": title, + "python": python, + "quarto": quarto, + "files": files, + } + + +def _explicit_init_requested( + content_type: Optional[str], + entrypoint: Optional[str], + title: Optional[str], + config_name: Optional[str], + package_file: Optional[str], + package_manager: Optional[str], + quarto_version: Optional[str], + files: Tuple[str, ...], + overwrite: bool, +) -> bool: + return any( + ( + content_type, + entrypoint, + title, + config_name, + package_file, + package_manager, + quarto_version, + files, + overwrite, + ) + ) + + +@click.command( + "init", + short_help="Initialize a project for publishing.", + context_settings={"help_option_names": ["-h", "--help"]}, +) +@click.argument( + "project_dir", + default=".", + type=click.Path(exists=True, file_okay=False, resolve_path=True), +) +@click.option( + "--type", + "content_type", + type=click.Choice(_CONTENT_TYPE_NAMES, case_sensitive=False), + help="Publisher content type.", +) +@click.option("--entrypoint", help="Application entrypoint, such as app.py:app.") +@click.option("--title", help="Content title.") +@click.option("--config", "config_name", help="Publisher configuration name.") +@click.option("--package-file", help="Python dependency file.") +@click.option( + "--package-manager", + type=click.Choice(_PYTHON_PACKAGE_MANAGERS, case_sensitive=False), + help="Python package manager.", +) +@click.option("--quarto-version", help="Required Quarto version.") +@click.option( + "--file", + "files", + multiple=True, + metavar="PATTERN", + help="Include file pattern. May be specified multiple times.", +) +@click.option("--overwrite", is_flag=True, help="Replace an existing configuration.") +def init( + project_dir: str, + content_type: Optional[str], + entrypoint: Optional[str], + title: Optional[str], + config_name: Optional[str], + package_file: Optional[str], + package_manager: Optional[str], + quarto_version: Optional[str], + files: Tuple[str, ...], + overwrite: bool, +) -> None: + """Create a .posit/publish configuration in PROJECT_DIR.""" + explicit = _explicit_init_requested( + content_type, + entrypoint, + title, + config_name, + package_file, + package_manager, + quarto_version, + files, + overwrite, + ) + + answers: Dict[str, Any] = {} + if not explicit: + if not _is_interactive(): + raise click.UsageError( + "Interactive input is unavailable; specify --type and --entrypoint." + ) + answers = collect_init_answers(project_dir) + elif not content_type or not entrypoint: + raise click.UsageError("--type and --entrypoint are required in non-interactive mode.") + + resolved_type = answers.get("content_type", content_type) + resolved_entrypoint = answers.get("entrypoint", entrypoint) + spec = _CONTENT_TYPES_BY_NAME[resolved_type] + + python = answers.get("python") + if spec.language == "python" and python is None and (package_file or package_manager): + python = { + "package_file": package_file or "requirements.txt", + "package_manager": package_manager or "pip", + } + elif spec.language != "python" and (package_file or package_manager): + raise click.UsageError("--package-file and --package-manager require Python content.") + + quarto = answers.get("quarto") + if resolved_type.startswith("quarto-"): + if quarto is None and not quarto_version: + raise click.UsageError("--quarto-version is required for Quarto content.") + quarto = quarto or {"version": quarto_version} + elif quarto_version: + raise click.UsageError("--quarto-version requires Quarto content.") + + try: + result = initialize_project( + InitRequest( + project_dir=project_dir, + content_type=resolved_type, + entrypoint=resolved_entrypoint, + config_name=config_name, + title=answers.get("title", title), + files=answers.get("files", files), + python=python, + quarto=quarto, + overwrite=overwrite, + ) + ) + except RSConnectException as exc: + raise click.ClickException(str(exc)) from exc + + click.echo("Initialized {} at {}".format(result.config_name, result.config_path)) diff --git a/src/posit_cli/connect/publish.py b/src/posit_cli/connect/publish.py new file mode 100644 index 0000000..e361c94 --- /dev/null +++ b/src/posit_cli/connect/publish.py @@ -0,0 +1,116 @@ +"""Publish a project from its .posit/publish configuration.""" + +from typing import Optional, Tuple + +import click +from rsconnect.exception import RSConnectException +from rsconnect.publisher import PublishRequest, publish_project + + +@click.command( + "publish", + short_help="Publish an initialized project.", + context_settings={"help_option_names": ["-h", "--help"]}, +) +@click.argument( + "project_dir", + default=".", + type=click.Path(exists=True, file_okay=False, resolve_path=True), +) +@click.option("--config", "config_name", help="Publisher configuration name.") +@click.option("--deployment", "deployment_name", help="Deployment record name.") +@click.option( + "--server", + "-s", + envvar="CONNECT_SERVER", + help="Connect server URL [env: CONNECT_SERVER].", +) +@click.option("--server-name", "--name", "-n", help="Nickname of a saved server.") +@click.option( + "--api-key", + "-k", + envvar="CONNECT_API_KEY", + help="Connect API key [env: CONNECT_API_KEY].", +) +@click.option( + "--snowflake-connection-name", + help="Snowflake connection name from the configuration file.", +) +@click.option( + "--no-tls-verify", + "insecure", + is_flag=True, + envvar="CONNECT_INSECURE", + help="Skip TLS certificate verification [env: CONNECT_INSECURE].", +) +@click.option( + "--cacert", + "-c", + envvar="CONNECT_CA_CERTIFICATE", + type=click.Path(exists=True, dir_okay=False), + help="Path to a trusted TLS CA certificate.", +) +@click.option("--content-id", help="Existing Connect content GUID or numeric ID.") +@click.option("--draft", is_flag=True, help="Deploy without activating the new bundle.") +@click.option( + "--verify/--no-verify", + default=None, + help="Override the configuration's post-deploy verification setting.", +) +@click.option( + "--exclude-renv", + is_flag=True, + help="Skip renv.lock detection when building the manifest.", +) +@click.option( + "--metadata", + multiple=True, + metavar="KEY=VALUE", + help="Include bundle metadata. May be specified multiple times.", +) +@click.option("--no-metadata", is_flag=True, help="Disable automatic git metadata.") +@click.pass_context +def publish( + ctx: click.Context, + project_dir: str, + config_name: Optional[str], + deployment_name: Optional[str], + server: Optional[str], + server_name: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + content_id: Optional[str], + draft: bool, + verify: Optional[bool], + exclude_renv: bool, + metadata: Tuple[str, ...], + no_metadata: bool, +) -> None: + """Publish PROJECT_DIR using its .posit/publish configuration.""" + try: + result = publish_project( + PublishRequest( + project_dir=project_dir, + config_name=config_name, + deployment_name=deployment_name, + server=server, + server_name=server_name, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + content_id=content_id, + draft=draft, + verify=verify, + exclude_renv=exclude_renv, + metadata=metadata, + no_metadata=no_metadata, + ctx=ctx, + ) + ) + except RSConnectException as exc: + raise click.ClickException(str(exc)) from exc + + click.echo(result.content_url) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e152ba..4eac37b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,6 +21,8 @@ def test_connect_mounts_api_command(runner): result = runner.invoke(cli, ["connect", "--help"]) assert result.exit_code == 0 assert "api" in result.output + assert "init" in result.output + assert "publish" in result.output # rsconnect commands we expect to re-expose under `posit connect`. diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py new file mode 100644 index 0000000..8e64a15 --- /dev/null +++ b/tests/test_publisher_cli.py @@ -0,0 +1,289 @@ +"""Tests for the Publisher-backed init and publish commands.""" + +import importlib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from click.testing import CliRunner +from rsconnect.exception import RSConnectException + +from posit_cli.__main__ import cli + + +init_mod = importlib.import_module("posit_cli.connect.init") +publish_mod = importlib.import_module("posit_cli.connect.publish") + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_init_without_flags_requires_tty(runner): + with patch.object(init_mod, "_is_interactive", return_value=False): + result = runner.invoke(cli, ["connect", "init"]) + + assert result.exit_code == 2 + assert "Interactive input is unavailable" in result.output + + +def test_init_explicit_flags_build_request(runner): + initialized = SimpleNamespace( + config_name="sales", config_path="/project/.posit/publish/sales.toml" + ) + with patch.object(init_mod, "initialize_project", return_value=initialized) as initialize: + result = runner.invoke( + cli, + [ + "connect", + "init", + "--type", + "python-fastapi", + "--entrypoint", + "app.py:app", + "--title", + "Sales API", + "--config", + "sales", + "--package-file", + "pyproject.toml", + "--package-manager", + "uv", + "--file", + "app.py", + "--file", + "src/**", + ], + ) + + assert result.exit_code == 0, result.output + request = initialize.call_args.args[0] + assert request.content_type == "python-fastapi" + assert request.entrypoint == "app.py:app" + assert request.title == "Sales API" + assert request.config_name == "sales" + assert request.python == { + "package_file": "pyproject.toml", + "package_manager": "uv", + } + assert request.files == ("app.py", "src/**") + assert "Initialized sales" in result.output + + +def test_init_writes_publisher_config(runner): + from rsconnect.publisher import config + + with runner.isolated_filesystem(): + Path("app.py").write_text("app = object()\n", encoding="utf-8") + Path("pyproject.toml").write_text("[project]\nname = 'sales'\n", encoding="utf-8") + result = runner.invoke( + cli, + [ + "connect", + "init", + "--type", + "python-fastapi", + "--entrypoint", + "app.py:app", + "--title", + "Sales API", + "--config", + "sales", + "--package-file", + "pyproject.toml", + "--package-manager", + "uv", + ], + ) + + config_path = Path(".posit/publish/sales.toml") + assert result.exit_code == 0, result.output + assert config_path.is_file() + initialized = config.read_config(str(config_path)) + assert initialized.type == "python-fastapi" + assert initialized.entrypoint == "app.py:app" + assert initialized.title == "Sales API" + assert initialized.python == { + "package_file": "pyproject.toml", + "package_manager": "uv", + } + + +def test_init_explicit_mode_requires_type_and_entrypoint(runner): + result = runner.invoke(cli, ["connect", "init", "--title", "Incomplete"]) + + assert result.exit_code == 2 + assert "--type and --entrypoint are required" in result.output + + +def test_init_quarto_requires_version(runner): + result = runner.invoke( + cli, + [ + "connect", + "init", + "--type", + "quarto-static", + "--entrypoint", + "report.qmd", + ], + ) + + assert result.exit_code == 2 + assert "--quarto-version is required" in result.output + + +def test_interactive_init_collects_python_answers(runner): + initialized = SimpleNamespace( + config_name="sales", config_path="/project/.posit/publish/sales.toml" + ) + user_input = ( + "\n".join( + [ + "python-fastapi", + "", + "Sales API", + "pyproject.toml", + "uv", + "", + ] + ) + + "\n" + ) + with patch.object(init_mod, "_is_interactive", return_value=True): + with patch.object(init_mod, "initialize_project", return_value=initialized) as initialize: + result = runner.invoke(cli, ["connect", "init"], input=user_input) + + assert result.exit_code == 0, result.output + request = initialize.call_args.args[0] + assert request.content_type == "python-fastapi" + assert request.entrypoint == "app.py:app" + assert request.title == "Sales API" + assert request.python == { + "package_file": "pyproject.toml", + "package_manager": "uv", + } + assert request.files == ("*",) + + +def test_interactive_quarto_asks_mode_and_version(runner): + initialized = SimpleNamespace( + config_name="report", config_path="/project/.posit/publish/report.toml" + ) + user_input = ( + "\n".join( + [ + "quarto-static", + "shiny", + "", + "Report", + "1.6.0", + "", + ] + ) + + "\n" + ) + with patch.object(init_mod, "_is_interactive", return_value=True): + with patch.object(init_mod, "initialize_project", return_value=initialized) as initialize: + result = runner.invoke(cli, ["connect", "init"], input=user_input) + + assert result.exit_code == 0, result.output + request = initialize.call_args.args[0] + assert request.content_type == "quarto-shiny" + assert request.quarto == {"version": "1.6.0"} + + +def test_init_wraps_rsconnect_errors(runner): + with patch.object( + init_mod, + "initialize_project", + side_effect=RSConnectException("configuration already exists"), + ): + result = runner.invoke( + cli, + [ + "connect", + "init", + "--type", + "html", + "--entrypoint", + "index.html", + ], + ) + + assert result.exit_code == 1 + assert "configuration already exists" in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + + +def test_publish_maps_all_request_fields(runner): + published = SimpleNamespace(content_url="https://connect.example/content/abc/") + with patch.object(publish_mod, "publish_project", return_value=published) as publish: + result = runner.invoke( + cli, + [ + "connect", + "publish", + ".", + "--config", + "sales-api", + "--deployment", + "production", + "--server", + "https://connect.example", + "--api-key", + "secret", + "--snowflake-connection-name", + "snowflake-prod", + "--no-tls-verify", + "--content-id", + "guid-1", + "--draft", + "--no-verify", + "--exclude-renv", + "--metadata", + "git_commit=abc", + "--no-metadata", + ], + ) + + assert result.exit_code == 0, result.output + request = publish.call_args.args[0] + assert request.config_name == "sales-api" + assert request.deployment_name == "production" + assert request.server == "https://connect.example" + assert request.api_key == "secret" + assert request.snowflake_connection_name == "snowflake-prod" + assert request.insecure is True + assert request.content_id == "guid-1" + assert request.draft is True + assert request.verify is False + assert request.exclude_renv is True + assert request.metadata == ("git_commit=abc",) + assert request.no_metadata is True + assert request.ctx is not None + assert result.output.strip() == published.content_url + + +def test_publish_server_name_alias(runner): + published = SimpleNamespace(content_url="https://connect.example/content/abc/") + with patch.object(publish_mod, "publish_project", return_value=published) as publish: + result = runner.invoke(cli, ["connect", "publish", "--name", "production"]) + + assert result.exit_code == 0, result.output + assert publish.call_args.args[0].server_name == "production" + + +def test_publish_wraps_rsconnect_errors(runner): + with patch.object( + publish_mod, + "publish_project", + side_effect=RSConnectException("specify server for the first publish"), + ): + result = runner.invoke(cli, ["connect", "publish"]) + + assert result.exit_code == 1 + assert "specify server for the first publish" in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) diff --git a/tests/test_rsconnect_contract.py b/tests/test_rsconnect_contract.py index bbb0881..42879e6 100644 --- a/tests/test_rsconnect_contract.py +++ b/tests/test_rsconnect_contract.py @@ -38,3 +38,32 @@ def test_http_response_surface(): params = inspect.signature(HTTPResponse.__init__).parameters assert {"full_uri", "response", "body", "exception"} <= set(params) + + +def test_publisher_service_surface(): + from rsconnect.publisher import ( + CONTENT_TYPES, + InitRequest, + PublishRequest, + initialize_project, + publish_project, + ) + + assert CONTENT_TYPES + assert {"project_dir", "content_type", "entrypoint"} <= set( + inspect.signature(InitRequest).parameters + ) + assert { + "project_dir", + "config_name", + "deployment_name", + "server", + "server_name", + "api_key", + "insecure", + "cacert", + "verify", + "metadata", + } <= set(inspect.signature(PublishRequest).parameters) + assert callable(initialize_project) + assert callable(publish_project) diff --git a/uv.lock b/uv.lock index a541b51..8d6735d 100644 --- a/uv.lock +++ b/uv.lock @@ -664,6 +664,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version == '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pip" version = "25.0.1" @@ -755,7 +781,7 @@ requires-dist = [ { name = "jq", specifier = ">=1.4" }, { name = "keyring", specifier = ">=23.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, - { name = "rsconnect-python", specifier = ">=1.30,<2" }, + { name = "rsconnect-python", git = "https://github.com/posit-dev/rsconnect-python.git?rev=c92d8f4ac033a5b1bb4f67bbaae33bf8e0b143e5" }, { name = "ruff", marker = "extra == 'lint'", specifier = ">=0.6" }, ] provides-extras = ["lint", "test"] @@ -898,12 +924,14 @@ wheels = [ [[package]] name = "rsconnect-python" -version = "1.30.0" -source = { registry = "https://pypi.org/simple" } +version = "1.30.1.dev1" +source = { git = "https://github.com/posit-dev/rsconnect-python.git?rev=c92d8f4ac033a5b1bb4f67bbaae33bf8e0b143e5#c92d8f4ac033a5b1bb4f67bbaae33bf8e0b143e5" } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging" }, + { name = "pathspec", version = "0.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pip", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pip", version = "26.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pip", version = "26.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -911,14 +939,12 @@ dependencies = [ { name = "pyjwt", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "semver" }, { name = "toml", marker = "python_full_version < '3.11'" }, + { name = "tomli-w", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "tomli-w", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/89/f39d015ed2f93b87f91ded62a2a9d9bbbf0b68bbc014869e670acd396889/rsconnect_python-1.30.0.tar.gz", hash = "sha256:cec4effe8267ca6a153f64c859bf72090d6bef006bca3b5b55371c767751bef0", size = 148054, upload-time = "2026-07-16T10:40:27.717Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/71/47804f7921725184e7c48ed092720039584c9a3e7afb3ddd488e265df5d5/rsconnect_python-1.30.0-py3-none-any.whl", hash = "sha256:1446643dbd3ce0a0489c28913334149c732eaa6448a99ae038d69153f3662303", size = 170470, upload-time = "2026-07-16T10:40:26.031Z" }, -] [[package]] name = "ruff" @@ -1052,6 +1078,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomli-w" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/49/05/6bf21838623186b91aedbda06248ad18f03487dc56fbc20e4db384abde6c/tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9", size = 6531, upload-time = "2021-12-01T23:55:11.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/01/1da9c66ecb20f31ed5aa5316a957e0b1a5e786a0d9689616ece4ceaf1321/tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463", size = 5984, upload-time = "2021-12-01T23:55:10.364Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version == '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2" From 673be293c37b11e7b6bde4afeb85719a017d9783 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 15:25:32 -0400 Subject: [PATCH 02/14] Use Questionary for interactive init --- .github/workflows/ci.yaml | 1 - README.md | 7 +- pyproject.toml | 3 +- src/posit_cli/connect/init.py | 88 +++++-- tests/test_api.py | 2 +- tests/test_publisher_cli.py | 67 +++-- uv.lock | 449 ++++++++-------------------------- 7 files changed, 211 insertions(+), 406 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0cd37c0..719f53e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.8" - "3.9" - "3.10" - "3.11" diff --git a/README.md b/README.md index 0b0a848..890b1f9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ This project is in early-stage development and so far only supports Posit Connec ## Installation +`posit-cli` requires Python 3.9 or newer. + Install [`posit-cli` from PyPI](https://pypi.org/project/posit-cli/) with [`uv`](https://docs.astral.sh/uv/): @@ -128,8 +130,9 @@ $ posit connect api v1/content -f name=my-app # POST body (creates ## `posit connect init` and `publish` -Run `posit connect init` in a terminal for an interactive setup wizard, or pass -the required content type and entrypoint explicitly: +Run `posit connect init` in a terminal for a Questionary-powered setup wizard +with navigable choices, or pass the required content type and entrypoint +explicitly: ```console $ posit connect init diff --git a/pyproject.toml b/pyproject.toml index a2bcbcd..54f7238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ name = "posit-cli" dynamic = ["version"] description = "A single, friendly command-line interface for Posit Connect, in the spirit of gh." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Posit Software, PBC" }] @@ -19,6 +19,7 @@ dependencies = [ # tokens land in the OS keyring. "keyring>=23.0", "click>=8.0", + "questionary>=2.1.1,<3", # jq bindings power `posit connect api --jq`, mirroring `gh api --jq`. "jq>=1.4", ] diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index fa6f530..1935459 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -6,6 +6,7 @@ from typing import Any, Dict, Optional, Tuple import click +import questionary from rsconnect.exception import RSConnectException from rsconnect.publisher import CONTENT_TYPES, InitRequest, initialize_project @@ -31,38 +32,69 @@ def _default_title(project_dir: str, entrypoint: str) -> str: return project_name or Path(entrypoint.split(":", 1)[0]).stem +def _ask(prompt: Any) -> Any: + try: + return prompt.unsafe_ask() + except (EOFError, KeyboardInterrupt) as exc: + raise click.Abort() from exc + + +def _required(value: str) -> bool: + return bool(value.strip()) + + def collect_init_answers(project_dir: str) -> Dict[str, Any]: """Prompt for initialization values without performing initialization.""" - click.echo("Content types:") - for spec in CONTENT_TYPES: - click.echo(" {:<18} {}".format(spec.type, spec.label)) - - content_type = click.prompt( - "Content type", - type=click.Choice(_CONTENT_TYPE_NAMES, case_sensitive=False), + content_type = _ask( + questionary.select( + "Content type", + choices=[ + questionary.Choice(title=spec.label, value=spec.type) for spec in CONTENT_TYPES + ], + ) ) if content_type.startswith("quarto-"): - mode = click.prompt( - "Quarto mode", - type=click.Choice(("static", "shiny"), case_sensitive=False), - default=content_type[len("quarto-") :], - show_choices=True, + mode = _ask( + questionary.select( + "Quarto mode", + choices=("static", "shiny"), + default=content_type[len("quarto-") :], + ) ) content_type = "quarto-" + mode spec = _CONTENT_TYPES_BY_NAME[content_type] - entrypoint = click.prompt("Entrypoint", default=spec.entrypoint_example) - title = click.prompt("Title", default=_default_title(project_dir, entrypoint)) + entrypoint = _ask( + questionary.text( + "Entrypoint", + default=spec.entrypoint_example, + validate=_required, + ) + ) + title = _ask( + questionary.text( + "Title", + default=_default_title(project_dir, entrypoint), + validate=_required, + ) + ) python: Optional[Dict[str, str]] = None if spec.language == "python": package_file, package_manager = _default_package_settings(project_dir) - package_file = click.prompt("Python package file", default=package_file) - package_manager = click.prompt( - "Python package manager", - type=click.Choice(_PYTHON_PACKAGE_MANAGERS, case_sensitive=False), - default=package_manager, - show_choices=True, + package_file = _ask( + questionary.text( + "Python package file", + default=package_file, + validate=_required, + ) + ) + package_manager = _ask( + questionary.select( + "Python package manager", + choices=_PYTHON_PACKAGE_MANAGERS, + default=package_manager, + ) ) python = { "package_file": package_file, @@ -71,11 +103,21 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: quarto: Optional[Dict[str, str]] = None if content_type.startswith("quarto-"): - quarto = {"version": click.prompt("Quarto version")} + quarto = { + "version": _ask( + questionary.text( + "Quarto version", + validate=_required, + ) + ) + } files = ("*",) - if not click.confirm( - "Use detected file patterns ({})?".format(", ".join(files)), default=True + if not _ask( + questionary.confirm( + "Use detected file patterns ({})?".format(", ".join(files)), + default=True, + ) ): raise click.Abort() diff --git a/tests/test_api.py b/tests/test_api.py index 416530d..b23f8dc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -267,7 +267,7 @@ def test_include_jq_runtime_error_leaks_nothing_to_stdout(runner): assert result.exit_code != 0 # Checked against combined output, not result.stdout/.stderr separately: # Click's CliRunner only captures those on separate streams in >=8.2 - # (older click, still resolved for our py3.8/3.9 floor, always mixes them). + # (older Click, still resolved at our Python 3.9 floor, always mixes them). assert "HTTP/" not in result.output # no header lines leaked assert "neal" not in result.output # no body leaked assert "jq: boom" in result.output diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 8e64a15..d273640 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -3,7 +3,7 @@ import importlib from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner @@ -23,10 +23,12 @@ def runner(): def test_init_without_flags_requires_tty(runner): with patch.object(init_mod, "_is_interactive", return_value=False): - result = runner.invoke(cli, ["connect", "init"]) + with patch.object(init_mod.questionary, "select") as select: + result = runner.invoke(cli, ["connect", "init"]) assert result.exit_code == 2 assert "Interactive input is unavailable" in result.output + assert not select.called def test_init_explicit_flags_build_request(runner): @@ -139,22 +141,23 @@ def test_interactive_init_collects_python_answers(runner): initialized = SimpleNamespace( config_name="sales", config_path="/project/.posit/publish/sales.toml" ) - user_input = ( - "\n".join( - [ + with patch.object(init_mod, "_is_interactive", return_value=True): + with patch.object( + init_mod, + "_ask", + side_effect=[ "python-fastapi", - "", + "app.py:app", "Sales API", "pyproject.toml", "uv", - "", - ] - ) - + "\n" - ) - with patch.object(init_mod, "_is_interactive", return_value=True): - with patch.object(init_mod, "initialize_project", return_value=initialized) as initialize: - result = runner.invoke(cli, ["connect", "init"], input=user_input) + True, + ], + ): + with patch.object( + init_mod, "initialize_project", return_value=initialized + ) as initialize: + result = runner.invoke(cli, ["connect", "init"]) assert result.exit_code == 0, result.output request = initialize.call_args.args[0] @@ -172,22 +175,23 @@ def test_interactive_quarto_asks_mode_and_version(runner): initialized = SimpleNamespace( config_name="report", config_path="/project/.posit/publish/report.toml" ) - user_input = ( - "\n".join( - [ + with patch.object(init_mod, "_is_interactive", return_value=True): + with patch.object( + init_mod, + "_ask", + side_effect=[ "quarto-static", "shiny", - "", + "report.qmd", "Report", "1.6.0", - "", - ] - ) - + "\n" - ) - with patch.object(init_mod, "_is_interactive", return_value=True): - with patch.object(init_mod, "initialize_project", return_value=initialized) as initialize: - result = runner.invoke(cli, ["connect", "init"], input=user_input) + True, + ], + ): + with patch.object( + init_mod, "initialize_project", return_value=initialized + ) as initialize: + result = runner.invoke(cli, ["connect", "init"]) assert result.exit_code == 0, result.output request = initialize.call_args.args[0] @@ -195,6 +199,17 @@ def test_interactive_quarto_asks_mode_and_version(runner): assert request.quarto == {"version": "1.6.0"} +def test_interactive_init_aborts_cleanly(runner): + prompt = MagicMock() + prompt.unsafe_ask.side_effect = KeyboardInterrupt + with patch.object(init_mod, "_is_interactive", return_value=True): + with patch.object(init_mod.questionary, "select", return_value=prompt): + result = runner.invoke(cli, ["connect", "init"]) + + assert result.exit_code == 1 + assert "Aborted!" in result.output + + def test_init_wraps_rsconnect_errors(runner): with patch.object( init_mod, diff --git a/uv.lock b/uv.lock index 8d6735d..d627fcd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.8" +requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", - "python_full_version < '3.9'", + "python_full_version <= '3.9'", ] [[package]] @@ -17,74 +16,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] -[[package]] -name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/c2/5b/f1523dd545f92f7df468e5f653ffa4df30ac222f3c884e51e139878f1cb5/cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", size = 425932, upload-time = "2024-09-04T20:44:49.491Z" }, - { url = "https://files.pythonhosted.org/packages/53/93/7e547ab4105969cc8c93b38a667b82a835dd2cc78f3a7dad6130cfd41e1d/cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", size = 448585, upload-time = "2024-09-04T20:44:51.671Z" }, - { url = "https://files.pythonhosted.org/packages/56/c4/a308f2c332006206bb511de219efeff090e9d63529ba0a77aae72e82248b/cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", size = 456268, upload-time = "2024-09-04T20:44:53.51Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5b/b63681518265f2f4060d2b60755c1c77ec89e5e045fc3773b72735ddaad5/cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", size = 436592, upload-time = "2024-09-04T20:44:55.085Z" }, - { url = "https://files.pythonhosted.org/packages/bb/19/b51af9f4a4faa4a8ac5a0e5d5c2522dcd9703d07fac69da34a36c4d960d3/cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", size = 446512, upload-time = "2024-09-04T20:44:57.135Z" }, - { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, - { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, - { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, - { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, - { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, -] - [[package]] name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } @@ -147,8 +84,7 @@ version = "8.1.8" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", - "python_full_version < '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, @@ -187,14 +123,11 @@ name = "cryptography" version = "47.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.9'", - "python_full_version < '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9'" }, + { name = "cffi", marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ @@ -246,8 +179,8 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, + { name = "cffi", marker = "python_full_version > '3.9' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -295,39 +228,23 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, -] - [[package]] name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -349,26 +266,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] -[[package]] -name = "importlib-resources" -version = "6.4.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/be/f3e8c6081b684f176b761e6a2fef02a0be939740ed6f54109a2951d806f3/importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065", size = 43372, upload-time = "2024-09-09T17:03:14.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/6a/4604f9ae2fa62ef47b9de2fa5ad599589d28c9fd1d335f32759813dfa91e/importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717", size = 36115, upload-time = "2024-09-09T17:03:13.39Z" }, -] - [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", - "python_full_version < '3.9'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ @@ -392,8 +296,7 @@ name = "jaraco-classes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools", version = "10.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } @@ -401,31 +304,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, ] -[[package]] -name = "jaraco-context" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, -] - [[package]] name = "jaraco-context" version = "6.1.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ - { name = "backports-tarfile", marker = "python_full_version == '3.9.*'" }, + { name = "backports-tarfile", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } wheels = [ @@ -447,31 +335,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, ] -[[package]] -name = "jaraco-functools" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "more-itertools", version = "10.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159, upload-time = "2024-09-27T19:47:09.122Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187, upload-time = "2024-09-27T19:47:07.14Z" }, -] - [[package]] name = "jaraco-functools" version = "4.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ - { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ @@ -550,12 +423,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/65/e41f566f5ce79ec9fbaeaea86944f4e2f9f258622ad2fe165c275f8711b7/jq-1.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e46494407cb074d2ffc35337cbe841686aa42f3d1a49901fab3571b55c2e2463", size = 757153, upload-time = "2026-01-16T16:37:38.184Z" }, { url = "https://files.pythonhosted.org/packages/c1/05/7dce2693991526c40b227c552e2829210099c38ef1c1d0f545ceafa57f0b/jq-1.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2bea91038d8ea749c54cc06f916afe07a2dbfa05817f3945f89efa75e3dd9517", size = 765389, upload-time = "2026-01-16T16:37:40.136Z" }, { url = "https://files.pythonhosted.org/packages/2c/b6/355daf5412b0d7730416e693b835d6688cc4a717b249beeb3faf073c0a47/jq-1.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:219ff02280ee55d2a57c0519b1b122003e538975e30522c21372d6df74b12317", size = 429233, upload-time = "2026-01-16T16:37:43.483Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bc/211c912723ae74676d15a36cb103b4eba66010fb55c79cae4a3a52e8c2ee/jq-1.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cd04536e250e9f9e123356b56d07e3320adfeffed166b8d6532d9f265f9ebcd2", size = 417267, upload-time = "2026-01-16T16:37:48.583Z" }, - { url = "https://files.pythonhosted.org/packages/18/6f/436f37d127d91a8c45da70b6ce943782f39d4818583a6dc91ae25c8dbed6/jq-1.11.0-cp38-cp38-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e65a38ce2ce3f8b5fc522b5d1e09351886a09026daa9e4ab6c258f0739b94b", size = 755513, upload-time = "2026-01-16T16:37:51.733Z" }, - { url = "https://files.pythonhosted.org/packages/93/d9/252aed7bfb3adb3e31046f9a7963609a35ee544e278051227f409cdaa120/jq-1.11.0-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e10f635d594a7ebec56175c51deaadce94dd506b1c97d6f6ae0a6c3322b804c8", size = 766969, upload-time = "2026-01-16T16:37:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/b0/be/3170d55586ec1239bc0c669cd88f6efdc758b52986e0821cb2cc056321c4/jq-1.11.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b18cda2f5a51ea7455beef19db604c6c5943ca9b994bd63d4a77ebb3db19dfec", size = 742910, upload-time = "2026-01-16T16:37:56.26Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b0/310c0ad7771432176ef6a807c29588578dc768a850925a68a23bcb3fb8d9/jq-1.11.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:610b51ea8a19275a22b290e419d665711f9b4e373602309f5fbdb2b507ad7050", size = 764671, upload-time = "2026-01-16T16:37:57.988Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/229b815938944a0466ac5b534d5efac5871cb249936528249c2a06e56452/jq-1.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:24ca54f24b21f2d7ef8f664c582716b44a6f5e5770ccbf475fc33b7b2d3145a5", size = 409698, upload-time = "2026-01-16T16:38:00.177Z" }, { url = "https://files.pythonhosted.org/packages/43/08/30fc4496f8a3edbfce049b6d7221a8d952c9f3e5791acf99b40a42166bfc/jq-1.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ecff3e4794058fe7acf5bf3dc75526954783adb8dd0907e94a757b4137b97e76", size = 415785, upload-time = "2026-01-16T16:38:02.824Z" }, { url = "https://files.pythonhosted.org/packages/4e/43/52ef6dda9add10e6ee72bd9efbee9f8043b62c28bcd389aa2cc95bfae17e/jq-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75ae4fb6891ca8ad19e986392f22080ec835072f6844c7f632241c019d26e6fc", size = 423300, upload-time = "2026-01-16T16:38:05.097Z" }, { url = "https://files.pythonhosted.org/packages/55/9e/66e82c3263eefe9b5b8632956a97d53395a3d435917a559db41808ca1400/jq-1.11.0-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a39c154e50949d87434ac40edfe1d6f479bb248acf4e9059d2a64f37a734780", size = 745359, upload-time = "2026-01-16T16:38:08.246Z" }, @@ -569,48 +436,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/39/0d819962352f178492069ba2767b4983e302a9867e856cec624f06bd21ed/jq-1.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:594ebd007244e16b333bd2f35a5b766176be107ee99f9d92883a79d50439b93c", size = 425107, upload-time = "2026-01-16T16:38:26.969Z" }, ] -[[package]] -name = "keyring" -version = "25.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "importlib-resources", marker = "python_full_version < '3.9'" }, - { name = "jaraco-classes", marker = "python_full_version < '3.9'" }, - { name = "jaraco-context", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "jaraco-functools", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "jeepney", marker = "python_full_version < '3.9' and sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' and sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/24/64447b13df6a0e2797b586dad715766d756c932ce8ace7f67bd384d76ae0/keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6", size = 62675, upload-time = "2024-10-26T15:40:12.344Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/c9/353c156fa2f057e669106e5d6bcdecf85ef8d3536ce68ca96f18dc7b6d6f/keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741", size = 39096, upload-time = "2024-10-26T15:40:10.296Z" }, -] - [[package]] name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "importlib-metadata", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, - { name = "jaraco-classes", marker = "python_full_version >= '3.9'" }, - { name = "jaraco-context", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "jaraco-context", version = "6.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jaraco-functools", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jaraco-functools", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "jaraco-functools", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jeepney", marker = "python_full_version >= '3.9' and sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, - { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*' and sys_platform == 'linux'" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, { name = "secretstorage", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } @@ -618,25 +458,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] -[[package]] -name = "more-itertools" -version = "10.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/51/78/65922308c4248e0eb08ebcbe67c95d48615cc6f27854b6f2e57143e9178f/more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6", size = 121020, upload-time = "2024-09-05T15:28:22.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/7e/3a64597054a70f7c86eb0a7d4fc315b8c1ab932f64883a297bdffeb5f967/more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef", size = 60952, upload-time = "2024-09-05T15:28:20.141Z" }, -] - [[package]] name = "more-itertools" version = "10.8.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } wheels = [ @@ -664,51 +492,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] -[[package]] -name = "pip" -version = "25.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/70/53/b309b4a497b09655cb7e07088966881a57d082f48ac3cb54ea729fd2c6cf/pip-25.0.1.tar.gz", hash = "sha256:88f96547ea48b940a3a385494e181e29fb8637898f88d88737c5049780f196ea", size = 1950850, upload-time = "2025-02-09T17:14:04.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/bc/b7db44f5f39f9d0494071bddae6880eb645970366d0a200022a1a93d57f5/pip-25.0.1-py3-none-any.whl", hash = "sha256:c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", size = 1841526, upload-time = "2025-02-09T17:14:01.463Z" }, -] - [[package]] name = "pip" version = "26.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } wheels = [ @@ -727,27 +526,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, ] -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -760,8 +542,8 @@ dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "jq" }, - { name = "keyring", version = "25.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "keyring", version = "25.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "keyring" }, + { name = "questionary" }, { name = "rsconnect-python" }, ] @@ -770,8 +552,7 @@ lint = [ { name = "ruff" }, ] test = [ - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] @@ -781,19 +562,50 @@ requires-dist = [ { name = "jq", specifier = ">=1.4" }, { name = "keyring", specifier = ">=23.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, + { name = "questionary", specifier = ">=2.1.1,<3" }, { name = "rsconnect-python", git = "https://github.com/posit-dev/rsconnect-python.git?rev=c92d8f4ac033a5b1bb4f67bbaae33bf8e0b143e5" }, { name = "ruff", marker = "extra == 'lint'", specifier = ">=0.6" }, ] provides-extras = ["lint", "test"] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "wcwidth", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "wcwidth", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + [[package]] name = "pycparser" version = "2.23" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", - "python_full_version < '3.9'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } wheels = [ @@ -821,71 +633,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyjwt" -version = "2.9.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825, upload-time = "2024-08-01T15:01:08.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344, upload-time = "2024-08-01T15:01:06.481Z" }, -] - [[package]] name = "pyjwt" version = "2.13.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] -[[package]] -name = "pytest" -version = "8.3.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, -] - [[package]] name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -904,7 +679,7 @@ dependencies = [ { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, { name = "pygments", marker = "python_full_version >= '3.10'" }, { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] @@ -922,6 +697,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit", version = "3.0.52", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "prompt-toolkit", version = "3.0.53", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "rsconnect-python" version = "1.30.1.dev1" @@ -930,19 +718,14 @@ dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging" }, - { name = "pathspec", version = "0.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pip", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pip", version = "26.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pathspec" }, + { name = "pip", version = "26.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pip", version = "26.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyjwt", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pyjwt", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pyjwt" }, { name = "semver" }, { name = "toml", marker = "python_full_version < '3.11'" }, - { name = "tomli-w", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli-w", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "tomli-w" }, + { name = "typing-extensions" }, { name = "uv" }, ] @@ -977,8 +760,7 @@ version = "3.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", - "python_full_version < '3.9'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, @@ -1078,53 +860,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "tomli-w" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/49/05/6bf21838623186b91aedbda06248ad18f03487dc56fbc20e4db384abde6c/tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9", size = 6531, upload-time = "2021-12-01T23:55:11.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/01/1da9c66ecb20f31ed5aa5316a957e0b1a5e786a0d9689616ece4ceaf1321/tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463", size = 5984, upload-time = "2021-12-01T23:55:10.364Z" }, -] - [[package]] name = "tomli-w" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] -[[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -1157,15 +905,12 @@ wheels = [ ] [[package]] -name = "zipp" -version = "3.20.2" +name = "wcwidth" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199, upload-time = "2024-09-13T13:44:16.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200, upload-time = "2024-09-13T13:44:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] @@ -1174,7 +919,7 @@ version = "3.23.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version == '3.9'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } wheels = [ From 82acf5b0fcad45d17cb6e10f10cc18a52909af8e Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 16:00:48 -0400 Subject: [PATCH 03/14] Polish the interactive init wizard --- src/posit_cli/connect/init.py | 136 +++++++++++++++++++++++++++++----- tests/test_publisher_cli.py | 4 + 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index 1935459..13f3239 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -1,6 +1,7 @@ """Initialize a project for the Posit Publisher workflow.""" import os +import shlex import sys from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -14,6 +15,18 @@ _CONTENT_TYPES_BY_NAME = {spec.type: spec for spec in CONTENT_TYPES} _CONTENT_TYPE_NAMES = tuple(_CONTENT_TYPES_BY_NAME) _PYTHON_PACKAGE_MANAGERS = ("pip", "uv", "none") +_QUESTIONARY_STYLE = questionary.Style( + [ + ("qmark", "fg:#44739b bold"), + ("question", "bold"), + ("answer", "fg:#44739b bold"), + ("pointer", "fg:#44739b bold"), + ("highlighted", "fg:#44739b bold"), + ("selected", "fg:#44739b"), + ("instruction", "fg:#7c8793"), + ("disabled", "fg:#858585 italic"), + ] +) def _is_interactive() -> bool: @@ -43,37 +56,111 @@ def _required(value: str) -> bool: return bool(value.strip()) +def _select(message: str, **kwargs: Any) -> Any: + return questionary.select( + message, + qmark=">", + pointer=">", + style=_QUESTIONARY_STYLE, + **kwargs, + ) + + +def _text(message: str, **kwargs: Any) -> Any: + return questionary.text( + message, + qmark=">", + style=_QUESTIONARY_STYLE, + **kwargs, + ) + + +def _confirm(message: str, **kwargs: Any) -> Any: + return questionary.confirm( + message, + qmark=">", + style=_QUESTIONARY_STYLE, + **kwargs, + ) + + +def _show_banner(project_dir: str) -> None: + mark = click.style + click.echo() + click.echo(mark(" /\\ /\\", fg="blue", bold=True)) + click.echo(mark(" / \\/ \\", fg="blue", bold=True) + " " + mark("Connect", bold=True)) + click.echo(mark(" \\ /\\ /", fg="blue", bold=True)) + click.echo(mark(" \\/ \\/", fg="blue", bold=True)) + click.echo() + click.secho("Configure a project for Posit Connect", bold=True) + click.echo( + click.style("Project ", fg="bright_black") + + click.style(os.path.abspath(project_dir), bold=True) + ) + click.echo() + + +def _note(message: str) -> None: + click.secho(" " + message, fg="bright_black") + + +def _show_success(project_dir: str, config_path: str) -> None: + relative_config = os.path.relpath(config_path, project_dir) + displayed_config = config_path if relative_config.startswith("..") else relative_config + publish_target = ( + "." + if os.path.abspath(project_dir) == os.path.abspath(os.getcwd()) + else shlex.quote(project_dir) + ) + + click.echo() + click.secho("[OK] Publisher project initialized", fg="green", bold=True) + click.echo(click.style(" Config ", fg="bright_black") + displayed_config) + click.echo( + click.style(" Next ", fg="bright_black") + + "posit connect publish {} --server ".format(publish_target) + ) + + def collect_init_answers(project_dir: str) -> Dict[str, Any]: """Prompt for initialization values without performing initialization.""" + _show_banner(project_dir) + _note("Choose the framework or document type used by this project.") content_type = _ask( - questionary.select( - "Content type", + _select( + "What kind of content are you publishing?", choices=[ questionary.Choice(title=spec.label, value=spec.type) for spec in CONTENT_TYPES ], ) ) if content_type.startswith("quarto-"): + _note("Static projects render documents; Shiny projects run interactively.") mode = _ask( - questionary.select( - "Quarto mode", - choices=("static", "shiny"), + _select( + "How should this Quarto project run on Connect?", + choices=( + questionary.Choice("Static document", value="static"), + questionary.Choice("Interactive Shiny document", value="shiny"), + ), default=content_type[len("quarto-") :], ) ) content_type = "quarto-" + mode spec = _CONTENT_TYPES_BY_NAME[content_type] + _note("Use a project-relative path; APIs may use file.py:object.") entrypoint = _ask( - questionary.text( - "Entrypoint", + _text( + "Which file or module should Connect run?", default=spec.entrypoint_example, validate=_required, ) ) + _note("This is the name users will see in the Connect dashboard.") title = _ask( - questionary.text( - "Title", + _text( + "What title should appear in the Connect dashboard?", default=_default_title(project_dir, entrypoint), validate=_required, ) @@ -82,17 +169,23 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: python: Optional[Dict[str, str]] = None if spec.language == "python": package_file, package_manager = _default_package_settings(project_dir) + _note("Choose requirements.txt, pyproject.toml, or another dependency file.") package_file = _ask( - questionary.text( - "Python package file", + _text( + "Which file defines this project's Python dependencies?", default=package_file, validate=_required, ) ) + _note("Connect uses this installer while restoring the Python environment.") package_manager = _ask( - questionary.select( - "Python package manager", - choices=_PYTHON_PACKAGE_MANAGERS, + _select( + "How should Connect install the Python dependencies?", + choices=( + questionary.Choice("pip - Install with pip", value="pip"), + questionary.Choice("uv - Resolve and install with uv", value="uv"), + questionary.Choice("none - Do not install Python packages", value="none"), + ), default=package_manager, ) ) @@ -103,19 +196,21 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: quarto: Optional[Dict[str, str]] = None if content_type.startswith("quarto-"): + _note("Enter an exact version, for example 1.6.42.") quarto = { "version": _ask( - questionary.text( - "Quarto version", + _text( + "Which Quarto version should Connect use?", validate=_required, ) ) } files = ("*",) + _note("The initial '*' pattern includes the project tree and can be refined later.") if not _ask( - questionary.confirm( - "Use detected file patterns ({})?".format(", ".join(files)), + _confirm( + "Use '{}' as the initial project file pattern?".format(", ".join(files)), default=True, ) ): @@ -264,4 +359,7 @@ def init( except RSConnectException as exc: raise click.ClickException(str(exc)) from exc - click.echo("Initialized {} at {}".format(result.config_name, result.config_path)) + if answers: + _show_success(project_dir, result.config_path) + else: + click.echo("Initialized {} at {}".format(result.config_name, result.config_path)) diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index d273640..418cd53 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -169,6 +169,10 @@ def test_interactive_init_collects_python_answers(runner): "package_manager": "uv", } assert request.files == ("*",) + assert "Connect" in result.output + assert "Configure a project for Posit Connect" in result.output + assert "[OK] Publisher project initialized" in result.output + assert "posit connect publish . --server " in result.output def test_interactive_quarto_asks_mode_and_version(runner): From 88ebfddf8728c3d63d550b6f8173761dde57204b Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 16:15:30 -0400 Subject: [PATCH 04/14] Improve interactive project discovery --- src/posit_cli/connect/init.py | 160 ++++++++++++++++++++++++++++------ tests/test_publisher_cli.py | 76 +++++++++++++++- 2 files changed, 207 insertions(+), 29 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index 13f3239..e148fc4 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -4,7 +4,7 @@ import shlex import sys from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import click import questionary @@ -14,7 +14,30 @@ _CONTENT_TYPES_BY_NAME = {spec.type: spec for spec in CONTENT_TYPES} _CONTENT_TYPE_NAMES = tuple(_CONTENT_TYPES_BY_NAME) -_PYTHON_PACKAGE_MANAGERS = ("pip", "uv", "none") +_PYTHON_PACKAGE_MANAGERS = ("uv", "pip", "none") +_OTHER_ENTRYPOINT = "__other_entrypoint__" +_OTHER_PACKAGE_FILE = "__other_package_file__" +_PYTHON_API_TYPES = {"python-fastapi", "python-flask", "python-dash"} +_ENTRYPOINT_PRIORITY = ( + "app.py", + "main.py", + "application.py", + "api.py", + "report.ipynb", + "notebook.ipynb", + "report.qmd", + "index.qmd", + "index.html", + "index.htm", + "app.js", + "server.js", + "index.js", + "main.js", + "app.ts", + "server.ts", + "index.ts", + "main.ts", +) _QUESTIONARY_STYLE = questionary.Style( [ ("qmark", "fg:#44739b bold"), @@ -33,11 +56,72 @@ def _is_interactive() -> bool: return sys.stdin.isatty() -def _default_package_settings(project_dir: str) -> Tuple[str, str]: - if os.path.exists(os.path.join(project_dir, "pyproject.toml")): - manager = "uv" if os.path.exists(os.path.join(project_dir, "uv.lock")) else "pip" - return "pyproject.toml", manager - return "requirements.txt", "pip" +def _entrypoint_example(content_type: str) -> str: + example = _CONTENT_TYPES_BY_NAME[content_type].entrypoint_example + if content_type in _PYTHON_API_TYPES: + return example.split(":", 1)[0] + return example + + +def _entrypoint_suffixes(content_type: str) -> Tuple[str, ...]: + if content_type.startswith("python-"): + return (".py",) + if content_type.startswith("jupyter-"): + return (".ipynb",) + if content_type.startswith("quarto-"): + return (".qmd",) + if content_type == "html": + return (".html", ".htm") + if content_type == "nodejs": + return (".js", ".mjs", ".cjs", ".ts") + return () + + +def _entrypoint_choices(project_dir: str, content_type: str) -> Tuple[List[Any], str]: + suffixes = _entrypoint_suffixes(content_type) + priority = {name: index for index, name in enumerate(_ENTRYPOINT_PRIORITY)} + + try: + candidates = [ + path.name + for path in Path(project_dir).iterdir() + if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in suffixes + ] + except OSError: + candidates = [] + + candidates.sort(key=lambda name: (priority.get(name.lower(), len(priority)), name.lower())) + default = candidates[0] if candidates else _entrypoint_example(content_type) + choices = [questionary.Choice("{} (detected)".format(name), value=name) for name in candidates] + if not candidates: + choices.append(questionary.Choice(default, value=default)) + choices.append( + questionary.Choice( + "Other - Enter a different file or module", + value=_OTHER_ENTRYPOINT, + ) + ) + return choices, default + + +def _package_file_choices() -> Tuple[Tuple[Any, ...], str]: + return ( + ( + questionary.Choice( + "requirements.txt", + value="requirements.txt", + ), + questionary.Choice( + "pyproject.toml", + value="pyproject.toml", + ), + questionary.Choice( + "Other dependency file...", + value=_OTHER_PACKAGE_FILE, + ), + ), + "requirements.txt", + ) def _default_title(project_dir: str, entrypoint: str) -> str: @@ -85,12 +169,20 @@ def _confirm(message: str, **kwargs: Any) -> Any: def _show_banner(project_dir: str) -> None: - mark = click.style click.echo() - click.echo(mark(" /\\ /\\", fg="blue", bold=True)) - click.echo(mark(" / \\/ \\", fg="blue", bold=True) + " " + mark("Connect", bold=True)) - click.echo(mark(" \\ /\\ /", fg="blue", bold=True)) - click.echo(mark(" \\/ \\/", fg="blue", bold=True)) + click.echo( + click.style(" / ", fg="bright_blue", bold=True) + click.style("/\\", fg="blue", bold=True) + ) + click.echo( + click.style("< ", fg="bright_blue", bold=True) + + click.style("< >", fg="blue", bold=True) + + " " + + click.style("Connect", bold=True) + ) + click.echo( + click.style(" \\ ", fg="bright_blue", bold=True) + + click.style("\\/", fg="blue", bold=True) + ) click.echo() click.secho("Configure a project for Posit Connect", bold=True) click.echo( @@ -149,14 +241,24 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: content_type = "quarto-" + mode spec = _CONTENT_TYPES_BY_NAME[content_type] - _note("Use a project-relative path; APIs may use file.py:object.") + entrypoint_choices, entrypoint_default = _entrypoint_choices(project_dir, content_type) + _note("Choose a project file, or select Other for a custom entrypoint.") entrypoint = _ask( - _text( - "Which file or module should Connect run?", - default=spec.entrypoint_example, - validate=_required, + _select( + "Which file should Connect run?", + choices=entrypoint_choices, + default=entrypoint_default, ) ) + if entrypoint == _OTHER_ENTRYPOINT: + if content_type in _PYTHON_API_TYPES: + _note("Use a project-relative file, or module:object for a nonstandard app object.") + entrypoint_message = "Enter the API entrypoint" + else: + _note("Use a path relative to the project directory.") + entrypoint_message = "Enter the content entrypoint" + entrypoint = _ask(_text(entrypoint_message, validate=_required)) + _note("This is the name users will see in the Connect dashboard.") title = _ask( _text( @@ -168,25 +270,33 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: python: Optional[Dict[str, str]] = None if spec.language == "python": - package_file, package_manager = _default_package_settings(project_dir) + package_file_choices, package_file_default = _package_file_choices() _note("Choose requirements.txt, pyproject.toml, or another dependency file.") package_file = _ask( - _text( + _select( "Which file defines this project's Python dependencies?", - default=package_file, - validate=_required, + choices=package_file_choices, + default=package_file_default, ) ) + if package_file == _OTHER_PACKAGE_FILE: + _note("Use a dependency file path relative to the project directory.") + package_file = _ask( + _text( + "Enter the Python dependency file", + validate=_required, + ) + ) _note("Connect uses this installer while restoring the Python environment.") package_manager = _ask( _select( - "How should Connect install the Python dependencies?", + "Which Python package installer should Connect use?", choices=( - questionary.Choice("pip - Install with pip", value="pip"), questionary.Choice("uv - Resolve and install with uv", value="uv"), + questionary.Choice("pip - Install with pip", value="pip"), questionary.Choice("none - Do not install Python packages", value="none"), ), - default=package_manager, + default="uv", ) ) python = { @@ -329,7 +439,7 @@ def init( if spec.language == "python" and python is None and (package_file or package_manager): python = { "package_file": package_file or "requirements.txt", - "package_manager": package_manager or "pip", + "package_manager": package_manager or "uv", } elif spec.language != "python" and (package_file or package_manager): raise click.UsageError("--package-file and --package-manager require Python content.") diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 418cd53..acff062 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -147,9 +147,9 @@ def test_interactive_init_collects_python_answers(runner): "_ask", side_effect=[ "python-fastapi", - "app.py:app", + "app.py", "Sales API", - "pyproject.toml", + "requirements.txt", "uv", True, ], @@ -162,19 +162,87 @@ def test_interactive_init_collects_python_answers(runner): assert result.exit_code == 0, result.output request = initialize.call_args.args[0] assert request.content_type == "python-fastapi" - assert request.entrypoint == "app.py:app" + assert request.entrypoint == "app.py" assert request.title == "Sales API" assert request.python == { - "package_file": "pyproject.toml", + "package_file": "requirements.txt", "package_manager": "uv", } assert request.files == ("*",) assert "Connect" in result.output + assert " / /\\" in result.output + assert "< < > Connect" in result.output + assert " \\ \\/" in result.output assert "Configure a project for Posit Connect" in result.output assert "[OK] Publisher project initialized" in result.output assert "posit connect publish . --server " in result.output +def test_interactive_init_detects_entrypoints_and_defaults(runner): + with runner.isolated_filesystem(): + Path("worker.py").write_text("", encoding="utf-8") + Path("main.py").write_text("", encoding="utf-8") + Path("app.py").write_text("", encoding="utf-8") + Path("notes.txt").write_text("", encoding="utf-8") + + choices, default = init_mod._entrypoint_choices(".", "python-fastapi") + package_choices, package_default = init_mod._package_file_choices() + + assert [choice.value for choice in choices] == [ + "app.py", + "main.py", + "worker.py", + init_mod._OTHER_ENTRYPOINT, + ] + assert default == "app.py" + assert [choice.value for choice in package_choices] == [ + "requirements.txt", + "pyproject.toml", + init_mod._OTHER_PACKAGE_FILE, + ] + assert package_default == "requirements.txt" + assert init_mod._PYTHON_PACKAGE_MANAGERS[0] == "uv" + + +def test_interactive_init_detects_content_specific_entrypoints(runner): + with runner.isolated_filesystem(): + Path("app.py").write_text("", encoding="utf-8") + Path("report.ipynb").write_text("{}", encoding="utf-8") + Path("index.html").write_text("", encoding="utf-8") + + notebook_choices, notebook_default = init_mod._entrypoint_choices(".", "jupyter-notebook") + html_choices, html_default = init_mod._entrypoint_choices(".", "html") + + assert [choice.value for choice in notebook_choices[:-1]] == ["report.ipynb"] + assert notebook_default == "report.ipynb" + assert [choice.value for choice in html_choices[:-1]] == ["index.html"] + assert html_default == "index.html" + + +def test_interactive_init_accepts_custom_entrypoint_and_package_file(): + with patch.object( + init_mod, + "_ask", + side_effect=[ + "python-fastapi", + init_mod._OTHER_ENTRYPOINT, + "src/api.py:create_app", + "Custom API", + init_mod._OTHER_PACKAGE_FILE, + "requirements/connect.txt", + "uv", + True, + ], + ): + answers = init_mod.collect_init_answers(".") + + assert answers["entrypoint"] == "src/api.py:create_app" + assert answers["python"] == { + "package_file": "requirements/connect.txt", + "package_manager": "uv", + } + + def test_interactive_quarto_asks_mode_and_version(runner): initialized = SimpleNamespace( config_name="report", config_path="/project/.posit/publish/report.toml" From a1fe49f1c9c10a84593098a2561faee73cd89351 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 16:30:20 -0400 Subject: [PATCH 05/14] Refine Connect wizard branding --- src/posit_cli/connect/init.py | 26 +++++++++++++++----------- tests/test_publisher_cli.py | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index e148fc4..a914647 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -8,6 +8,7 @@ import click import questionary +from prompt_toolkit.output import ColorDepth from rsconnect.exception import RSConnectException from rsconnect.publisher import CONTENT_TYPES, InitRequest, initialize_project @@ -40,14 +41,14 @@ ) _QUESTIONARY_STYLE = questionary.Style( [ - ("qmark", "fg:#44739b bold"), - ("question", "bold"), - ("answer", "fg:#44739b bold"), - ("pointer", "fg:#44739b bold"), - ("highlighted", "fg:#44739b bold"), - ("selected", "fg:#44739b"), - ("instruction", "fg:#7c8793"), - ("disabled", "fg:#858585 italic"), + ("qmark", "ansibrightblue bold"), + ("question", "ansiblue bold"), + ("answer", "ansiblue bold"), + ("pointer", "ansibrightblue bold"), + ("highlighted", "ansiblue bold"), + ("selected", "ansiblue"), + ("instruction", "ansibrightblack"), + ("disabled", "ansibrightblack italic"), ] ) @@ -146,6 +147,7 @@ def _select(message: str, **kwargs: Any) -> Any: qmark=">", pointer=">", style=_QUESTIONARY_STYLE, + color_depth=ColorDepth.DEPTH_8_BIT, **kwargs, ) @@ -155,6 +157,7 @@ def _text(message: str, **kwargs: Any) -> Any: message, qmark=">", style=_QUESTIONARY_STYLE, + color_depth=ColorDepth.DEPTH_8_BIT, **kwargs, ) @@ -164,6 +167,7 @@ def _confirm(message: str, **kwargs: Any) -> Any: message, qmark=">", style=_QUESTIONARY_STYLE, + color_depth=ColorDepth.DEPTH_8_BIT, **kwargs, ) @@ -174,9 +178,9 @@ def _show_banner(project_dir: str) -> None: click.style(" / ", fg="bright_blue", bold=True) + click.style("/\\", fg="blue", bold=True) ) click.echo( - click.style("< ", fg="bright_blue", bold=True) - + click.style("< >", fg="blue", bold=True) - + " " + click.style(" | ", fg="bright_blue", bold=True) + + click.style("| |", fg="blue", bold=True) + + " " + click.style("Connect", bold=True) ) click.echo( diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index acff062..093dcdf 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -171,7 +171,7 @@ def test_interactive_init_collects_python_answers(runner): assert request.files == ("*",) assert "Connect" in result.output assert " / /\\" in result.output - assert "< < > Connect" in result.output + assert " | | | Connect" in result.output assert " \\ \\/" in result.output assert "Configure a project for Posit Connect" in result.output assert "[OK] Publisher project initialized" in result.output From 845a757263175bf4596245df8ae6d16f520370d1 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 16:45:37 -0400 Subject: [PATCH 06/14] Simplify init project choices --- src/posit_cli/connect/init.py | 34 +++++++++++++--------------------- tests/test_publisher_cli.py | 6 +----- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index a914647..53923fd 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -79,6 +79,19 @@ def _entrypoint_suffixes(content_type: str) -> Tuple[str, ...]: def _entrypoint_choices(project_dir: str, content_type: str) -> Tuple[List[Any], str]: + if content_type.startswith("python-"): + return ( + [ + questionary.Choice("app.py", value="app.py"), + questionary.Choice("main.py", value="main.py"), + questionary.Choice( + "Other - Enter a different file or module", + value=_OTHER_ENTRYPOINT, + ), + ], + "app.py", + ) + suffixes = _entrypoint_suffixes(content_type) priority = {name: index for index, name in enumerate(_ENTRYPOINT_PRIORITY)} @@ -162,16 +175,6 @@ def _text(message: str, **kwargs: Any) -> Any: ) -def _confirm(message: str, **kwargs: Any) -> Any: - return questionary.confirm( - message, - qmark=">", - style=_QUESTIONARY_STYLE, - color_depth=ColorDepth.DEPTH_8_BIT, - **kwargs, - ) - - def _show_banner(project_dir: str) -> None: click.echo() click.echo( @@ -320,23 +323,12 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: ) } - files = ("*",) - _note("The initial '*' pattern includes the project tree and can be refined later.") - if not _ask( - _confirm( - "Use '{}' as the initial project file pattern?".format(", ".join(files)), - default=True, - ) - ): - raise click.Abort() - return { "content_type": content_type, "entrypoint": entrypoint, "title": title, "python": python, "quarto": quarto, - "files": files, } diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 093dcdf..5e2b9e2 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -151,7 +151,6 @@ def test_interactive_init_collects_python_answers(runner): "Sales API", "requirements.txt", "uv", - True, ], ): with patch.object( @@ -168,7 +167,7 @@ def test_interactive_init_collects_python_answers(runner): "package_file": "requirements.txt", "package_manager": "uv", } - assert request.files == ("*",) + assert request.files == () assert "Connect" in result.output assert " / /\\" in result.output assert " | | | Connect" in result.output @@ -191,7 +190,6 @@ def test_interactive_init_detects_entrypoints_and_defaults(runner): assert [choice.value for choice in choices] == [ "app.py", "main.py", - "worker.py", init_mod._OTHER_ENTRYPOINT, ] assert default == "app.py" @@ -231,7 +229,6 @@ def test_interactive_init_accepts_custom_entrypoint_and_package_file(): init_mod._OTHER_PACKAGE_FILE, "requirements/connect.txt", "uv", - True, ], ): answers = init_mod.collect_init_answers(".") @@ -257,7 +254,6 @@ def test_interactive_quarto_asks_mode_and_version(runner): "report.qmd", "Report", "1.6.0", - True, ], ): with patch.object( From 1d5debafff66a0a3595abcad05ad6a3f2ad188de Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 16:46:12 -0400 Subject: [PATCH 07/14] Use full Posit Connect wordmark --- src/posit_cli/connect/init.py | 2 +- tests/test_publisher_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index 53923fd..66c56c0 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -184,7 +184,7 @@ def _show_banner(project_dir: str) -> None: click.style(" | ", fg="bright_blue", bold=True) + click.style("| |", fg="blue", bold=True) + " " - + click.style("Connect", bold=True) + + click.style("Posit Connect", bold=True) ) click.echo( click.style(" \\ ", fg="bright_blue", bold=True) diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 5e2b9e2..2866f68 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -170,7 +170,7 @@ def test_interactive_init_collects_python_answers(runner): assert request.files == () assert "Connect" in result.output assert " / /\\" in result.output - assert " | | | Connect" in result.output + assert " | | | Posit Connect" in result.output assert " \\ \\/" in result.output assert "Configure a project for Posit Connect" in result.output assert "[OK] Publisher project initialized" in result.output From 99b0d28cfdfd499d120c8102a788c2a7b178727f Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 16:58:40 -0400 Subject: [PATCH 08/14] Add interactive file inclusion picker --- src/posit_cli/connect/init.py | 119 ++++++++++++++++++++++++++++++++++ tests/test_publisher_cli.py | 35 +++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index 66c56c0..2206d99 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -18,7 +18,18 @@ _PYTHON_PACKAGE_MANAGERS = ("uv", "pip", "none") _OTHER_ENTRYPOINT = "__other_entrypoint__" _OTHER_PACKAGE_FILE = "__other_package_file__" +_INCLUDE_ALL_FILES = "__include_all_files__" +_SELECT_FILES_MANUALLY = "__select_files_manually__" _PYTHON_API_TYPES = {"python-fastapi", "python-flask", "python-dash"} +_FILE_PICKER_EXCLUSIONS = { + ".git", + ".posit", + ".svn", + ".venv", + "__pycache__", + "node_modules", + "venv", +} _ENTRYPOINT_PRIORITY = ( "app.py", "main.py", @@ -138,6 +149,65 @@ def _package_file_choices() -> Tuple[Tuple[Any, ...], str]: ) +def _top_level_name(project_dir: str, path: Optional[str]) -> Optional[str]: + if not path: + return None + + relative = path.split(":", 1)[0].replace("\\", "/").lstrip("/") + while relative.startswith("./"): + relative = relative[2:] + if not relative: + return None + + try: + entries = {entry.name for entry in Path(project_dir).iterdir()} + except OSError: + return None + top_level = relative.split("/", 1)[0] + if top_level in entries: + return top_level + if "/" not in relative and "{}.py".format(relative) in entries: + return "{}.py".format(relative) + if "/" not in relative and not relative.endswith(".py"): + package = relative.split(".", 1)[0] + if package in entries: + return package + return None + + +def _top_level_file_choices( + project_dir: str, + entrypoint: str, + package_file: Optional[str], +) -> List[Any]: + checked_entries = { + name + for name in ( + _top_level_name(project_dir, entrypoint), + _top_level_name(project_dir, package_file), + ) + if name + } + try: + entries = [ + entry + for entry in Path(project_dir).iterdir() + if entry.name not in _FILE_PICKER_EXCLUSIONS + ] + except OSError: + entries = [] + + entries.sort(key=lambda entry: (not entry.is_dir(), entry.name.lower())) + return [ + questionary.Choice( + title=entry.name + ("/" if entry.is_dir() else ""), + value="/{}/".format(entry.name) if entry.is_dir() else "/{}".format(entry.name), + checked=entry.name in checked_entries, + ) + for entry in entries + ] + + def _default_title(project_dir: str, entrypoint: str) -> str: project_name = os.path.basename(os.path.abspath(project_dir)) return project_name or Path(entrypoint.split(":", 1)[0]).stem @@ -175,6 +245,17 @@ def _text(message: str, **kwargs: Any) -> Any: ) +def _checkbox(message: str, **kwargs: Any) -> Any: + return questionary.checkbox( + message, + qmark=">", + pointer=">", + style=_QUESTIONARY_STYLE, + color_depth=ColorDepth.DEPTH_8_BIT, + **kwargs, + ) + + def _show_banner(project_dir: str) -> None: click.echo() click.echo( @@ -276,6 +357,7 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: ) python: Optional[Dict[str, str]] = None + package_file: Optional[str] = None if spec.language == "python": package_file_choices, package_file_default = _package_file_choices() _note("Choose requirements.txt, pyproject.toml, or another dependency file.") @@ -323,12 +405,49 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: ) } + _note("'*' includes current and future project files.") + _note("Publisher still skips metadata, environments, caches, and node_modules.") + file_mode = _ask( + _select( + "Which project files should Connect include when publishing?", + choices=( + questionary.Choice( + "All project files (*)", + value=_INCLUDE_ALL_FILES, + ), + questionary.Choice( + "Choose top-level files and folders", + value=_SELECT_FILES_MANUALLY, + ), + ), + default=_INCLUDE_ALL_FILES, + ) + ) + if file_mode == _INCLUDE_ALL_FILES: + files = ("*",) + else: + file_choices = _top_level_file_choices(project_dir, entrypoint, package_file) + if not file_choices: + raise click.ClickException( + "No top-level files or folders are available for manual selection." + ) + _note("Folders include everything beneath them. Press Space to toggle a checkbox.") + selected_files = _ask( + _checkbox( + "Select the top-level files and folders to include", + choices=file_choices, + validate=lambda selected: bool(selected) or "Select at least one file or folder.", + ) + ) + files = tuple(selected_files) + return { "content_type": content_type, "entrypoint": entrypoint, "title": title, "python": python, "quarto": quarto, + "files": files, } diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 2866f68..3e76dc9 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -151,6 +151,7 @@ def test_interactive_init_collects_python_answers(runner): "Sales API", "requirements.txt", "uv", + init_mod._INCLUDE_ALL_FILES, ], ): with patch.object( @@ -167,7 +168,7 @@ def test_interactive_init_collects_python_answers(runner): "package_file": "requirements.txt", "package_manager": "uv", } - assert request.files == () + assert request.files == ("*",) assert "Connect" in result.output assert " / /\\" in result.output assert " | | | Posit Connect" in result.output @@ -217,6 +218,34 @@ def test_interactive_init_detects_content_specific_entrypoints(runner): assert html_default == "index.html" +def test_manual_file_choices_precheck_entrypoint_and_dependencies(runner): + with runner.isolated_filesystem(): + Path("src").mkdir() + Path("src/api.py").write_text("", encoding="utf-8") + Path("app.py").write_text("", encoding="utf-8") + Path("requirements.txt").write_text("", encoding="utf-8") + Path("README.md").write_text("", encoding="utf-8") + + choices = init_mod._top_level_file_choices( + ".", + "app.py", + "requirements.txt", + ) + + assert [choice.value for choice in choices] == [ + "/src/", + "/app.py", + "/README.md", + "/requirements.txt", + ] + assert {choice.value: choice.checked for choice in choices} == { + "/src/": False, + "/app.py": True, + "/README.md": False, + "/requirements.txt": True, + } + + def test_interactive_init_accepts_custom_entrypoint_and_package_file(): with patch.object( init_mod, @@ -229,6 +258,8 @@ def test_interactive_init_accepts_custom_entrypoint_and_package_file(): init_mod._OTHER_PACKAGE_FILE, "requirements/connect.txt", "uv", + init_mod._SELECT_FILES_MANUALLY, + ["/src/", "/requirements/"], ], ): answers = init_mod.collect_init_answers(".") @@ -238,6 +269,7 @@ def test_interactive_init_accepts_custom_entrypoint_and_package_file(): "package_file": "requirements/connect.txt", "package_manager": "uv", } + assert answers["files"] == ("/src/", "/requirements/") def test_interactive_quarto_asks_mode_and_version(runner): @@ -254,6 +286,7 @@ def test_interactive_quarto_asks_mode_and_version(runner): "report.qmd", "Report", "1.6.0", + init_mod._INCLUDE_ALL_FILES, ], ): with patch.object( From 2432f3be351d0c6f48a1e3778a511a717b86f6f9 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Wed, 12 Aug 2026 17:04:28 -0400 Subject: [PATCH 09/14] Always include required project files --- src/posit_cli/connect/init.py | 50 ++++++++++++++++++++++++++++++++++- tests/test_publisher_cli.py | 23 ++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index 2206d99..a9d7513 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -208,6 +208,46 @@ def _top_level_file_choices( ] +def _root_anchored(path: str) -> str: + return "/" + path.replace("\\", "/").lstrip("/") + + +def _entrypoint_file(project_dir: str, entrypoint: str) -> str: + value = entrypoint.split(":", 1)[0].replace("\\", "/").lstrip("/") + candidates = [value] + if not Path(value).suffix: + candidates.extend( + ( + "{}.py".format(value), + "{}.py".format(value.replace(".", "/")), + "{}/__init__.py".format(value.replace(".", "/")), + ) + ) + for candidate in candidates: + if os.path.isfile(os.path.join(project_dir, candidate)): + return candidate + return value + + +def _include_required_files( + project_dir: str, + entrypoint: str, + package_file: Optional[str], + files: Tuple[str, ...], +) -> Tuple[str, ...]: + selected = list(files or ("*",)) + required = [_root_anchored(_entrypoint_file(project_dir, entrypoint))] + if package_file: + required.append(_root_anchored(package_file)) + + normalized = {pattern.lstrip("/") for pattern in selected} + for pattern in required: + if pattern.lstrip("/") not in normalized: + selected.append(pattern) + normalized.add(pattern.lstrip("/")) + return tuple(selected) + + def _default_title(project_dir: str, entrypoint: str) -> str: project_name = os.path.basename(os.path.abspath(project_dir)) return project_name or Path(entrypoint.split(":", 1)[0]).stem @@ -431,6 +471,7 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: raise click.ClickException( "No top-level files or folders are available for manual selection." ) + _note("The entrypoint and dependency file are always included.") _note("Folders include everything beneath them. Press Space to toggle a checkbox.") selected_files = _ask( _checkbox( @@ -567,6 +608,13 @@ def init( elif quarto_version: raise click.UsageError("--quarto-version requires Quarto content.") + resolved_files = _include_required_files( + project_dir, + resolved_entrypoint, + python.get("package_file") if python else None, + answers.get("files", files), + ) + try: result = initialize_project( InitRequest( @@ -575,7 +623,7 @@ def init( entrypoint=resolved_entrypoint, config_name=config_name, title=answers.get("title", title), - files=answers.get("files", files), + files=resolved_files, python=python, quarto=quarto, overwrite=overwrite, diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 3e76dc9..b82edcd 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -70,7 +70,7 @@ def test_init_explicit_flags_build_request(runner): "package_file": "pyproject.toml", "package_manager": "uv", } - assert request.files == ("app.py", "src/**") + assert request.files == ("app.py", "src/**", "/pyproject.toml") assert "Initialized sales" in result.output @@ -111,6 +111,9 @@ def test_init_writes_publisher_config(runner): "package_file": "pyproject.toml", "package_manager": "uv", } + assert "*" in initialized.files + assert "/app.py" in initialized.files + assert "/pyproject.toml" in initialized.files def test_init_explicit_mode_requires_type_and_entrypoint(runner): @@ -168,7 +171,7 @@ def test_interactive_init_collects_python_answers(runner): "package_file": "requirements.txt", "package_manager": "uv", } - assert request.files == ("*",) + assert request.files == ("*", "/app.py", "/requirements.txt") assert "Connect" in result.output assert " / /\\" in result.output assert " | | | Posit Connect" in result.output @@ -246,6 +249,21 @@ def test_manual_file_choices_precheck_entrypoint_and_dependencies(runner): } +def test_required_files_are_added_after_manual_selection(): + files = init_mod._include_required_files( + ".", + "src/api.py:create_app", + "requirements/connect.txt", + ("/README.md",), + ) + + assert files == ( + "/README.md", + "/src/api.py", + "/requirements/connect.txt", + ) + + def test_interactive_init_accepts_custom_entrypoint_and_package_file(): with patch.object( init_mod, @@ -298,6 +316,7 @@ def test_interactive_quarto_asks_mode_and_version(runner): request = initialize.call_args.args[0] assert request.content_type == "quarto-shiny" assert request.quarto == {"version": "1.6.0"} + assert request.files == ("*", "/report.qmd") def test_interactive_init_aborts_cleanly(runner): From 45626ab5f4d1e56125c8d6e599bcc696f6ba46f0 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 13 Aug 2026 10:07:34 -0400 Subject: [PATCH 10/14] Add Connect-backed FastAPI integration test --- .github/workflows/ci.yaml | 16 ++ .gitignore | 4 + AGENTS.md | 23 +++ CLAUDE.md | 2 + Justfile | 12 ++ pyproject.toml | 5 + tests/integration/README.md | 24 +++ tests/integration/fixtures/fastapi/app.py | 14 ++ .../fixtures/fastapi/pyproject.toml | 4 + .../fixtures/fastapi/requirements.txt | 2 + tests/integration/test_fastapi_publish.py | 175 ++++++++++++++++++ 11 files changed, 281 insertions(+) create mode 100644 AGENTS.md create mode 100644 tests/integration/README.md create mode 100644 tests/integration/fixtures/fastapi/app.py create mode 100644 tests/integration/fixtures/fastapi/pyproject.toml create mode 100644 tests/integration/fixtures/fastapi/requirements.txt create mode 100644 tests/integration/test_fastapi_publish.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 719f53e..43842b3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,6 +38,22 @@ jobs: - uses: extractions/setup-just@v4 - run: just test ${{ matrix.python-version }} + integration: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.13" + - run: uv sync --python 3.13 --extra test + - name: Test init and publish against Posit Connect + uses: posit-dev/with-connect@main + with: + version: release + license: ${{ secrets.CONNECT_LICENSE_FILE }} + command: uv run --no-sync pytest -m integration -vv tests/integration + build: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index b631e2c..c6a6a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,9 @@ venv/ .ruff_cache/ .mypy_cache/ .coverage + +# Local Posit Connect integration-test license +/.connect-license.lic + # roborev snapshots /.roborev/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..394de1c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Repository Instructions + +## Local Connect integration tests + +The live integration suite requires Docker and a valid Posit Connect license copied to +`.connect-license.lic` at the repository root. This file is intentionally gitignored and must +never be committed. + +For Posit developers with a local Connect checkout, copy the standard test license: + +```bash +cp /path/to/connect/test/licenses/legacy-enterprise.lic \ + .connect-license.lic +``` + +Run the suite with: + +```bash +just integration +``` + +This uses `posit-dev/with-connect` to start a temporary Connect container, provides +`CONNECT_SERVER` and `CONNECT_API_KEY` to pytest, and stops the container afterward. diff --git a/CLAUDE.md b/CLAUDE.md index b38b5b4..c158c2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ # posit-cli +@AGENTS.md + A single, friendly CLI — `posit` — for working with Posit Connect, in the spirit of [`gh`](https://cli.github.com/). Distribution name `posit-cli`, import package `posit_cli`, executable `posit`. diff --git a/Justfile b/Justfile index c9860f2..878c60d 100644 --- a/Justfile +++ b/Justfile @@ -4,6 +4,18 @@ test py="3.13": uv run --python {{py}} --extra test pytest tests +# Run tests that publish to a live Connect instance. +integration: + #!/usr/bin/env bash + set -euo pipefail + if [[ ! -f .connect-license.lic ]]; then + echo "Missing .connect-license.lic; see AGENTS.md." >&2 + exit 1 + fi + uvx --from git+https://github.com/posit-dev/with-connect.git \ + with-connect --license .connect-license.lic -- \ + uv run --extra test pytest -m integration -vv tests/integration + # Check formatting and lint lint: uv run --extra lint ruff format --check diff --git a/pyproject.toml b/pyproject.toml index 54f7238..223af56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,3 +54,8 @@ line-length = 99 [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] + +[tool.pytest.ini_options] +markers = [ + "integration: requires a live Posit Connect server and API key", +] diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..95155a6 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,24 @@ +# Posit Connect integration tests + +These tests exercise `posit connect init` and `posit connect publish` against a +live Posit Connect instance. Local runs require: + +- Docker +- A valid Posit Connect license at `.connect-license.lic` in the repository root + +For Posit developers with a local Connect checkout: + +```console +cp /path/to/connect/test/licenses/legacy-enterprise.lic \ + .connect-license.lic +``` + +Run them with: + +```console +just integration +``` + +The command uses `posit-dev/with-connect` to provide a temporary Connect +instance and set `CONNECT_SERVER` and `CONNECT_API_KEY`. CI uses the same +utility through its GitHub Action. diff --git a/tests/integration/fixtures/fastapi/app.py b/tests/integration/fixtures/fastapi/app.py new file mode 100644 index 0000000..05157e5 --- /dev/null +++ b/tests/integration/fixtures/fastapi/app.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI + + +VERSION = "one" + +app = FastAPI(title="Posit CLI integration test") + + +@app.get("/") +def root(): + return { + "service": "posit-cli-fastapi", + "version": VERSION, + } diff --git a/tests/integration/fixtures/fastapi/pyproject.toml b/tests/integration/fixtures/fastapi/pyproject.toml new file mode 100644 index 0000000..e83c60c --- /dev/null +++ b/tests/integration/fixtures/fastapi/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "posit-cli-fastapi-integration" +version = "0.0.0" +requires-python = ">=3.9" diff --git a/tests/integration/fixtures/fastapi/requirements.txt b/tests/integration/fixtures/fastapi/requirements.txt new file mode 100644 index 0000000..65d99d9 --- /dev/null +++ b/tests/integration/fixtures/fastapi/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115,<1 +uvicorn>=0.30,<1 diff --git a/tests/integration/test_fastapi_publish.py b/tests/integration/test_fastapi_publish.py new file mode 100644 index 0000000..9b8a744 --- /dev/null +++ b/tests/integration/test_fastapi_publish.py @@ -0,0 +1,175 @@ +"""End-to-end Publisher workflow against a live Posit Connect instance.""" + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +import pytest +from rsconnect.publisher import config +from rsconnect.publisher.record import discover_records, read_record + + +pytestmark = pytest.mark.integration + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "fastapi" + + +def _connect_credentials(): + server = os.environ.get("CONNECT_SERVER") + api_key = os.environ.get("CONNECT_API_KEY") + if not server or not api_key: + pytest.skip("CONNECT_SERVER and CONNECT_API_KEY are required") + return server, api_key + + +def _run_posit(project_dir, home_dir, *args, env_overrides=None): + env = os.environ.copy() + env["HOME"] = str(home_dir) + if env_overrides: + for name, value in env_overrides.items(): + if value is None: + env.pop(name, None) + else: + env[name] = value + + result = subprocess.run( + [sys.executable, "-m", "posit_cli", *args], + cwd=project_dir, + env=env, + capture_output=True, + text=True, + timeout=600, + ) + assert result.returncode == 0, ( + "posit command failed:\ncommand: {}\nstdout:\n{}\nstderr:\n{}".format( + " ".join(args), result.stdout, result.stderr + ) + ) + output_lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + return output_lines[-1] if output_lines else "" + + +def _wait_for_json(content_url, api_key, expected_version): + deadline = time.monotonic() + 120 + last_error = None + while time.monotonic() < deadline: + request = Request( + content_url.rstrip("/") + "/", + headers={ + "Accept": "application/json", + "Authorization": "Key {}".format(api_key), + }, + ) + try: + with urlopen(request, timeout=15) as response: + payload = json.load(response) + if payload == { + "service": "posit-cli-fastapi", + "version": expected_version, + }: + return + last_error = AssertionError("unexpected response: {!r}".format(payload)) + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = exc + time.sleep(2) + + raise AssertionError( + "content did not return version {!r}: {}".format(expected_version, last_error) + ) + + +def _only_record(project_dir): + paths = discover_records(str(project_dir)) + assert len(paths) == 1, "expected one deployment record, found {!r}".format(paths) + return read_record(paths[0]) + + +def test_init_and_republish_fastapi(tmp_path): + server, api_key = _connect_credentials() + project_dir = tmp_path / "fastapi" + home_dir = tmp_path / "home" + shutil.copytree(FIXTURE_DIR, project_dir) + home_dir.mkdir() + + _run_posit( + project_dir, + home_dir, + "connect", + "init", + ".", + "--type", + "python-fastapi", + "--entrypoint", + "app.py", + "--title", + "Posit CLI FastAPI integration", + "--config", + "fastapi-integration", + "--package-file", + "requirements.txt", + "--package-manager", + "uv", + ) + + publisher_config = config.read_config( + str(project_dir / ".posit" / "publish" / "fastapi-integration.toml") + ) + assert publisher_config.entrypoint == "app.py" + assert publisher_config.python == { + "package_file": "requirements.txt", + "package_manager": "uv", + } + assert "*" in publisher_config.files + assert "/app.py" in publisher_config.files + assert "/requirements.txt" in publisher_config.files + + first_url = _run_posit( + project_dir, + home_dir, + "connect", + "publish", + ".", + "--server", + server, + "--api-key", + api_key, + "--no-metadata", + ) + assert first_url.startswith(server.rstrip("/") + "/") + _wait_for_json(first_url, api_key, "one") + + first_record = _only_record(project_dir) + assert first_record.id + assert first_record.bundle_id + + app_path = project_dir / "app.py" + updated_app = app_path.read_text(encoding="utf-8").replace( + 'VERSION = "one"', 'VERSION = "two"' + ) + assert 'VERSION = "two"' in updated_app + app_path.write_text(updated_app, encoding="utf-8") + + second_url = _run_posit( + project_dir, + home_dir, + "connect", + "publish", + ".", + "--api-key", + api_key, + "--no-metadata", + env_overrides={"CONNECT_SERVER": None}, + ) + assert second_url == first_url + _wait_for_json(second_url, api_key, "two") + + second_record = _only_record(project_dir) + assert second_record.id == first_record.id + assert second_record.bundle_id + assert second_record.bundle_id != first_record.bundle_id From 68d7d9ce1b179bd0049068896e3f3318f2f9f74f Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 13 Aug 2026 11:48:01 -0400 Subject: [PATCH 11/14] Fold project setup into publish --- .github/workflows/ci.yaml | 2 +- README.md | 35 ++-- src/posit_cli/connect/__init__.py | 2 - src/posit_cli/connect/init.py | 61 ++----- src/posit_cli/connect/publish.py | 135 +++++++++++++- tests/integration/README.md | 4 +- tests/integration/fixtures/fastapi/.gitignore | 2 + tests/integration/test_fastapi_publish.py | 9 +- tests/test_cli.py | 2 +- tests/test_publisher_cli.py | 170 +++++++++++------- 10 files changed, 285 insertions(+), 137 deletions(-) create mode 100644 tests/integration/fixtures/fastapi/.gitignore diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 43842b3..f72b9fd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -47,7 +47,7 @@ jobs: with: python-version: "3.13" - run: uv sync --python 3.13 --extra test - - name: Test init and publish against Posit Connect + - name: Test publish setup and deployment against Posit Connect uses: posit-dev/with-connect@main with: version: release diff --git a/README.md b/README.md index 890b1f9..eeb75b0 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ A friendly command-line interface for Posit products, in the spirit of [`gh`](ht ```console $ 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 init --type python-fastapi --entrypoint app.py:app $ posit connect publish . --server https://connect.example.com +$ posit connect publish . --init # configure without publishing ``` This project is in early-stage development and so far only supports Posit Connect's APIs. @@ -56,15 +56,17 @@ $ posit connect login https://connect.example.com $ posit connect api v1/user -q .username ``` -**3. Initialize and publish.** Create a Publisher-compatible -`.posit/publish` configuration, then publish it: +**3. Publish.** Run the command from your project directory: ```console $ cd my-app -$ posit connect init $ posit connect publish . --server https://connect.example.com ``` +If the project has not been configured yet, an interactive terminal opens the +setup wizard before continuing with the publish. The generated +`.posit/publish` configuration is reused on later runs. + The server is needed only for the first publish. Later publishes reuse the saved deployment record: @@ -128,21 +130,28 @@ $ posit connect api v1/users -X GET -f page_size=5 # or force GET $ posit connect api v1/content -f name=my-app # POST body (creates content) ``` -## `posit connect init` and `publish` +## `posit connect publish` + +`posit connect publish` is the main workflow. On a fresh project it opens a +Questionary-powered setup wizard in an interactive terminal and then publishes +the content: + +```console +$ posit connect publish . --server https://connect.example.com +``` -Run `posit connect init` in a terminal for a Questionary-powered setup wizard -with navigable choices, or pass the required content type and entrypoint -explicitly: +Use `--init` when you want to configure the project without publishing it: ```console -$ posit connect init -$ posit connect init --type python-fastapi --entrypoint app.py:app --title "Sales API" +$ posit connect publish . --init +$ posit connect publish . --init --type python-fastapi --entrypoint app.py --title "Sales API" ``` -The command does not prompt when stdin is non-interactive. Automation must pass -`--type` and `--entrypoint`; Quarto content also requires `--quarto-version`. +Fresh projects do not prompt when stdin is non-interactive. Automation should +run `publish --init` with `--type` and `--entrypoint` first; Quarto content also +requires `--quarto-version`. -Publish the initialized project to a URL or saved server name: +Configured projects can publish to a URL or saved server name: ```console $ posit connect publish . --server https://connect.example.com diff --git a/src/posit_cli/connect/__init__.py b/src/posit_cli/connect/__init__.py index e63c41f..2571b5e 100644 --- a/src/posit_cli/connect/__init__.py +++ b/src/posit_cli/connect/__init__.py @@ -4,7 +4,6 @@ from rsconnect.main import cli as rsconnect_cli from .api import api as api_cmd -from .init import init as init_cmd from .publish import publish as publish_cmd @@ -26,5 +25,4 @@ def connect() -> None: connect.add_command(_cmd, name=_name) connect.add_command(api_cmd, name="api") -connect.add_command(init_cmd, name="init") connect.add_command(publish_cmd, name="publish") diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index a9d7513..ba1bcef 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -14,8 +14,6 @@ _CONTENT_TYPES_BY_NAME = {spec.type: spec for spec in CONTENT_TYPES} -_CONTENT_TYPE_NAMES = tuple(_CONTENT_TYPES_BY_NAME) -_PYTHON_PACKAGE_MANAGERS = ("uv", "pip", "none") _OTHER_ENTRYPOINT = "__other_entrypoint__" _OTHER_PACKAGE_FILE = "__other_package_file__" _INCLUDE_ALL_FILES = "__include_all_files__" @@ -334,7 +332,7 @@ def _show_success(project_dir: str, config_path: str) -> None: ) click.echo() - click.secho("[OK] Publisher project initialized", fg="green", bold=True) + click.secho("[OK] Project configured for publishing", fg="green", bold=True) click.echo(click.style(" Config ", fg="bright_black") + displayed_config) click.echo( click.style(" Next ", fg="bright_black") @@ -496,63 +494,25 @@ def _explicit_init_requested( content_type: Optional[str], entrypoint: Optional[str], title: Optional[str], - config_name: Optional[str], package_file: Optional[str], package_manager: Optional[str], quarto_version: Optional[str], files: Tuple[str, ...], - overwrite: bool, ) -> bool: return any( ( content_type, entrypoint, title, - config_name, package_file, package_manager, quarto_version, files, - overwrite, ) ) -@click.command( - "init", - short_help="Initialize a project for publishing.", - context_settings={"help_option_names": ["-h", "--help"]}, -) -@click.argument( - "project_dir", - default=".", - type=click.Path(exists=True, file_okay=False, resolve_path=True), -) -@click.option( - "--type", - "content_type", - type=click.Choice(_CONTENT_TYPE_NAMES, case_sensitive=False), - help="Publisher content type.", -) -@click.option("--entrypoint", help="Application entrypoint, such as app.py:app.") -@click.option("--title", help="Content title.") -@click.option("--config", "config_name", help="Publisher configuration name.") -@click.option("--package-file", help="Python dependency file.") -@click.option( - "--package-manager", - type=click.Choice(_PYTHON_PACKAGE_MANAGERS, case_sensitive=False), - help="Python package manager.", -) -@click.option("--quarto-version", help="Required Quarto version.") -@click.option( - "--file", - "files", - multiple=True, - metavar="PATTERN", - help="Include file pattern. May be specified multiple times.", -) -@click.option("--overwrite", is_flag=True, help="Replace an existing configuration.") -def init( +def initialize_publish_project( project_dir: str, content_type: Optional[str], entrypoint: Optional[str], @@ -563,18 +523,17 @@ def init( quarto_version: Optional[str], files: Tuple[str, ...], overwrite: bool, -) -> None: - """Create a .posit/publish configuration in PROJECT_DIR.""" + show_success: bool = True, +) -> Any: + """Create a Publisher configuration and return the initialization result.""" explicit = _explicit_init_requested( content_type, entrypoint, title, - config_name, package_file, package_manager, quarto_version, files, - overwrite, ) answers: Dict[str, Any] = {} @@ -632,7 +591,9 @@ def init( except RSConnectException as exc: raise click.ClickException(str(exc)) from exc - if answers: - _show_success(project_dir, result.config_path) - else: - click.echo("Initialized {} at {}".format(result.config_name, result.config_path)) + if show_success: + if answers: + _show_success(project_dir, result.config_path) + else: + click.echo("Configured {} at {}".format(result.config_name, result.config_path)) + return result diff --git a/src/posit_cli/connect/publish.py b/src/posit_cli/connect/publish.py index e361c94..b18efbc 100644 --- a/src/posit_cli/connect/publish.py +++ b/src/posit_cli/connect/publish.py @@ -1,15 +1,46 @@ -"""Publish a project from its .posit/publish configuration.""" +"""Configure and publish projects with Posit Publisher.""" from typing import Optional, Tuple import click from rsconnect.exception import RSConnectException -from rsconnect.publisher import PublishRequest, publish_project +from rsconnect.publisher import CONTENT_TYPES, PublishRequest, publish_project +from rsconnect.publisher.config import discover_configs + +from . import init as init_workflow + + +_CONTENT_TYPE_NAMES = tuple(spec.type for spec in CONTENT_TYPES) +_PYTHON_PACKAGE_MANAGERS = ("uv", "pip", "none") + + +def _setup_options_requested( + content_type: Optional[str], + entrypoint: Optional[str], + title: Optional[str], + package_file: Optional[str], + package_manager: Optional[str], + quarto_version: Optional[str], + files: Tuple[str, ...], + overwrite: bool, +) -> bool: + return any( + ( + content_type, + entrypoint, + title, + package_file, + package_manager, + quarto_version, + files, + overwrite, + ) + ) @click.command( "publish", - short_help="Publish an initialized project.", + short_help="Configure or publish a project.", context_settings={"help_option_names": ["-h", "--help"]}, ) @click.argument( @@ -17,7 +48,43 @@ default=".", type=click.Path(exists=True, file_okay=False, resolve_path=True), ) +@click.option( + "--init", + "initialize_only", + is_flag=True, + help="Configure the project for publishing without publishing it.", +) +@click.option( + "--type", + "content_type", + type=click.Choice(_CONTENT_TYPE_NAMES, case_sensitive=False), + help="Publisher content type. Requires --init.", +) +@click.option( + "--entrypoint", + help="Application entrypoint, such as app.py:app. Requires --init.", +) +@click.option("--title", help="Content title. Requires --init.") @click.option("--config", "config_name", help="Publisher configuration name.") +@click.option("--package-file", help="Python dependency file. Requires --init.") +@click.option( + "--package-manager", + type=click.Choice(_PYTHON_PACKAGE_MANAGERS, case_sensitive=False), + help="Python package manager. Requires --init.", +) +@click.option("--quarto-version", help="Required Quarto version. Requires --init.") +@click.option( + "--file", + "files", + multiple=True, + metavar="PATTERN", + help="Include file pattern. Requires --init; may be repeated.", +) +@click.option( + "--overwrite", + is_flag=True, + help="Replace an existing Publisher configuration. Requires --init.", +) @click.option("--deployment", "deployment_name", help="Deployment record name.") @click.option( "--server", @@ -73,7 +140,16 @@ def publish( ctx: click.Context, project_dir: str, + initialize_only: bool, + content_type: Optional[str], + entrypoint: Optional[str], + title: Optional[str], config_name: Optional[str], + package_file: Optional[str], + package_manager: Optional[str], + quarto_version: Optional[str], + files: Tuple[str, ...], + overwrite: bool, deployment_name: Optional[str], server: Optional[str], server_name: Optional[str], @@ -88,7 +164,58 @@ def publish( metadata: Tuple[str, ...], no_metadata: bool, ) -> None: - """Publish PROJECT_DIR using its .posit/publish configuration.""" + """Publish PROJECT_DIR, configuring it first when needed.""" + setup_requested = _setup_options_requested( + content_type, + entrypoint, + title, + package_file, + package_manager, + quarto_version, + files, + overwrite, + ) + if setup_requested and not initialize_only: + raise click.UsageError( + "Project setup options require --init; use 'posit connect publish --init'." + ) + + if initialize_only: + init_workflow.initialize_publish_project( + project_dir=project_dir, + content_type=content_type, + entrypoint=entrypoint, + title=title, + config_name=config_name, + package_file=package_file, + package_manager=package_manager, + quarto_version=quarto_version, + files=files, + overwrite=overwrite, + ) + return + + if not discover_configs(project_dir): + if not init_workflow._is_interactive(): + raise click.UsageError( + "No Publisher configuration found. Run " + "'posit connect publish --init --type TYPE --entrypoint ENTRYPOINT' first." + ) + initialized = init_workflow.initialize_publish_project( + project_dir=project_dir, + content_type=None, + entrypoint=None, + title=None, + config_name=config_name, + package_file=None, + package_manager=None, + quarto_version=None, + files=(), + overwrite=False, + show_success=False, + ) + config_name = initialized.config_name + try: result = publish_project( PublishRequest( diff --git a/tests/integration/README.md b/tests/integration/README.md index 95155a6..b94f9be 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,7 +1,7 @@ # Posit Connect integration tests -These tests exercise `posit connect init` and `posit connect publish` against a -live Posit Connect instance. Local runs require: +These tests exercise `posit connect publish --init` and `posit connect publish` +against a live Posit Connect instance. Local runs require: - Docker - A valid Posit Connect license at `.connect-license.lic` in the repository root diff --git a/tests/integration/fixtures/fastapi/.gitignore b/tests/integration/fixtures/fastapi/.gitignore new file mode 100644 index 0000000..a23bac6 --- /dev/null +++ b/tests/integration/fixtures/fastapi/.gitignore @@ -0,0 +1,2 @@ +/.posit/ +/rsconnect-python/ diff --git a/tests/integration/test_fastapi_publish.py b/tests/integration/test_fastapi_publish.py index 9b8a744..1c57ac7 100644 --- a/tests/integration/test_fastapi_publish.py +++ b/tests/integration/test_fastapi_publish.py @@ -90,19 +90,22 @@ def _only_record(project_dir): return read_record(paths[0]) -def test_init_and_republish_fastapi(tmp_path): +def test_publish_init_and_republish_fastapi(tmp_path): server, api_key = _connect_credentials() project_dir = tmp_path / "fastapi" home_dir = tmp_path / "home" - shutil.copytree(FIXTURE_DIR, project_dir) + project_dir.mkdir() + for filename in ("app.py", "pyproject.toml", "requirements.txt"): + shutil.copy2(FIXTURE_DIR / filename, project_dir / filename) home_dir.mkdir() _run_posit( project_dir, home_dir, "connect", - "init", + "publish", ".", + "--init", "--type", "python-fastapi", "--entrypoint", diff --git a/tests/test_cli.py b/tests/test_cli.py index 4eac37b..b90f39d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,8 +21,8 @@ def test_connect_mounts_api_command(runner): result = runner.invoke(cli, ["connect", "--help"]) assert result.exit_code == 0 assert "api" in result.output - assert "init" in result.output assert "publish" in result.output + assert "\n init " not in result.output # rsconnect commands we expect to re-expose under `posit connect`. diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index b82edcd..9c5814e 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -1,4 +1,4 @@ -"""Tests for the Publisher-backed init and publish commands.""" +"""Tests for the Publisher-backed publish workflow.""" import importlib from pathlib import Path @@ -21,17 +21,17 @@ def runner(): return CliRunner() -def test_init_without_flags_requires_tty(runner): +def test_publish_init_without_flags_requires_tty(runner): with patch.object(init_mod, "_is_interactive", return_value=False): with patch.object(init_mod.questionary, "select") as select: - result = runner.invoke(cli, ["connect", "init"]) + result = runner.invoke(cli, ["connect", "publish", "--init"]) assert result.exit_code == 2 assert "Interactive input is unavailable" in result.output assert not select.called -def test_init_explicit_flags_build_request(runner): +def test_publish_init_explicit_flags_build_request(runner): initialized = SimpleNamespace( config_name="sales", config_path="/project/.posit/publish/sales.toml" ) @@ -40,7 +40,8 @@ def test_init_explicit_flags_build_request(runner): cli, [ "connect", - "init", + "publish", + "--init", "--type", "python-fastapi", "--entrypoint", @@ -71,10 +72,10 @@ def test_init_explicit_flags_build_request(runner): "package_manager": "uv", } assert request.files == ("app.py", "src/**", "/pyproject.toml") - assert "Initialized sales" in result.output + assert "Configured sales" in result.output -def test_init_writes_publisher_config(runner): +def test_publish_init_writes_publisher_config(runner): from rsconnect.publisher import config with runner.isolated_filesystem(): @@ -84,7 +85,8 @@ def test_init_writes_publisher_config(runner): cli, [ "connect", - "init", + "publish", + "--init", "--type", "python-fastapi", "--entrypoint", @@ -116,19 +118,20 @@ def test_init_writes_publisher_config(runner): assert "/pyproject.toml" in initialized.files -def test_init_explicit_mode_requires_type_and_entrypoint(runner): - result = runner.invoke(cli, ["connect", "init", "--title", "Incomplete"]) +def test_publish_init_explicit_mode_requires_type_and_entrypoint(runner): + result = runner.invoke(cli, ["connect", "publish", "--init", "--title", "Incomplete"]) assert result.exit_code == 2 assert "--type and --entrypoint are required" in result.output -def test_init_quarto_requires_version(runner): +def test_publish_init_quarto_requires_version(runner): result = runner.invoke( cli, [ "connect", - "init", + "publish", + "--init", "--type", "quarto-static", "--entrypoint", @@ -140,7 +143,7 @@ def test_init_quarto_requires_version(runner): assert "--quarto-version is required" in result.output -def test_interactive_init_collects_python_answers(runner): +def test_interactive_publish_init_collects_python_answers(runner): initialized = SimpleNamespace( config_name="sales", config_path="/project/.posit/publish/sales.toml" ) @@ -160,7 +163,7 @@ def test_interactive_init_collects_python_answers(runner): with patch.object( init_mod, "initialize_project", return_value=initialized ) as initialize: - result = runner.invoke(cli, ["connect", "init"]) + result = runner.invoke(cli, ["connect", "publish", "--init"]) assert result.exit_code == 0, result.output request = initialize.call_args.args[0] @@ -177,11 +180,11 @@ def test_interactive_init_collects_python_answers(runner): assert " | | | Posit Connect" in result.output assert " \\ \\/" in result.output assert "Configure a project for Posit Connect" in result.output - assert "[OK] Publisher project initialized" in result.output + assert "[OK] Project configured for publishing" in result.output assert "posit connect publish . --server " in result.output -def test_interactive_init_detects_entrypoints_and_defaults(runner): +def test_interactive_publish_init_detects_entrypoints_and_defaults(runner): with runner.isolated_filesystem(): Path("worker.py").write_text("", encoding="utf-8") Path("main.py").write_text("", encoding="utf-8") @@ -203,10 +206,10 @@ def test_interactive_init_detects_entrypoints_and_defaults(runner): init_mod._OTHER_PACKAGE_FILE, ] assert package_default == "requirements.txt" - assert init_mod._PYTHON_PACKAGE_MANAGERS[0] == "uv" + assert publish_mod._PYTHON_PACKAGE_MANAGERS[0] == "uv" -def test_interactive_init_detects_content_specific_entrypoints(runner): +def test_interactive_publish_init_detects_content_specific_entrypoints(runner): with runner.isolated_filesystem(): Path("app.py").write_text("", encoding="utf-8") Path("report.ipynb").write_text("{}", encoding="utf-8") @@ -264,7 +267,7 @@ def test_required_files_are_added_after_manual_selection(): ) -def test_interactive_init_accepts_custom_entrypoint_and_package_file(): +def test_interactive_publish_init_accepts_custom_entrypoint_and_package_file(): with patch.object( init_mod, "_ask", @@ -290,7 +293,7 @@ def test_interactive_init_accepts_custom_entrypoint_and_package_file(): assert answers["files"] == ("/src/", "/requirements/") -def test_interactive_quarto_asks_mode_and_version(runner): +def test_interactive_publish_init_quarto_asks_mode_and_version(runner): initialized = SimpleNamespace( config_name="report", config_path="/project/.posit/publish/report.toml" ) @@ -310,7 +313,7 @@ def test_interactive_quarto_asks_mode_and_version(runner): with patch.object( init_mod, "initialize_project", return_value=initialized ) as initialize: - result = runner.invoke(cli, ["connect", "init"]) + result = runner.invoke(cli, ["connect", "publish", "--init"]) assert result.exit_code == 0, result.output request = initialize.call_args.args[0] @@ -319,18 +322,18 @@ def test_interactive_quarto_asks_mode_and_version(runner): assert request.files == ("*", "/report.qmd") -def test_interactive_init_aborts_cleanly(runner): +def test_interactive_publish_init_aborts_cleanly(runner): prompt = MagicMock() prompt.unsafe_ask.side_effect = KeyboardInterrupt with patch.object(init_mod, "_is_interactive", return_value=True): with patch.object(init_mod.questionary, "select", return_value=prompt): - result = runner.invoke(cli, ["connect", "init"]) + result = runner.invoke(cli, ["connect", "publish", "--init"]) assert result.exit_code == 1 assert "Aborted!" in result.output -def test_init_wraps_rsconnect_errors(runner): +def test_publish_init_wraps_rsconnect_errors(runner): with patch.object( init_mod, "initialize_project", @@ -340,7 +343,8 @@ def test_init_wraps_rsconnect_errors(runner): cli, [ "connect", - "init", + "publish", + "--init", "--type", "html", "--entrypoint", @@ -355,34 +359,35 @@ def test_init_wraps_rsconnect_errors(runner): def test_publish_maps_all_request_fields(runner): published = SimpleNamespace(content_url="https://connect.example/content/abc/") - with patch.object(publish_mod, "publish_project", return_value=published) as publish: - result = runner.invoke( - cli, - [ - "connect", - "publish", - ".", - "--config", - "sales-api", - "--deployment", - "production", - "--server", - "https://connect.example", - "--api-key", - "secret", - "--snowflake-connection-name", - "snowflake-prod", - "--no-tls-verify", - "--content-id", - "guid-1", - "--draft", - "--no-verify", - "--exclude-renv", - "--metadata", - "git_commit=abc", - "--no-metadata", - ], - ) + with patch.object(publish_mod, "discover_configs", return_value=["config.toml"]): + with patch.object(publish_mod, "publish_project", return_value=published) as publish: + result = runner.invoke( + cli, + [ + "connect", + "publish", + ".", + "--config", + "sales-api", + "--deployment", + "production", + "--server", + "https://connect.example", + "--api-key", + "secret", + "--snowflake-connection-name", + "snowflake-prod", + "--no-tls-verify", + "--content-id", + "guid-1", + "--draft", + "--no-verify", + "--exclude-renv", + "--metadata", + "git_commit=abc", + "--no-metadata", + ], + ) assert result.exit_code == 0, result.output request = publish.call_args.args[0] @@ -404,21 +409,64 @@ def test_publish_maps_all_request_fields(runner): def test_publish_server_name_alias(runner): published = SimpleNamespace(content_url="https://connect.example/content/abc/") - with patch.object(publish_mod, "publish_project", return_value=published) as publish: - result = runner.invoke(cli, ["connect", "publish", "--name", "production"]) + with patch.object(publish_mod, "discover_configs", return_value=["config.toml"]): + with patch.object(publish_mod, "publish_project", return_value=published) as publish: + result = runner.invoke(cli, ["connect", "publish", "--name", "production"]) assert result.exit_code == 0, result.output assert publish.call_args.args[0].server_name == "production" def test_publish_wraps_rsconnect_errors(runner): - with patch.object( - publish_mod, - "publish_project", - side_effect=RSConnectException("specify server for the first publish"), - ): - result = runner.invoke(cli, ["connect", "publish"]) + with patch.object(publish_mod, "discover_configs", return_value=["config.toml"]): + with patch.object( + publish_mod, + "publish_project", + side_effect=RSConnectException("specify server for the first publish"), + ): + result = runner.invoke(cli, ["connect", "publish"]) assert result.exit_code == 1 assert "specify server for the first publish" in result.output assert result.exception is None or isinstance(result.exception, SystemExit) + + +def test_publish_auto_initializes_fresh_interactive_project(runner): + initialized = SimpleNamespace( + config_name="sales", config_path="/project/.posit/publish/sales.toml" + ) + published = SimpleNamespace(content_url="https://connect.example/content/abc/") + with patch.object(publish_mod, "discover_configs", return_value=[]): + with patch.object(init_mod, "_is_interactive", return_value=True): + with patch.object( + init_mod, "initialize_publish_project", return_value=initialized + ) as initialize: + with patch.object( + publish_mod, "publish_project", return_value=published + ) as publish: + result = runner.invoke(cli, ["connect", "publish"]) + + assert result.exit_code == 0, result.output + assert initialize.call_args.kwargs["show_success"] is False + assert publish.call_args.args[0].config_name == "sales" + assert result.output.strip() == published.content_url + + +def test_publish_fresh_noninteractive_project_explains_init(runner): + with patch.object(publish_mod, "discover_configs", return_value=[]): + with patch.object(init_mod, "_is_interactive", return_value=False): + result = runner.invoke(cli, ["connect", "publish"]) + + assert result.exit_code == 2 + assert "No Publisher configuration found" in result.output + assert "posit connect publish --init" in result.output + + +def test_publish_setup_options_require_init(runner): + result = runner.invoke( + cli, + ["connect", "publish", "--type", "python-fastapi", "--entrypoint", "app.py"], + ) + + assert result.exit_code == 2 + assert "Project setup options require --init" in result.output From 2e42b0d139110a5b1aca85d8a960bb103f7c5f7c Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 13 Aug 2026 13:27:26 -0400 Subject: [PATCH 12/14] Prefer detected publish setup files --- src/posit_cli/connect/init.py | 27 +++++++++++++++++++++++---- tests/test_publisher_cli.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index ba1bcef..fcb0cde 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -87,8 +87,22 @@ def _entrypoint_suffixes(content_type: str) -> Tuple[str, ...]: return () +def _single_existing_default( + project_dir: str, + candidates: Tuple[str, ...], + fallback: str, +) -> str: + existing = [name for name in candidates if (Path(project_dir) / name).is_file()] + return existing[0] if len(existing) == 1 else fallback + + def _entrypoint_choices(project_dir: str, content_type: str) -> Tuple[List[Any], str]: if content_type.startswith("python-"): + default = _single_existing_default( + project_dir, + ("app.py", "main.py"), + "app.py", + ) return ( [ questionary.Choice("app.py", value="app.py"), @@ -98,7 +112,7 @@ def _entrypoint_choices(project_dir: str, content_type: str) -> Tuple[List[Any], value=_OTHER_ENTRYPOINT, ), ], - "app.py", + default, ) suffixes = _entrypoint_suffixes(content_type) @@ -127,7 +141,12 @@ def _entrypoint_choices(project_dir: str, content_type: str) -> Tuple[List[Any], return choices, default -def _package_file_choices() -> Tuple[Tuple[Any, ...], str]: +def _package_file_choices(project_dir: str) -> Tuple[Tuple[Any, ...], str]: + default = _single_existing_default( + project_dir, + ("requirements.txt", "pyproject.toml"), + "requirements.txt", + ) return ( ( questionary.Choice( @@ -143,7 +162,7 @@ def _package_file_choices() -> Tuple[Tuple[Any, ...], str]: value=_OTHER_PACKAGE_FILE, ), ), - "requirements.txt", + default, ) @@ -397,7 +416,7 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: python: Optional[Dict[str, str]] = None package_file: Optional[str] = None if spec.language == "python": - package_file_choices, package_file_default = _package_file_choices() + package_file_choices, package_file_default = _package_file_choices(project_dir) _note("Choose requirements.txt, pyproject.toml, or another dependency file.") package_file = _ask( _select( diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 9c5814e..351afc9 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -190,9 +190,11 @@ def test_interactive_publish_init_detects_entrypoints_and_defaults(runner): Path("main.py").write_text("", encoding="utf-8") Path("app.py").write_text("", encoding="utf-8") Path("notes.txt").write_text("", encoding="utf-8") + Path("requirements.txt").write_text("", encoding="utf-8") + Path("pyproject.toml").write_text("", encoding="utf-8") choices, default = init_mod._entrypoint_choices(".", "python-fastapi") - package_choices, package_default = init_mod._package_file_choices() + package_choices, package_default = init_mod._package_file_choices(".") assert [choice.value for choice in choices] == [ "app.py", @@ -209,6 +211,31 @@ def test_interactive_publish_init_detects_entrypoints_and_defaults(runner): assert publish_mod._PYTHON_PACKAGE_MANAGERS[0] == "uv" +@pytest.mark.parametrize( + ("entrypoint_file", "package_file", "expected_entrypoint", "expected_package"), + [ + ("main.py", "pyproject.toml", "main.py", "pyproject.toml"), + ("app.py", "requirements.txt", "app.py", "requirements.txt"), + ], +) +def test_interactive_publish_init_prefers_single_existing_choice( + runner, + entrypoint_file, + package_file, + expected_entrypoint, + expected_package, +): + with runner.isolated_filesystem(): + Path(entrypoint_file).write_text("", encoding="utf-8") + Path(package_file).write_text("", encoding="utf-8") + + _, entrypoint_default = init_mod._entrypoint_choices(".", "python-fastapi") + _, package_default = init_mod._package_file_choices(".") + + assert entrypoint_default == expected_entrypoint + assert package_default == expected_package + + def test_interactive_publish_init_detects_content_specific_entrypoints(runner): with runner.isolated_filesystem(): Path("app.py").write_text("", encoding="utf-8") From 05861797dbb33d5cefcec0877b0a55923d8f44e3 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 13 Aug 2026 13:33:03 -0400 Subject: [PATCH 13/14] Default publish setup to file selection --- src/posit_cli/connect/init.py | 47 ++++++++--------------------------- tests/test_publisher_cli.py | 30 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index fcb0cde..a9a30a8 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -16,8 +16,6 @@ _CONTENT_TYPES_BY_NAME = {spec.type: spec for spec in CONTENT_TYPES} _OTHER_ENTRYPOINT = "__other_entrypoint__" _OTHER_PACKAGE_FILE = "__other_package_file__" -_INCLUDE_ALL_FILES = "__include_all_files__" -_SELECT_FILES_MANUALLY = "__select_files_manually__" _PYTHON_API_TYPES = {"python-fastapi", "python-flask", "python-dash"} _FILE_PICKER_EXCLUSIONS = { ".git", @@ -462,42 +460,19 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: ) } - _note("'*' includes current and future project files.") - _note("Publisher still skips metadata, environments, caches, and node_modules.") - file_mode = _ask( - _select( - "Which project files should Connect include when publishing?", - choices=( - questionary.Choice( - "All project files (*)", - value=_INCLUDE_ALL_FILES, - ), - questionary.Choice( - "Choose top-level files and folders", - value=_SELECT_FILES_MANUALLY, - ), - ), - default=_INCLUDE_ALL_FILES, + file_choices = _top_level_file_choices(project_dir, entrypoint, package_file) + if not file_choices: + raise click.ClickException("No top-level files or folders are available for selection.") + _note("The chosen entrypoint and dependency file start selected.") + _note("Folders include everything beneath them. Press A to toggle all entries.") + selected_files = _ask( + _checkbox( + "Select the top-level files and folders to include", + choices=file_choices, + validate=lambda selected: bool(selected) or "Select at least one file or folder.", ) ) - if file_mode == _INCLUDE_ALL_FILES: - files = ("*",) - else: - file_choices = _top_level_file_choices(project_dir, entrypoint, package_file) - if not file_choices: - raise click.ClickException( - "No top-level files or folders are available for manual selection." - ) - _note("The entrypoint and dependency file are always included.") - _note("Folders include everything beneath them. Press Space to toggle a checkbox.") - selected_files = _ask( - _checkbox( - "Select the top-level files and folders to include", - choices=file_choices, - validate=lambda selected: bool(selected) or "Select at least one file or folder.", - ) - ) - files = tuple(selected_files) + files = tuple(selected_files) return { "content_type": content_type, diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index 351afc9..cacb2ea 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -157,7 +157,7 @@ def test_interactive_publish_init_collects_python_answers(runner): "Sales API", "requirements.txt", "uv", - init_mod._INCLUDE_ALL_FILES, + ["/app.py", "/requirements.txt"], ], ): with patch.object( @@ -174,7 +174,7 @@ def test_interactive_publish_init_collects_python_answers(runner): "package_file": "requirements.txt", "package_manager": "uv", } - assert request.files == ("*", "/app.py", "/requirements.txt") + assert request.files == ("/app.py", "/requirements.txt") assert "Connect" in result.output assert " / /\\" in result.output assert " | | | Posit Connect" in result.output @@ -279,6 +279,27 @@ def test_manual_file_choices_precheck_entrypoint_and_dependencies(runner): } +def test_file_choices_use_answered_nested_paths_for_checked_folders(runner): + with runner.isolated_filesystem(): + Path("src").mkdir() + Path("src/api.py").write_text("", encoding="utf-8") + Path("requirements").mkdir() + Path("requirements/connect.txt").write_text("", encoding="utf-8") + Path("README.md").write_text("", encoding="utf-8") + + choices = init_mod._top_level_file_choices( + ".", + "src/api.py:create_app", + "requirements/connect.txt", + ) + + assert {choice.value: choice.checked for choice in choices} == { + "/requirements/": True, + "/src/": True, + "/README.md": False, + } + + def test_required_files_are_added_after_manual_selection(): files = init_mod._include_required_files( ".", @@ -306,7 +327,6 @@ def test_interactive_publish_init_accepts_custom_entrypoint_and_package_file(): init_mod._OTHER_PACKAGE_FILE, "requirements/connect.txt", "uv", - init_mod._SELECT_FILES_MANUALLY, ["/src/", "/requirements/"], ], ): @@ -334,7 +354,7 @@ def test_interactive_publish_init_quarto_asks_mode_and_version(runner): "report.qmd", "Report", "1.6.0", - init_mod._INCLUDE_ALL_FILES, + ["/report.qmd"], ], ): with patch.object( @@ -346,7 +366,7 @@ def test_interactive_publish_init_quarto_asks_mode_and_version(runner): request = initialize.call_args.args[0] assert request.content_type == "quarto-shiny" assert request.quarto == {"version": "1.6.0"} - assert request.files == ("*", "/report.qmd") + assert request.files == ("/report.qmd",) def test_interactive_publish_init_aborts_cleanly(runner): From c275d9dcbaad0c751df66108e1c25842ceb2d659 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 13 Aug 2026 13:41:10 -0400 Subject: [PATCH 14/14] Show recursive publish file tree --- src/posit_cli/connect/init.py | 112 +++++++++++++++++----------------- tests/test_publisher_cli.py | 41 +++++++++++-- 2 files changed, 91 insertions(+), 62 deletions(-) diff --git a/src/posit_cli/connect/init.py b/src/posit_cli/connect/init.py index a9a30a8..ff97489 100644 --- a/src/posit_cli/connect/init.py +++ b/src/posit_cli/connect/init.py @@ -164,63 +164,62 @@ def _package_file_choices(project_dir: str) -> Tuple[Tuple[Any, ...], str]: ) -def _top_level_name(project_dir: str, path: Optional[str]) -> Optional[str]: - if not path: - return None - - relative = path.split(":", 1)[0].replace("\\", "/").lstrip("/") - while relative.startswith("./"): - relative = relative[2:] - if not relative: - return None - - try: - entries = {entry.name for entry in Path(project_dir).iterdir()} - except OSError: - return None - top_level = relative.split("/", 1)[0] - if top_level in entries: - return top_level - if "/" not in relative and "{}.py".format(relative) in entries: - return "{}.py".format(relative) - if "/" not in relative and not relative.endswith(".py"): - package = relative.split(".", 1)[0] - if package in entries: - return package - return None - - -def _top_level_file_choices( +def _file_tree_choices( project_dir: str, entrypoint: str, package_file: Optional[str], ) -> List[Any]: - checked_entries = { - name - for name in ( - _top_level_name(project_dir, entrypoint), - _top_level_name(project_dir, package_file), + checked_paths = {_root_anchored(_entrypoint_file(project_dir, entrypoint)).rstrip("/")} + if package_file: + checked_paths.add(_root_anchored(package_file).rstrip("/")) + + choices: List[Any] = [] + + def add_directory(directory: Path, relative_dir: Path, depth: int) -> None: + try: + entries = [ + entry for entry in directory.iterdir() if entry.name not in _FILE_PICKER_EXCLUSIONS + ] + except OSError: + return + + entries.sort( + key=lambda entry: ( + not (entry.is_dir() and not entry.is_symlink()), + entry.name.lower(), + ) ) - if name - } - try: - entries = [ - entry - for entry in Path(project_dir).iterdir() - if entry.name not in _FILE_PICKER_EXCLUSIONS - ] - except OSError: - entries = [] + for entry in entries: + relative_path = relative_dir / entry.name + relative_value = relative_path.as_posix() + is_directory = entry.is_dir() and not entry.is_symlink() + value = "/{}/".format(relative_value) if is_directory else "/{}".format(relative_value) + title = "{}- {}{}".format( + " " * depth, + entry.name, + "/ (all files)" if is_directory else "", + ) + choices.append( + questionary.Choice( + title=title, + value=value, + checked=value.rstrip("/") in checked_paths, + ) + ) + if is_directory: + add_directory(entry, relative_path, depth + 1) - entries.sort(key=lambda entry: (not entry.is_dir(), entry.name.lower())) - return [ - questionary.Choice( - title=entry.name + ("/" if entry.is_dir() else ""), - value="/{}/".format(entry.name) if entry.is_dir() else "/{}".format(entry.name), - checked=entry.name in checked_entries, - ) - for entry in entries - ] + add_directory(Path(project_dir), Path(), 0) + return choices + + +def _collapse_file_selections(files: Tuple[str, ...]) -> Tuple[str, ...]: + selected_folders = [pattern for pattern in files if pattern.endswith("/")] + return tuple( + pattern + for pattern in files + if not any(pattern != folder and pattern.startswith(folder) for folder in selected_folders) + ) def _root_anchored(path: str) -> str: @@ -460,19 +459,20 @@ def collect_init_answers(project_dir: str) -> Dict[str, Any]: ) } - file_choices = _top_level_file_choices(project_dir, entrypoint, package_file) + file_choices = _file_tree_choices(project_dir, entrypoint, package_file) if not file_choices: - raise click.ClickException("No top-level files or folders are available for selection.") + raise click.ClickException("No project files or folders are available for selection.") _note("The chosen entrypoint and dependency file start selected.") - _note("Folders include everything beneath them. Press A to toggle all entries.") + _note("Folders select everything beneath them; indented rows select individual paths.") + _note("Press A to toggle all entries.") selected_files = _ask( _checkbox( - "Select the top-level files and folders to include", + "Select the project files and folders to include", choices=file_choices, validate=lambda selected: bool(selected) or "Select at least one file or folder.", ) ) - files = tuple(selected_files) + files = _collapse_file_selections(tuple(selected_files)) return { "content_type": content_type, diff --git a/tests/test_publisher_cli.py b/tests/test_publisher_cli.py index cacb2ea..31e3d89 100644 --- a/tests/test_publisher_cli.py +++ b/tests/test_publisher_cli.py @@ -251,15 +251,17 @@ def test_interactive_publish_init_detects_content_specific_entrypoints(runner): assert html_default == "index.html" -def test_manual_file_choices_precheck_entrypoint_and_dependencies(runner): +def test_file_tree_choices_precheck_entrypoint_and_dependencies(runner): with runner.isolated_filesystem(): Path("src").mkdir() Path("src/api.py").write_text("", encoding="utf-8") + Path("src/models").mkdir() + Path("src/models/user.py").write_text("", encoding="utf-8") Path("app.py").write_text("", encoding="utf-8") Path("requirements.txt").write_text("", encoding="utf-8") Path("README.md").write_text("", encoding="utf-8") - choices = init_mod._top_level_file_choices( + choices = init_mod._file_tree_choices( ".", "app.py", "requirements.txt", @@ -267,19 +269,30 @@ def test_manual_file_choices_precheck_entrypoint_and_dependencies(runner): assert [choice.value for choice in choices] == [ "/src/", + "/src/models/", + "/src/models/user.py", + "/src/api.py", "/app.py", "/README.md", "/requirements.txt", ] assert {choice.value: choice.checked for choice in choices} == { "/src/": False, + "/src/models/": False, + "/src/models/user.py": False, + "/src/api.py": False, "/app.py": True, "/README.md": False, "/requirements.txt": True, } + assert {choice.value: choice.title for choice in choices}["/src/"] == "- src/ (all files)" + assert {choice.value: choice.title for choice in choices}["/src/api.py"] == " - api.py" + assert {choice.value: choice.title for choice in choices}["/src/models/user.py"] == ( + " - user.py" + ) -def test_file_choices_use_answered_nested_paths_for_checked_folders(runner): +def test_file_tree_choices_use_answered_exact_nested_paths(runner): with runner.isolated_filesystem(): Path("src").mkdir() Path("src/api.py").write_text("", encoding="utf-8") @@ -287,19 +300,35 @@ def test_file_choices_use_answered_nested_paths_for_checked_folders(runner): Path("requirements/connect.txt").write_text("", encoding="utf-8") Path("README.md").write_text("", encoding="utf-8") - choices = init_mod._top_level_file_choices( + choices = init_mod._file_tree_choices( ".", "src/api.py:create_app", "requirements/connect.txt", ) assert {choice.value: choice.checked for choice in choices} == { - "/requirements/": True, - "/src/": True, + "/requirements/": False, + "/requirements/connect.txt": True, + "/src/": False, + "/src/api.py": True, "/README.md": False, } +def test_selected_folder_supersedes_descendants(): + files = init_mod._collapse_file_selections( + ( + "/src/", + "/src/api.py", + "/src/models/", + "/src/models/user.py", + "/README.md", + ) + ) + + assert files == ("/src/", "/README.md") + + def test_required_files_are_added_after_manual_selection(): files = init_mod._include_required_files( ".",