Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI
on:
pull_request:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
- uses: extractions/setup-just@v4
# Fail fast if uv.lock has drifted from pyproject.toml.
- run: uv lock --locked
- run: just lint

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need to test this across a matrix of python versions when the point is to tell people to uv tool install?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think to be able to ensure we support the same versions as rsconnect-python this is a must and potential forcing function to drop EOL py versions 3.8 and 3.9 asap.

also uv tool install is one way but we should still support pip installs or uv adds

- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: ${{ matrix.python-version }}
- uses: extractions/setup-just@v4
- run: just test ${{ matrix.python-version }}

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
- uses: extractions/setup-just@v4
- run: just build
- run: just smoke
37 changes: 37 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v9.0.0
- uses: extractions/setup-just@v4
- name: assert tag matches pyproject version
env:
TAG: ${{ github.ref_name }}
run: |
ver="$(just version)"
if [ "$TAG" != "v$ver" ]; then
echo "::error::tag '${TAG}' does not match pyproject version 'v${ver}'"
exit 1
fi
- name: assert tag is on main
env:
TAG: ${{ github.ref_name }}
run: |
if ! git merge-base --is-ancestor HEAD origin/main; then
echo "::error::tag '${TAG}' points to a commit that is not on main"
exit 1
fi
- run: just build
- run: just smoke
- uses: pypa/gh-action-pypi-publish@release/v1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
# roborev snapshots
/.roborev/
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ posit-sdk) and the OAuth-release gotcha.
- `posit connect api` uses rsconnect's *internal* client (`RSConnectExecutor` ->
`RSConnectClient`), which has no stability contract. `tests/test_rsconnect_contract.py` guards
the surface we depend on; pin the rsconnect version and re-verify on bumps.

See `RELEASE.md` for how to cut a release to PyPI.
38 changes: 38 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# posit-cli task runner. Run `just --list` to see recipes.
Comment thread
mconflitti-pbc marked this conversation as resolved.

# Run the test suite against a single Python version (default 3.13)
test py="3.13":
uv run --python {{py}} --extra test pytest tests

# Check formatting and lint
lint:
uv run --extra lint ruff format --check
uv run --extra lint ruff check

# Auto-format and apply lint fixes
fmt:
uv run --extra lint ruff format
uv run --extra lint ruff check --fix

# Build wheel + sdist
build:
uv build

