From f39680805e237d7b15abd7de0f255bdac682cf6b Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 31 Jul 2026 12:03:19 -0400 Subject: [PATCH 1/7] Add CI and PyPI-publish workflows Adds .github/workflows/ci.yaml (lint, test matrix, build) and release.yaml (tag-triggered PyPI publish via trusted publishing), both driven through a new Justfile so the same commands work locally and in CI. Pins rsconnect-python to the latest release (>=1.30,<2) instead of a main-branch git dependency, now that the OAuth login/logout commands it was tracking have shipped. Adds a LICENSE file and a pinned ruff lint config. --- .github/workflows/ci.yaml | 43 +++++++ .github/workflows/release.yaml | 36 ++++++ .gitignore | 1 + Justfile | 31 +++++ LICENSE | 21 ++++ plan.md | 159 +++++++++++++++++++++++++ pyproject.toml | 19 ++- src/posit_cli/connect/__init__.py | 18 +-- src/posit_cli/connect/api.py | 10 +- test-landscape.md | 185 ++++++++++++++++++++++++++++++ tests/test_api.py | 24 ++-- tests/test_cli.py | 4 +- uv.lock | 42 ++++++- 13 files changed, 543 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 Justfile create mode 100644 LICENSE create mode 100644 plan.md create mode 100644 test-landscape.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..14889f0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,43 @@ +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@v4 + - uses: astral-sh/setup-uv@v6 + - uses: extractions/setup-just@v3 + - run: just lint + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - uses: extractions/setup-just@v3 + - run: uv python install ${{ matrix.python-version }} + - run: just test ${{ matrix.python-version }} + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - uses: extractions/setup-just@v3 + - run: just build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8dc4c1b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,36 @@ +name: Release +on: + push: + tags: + - "v*.*.*" +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v6 + - uses: extractions/setup-just@v3 + - name: assert tag matches pyproject version + run: | + tag="${GITHUB_REF_NAME#v}" + ver="$(just version)" + if [ "$tag" != "$ver" ]; then + echo "::error::tag '${GITHUB_REF_NAME}' does not match pyproject version '$ver'" + exit 1 + fi + - name: assert tag is on main + run: | + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::tag '${GITHUB_REF_NAME}' points to a commit that is not on main" + exit 1 + fi + - run: just build + - name: smoke test the built wheel + run: | + WHL=$(ls dist/*.whl | head -1) + uv run --no-project --with "$WHL" posit --help + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 0d08d64..438ee50 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ venv/ .pytest_cache/ .ruff_cache/ .mypy_cache/ +.coverage # roborev snapshots /.roborev/ diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..c5ff5db --- /dev/null +++ b/Justfile @@ -0,0 +1,31 @@ +# posit-cli task runner. Run `just --list` to see recipes. + +# 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 + +# Install the most recently built wheel into the active environment +install: build + uv pip install dist/*.whl + +# Print the current version +version: + @uv version --short + +# Remove build/test artifacts +clean: + rm -rf .coverage .pytest_cache .ruff_cache build dist *.egg-info diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..691ed50 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..cf747fc --- /dev/null +++ b/plan.md @@ -0,0 +1,159 @@ +# Plan: publish posit-cli to PyPI + +## Goal + +Add CI steps that build and publish `posit-cli` to PyPI on a version tag, +following the pattern used by `posit-dev/posit-sdk-py` and +`posit-dev/rsconnect-python`. Both use: + +- `astral-sh/setup-uv` + `uv build` for the build step. +- Trusted publishing (OIDC) via `pypa/gh-action-pypi-publish@release/v1`, + no long-lived PyPI API token, `permissions: id-token: write`. +- A tag push (`v*.*.*` or bare `*`) as the release trigger. + +## Blocker to resolve before the workflow can succeed (not blocking this PR) + +**The name `posit-cli` is already registered on PyPI**, owned by an unrelated +project (`sol-eng/posit-cli` by SamEdwardes, latest release `0.1.1a1`). +This matters for two reasons, not just cosmetics: + +1. `posit-dev` cannot pre-register a PyPI "pending publisher" for a name that + already exists — pending publishers are only for names that don't exist + yet. +2. `posit-dev` cannot add a trusted publisher to the *existing* `posit-cli` + project unless granted collaborator access by its current owner. + +So the release workflow below will be correct but **cannot actually publish** +until this is resolved (new name, or ownership/collaborator access on the +existing project). Per your call, proceeding with `posit-cli` as a +placeholder everywhere; you're handling the naming question separately. + +## Gaps in the current build toolchain + +Found by comparing this repo against posit-sdk-py and rsconnect-python. None +of these block writing the workflow files, but the workflow will expose them +(fail lint, fail build, etc.) if left unaddressed. + +1. **No CI at all yet.** `.github/` doesn't exist in this repo. Both + reference projects gate releases on a passing CI job; here the release + workflow would be the *first* thing to run any lint/test/build step. I'm + adding a basic `ci.yaml` (lint + test matrix + build) alongside the + release workflow so tags aren't published untested. + +2. **No LICENSE file on disk.** `pyproject.toml` declares + `license = { text = "MIT" }`, but there is no `LICENSE` file in the repo. + Both reference projects ship one (posit-sdk-py's `license = { file = + "LICENSE" }` even points at it). PyPI renders license info from the file + when present; worth adding for a real release. + +3. **Static, hand-maintained version.** `pyproject.toml` hardcodes + `version = "0.1.0"`. Neither reference project does this by hand: + - posit-sdk-py uses `setuptools-scm` (`dynamic = ["version"]`, version + derived from the git tag). + - rsconnect-python keeps a static version too, but its release workflow + *asserts* the pushed tag matches `uv version --short` and fails the + release otherwise. + posit-cli has neither: no dynamic versioning and no tag/version + consistency check. I've modeled the release workflow on rsconnect-python's + assertion approach (simplest change), but adding `hatch-vcs` for + git-tag-driven versioning (this repo already uses hatchling) is the + lower-maintenance option if you want it instead — flagging as a decision + for you, not deciding it myself. + +4. **No lint or type-check tooling declared.** Both reference projects run + `ruff` (lint + format check) and a type checker (`pyright` for + posit-sdk-py) in CI. This repo has no `ruff`/`pyright` in + `[project.optional-dependencies]` or a `dependency-groups` table, and no + `ruff.toml`/`[tool.ruff]` config. The `ci.yaml` I'm adding needs at least + `ruff` to do anything meaningful — currently there's nothing to run. + +5. **No task runner.** Both reference repos drive CI through a `Makefile` + (posit-sdk-py) or `just` (rsconnect-python), so the workflow files stay + thin and the same commands work locally. This repo has neither. I'm + keeping the new workflow files self-contained (raw `uv run`/`uv build` + commands) rather than introducing a new tool, but that means CI and local + dev commands can drift — worth a `Makefile` later if this grows. + +6. **Git-pinned rsconnect-python dependency.** `pyproject.toml` depends on + `rsconnect-python @ git+https://github.com/posit-dev/rsconnect-python.git@main` + (already called out as TEMPORARY in that file, tracking unreleased OAuth + commands). A package published to PyPI with a direct git-URL dependency is + unusual and fragile for consumers (`pip install posit-cli` will try to + clone GitHub at install time). This should be pinned to a released + rsconnect-python version before a real PyPI release, independent of the + CI/publish plumbing itself. + +7. **No `py.typed` marker.** Minor; only matters if you want the + `Typing :: Typed` classifier posit-sdk-py carries. Not a blocker. + +## Decisions (confirmed by you) + +- Versioning: keep the static `version` field in `pyproject.toml`, and have + the release workflow assert the pushed tag matches it (rsconnect-python + style) rather than switching to `hatch-vcs`. +- Lint: add `ruff` now, not deferred to a follow-up. +- LICENSE: add an MIT `LICENSE` file now. +- Naming clash with PyPI's existing `posit-cli`: proceed using `posit-cli` + everywhere; you're resolving the naming/ownership question separately. + +## Implemented + +- **`LICENSE`** — standard MIT text, copyright Posit Software, PBC. + `pyproject.toml`'s `license` field now points at it + (`{ file = "LICENSE" }`) instead of an inline string. +- **`pyproject.toml`** — added a `lint` extra (`ruff>=0.6`) and a + `[tool.ruff]`/`[tool.ruff.lint]` config. Rule selection is pinned + explicitly to ruff's own documented defaults (`E4`, `E7`, `E9`, `F`) + rather than left implicit, so a future ruff upgrade can't silently turn on + new default rules and break CI. +- **Pinned `rsconnect-python` to the latest release.** Changed the + dependency from + `rsconnect-python @ git+https://github.com/posit-dev/rsconnect-python.git@main` + to `rsconnect-python>=1.30,<2`. The OAuth `login`/`logout` commands this + repo was tracking on `main` for have since shipped in the `1.30.0` + release, so the git dependency is no longer needed. Dropped the now-unused + `[tool.hatch.metadata] allow-direct-references = true` (only needed for + git dependencies). Simplified `src/posit_cli/connect/__init__.py`: it had + an `if "login" in rsconnect_cli.commands` branch to handle the + main-branch-only case; since the pin guarantees `login` is always present, + that branch is now dead code and was removed, leaving just the one epilog + string. Re-verified `tests/test_rsconnect_contract.py`'s internal-API + assumptions against the real `1.30.0` release — still holds. +- **`Justfile`** — task runner recipes (`test`, `lint`, `fmt`, `build`, + `install`, `version`, `clean`), modeled on rsconnect-python's `Justfile`. + CI and the release workflow call these instead of raw `uv` commands, so + the same commands work identically in CI and local dev. + - Note: `version` recipe uses `@uv version --short` (the `@` suppresses + just's default command-echo) — without it, `just version`'s output + includes the echoed command line before the actual version string, + which would break the release workflow's tag-match comparison. +- **`.github/workflows/ci.yaml`** — three jobs on PR + push to `main`: + `lint` (`just lint`), `test` (`just test ` matrix, py3.9–3.13), + `build` (`just build`). Uses `extractions/setup-just` alongside + `astral-sh/setup-uv`. +- **`.github/workflows/release.yaml`** — on `v*.*.*` tag push: checkout, + setup-uv, setup-just, assert the tag (minus its `v` prefix) matches + `just version`, assert the tag is on `main`, `just build`, smoke-test the + built wheel (`posit --help` from the wheel, no project install), publish + via `pypa/gh-action-pypi-publish@release/v1` (trusted publishing, + `permissions: id-token: write`, no stored PyPI token). +- Ran `ruff format` once to bring the two pre-existing files it flagged + (`src/posit_cli/connect/api.py`, `tests/test_api.py`) in line with the new + config — no behavior change, formatting only. +- Verified locally: `just lint` clean, full `just test` suite passes + (64 tests) against the pinned `rsconnect-python==1.30.0`, `just build` + succeeds, and the built wheel's `posit --help` smoke test (as used in + `release.yaml`) works. Also confirmed the tag-match assertion logic + (`tag="${GITHUB_REF_NAME#v}"` vs. `just version`) resolves correctly. + +## Manual setup (outside this repo, can't be done via CI files) + +- Register `posit-dev/posit-cli`'s `release.yaml` as a trusted publisher on + PyPI for the `posit-cli` project — blocked until the naming/ownership + question above is resolved. + +## Still open / not addressed in this change + +- **No `py.typed` marker** (gap #7 above) — minor, only matters for the + `Typing :: Typed` classifier. +- **PyPI name clash** — see blocker section above; not resolved here. diff --git a/pyproject.toml b/pyproject.toml index 5f341f0..d7c703e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,13 @@ 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" } +license = { file = "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", @@ -27,15 +24,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"] diff --git a/src/posit_cli/connect/__init__.py b/src/posit_cli/connect/__init__.py index 3e82c2d..52219d4 100644 --- a/src/posit_cli/connect/__init__.py +++ b/src/posit_cli/connect/__init__.py @@ -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 add' (stores a plaintext API key)." +) @click.group(no_args_is_help=True, epilog=_epilog) diff --git a/src/posit_cli/connect/api.py b/src/posit_cli/connect/api.py index 181eb0e..de7c823 100644 --- a/src/posit_cli/connect/api.py +++ b/src/posit_cli/connect/api.py @@ -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 @@ -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 @@ -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 diff --git a/test-landscape.md b/test-landscape.md new file mode 100644 index 0000000..75af2fb --- /dev/null +++ b/test-landscape.md @@ -0,0 +1,185 @@ +# Test landscape: posit-cli + +Investigation date: 2026-07-31. Scope: `tests/` against `src/posit_cli/`. + +## Summary + +| File | Purpose | Tests | +|---|---|---| +| `tests/test_api.py` | `posit connect api` argument handling, no network | 51 | +| `tests/test_cli.py` | Command-tree smoke tests | 4 (+8 parametrized) | +| `tests/test_rsconnect_contract.py` | Guards the rsconnect internal API surface | 2 | + +Total: 65 test functions (64 collected test items after parametrization +counts as separate items; the exact number that runs is 64 per `pytest`). + +Line coverage of `src/`: **96%** (251/261 statements). All uncovered lines +are in `src/posit_cli/connect/api.py`. + +## How this was measured + +```console +uv run --extra test python -m coverage run --source=src -m pytest tests +uv run --extra test python -m coverage report -m +``` + +No coverage tool or threshold is wired into `pyproject.toml` or CI today — +this was a one-off local run to find gaps, not a repeatable check. + +## Gaps found + +### 1. Uncovered lines in `api.py` (10 statements, 96% → could be 100%) + +| Line(s) | Code | Why it's untested | +|---|---|---| +| 30 | `_read_at_value`: `return sys.stdin.read()` for `@-` | No test feeds a field or `--input` value via stdin. | +| 38 | `_parse_typed`: `@file` branch (`return _read_at_value(value)`) | No test exercises `-F key=@file` (typed field reading from a file). | +| 42 | `_parse_typed`: `false` branch | Only `true` is tested (`test_typed_field_parsing`); `false` is not. | +| 44 | `_parse_typed`: `null` branch | Not exercised at all. | +| 73 | `_split_headers`: malformed header (`raise click.BadParameter`, missing `:`) | The analogous field-parsing error (`test_bad_field_format`) is tested; the header one is not. | +| 238 | `api()`: `body = raw_body` when `--input` is used *without* conflicting fields | `test_input_conflicts_with_fields` only exercises the *rejection* path (`--input` + `-f` together); no test sends `--input` alone and checks it becomes the request body. | +| 283 | `api()`: `except RSConnectException as exc: raise click.ClickException(...)` | No test simulates `RSConnectExecutor`/`setup_client`/`request` raising `RSConnectException` (e.g. connection refused, unknown server). All error-path tests go through the `HTTPResponse` return value, not this exception path. | +| 458 | `_split_query`: `if query_params: params.update(query_params)` | Only reached when both a `?query` in `path` *and* a non-empty `query_params` dict collide during pagination; no test builds that combination directly (though pagination tests all pass `query_params=None` at the top level). | +| 535 | `_merge_pages`: `return pages` (unrecognized per-page shape, not falling back to `results`) | No test feeds `--paginate` a page missing a `results` list, to check pages are returned raw instead of silently dropped. | +| 584 | `_dumps`: `return str(value)` (non-dict/list scalar) | Every success/error test so far renders a dict, list, or empty body; no test renders a bare scalar (e.g. a plain string or number) through `_dumps`. | + +None of these are exotic: each is a real, reachable branch a user can hit +(`-F count=@file`, `-F flag=false`, `--input body.json` alone, a Connect +server that's unreachable, a scalar JSON response). Recommend closing all +ten before calling coverage "done," since they're cheap unit tests, not new +infrastructure. + +### 2. CLI-surface options with no dedicated test + +Every `click.option` on `posit connect api` should have at least one test +that proves it's wired to the right effect. Checked against the option list +in `src/posit_cli/connect/api.py:84-173`: + +| Option | Tested? | +|---|---| +| `PATH` argument | Yes | +| `--method`/`-X` | Yes | +| `--field`/`-F` | Yes | +| `--raw-field`/`-f` | Yes | +| `--header`/`-H` | Yes | +| `--input` | Partially — only the conflict-with-fields case (see gap #1, line 238) | +| `--jq`/`-q` | Yes | +| `--include`/`-i` | Yes | +| `--paginate` | Yes | +| `--name`/`-n` | Yes (via `test_credential_options_passed_to_executor`) | +| `--server`/`-s` | Yes (same test) | +| `--api-key`/`-k` | Yes (same test) | +| `--no-tls-verify` | Yes | +| `--cacert`/`-c` | **No test at all** | + +`--cacert` is a real gap: it takes a `click.Path(exists=True, ...)`, so it +also needs a test that the path-existence validation itself works (a +missing file should be a clean Click error, not a traceback). + +### 3. Environment-variable wiring is untested + +`--server`, `--api-key`, `--no-tls-verify`, and `--cacert` all declare +`envvar=` (`CONNECT_SERVER`, `CONNECT_API_KEY`, `CONNECT_INSECURE`, +`CONNECT_CA_CERTIFICATE` respectively — see `CLAUDE.md`'s "Conventions" +section, which calls out keeping these consistent with rsconnect on +purpose). No test sets any of these env vars and checks the CLI picks them +up when the flag is omitted. This matters here specifically because +`CLAUDE.md` treats this env-var consistency as a project convention worth +guarding, similar to why `test_rsconnect_contract.py` exists — an accidental +rename would silently break scripts relying on the env var, not just the +flag. + +### 4. `--version` is untested + +`src/posit_cli/__main__.py` wires `@click.version_option(version=__version__)` +onto the top-level `cli` group. No test invokes `posit --version` and +checks it exits 0 and prints something. Cheap to add, and it's the one +thing `src/posit_cli/__init__.py`'s `importlib.metadata` fallback logic +(`__version__ = "0.0.0+unknown"` when not installed) has no test guarding +either — if that fallback ever throws instead of catching +`PackageNotFoundError`, nothing would catch it today. + +### 5. Mounted rsconnect commands: presence-only, not behavior + +`tests/test_cli.py` checks that expected rsconnect commands +(`add`, `deploy`, `list`, `details`, `remove`, `bootstrap`, `login`, +`logout`) are *present* in `connect.commands`, and that `deploy --help` +mentions `streamlit`. That's appropriate — deep-testing rsconnect's own +command behavior would duplicate rsconnect-python's own test suite, and +per `CLAUDE.md`'s design principle ("Mounted rsconnect commands stay as-is +... the value of mounting is that they track upstream"), that's +intentional, not a gap to close. + +One real gap here: `EXPECTED_RSCONNECT_COMMANDS`'s comment +(`tests/test_cli.py:26-28`) is stale. It says: + +> `login`/`logout` (OAuth) come from the rsconnect main-branch build we +> currently track; they should remain present once that work is released. + +This described the *pre-pin* state. `rsconnect-python` is now pinned to the +released `>=1.30,<2` (this session's earlier change), where `login`/`logout` +already ship. The comment should be updated or removed — it now describes +a state that no longer exists, and a future reader would wrongly conclude +the dependency is still tracking `main`. + +### 6. No integration test against a real Connect server + +Everything in `test_api.py` mocks `RSConnectExecutor` — by design, this is +a fast, network-free unit-test suite (docstring: "no network"). posit-cli +has no equivalent — its only Connect-shaped guard is +`test_rsconnect_contract.py`'s introspection of rsconnect's internal API +surface, which checks shape, not live behavior. + +Both reference projects keep integration tests structurally separate from +unit tests and run them against a real Connect server via +`posit-dev/with-connect` in CI: + +- **rsconnect-python** interleaves integration tests into the *same* + `tests/` tree as unit tests, gated by `pytest.skip()` helpers in + `tests/utils.py` (`require_connect()`, `require_api_key()` — skip unless + `CONNECT_SERVER`/`CONNECT_API_KEY` are set). `test_main_content.py` and + `test_main_integration.py` register fake Connect endpoints with + `httpretty` (`httpretty.register_uri(...)`) and drive them through the + real `click` CLI (`CliRunner().invoke(cli, [...])`) — closer to full + request/response round-trips than posit-cli's current + mock-the-Python-object style. +- **posit-sdk-py** keeps a *separate* `integration/` tree entirely, driven + by `integration/Makefile`'s `CONNECT_VERSIONS` matrix (a list of pinned + Connect release versions run against a real server per version). Its + *unit* tests (`tests/posit/connect/*.py`) mock HTTP directly with the + `responses` library (`responses.get(url, json=..., match=[...])`) against + real Connect JSON response fixtures loaded via `tests/posit/connect/api.py`'s + `load_mock()` (reading from a `tests/posit/connect/__api__/` fixture + tree) — a cleaner separation than either mocking the client object + (posit-cli's current approach) or `httpretty` (rsconnect-python's). + +Not flagging this as a must-fix: it's a meaningfully larger lift (Connect +license, `with-connect` action, matrix of Connect versions) than the unit +gaps above, and the project is early-stage. Worth deciding deliberately +rather than defaulting into it — and worth picking one of the two patterns +above rather than inventing a third. + +### 7. No coverage enforcement + +Nothing in `pyproject.toml` or `.github/workflows/ci.yaml` runs or +thresholds coverage. The 96% figure in this document is a one-off manual +measurement, not a number future changes are held to. Both reference +projects wire coverage into CI: posit-sdk-py enforces a hard floor via +`.coveragerc`'s `fail_under = 80` (plus `make cov`/`cov-xml` targets and an +`orgoro/coverage` PR-comment step); rsconnect-python produces +`coverage.xml` and posts it via the same `orgoro/coverage` action, without +a `fail_under` gate. If coverage matters to this project, it isn't +enforced anywhere yet — and posit-sdk-py's stricter, gated approach is the +better model given posit-cli is already at 96%. + +## Priority if closing these + +1. **Cheap, high-value, no new infra** — close the 10 uncovered lines + (gap #1) and add `--cacert` + `--version` tests (gaps #2, #4). All unit + tests, all mockable, no new dependencies. +2. **Cheap, correctness-adjacent** — fix the stale comment in + `test_cli.py` (gap #5) and add env-var wiring tests (gap #3), since + `CLAUDE.md` calls out env-var consistency as a deliberate convention. +3. **Bigger decisions, not urgent** — coverage enforcement in CI (gap #7) + and a real integration-test job against Connect (gap #6). Worth a + deliberate yes/no from you, not something to default into. diff --git a/tests/test_api.py b/tests/test_api.py index 43d946d..7a77e73 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" @@ -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" @@ -263,9 +263,7 @@ 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 @@ -446,9 +444,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 @@ -470,7 +466,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 @@ -480,7 +478,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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 2248cba..4e152ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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", diff --git a/uv.lock b/uv.lock index 29ad384..5ef7d4b 100644 --- a/uv.lock +++ b/uv.lock @@ -539,6 +539,9 @@ dependencies = [ ] [package.optional-dependencies] +lint = [ + { name = "ruff" }, +] test = [ { 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'" }, @@ -550,9 +553,10 @@ requires-dist = [ { name = "jq", specifier = ">=1.4" }, { name = "keyring", specifier = ">=23.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, - { name = "rsconnect-python", git = "https://github.com/posit-dev/rsconnect-python.git?rev=main" }, + { name = "rsconnect-python", specifier = ">=1.30,<2" }, + { name = "ruff", marker = "extra == 'lint'", specifier = ">=0.6" }, ] -provides-extras = ["test"] +provides-extras = ["test", "lint"] [[package]] name = "pycparser" @@ -654,11 +658,12 @@ wheels = [ [[package]] name = "rsconnect-python" -version = "1.29.1.dev14+g9f22fddc7" -source = { git = "https://github.com/posit-dev/rsconnect-python.git?rev=main#9f22fddc7bcd67659df99c24c1bb668952d57ccd" } +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } 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 = "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" }, @@ -667,6 +672,35 @@ dependencies = [ { name = "typing-extensions" }, { 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" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] [[package]] name = "secretstorage" From 83d4ae18e5bd709e2b965728e9fe7f4b1e6735d9 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Mon, 3 Aug 2026 10:37:11 -0400 Subject: [PATCH 2/7] Address PR review: action versions, Python matrix, epilog wording - Bump actions/checkout, astral-sh/setup-uv, and extractions/setup-just to their latest releases; use setup-uv's python-version input for the matrix instead of a separate uv python install step. - Widen the test matrix and requires-python floor to match rsconnect-python's own supported range (>=3.8), then add 3.14 on top since it's already out and works fine locally. This surfaced a real cross-version bug: click's CliRunner API differs between the 8.1.8 resolved for <3.10 and 8.4.2 for >=3.10 (stdout/stderr mixing default changed), which broke one test's assumptions on the older interpreters. Fixed by asserting against combined output instead of separately-captured streams. - Simplify the release tag-match check to compare the full tag against a reconstructed v instead of stripping the "v" prefix, and source it from an explicit env: block per GitHub's script-injection hardening guidance. - Point the connect group's epilog at 'posit connect server add' (rsconnect-python's new recommended alias for 'add') instead of the bare top-level command. - Remove plan.md and test-landscape.md; they were working notes, not something worth keeping in repo history. --- .github/workflows/ci.yaml | 23 +- .github/workflows/release.yaml | 17 +- plan.md | 159 ------------- pyproject.toml | 2 +- src/posit_cli/connect/__init__.py | 2 +- test-landscape.md | 185 --------------- tests/test_api.py | 8 +- uv.lock | 364 ++++++++++++++++++++++++++---- 8 files changed, 350 insertions(+), 410 deletions(-) delete mode 100644 plan.md delete mode 100644 test-landscape.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 14889f0..54ca2c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,9 +11,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - - uses: extractions/setup-just@v3 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v9.0.0 + - uses: extractions/setup-just@v4 - run: just lint test: @@ -22,22 +22,25 @@ jobs: fail-fast: false matrix: python-version: + - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" + - "3.14" steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - - uses: extractions/setup-just@v3 - - run: uv python install ${{ matrix.python-version }} + - 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@v4 - - uses: astral-sh/setup-uv@v6 - - uses: extractions/setup-just@v3 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v9.0.0 + - uses: extractions/setup-just@v4 - run: just build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8dc4c1b..114de72 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,23 +9,26 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v6 - - uses: extractions/setup-just@v3 + - 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: | - tag="${GITHUB_REF_NAME#v}" ver="$(just version)" - if [ "$tag" != "$ver" ]; then - echo "::error::tag '${GITHUB_REF_NAME}' does not match pyproject version '$ver'" + 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 '${GITHUB_REF_NAME}' points to a commit that is not on main" + echo "::error::tag '${TAG}' points to a commit that is not on main" exit 1 fi - run: just build diff --git a/plan.md b/plan.md deleted file mode 100644 index cf747fc..0000000 --- a/plan.md +++ /dev/null @@ -1,159 +0,0 @@ -# Plan: publish posit-cli to PyPI - -## Goal - -Add CI steps that build and publish `posit-cli` to PyPI on a version tag, -following the pattern used by `posit-dev/posit-sdk-py` and -`posit-dev/rsconnect-python`. Both use: - -- `astral-sh/setup-uv` + `uv build` for the build step. -- Trusted publishing (OIDC) via `pypa/gh-action-pypi-publish@release/v1`, - no long-lived PyPI API token, `permissions: id-token: write`. -- A tag push (`v*.*.*` or bare `*`) as the release trigger. - -## Blocker to resolve before the workflow can succeed (not blocking this PR) - -**The name `posit-cli` is already registered on PyPI**, owned by an unrelated -project (`sol-eng/posit-cli` by SamEdwardes, latest release `0.1.1a1`). -This matters for two reasons, not just cosmetics: - -1. `posit-dev` cannot pre-register a PyPI "pending publisher" for a name that - already exists — pending publishers are only for names that don't exist - yet. -2. `posit-dev` cannot add a trusted publisher to the *existing* `posit-cli` - project unless granted collaborator access by its current owner. - -So the release workflow below will be correct but **cannot actually publish** -until this is resolved (new name, or ownership/collaborator access on the -existing project). Per your call, proceeding with `posit-cli` as a -placeholder everywhere; you're handling the naming question separately. - -## Gaps in the current build toolchain - -Found by comparing this repo against posit-sdk-py and rsconnect-python. None -of these block writing the workflow files, but the workflow will expose them -(fail lint, fail build, etc.) if left unaddressed. - -1. **No CI at all yet.** `.github/` doesn't exist in this repo. Both - reference projects gate releases on a passing CI job; here the release - workflow would be the *first* thing to run any lint/test/build step. I'm - adding a basic `ci.yaml` (lint + test matrix + build) alongside the - release workflow so tags aren't published untested. - -2. **No LICENSE file on disk.** `pyproject.toml` declares - `license = { text = "MIT" }`, but there is no `LICENSE` file in the repo. - Both reference projects ship one (posit-sdk-py's `license = { file = - "LICENSE" }` even points at it). PyPI renders license info from the file - when present; worth adding for a real release. - -3. **Static, hand-maintained version.** `pyproject.toml` hardcodes - `version = "0.1.0"`. Neither reference project does this by hand: - - posit-sdk-py uses `setuptools-scm` (`dynamic = ["version"]`, version - derived from the git tag). - - rsconnect-python keeps a static version too, but its release workflow - *asserts* the pushed tag matches `uv version --short` and fails the - release otherwise. - posit-cli has neither: no dynamic versioning and no tag/version - consistency check. I've modeled the release workflow on rsconnect-python's - assertion approach (simplest change), but adding `hatch-vcs` for - git-tag-driven versioning (this repo already uses hatchling) is the - lower-maintenance option if you want it instead — flagging as a decision - for you, not deciding it myself. - -4. **No lint or type-check tooling declared.** Both reference projects run - `ruff` (lint + format check) and a type checker (`pyright` for - posit-sdk-py) in CI. This repo has no `ruff`/`pyright` in - `[project.optional-dependencies]` or a `dependency-groups` table, and no - `ruff.toml`/`[tool.ruff]` config. The `ci.yaml` I'm adding needs at least - `ruff` to do anything meaningful — currently there's nothing to run. - -5. **No task runner.** Both reference repos drive CI through a `Makefile` - (posit-sdk-py) or `just` (rsconnect-python), so the workflow files stay - thin and the same commands work locally. This repo has neither. I'm - keeping the new workflow files self-contained (raw `uv run`/`uv build` - commands) rather than introducing a new tool, but that means CI and local - dev commands can drift — worth a `Makefile` later if this grows. - -6. **Git-pinned rsconnect-python dependency.** `pyproject.toml` depends on - `rsconnect-python @ git+https://github.com/posit-dev/rsconnect-python.git@main` - (already called out as TEMPORARY in that file, tracking unreleased OAuth - commands). A package published to PyPI with a direct git-URL dependency is - unusual and fragile for consumers (`pip install posit-cli` will try to - clone GitHub at install time). This should be pinned to a released - rsconnect-python version before a real PyPI release, independent of the - CI/publish plumbing itself. - -7. **No `py.typed` marker.** Minor; only matters if you want the - `Typing :: Typed` classifier posit-sdk-py carries. Not a blocker. - -## Decisions (confirmed by you) - -- Versioning: keep the static `version` field in `pyproject.toml`, and have - the release workflow assert the pushed tag matches it (rsconnect-python - style) rather than switching to `hatch-vcs`. -- Lint: add `ruff` now, not deferred to a follow-up. -- LICENSE: add an MIT `LICENSE` file now. -- Naming clash with PyPI's existing `posit-cli`: proceed using `posit-cli` - everywhere; you're resolving the naming/ownership question separately. - -## Implemented - -- **`LICENSE`** — standard MIT text, copyright Posit Software, PBC. - `pyproject.toml`'s `license` field now points at it - (`{ file = "LICENSE" }`) instead of an inline string. -- **`pyproject.toml`** — added a `lint` extra (`ruff>=0.6`) and a - `[tool.ruff]`/`[tool.ruff.lint]` config. Rule selection is pinned - explicitly to ruff's own documented defaults (`E4`, `E7`, `E9`, `F`) - rather than left implicit, so a future ruff upgrade can't silently turn on - new default rules and break CI. -- **Pinned `rsconnect-python` to the latest release.** Changed the - dependency from - `rsconnect-python @ git+https://github.com/posit-dev/rsconnect-python.git@main` - to `rsconnect-python>=1.30,<2`. The OAuth `login`/`logout` commands this - repo was tracking on `main` for have since shipped in the `1.30.0` - release, so the git dependency is no longer needed. Dropped the now-unused - `[tool.hatch.metadata] allow-direct-references = true` (only needed for - git dependencies). Simplified `src/posit_cli/connect/__init__.py`: it had - an `if "login" in rsconnect_cli.commands` branch to handle the - main-branch-only case; since the pin guarantees `login` is always present, - that branch is now dead code and was removed, leaving just the one epilog - string. Re-verified `tests/test_rsconnect_contract.py`'s internal-API - assumptions against the real `1.30.0` release — still holds. -- **`Justfile`** — task runner recipes (`test`, `lint`, `fmt`, `build`, - `install`, `version`, `clean`), modeled on rsconnect-python's `Justfile`. - CI and the release workflow call these instead of raw `uv` commands, so - the same commands work identically in CI and local dev. - - Note: `version` recipe uses `@uv version --short` (the `@` suppresses - just's default command-echo) — without it, `just version`'s output - includes the echoed command line before the actual version string, - which would break the release workflow's tag-match comparison. -- **`.github/workflows/ci.yaml`** — three jobs on PR + push to `main`: - `lint` (`just lint`), `test` (`just test ` matrix, py3.9–3.13), - `build` (`just build`). Uses `extractions/setup-just` alongside - `astral-sh/setup-uv`. -- **`.github/workflows/release.yaml`** — on `v*.*.*` tag push: checkout, - setup-uv, setup-just, assert the tag (minus its `v` prefix) matches - `just version`, assert the tag is on `main`, `just build`, smoke-test the - built wheel (`posit --help` from the wheel, no project install), publish - via `pypa/gh-action-pypi-publish@release/v1` (trusted publishing, - `permissions: id-token: write`, no stored PyPI token). -- Ran `ruff format` once to bring the two pre-existing files it flagged - (`src/posit_cli/connect/api.py`, `tests/test_api.py`) in line with the new - config — no behavior change, formatting only. -- Verified locally: `just lint` clean, full `just test` suite passes - (64 tests) against the pinned `rsconnect-python==1.30.0`, `just build` - succeeds, and the built wheel's `posit --help` smoke test (as used in - `release.yaml`) works. Also confirmed the tag-match assertion logic - (`tag="${GITHUB_REF_NAME#v}"` vs. `just version`) resolves correctly. - -## Manual setup (outside this repo, can't be done via CI files) - -- Register `posit-dev/posit-cli`'s `release.yaml` as a trusted publisher on - PyPI for the `posit-cli` project — blocked until the naming/ownership - question above is resolved. - -## Still open / not addressed in this change - -- **No `py.typed` marker** (gap #7 above) — minor, only matters for the - `Typing :: Typed` classifier. -- **PyPI name clash** — see blocker section above; not resolved here. diff --git a/pyproject.toml b/pyproject.toml index d7c703e..33474a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ 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" +requires-python = ">=3.8" license = { file = "LICENSE" } authors = [{ name = "Posit Software, PBC" }] dependencies = [ diff --git a/src/posit_cli/connect/__init__.py b/src/posit_cli/connect/__init__.py index 52219d4..a7cafad 100644 --- a/src/posit_cli/connect/__init__.py +++ b/src/posit_cli/connect/__init__.py @@ -13,7 +13,7 @@ _epilog = ( "Tip: prefer 'posit connect login' (OAuth, tokens stored in your OS " - "keyring) over 'posit connect add' (stores a plaintext API key)." + "keyring) over 'posit connect server add' (stores a plaintext API key)." ) diff --git a/test-landscape.md b/test-landscape.md deleted file mode 100644 index 75af2fb..0000000 --- a/test-landscape.md +++ /dev/null @@ -1,185 +0,0 @@ -# Test landscape: posit-cli - -Investigation date: 2026-07-31. Scope: `tests/` against `src/posit_cli/`. - -## Summary - -| File | Purpose | Tests | -|---|---|---| -| `tests/test_api.py` | `posit connect api` argument handling, no network | 51 | -| `tests/test_cli.py` | Command-tree smoke tests | 4 (+8 parametrized) | -| `tests/test_rsconnect_contract.py` | Guards the rsconnect internal API surface | 2 | - -Total: 65 test functions (64 collected test items after parametrization -counts as separate items; the exact number that runs is 64 per `pytest`). - -Line coverage of `src/`: **96%** (251/261 statements). All uncovered lines -are in `src/posit_cli/connect/api.py`. - -## How this was measured - -```console -uv run --extra test python -m coverage run --source=src -m pytest tests -uv run --extra test python -m coverage report -m -``` - -No coverage tool or threshold is wired into `pyproject.toml` or CI today — -this was a one-off local run to find gaps, not a repeatable check. - -## Gaps found - -### 1. Uncovered lines in `api.py` (10 statements, 96% → could be 100%) - -| Line(s) | Code | Why it's untested | -|---|---|---| -| 30 | `_read_at_value`: `return sys.stdin.read()` for `@-` | No test feeds a field or `--input` value via stdin. | -| 38 | `_parse_typed`: `@file` branch (`return _read_at_value(value)`) | No test exercises `-F key=@file` (typed field reading from a file). | -| 42 | `_parse_typed`: `false` branch | Only `true` is tested (`test_typed_field_parsing`); `false` is not. | -| 44 | `_parse_typed`: `null` branch | Not exercised at all. | -| 73 | `_split_headers`: malformed header (`raise click.BadParameter`, missing `:`) | The analogous field-parsing error (`test_bad_field_format`) is tested; the header one is not. | -| 238 | `api()`: `body = raw_body` when `--input` is used *without* conflicting fields | `test_input_conflicts_with_fields` only exercises the *rejection* path (`--input` + `-f` together); no test sends `--input` alone and checks it becomes the request body. | -| 283 | `api()`: `except RSConnectException as exc: raise click.ClickException(...)` | No test simulates `RSConnectExecutor`/`setup_client`/`request` raising `RSConnectException` (e.g. connection refused, unknown server). All error-path tests go through the `HTTPResponse` return value, not this exception path. | -| 458 | `_split_query`: `if query_params: params.update(query_params)` | Only reached when both a `?query` in `path` *and* a non-empty `query_params` dict collide during pagination; no test builds that combination directly (though pagination tests all pass `query_params=None` at the top level). | -| 535 | `_merge_pages`: `return pages` (unrecognized per-page shape, not falling back to `results`) | No test feeds `--paginate` a page missing a `results` list, to check pages are returned raw instead of silently dropped. | -| 584 | `_dumps`: `return str(value)` (non-dict/list scalar) | Every success/error test so far renders a dict, list, or empty body; no test renders a bare scalar (e.g. a plain string or number) through `_dumps`. | - -None of these are exotic: each is a real, reachable branch a user can hit -(`-F count=@file`, `-F flag=false`, `--input body.json` alone, a Connect -server that's unreachable, a scalar JSON response). Recommend closing all -ten before calling coverage "done," since they're cheap unit tests, not new -infrastructure. - -### 2. CLI-surface options with no dedicated test - -Every `click.option` on `posit connect api` should have at least one test -that proves it's wired to the right effect. Checked against the option list -in `src/posit_cli/connect/api.py:84-173`: - -| Option | Tested? | -|---|---| -| `PATH` argument | Yes | -| `--method`/`-X` | Yes | -| `--field`/`-F` | Yes | -| `--raw-field`/`-f` | Yes | -| `--header`/`-H` | Yes | -| `--input` | Partially — only the conflict-with-fields case (see gap #1, line 238) | -| `--jq`/`-q` | Yes | -| `--include`/`-i` | Yes | -| `--paginate` | Yes | -| `--name`/`-n` | Yes (via `test_credential_options_passed_to_executor`) | -| `--server`/`-s` | Yes (same test) | -| `--api-key`/`-k` | Yes (same test) | -| `--no-tls-verify` | Yes | -| `--cacert`/`-c` | **No test at all** | - -`--cacert` is a real gap: it takes a `click.Path(exists=True, ...)`, so it -also needs a test that the path-existence validation itself works (a -missing file should be a clean Click error, not a traceback). - -### 3. Environment-variable wiring is untested - -`--server`, `--api-key`, `--no-tls-verify`, and `--cacert` all declare -`envvar=` (`CONNECT_SERVER`, `CONNECT_API_KEY`, `CONNECT_INSECURE`, -`CONNECT_CA_CERTIFICATE` respectively — see `CLAUDE.md`'s "Conventions" -section, which calls out keeping these consistent with rsconnect on -purpose). No test sets any of these env vars and checks the CLI picks them -up when the flag is omitted. This matters here specifically because -`CLAUDE.md` treats this env-var consistency as a project convention worth -guarding, similar to why `test_rsconnect_contract.py` exists — an accidental -rename would silently break scripts relying on the env var, not just the -flag. - -### 4. `--version` is untested - -`src/posit_cli/__main__.py` wires `@click.version_option(version=__version__)` -onto the top-level `cli` group. No test invokes `posit --version` and -checks it exits 0 and prints something. Cheap to add, and it's the one -thing `src/posit_cli/__init__.py`'s `importlib.metadata` fallback logic -(`__version__ = "0.0.0+unknown"` when not installed) has no test guarding -either — if that fallback ever throws instead of catching -`PackageNotFoundError`, nothing would catch it today. - -### 5. Mounted rsconnect commands: presence-only, not behavior - -`tests/test_cli.py` checks that expected rsconnect commands -(`add`, `deploy`, `list`, `details`, `remove`, `bootstrap`, `login`, -`logout`) are *present* in `connect.commands`, and that `deploy --help` -mentions `streamlit`. That's appropriate — deep-testing rsconnect's own -command behavior would duplicate rsconnect-python's own test suite, and -per `CLAUDE.md`'s design principle ("Mounted rsconnect commands stay as-is -... the value of mounting is that they track upstream"), that's -intentional, not a gap to close. - -One real gap here: `EXPECTED_RSCONNECT_COMMANDS`'s comment -(`tests/test_cli.py:26-28`) is stale. It says: - -> `login`/`logout` (OAuth) come from the rsconnect main-branch build we -> currently track; they should remain present once that work is released. - -This described the *pre-pin* state. `rsconnect-python` is now pinned to the -released `>=1.30,<2` (this session's earlier change), where `login`/`logout` -already ship. The comment should be updated or removed — it now describes -a state that no longer exists, and a future reader would wrongly conclude -the dependency is still tracking `main`. - -### 6. No integration test against a real Connect server - -Everything in `test_api.py` mocks `RSConnectExecutor` — by design, this is -a fast, network-free unit-test suite (docstring: "no network"). posit-cli -has no equivalent — its only Connect-shaped guard is -`test_rsconnect_contract.py`'s introspection of rsconnect's internal API -surface, which checks shape, not live behavior. - -Both reference projects keep integration tests structurally separate from -unit tests and run them against a real Connect server via -`posit-dev/with-connect` in CI: - -- **rsconnect-python** interleaves integration tests into the *same* - `tests/` tree as unit tests, gated by `pytest.skip()` helpers in - `tests/utils.py` (`require_connect()`, `require_api_key()` — skip unless - `CONNECT_SERVER`/`CONNECT_API_KEY` are set). `test_main_content.py` and - `test_main_integration.py` register fake Connect endpoints with - `httpretty` (`httpretty.register_uri(...)`) and drive them through the - real `click` CLI (`CliRunner().invoke(cli, [...])`) — closer to full - request/response round-trips than posit-cli's current - mock-the-Python-object style. -- **posit-sdk-py** keeps a *separate* `integration/` tree entirely, driven - by `integration/Makefile`'s `CONNECT_VERSIONS` matrix (a list of pinned - Connect release versions run against a real server per version). Its - *unit* tests (`tests/posit/connect/*.py`) mock HTTP directly with the - `responses` library (`responses.get(url, json=..., match=[...])`) against - real Connect JSON response fixtures loaded via `tests/posit/connect/api.py`'s - `load_mock()` (reading from a `tests/posit/connect/__api__/` fixture - tree) — a cleaner separation than either mocking the client object - (posit-cli's current approach) or `httpretty` (rsconnect-python's). - -Not flagging this as a must-fix: it's a meaningfully larger lift (Connect -license, `with-connect` action, matrix of Connect versions) than the unit -gaps above, and the project is early-stage. Worth deciding deliberately -rather than defaulting into it — and worth picking one of the two patterns -above rather than inventing a third. - -### 7. No coverage enforcement - -Nothing in `pyproject.toml` or `.github/workflows/ci.yaml` runs or -thresholds coverage. The 96% figure in this document is a one-off manual -measurement, not a number future changes are held to. Both reference -projects wire coverage into CI: posit-sdk-py enforces a hard floor via -`.coveragerc`'s `fail_under = 80` (plus `make cov`/`cov-xml` targets and an -`orgoro/coverage` PR-comment step); rsconnect-python produces -`coverage.xml` and posts it via the same `orgoro/coverage` action, without -a `fail_under` gate. If coverage matters to this project, it isn't -enforced anywhere yet — and posit-sdk-py's stricter, gated approach is the -better model given posit-cli is already at 96%. - -## Priority if closing these - -1. **Cheap, high-value, no new infra** — close the 10 uncovered lines - (gap #1) and add `--cacert` + `--version` tests (gaps #2, #4). All unit - tests, all mockable, no new dependencies. -2. **Cheap, correctness-adjacent** — fix the stale comment in - `test_cli.py` (gap #5) and add env-var wiring tests (gap #3), since - `CLAUDE.md` calls out env-var consistency as a deliberate convention. -3. **Bigger decisions, not urgent** — coverage enforcement in CI (gap #7) - and a real integration-test job against Connect (gap #6). Worth a - deliberate yes/no from you, not something to default into. diff --git a/tests/test_api.py b/tests/test_api.py index 7a77e73..416530d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -265,8 +265,12 @@ def test_include_jq_runtime_error_leaks_nothing_to_stdout(runner): ce.client.request.return_value = resp 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): diff --git a/uv.lock b/uv.lock index 5ef7d4b..ea7ae22 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,11 @@ version = 1 revision = 3 -requires-python = ">=3.9" +requires-python = ">=3.8" 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]] @@ -16,12 +17,74 @@ 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.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*' 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" } @@ -84,7 +147,8 @@ 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'" }, @@ -123,11 +187,14 @@ 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", marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { 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'" }, ] 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 = [ @@ -179,8 +246,8 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { 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'" }, + { 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'" }, ] 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 = [ @@ -228,23 +295,39 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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'" }, ] 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.10'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] 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 = [ @@ -266,13 +349,26 @@ 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 = [ @@ -296,7 +392,8 @@ name = "jaraco-classes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { 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 = "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" } @@ -304,16 +401,31 @@ 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.10'" }, + { name = "backports-tarfile", marker = "python_full_version == '3.9.*'" }, ] 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 = [ @@ -335,16 +447,31 @@ 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.10'" }, + { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] 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 = [ @@ -423,6 +550,12 @@ 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" }, @@ -436,21 +569,48 @@ 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.10'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { 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" }, - { name = "jaraco-context", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { 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-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.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.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { 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 = "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 = "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" } @@ -458,13 +618,25 @@ 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 = [ @@ -492,13 +664,25 @@ 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 = "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 = [ @@ -517,10 +701,27 @@ 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" }, @@ -534,7 +735,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" }, + { 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 = "rsconnect-python" }, ] @@ -543,7 +745,8 @@ lint = [ { name = "ruff" }, ] test = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { 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 = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] @@ -564,7 +767,8 @@ 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 = [ @@ -592,34 +796,71 @@ 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", marker = "python_full_version < '3.11'" }, + { 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'" }, ] 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.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'" }, + { 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.*'" }, ] 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 = [ @@ -638,7 +879,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", 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 = "pygments", marker = "python_full_version >= '3.10'" }, { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] @@ -664,12 +905,15 @@ 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 = "pip", version = "26.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { 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'" }, - { name = "pyjwt" }, + { 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 = "semver" }, { name = "toml", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, + { 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" } @@ -708,7 +952,8 @@ 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'" }, @@ -808,10 +1053,27 @@ 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 = "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" }, @@ -843,13 +1105,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/f4/2860fef80fa82a33b4f05f744be5620699a5c3c99239499deedeea57be4a/uv-0.11.24-py3-none-win_arm64.whl", hash = "sha256:047d763d20d71968c00f4afec40b0e75d9da7e3693f725b9f502d84a25256893", size = 24140983, upload-time = "2026-06-23T21:14:22.43Z" }, ] +[[package]] +name = "zipp" +version = "3.20.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" } +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" }, +] + [[package]] name = "zipp" 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 c5658488697eeeea6a7802178cf368023846620d Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Mon, 3 Aug 2026 11:45:49 -0400 Subject: [PATCH 3/7] Add lockfile drift check and wheel smoke test to CI - lint job now runs 'uv lock --locked' first, so a pyproject.toml/ uv.lock mismatch fails fast instead of surfacing as a confusing downstream error. - New 'just smoke' recipe installs the just-built wheel standalone (--no-project) and runs 'posit --help', catching packaging bugs (bad wheel contents, broken entry point) that unit tests can't see since those run against the source tree, not the built artifact. Wired into both the PR build job and the release workflow, where it replaces a previously inline, now-duplicated version of the same check. Deliberately did not add cross-OS test jobs: rsconnect-python already runs its own macOS/Windows matrix, and posit-cli's tests fully mock RSConnectExecutor, so they wouldn't exercise anything OS-specific that isn't already rsconnect-python's responsibility to cover. --- .github/workflows/ci.yaml | 3 +++ .github/workflows/release.yaml | 5 +---- Justfile | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 54ca2c6..0213a8d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,6 +14,8 @@ jobs: - 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: @@ -44,3 +46,4 @@ jobs: - uses: astral-sh/setup-uv@v9.0.0 - uses: extractions/setup-just@v4 - run: just build + - run: just smoke diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 114de72..286c319 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -32,8 +32,5 @@ jobs: exit 1 fi - run: just build - - name: smoke test the built wheel - run: | - WHL=$(ls dist/*.whl | head -1) - uv run --no-project --with "$WHL" posit --help + - run: just smoke - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/Justfile b/Justfile index c5ff5db..76afb54 100644 --- a/Justfile +++ b/Justfile @@ -18,6 +18,13 @@ fmt: build: uv build +# Smoke-test the most recently built wheel (no project install) +smoke: + #!/usr/bin/env bash + set -euo pipefail + WHL=$(ls 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 dist/*.whl From 1c0d103b2e898e904167f1b19789ccf3af1b8efa Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Tue, 4 Aug 2026 14:47:08 -0400 Subject: [PATCH 4/7] Use dedicated release environment for PyPI publish Restricts trusted-publisher OIDC access to a protected GitHub environment so maintainers with repo write access don't automatically get PyPI publish rights. --- .github/workflows/release.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 286c319..44cb43d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,6 +6,7 @@ on: jobs: publish: runs-on: ubuntu-latest + environment: release permissions: id-token: write steps: From 87cd36561889c0b62025652328c08716c6a9a4c1 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Tue, 4 Aug 2026 15:24:22 -0400 Subject: [PATCH 5/7] Fix PyPI license metadata and wheel-glob footgun in Justfile license = {file = "LICENSE"} dumped the full MIT text into the wheel's License field instead of a clean identifier; switch to PEP 639 license/license-files. Also make smoke/install pick the newest wheel in dist/ instead of the lexically-first one. --- Justfile | 4 ++-- pyproject.toml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Justfile b/Justfile index 76afb54..413a557 100644 --- a/Justfile +++ b/Justfile @@ -22,12 +22,12 @@ build: smoke: #!/usr/bin/env bash set -euo pipefail - WHL=$(ls dist/*.whl | head -1) + 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 dist/*.whl + uv pip install "$(ls -t dist/*.whl | head -1)" # Print the current version version: diff --git a/pyproject.toml b/pyproject.toml index 33474a3..0583691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ 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.8" -license = { file = "LICENSE" } +license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "Posit Software, PBC" }] dependencies = [ # posit-cli reuses rsconnect-python's *internal* API (RSConnectExecutor, From cfaff631008231aac3077a021e8c41b6898e77e1 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Tue, 4 Aug 2026 15:28:31 -0400 Subject: [PATCH 6/7] Document the release process The steps for cutting a release, the tag/version/main checks the workflow enforces, and the one-time GitHub environment and PyPI trusted-publisher setup lived only in the release.yaml workflow and in review discussion. Write them down so a future releaser doesn't have to reverse-engineer the YAML. --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7e6a373..3913548 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,3 +36,26 @@ 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. + +## 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`. From 7e027ad2bec4a3da66e30acf9965c51adc1f9fe7 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Tue, 4 Aug 2026 15:30:40 -0400 Subject: [PATCH 7/7] Move release process docs into RELEASE.md Keep CLAUDE.md focused on architecture/design conventions; the step-by-step release process reads better as its own file. --- CLAUDE.md | 23 +---------------------- RELEASE.md | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 RELEASE.md diff --git a/CLAUDE.md b/CLAUDE.md index 3913548..1a58781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,25 +37,4 @@ posit-sdk) and the OAuth-release gotcha. `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. -## 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`. +See `RELEASE.md` for how to cut a release to PyPI. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..668ca10 --- /dev/null +++ b/RELEASE.md @@ -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`.