# Smoke-test the most recently built wheel (no project install)
smoke:
#!/usr/bin/env bash
set -euo pipefail
WHL=$(ls -t dist/*.whl | head -1)
uv run --no-project --with "$WHL" posit --help

# Install the most recently built wheel into the active environment
install: build
uv pip install "$(ls -t dist/*.whl | head -1)"

# Print the current version
version:
@uv version --short

# Remove build/test artifacts
clean:
rm -rf .coverage .pytest_cache .ruff_cache build dist *.egg-info
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Posit Software, PBC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
22 changes: 22 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Releasing

Follow these steps to publish a new version to PyPI:

1. Bump `version` in `pyproject.toml`.
2. Run `uv lock` if the bump changed any dependency. CI fails the build if `uv.lock` has drifted.
3. Commit the change and merge it to `main`.
4. Tag the merged commit on `main` as `vX.Y.Z`. The tag must match the `pyproject.toml` version
exactly.
5. Push the tag. This triggers `.github/workflows/release.yaml`.

The release workflow checks the tag against `pyproject.toml` and against `main`, builds the
wheel and sdist, smoke-tests the wheel, then publishes to PyPI through GitHub's OIDC
trusted-publisher flow. No PyPI token is stored in this repo.

## One-time setup for a new repo

- Create a `release` environment under the repo's Settings > Environments, with required
reviewers. The `publish` job runs under this environment; without required reviewers, any tag
push publishes to PyPI immediately with no human gate.
- Configure a trusted publisher for `posit-cli` on PyPI with: Owner `posit-dev`, Repository name
`posit-cli`, Workflow name `release.yaml`, Environment name `release`.
22 changes: 11 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@ name = "posit-cli"
version = "0.1.0"
description = "A single, friendly command-line interface for Posit Connect, in the spirit of gh."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
requires-python = ">=3.8"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Posit Software, PBC" }]
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.
#
# TEMPORARY: tracking rsconnect's main branch for the unreleased OAuth
# `login`/`logout` commands. Pin back to a released ">=1.30,<2" once that ships.
"rsconnect-python @ git+https://github.com/posit-dev/rsconnect-python.git@main",
"rsconnect-python>=1.30,<2",
# rsconnect imports `keyring` optionally; we depend on it directly so OAuth
# tokens land in the OS keyring.
"keyring>=23.0",
Expand All @@ -27,15 +25,17 @@ posit = "posit_cli.__main__:cli"

[project.optional-dependencies]
test = ["pytest>=7"]
lint = ["ruff>=0.6"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.metadata]
# Required for the temporary git dependency on rsconnect-python's main branch.
# Drop once we pin back to a released rsconnect-python.
allow-direct-references = true

[tool.hatch.build.targets.wheel]
packages = ["src/posit_cli"]

[tool.ruff]
line-length = 99

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
18 changes: 4 additions & 14 deletions src/posit_cli/connect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,10 @@
from .api import api as api_cmd


# rsconnect's OAuth `login` only exists on its (currently unreleased) main branch.
# Recommend it when present; otherwise point users at the API-key path that works
# today. Because commands are mounted dynamically below, `login` appears for free
# once a release ships it.
if "login" in rsconnect_cli.commands:
_epilog = (
"Tip: prefer 'posit connect login' (OAuth, tokens stored in your OS "
"keyring) over 'posit connect add' (stores a plaintext API key)."
)
else:
_epilog = (
"Tip: authenticate with 'posit connect add', or set CONNECT_SERVER and "
"CONNECT_API_KEY. (OAuth 'login' arrives with a future rsconnect release.)"
)
_epilog = (
"Tip: prefer 'posit connect login' (OAuth, tokens stored in your OS "
"keyring) over 'posit connect server add' (stores a plaintext API key)."
)


@click.group(no_args_is_help=True, epilog=_epilog)
Expand Down
10 changes: 3 additions & 7 deletions src/posit_cli/connect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,7 @@ def _split_headers(headers: Tuple[str, ...]) -> Dict[str, str]:
out: Dict[str, str] = {}
for header in headers:
if ":" not in header:
raise click.BadParameter(
f"expected key:value, got {header!r}", param_hint="--header"
)
raise click.BadParameter(f"expected key:value, got {header!r}", param_hint="--header")
key, value = header.split(":", 1)
out[key.strip()] = value.strip()
return out
Expand Down Expand Up @@ -445,9 +443,7 @@ def _request_all_pages(
return _merge_pages(pages)


def _split_query(
path: str, query_params: Optional[Dict[str, Any]]
) -> Tuple[str, Dict[str, Any]]:
def _split_query(path: str, query_params: Optional[Dict[str, Any]]) -> Tuple[str, Dict[str, Any]]:
"""Split any ``?query`` off ``path`` and merge it with ``query_params``.

Pagination needs the query as a mutable dict so it can advance the cursor or
Expand Down Expand Up @@ -517,7 +513,7 @@ def _next_page_path(next_url: str) -> str:
parsed = urlparse(next_url)
marker = "/__api__/"
idx = parsed.path.find(marker)
rel = parsed.path[idx + len(marker):] if idx != -1 else parsed.path.lstrip("/")
rel = parsed.path[idx + len(marker) :] if idx != -1 else parsed.path.lstrip("/")
return f"{rel}?{parsed.query}" if parsed.query else rel


Expand Down
32 changes: 18 additions & 14 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ def test_fields_imply_post_and_become_body(runner):


def test_json_body_preserves_user_headers(runner):
_, request, _ = _invoke(
runner, ["v1/content", "-f", "name=app", "-H", "X-Test: 1"]
)
_, request, _ = _invoke(runner, ["v1/content", "-f", "name=app", "-H", "X-Test: 1"])
headers = request.call_args.kwargs["headers"]
assert headers["X-Test"] == "1" # finding 2: must survive a JSON body
assert headers["Content-Type"] == "application/json"
Expand Down Expand Up @@ -141,7 +139,9 @@ def test_credential_options_passed_to_executor(runner):


def test_jq_extracts_scalar_unquoted(runner):
result, _, _ = _invoke(runner, ["v1/user", "-q", ".username"], request_return={"username": "neal"})
result, _, _ = _invoke(
runner, ["v1/user", "-q", ".username"], request_return={"username": "neal"}
)
assert result.exit_code == 0, result.output
# gh-style: a string result prints raw, without surrounding quotes.
assert result.output.strip() == "neal"
Expand Down Expand Up @@ -263,12 +263,14 @@ def test_include_jq_runtime_error_leaks_nothing_to_stdout(runner):
with patch("posit_cli.connect.api.RSConnectExecutor") as Executor:
ce = Executor.return_value
ce.client.request.return_value = resp
result = runner.invoke(
cli, ["connect", "api", "v1/user", "-i", "-q", 'error("boom")']
)
result = runner.invoke(cli, ["connect", "api", "v1/user", "-i", "-q", 'error("boom")'])
assert result.exit_code != 0
assert result.stdout == "" # no headers, no body
assert "jq:" in result.stderr
# 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).
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


def _paginated_invoke(runner, args, pages):
Expand Down Expand Up @@ -446,9 +448,7 @@ def test_no_tls_verify_flag_sets_insecure(runner):
def test_input_conflicts_with_fields(runner, tmp_path):
body_file = tmp_path / "b.json"
body_file.write_text("{}")
result, _, _ = _invoke(
runner, ["v1/content", "--input", str(body_file), "-f", "a=b"]
)
result, _, _ = _invoke(runner, ["v1/content", "--input", str(body_file), "-f", "a=b"])
assert result.exit_code != 0
assert "cannot be combined" in result.output

Expand All @@ -470,7 +470,9 @@ def test_non_2xx_response_exits_nonzero(runner):
def test_implicit_post_4xx_hints_query_params(runner):
# gh parity: bare -f implies POST. When that POST 4xxs, nudge toward query
# params (the common cause is using -f to filter a read).
err = _http_response(status=400, reason="Bad Request", body=json.dumps({"error": "unknown field"}))
err = _http_response(
status=400, reason="Bad Request", body=json.dumps({"error": "unknown field"})
)
result, _, _ = _invoke(runner, ["v1/content", "-f", "limit=2"], request_return=err)
assert result.exit_code == 1
assert "-X GET" in result.output
Expand All @@ -480,7 +482,9 @@ def test_implicit_post_4xx_hints_query_params(runner):
def test_explicit_method_4xx_omits_hint(runner):
# If the user chose the method, the implicit-POST hint would be noise.
err = _http_response(status=400, reason="Bad Request", body=json.dumps({"error": "nope"}))
result, _, _ = _invoke(runner, ["v1/content", "-X", "POST", "-f", "name=x"], request_return=err)
result, _, _ = _invoke(
runner, ["v1/content", "-X", "POST", "-f", "name=x"], request_return=err
)
assert result.exit_code == 1
assert "query parameters" not in result.output

Expand Down
4 changes: 1 addition & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ def test_connect_mounts_api_command(runner):
assert "api" in result.output


# rsconnect commands we expect to re-expose under `posit connect`. `login`/`logout`
# (OAuth) come from the rsconnect main-branch build we currently track; they should
# remain present once that work is released.
# rsconnect commands we expect to re-expose under `posit connect`.
EXPECTED_RSCONNECT_COMMANDS = [
"add",
"deploy",
Expand Down
Loading