From 9d9440e065982ac347f75926d75a1723945e78ae Mon Sep 17 00:00:00 2001 From: Ian Flores Siaca <18703558+ian-flores@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:43:59 -0700 Subject: [PATCH] refactor(cli): split cli.py into one module per command --- .github/workflows/connect-smoke.yml | 2 +- .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/install-flow-smoke.yml | 4 +- .github/workflows/mock-idp-e2e.yml | 4 +- .github/workflows/packagemanager-smoke.yml | 2 +- .github/workflows/workbench-smoke.yml | 2 +- AGENTS.md | 8 +- pyproject.toml | 2 +- selftests/install/test_cli_uninstall.py | 2 +- selftests/test_auth_cache.py | 4 +- selftests/test_auth_scheme.py | 2 +- selftests/test_cli_cleanup.py | 11 +- selftests/test_cli_report.py | 16 +- selftests/test_cli_url_scheme.py | 2 +- selftests/test_cli_verify.py | 12 +- selftests/test_errors.py | 7 +- src/vip/auth/cache.py | 2 +- src/vip/auth/flows.py | 2 +- src/vip/auth/scheme.py | 5 +- src/vip/cli.py | 2137 -------------------- src/vip/cli/__init__.py | 55 + src/vip/cli/__main__.py | 5 + src/vip/cli/_common.py | 47 + src/vip/cli/app.py | 583 ++++++ src/vip/cli/auth.py | 29 + src/vip/cli/cleanup.py | 260 +++ src/vip/cli/install.py | 216 ++ src/vip/cli/report.py | 223 ++ src/vip/cli/scaffold.py | 146 ++ src/vip/cli/status.py | 112 + src/vip/cli/verify.py | 585 ++++++ src/vip/cli/version.py | 26 + src/vip/plugin.py | 2 +- 33 files changed, 2335 insertions(+), 2182 deletions(-) delete mode 100644 src/vip/cli.py create mode 100644 src/vip/cli/__init__.py create mode 100644 src/vip/cli/__main__.py create mode 100644 src/vip/cli/_common.py create mode 100644 src/vip/cli/app.py create mode 100644 src/vip/cli/auth.py create mode 100644 src/vip/cli/cleanup.py create mode 100644 src/vip/cli/install.py create mode 100644 src/vip/cli/report.py create mode 100644 src/vip/cli/scaffold.py create mode 100644 src/vip/cli/status.py create mode 100644 src/vip/cli/verify.py create mode 100644 src/vip/cli/version.py diff --git a/.github/workflows/connect-smoke.yml b/.github/workflows/connect-smoke.yml index 7d2cfdb1..6adb3fd2 100644 --- a/.github/workflows/connect-smoke.yml +++ b/.github/workflows/connect-smoke.yml @@ -54,7 +54,7 @@ jobs: - 'src/vip/idp.py' - 'src/vip/config.py' - 'src/vip/plugin.py' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'src/vip_tests/conftest.py' - 'pyproject.toml' - 'uv.lock' diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index a6d32609..5f9647ad 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -39,7 +39,7 @@ jobs: uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 # AGENTS.md documents `vip report`, which shells out to a separate - # `quarto` executable (src/vip/cli.py) -- pyproject.toml only installs + # `quarto` executable (src/vip/cli/report.py) -- pyproject.toml only installs # the Python/Jupyter side. Same action, same pin, same version as # example-report.yml's Quarto install. - uses: quarto-dev/quarto-actions/setup@8a96df13519ee81fd526f2dfca5962811136661b # v2 diff --git a/.github/workflows/install-flow-smoke.yml b/.github/workflows/install-flow-smoke.yml index 813944f5..e5770537 100644 --- a/.github/workflows/install-flow-smoke.yml +++ b/.github/workflows/install-flow-smoke.yml @@ -18,14 +18,14 @@ on: branches: [main] paths: - 'src/vip/install/**' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'pyproject.toml' - 'uv.lock' - '.github/workflows/install-flow-smoke.yml' pull_request: paths: - 'src/vip/install/**' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'pyproject.toml' - 'uv.lock' - '.github/workflows/install-flow-smoke.yml' diff --git a/.github/workflows/mock-idp-e2e.yml b/.github/workflows/mock-idp-e2e.yml index f6a1d258..d91f594e 100644 --- a/.github/workflows/mock-idp-e2e.yml +++ b/.github/workflows/mock-idp-e2e.yml @@ -16,7 +16,7 @@ on: - 'src/vip/auth/**' - 'src/vip/idp.py' - 'src/vip/totp.py' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'src/vip_tests/conftest.py' - 'src/vip_tests/connect/test_auth.py' - 'src/vip_tests/connect/test_auth.feature' @@ -62,7 +62,7 @@ jobs: - 'src/vip/auth/**' - 'src/vip/idp.py' - 'src/vip/totp.py' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'src/vip_tests/conftest.py' - 'src/vip_tests/connect/test_auth.py' - 'src/vip_tests/connect/test_auth.feature' diff --git a/.github/workflows/packagemanager-smoke.yml b/.github/workflows/packagemanager-smoke.yml index efc1769c..2b497666 100644 --- a/.github/workflows/packagemanager-smoke.yml +++ b/.github/workflows/packagemanager-smoke.yml @@ -46,7 +46,7 @@ jobs: - 'src/vip/idp.py' - 'src/vip/config.py' - 'src/vip/plugin.py' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'src/vip/clients/base.py' - 'src/vip/timeouts.py' - 'src/vip_tests/conftest.py' diff --git a/.github/workflows/workbench-smoke.yml b/.github/workflows/workbench-smoke.yml index b4c7de65..c034189f 100644 --- a/.github/workflows/workbench-smoke.yml +++ b/.github/workflows/workbench-smoke.yml @@ -52,7 +52,7 @@ jobs: - 'src/vip/idp.py' - 'src/vip/config.py' - 'src/vip/plugin.py' - - 'src/vip/cli.py' + - 'src/vip/cli/**' - 'src/vip/clients/base.py' - 'src/vip/timeouts.py' - 'src/vip_tests/conftest.py' diff --git a/AGENTS.md b/AGENTS.md index 4a13beaf..b82e5e11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,10 +186,10 @@ Key principles: | File | Purpose | |------------------------------------|------------------------------------| -| `src/vip/cli.py` | CLI entry point: version, verify (including `--basic` to skip `@slow`-tagged scenarios), cleanup (Connect content + orphaned Workbench sessions via `--workbench-url`), install, uninstall, auth, scaffold commands; `--version` flag | +| `src/vip/cli/` | CLI entry point, one module per command: `version.py`, `verify.py` (including `--basic` to skip `@slow`-tagged scenarios), `cleanup.py` (Connect content + orphaned Workbench sessions via `--workbench-url`), `install.py` (install, uninstall), `auth.py`, `report.py`, `status.py`, `scaffold.py`; `app.py` builds the parser and holds `main()` and the `--version` flag; `_common.py` holds helpers shared by several commands; `__init__.py` re-exports the names callers import from `vip.cli`, but a selftest must patch a helper on the submodule that calls it | | `src/vip/config.py` | TOML config loader and dataclasses (includes `[proxy]` → `ProxyConfig`) | | `src/vip/proxy.py` | Single source of truth for outbound-proxy resolution. `ProxyConfig` + `build_proxy_map` (mirrors httpx's `get_environment_proxies`, incl. NO_PROXY formatting), `build_mounts` (per-scheme `HTTPTransport` mounts that keep `verify`), `proxy_for_url` (httpx-identical most-specific-pattern selection, used by non-httpx probes), `playwright_proxy` (renders a Playwright `launch(proxy=)` dict). Every HTTP egress path routes through this so VIP never diverges from httpx's own env-proxy behavior — see "Outbound proxy support" below | -| `src/vip/auth/` | Interactive and headless browser authentication for OIDC providers, split into `browser.py` (Chromium launch, `InteractiveAuthSession`), `cache.py`, `flows.py` (`start_interactive_auth`, `start_headless_auth`), `sso.py`, `workbench.py`, `scheme.py` (`resolve_url_scheme`) and `apikey.py` (Connect API-key minting); `__init__.py` re-exports the names callers import from `vip.auth`, but a selftest must patch a helper on the submodule that calls it; `authenticated_page` opens a headless page from a cached auth session for `vip cleanup --workbench-url`; `auth_cache_path()` is the single source of truth for the `.vip-auth-cache.json` location (both `plugin.py` and `cli.py` must use it), and `_load_cached_auth` probes Workbench before trusting a cached session; `refresh_auth_cache_from_storage_state` writes a live context's cookies back over a cache whose session has been invalidated (atomic, 0600, existing caches only) | +| `src/vip/auth/` | Interactive and headless browser authentication for OIDC providers, split into `browser.py` (Chromium launch, `InteractiveAuthSession`), `cache.py`, `flows.py` (`start_interactive_auth`, `start_headless_auth`), `sso.py`, `workbench.py`, `scheme.py` (`resolve_url_scheme`) and `apikey.py` (Connect API-key minting); `__init__.py` re-exports the names callers import from `vip.auth`, but a selftest must patch a helper on the submodule that calls it; `authenticated_page` opens a headless page from a cached auth session for `vip cleanup --workbench-url`; `auth_cache_path()` is the single source of truth for the `.vip-auth-cache.json` location (both `plugin.py` and `cli/cleanup.py` must use it), and `_load_cached_auth` probes Workbench before trusting a cached session; `refresh_auth_cache_from_storage_state` writes a live context's cookies back over a cache whose session has been invalidated (atomic, 0600, existing caches only) | | `src/vip/idp.py` | IdP login form strategies for headless auth (Keycloak, Okta) | | `src/vip/attest.py` | The two skip helpers (`not_applicable`, `unproven`) that record whether a skipped check was out of scope or simply never verified | | `src/vip/plugin.py` | pytest plugin: markers (including `slow`, used by `verify --basic`), auto-skip, JSON report output; `pytest_configure` also registers `vip.fixtures` as its own named plugin (`"vip-fixtures"`) so core fixtures resolve regardless of directory ancestry — see that module's docstring | @@ -292,7 +292,7 @@ VIP talks to deployments over three different HTTP mechanisms, and left alone th - **Bare httpx call sites** (auth mint/probe/delete, cache-liveness probe, `fetch_content`, scheme resolution): pass an explicit `proxy=proxy_for_url(url, build_proxy_map(proxy))` **and** `trust_env=False`, so the resolved per-URL proxy (which honors NO_PROXY) is authoritative rather than httpx re-reading the env. - **Playwright** (`_launch_chromium`, and the in-suite `browser_context_args`): pass `proxy=playwright_proxy(build_proxy_map(proxy), target_url)` so the browser shares the same proxy. Always pass the URL that browser is about to navigate. Playwright takes one `server` per browser and rewrites it to a single `scheme://host:port` (its `normalizeProxySettings`), so Chromium's per-scheme `--proxy-server=http=a;https=b` form is unavailable — `target_url`'s scheme is what keeps the browser on the same gateway as httpx when `HTTP_PROXY` and `HTTPS_PROXY` differ and the product is served over plain http. `target_url` chooses *which* proxy, never *whether*: a bypassed target still returns a dict, because the login browser also navigates the IdP, which usually is not bypassed. -`ProxyConfig` (from `[proxy]` in `vip.toml`, or `--proxy`/`--no-proxy`) threads from `VIPConfig.proxy` through the conftest client fixtures, the plugin auth entrypoints, and every `cli.py` command. Default (`trust_env=True`, no `url`) reads the ambient environment exactly as httpx would — so the no-config case is unchanged. +`ProxyConfig` (from `[proxy]` in `vip.toml`, or `--proxy`/`--no-proxy`) threads from `VIPConfig.proxy` through the conftest client fixtures, the plugin auth entrypoints, and every `vip.cli` command. Default (`trust_env=True`, no `url`) reads the ambient environment exactly as httpx would — so the no-config case is unchanged. **One deliberate divergence from httpx** (`_promote_http_proxy_to_https`): httpx keys its env map by *target* scheme, so a lone `HTTP_PROXY` (no `HTTPS_PROXY`/`ALL_PROXY`) yields `{'http://': …}` and httpx sends **https direct**. Many orgs run a single forward proxy as their *only* outbound tunnel and set just `http_proxy`, expecting https to tunnel through it via `CONNECT`; on a proxy-only network httpx's default means VIP's https traffic can never leave the host. So when the env gives an `http://` proxy with no explicit https/all coverage, `build_proxy_map` promotes it to cover `https://` too — applied to the whole map, so `proxy_for_url`, `build_mounts`, and `playwright_proxy` all agree (browser and API take the same route by construction). An explicit `HTTPS_PROXY`/`ALL_PROXY` is never overridden, and `NO_PROXY` still bypasses. This is why `build_proxy_map` is *not* byte-for-byte identical to `get_environment_proxies` in the http-only case. @@ -364,7 +364,7 @@ Every render also produces `_output/vip-report.pdf` from `report/vip-report.qmd` `workbench-smoke.yml` additionally splits into two tiers via a `suite` value: PR/push runs `gate` (the fast subset), the nightly schedule runs `full` (every Workbench file), and `workflow_dispatch` can pick either. The split is based on a measured run rather than taste — `full` buys 8 more real passes for ~523s more test time, which is worth a nightly but not a merge gate. Skip reasons do **not** appear in the log even with `-rs`, because VIP's plugin owns the terminal reporter; read them from `` in the uploaded `smoke-results.xml`. The three cross-cutting suites (`cross_product/test_resources`, `security/test_auth_policy`, `config_hygiene/test_secrets`) run in all three product workflows. `test_secrets` asserts no plaintext `api_key`/`password` in the generated `vip.toml`, so credentials must be passed to pytest as step `env` (`VIP_CONNECT_API_KEY`, `VIP_TEST_PASSWORD`) and never written into the config file. - **`connect-integration.yml`** -- push to `main`, `schedule` (daily), and `workflow_dispatch`; deliberately no `pull_request` trigger and not a required check (decided in #421). Runs the full VIP Connect category (`vip verify --api-auth --categories connect`) against ephemeral `release` and `preview` (nightly build) Connect servers via `with-connect`, so an unreleased upstream build never blocks a PR. It's a drift detector, not a gate: failures post to Slack via the `SLACK_WEBHOOK_CONNECT_CI` secret instead of failing anyone's merge. -- **`install-flow-smoke.yml`** -- push to `main` or PR (paths-gated to `src/vip/install/**`, `src/vip/cli.py`, `pyproject.toml`, `uv.lock`) or `workflow_dispatch`; reproduces the documented default install flow from the README (`uv tool install posit-vip` then `vip install`) on Ubuntu-as-root and macOS. Distinct from the product smoke workflows above: `uv tool install` only puts vip's own entry point on `PATH`, a topology every other install test misses because it runs `uv run vip install` from inside the project venv. +- **`install-flow-smoke.yml`** -- push to `main` or PR (paths-gated to `src/vip/install/**`, `src/vip/cli/**`, `pyproject.toml`, `uv.lock`) or `workflow_dispatch`; reproduces the documented default install flow from the README (`uv tool install posit-vip` then `vip install`) on Ubuntu-as-root and macOS. Distinct from the product smoke workflows above: `uv tool install` only puts vip's own entry point on `PATH`, a topology every other install test misses because it runs `uv run vip install` from inside the project venv. - **`linux-smoke.yml`** -- push to `main` or PR (paths-gated to `docker/rhel*/`, `docker/opensuse-leap/`, `docker/ubuntu2404/`, `docker/playwright-smoke.py`, `scripts/rhel-smoke.sh`, `scripts/opensuse-leap-smoke.sh`, `scripts/ubuntu2404-smoke.sh`, `justfile`, `pyproject.toml`, `uv.lock`, `src/**`, `selftests/**`); builds a Docker image per distro (`rhel9`, `rhel10`, `opensuse-leap`, `ubuntu2404`) that exercises `vip install`'s `apt`/`zypper` system-package path, then runs the smoke script inside it. `ubuntu2404`'s Dockerfile also proves the #621 t64-detection fix at build time: it installs the four renamed packages under their old names, then a second `vip install --dry-run` must report nothing left to install and the written manifest must name the t64 packages apt actually installed rather than the requested aliases, failing the build (not just the container run) if either regresses. - **`mac-smoke.yml`** -- push to `main` or PR (paths-gated to `docker/playwright-smoke.py`, `pyproject.toml`, `uv.lock`, `src/**`); runs `vip install` and a Playwright smoke script natively on `macos-latest`, the one CI job that exercises the install flow on real macOS rather than in a container. - **`add-to-team-project.yml`** -- when a `team: connect`, `team: workbench`, or `team: package manager` label is added to an issue, adds it to that product team's org-level GitHub project board. Ported from rstudio/helm. Requires the cross-org `POSIT_PLATFORM_CLIENT_ID`/`POSIT_PLATFORM_PEM` app secrets. diff --git a/pyproject.toml b/pyproject.toml index 5212f808..8cc5d75b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,7 +160,7 @@ packages = ["src/vip", "src/vip_tests"] "examples/custom_tests" = "vip/_scaffold/custom_tests" # AGENTS.md source shared by every scaffold template; copied into the output # dir by run_scaffold() after copytree(). Keep this path in sync with -# _resolve_scaffold_source("_shared") in src/vip/cli.py. +# _resolve_scaffold_source("_shared") in src/vip/cli/scaffold.py. "examples/_shared" = "vip/_scaffold/_shared" # Quarto report templates so `vip report` works when installed as a wheel, # not only from a source checkout. Copied into the working ./report dir by diff --git a/selftests/install/test_cli_uninstall.py b/selftests/install/test_cli_uninstall.py index 4221a61f..45765ea5 100644 --- a/selftests/install/test_cli_uninstall.py +++ b/selftests/install/test_cli_uninstall.py @@ -327,7 +327,7 @@ def _parse_uninstall(monkeypatch, *argv: str): from vip import cli seen: list[argparse.Namespace] = [] - monkeypatch.setattr(cli, "run_uninstall", seen.append) + monkeypatch.setattr("vip.cli.app.run_uninstall", seen.append) monkeypatch.setattr(sys, "argv", ["vip", "uninstall", *argv]) cli.main() assert seen, "run_uninstall was never reached" diff --git a/selftests/test_auth_cache.py b/selftests/test_auth_cache.py index 36200ba8..ddc030b9 100644 --- a/selftests/test_auth_cache.py +++ b/selftests/test_auth_cache.py @@ -568,10 +568,10 @@ def test_call_sites_do_not_build_the_path_inline(self): from pathlib import Path as _Path import vip.auth.cache - import vip.cli + import vip.cli.cleanup import vip.plugin - for module in (vip.cli, vip.plugin): + for module in (vip.cli.cleanup, vip.plugin): source = _Path(module.__file__).read_text() assert ".vip-auth-cache.json" not in source, ( f"{module.__name__} builds the auth cache path inline; " diff --git a/selftests/test_auth_scheme.py b/selftests/test_auth_scheme.py index 6c1716fa..1f3cd373 100644 --- a/selftests/test_auth_scheme.py +++ b/selftests/test_auth_scheme.py @@ -125,7 +125,7 @@ def test_defaults_return_true(self): def test_insecure_wins_over_ca_bundle(self, tmp_path): """When both insecure=True and a ca_bundle path are provided, - insecure wins — mirrors cli.py:391 logic. + insecure wins — mirrors vip.cli._common._resolve_effective_ca_bundle. """ from vip.auth import _httpx_verify diff --git a/selftests/test_cli_cleanup.py b/selftests/test_cli_cleanup.py index 477b1da5..6864dc7c 100644 --- a/selftests/test_cli_cleanup.py +++ b/selftests/test_cli_cleanup.py @@ -20,6 +20,7 @@ import vip.auth import vip.cli +import vip.cli.cleanup import vip.workbench_ui from vip.auth import InteractiveAuthSession from vip.clients.workbench import is_vip_session @@ -126,7 +127,7 @@ def cleanup_vip_content(self): def _fail(*a, **k): pytest.fail("workbench cleanup should not run without a workbench URL") - monkeypatch.setattr(vip.cli, "_cleanup_workbench_sessions", _fail) + monkeypatch.setattr(vip.cli.cleanup, "_cleanup_workbench_sessions", _fail) vip.cli.run_cleanup(_make_args(connect_url="https://c.example.com")) @@ -147,7 +148,7 @@ def _fail(*a, **k): called = {} monkeypatch.setattr( - vip.cli, + vip.cli.cleanup, "_cleanup_workbench_sessions", lambda url, args, config: called.setdefault("url", url), ) @@ -177,7 +178,7 @@ def cleanup_vip_content(self): called = {} monkeypatch.setattr( - vip.cli, + vip.cli.cleanup, "_cleanup_workbench_sessions", lambda url, args, config: called.setdefault("url", url), ) @@ -226,7 +227,7 @@ def test_workbench_url_falls_back_to_vip_toml(self, tmp_path, monkeypatch): called = {} monkeypatch.setattr( - vip.cli, + vip.cli.cleanup, "_cleanup_workbench_sessions", lambda url, args, config: called.setdefault("url", url), ) @@ -459,7 +460,7 @@ def _parse_cleanup(self, *argv: str) -> argparse.Namespace: """ seen: list[argparse.Namespace] = [] with ( - patch("vip.cli.run_cleanup", side_effect=seen.append), + patch("vip.cli.app.run_cleanup", side_effect=seen.append), patch.object(sys, "argv", ["vip", "cleanup", *argv]), ): from vip.cli import main diff --git a/selftests/test_cli_report.py b/selftests/test_cli_report.py index 8b7bc27b..697883dc 100644 --- a/selftests/test_cli_report.py +++ b/selftests/test_cli_report.py @@ -212,7 +212,7 @@ def test_renders_report_when_results_present(self, tmp_path, monkeypatch, capsys report_dir = tmp_path / "report" report_dir.mkdir() (report_dir / "results.json").write_text('{"results": []}') - monkeypatch.setattr(cli.subprocess, "run", _fake_quarto(create_output=True)) + monkeypatch.setattr("vip.cli.report.subprocess.run", _fake_quarto(create_output=True)) cli.run_report(_make_args()) @@ -237,7 +237,7 @@ def test_does_not_nest_when_already_inside_report_dir(self, tmp_path, monkeypatc report_dir.mkdir() (report_dir / "results.json").write_text('{"results": []}') monkeypatch.chdir(report_dir) - monkeypatch.setattr(cli.subprocess, "run", _fake_quarto(create_output=True)) + monkeypatch.setattr("vip.cli.report.subprocess.run", _fake_quarto(create_output=True)) cli.run_report(_make_args(results="results.json")) @@ -270,7 +270,7 @@ def _capture(cmd, cwd=None, env=None, **kwargs): # A hostile VIRTUAL_ENV must not win over the explicit pin. monkeypatch.setenv("VIRTUAL_ENV", "/some/other/venv") - monkeypatch.setattr(cli.subprocess, "run", _capture) + monkeypatch.setattr("vip.cli.report.subprocess.run", _capture) cli.run_report(_make_args()) @@ -286,7 +286,7 @@ def test_errors_when_render_produces_no_output(self, tmp_path, monkeypatch): report_dir.mkdir() (report_dir / "results.json").write_text('{"results": []}') # quarto "succeeds" but writes nothing — the old bug rendered silently. - monkeypatch.setattr(cli.subprocess, "run", _fake_quarto(create_output=False)) + monkeypatch.setattr("vip.cli.report.subprocess.run", _fake_quarto(create_output=False)) with pytest.raises(ReportError) as exc: cli.run_report(_make_args()) @@ -299,7 +299,7 @@ def test_errors_when_results_missing(self, tmp_path, monkeypatch): from vip.errors import ReportError monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cli.subprocess, "run", _fake_quarto(create_output=True)) + monkeypatch.setattr("vip.cli.report.subprocess.run", _fake_quarto(create_output=True)) with pytest.raises(ReportError) as exc: cli.run_report(_make_args(results=str(tmp_path / "nope.json"))) @@ -319,7 +319,7 @@ def test_errors_when_quarto_not_installed(self, tmp_path, monkeypatch): def _missing_quarto(cmd, cwd=None, **kwargs): raise FileNotFoundError(2, "No such file or directory", cmd[0]) - monkeypatch.setattr(cli.subprocess, "run", _missing_quarto) + monkeypatch.setattr("vip.cli.report.subprocess.run", _missing_quarto) with pytest.raises(ReportError) as exc: cli.run_report(_make_args()) @@ -342,7 +342,7 @@ def test_pdf_failure_degrades_to_warning(self, tmp_path, monkeypatch, capsys): report_dir.mkdir() (report_dir / "results.json").write_text('{"results": []}') monkeypatch.setattr( - cli.subprocess, "run", _fake_quarto(create_output=True, pdf_returncode=1) + "vip.cli.report.subprocess.run", _fake_quarto(create_output=True, pdf_returncode=1) ) cli.run_report(_make_args()) @@ -367,7 +367,7 @@ def _failing_html(cmd, cwd=None, **kwargs): rendered.append(cmd[-1]) return types.SimpleNamespace(returncode=3) - monkeypatch.setattr(cli.subprocess, "run", _failing_html) + monkeypatch.setattr("vip.cli.report.subprocess.run", _failing_html) with pytest.raises(SystemExit) as exc: cli.run_report(_make_args()) diff --git a/selftests/test_cli_url_scheme.py b/selftests/test_cli_url_scheme.py index a659057e..b1f7ef59 100644 --- a/selftests/test_cli_url_scheme.py +++ b/selftests/test_cli_url_scheme.py @@ -8,7 +8,7 @@ the caller gave an explicit scheme for. ``resolve_url_scheme`` itself (the shared function all three call directly -- -cli.py has no wrapper of its own, see #562) is tested exhaustively in +vip.cli has no wrapper of its own, see #562) is tested exhaustively in ``TestResolveUrlScheme`` in ``test_auth_scheme.py``; the tests below exercise the real ``_collect_status``/``run_cleanup``/``run_uninstall`` entry points end to end, mocking only ``httpx.get`` (the network boundary) and the product diff --git a/selftests/test_cli_verify.py b/selftests/test_cli_verify.py index 328cbd0c..9fb1bfcf 100644 --- a/selftests/test_cli_verify.py +++ b/selftests/test_cli_verify.py @@ -60,8 +60,8 @@ def fake_run(cmd, **kwargs): return result with ( - patch("vip.cli.subprocess.run", side_effect=fake_run), - patch("vip.cli.sys.exit"), + patch("vip.cli.verify.subprocess.run", side_effect=fake_run), + patch("vip.cli.verify.sys.exit"), ): from vip.cli import run_verify @@ -719,7 +719,7 @@ def fake_run_verify(args): captured["test_timeout"] = args.test_timeout raise SystemExit(0) - monkeypatch.setattr("vip.cli.run_verify", fake_run_verify) + monkeypatch.setattr("vip.cli.app.run_verify", fake_run_verify) monkeypatch.setattr(sys, "argv", ["vip", "verify", "--config", str(cfg)]) from vip.cli import main @@ -758,7 +758,7 @@ def fake_run(cmd, **kwargs): from vip.errors import VipError with ( - patch("vip.cli.subprocess.run", side_effect=fake_run), + patch("vip.cli.verify.subprocess.run", side_effect=fake_run), pytest.raises(VipError) as exc_info, ): run_verify(_make_args(config=str(cfg))) @@ -1434,7 +1434,7 @@ def test_ci_flag_bundles_formats_and_tb_short(self, tmp_path): def test_unknown_format_rejected(self, tmp_path, monkeypatch): """Must call run_verify directly (a real raise): _capture_cmd/_capture_call - patch ``vip.cli.sys.exit`` to a no-op so run_verify falls through to + patch ``vip.cli.verify.sys.exit`` to a no-op so run_verify falls through to subprocess.run for the "happy path" tests above, but that trick only ever suppressed the bare ``sys.exit`` calls that remain (e.g. the subprocess-return-code passthrough) — a ``raise ConfigError`` cannot be @@ -1603,7 +1603,7 @@ def _parse_verify(self, *argv: str) -> argparse.Namespace: """ seen: list[argparse.Namespace] = [] with ( - patch("vip.cli.run_verify", side_effect=seen.append), + patch("vip.cli.app.run_verify", side_effect=seen.append), patch.object(sys, "argv", ["vip", "verify", *argv]), ): from vip.cli import main diff --git a/selftests/test_errors.py b/selftests/test_errors.py index dbba724d..72b32b8f 100644 --- a/selftests/test_errors.py +++ b/selftests/test_errors.py @@ -8,6 +8,7 @@ import vip.auth import vip.cli +import vip.cli.app from vip.errors import ( AuthConfigError, AuthError, @@ -68,7 +69,7 @@ def test_vip_error_exits_with_its_exit_code(self, monkeypatch, capsys): def _boom(_args): raise ConfigError("bad config", exit_code=3) - monkeypatch.setattr(vip.cli, "run_version", _boom) + monkeypatch.setattr(vip.cli.app, "run_version", _boom) monkeypatch.setattr(sys, "argv", ["vip", "version"]) with pytest.raises(SystemExit) as exc_info: @@ -81,7 +82,7 @@ def test_vip_error_default_exit_code_is_one(self, monkeypatch, capsys): def _boom(_args): raise ReportError("quarto not found") - monkeypatch.setattr(vip.cli, "run_version", _boom) + monkeypatch.setattr(vip.cli.app, "run_version", _boom) monkeypatch.setattr(sys, "argv", ["vip", "version"]) with pytest.raises(SystemExit) as exc_info: @@ -94,7 +95,7 @@ def test_non_vip_error_is_not_caught_by_the_handler(self, monkeypatch): def _boom(_args): raise RuntimeError("unexpected") - monkeypatch.setattr(vip.cli, "run_version", _boom) + monkeypatch.setattr(vip.cli.app, "run_version", _boom) monkeypatch.setattr(sys, "argv", ["vip", "version"]) with pytest.raises(RuntimeError, match="unexpected"): diff --git a/src/vip/auth/cache.py b/src/vip/auth/cache.py index b79eaa34..8fd903cc 100644 --- a/src/vip/auth/cache.py +++ b/src/vip/auth/cache.py @@ -32,7 +32,7 @@ def auth_cache_path() -> Path: """Path to the auth-session cache for the current invocation directory. Single source of truth for both call sites: ``plugin.py`` (``vip verify``) - and ``cli.py`` (``vip cleanup --workbench-url``). They must agree, or + and ``cli/cleanup.py`` (``vip cleanup --workbench-url``). They must agree, or ``cleanup`` cannot find the session ``verify`` just cached. Keyed on the *invocation* directory rather than pytest's ``config.rootpath``. diff --git a/src/vip/auth/flows.py b/src/vip/auth/flows.py index 685388f0..3fd50626 100644 --- a/src/vip/auth/flows.py +++ b/src/vip/auth/flows.py @@ -72,7 +72,7 @@ def _resolve_str_if_inferred( # known wart, not a bug: the clean fix is changing start_interactive_auth/ # start_headless_auth to take ProductConfig objects directly instead of # a string + a separate bool, which would touch ~15 call sites across - # the auth selftests plus plugin.py/cli.py -- out of scope for this bug fix. + # the auth selftests plus plugin.py/cli/ -- out of scope for this bug fix. pc = ProductConfig(url=url) pc.url_scheme_inferred = inferred return resolve_url_scheme(pc, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy) diff --git a/src/vip/auth/scheme.py b/src/vip/auth/scheme.py index e28f6575..d43d19dc 100644 --- a/src/vip/auth/scheme.py +++ b/src/vip/auth/scheme.py @@ -28,8 +28,9 @@ def _httpx_verify(insecure: bool, ca_bundle: Path | None) -> bool | str: - ``ca_bundle`` set → ``str`` path to the bundle file - default → ``True`` (system trust store) - Mirrors ``cli.py:391`` and ``VIPConfig.verify`` so TLS behaviour is - consistent across every httpx call site in the auth package. + Mirrors ``vip.cli._common._resolve_effective_ca_bundle`` and + ``VIPConfig.verify`` so TLS behaviour is consistent across every httpx + call site in the auth package. """ if insecure: return False diff --git a/src/vip/cli.py b/src/vip/cli.py deleted file mode 100644 index 652da2d2..00000000 --- a/src/vip/cli.py +++ /dev/null @@ -1,2137 +0,0 @@ -"""VIP command-line tools for credential management and verification.""" - -from __future__ import annotations - -import argparse -import contextlib -import json -import logging -import os -import re -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import TYPE_CHECKING - -from vip.errors import ( - AuthError, - ConfigError, - InstallError, - ProductUnreachableError, - ReportError, - VipError, -) -from vip.reporting import VALID_FORMATS -from vip.timeouts import scaled - -if TYPE_CHECKING: - from vip.config import ProductConfig, VIPConfig - -# Default for ``vip verify --test-timeout``. Generous enough for a full -# Connect suite with several content deployments (each can take 3-5 minutes -# for R package restore or Python venv creation). -DEFAULT_TEST_TIMEOUT_SECONDS = int(scaled(3600)) - -# Valid test categories. Maps every accepted spelling (hyphenated and -# underscored) to the internal pytest marker name. -VALID_CATEGORIES: dict[str, str] = { - "prerequisites": "prerequisites", - "connect": "connect", - "workbench": "workbench", - "package-manager": "package_manager", - "package_manager": "package_manager", - "cross-product": "cross_product", - "cross_product": "cross_product", - "performance": "performance", - "security": "security", - "config-hygiene": "config_hygiene", - "config_hygiene": "config_hygiene", -} - -# Categories that are excluded from the default ``vip verify`` run and only -# executed when the user explicitly opts in, either via ``--categories`` or -# a dedicated opt-in flag (for example ``--performance-tests``). These tests -# check VIP's own configuration rather than the Posit deployment. -_OPT_IN_CATEGORIES = frozenset({"config_hygiene", "performance"}) - -# Marker expression keywords that are not category names. -_MARKER_KEYWORDS = {"and", "or", "not"} - -# Regex matching a complete identifier token (may contain hyphens or -# underscores). Negative lookbehind/lookahead ensure we don't match a -# substring inside a larger token like ``_connect`` or ``1connect``. -_IDENT_RE = re.compile(r"(? str: - """Return a comma-separated string of preferred (hyphenated) category names.""" - seen: dict[str, str] = {} - for k, v in VALID_CATEGORIES.items(): - if v not in seen or "-" in k: - seen[v] = k - return ", ".join(sorted(seen.values())) - - -def _default_marker_expr(extra_keep: frozenset[str] = frozenset()) -> str: - """Marker expression applied when the user doesn't pass ``--categories``. - - Excludes every opt-in category so that ``vip verify`` runs only the - product-verification tests by default. Pass ``extra_keep`` to re-include - specific opt-in categories (e.g. ``frozenset({"performance"})`` when - ``--performance-tests`` is set). - """ - excluded = _OPT_IN_CATEGORIES - extra_keep - return " and ".join(f"not {name}" for name in sorted(excluded)) - - -def _extra_keep_from_args(args: argparse.Namespace) -> frozenset[str]: - """Return the set of opt-in categories to re-include based on CLI flags. - - For example, ``--performance-tests`` adds ``"performance"`` to the set so - that :func:`_default_marker_expr` keeps it in the expression. - """ - extra: set[str] = set() - if getattr(args, "performance_tests", False): - extra.add("performance") - return frozenset(extra) - - -def _normalize_categories(expr: str) -> str: - """Validate and normalize a ``--categories`` expression. - - Accepts user-facing hyphenated names (e.g. ``package-manager``) as well - as underscore names (``package_manager``) and translates both to the - internal pytest marker names. Raises :class:`SystemExit` if any - identifier token is not a recognised category or keyword. - """ - - def _replace(match: re.Match[str]) -> str: - word = match.group(0) - if word in _MARKER_KEYWORDS: - return word - if word in VALID_CATEGORIES: - return VALID_CATEGORIES[word] - raise ConfigError( - f"unknown category '{word}'. Valid categories: {_valid_categories_message()}" - ) - - result = _IDENT_RE.sub(_replace, expr) - # After substitution, only whitespace and parentheses should remain - # between identifiers. Any leftover characters (digits, underscores - # from malformed tokens like ``_connect`` or ``1connect``) are invalid. - leftover = _IDENT_RE.sub("", result).replace("(", "").replace(")", "").strip() - if leftover: - raise ConfigError( - f"invalid characters in category expression: '{expr}'. " - f"Valid categories: {_valid_categories_message()}" - ) - return result - - -def mint_connect_key(args: argparse.Namespace) -> None: - """Launch interactive browser auth and mint a Connect API key.""" - from vip.auth import start_interactive_auth - - session = start_interactive_auth(args.url) - - if not session.api_key: - # Kept as a direct print+exit rather than raise AuthError: this command's - # success output is JSON on stdout, so its failure output stays JSON on - # stderr too instead of the central handler's plain-text "Error: ..." -- - # a script parsing this command's failures expects {"error": "..."}. - print(json.dumps({"error": "Failed to mint API key"}), file=sys.stderr) - sys.exit(1) - - result = { - "api_key": session.api_key, - "key_name": session.key_name, - } - - print(json.dumps(result)) - - -# --------------------------------------------------------------------------- -# Config generation from CLI URL args -# --------------------------------------------------------------------------- - - -def _print_skip_notes(config_path: str | None) -> None: - """Print a note for each product that is not configured.""" - from vip.config import load_config - - try: - cfg = load_config(config_path) - except ValueError as exc: - raise ConfigError(str(exc)) from exc - products = [ - ("Connect", cfg.connect), - ("Workbench", cfg.workbench), - ("Package Manager", cfg.package_manager), - ] - for name, pc in products: - if not pc.is_configured: - reason = "disabled" if not pc.enabled else "no URL given" - print(f"Note: {name} {reason} — {name} tests will not be collected.", flush=True) - - -def _check_credentials( - config_path: str | None, - *, - interactive_auth: bool, - categories: str | None, -) -> None: - """Exit early when products are configured but credentials are missing. - - When *categories* is provided, only check products whose marker appears - in the expression. Without categories all configured products are checked. - """ - from vip.config import load_config - - try: - cfg = load_config(config_path) - except ValueError as exc: - raise ConfigError(str(exc)) from exc - if interactive_auth: - return - - has_creds = bool(cfg.auth.username and cfg.auth.password) - needs_creds: list[str] = [] - - # When a category filter is active, only enforce credential checks for - # products that are actually selected. We tokenize the expression and - # check that the marker appears as a positive term (not negated by "not"). - def _category_selected(marker: str) -> bool: - if categories is None: - return True - tokens = re.findall(r"\w+", categories) - for i, tok in enumerate(tokens): - if tok == marker and (i == 0 or tokens[i - 1] != "not"): - return True - return False - - # Connect tests include UI login and user-management scenarios that use - # VIP_TEST_USERNAME/VIP_TEST_PASSWORD even when VIP_CONNECT_API_KEY is set, - # so require credentials whenever Connect is selected (users can pass - # --no-auth to deselect Connect tests entirely). - if cfg.connect.is_configured and not has_creds and _category_selected("connect"): - needs_creds.append("Connect") - if cfg.workbench.is_configured and not has_creds and _category_selected("workbench"): - needs_creds.append("Workbench") - - if needs_creds: - products = " and ".join(needs_creds) - raise ConfigError( - f"{products} tests selected but no credentials provided.\n" - "Set VIP_TEST_USERNAME and VIP_TEST_PASSWORD (optionally with --headless-auth),\n" - "or use --interactive-auth, or --no-auth to skip tests that require " - "authentication." - ) - - -def _config_idp(config_path: str | None) -> str: - """Return the normalized ``[auth] idp`` from the resolved config. - - This is the IdP the run will actually use: the ``--idp`` flag is folded - into the generated config on URL-driven runs and is not otherwise forwarded - to pytest, so the config file is the source of truth. Returns "" when there - is no config or it can't be read (pytest surfaces config errors later). - Normalized (stripped, lowercased) to match ``idp.get_idp_strategy``. - """ - if not config_path: - return "" - from vip.config import load_config - - try: - return (load_config(config_path).auth.idp or "").strip().lower() - except ValueError: - return "" - - -# Pytest options that consume the next argument as a directory path. -# We skip these values so they aren't mistaken for positional test targets. -_CONSUMES_DIR_VALUE = frozenset({"--rootdir", "--confcutdir", "--basetemp"}) - - -def _has_explicit_test_targets(pytest_args: list[str]) -> bool: - """Return True if *pytest_args* contains what looks like test paths or nodeids. - - This avoids injecting the default ``vip_tests`` path when the user already - passed explicit targets after ``--`` (e.g. ``vip verify -- tests/foo.py``). - Directory values consumed by known pytest options (``--rootdir``, etc.) are - excluded so they don't trigger false-positive detection. - """ - skip_next = False - for arg in pytest_args: - if skip_next: - skip_next = False - continue - if arg in _CONSUMES_DIR_VALUE: - skip_next = True - continue - if arg.startswith("-"): - continue - if "::" in arg or arg.endswith(".py") or Path(arg).is_dir(): - return True - return False - - -def _user_set_xdist(pytest_args: list[str]) -> tuple[bool, bool]: - """Return (user_set_numprocesses, user_set_dist) from user-supplied pytest args. - - Lets `vip verify` supply default ``-n``/``--dist`` without overriding an - explicit user choice (including ``-p no:xdist``, which disables xdist - entirely and so counts as the user managing both). - """ - set_n = False - set_dist = False - for a in pytest_args: - if a in ("-n", "--numprocesses") or a.startswith(("-n", "--numprocesses=")): - set_n = True - if a.startswith(("--dist", "no:xdist")) or a == "no:xdist": - set_dist = True - if "no:xdist" in pytest_args or any(x.startswith("no:xdist") for x in pytest_args): - set_n = set_dist = True - return set_n, set_dist - - -def _resolve_effective_ca_bundle(insecure: bool, ca_bundle: Path | None) -> Path | None: - """Apply --insecure/--ca-bundle precedence: insecure wins, ca_bundle is dropped. - - Warns when both are set (mirrors curl's own precedence for -k combined with - --cacert). Shared by every command that accepts both flags -- ``verify`` (via - ``_generate_temp_config``), and ``cleanup``/``uninstall`` (via - ``_load_cleanup_config``/``run_uninstall``) -- so the collision is handled - identically everywhere instead of three independent copies drifting apart. - - The warning fires on the collision regardless of where each value came - from. ``cleanup``/``uninstall`` call this *after* merging a CLI flag with - the corresponding ``vip.toml`` [tls] value (CLI wins per-field), so the - pair handed in here may be flag+flag, toml+toml, or one of each -- the - message therefore doesn't claim a CLI-only cause. This is a deliberate - divergence from ``verify``: its own ``--config``/default-``./vip.toml`` - path loads ``[tls]`` straight through ``vip.config.load_config()`` and - never calls this helper at all, so an identical ``vip.toml`` with both - keys set warns for ``cleanup``/``uninstall`` but not for ``verify`` against - that same file. Covered by ``test_toml_only_conflict_warns_and_insecure_wins`` - in ``selftests/test_cli_cleanup.py`` and its uninstall counterpart. - """ - if insecure and ca_bundle: - import warnings - - # stacklevel=2 attributes the warning to this helper's direct caller - # (_generate_temp_config / _load_cleanup_config / run_uninstall). - # Before this logic was extracted, the inline warnings.warn() in - # _generate_temp_config used stacklevel=2 to reach *its* caller - # instead -- one frame further up. No single stacklevel is correct - # for all three call sites (they sit at different depths from the - # command dispatch that ultimately triggered this), so this is a - # deliberate, accepted drift rather than an oversight. - warnings.warn( - "insecure and a ca_bundle are both configured (whether via " - "--insecure/--ca-bundle or [tls] insecure/ca_bundle in vip.toml); " - "insecure takes precedence and the ca_bundle will be ignored for " - "TLS verification.", - stacklevel=2, - ) - return None if insecure else ca_bundle - - -def _generate_temp_config(args: argparse.Namespace) -> str: - """Write a minimal vip.toml from CLI URL arguments. Returns temp file path.""" - lines = ["[general]", 'deployment_name = "Posit Team"', ""] - - if args.connect_url: - lines.extend(["[connect]", f"url = {json.dumps(args.connect_url)}"]) - connect_version = getattr(args, "connect_version", None) - if connect_version: - lines.append(f"version = {json.dumps(connect_version)}") - lines.append("") - else: - lines.extend(["[connect]", "enabled = false", ""]) - - if args.workbench_url: - lines.extend(["[workbench]", f"url = {json.dumps(args.workbench_url)}"]) - workbench_version = getattr(args, "workbench_version", None) - if workbench_version: - lines.append(f"version = {json.dumps(workbench_version)}") - lines.append("") - else: - lines.extend(["[workbench]", "enabled = false", ""]) - - if args.package_manager_url: - lines.extend(["[package_manager]", f"url = {json.dumps(args.package_manager_url)}"]) - package_manager_version = getattr(args, "package_manager_version", None) - if package_manager_version: - lines.append(f"version = {json.dumps(package_manager_version)}") - lines.append("") - else: - lines.extend(["[package_manager]", "enabled = false", ""]) - - idp = getattr(args, "idp", None) - inherited_provider: str | None = None - - # Inherit from an existing vip.toml so ``vip verify --workbench-url ... - # --headless-auth`` can pick up the [auth] section the user already - # configured. Done best-effort: a malformed vip.toml should not break a - # URL-driven command that doesn't depend on it. - env = os.environ.get("VIP_CONFIG") - default_path = Path(env) if env else Path("vip.toml") - if default_path.is_file(): - from vip.config import load_config - - try: - existing = load_config(default_path) - except Exception: # noqa: BLE001 - existing = None - if existing is not None: - if not idp and existing.auth.idp: - idp = existing.auth.idp - if existing.auth.provider and existing.auth.provider != "password": - inherited_provider = existing.auth.provider - - # Resolve the provider: - # - An explicit --provider always wins, overriding both --idp's implied - # "oidc" and anything inherited from vip.toml. - # - Otherwise, with --idp set, the user wants IdP-based auth. Keep an - # inherited IdP-class value (saml/oauth2) so specific declarations - # survive; but ignore inherited non-IdP providers (ldap) that would - # contradict the CLI intent — vip.auth's flow selection keys off - # provider, not idp. - # - Without --provider or --idp, just honour whatever vip.toml declared. - explicit_provider = getattr(args, "provider", None) - if explicit_provider: - auth_provider: str | None = explicit_provider - elif idp: - auth_provider = inherited_provider if inherited_provider in _IDP_PROVIDERS else "oidc" - else: - auth_provider = inherited_provider - - if auth_provider or idp: - lines.append("[auth]") - if auth_provider: - lines.append(f'provider = "{auth_provider}"') - if idp: - lines.append(f'idp = "{idp}"') - lines.append("") - - insecure = getattr(args, "insecure", False) - ca_bundle = getattr(args, "ca_bundle", None) - effective_ca_bundle = _resolve_effective_ca_bundle(insecure, ca_bundle) - if insecure or effective_ca_bundle: - lines.append("[tls]") - if insecure: - lines.append("insecure = true") - if effective_ca_bundle: - lines.append(f"ca_bundle = {json.dumps(str(effective_ca_bundle))}") - lines.append("") - - # Proxy: --proxy sets an explicit proxy URL; --no-proxy lists bypass hosts. - # An empty --no-proxy with no --proxy means "proxying off" (enabled=false), - # which forces every request direct regardless of the ambient environment. - proxy_url = getattr(args, "proxy", None) - no_proxy = getattr(args, "no_proxy", None) - if proxy_url or no_proxy is not None: - # Parse the bypass list once, stripping tokens; a value that is empty or - # only whitespace/commas yields no hosts. - hosts = [h.strip() for h in no_proxy.split(",") if h.strip()] if no_proxy else [] - lines.append("[proxy]") - if proxy_url: - lines.append(f"url = {json.dumps(proxy_url)}") - elif not hosts: - # No proxy URL and no bypass hosts (--no-proxy '' or whitespace-only): - # disable proxying entirely, ignoring any ambient proxy env vars. - lines.append("enabled = false") - if hosts: - lines.append(f"no_proxy = {json.dumps(hosts)}") - lines.append("") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: - f.write("\n".join(lines) + "\n") - return f.name - - -# --------------------------------------------------------------------------- -# vip verify -# --------------------------------------------------------------------------- - - -def run_verify(args: argparse.Namespace) -> None: - """Run VIP tests locally against URL args or a vip.toml config.""" - provider = getattr(args, "provider", None) - if provider and provider not in _IDP_PROVIDERS: - raise ConfigError( - f"unknown --provider value: {provider}. Valid: {', '.join(_IDP_PROVIDERS)}.", - exit_code=2, - ) - - config_path = args.config - temp_config = None - - proxy_flag_set = getattr(args, "proxy", None) or getattr(args, "no_proxy", None) is not None - if not config_path and (args.connect_url or args.workbench_url or args.package_manager_url): - temp_config = _generate_temp_config(args) - config_path = temp_config - elif proxy_flag_set: - # --proxy/--no-proxy are only woven into the generated temp config (via - # _generate_temp_config); any run that loads a config file instead has no - # consumer for them, so the pytest subprocess would load the file's - # [proxy] (or none) and silently ignore the flags. This mirrors - # --insecure/--ca-bundle on the same branch. Rather than swallow the - # flag, tell the user how to make it take effect. (Ambient - # HTTP(S)_PROXY still works on a config run.) - # - # Keyed on "no temp config was generated", NOT on ``config_path``: the - # default-resolution path (a ./vip.toml with no --config and no URL - # flags -- the documented normal setup) still has config_path=None here - # and would slip through a ``config_path and ...`` test entirely. - print( - ">>> Warning: --proxy/--no-proxy are ignored when a config file is used. " - "Put the proxy under a [proxy] section in your config file " - "(url = ..., no_proxy = [...], or enabled = false), or set " - "HTTP_PROXY/HTTPS_PROXY/NO_PROXY in the environment.", - file=sys.stderr, - ) - - # Fail fast when a config file is expected but doesn't exist. - if config_path and not Path(config_path).is_file(): - raise ConfigError(f"config file not found: {config_path}") - if not config_path: - # No explicit config and no URL args — check the default resolution. - env = os.environ.get("VIP_CONFIG") - default = Path(env) if env else Path("vip.toml") - if not default.is_file(): - raise ConfigError( - f"config file not found: {default}\n" - "Provide a config file with --config, or pass product URLs directly " - "(e.g. --connect-url https://connect.example.com)." - ) - # Pin the resolved default so pytest loads the same file the CLI - # validated, regardless of pytest's rootdir or subprocess CWD. - config_path = str(default.resolve()) - - # Resolve explicit paths too so --vip-config always gets an absolute path. - config_path = str(Path(config_path).resolve()) - - if args.interactive_auth and args.headless_auth: - raise ConfigError("--interactive-auth and --headless-auth are mutually exclusive.") - - if args.no_auth and args.api_auth: - raise ConfigError("--no-auth and --api-auth are mutually exclusive.") - - if getattr(args, "ci", False) and (args.interactive_auth or args.headless_auth): - raise ConfigError( - "--ci requires non-interactive execution and cannot be combined " - "with --interactive-auth/--headless-auth." - ) - - if args.api_auth and _config_idp(config_path) == "snowflake": - raise ConfigError( - "--api-auth is not supported with the Snowflake identity provider.\n" - "A Posit Team Native App authenticates through the Snowpark Container " - "Services ingress and has no standalone product API key for --api-auth to " - "use.\n" - "Use --headless-auth to run the full suite, or --no-auth for the stateless " - "checks that do not require a login." - ) - - # Print notes for products that are not configured so the user knows - # upfront which categories will be skipped. - _print_skip_notes(config_path) - if not args.no_auth and not args.api_auth: - _check_credentials( - config_path, - interactive_auth=args.interactive_auth or args.headless_auth, - categories=args.categories, - ) - - cmd = [sys.executable, "-m", "pytest", "-v", "--no-header"] - - # Resolve the installed vip_tests package so pytest finds tests even - # when running outside the source tree (e.g. ``pip install posit-vip``). - # Skip when the user already passed explicit test targets after ``--``. - if not _has_explicit_test_targets(args.pytest_args): - from importlib.util import find_spec - - _spec = find_spec("vip_tests") - if _spec and _spec.submodule_search_locations: - cmd.append(_spec.submodule_search_locations[0]) - - if config_path: - cmd.append(f"--vip-config={config_path}") - if args.report: - cmd.append(f"--vip-report={args.report}") - - fmt = "json,junit,sarif" if getattr(args, "ci", False) else getattr(args, "format", "json") - requested = [f.strip().lower() for f in fmt.split(",") if f.strip()] - unknown = [f for f in requested if f not in VALID_FORMATS] - if unknown: - raise ConfigError( - f"unknown --format value(s): {', '.join(unknown)}. " - f"Valid: {', '.join(sorted(VALID_FORMATS))}.", - exit_code=2, - ) - cmd.append(f"--vip-format={','.join(requested)}") - if args.interactive_auth: - cmd.append("--interactive-auth") - if args.headless_auth: - cmd.append("--headless-auth") - if args.no_auth: - cmd.append("--no-auth") - if args.api_auth: - cmd.append("--api-auth") - if getattr(args, "allow_unproven", False): - cmd.append("--vip-allow-unproven") - cmd.extend(f"--vip-extensions={ext}" for ext in args.extensions or []) - if args.categories: - marker_expr = _normalize_categories(args.categories) - else: - marker_expr = _default_marker_expr(_extra_keep_from_args(args)) - if getattr(args, "basic", False): - marker_expr = f"({marker_expr}) and not slow" if marker_expr else "not slow" - cmd.extend(["-m", marker_expr]) - if args.filter_expr: - cmd.extend(["-k", args.filter_expr]) - - if args.verbose: - cmd.append("--vip-verbose") - cmd.append("-s") - - # Default to a conservative parallel-by-group run so pip-installed users - # get grouping too -- pyproject.toml's `addopts = "-n auto --dist - # loadgroup"` only applies when pytest's rootdir is this repo. 2 workers - # is a safe default: product tests log real sessions in against a shared - # deployment and a single shared test account, and higher default - # concurrency intermittently storms the OIDC IdP (`?error=2`) and exceeds - # small deployments' concurrent-session capacity. Users raise it with - # `-- -n N` when their deployment can handle more. Respect an explicit - # user choice for either flag (including `-p no:xdist`, which disables - # xdist and thus both). - _set_n, _set_dist = _user_set_xdist(args.pytest_args) - if not _set_n: - cmd.extend(["-n", "2"]) - if not _set_dist: - cmd.extend(["--dist", "loadgroup"]) - - if getattr(args, "ci", False): - cmd.append("--tb=short") - - cmd.extend(args.pytest_args) - if args.headless_auth: - # MFA prompting needs stdin; always append -s last so it - # overrides any conflicting --capture args from user or verbose. - cmd.append("-s") - - # Reconcile the child environment with the resolved proxy so the - # env-honoring egress in the suites (bare httpx.get probes, the load engine, - # Chromium's own detection) takes the same route as the pooled clients that - # read the config directly. Without this an explicit [proxy] url proxies the - # clients but not the probes, and enabled=false disables the clients while - # the probes stay on the ambient proxy. Best-effort: any config-load error - # falls through to the ambient environment unchanged (env=None). - subprocess_env: dict[str, str] | None = None - if config_path: - try: - from vip.config import load_config - from vip.proxy import proxy_env_for_subprocess - - subprocess_env = proxy_env_for_subprocess(load_config(config_path).proxy, os.environ) - except Exception: # noqa: BLE001 - subprocess_env = None - - try: - result = subprocess.run(cmd, timeout=args.test_timeout, env=subprocess_env, check=False) - sys.exit(result.returncode) - except subprocess.TimeoutExpired: - raise VipError( - f"tests timed out after {args.test_timeout} seconds. " - "Increase with --test-timeout or investigate hung tests." - ) from None - finally: - if temp_config: - Path(temp_config).unlink(missing_ok=True) - - -# Quarto report template files copied into the working report/ directory. -# Keep in sync with the force-include block in pyproject.toml. The fonts are -# part of the template set: vip-report.qmd resolves them via a relative -# `font-paths: fonts`, so a working report directory without them falls back -# to whatever faces the host has and renders a different-looking PDF. -_REPORT_TEMPLATE_FILES = ( - "index.qmd", - "details.qmd", - "vip-report.qmd", - "_quarto.yml", - "styles.css", - "fonts/SourceSans3-Regular.otf", - "fonts/SourceSans3-It.otf", - "fonts/SourceSans3-Semibold.otf", - "fonts/SourceSans3-Bold.otf", - "fonts/SourceCodePro-Regular.otf", - "fonts/LICENSE-SourceSans3.md", - "fonts/LICENSE-SourceCodePro.md", -) - - -def _has_all_report_templates(directory: Path) -> bool: - """Whether ``directory`` contains every required Quarto template file.""" - return all((directory / name).is_file() for name in _REPORT_TEMPLATE_FILES) - - -def _copy_report_templates(src: Path, report_dir: Path) -> list[str]: - """Copy template files from ``src``, returning names whose content changed. - - Files already identical in ``report_dir`` are left untouched, and only - pre-existing files that were overwritten with different content are - reported (fresh copies into an empty directory are not). - """ - import shutil - - replaced = [] - for name in _REPORT_TEMPLATE_FILES: - candidate = src / name - dest = report_dir / name - if not candidate.is_file(): - continue - if dest.is_file(): - if dest.read_bytes() == candidate.read_bytes(): - continue - replaced.append(name) - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(candidate, dest) - return replaced - - -def _ensure_report_templates(report_dir: Path) -> bool: - """Make sure the Quarto templates exist in ``report_dir``. - - Prefers the copy bundled in the installed wheel (``vip/_report``), - refreshing ``report_dir`` from it on every run so an upgraded VIP renders - its current templates. Falls back to the repo's top-level ``report/`` so - in-repo usage and selftests work without building a wheel. Returns ``True`` - only when *all* of ``_REPORT_TEMPLATE_FILES`` are present in ``report_dir``, - so a partial source (e.g. a template missing from one location) is topped - up from the other rather than silently rendering a degraded report. - - Identical files are not rewritten, and a notice lists any existing files - that the refresh did overwrite, so local template customizations never - disappear silently. - """ - import importlib.resources - - replaced: list[str] = [] - - # Bundled wheel copy: refresh templates into the working directory. Only - # materializing the resource is guarded (OSError covers as_file() failures - # on zip-imported packages before Python 3.12); a failure while copying - # into report_dir must propagate, or a stale set already present there - # would be rendered as if it were current. - with contextlib.ExitStack() as stack: - try: - bundled = importlib.resources.files("vip") / "_report" - p = stack.enter_context(importlib.resources.as_file(bundled)) - except (TypeError, OSError, ModuleNotFoundError): - p = None - if p is not None and _has_all_report_templates(p): - replaced += _copy_report_templates(p, report_dir) - - # Source checkout: three levels up from src/vip/cli.py → repo root/report. - if not _has_all_report_templates(report_dir): - repo_report = Path(__file__).parent.parent.parent / "report" - if _has_all_report_templates(repo_report) and repo_report.resolve() != report_dir.resolve(): - replaced += _copy_report_templates(repo_report, report_dir) - - if replaced: - print( - f"Refreshed report templates in {report_dir}: {', '.join(replaced)}", - file=sys.stderr, - ) - - # True only if the working directory now has the complete set (from a - # bundled/repo copy above, or from a prior run's copy already present). - return _has_all_report_templates(report_dir) - - -def _resolve_report_dir() -> Path: - """Return the working report directory for the current invocation. - - The report directory is ``./report`` relative to the invocation, but a - plain ``Path("report")`` also resolves that way when the caller is already - standing *inside* a report directory. Treat a working directory already - named ``report`` as the report directory itself, instead of descending - into it: otherwise ``vip report --results results.json`` run from within - ``report/`` creates a nested ``report/report/``, copies the templates - into it, and renders there, leaving a stray tree behind (papered over by - a ``report/report/`` .gitignore entry) and hiding the rendered output one - level deeper than the caller expected. - """ - cwd = Path.cwd() - if cwd.name == "report": - return Path() - return Path("report") - - -def run_report(args: argparse.Namespace) -> None: - """Render the Quarto report from a results.json file.""" - import shutil - import webbrowser - - report_dir = _resolve_report_dir() - report_dir.mkdir(parents=True, exist_ok=True) - - results_src = Path(args.results) - results_dest = report_dir / "results.json" - - if results_src.resolve() != results_dest.resolve(): - if not results_src.exists(): - raise ReportError(f"results file not found: {results_src}") - shutil.copy2(results_src, results_dest) - elif not results_dest.exists(): - raise ReportError( - f"no results found at {results_dest}. Run 'vip verify' first, or pass --results PATH." - ) - - if not _ensure_report_templates(report_dir): - raise ReportError( - "could not locate the VIP report templates. " - "Reinstall posit-vip so the bundled report is available." - ) - - # Pin Quarto's Jupyter kernel to the interpreter running `vip`. Quarto - # otherwise discovers Python via the ambient VIRTUAL_ENV (set by `uv run` - # or an activated venv) or falls back to /usr/bin/python3 -- neither of - # which is guaranteed to have posit-vip (for the report/*.qmd cells that - # import vip.gherkin / vip.reporting) or the Jupyter stack. sys.executable - # is the vip install itself, which always has both. See issue #554. - env = {**os.environ, "QUARTO_PYTHON": sys.executable} - - # The HTML pages and the PDF render as separate quarto invocations on - # purpose. One combined `quarto render` ties their fates together: on a - # Quarto too old to know Typst (pre-1.4), the PDF document fails the - # whole render *after* the HTML pages already rendered, and `vip report` - # would exit nonzero without handing over the HTML report it just - # produced. HTML is the primary artifact, so only its failure is fatal; - # the PDF degrades to a warning. All three documents stay in - # _quarto.yml's render list because a single-document render only lands - # in _output/ for listed files. - for page in ("index.qmd", "details.qmd"): - returncode = _quarto_render(page, report_dir, env) - if returncode != 0: - sys.exit(returncode) - - output = report_dir / "_output" / "index.html" - if not output.exists(): - raise ReportError( - "no report was produced. Ensure Quarto is installed " - "(https://quarto.org/docs/get-started/) and re-run." - ) - - print(f"Report generated: {output}") - - # The PDF is the copy customers archive, so a missing one warns loudly — - # but never fails the command, and never blocks the HTML hand-off above. - pdf = report_dir / "_output" / "vip-report.pdf" - if _quarto_render("vip-report.qmd", report_dir, env) == 0 and pdf.exists(): - print(f"PDF generated: {pdf}") - else: - print( - f"Warning: the HTML report rendered but {pdf} did not. " - "Quarto compiles it with Typst, which ships with Quarto 1.4 and " - "later — check `quarto --version` and upgrade if it is older.", - file=sys.stderr, - ) - - if args.open: - webbrowser.open(output.resolve().as_uri()) - - -def _quarto_render(document: str, report_dir: Path, env: dict[str, str]) -> int: - """Render one listed document of the report project, returning quarto's exit code. - - A missing quarto binary is fatal here rather than at the caller: it means - no document can render at all, and the message is the same wherever it - surfaces. - """ - try: - result = subprocess.run( - ["quarto", "render", document], cwd=str(report_dir), env=env, check=False - ) - except FileNotFoundError: - raise ReportError( - "quarto was not found on PATH. Install Quarto " - "(https://quarto.org/docs/get-started/) and re-run." - ) from None - return result.returncode - - -def _collect_status(config: VIPConfig) -> dict: - """Run health checks and return structured status data. - - Returns a dict with the schema:: - - { - "products": { - "connect": {"configured": bool, "state": "ok"|"fail"|"skip", ...}, - "workbench": {...}, - "package_manager": {...}, - }, - "outcome": "ok" | "fail", - "exit_status": 0 | 1, - } - - No printing or sys.exit side effects; callers handle rendering. - """ - from vip.clients.connect import ConnectClient - from vip.clients.packagemanager import PackageManagerClient - from vip.clients.workbench import WorkbenchClient - - checks = [ - ("connect", config.connect), - ("workbench", config.workbench), - ("package_manager", config.package_manager), - ] - - products: dict[str, dict] = {} - for name, pc in checks: - if not pc.is_configured: - products[name] = {"configured": False, "state": "skip", "detail": "not configured"} - continue - try: - from vip.auth import resolve_url_scheme - - resolve_url_scheme( - pc, insecure=config.insecure, ca_bundle=config.ca_bundle, proxy=config.proxy - ) - if name == "connect": - client: ConnectClient | WorkbenchClient | PackageManagerClient = ConnectClient( - pc.url, - pc.api_key, # type: ignore[attr-defined] - proxy=config.proxy, - ) - elif name == "workbench": - client = WorkbenchClient( - pc.url, - pc.api_key, # type: ignore[attr-defined] - proxy=config.proxy, - ) - else: - client = PackageManagerClient( - pc.url, - pc.token, # type: ignore[attr-defined] - proxy=config.proxy, - ) - http_status = client.health() - state = "ok" if http_status < 400 else "fail" - products[name] = { - "configured": True, - "url": pc.url, - "http_status": http_status, - "state": state, - } - except Exception as e: # noqa: BLE001 - products[name] = { - "configured": True, - "url": pc.url, - "state": "fail", - "detail": str(e), - } - - all_ok = all(p["state"] in ("ok", "skip") for p in products.values()) - outcome = "ok" if all_ok else "fail" - exit_status = 0 if all_ok else 1 - return {"products": products, "outcome": outcome, "exit_status": exit_status} - - -def run_status(args: argparse.Namespace) -> None: - """Run preflight health checks against each configured product.""" - from vip.config import load_config - - config = load_config(args.config) - data = _collect_status(config) - - if getattr(args, "json", False): - print(json.dumps(data)) - else: - for name, product in data["products"].items(): - state = product["state"] - if state == "skip": - detail = product.get("detail", "not configured") - elif "http_status" in product: - detail = f"HTTP {product['http_status']}" - else: - detail = product.get("detail", "") - print(f" {state.upper():4s} {name:20s} {detail}") - - sys.exit(data["exit_status"]) - - -def run_install(args: argparse.Namespace) -> None: - """Provision system packages and Playwright Chromium for VIP local mode.""" - from datetime import datetime, timezone - - from vip.install import platform as plat - from vip.install.manifest import ( - SCHEMA_VERSION, - Manifest, - current_host, - default_path, - load, - ) - from vip.install.packages import PackageQueryError, installed_dpkg, installed_rpm - from vip.install.plan import build_install_plan - from vip.install.playwright import PlaywrightInstallError, chromium_installed, default_cache_dir - from vip.install.runner import execute_install_plan, format_install_plan - - info = plat.detect() - manifest_path = default_path() - manifest = load(manifest_path) - - cache_dir = default_cache_dir() - - try: - plan = build_install_plan( - platform_info=info, - manifest=manifest, - rpm_installed=installed_rpm, - dpkg_installed=installed_dpkg, - chromium_present=chromium_installed(cache_dir), - playwright_cache_dir=cache_dir, - skip_system=bool(getattr(args, "skip_system", False)), - ) - - if getattr(args, "dry_run", False): - print(format_install_plan(plan), end="") - return - - if manifest is None: - now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - from vip import __version__ as vip_version - - manifest = Manifest( - version=SCHEMA_VERSION, - vip_version=vip_version, - created_at=now, - updated_at=now, - host=current_host(), - platform=info.family, - platform_id=info.id, - platform_version=info.version, - ) - - rc = execute_install_plan( - plan, - manifest=manifest, - manifest_path=manifest_path, - # Only Debian resolves a requested name to a different installed one. - resolve_installed=installed_dpkg if info.family == "debian-family" else None, - ) - except (PlaywrightInstallError, PackageQueryError) as exc: - raise InstallError(str(exc)) from exc - sys.exit(rc) - - -def run_uninstall(args: argparse.Namespace) -> None: - """Reverse `vip install` using the manifest.""" - from vip.install.manifest import ( - ManifestError, - current_host, - default_path, - load, - ) - from vip.install.plan import build_uninstall_plan - from vip.install.runner import execute_uninstall_plan - - manifest_path = default_path() - try: - manifest = load(manifest_path) - except ManifestError as exc: - raise InstallError(str(exc)) from exc - - if manifest is None: - raise InstallError( - f"No {manifest_path.name} found. Nothing to uninstall, or vip was " - "installed by a different mechanism." - ) - - if manifest.host != current_host() and not getattr(args, "force_host", False): - raise InstallError( - f"manifest host {manifest.host!r} does not match current host " - f"{current_host()!r}. Pass --force-host to override." - ) - - # Load vip.toml (when present) regardless of whether --connect-url was - # passed, so its [tls]/[proxy] settings apply to a --connect-url-only - # invocation too -- otherwise a deployment behind a self-signed cert with - # [tls] insecure = true in vip.toml has no route to that setting when the - # Connect URL itself comes from the CLI. Loading stays silent when - # vip.toml simply doesn't exist (mirrors _load_cleanup_config's guard); - # test_run_uninstall_silent_when_vip_toml_missing pins the no-config case. - # cfg carries the TLS settings (insecure/ca_bundle) for the - # probe-and-fallback below whether the Connect URL came from vip.toml or - # the CLI; with neither vip.toml present nor --insecure/--ca-bundle - # passed, it probes with defaults (verify=True). - from vip.config import ProductConfig - - connect_arg = getattr(args, "connect_url", None) - - cfg = None - env = os.environ.get("VIP_CONFIG") - config_path = Path(env) if env else Path("vip.toml") - if config_path.exists(): - if sys.version_info >= (3, 11): - import tomllib as _tomllib - else: - import tomli as _tomllib - - try: - from vip.config import load_config - - cfg = load_config() - except (_tomllib.TOMLDecodeError, ValueError) as exc: - print( - f"warning: failed to load vip.toml for chained cleanup: {exc}; " - "continuing without vip.toml-derived settings", - file=sys.stderr, - ) - - # A CLI --connect-url wins over vip.toml's [connect] url; wrapping it in - # ProductConfig routes a scheme-less --connect-url through the same - # _normalize_url every other entry point uses, so ConnectClient never - # sees an unnormalized URL. - if connect_arg: - connect_pc: ProductConfig | None = ProductConfig(url=connect_arg) - elif cfg and cfg.connect and cfg.connect.url: - connect_pc = cfg.connect - else: - connect_pc = None - - # --insecure/--ca-bundle win over the corresponding vip.toml [tls] value, - # same precedence _load_cleanup_config gives vip cleanup's equivalent flags. - insecure = getattr(args, "insecure", False) or (cfg.insecure if cfg else False) - ca_bundle = getattr(args, "ca_bundle", None) or (cfg.ca_bundle if cfg else None) - ca_bundle = _resolve_effective_ca_bundle(insecure, ca_bundle) - proxy = cfg.proxy if cfg else None - yes = bool(getattr(args, "yes", False)) - - # Resolve now, before the plan is built (and therefore before it's - # printed) -- but only when --yes was passed. execute_uninstall_plan - # prints format_uninstall_plan(plan) unconditionally, *before* its own - # --yes gate; resolving lazily inside cleanup_callable (the previous - # approach) meant the printed plan could announce - # "run vip cleanup against https://host" and then actually clean up - # against "http://host" once resolve_url_scheme downgraded it -- exactly - # the kind of scheme mismatch this whole feature exists to prevent. - # Gating on --yes preserves the dry-run guarantee: a plan preview must - # never probe the network. - if yes and connect_pc is not None: - from vip.auth import resolve_url_scheme - - resolve_url_scheme(connect_pc, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy) - - plan = build_uninstall_plan( - manifest=manifest, - connect_url=connect_pc.url if connect_pc else None, - ) - - cleanup_callable = None - if connect_pc is not None: - api_key = getattr(args, "api_key", None) or os.environ.get("VIP_CONNECT_API_KEY", "") - - def cleanup_callable(_url: str) -> None: - from vip.auth import resolve_url_scheme - from vip.clients.connect import ConnectClient - - # connect_pc.url was already resolved above -- this callable only - # ever runs when execute_uninstall_plan actually executes - # (--yes was passed), which is the same condition that already - # triggered the resolve above, so the printed plan and the URL - # used here are guaranteed to match. resolve_url_scheme is - # idempotent (it resets url_scheme_inferred once resolved), so - # calling it again here is a plain attribute read -- kept as a - # belt-and-suspenders safety net rather than trusted by omission. - resolved = resolve_url_scheme( - connect_pc, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy - ) - with ConnectClient( - resolved, api_key, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy - ) as client: - client.cleanup_vip_content() - - rc = execute_uninstall_plan( - plan, - manifest_path=manifest_path, - yes=yes, - cleanup_callable=cleanup_callable, - ) - sys.exit(rc) - - -# Registry of scaffold templates: name -> (examples/ source directory, one-line -# description). "cross-product" is the default so existing `vip scaffold -# --output DIR` invocations (predating --template) are unchanged. -_SCAFFOLD_TEMPLATES: dict[str, tuple[str, str]] = { - "minimal": ( - "custom_tests", - "Single-scenario HTTP health check against your configured product (start here)", - ), - "cross-product": ( - "cross_product_validation", - "R/Python runtime versions and package installability across Connect and Workbench", - ), -} -_DEFAULT_SCAFFOLD_TEMPLATE = "cross-product" - - -def _resolve_scaffold_source(dirname: str, stack: contextlib.ExitStack) -> Path | None: - """Locate the source directory for a scaffold template, or None if missing. - - Prefer the bundled copy inside the installed wheel (_scaffold/ is embedded - via [tool.hatch.build.targets.wheel.force-include]). Fall back to the - repo's top-level examples/ directory so in-repo usage and selftests work - without building a wheel first. - - The bundled path is materialized through ``importlib.resources.as_file``, - whose context must stay open for as long as anyone reads from the returned - path: for a zip-imported package as_file() extracts into a temporary - directory that is deleted when the context exits, so closing it here would - hand back a path that no longer exists by the time the caller copies from - it. It is entered on the caller's ExitStack instead, which run_scaffold - holds open across the copy -- the same pattern _ensure_report_templates - uses for the bundled Quarto templates. - """ - import importlib.resources - - try: - scaffold_pkg = importlib.resources.files("vip") / "_scaffold" / dirname - # files() returns a Traversable; we need a real Path for shutil.copytree. - p = stack.enter_context(importlib.resources.as_file(scaffold_pkg)) - if p.is_dir(): - return p - except (TypeError, OSError, ModuleNotFoundError): - pass - - # Source checkout: three levels up from src/vip/cli.py → repo root. - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / "examples" / dirname - if candidate.is_dir(): - return candidate - return None - - -def _scaffold_next_steps(template: str, dest: Path) -> str: - """Per-template "Next steps" text printed after a successful scaffold.""" - if template == "cross-product": - return ( - f"\nNext steps:\n" - f" 1. Edit {dest / 'conftest.py'} to set your package names and versions.\n" - f" 2. Add a [runtimes] block to vip.toml:\n" - f" [runtimes]\n" - f' r_versions = ["4.4.0"]\n' - f' python_versions = ["3.11.0"]\n' - f" 3. Run the extension:\n" - f" vip verify --config vip.toml --extensions {dest}\n" - f"\nSee {dest / 'README.md'} for full customization instructions." - ) - return ( - f"\nNext steps:\n" - f" 1. Edit {dest / 'test_custom_check.feature'} and" - f" {dest / 'test_custom_check.py'} to check your own endpoint.\n" - f" 2. Run the extension:\n" - f" vip verify --config vip.toml --extensions {dest}\n" - f"\nSee {dest / 'README.md'} for full customization instructions." - ) - - -def run_scaffold(args: argparse.Namespace) -> None: - """Copy a scaffold template to a user-specified directory, or list templates.""" - import shutil - - if getattr(args, "list", False): - print("Available templates:\n") - for name, (_dirname, description) in _SCAFFOLD_TEMPLATES.items(): - print(f" {name} - {description}") - return - - template = getattr(args, "template", None) or _DEFAULT_SCAFFOLD_TEMPLATE - if template not in _SCAFFOLD_TEMPLATES: - valid = ", ".join(sorted(_SCAFFOLD_TEMPLATES)) - raise ConfigError(f"unknown template {template!r}. Valid templates: {valid}") - dirname, _description = _SCAFFOLD_TEMPLATES[template] - - # One ExitStack spans every read of a bundled resource: the paths handed - # back by _resolve_scaffold_source are only guaranteed to exist while it is - # open (see that function's docstring). - with contextlib.ExitStack() as stack: - src = _resolve_scaffold_source(dirname, stack) - if src is None: - raise VipError( - f"could not locate examples/{dirname}/. " - "Ensure VIP is installed from source or as a wheel built with examples." - ) - - dest = Path(args.output) - if dest.exists() and not args.force: - raise ConfigError(f"destination already exists: {dest}\nPass --force to overwrite.") - - if dest.exists(): - if dest.is_dir() and not dest.is_symlink(): - shutil.rmtree(dest) - else: - dest.unlink() - - shutil.copytree(src, dest) - - # AGENTS.md is shared across every template (single source of truth), so - # it's copied in separately rather than living inside each template dir. - shared_agents_md = _resolve_scaffold_source("_shared", stack) - if shared_agents_md is not None: - shutil.copyfile(shared_agents_md / "AGENTS.md", dest / "AGENTS.md") - else: - # Don't fail the scaffold over it -- the tests themselves are still - # usable -- but say so, because a silently missing AGENTS.md means a - # packaging regression that is otherwise invisible to the user. - print( - "Warning: could not locate examples/_shared/AGENTS.md; " - "the scaffolded directory has no extension-authoring guide.", - file=sys.stderr, - ) - - print(f"Scaffolded extension to: {dest}") - print(_scaffold_next_steps(template, dest)) - - -def _cleanup_workbench_sessions( - workbench_url: str, - _args: argparse.Namespace, - config: VIPConfig, -) -> None: - """Authenticate to Workbench and quit orphaned VIP-named sessions. - - API-first: quits via :meth:`~vip.clients.workbench.WorkbenchClient.quit_vip_sessions` - when the session API is reachable. Escalates to a browser-driven UI sweep - (:func:`vip.workbench_ui.quit_vip_sessions_via_ui`) when the API is - unreachable *or* VIP sessions remain after the API sweep — a reachable API - whose DELETE/suspend call is a silent no-op is exactly the #467 bug, so a - "no error" response is not trusted on its own. - - Authentication is cache-aware: reuses a storage state saved by a prior - ``vip verify`` run (same cache path, <4h old) when available. Uses - ``--headless-auth``'s flow when ``VIP_TEST_USERNAME``/``VIP_TEST_PASSWORD`` - are set, otherwise opens an interactive browser login. Never lets an - authentication failure crash with a bare traceback — prints an actionable - error and exits 1. - """ - from vip.auth import ( - AuthConfigError, - auth_cache_path, - authenticated_page, - start_headless_auth, - start_interactive_auth, - ) - from vip.clients.workbench import WorkbenchClient - from vip.workbench_ui import quit_vip_sessions_via_ui - - insecure = config.insecure - ca_bundle = config.ca_bundle - proxy = config.proxy - # Same helper plugin.py uses, so this finds the session a prior `vip verify` - # from this directory cached. - cache_path = auth_cache_path() - - username = config.auth.username - password = config.auth.password - - try: - if username and password: - session = start_headless_auth( - workbench_url=workbench_url, - provider=config.auth.provider, - username=username, - password=password, - idp=config.auth.idp, - cache_path=cache_path, - insecure=insecure, - ca_bundle=ca_bundle, - proxy=proxy, - ) - else: - session = start_interactive_auth( - workbench_url=workbench_url, - cache_path=cache_path, - insecure=insecure, - ca_bundle=ca_bundle, - proxy=proxy, - ) - except AuthConfigError as exc: - raise AuthError(f"could not authenticate to Workbench: {exc}") from exc - except Exception as exc: - raise AuthError( - f"could not authenticate to Workbench at {workbench_url}: {exc}\n" - "Set VIP_TEST_USERNAME and VIP_TEST_PASSWORD for non-interactive cleanup, " - "or run this command where a browser can open for an interactive login." - ) from exc - - try: - print(f"Cleaning up orphaned Workbench sessions at {workbench_url}") - cookies = session.load_cookies() - client = WorkbenchClient( - workbench_url, cookies=cookies, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy - ) - try: - # sessions_api_reachable/count_vip_sessions currently signal an - # unreachable API via their return value (False/-1), never a raise -- - # but a ProductUnreachableError is caught here too so this still - # escalates to the UI sweep instead of aborting the whole command if - # a future client change (errors-narrow-clients) starts raising it. - try: - api_reachable = client.sessions_api_reachable() - except ProductUnreachableError: - api_reachable = False - if api_reachable: - quit_count = client.quit_vip_sessions() - print(f"Quit {quit_count} VIP Workbench session(s) via the API") - try: - remaining = client.count_vip_sessions() - except ProductUnreachableError: - remaining = -1 - else: - remaining = -1 # unknown — escalate below - - # Escalate when the API is unreachable, when VIP sessions remain, or - # when the count is undeterminable (-1). Only a confirmed 0 skips the - # UI sweep, so an unparseable API response can never silently orphan - # sessions (issue #467). - if not api_reachable or remaining != 0: - print("Escalating to browser-driven session cleanup ...") - with authenticated_page( - session, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy - ) as page: - ui_count = quit_vip_sessions_via_ui(page, workbench_url) - print(f"Quit {ui_count} VIP Workbench session(s) via the UI") - finally: - client.close() - finally: - session.cleanup() - - -def _ensure_cli_logging() -> None: - """Route ``vip.*`` INFO/WARNING logs to stderr for the cleanup command. - - The session-cleanup path (WorkbenchClient + workbench_ui) emits progress - and "sessions still present" warnings via ``logging``; without a handler - those are invisible (or only WARNING via the lastResort handler). Attach a - single stderr handler to the ``vip`` logger so ``vip cleanup`` surfaces - what it did. Idempotent: only configures once. - """ - vip_logger = logging.getLogger("vip") - if not vip_logger.handlers: - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) - vip_logger.addHandler(handler) - vip_logger.setLevel(logging.INFO) - vip_logger.propagate = False - - -def _load_cleanup_config(args: argparse.Namespace) -> VIPConfig: - """Load ``vip.toml`` if present, else a default ``VIPConfig``. - - ``load_config`` warns when no config file exists, which is noise for - ``vip cleanup`` when the user passes URLs explicitly and has no - ``vip.toml``. This loads the file only when it actually exists; otherwise - it returns a default ``VIPConfig`` whose ``__post_init__`` still picks up - env-based credentials (``VIP_TEST_USERNAME``/``VIP_TEST_PASSWORD``, - ``VIP_WORKBENCH_API_KEY``, etc.). - - ``--insecure``/``--ca-bundle`` win over the corresponding ``[tls]`` value - already on the returned config, mirroring how a CLI ``--connect-url`` - wins over ``[connect] url`` elsewhere in this command -- and letting the - flags work standalone with no ``vip.toml`` at all, which is the case - issue #563 calls out as having nowhere else to put them. The merged pair - still goes through ``_resolve_effective_ca_bundle`` so ``--insecure`` and - ``[tls] insecure`` both take the same precedence over a bundle as ``verify``. - """ - from vip.config import VIPConfig, load_config - - env = os.environ.get("VIP_CONFIG") - path = Path(env) if env else Path("vip.toml") - config = load_config() if path.exists() else VIPConfig() - - config.insecure = getattr(args, "insecure", False) or config.insecure - config.ca_bundle = getattr(args, "ca_bundle", None) or config.ca_bundle - config.ca_bundle = _resolve_effective_ca_bundle(config.insecure, config.ca_bundle) - return config - - -def run_cleanup(args: argparse.Namespace) -> None: - """Delete VIP test content from Connect and quit orphaned Workbench sessions. - - Connect cleanup deletes all content tagged ``_vip_test``. Workbench - cleanup quits VIP-named sessions (see - :func:`vip.clients.workbench.is_vip_session`), escalating to a - browser-driven UI sweep when the session API is unreachable or sessions - persist despite the API reporting success. The Connect/Workbench URLs - come from ``--connect-url``/``--workbench-url`` or, if omitted, from - ``[connect] url``/``[workbench] url`` in ``vip.toml``. At least one of - the two must resolve. A scheme-less URL defaults to ``https://`` and - falls back to ``http://`` if https doesn't answer (see - ``vip.config._normalize_url`` / ``vip.auth.resolve_url_scheme``). - """ - _ensure_cli_logging() - - from vip.config import ProductConfig - - connect_arg = getattr(args, "connect_url", None) - api_key = getattr(args, "api_key", None) or os.environ.get("VIP_CONNECT_API_KEY", "") - workbench_arg = getattr(args, "workbench_url", None) - - # Load vip.toml when present to fill in any URL not passed on the CLI, and - # to supply TLS/auth settings for the Workbench path. Loaded quietly: an - # explicit `vip cleanup --connect-url ...` with no vip.toml must not emit a - # "Config file not found" warning (env-based credentials still apply). - config = _load_cleanup_config(args) - - # A CLI flag wins over vip.toml. Wrapping the CLI arg in ProductConfig - # routes it through the same _normalize_url a bare hostname gets from - # every other entry point (vip verify, vip status), so ConnectClient - # never receives a scheme-less URL -- httpx requires an absolute one -- - # and the probe-and-fallback treatment below still applies. - # config.connect/config.workbench are already normalized ProductConfig - # instances -- every ProductConfig runs _normalize_url in its own - # __post_init__ regardless of how it was constructed, including the bare - # VIPConfig() that _load_cleanup_config() returns when no vip.toml exists - # -- so the vip.toml path needs no wrapping here. - connect_pc: ProductConfig = ProductConfig(url=connect_arg) if connect_arg else config.connect - workbench_pc: ProductConfig = ( - ProductConfig(url=workbench_arg) if workbench_arg else config.workbench - ) - - if not connect_pc.url and not workbench_pc.url: - raise ConfigError( - "no Connect or Workbench URL found. Pass --connect-url / " - "--workbench-url, or set [connect] url / [workbench] url in vip.toml." - ) - - if connect_pc.url: - from vip.auth import resolve_url_scheme - from vip.clients.connect import ConnectClient - - connect_url = resolve_url_scheme( - connect_pc, insecure=config.insecure, ca_bundle=config.ca_bundle, proxy=config.proxy - ) - print(f"Cleaning up VIP test content on Connect at {connect_url}") - with ConnectClient( - connect_url, - api_key, - insecure=config.insecure, - ca_bundle=config.ca_bundle, - proxy=config.proxy, - ) as client: - deleted = client.cleanup_vip_content() - print(f"Deleted {deleted} VIP test content item(s)") - - if workbench_pc.url: - from vip.auth import resolve_url_scheme - - workbench_url = resolve_url_scheme( - workbench_pc, insecure=config.insecure, ca_bundle=config.ca_bundle, proxy=config.proxy - ) - _cleanup_workbench_sessions(workbench_url, args, config) - - print("Cleanup completed successfully") - - -def _reorder_help_args(argv: list[str], commands: set[str]) -> list[str]: - """Let ``vip -h verify`` show verify's help instead of the top-level help. - - argparse's top-level parser consumes ``-h``/``--help`` before it delegates to - a subparser, so a help flag placed *before* the subcommand prints the generic - help. If a help flag appears ahead of a known subcommand, move it after the - subcommand so the subparser handles it and prints its own help. - """ - help_flags = {"-h", "--help"} - first_help = next((i for i, a in enumerate(argv) if a in help_flags), None) - if first_help is None: - return argv - first_cmd = next((i for i, a in enumerate(argv) if a in commands), None) - if first_cmd is None or first_help > first_cmd: - # No subcommand (top-level help is correct) or help already after it. - return argv - reordered = [a for a in argv if a not in help_flags] - # Insert the help flag before any ``--`` passthrough separator. After ``--`` - # argparse treats every token as a positional, so an appended ``--help`` - # would be swallowed as a pytest arg and the command would run instead of - # printing help. - insert_at = reordered.index("--") if "--" in reordered else len(reordered) - reordered.insert(insert_at, "--help") - return reordered - - -def _format_version_details() -> str: - """Render the vip version and the minimum supported Posit Team release. - - VIP's own version and the Posit Team support floor are both calendar-versioned - (e.g. ``2026.7.0``) but are unrelated numbers, so each line is labeled - explicitly to avoid a reader mistaking one for the other. - """ - from vip import __version__ - from vip.version import MINIMUM_SUPPORTED_POSIT_TEAM - - return ( - f"VIP version: {__version__}\n" - f"Supported Posit Team versions: {MINIMUM_SUPPORTED_POSIT_TEAM} and newer" - ) - - -def run_version(_args: argparse.Namespace) -> None: - """Print the vip version and the minimum supported Posit Team version.""" - print(_format_version_details()) - - -def main() -> None: - """Main entry point for the VIP CLI.""" - from vip import __version__ - - parser = argparse.ArgumentParser( - prog="vip", description="VIP verification and credential tools" - ) - parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {__version__}", - help="Print the vip version and exit", - ) - subparsers = parser.add_subparsers(dest="command") - - # vip version - version_parser = subparsers.add_parser( - "version", - help="Print the vip version and the minimum supported Posit Team version", - ) - version_parser.set_defaults(func=run_version) - - # vip auth - auth_parser = subparsers.add_parser("auth", help="Authentication tools") - auth_sub = auth_parser.add_subparsers(dest="auth_command") - - # vip auth mint-connect-key - mint_parser = auth_sub.add_parser( - "mint-connect-key", - help="Mint a Connect API key via interactive browser login", - ) - mint_parser.add_argument("--url", required=True, help="Connect server URL") - mint_parser.set_defaults(func=mint_connect_key) - - # vip verify - verify_parser = subparsers.add_parser( - "verify", - help="Run VIP tests against a Posit Team deployment", - description=( - "Run VIP tests against a Posit Team deployment.\n\n" - "Quick start (no config file needed):\n" - " vip verify --connect-url https://connect.example.com\n\n" - "A browser window opens for authentication. After login,\n" - "tests run headlessly and the browser session is cleaned up.\n\n" - "With an existing config file:\n" - " vip verify --config vip.toml --no-interactive-auth\n\n" - "Filter tests by name:\n" - " vip verify --connect-url https://connect.example.com --filter 'login'\n\n" - "Any arguments after -- are passed directly to pytest:\n" - " vip verify --connect-url https://connect.example.com -- -x" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - # URL args (no config file needed) - url_group = verify_parser.add_argument_group("product URLs (no config file needed)") - url_group.add_argument("--connect-url", default=None, help="Connect server URL") - url_group.add_argument("--workbench-url", default=None, help="Workbench server URL") - url_group.add_argument("--package-manager-url", default=None, help="Package Manager server URL") - url_group.add_argument( - "--connect-version", - default=None, - help=( - "Deployed Connect version (e.g. 2026.06.0). Required for tests with a " - "min_version marker to run instead of being skipped as N/A-by-version." - ), - ) - url_group.add_argument( - "--workbench-version", - default=None, - help=( - "Deployed Workbench version (e.g. 2026.06.0). Required for tests with a " - "min_version marker to run instead of being skipped as N/A-by-version." - ), - ) - url_group.add_argument( - "--package-manager-version", - default=None, - help=( - "Deployed Package Manager version (e.g. 2026.06.0). Required for tests with a " - "min_version marker to run instead of being skipped as N/A-by-version." - ), - ) - - # TLS configuration - tls_group = verify_parser.add_argument_group("TLS configuration") - tls_group.add_argument( - "--insecure", - action="store_true", - default=False, - help=( - "Disable TLS certificate verification (equivalent to curl -k). " - "Use only in trusted environments; this silently ignores certificate errors. " - "For Playwright browser contexts, this sets ignore_https_errors=True. " - "Note: --ca-bundle is preferred when you have a custom CA certificate." - ), - ) - tls_group.add_argument( - "--ca-bundle", - default=None, - metavar="PATH", - type=Path, - help=( - "Path to a custom CA certificate bundle (PEM) to trust. " - "Useful for self-signed or corporate CAs. " - "For Playwright, sets NODE_EXTRA_CA_CERTS before launching Chromium " - "(Chromium-level trust only; does not update the OS certificate store)." - ), - ) - - # Proxy configuration - proxy_group = verify_parser.add_argument_group("outbound proxy") - proxy_group.add_argument( - "--proxy", - default=None, - metavar="URL", - help=( - "Route outbound HTTP(S) through this proxy (e.g. http://proxy:8080). " - "Applies to the product API clients, the auth/probe requests, and the " - "Playwright browser login. Overrides HTTP_PROXY/HTTPS_PROXY, and also " - "supersedes any NO_PROXY in the environment (repeat bypass hosts with " - "--no-proxy). Only takes effect alongside the product-URL flags; it is " - "ignored on a run that loads a config file, whether via --config or a " - "./vip.toml (use a [proxy] section there instead). When omitted, VIP " - "reads the ambient HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment (same " - "as httpx)." - ), - ) - proxy_group.add_argument( - "--no-proxy", - default=None, - metavar="HOSTS", - help=( - "Comma-separated hosts to reach directly, bypassing --proxy " - "(e.g. localhost,.internal.example). With no --proxy, passing an " - "empty value (--no-proxy '') disables proxying entirely, ignoring " - "any proxy environment variables. Ignored on a run that loads a " - "config file, whether via --config or a ./vip.toml (use a [proxy] " - "section there instead)." - ), - ) - - # Config file - verify_parser.add_argument( - "--config", - default=None, - help="Path to vip.toml (default: VIP_CONFIG env var or ./vip.toml)", - ) - - # Auth - auth_group = verify_parser.add_argument_group("authentication") - auth_group.add_argument( - "--idp", - default=None, - help='Identity provider for --headless-auth: "keycloak", "okta", "snowflake". ' - 'Presence implies provider = "oidc" unless overridden by --provider or vip.toml.', - ) - auth_group.add_argument( - "--provider", - default=None, - help=f"Auth provider for --headless-auth/--interactive-auth: " - f"{', '.join(_IDP_PROVIDERS)}. Overrides both --idp's implied " - f'"oidc" and any provider inherited from vip.toml.', - ) - verify_parser.add_argument( - "--interactive-auth", - action=argparse.BooleanOptionalAction, - default=False, - help="Launch a browser for OIDC login (default: disabled, use " - "--interactive-auth to enable)", - ) - verify_parser.add_argument( - "--headless-auth", - action=argparse.BooleanOptionalAction, - default=False, - help=( - "Automate login in a headless browser (OIDC/SAML/OAuth2 requires " - "[auth] idp). If VIP_TEST_TOTP_SECRET is set (a base32 TOTP seed " - "for a TEST SERVICE ACCOUNT), VIP auto-fills the MFA code instead " - "of prompting. Never use a personal account's seed." - ), - ) - verify_parser.add_argument( - "--no-auth", - action="store_true", - default=False, - help="Skip all tests that require authentication (Connect and Workbench)", - ) - verify_parser.add_argument( - "--api-auth", - action="store_true", - default=False, - help="Run only API-key-authenticated tests; skip tests requiring browser credentials", - ) - - # Test selection - verify_parser.add_argument( - "--categories", - default=None, - help="Test categories as a pytest marker expression " - "(e.g. 'connect', 'package-manager', 'workbench'). " - "To include performance tests use --performance-tests instead.", - ) - verify_parser.add_argument( - "--performance-tests", - action="store_true", - default=False, - help="Include performance tests in the default selection (excluded otherwise). " - "Has no effect when --categories is also specified.", - ) - verify_parser.add_argument( - "--basic", - action="store_true", - default=False, - help="Run only the basic subset; exclude detailed/long-running checks " - "tagged @slow (IDE extensions, jobs, git ops, publish to Connect). " - "Composes with --categories.", - ) - verify_parser.add_argument( - "-f", - "--filter", - default=None, - dest="filter_expr", - help="Filter tests by name expression, passed to pytest -k " - "(e.g. 'test_login', 'test_login and not saml')", - ) - verify_parser.add_argument( - "--report", - default="report/results.json", - help="Write JSON results to this path for Quarto report generation" - " (default: report/results.json)", - ) - verify_parser.add_argument( - "--format", - default="json", - help="Comma-separated output formats: json,junit,sarif. json (results.json)" - " is always written; junit/sarif land beside --report. (default: json)", - ) - verify_parser.add_argument( - "--ci", - action="store_true", - default=False, - help="CI preset: emit json,junit,sarif and use concise tracebacks (--tb=short)." - " Overrides --format. Not compatible with --interactive-auth/--headless-auth.", - ) - verify_parser.add_argument( - "--verbose", - action="store_true", - default=False, - help="Show full pytest tracebacks instead of concise error messages", - ) - verify_parser.add_argument( - "--extensions", - action="append", - default=[], - help="Additional directories containing custom test cases (repeatable)", - ) - verify_parser.add_argument( - "--test-timeout", - type=int, - default=DEFAULT_TEST_TIMEOUT_SECONDS, - help=( - "Timeout in seconds for the pytest subprocess " - f"(default: {DEFAULT_TEST_TIMEOUT_SECONDS}). " - "A full Connect run includes content deployments that each take " - "several minutes (R package restore, Python venv creation), so " - "raise this further for large suites or slow servers. For " - "per-deploy limits, set deploy_timeout under [connect] in vip.toml." - ), - ) - - verify_parser.add_argument( - "--allow-unproven", - action="store_true", - default=False, - help=( - "Exit 0 even when checks could not be verified. By default a check " - "that VIP was asked to run but could not (for example, a configured " - "product whose authentication never completed) fails the run, so an " - "unverified deployment is not reported as a passing one." - ), - ) - - # Pytest passthrough - verify_parser.add_argument( - "pytest_args", - nargs="*", - default=[], - help="Additional arguments passed to pytest (place after --)", - ) - verify_parser.set_defaults(func=run_verify) - - # vip cleanup - cleanup_parser = subparsers.add_parser( - "cleanup", - help="Delete VIP _vip_test content from Connect and quit orphaned Workbench sessions", - description=( - "Delete VIP _vip_test-tagged content from Connect, and/or quit orphaned\n" - "VIP-named Workbench sessions. At least one of --connect-url /\n" - "--workbench-url (or the corresponding vip.toml URL) must resolve.\n\n" - " vip cleanup --connect-url https://connect.example.com\n" - " vip cleanup --workbench-url https://workbench.example.com\n" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - cleanup_parser.add_argument( - "--connect-url", - default=None, - help="Connect server URL (falls back to vip.toml if omitted)", - ) - cleanup_parser.add_argument( - "--api-key", - default=None, - help="Connect API key (default: VIP_CONNECT_API_KEY env var)", - ) - cleanup_parser.add_argument( - "--workbench-url", - default=None, - help=( - "Workbench server URL (falls back to vip.toml if omitted). Quits orphaned " - "VIP-named sessions via the session API, escalating to a browser-driven UI " - "sweep if the API is unreachable or sessions persist. Requires " - "VIP_TEST_USERNAME/VIP_TEST_PASSWORD for non-interactive auth, or an " - "interactive browser login." - ), - ) - cleanup_tls_group = cleanup_parser.add_argument_group("TLS configuration") - cleanup_tls_group.add_argument( - "--insecure", - action="store_true", - default=False, - help=( - "Disable TLS certificate verification (equivalent to curl -k). " - "Use only in trusted environments; this silently ignores certificate errors. " - "For Playwright browser contexts, this sets ignore_https_errors=True. " - "Note: --ca-bundle is preferred when you have a custom CA certificate." - ), - ) - cleanup_tls_group.add_argument( - "--ca-bundle", - default=None, - metavar="PATH", - type=Path, - help=( - "Path to a custom CA certificate bundle (PEM) to trust. " - "Useful for self-signed or corporate CAs. " - "For Playwright, sets NODE_EXTRA_CA_CERTS before launching Chromium " - "(Chromium-level trust only; does not update the OS certificate store)." - ), - ) - cleanup_parser.set_defaults(func=run_cleanup) - - # vip install - install_parser = subparsers.add_parser( - "install", - help="Install system packages and Playwright Chromium", - description=( - "Install VIP's machine-side dependencies: Chromium runtime libraries " - "(via dnf or apt) and Playwright's Chromium browser. " - "Records what was installed in .vip-install.json so vip uninstall can " - "reverse only what this command added." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - install_parser.add_argument( - "--skip-system", - action="store_true", - default=False, - help=( - "Skip the system-package step. VIP will not record those packages in " - ".vip-install.json, so vip uninstall will not propose removing them. " - "Use this when you manage system packages yourself or don't have sudo." - ), - ) - install_parser.add_argument( - "--dry-run", - action="store_true", - default=False, - help="Print the plan without executing.", - ) - install_parser.set_defaults(func=run_install) - - # vip uninstall - uninstall_parser = subparsers.add_parser( - "uninstall", - help="Reverse vip install (dry-run by default; --yes to execute)", - description=( - "Reverse vip install using the per-project .vip-install.json manifest. " - "Removes the Playwright cache and manifest; prints the sudo command for " - "any system packages vip recorded so you can remove them yourself. " - "Always prints a dry-run plan; pass --yes to execute the user-space steps." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - uninstall_parser.add_argument("--yes", action="store_true", default=False) - uninstall_parser.add_argument("--force-host", action="store_true", default=False) - uninstall_parser.add_argument( - "--connect-url", - default=None, - help="Connect URL for chained vip cleanup (default: config / autodetect).", - ) - uninstall_parser.add_argument("--api-key", default=None) - # uninstall's chained cleanup only ever constructs a ConnectClient (no - # Playwright/browser path, unlike verify and cleanup's Workbench sweep), - # so its help text drops the Playwright-specific sentences verify's - # otherwise-identical help carries -- they'd promise an effect uninstall - # cannot produce. - uninstall_tls_group = uninstall_parser.add_argument_group("TLS configuration") - uninstall_tls_group.add_argument( - "--insecure", - action="store_true", - default=False, - help=( - "Disable TLS certificate verification (equivalent to curl -k). " - "Use only in trusted environments; this silently ignores certificate errors. " - "Note: --ca-bundle is preferred when you have a custom CA certificate." - ), - ) - uninstall_tls_group.add_argument( - "--ca-bundle", - default=None, - metavar="PATH", - type=Path, - help=( - "Path to a custom CA certificate bundle (PEM) to trust. " - "Useful for self-signed or corporate CAs." - ), - ) - uninstall_parser.set_defaults(func=run_uninstall) - - # vip report - report_parser = subparsers.add_parser( - "report", - help="Render the Quarto report from a results.json file", - ) - report_parser.add_argument( - "--results", - default="report/results.json", - help="Path to results.json (default: report/results.json)", - ) - report_parser.add_argument( - "--open", - action="store_true", - default=False, - help="Open the rendered report in a browser after rendering", - ) - report_parser.set_defaults(func=run_report) - - # vip status - status_parser = subparsers.add_parser( - "status", - help="Check health endpoints for each configured product", - ) - status_parser.add_argument( - "--config", - default=None, - help="Path to vip.toml (default: VIP_CONFIG env var or ./vip.toml)", - ) - status_parser.add_argument( - "--json", - action="store_true", - default=False, - help="Emit machine-readable JSON instead of human-formatted text", - ) - status_parser.set_defaults(func=run_status) - - # vip scaffold - scaffold_parser = subparsers.add_parser( - "scaffold", - help="Generate a ready-to-run custom test extension directory", - description=( - "Copy a scaffold template to a new directory, ready to customise and run\n" - "with:\n\n" - " vip verify --config vip.toml --extensions \n\n" - "Templates range from a minimal single-scenario health check to a fuller\n" - "cross-product example spanning Workbench and Connect. Run\n" - "'vip scaffold --list' to see what's available. Every template also\n" - "receives an AGENTS.md documenting VIP's fixtures, markers, and client\n" - "layers for anyone (human or AI assistant) writing the extension." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - scaffold_parser.add_argument( - "--output", - default="./custom_tests", - metavar="DIR", - help="Destination directory for the scaffolded extension (default: ./custom_tests)", - ) - scaffold_parser.add_argument( - "--template", - default=_DEFAULT_SCAFFOLD_TEMPLATE, - metavar="NAME", - help=( - "Which template to scaffold (default: %(default)s). " - "Run 'vip scaffold --list' for the available templates." - ), - ) - scaffold_parser.add_argument( - "--list", - action="store_true", - default=False, - help="List available templates and exit", - ) - scaffold_parser.add_argument( - "--force", - action="store_true", - default=False, - help="Overwrite destination if it already exists", - ) - scaffold_parser.set_defaults(func=run_scaffold) - - # Map command names to their parsers for context-appropriate help - subcommand_parsers = { - "version": version_parser, - "verify": verify_parser, - "cleanup": cleanup_parser, - "install": install_parser, - "uninstall": uninstall_parser, - "auth": auth_parser, - "report": report_parser, - "status": status_parser, - "scaffold": scaffold_parser, - } - - argv = _reorder_help_args(sys.argv[1:], set(subcommand_parsers)) - args = parser.parse_args(argv) - if not hasattr(args, "func"): - sub = subcommand_parsers.get(args.command) - if sub: - sub.print_help() - else: - parser.print_help() - sys.exit(1) - try: - args.func(args) - except VipError as exc: - print(f"Error: {exc}", file=sys.stderr) - sys.exit(exc.exit_code) - - -if __name__ == "__main__": - main() diff --git a/src/vip/cli/__init__.py b/src/vip/cli/__init__.py new file mode 100644 index 00000000..de8c79d7 --- /dev/null +++ b/src/vip/cli/__init__.py @@ -0,0 +1,55 @@ +"""VIP command-line tools for credential management and verification. + +One module per subcommand; ``app`` builds the parser and holds ``main()``. +The package namespace re-exports every name imported from ``vip.cli``. +Patch a helper on the submodule that calls it, not here: a re-export does +not change the global the submodule looks up. +""" + +from vip.cli.app import _reorder_help_args, main +from vip.cli.auth import mint_connect_key +from vip.cli.cleanup import _cleanup_workbench_sessions, run_cleanup +from vip.cli.install import run_install, run_uninstall +from vip.cli.report import ( + _REPORT_TEMPLATE_FILES, + _ensure_report_templates, + _has_all_report_templates, + run_report, +) +from vip.cli.scaffold import run_scaffold +from vip.cli.status import _collect_status, run_status +from vip.cli.verify import ( + _OPT_IN_CATEGORIES, + DEFAULT_TEST_TIMEOUT_SECONDS, + _default_marker_expr, + _extra_keep_from_args, + _generate_temp_config, + _normalize_categories, + run_verify, +) +from vip.cli.version import run_version + +__all__ = [ + "DEFAULT_TEST_TIMEOUT_SECONDS", + "_OPT_IN_CATEGORIES", + "_REPORT_TEMPLATE_FILES", + "_cleanup_workbench_sessions", + "_collect_status", + "_default_marker_expr", + "_ensure_report_templates", + "_extra_keep_from_args", + "_generate_temp_config", + "_has_all_report_templates", + "_normalize_categories", + "_reorder_help_args", + "main", + "mint_connect_key", + "run_cleanup", + "run_install", + "run_report", + "run_scaffold", + "run_status", + "run_uninstall", + "run_verify", + "run_version", +] diff --git a/src/vip/cli/__main__.py b/src/vip/cli/__main__.py new file mode 100644 index 00000000..39159aae --- /dev/null +++ b/src/vip/cli/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m vip.cli``.""" + +from vip.cli.app import main + +main() diff --git a/src/vip/cli/_common.py b/src/vip/cli/_common.py new file mode 100644 index 00000000..02386329 --- /dev/null +++ b/src/vip/cli/_common.py @@ -0,0 +1,47 @@ +"""Helpers shared by more than one ``vip`` subcommand.""" + +from __future__ import annotations + +from pathlib import Path + + +def _resolve_effective_ca_bundle(insecure: bool, ca_bundle: Path | None) -> Path | None: + """Apply --insecure/--ca-bundle precedence: insecure wins, ca_bundle is dropped. + + Warns when both are set (mirrors curl's own precedence for -k combined with + --cacert). Shared by every command that accepts both flags -- ``verify`` (via + ``_generate_temp_config``), and ``cleanup``/``uninstall`` (via + ``_load_cleanup_config``/``run_uninstall``) -- so the collision is handled + identically everywhere instead of three independent copies drifting apart. + + The warning fires on the collision regardless of where each value came + from. ``cleanup``/``uninstall`` call this *after* merging a CLI flag with + the corresponding ``vip.toml`` [tls] value (CLI wins per-field), so the + pair handed in here may be flag+flag, toml+toml, or one of each -- the + message therefore doesn't claim a CLI-only cause. This is a deliberate + divergence from ``verify``: its own ``--config``/default-``./vip.toml`` + path loads ``[tls]`` straight through ``vip.config.load_config()`` and + never calls this helper at all, so an identical ``vip.toml`` with both + keys set warns for ``cleanup``/``uninstall`` but not for ``verify`` against + that same file. Covered by ``test_toml_only_conflict_warns_and_insecure_wins`` + in ``selftests/test_cli_cleanup.py`` and its uninstall counterpart. + """ + if insecure and ca_bundle: + import warnings + + # stacklevel=2 attributes the warning to this helper's direct caller + # (_generate_temp_config / _load_cleanup_config / run_uninstall). + # Before this logic was extracted, the inline warnings.warn() in + # _generate_temp_config used stacklevel=2 to reach *its* caller + # instead -- one frame further up. No single stacklevel is correct + # for all three call sites (they sit at different depths from the + # command dispatch that ultimately triggered this), so this is a + # deliberate, accepted drift rather than an oversight. + warnings.warn( + "insecure and a ca_bundle are both configured (whether via " + "--insecure/--ca-bundle or [tls] insecure/ca_bundle in vip.toml); " + "insecure takes precedence and the ca_bundle will be ignored for " + "TLS verification.", + stacklevel=2, + ) + return None if insecure else ca_bundle diff --git a/src/vip/cli/app.py b/src/vip/cli/app.py new file mode 100644 index 00000000..836a6836 --- /dev/null +++ b/src/vip/cli/app.py @@ -0,0 +1,583 @@ +"""Argument parser construction and the ``vip`` entry point.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from vip.cli.auth import mint_connect_key +from vip.cli.cleanup import run_cleanup +from vip.cli.install import run_install, run_uninstall +from vip.cli.report import run_report +from vip.cli.scaffold import _DEFAULT_SCAFFOLD_TEMPLATE, run_scaffold +from vip.cli.status import run_status +from vip.cli.verify import _IDP_PROVIDERS, DEFAULT_TEST_TIMEOUT_SECONDS, run_verify +from vip.cli.version import run_version +from vip.errors import VipError + + +def _reorder_help_args(argv: list[str], commands: set[str]) -> list[str]: + """Let ``vip -h verify`` show verify's help instead of the top-level help. + + argparse's top-level parser consumes ``-h``/``--help`` before it delegates to + a subparser, so a help flag placed *before* the subcommand prints the generic + help. If a help flag appears ahead of a known subcommand, move it after the + subcommand so the subparser handles it and prints its own help. + """ + help_flags = {"-h", "--help"} + first_help = next((i for i, a in enumerate(argv) if a in help_flags), None) + if first_help is None: + return argv + first_cmd = next((i for i, a in enumerate(argv) if a in commands), None) + if first_cmd is None or first_help > first_cmd: + # No subcommand (top-level help is correct) or help already after it. + return argv + reordered = [a for a in argv if a not in help_flags] + # Insert the help flag before any ``--`` passthrough separator. After ``--`` + # argparse treats every token as a positional, so an appended ``--help`` + # would be swallowed as a pytest arg and the command would run instead of + # printing help. + insert_at = reordered.index("--") if "--" in reordered else len(reordered) + reordered.insert(insert_at, "--help") + return reordered + + +def main() -> None: + """Main entry point for the VIP CLI.""" + from vip import __version__ + + parser = argparse.ArgumentParser( + prog="vip", description="VIP verification and credential tools" + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + help="Print the vip version and exit", + ) + subparsers = parser.add_subparsers(dest="command") + + # vip version + version_parser = subparsers.add_parser( + "version", + help="Print the vip version and the minimum supported Posit Team version", + ) + version_parser.set_defaults(func=run_version) + + # vip auth + auth_parser = subparsers.add_parser("auth", help="Authentication tools") + auth_sub = auth_parser.add_subparsers(dest="auth_command") + + # vip auth mint-connect-key + mint_parser = auth_sub.add_parser( + "mint-connect-key", + help="Mint a Connect API key via interactive browser login", + ) + mint_parser.add_argument("--url", required=True, help="Connect server URL") + mint_parser.set_defaults(func=mint_connect_key) + + # vip verify + verify_parser = subparsers.add_parser( + "verify", + help="Run VIP tests against a Posit Team deployment", + description=( + "Run VIP tests against a Posit Team deployment.\n\n" + "Quick start (no config file needed):\n" + " vip verify --connect-url https://connect.example.com\n\n" + "A browser window opens for authentication. After login,\n" + "tests run headlessly and the browser session is cleaned up.\n\n" + "With an existing config file:\n" + " vip verify --config vip.toml --no-interactive-auth\n\n" + "Filter tests by name:\n" + " vip verify --connect-url https://connect.example.com --filter 'login'\n\n" + "Any arguments after -- are passed directly to pytest:\n" + " vip verify --connect-url https://connect.example.com -- -x" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # URL args (no config file needed) + url_group = verify_parser.add_argument_group("product URLs (no config file needed)") + url_group.add_argument("--connect-url", default=None, help="Connect server URL") + url_group.add_argument("--workbench-url", default=None, help="Workbench server URL") + url_group.add_argument("--package-manager-url", default=None, help="Package Manager server URL") + url_group.add_argument( + "--connect-version", + default=None, + help=( + "Deployed Connect version (e.g. 2026.06.0). Required for tests with a " + "min_version marker to run instead of being skipped as N/A-by-version." + ), + ) + url_group.add_argument( + "--workbench-version", + default=None, + help=( + "Deployed Workbench version (e.g. 2026.06.0). Required for tests with a " + "min_version marker to run instead of being skipped as N/A-by-version." + ), + ) + url_group.add_argument( + "--package-manager-version", + default=None, + help=( + "Deployed Package Manager version (e.g. 2026.06.0). Required for tests with a " + "min_version marker to run instead of being skipped as N/A-by-version." + ), + ) + + # TLS configuration + tls_group = verify_parser.add_argument_group("TLS configuration") + tls_group.add_argument( + "--insecure", + action="store_true", + default=False, + help=( + "Disable TLS certificate verification (equivalent to curl -k). " + "Use only in trusted environments; this silently ignores certificate errors. " + "For Playwright browser contexts, this sets ignore_https_errors=True. " + "Note: --ca-bundle is preferred when you have a custom CA certificate." + ), + ) + tls_group.add_argument( + "--ca-bundle", + default=None, + metavar="PATH", + type=Path, + help=( + "Path to a custom CA certificate bundle (PEM) to trust. " + "Useful for self-signed or corporate CAs. " + "For Playwright, sets NODE_EXTRA_CA_CERTS before launching Chromium " + "(Chromium-level trust only; does not update the OS certificate store)." + ), + ) + + # Proxy configuration + proxy_group = verify_parser.add_argument_group("outbound proxy") + proxy_group.add_argument( + "--proxy", + default=None, + metavar="URL", + help=( + "Route outbound HTTP(S) through this proxy (e.g. http://proxy:8080). " + "Applies to the product API clients, the auth/probe requests, and the " + "Playwright browser login. Overrides HTTP_PROXY/HTTPS_PROXY, and also " + "supersedes any NO_PROXY in the environment (repeat bypass hosts with " + "--no-proxy). Only takes effect alongside the product-URL flags; it is " + "ignored on a run that loads a config file, whether via --config or a " + "./vip.toml (use a [proxy] section there instead). When omitted, VIP " + "reads the ambient HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment (same " + "as httpx)." + ), + ) + proxy_group.add_argument( + "--no-proxy", + default=None, + metavar="HOSTS", + help=( + "Comma-separated hosts to reach directly, bypassing --proxy " + "(e.g. localhost,.internal.example). With no --proxy, passing an " + "empty value (--no-proxy '') disables proxying entirely, ignoring " + "any proxy environment variables. Ignored on a run that loads a " + "config file, whether via --config or a ./vip.toml (use a [proxy] " + "section there instead)." + ), + ) + + # Config file + verify_parser.add_argument( + "--config", + default=None, + help="Path to vip.toml (default: VIP_CONFIG env var or ./vip.toml)", + ) + + # Auth + auth_group = verify_parser.add_argument_group("authentication") + auth_group.add_argument( + "--idp", + default=None, + help='Identity provider for --headless-auth: "keycloak", "okta", "snowflake". ' + 'Presence implies provider = "oidc" unless overridden by --provider or vip.toml.', + ) + auth_group.add_argument( + "--provider", + default=None, + help=f"Auth provider for --headless-auth/--interactive-auth: " + f"{', '.join(_IDP_PROVIDERS)}. Overrides both --idp's implied " + f'"oidc" and any provider inherited from vip.toml.', + ) + verify_parser.add_argument( + "--interactive-auth", + action=argparse.BooleanOptionalAction, + default=False, + help="Launch a browser for OIDC login (default: disabled, use " + "--interactive-auth to enable)", + ) + verify_parser.add_argument( + "--headless-auth", + action=argparse.BooleanOptionalAction, + default=False, + help=( + "Automate login in a headless browser (OIDC/SAML/OAuth2 requires " + "[auth] idp). If VIP_TEST_TOTP_SECRET is set (a base32 TOTP seed " + "for a TEST SERVICE ACCOUNT), VIP auto-fills the MFA code instead " + "of prompting. Never use a personal account's seed." + ), + ) + verify_parser.add_argument( + "--no-auth", + action="store_true", + default=False, + help="Skip all tests that require authentication (Connect and Workbench)", + ) + verify_parser.add_argument( + "--api-auth", + action="store_true", + default=False, + help="Run only API-key-authenticated tests; skip tests requiring browser credentials", + ) + + # Test selection + verify_parser.add_argument( + "--categories", + default=None, + help="Test categories as a pytest marker expression " + "(e.g. 'connect', 'package-manager', 'workbench'). " + "To include performance tests use --performance-tests instead.", + ) + verify_parser.add_argument( + "--performance-tests", + action="store_true", + default=False, + help="Include performance tests in the default selection (excluded otherwise). " + "Has no effect when --categories is also specified.", + ) + verify_parser.add_argument( + "--basic", + action="store_true", + default=False, + help="Run only the basic subset; exclude detailed/long-running checks " + "tagged @slow (IDE extensions, jobs, git ops, publish to Connect). " + "Composes with --categories.", + ) + verify_parser.add_argument( + "-f", + "--filter", + default=None, + dest="filter_expr", + help="Filter tests by name expression, passed to pytest -k " + "(e.g. 'test_login', 'test_login and not saml')", + ) + verify_parser.add_argument( + "--report", + default="report/results.json", + help="Write JSON results to this path for Quarto report generation" + " (default: report/results.json)", + ) + verify_parser.add_argument( + "--format", + default="json", + help="Comma-separated output formats: json,junit,sarif. json (results.json)" + " is always written; junit/sarif land beside --report. (default: json)", + ) + verify_parser.add_argument( + "--ci", + action="store_true", + default=False, + help="CI preset: emit json,junit,sarif and use concise tracebacks (--tb=short)." + " Overrides --format. Not compatible with --interactive-auth/--headless-auth.", + ) + verify_parser.add_argument( + "--verbose", + action="store_true", + default=False, + help="Show full pytest tracebacks instead of concise error messages", + ) + verify_parser.add_argument( + "--extensions", + action="append", + default=[], + help="Additional directories containing custom test cases (repeatable)", + ) + verify_parser.add_argument( + "--test-timeout", + type=int, + default=DEFAULT_TEST_TIMEOUT_SECONDS, + help=( + "Timeout in seconds for the pytest subprocess " + f"(default: {DEFAULT_TEST_TIMEOUT_SECONDS}). " + "A full Connect run includes content deployments that each take " + "several minutes (R package restore, Python venv creation), so " + "raise this further for large suites or slow servers. For " + "per-deploy limits, set deploy_timeout under [connect] in vip.toml." + ), + ) + + verify_parser.add_argument( + "--allow-unproven", + action="store_true", + default=False, + help=( + "Exit 0 even when checks could not be verified. By default a check " + "that VIP was asked to run but could not (for example, a configured " + "product whose authentication never completed) fails the run, so an " + "unverified deployment is not reported as a passing one." + ), + ) + + # Pytest passthrough + verify_parser.add_argument( + "pytest_args", + nargs="*", + default=[], + help="Additional arguments passed to pytest (place after --)", + ) + verify_parser.set_defaults(func=run_verify) + + # vip cleanup + cleanup_parser = subparsers.add_parser( + "cleanup", + help="Delete VIP _vip_test content from Connect and quit orphaned Workbench sessions", + description=( + "Delete VIP _vip_test-tagged content from Connect, and/or quit orphaned\n" + "VIP-named Workbench sessions. At least one of --connect-url /\n" + "--workbench-url (or the corresponding vip.toml URL) must resolve.\n\n" + " vip cleanup --connect-url https://connect.example.com\n" + " vip cleanup --workbench-url https://workbench.example.com\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + cleanup_parser.add_argument( + "--connect-url", + default=None, + help="Connect server URL (falls back to vip.toml if omitted)", + ) + cleanup_parser.add_argument( + "--api-key", + default=None, + help="Connect API key (default: VIP_CONNECT_API_KEY env var)", + ) + cleanup_parser.add_argument( + "--workbench-url", + default=None, + help=( + "Workbench server URL (falls back to vip.toml if omitted). Quits orphaned " + "VIP-named sessions via the session API, escalating to a browser-driven UI " + "sweep if the API is unreachable or sessions persist. Requires " + "VIP_TEST_USERNAME/VIP_TEST_PASSWORD for non-interactive auth, or an " + "interactive browser login." + ), + ) + cleanup_tls_group = cleanup_parser.add_argument_group("TLS configuration") + cleanup_tls_group.add_argument( + "--insecure", + action="store_true", + default=False, + help=( + "Disable TLS certificate verification (equivalent to curl -k). " + "Use only in trusted environments; this silently ignores certificate errors. " + "For Playwright browser contexts, this sets ignore_https_errors=True. " + "Note: --ca-bundle is preferred when you have a custom CA certificate." + ), + ) + cleanup_tls_group.add_argument( + "--ca-bundle", + default=None, + metavar="PATH", + type=Path, + help=( + "Path to a custom CA certificate bundle (PEM) to trust. " + "Useful for self-signed or corporate CAs. " + "For Playwright, sets NODE_EXTRA_CA_CERTS before launching Chromium " + "(Chromium-level trust only; does not update the OS certificate store)." + ), + ) + cleanup_parser.set_defaults(func=run_cleanup) + + # vip install + install_parser = subparsers.add_parser( + "install", + help="Install system packages and Playwright Chromium", + description=( + "Install VIP's machine-side dependencies: Chromium runtime libraries " + "(via dnf or apt) and Playwright's Chromium browser. " + "Records what was installed in .vip-install.json so vip uninstall can " + "reverse only what this command added." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + install_parser.add_argument( + "--skip-system", + action="store_true", + default=False, + help=( + "Skip the system-package step. VIP will not record those packages in " + ".vip-install.json, so vip uninstall will not propose removing them. " + "Use this when you manage system packages yourself or don't have sudo." + ), + ) + install_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Print the plan without executing.", + ) + install_parser.set_defaults(func=run_install) + + # vip uninstall + uninstall_parser = subparsers.add_parser( + "uninstall", + help="Reverse vip install (dry-run by default; --yes to execute)", + description=( + "Reverse vip install using the per-project .vip-install.json manifest. " + "Removes the Playwright cache and manifest; prints the sudo command for " + "any system packages vip recorded so you can remove them yourself. " + "Always prints a dry-run plan; pass --yes to execute the user-space steps." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + uninstall_parser.add_argument("--yes", action="store_true", default=False) + uninstall_parser.add_argument("--force-host", action="store_true", default=False) + uninstall_parser.add_argument( + "--connect-url", + default=None, + help="Connect URL for chained vip cleanup (default: config / autodetect).", + ) + uninstall_parser.add_argument("--api-key", default=None) + # uninstall's chained cleanup only ever constructs a ConnectClient (no + # Playwright/browser path, unlike verify and cleanup's Workbench sweep), + # so its help text drops the Playwright-specific sentences verify's + # otherwise-identical help carries -- they'd promise an effect uninstall + # cannot produce. + uninstall_tls_group = uninstall_parser.add_argument_group("TLS configuration") + uninstall_tls_group.add_argument( + "--insecure", + action="store_true", + default=False, + help=( + "Disable TLS certificate verification (equivalent to curl -k). " + "Use only in trusted environments; this silently ignores certificate errors. " + "Note: --ca-bundle is preferred when you have a custom CA certificate." + ), + ) + uninstall_tls_group.add_argument( + "--ca-bundle", + default=None, + metavar="PATH", + type=Path, + help=( + "Path to a custom CA certificate bundle (PEM) to trust. " + "Useful for self-signed or corporate CAs." + ), + ) + uninstall_parser.set_defaults(func=run_uninstall) + + # vip report + report_parser = subparsers.add_parser( + "report", + help="Render the Quarto report from a results.json file", + ) + report_parser.add_argument( + "--results", + default="report/results.json", + help="Path to results.json (default: report/results.json)", + ) + report_parser.add_argument( + "--open", + action="store_true", + default=False, + help="Open the rendered report in a browser after rendering", + ) + report_parser.set_defaults(func=run_report) + + # vip status + status_parser = subparsers.add_parser( + "status", + help="Check health endpoints for each configured product", + ) + status_parser.add_argument( + "--config", + default=None, + help="Path to vip.toml (default: VIP_CONFIG env var or ./vip.toml)", + ) + status_parser.add_argument( + "--json", + action="store_true", + default=False, + help="Emit machine-readable JSON instead of human-formatted text", + ) + status_parser.set_defaults(func=run_status) + + # vip scaffold + scaffold_parser = subparsers.add_parser( + "scaffold", + help="Generate a ready-to-run custom test extension directory", + description=( + "Copy a scaffold template to a new directory, ready to customise and run\n" + "with:\n\n" + " vip verify --config vip.toml --extensions \n\n" + "Templates range from a minimal single-scenario health check to a fuller\n" + "cross-product example spanning Workbench and Connect. Run\n" + "'vip scaffold --list' to see what's available. Every template also\n" + "receives an AGENTS.md documenting VIP's fixtures, markers, and client\n" + "layers for anyone (human or AI assistant) writing the extension." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + scaffold_parser.add_argument( + "--output", + default="./custom_tests", + metavar="DIR", + help="Destination directory for the scaffolded extension (default: ./custom_tests)", + ) + scaffold_parser.add_argument( + "--template", + default=_DEFAULT_SCAFFOLD_TEMPLATE, + metavar="NAME", + help=( + "Which template to scaffold (default: %(default)s). " + "Run 'vip scaffold --list' for the available templates." + ), + ) + scaffold_parser.add_argument( + "--list", + action="store_true", + default=False, + help="List available templates and exit", + ) + scaffold_parser.add_argument( + "--force", + action="store_true", + default=False, + help="Overwrite destination if it already exists", + ) + scaffold_parser.set_defaults(func=run_scaffold) + + # Map command names to their parsers for context-appropriate help + subcommand_parsers = { + "version": version_parser, + "verify": verify_parser, + "cleanup": cleanup_parser, + "install": install_parser, + "uninstall": uninstall_parser, + "auth": auth_parser, + "report": report_parser, + "status": status_parser, + "scaffold": scaffold_parser, + } + + argv = _reorder_help_args(sys.argv[1:], set(subcommand_parsers)) + args = parser.parse_args(argv) + if not hasattr(args, "func"): + sub = subcommand_parsers.get(args.command) + if sub: + sub.print_help() + else: + parser.print_help() + sys.exit(1) + try: + args.func(args) + except VipError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(exc.exit_code) diff --git a/src/vip/cli/auth.py b/src/vip/cli/auth.py new file mode 100644 index 00000000..8e2f015b --- /dev/null +++ b/src/vip/cli/auth.py @@ -0,0 +1,29 @@ +"""``vip auth``: interactive browser auth and Connect API-key minting.""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def mint_connect_key(args: argparse.Namespace) -> None: + """Launch interactive browser auth and mint a Connect API key.""" + from vip.auth import start_interactive_auth + + session = start_interactive_auth(args.url) + + if not session.api_key: + # Kept as a direct print+exit rather than raise AuthError: this command's + # success output is JSON on stdout, so its failure output stays JSON on + # stderr too instead of the central handler's plain-text "Error: ..." -- + # a script parsing this command's failures expects {"error": "..."}. + print(json.dumps({"error": "Failed to mint API key"}), file=sys.stderr) + sys.exit(1) + + result = { + "api_key": session.api_key, + "key_name": session.key_name, + } + + print(json.dumps(result)) diff --git a/src/vip/cli/cleanup.py b/src/vip/cli/cleanup.py new file mode 100644 index 00000000..b8002c5d --- /dev/null +++ b/src/vip/cli/cleanup.py @@ -0,0 +1,260 @@ +"""``vip cleanup``: remove VIP-created Connect content and Workbench sessions.""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from vip.cli._common import _resolve_effective_ca_bundle +from vip.errors import ( + AuthError, + ConfigError, + ProductUnreachableError, +) + +if TYPE_CHECKING: + from vip.config import ProductConfig, VIPConfig + + +def _cleanup_workbench_sessions( + workbench_url: str, + _args: argparse.Namespace, + config: VIPConfig, +) -> None: + """Authenticate to Workbench and quit orphaned VIP-named sessions. + + API-first: quits via :meth:`~vip.clients.workbench.WorkbenchClient.quit_vip_sessions` + when the session API is reachable. Escalates to a browser-driven UI sweep + (:func:`vip.workbench_ui.quit_vip_sessions_via_ui`) when the API is + unreachable *or* VIP sessions remain after the API sweep — a reachable API + whose DELETE/suspend call is a silent no-op is exactly the #467 bug, so a + "no error" response is not trusted on its own. + + Authentication is cache-aware: reuses a storage state saved by a prior + ``vip verify`` run (same cache path, <4h old) when available. Uses + ``--headless-auth``'s flow when ``VIP_TEST_USERNAME``/``VIP_TEST_PASSWORD`` + are set, otherwise opens an interactive browser login. Never lets an + authentication failure crash with a bare traceback — prints an actionable + error and exits 1. + """ + from vip.auth import ( + AuthConfigError, + auth_cache_path, + authenticated_page, + start_headless_auth, + start_interactive_auth, + ) + from vip.clients.workbench import WorkbenchClient + from vip.workbench_ui import quit_vip_sessions_via_ui + + insecure = config.insecure + ca_bundle = config.ca_bundle + proxy = config.proxy + # Same helper plugin.py uses, so this finds the session a prior `vip verify` + # from this directory cached. + cache_path = auth_cache_path() + + username = config.auth.username + password = config.auth.password + + try: + if username and password: + session = start_headless_auth( + workbench_url=workbench_url, + provider=config.auth.provider, + username=username, + password=password, + idp=config.auth.idp, + cache_path=cache_path, + insecure=insecure, + ca_bundle=ca_bundle, + proxy=proxy, + ) + else: + session = start_interactive_auth( + workbench_url=workbench_url, + cache_path=cache_path, + insecure=insecure, + ca_bundle=ca_bundle, + proxy=proxy, + ) + except AuthConfigError as exc: + raise AuthError(f"could not authenticate to Workbench: {exc}") from exc + except Exception as exc: + raise AuthError( + f"could not authenticate to Workbench at {workbench_url}: {exc}\n" + "Set VIP_TEST_USERNAME and VIP_TEST_PASSWORD for non-interactive cleanup, " + "or run this command where a browser can open for an interactive login." + ) from exc + + try: + print(f"Cleaning up orphaned Workbench sessions at {workbench_url}") + cookies = session.load_cookies() + client = WorkbenchClient( + workbench_url, cookies=cookies, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy + ) + try: + # sessions_api_reachable/count_vip_sessions currently signal an + # unreachable API via their return value (False/-1), never a raise -- + # but a ProductUnreachableError is caught here too so this still + # escalates to the UI sweep instead of aborting the whole command if + # a future client change (errors-narrow-clients) starts raising it. + try: + api_reachable = client.sessions_api_reachable() + except ProductUnreachableError: + api_reachable = False + if api_reachable: + quit_count = client.quit_vip_sessions() + print(f"Quit {quit_count} VIP Workbench session(s) via the API") + try: + remaining = client.count_vip_sessions() + except ProductUnreachableError: + remaining = -1 + else: + remaining = -1 # unknown — escalate below + + # Escalate when the API is unreachable, when VIP sessions remain, or + # when the count is undeterminable (-1). Only a confirmed 0 skips the + # UI sweep, so an unparseable API response can never silently orphan + # sessions (issue #467). + if not api_reachable or remaining != 0: + print("Escalating to browser-driven session cleanup ...") + with authenticated_page( + session, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy + ) as page: + ui_count = quit_vip_sessions_via_ui(page, workbench_url) + print(f"Quit {ui_count} VIP Workbench session(s) via the UI") + finally: + client.close() + finally: + session.cleanup() + + +def _ensure_cli_logging() -> None: + """Route ``vip.*`` INFO/WARNING logs to stderr for the cleanup command. + + The session-cleanup path (WorkbenchClient + workbench_ui) emits progress + and "sessions still present" warnings via ``logging``; without a handler + those are invisible (or only WARNING via the lastResort handler). Attach a + single stderr handler to the ``vip`` logger so ``vip cleanup`` surfaces + what it did. Idempotent: only configures once. + """ + vip_logger = logging.getLogger("vip") + if not vip_logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + vip_logger.addHandler(handler) + vip_logger.setLevel(logging.INFO) + vip_logger.propagate = False + + +def _load_cleanup_config(args: argparse.Namespace) -> VIPConfig: + """Load ``vip.toml`` if present, else a default ``VIPConfig``. + + ``load_config`` warns when no config file exists, which is noise for + ``vip cleanup`` when the user passes URLs explicitly and has no + ``vip.toml``. This loads the file only when it actually exists; otherwise + it returns a default ``VIPConfig`` whose ``__post_init__`` still picks up + env-based credentials (``VIP_TEST_USERNAME``/``VIP_TEST_PASSWORD``, + ``VIP_WORKBENCH_API_KEY``, etc.). + + ``--insecure``/``--ca-bundle`` win over the corresponding ``[tls]`` value + already on the returned config, mirroring how a CLI ``--connect-url`` + wins over ``[connect] url`` elsewhere in this command -- and letting the + flags work standalone with no ``vip.toml`` at all, which is the case + issue #563 calls out as having nowhere else to put them. The merged pair + still goes through ``_resolve_effective_ca_bundle`` so ``--insecure`` and + ``[tls] insecure`` both take the same precedence over a bundle as ``verify``. + """ + from vip.config import VIPConfig, load_config + + env = os.environ.get("VIP_CONFIG") + path = Path(env) if env else Path("vip.toml") + config = load_config() if path.exists() else VIPConfig() + + config.insecure = getattr(args, "insecure", False) or config.insecure + config.ca_bundle = getattr(args, "ca_bundle", None) or config.ca_bundle + config.ca_bundle = _resolve_effective_ca_bundle(config.insecure, config.ca_bundle) + return config + + +def run_cleanup(args: argparse.Namespace) -> None: + """Delete VIP test content from Connect and quit orphaned Workbench sessions. + + Connect cleanup deletes all content tagged ``_vip_test``. Workbench + cleanup quits VIP-named sessions (see + :func:`vip.clients.workbench.is_vip_session`), escalating to a + browser-driven UI sweep when the session API is unreachable or sessions + persist despite the API reporting success. The Connect/Workbench URLs + come from ``--connect-url``/``--workbench-url`` or, if omitted, from + ``[connect] url``/``[workbench] url`` in ``vip.toml``. At least one of + the two must resolve. A scheme-less URL defaults to ``https://`` and + falls back to ``http://`` if https doesn't answer (see + ``vip.config._normalize_url`` / ``vip.auth.resolve_url_scheme``). + """ + _ensure_cli_logging() + + from vip.config import ProductConfig + + connect_arg = getattr(args, "connect_url", None) + api_key = getattr(args, "api_key", None) or os.environ.get("VIP_CONNECT_API_KEY", "") + workbench_arg = getattr(args, "workbench_url", None) + + # Load vip.toml when present to fill in any URL not passed on the CLI, and + # to supply TLS/auth settings for the Workbench path. Loaded quietly: an + # explicit `vip cleanup --connect-url ...` with no vip.toml must not emit a + # "Config file not found" warning (env-based credentials still apply). + config = _load_cleanup_config(args) + + # A CLI flag wins over vip.toml. Wrapping the CLI arg in ProductConfig + # routes it through the same _normalize_url a bare hostname gets from + # every other entry point (vip verify, vip status), so ConnectClient + # never receives a scheme-less URL -- httpx requires an absolute one -- + # and the probe-and-fallback treatment below still applies. + # config.connect/config.workbench are already normalized ProductConfig + # instances -- every ProductConfig runs _normalize_url in its own + # __post_init__ regardless of how it was constructed, including the bare + # VIPConfig() that _load_cleanup_config() returns when no vip.toml exists + # -- so the vip.toml path needs no wrapping here. + connect_pc: ProductConfig = ProductConfig(url=connect_arg) if connect_arg else config.connect + workbench_pc: ProductConfig = ( + ProductConfig(url=workbench_arg) if workbench_arg else config.workbench + ) + + if not connect_pc.url and not workbench_pc.url: + raise ConfigError( + "no Connect or Workbench URL found. Pass --connect-url / " + "--workbench-url, or set [connect] url / [workbench] url in vip.toml." + ) + + if connect_pc.url: + from vip.auth import resolve_url_scheme + from vip.clients.connect import ConnectClient + + connect_url = resolve_url_scheme( + connect_pc, insecure=config.insecure, ca_bundle=config.ca_bundle, proxy=config.proxy + ) + print(f"Cleaning up VIP test content on Connect at {connect_url}") + with ConnectClient( + connect_url, + api_key, + insecure=config.insecure, + ca_bundle=config.ca_bundle, + proxy=config.proxy, + ) as client: + deleted = client.cleanup_vip_content() + print(f"Deleted {deleted} VIP test content item(s)") + + if workbench_pc.url: + from vip.auth import resolve_url_scheme + + workbench_url = resolve_url_scheme( + workbench_pc, insecure=config.insecure, ca_bundle=config.ca_bundle, proxy=config.proxy + ) + _cleanup_workbench_sessions(workbench_url, args, config) + + print("Cleanup completed successfully") diff --git a/src/vip/cli/install.py b/src/vip/cli/install.py new file mode 100644 index 00000000..3bed7fcd --- /dev/null +++ b/src/vip/cli/install.py @@ -0,0 +1,216 @@ +"""``vip install`` and ``vip uninstall``.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from vip.cli._common import _resolve_effective_ca_bundle +from vip.errors import InstallError + +if TYPE_CHECKING: + from vip.config import ProductConfig + + +def run_install(args: argparse.Namespace) -> None: + """Provision system packages and Playwright Chromium for VIP local mode.""" + from datetime import datetime, timezone + + from vip.install import platform as plat + from vip.install.manifest import ( + SCHEMA_VERSION, + Manifest, + current_host, + default_path, + load, + ) + from vip.install.packages import PackageQueryError, installed_dpkg, installed_rpm + from vip.install.plan import build_install_plan + from vip.install.playwright import PlaywrightInstallError, chromium_installed, default_cache_dir + from vip.install.runner import execute_install_plan, format_install_plan + + info = plat.detect() + manifest_path = default_path() + manifest = load(manifest_path) + + cache_dir = default_cache_dir() + + try: + plan = build_install_plan( + platform_info=info, + manifest=manifest, + rpm_installed=installed_rpm, + dpkg_installed=installed_dpkg, + chromium_present=chromium_installed(cache_dir), + playwright_cache_dir=cache_dir, + skip_system=bool(getattr(args, "skip_system", False)), + ) + + if getattr(args, "dry_run", False): + print(format_install_plan(plan), end="") + return + + if manifest is None: + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + from vip import __version__ as vip_version + + manifest = Manifest( + version=SCHEMA_VERSION, + vip_version=vip_version, + created_at=now, + updated_at=now, + host=current_host(), + platform=info.family, + platform_id=info.id, + platform_version=info.version, + ) + + rc = execute_install_plan( + plan, + manifest=manifest, + manifest_path=manifest_path, + # Only Debian resolves a requested name to a different installed one. + resolve_installed=installed_dpkg if info.family == "debian-family" else None, + ) + except (PlaywrightInstallError, PackageQueryError) as exc: + raise InstallError(str(exc)) from exc + sys.exit(rc) + + +def run_uninstall(args: argparse.Namespace) -> None: + """Reverse `vip install` using the manifest.""" + from vip.install.manifest import ( + ManifestError, + current_host, + default_path, + load, + ) + from vip.install.plan import build_uninstall_plan + from vip.install.runner import execute_uninstall_plan + + manifest_path = default_path() + try: + manifest = load(manifest_path) + except ManifestError as exc: + raise InstallError(str(exc)) from exc + + if manifest is None: + raise InstallError( + f"No {manifest_path.name} found. Nothing to uninstall, or vip was " + "installed by a different mechanism." + ) + + if manifest.host != current_host() and not getattr(args, "force_host", False): + raise InstallError( + f"manifest host {manifest.host!r} does not match current host " + f"{current_host()!r}. Pass --force-host to override." + ) + + # Load vip.toml (when present) regardless of whether --connect-url was + # passed, so its [tls]/[proxy] settings apply to a --connect-url-only + # invocation too -- otherwise a deployment behind a self-signed cert with + # [tls] insecure = true in vip.toml has no route to that setting when the + # Connect URL itself comes from the CLI. Loading stays silent when + # vip.toml simply doesn't exist (mirrors _load_cleanup_config's guard); + # test_run_uninstall_silent_when_vip_toml_missing pins the no-config case. + # cfg carries the TLS settings (insecure/ca_bundle) for the + # probe-and-fallback below whether the Connect URL came from vip.toml or + # the CLI; with neither vip.toml present nor --insecure/--ca-bundle + # passed, it probes with defaults (verify=True). + from vip.config import ProductConfig + + connect_arg = getattr(args, "connect_url", None) + + cfg = None + env = os.environ.get("VIP_CONFIG") + config_path = Path(env) if env else Path("vip.toml") + if config_path.exists(): + if sys.version_info >= (3, 11): + import tomllib as _tomllib + else: + import tomli as _tomllib + + try: + from vip.config import load_config + + cfg = load_config() + except (_tomllib.TOMLDecodeError, ValueError) as exc: + print( + f"warning: failed to load vip.toml for chained cleanup: {exc}; " + "continuing without vip.toml-derived settings", + file=sys.stderr, + ) + + # A CLI --connect-url wins over vip.toml's [connect] url; wrapping it in + # ProductConfig routes a scheme-less --connect-url through the same + # _normalize_url every other entry point uses, so ConnectClient never + # sees an unnormalized URL. + if connect_arg: + connect_pc: ProductConfig | None = ProductConfig(url=connect_arg) + elif cfg and cfg.connect and cfg.connect.url: + connect_pc = cfg.connect + else: + connect_pc = None + + # --insecure/--ca-bundle win over the corresponding vip.toml [tls] value, + # same precedence _load_cleanup_config gives vip cleanup's equivalent flags. + insecure = getattr(args, "insecure", False) or (cfg.insecure if cfg else False) + ca_bundle = getattr(args, "ca_bundle", None) or (cfg.ca_bundle if cfg else None) + ca_bundle = _resolve_effective_ca_bundle(insecure, ca_bundle) + proxy = cfg.proxy if cfg else None + yes = bool(getattr(args, "yes", False)) + + # Resolve now, before the plan is built (and therefore before it's + # printed) -- but only when --yes was passed. execute_uninstall_plan + # prints format_uninstall_plan(plan) unconditionally, *before* its own + # --yes gate; resolving lazily inside cleanup_callable (the previous + # approach) meant the printed plan could announce + # "run vip cleanup against https://host" and then actually clean up + # against "http://host" once resolve_url_scheme downgraded it -- exactly + # the kind of scheme mismatch this whole feature exists to prevent. + # Gating on --yes preserves the dry-run guarantee: a plan preview must + # never probe the network. + if yes and connect_pc is not None: + from vip.auth import resolve_url_scheme + + resolve_url_scheme(connect_pc, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy) + + plan = build_uninstall_plan( + manifest=manifest, + connect_url=connect_pc.url if connect_pc else None, + ) + + cleanup_callable = None + if connect_pc is not None: + api_key = getattr(args, "api_key", None) or os.environ.get("VIP_CONNECT_API_KEY", "") + + def cleanup_callable(_url: str) -> None: + from vip.auth import resolve_url_scheme + from vip.clients.connect import ConnectClient + + # connect_pc.url was already resolved above -- this callable only + # ever runs when execute_uninstall_plan actually executes + # (--yes was passed), which is the same condition that already + # triggered the resolve above, so the printed plan and the URL + # used here are guaranteed to match. resolve_url_scheme is + # idempotent (it resets url_scheme_inferred once resolved), so + # calling it again here is a plain attribute read -- kept as a + # belt-and-suspenders safety net rather than trusted by omission. + resolved = resolve_url_scheme( + connect_pc, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy + ) + with ConnectClient( + resolved, api_key, insecure=insecure, ca_bundle=ca_bundle, proxy=proxy + ) as client: + client.cleanup_vip_content() + + rc = execute_uninstall_plan( + plan, + manifest_path=manifest_path, + yes=yes, + cleanup_callable=cleanup_callable, + ) + sys.exit(rc) diff --git a/src/vip/cli/report.py b/src/vip/cli/report.py new file mode 100644 index 00000000..872c3efe --- /dev/null +++ b/src/vip/cli/report.py @@ -0,0 +1,223 @@ +"""``vip report``: render the Quarto report from a results file.""" + +from __future__ import annotations + +import argparse +import contextlib +import os +import subprocess +import sys +from pathlib import Path + +from vip.errors import ReportError + +# Quarto report template files copied into the working report/ directory. +# Keep in sync with the force-include block in pyproject.toml. The fonts are +# part of the template set: vip-report.qmd resolves them via a relative +# `font-paths: fonts`, so a working report directory without them falls back +# to whatever faces the host has and renders a different-looking PDF. +_REPORT_TEMPLATE_FILES = ( + "index.qmd", + "details.qmd", + "vip-report.qmd", + "_quarto.yml", + "styles.css", + "fonts/SourceSans3-Regular.otf", + "fonts/SourceSans3-It.otf", + "fonts/SourceSans3-Semibold.otf", + "fonts/SourceSans3-Bold.otf", + "fonts/SourceCodePro-Regular.otf", + "fonts/LICENSE-SourceSans3.md", + "fonts/LICENSE-SourceCodePro.md", +) + + +def _has_all_report_templates(directory: Path) -> bool: + """Whether ``directory`` contains every required Quarto template file.""" + return all((directory / name).is_file() for name in _REPORT_TEMPLATE_FILES) + + +def _copy_report_templates(src: Path, report_dir: Path) -> list[str]: + """Copy template files from ``src``, returning names whose content changed. + + Files already identical in ``report_dir`` are left untouched, and only + pre-existing files that were overwritten with different content are + reported (fresh copies into an empty directory are not). + """ + import shutil + + replaced = [] + for name in _REPORT_TEMPLATE_FILES: + candidate = src / name + dest = report_dir / name + if not candidate.is_file(): + continue + if dest.is_file(): + if dest.read_bytes() == candidate.read_bytes(): + continue + replaced.append(name) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(candidate, dest) + return replaced + + +def _ensure_report_templates(report_dir: Path) -> bool: + """Make sure the Quarto templates exist in ``report_dir``. + + Prefers the copy bundled in the installed wheel (``vip/_report``), + refreshing ``report_dir`` from it on every run so an upgraded VIP renders + its current templates. Falls back to the repo's top-level ``report/`` so + in-repo usage and selftests work without building a wheel. Returns ``True`` + only when *all* of ``_REPORT_TEMPLATE_FILES`` are present in ``report_dir``, + so a partial source (e.g. a template missing from one location) is topped + up from the other rather than silently rendering a degraded report. + + Identical files are not rewritten, and a notice lists any existing files + that the refresh did overwrite, so local template customizations never + disappear silently. + """ + import importlib.resources + + replaced: list[str] = [] + + # Bundled wheel copy: refresh templates into the working directory. Only + # materializing the resource is guarded (OSError covers as_file() failures + # on zip-imported packages before Python 3.12); a failure while copying + # into report_dir must propagate, or a stale set already present there + # would be rendered as if it were current. + with contextlib.ExitStack() as stack: + try: + bundled = importlib.resources.files("vip") / "_report" + p = stack.enter_context(importlib.resources.as_file(bundled)) + except (TypeError, OSError, ModuleNotFoundError): + p = None + if p is not None and _has_all_report_templates(p): + replaced += _copy_report_templates(p, report_dir) + + # Source checkout: four levels up from src/vip/cli/report.py → repo root/report. + if not _has_all_report_templates(report_dir): + repo_report = Path(__file__).parent.parent.parent.parent / "report" + if _has_all_report_templates(repo_report) and repo_report.resolve() != report_dir.resolve(): + replaced += _copy_report_templates(repo_report, report_dir) + + if replaced: + print( + f"Refreshed report templates in {report_dir}: {', '.join(replaced)}", + file=sys.stderr, + ) + + # True only if the working directory now has the complete set (from a + # bundled/repo copy above, or from a prior run's copy already present). + return _has_all_report_templates(report_dir) + + +def _resolve_report_dir() -> Path: + """Return the working report directory for the current invocation. + + The report directory is ``./report`` relative to the invocation, but a + plain ``Path("report")`` also resolves that way when the caller is already + standing *inside* a report directory. Treat a working directory already + named ``report`` as the report directory itself, instead of descending + into it: otherwise ``vip report --results results.json`` run from within + ``report/`` creates a nested ``report/report/``, copies the templates + into it, and renders there, leaving a stray tree behind (papered over by + a ``report/report/`` .gitignore entry) and hiding the rendered output one + level deeper than the caller expected. + """ + cwd = Path.cwd() + if cwd.name == "report": + return Path() + return Path("report") + + +def run_report(args: argparse.Namespace) -> None: + """Render the Quarto report from a results.json file.""" + import shutil + import webbrowser + + report_dir = _resolve_report_dir() + report_dir.mkdir(parents=True, exist_ok=True) + + results_src = Path(args.results) + results_dest = report_dir / "results.json" + + if results_src.resolve() != results_dest.resolve(): + if not results_src.exists(): + raise ReportError(f"results file not found: {results_src}") + shutil.copy2(results_src, results_dest) + elif not results_dest.exists(): + raise ReportError( + f"no results found at {results_dest}. Run 'vip verify' first, or pass --results PATH." + ) + + if not _ensure_report_templates(report_dir): + raise ReportError( + "could not locate the VIP report templates. " + "Reinstall posit-vip so the bundled report is available." + ) + + # Pin Quarto's Jupyter kernel to the interpreter running `vip`. Quarto + # otherwise discovers Python via the ambient VIRTUAL_ENV (set by `uv run` + # or an activated venv) or falls back to /usr/bin/python3 -- neither of + # which is guaranteed to have posit-vip (for the report/*.qmd cells that + # import vip.gherkin / vip.reporting) or the Jupyter stack. sys.executable + # is the vip install itself, which always has both. See issue #554. + env = {**os.environ, "QUARTO_PYTHON": sys.executable} + + # The HTML pages and the PDF render as separate quarto invocations on + # purpose. One combined `quarto render` ties their fates together: on a + # Quarto too old to know Typst (pre-1.4), the PDF document fails the + # whole render *after* the HTML pages already rendered, and `vip report` + # would exit nonzero without handing over the HTML report it just + # produced. HTML is the primary artifact, so only its failure is fatal; + # the PDF degrades to a warning. All three documents stay in + # _quarto.yml's render list because a single-document render only lands + # in _output/ for listed files. + for page in ("index.qmd", "details.qmd"): + returncode = _quarto_render(page, report_dir, env) + if returncode != 0: + sys.exit(returncode) + + output = report_dir / "_output" / "index.html" + if not output.exists(): + raise ReportError( + "no report was produced. Ensure Quarto is installed " + "(https://quarto.org/docs/get-started/) and re-run." + ) + + print(f"Report generated: {output}") + + # The PDF is the copy customers archive, so a missing one warns loudly — + # but never fails the command, and never blocks the HTML hand-off above. + pdf = report_dir / "_output" / "vip-report.pdf" + if _quarto_render("vip-report.qmd", report_dir, env) == 0 and pdf.exists(): + print(f"PDF generated: {pdf}") + else: + print( + f"Warning: the HTML report rendered but {pdf} did not. " + "Quarto compiles it with Typst, which ships with Quarto 1.4 and " + "later — check `quarto --version` and upgrade if it is older.", + file=sys.stderr, + ) + + if args.open: + webbrowser.open(output.resolve().as_uri()) + + +def _quarto_render(document: str, report_dir: Path, env: dict[str, str]) -> int: + """Render one listed document of the report project, returning quarto's exit code. + + A missing quarto binary is fatal here rather than at the caller: it means + no document can render at all, and the message is the same wherever it + surfaces. + """ + try: + result = subprocess.run( + ["quarto", "render", document], cwd=str(report_dir), env=env, check=False + ) + except FileNotFoundError: + raise ReportError( + "quarto was not found on PATH. Install Quarto " + "(https://quarto.org/docs/get-started/) and re-run." + ) from None + return result.returncode diff --git a/src/vip/cli/scaffold.py b/src/vip/cli/scaffold.py new file mode 100644 index 00000000..c84e80a7 --- /dev/null +++ b/src/vip/cli/scaffold.py @@ -0,0 +1,146 @@ +"""``vip scaffold``: copy an example extension template to a directory.""" + +from __future__ import annotations + +import argparse +import contextlib +import sys +from pathlib import Path + +from vip.errors import ( + ConfigError, + VipError, +) + +# Registry of scaffold templates: name -> (examples/ source directory, one-line +# description). "cross-product" is the default so existing `vip scaffold +# --output DIR` invocations (predating --template) are unchanged. +_SCAFFOLD_TEMPLATES: dict[str, tuple[str, str]] = { + "minimal": ( + "custom_tests", + "Single-scenario HTTP health check against your configured product (start here)", + ), + "cross-product": ( + "cross_product_validation", + "R/Python runtime versions and package installability across Connect and Workbench", + ), +} +_DEFAULT_SCAFFOLD_TEMPLATE = "cross-product" + + +def _resolve_scaffold_source(dirname: str, stack: contextlib.ExitStack) -> Path | None: + """Locate the source directory for a scaffold template, or None if missing. + + Prefer the bundled copy inside the installed wheel (_scaffold/ is embedded + via [tool.hatch.build.targets.wheel.force-include]). Fall back to the + repo's top-level examples/ directory so in-repo usage and selftests work + without building a wheel first. + + The bundled path is materialized through ``importlib.resources.as_file``, + whose context must stay open for as long as anyone reads from the returned + path: for a zip-imported package as_file() extracts into a temporary + directory that is deleted when the context exits, so closing it here would + hand back a path that no longer exists by the time the caller copies from + it. It is entered on the caller's ExitStack instead, which run_scaffold + holds open across the copy -- the same pattern _ensure_report_templates + uses for the bundled Quarto templates. + """ + import importlib.resources + + try: + scaffold_pkg = importlib.resources.files("vip") / "_scaffold" / dirname + # files() returns a Traversable; we need a real Path for shutil.copytree. + p = stack.enter_context(importlib.resources.as_file(scaffold_pkg)) + if p.is_dir(): + return p + except (TypeError, OSError, ModuleNotFoundError): + pass + + # Source checkout: four levels up from src/vip/cli/scaffold.py → repo root. + repo_root = Path(__file__).parent.parent.parent.parent + candidate = repo_root / "examples" / dirname + if candidate.is_dir(): + return candidate + return None + + +def _scaffold_next_steps(template: str, dest: Path) -> str: + """Per-template "Next steps" text printed after a successful scaffold.""" + if template == "cross-product": + return ( + f"\nNext steps:\n" + f" 1. Edit {dest / 'conftest.py'} to set your package names and versions.\n" + f" 2. Add a [runtimes] block to vip.toml:\n" + f" [runtimes]\n" + f' r_versions = ["4.4.0"]\n' + f' python_versions = ["3.11.0"]\n' + f" 3. Run the extension:\n" + f" vip verify --config vip.toml --extensions {dest}\n" + f"\nSee {dest / 'README.md'} for full customization instructions." + ) + return ( + f"\nNext steps:\n" + f" 1. Edit {dest / 'test_custom_check.feature'} and" + f" {dest / 'test_custom_check.py'} to check your own endpoint.\n" + f" 2. Run the extension:\n" + f" vip verify --config vip.toml --extensions {dest}\n" + f"\nSee {dest / 'README.md'} for full customization instructions." + ) + + +def run_scaffold(args: argparse.Namespace) -> None: + """Copy a scaffold template to a user-specified directory, or list templates.""" + import shutil + + if getattr(args, "list", False): + print("Available templates:\n") + for name, (_dirname, description) in _SCAFFOLD_TEMPLATES.items(): + print(f" {name} - {description}") + return + + template = getattr(args, "template", None) or _DEFAULT_SCAFFOLD_TEMPLATE + if template not in _SCAFFOLD_TEMPLATES: + valid = ", ".join(sorted(_SCAFFOLD_TEMPLATES)) + raise ConfigError(f"unknown template {template!r}. Valid templates: {valid}") + dirname, _description = _SCAFFOLD_TEMPLATES[template] + + # One ExitStack spans every read of a bundled resource: the paths handed + # back by _resolve_scaffold_source are only guaranteed to exist while it is + # open (see that function's docstring). + with contextlib.ExitStack() as stack: + src = _resolve_scaffold_source(dirname, stack) + if src is None: + raise VipError( + f"could not locate examples/{dirname}/. " + "Ensure VIP is installed from source or as a wheel built with examples." + ) + + dest = Path(args.output) + if dest.exists() and not args.force: + raise ConfigError(f"destination already exists: {dest}\nPass --force to overwrite.") + + if dest.exists(): + if dest.is_dir() and not dest.is_symlink(): + shutil.rmtree(dest) + else: + dest.unlink() + + shutil.copytree(src, dest) + + # AGENTS.md is shared across every template (single source of truth), so + # it's copied in separately rather than living inside each template dir. + shared_agents_md = _resolve_scaffold_source("_shared", stack) + if shared_agents_md is not None: + shutil.copyfile(shared_agents_md / "AGENTS.md", dest / "AGENTS.md") + else: + # Don't fail the scaffold over it -- the tests themselves are still + # usable -- but say so, because a silently missing AGENTS.md means a + # packaging regression that is otherwise invisible to the user. + print( + "Warning: could not locate examples/_shared/AGENTS.md; " + "the scaffolded directory has no extension-authoring guide.", + file=sys.stderr, + ) + + print(f"Scaffolded extension to: {dest}") + print(_scaffold_next_steps(template, dest)) diff --git a/src/vip/cli/status.py b/src/vip/cli/status.py new file mode 100644 index 00000000..dad174a1 --- /dev/null +++ b/src/vip/cli/status.py @@ -0,0 +1,112 @@ +"""``vip status``: product health checks.""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vip.config import VIPConfig + + +def _collect_status(config: VIPConfig) -> dict: + """Run health checks and return structured status data. + + Returns a dict with the schema:: + + { + "products": { + "connect": {"configured": bool, "state": "ok"|"fail"|"skip", ...}, + "workbench": {...}, + "package_manager": {...}, + }, + "outcome": "ok" | "fail", + "exit_status": 0 | 1, + } + + No printing or sys.exit side effects; callers handle rendering. + """ + from vip.clients.connect import ConnectClient + from vip.clients.packagemanager import PackageManagerClient + from vip.clients.workbench import WorkbenchClient + + checks = [ + ("connect", config.connect), + ("workbench", config.workbench), + ("package_manager", config.package_manager), + ] + + products: dict[str, dict] = {} + for name, pc in checks: + if not pc.is_configured: + products[name] = {"configured": False, "state": "skip", "detail": "not configured"} + continue + try: + from vip.auth import resolve_url_scheme + + resolve_url_scheme( + pc, insecure=config.insecure, ca_bundle=config.ca_bundle, proxy=config.proxy + ) + if name == "connect": + client: ConnectClient | WorkbenchClient | PackageManagerClient = ConnectClient( + pc.url, + pc.api_key, # type: ignore[attr-defined] + proxy=config.proxy, + ) + elif name == "workbench": + client = WorkbenchClient( + pc.url, + pc.api_key, # type: ignore[attr-defined] + proxy=config.proxy, + ) + else: + client = PackageManagerClient( + pc.url, + pc.token, # type: ignore[attr-defined] + proxy=config.proxy, + ) + http_status = client.health() + state = "ok" if http_status < 400 else "fail" + products[name] = { + "configured": True, + "url": pc.url, + "http_status": http_status, + "state": state, + } + except Exception as e: # noqa: BLE001 + products[name] = { + "configured": True, + "url": pc.url, + "state": "fail", + "detail": str(e), + } + + all_ok = all(p["state"] in ("ok", "skip") for p in products.values()) + outcome = "ok" if all_ok else "fail" + exit_status = 0 if all_ok else 1 + return {"products": products, "outcome": outcome, "exit_status": exit_status} + + +def run_status(args: argparse.Namespace) -> None: + """Run preflight health checks against each configured product.""" + from vip.config import load_config + + config = load_config(args.config) + data = _collect_status(config) + + if getattr(args, "json", False): + print(json.dumps(data)) + else: + for name, product in data["products"].items(): + state = product["state"] + if state == "skip": + detail = product.get("detail", "not configured") + elif "http_status" in product: + detail = f"HTTP {product['http_status']}" + else: + detail = product.get("detail", "") + print(f" {state.upper():4s} {name:20s} {detail}") + + sys.exit(data["exit_status"]) diff --git a/src/vip/cli/verify.py b/src/vip/cli/verify.py new file mode 100644 index 00000000..a409fa71 --- /dev/null +++ b/src/vip/cli/verify.py @@ -0,0 +1,585 @@ +"""``vip verify``: run the VIP test suite against URL args or a vip.toml config.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +from vip.cli._common import _resolve_effective_ca_bundle +from vip.errors import ( + ConfigError, + VipError, +) +from vip.reporting import VALID_FORMATS +from vip.timeouts import scaled + +# Default for ``vip verify --test-timeout``. Generous enough for a full +# Connect suite with several content deployments (each can take 3-5 minutes +# for R package restore or Python venv creation). +DEFAULT_TEST_TIMEOUT_SECONDS = int(scaled(3600)) + +# Valid test categories. Maps every accepted spelling (hyphenated and +# underscored) to the internal pytest marker name. +VALID_CATEGORIES: dict[str, str] = { + "prerequisites": "prerequisites", + "connect": "connect", + "workbench": "workbench", + "package-manager": "package_manager", + "package_manager": "package_manager", + "cross-product": "cross_product", + "cross_product": "cross_product", + "performance": "performance", + "security": "security", + "config-hygiene": "config_hygiene", + "config_hygiene": "config_hygiene", +} + +# Categories that are excluded from the default ``vip verify`` run and only +# executed when the user explicitly opts in, either via ``--categories`` or +# a dedicated opt-in flag (for example ``--performance-tests``). These tests +# check VIP's own configuration rather than the Posit deployment. +_OPT_IN_CATEGORIES = frozenset({"config_hygiene", "performance"}) + +# Marker expression keywords that are not category names. +_MARKER_KEYWORDS = {"and", "or", "not"} + +# Regex matching a complete identifier token (may contain hyphens or +# underscores). Negative lookbehind/lookahead ensure we don't match a +# substring inside a larger token like ``_connect`` or ``1connect``. +_IDENT_RE = re.compile(r"(? str: + """Return a comma-separated string of preferred (hyphenated) category names.""" + seen: dict[str, str] = {} + for k, v in VALID_CATEGORIES.items(): + if v not in seen or "-" in k: + seen[v] = k + return ", ".join(sorted(seen.values())) + + +def _default_marker_expr(extra_keep: frozenset[str] = frozenset()) -> str: + """Marker expression applied when the user doesn't pass ``--categories``. + + Excludes every opt-in category so that ``vip verify`` runs only the + product-verification tests by default. Pass ``extra_keep`` to re-include + specific opt-in categories (e.g. ``frozenset({"performance"})`` when + ``--performance-tests`` is set). + """ + excluded = _OPT_IN_CATEGORIES - extra_keep + return " and ".join(f"not {name}" for name in sorted(excluded)) + + +def _extra_keep_from_args(args: argparse.Namespace) -> frozenset[str]: + """Return the set of opt-in categories to re-include based on CLI flags. + + For example, ``--performance-tests`` adds ``"performance"`` to the set so + that :func:`_default_marker_expr` keeps it in the expression. + """ + extra: set[str] = set() + if getattr(args, "performance_tests", False): + extra.add("performance") + return frozenset(extra) + + +def _normalize_categories(expr: str) -> str: + """Validate and normalize a ``--categories`` expression. + + Accepts user-facing hyphenated names (e.g. ``package-manager``) as well + as underscore names (``package_manager``) and translates both to the + internal pytest marker names. Raises :class:`SystemExit` if any + identifier token is not a recognised category or keyword. + """ + + def _replace(match: re.Match[str]) -> str: + word = match.group(0) + if word in _MARKER_KEYWORDS: + return word + if word in VALID_CATEGORIES: + return VALID_CATEGORIES[word] + raise ConfigError( + f"unknown category '{word}'. Valid categories: {_valid_categories_message()}" + ) + + result = _IDENT_RE.sub(_replace, expr) + # After substitution, only whitespace and parentheses should remain + # between identifiers. Any leftover characters (digits, underscores + # from malformed tokens like ``_connect`` or ``1connect``) are invalid. + leftover = _IDENT_RE.sub("", result).replace("(", "").replace(")", "").strip() + if leftover: + raise ConfigError( + f"invalid characters in category expression: '{expr}'. " + f"Valid categories: {_valid_categories_message()}" + ) + return result + + +# --------------------------------------------------------------------------- +# Config generation from CLI URL args +# --------------------------------------------------------------------------- + + +def _print_skip_notes(config_path: str | None) -> None: + """Print a note for each product that is not configured.""" + from vip.config import load_config + + try: + cfg = load_config(config_path) + except ValueError as exc: + raise ConfigError(str(exc)) from exc + products = [ + ("Connect", cfg.connect), + ("Workbench", cfg.workbench), + ("Package Manager", cfg.package_manager), + ] + for name, pc in products: + if not pc.is_configured: + reason = "disabled" if not pc.enabled else "no URL given" + print(f"Note: {name} {reason} — {name} tests will not be collected.", flush=True) + + +def _check_credentials( + config_path: str | None, + *, + interactive_auth: bool, + categories: str | None, +) -> None: + """Exit early when products are configured but credentials are missing. + + When *categories* is provided, only check products whose marker appears + in the expression. Without categories all configured products are checked. + """ + from vip.config import load_config + + try: + cfg = load_config(config_path) + except ValueError as exc: + raise ConfigError(str(exc)) from exc + if interactive_auth: + return + + has_creds = bool(cfg.auth.username and cfg.auth.password) + needs_creds: list[str] = [] + + # When a category filter is active, only enforce credential checks for + # products that are actually selected. We tokenize the expression and + # check that the marker appears as a positive term (not negated by "not"). + def _category_selected(marker: str) -> bool: + if categories is None: + return True + tokens = re.findall(r"\w+", categories) + for i, tok in enumerate(tokens): + if tok == marker and (i == 0 or tokens[i - 1] != "not"): + return True + return False + + # Connect tests include UI login and user-management scenarios that use + # VIP_TEST_USERNAME/VIP_TEST_PASSWORD even when VIP_CONNECT_API_KEY is set, + # so require credentials whenever Connect is selected (users can pass + # --no-auth to deselect Connect tests entirely). + if cfg.connect.is_configured and not has_creds and _category_selected("connect"): + needs_creds.append("Connect") + if cfg.workbench.is_configured and not has_creds and _category_selected("workbench"): + needs_creds.append("Workbench") + + if needs_creds: + products = " and ".join(needs_creds) + raise ConfigError( + f"{products} tests selected but no credentials provided.\n" + "Set VIP_TEST_USERNAME and VIP_TEST_PASSWORD (optionally with --headless-auth),\n" + "or use --interactive-auth, or --no-auth to skip tests that require " + "authentication." + ) + + +def _config_idp(config_path: str | None) -> str: + """Return the normalized ``[auth] idp`` from the resolved config. + + This is the IdP the run will actually use: the ``--idp`` flag is folded + into the generated config on URL-driven runs and is not otherwise forwarded + to pytest, so the config file is the source of truth. Returns "" when there + is no config or it can't be read (pytest surfaces config errors later). + Normalized (stripped, lowercased) to match ``idp.get_idp_strategy``. + """ + if not config_path: + return "" + from vip.config import load_config + + try: + return (load_config(config_path).auth.idp or "").strip().lower() + except ValueError: + return "" + + +# Pytest options that consume the next argument as a directory path. +# We skip these values so they aren't mistaken for positional test targets. +_CONSUMES_DIR_VALUE = frozenset({"--rootdir", "--confcutdir", "--basetemp"}) + + +def _has_explicit_test_targets(pytest_args: list[str]) -> bool: + """Return True if *pytest_args* contains what looks like test paths or nodeids. + + This avoids injecting the default ``vip_tests`` path when the user already + passed explicit targets after ``--`` (e.g. ``vip verify -- tests/foo.py``). + Directory values consumed by known pytest options (``--rootdir``, etc.) are + excluded so they don't trigger false-positive detection. + """ + skip_next = False + for arg in pytest_args: + if skip_next: + skip_next = False + continue + if arg in _CONSUMES_DIR_VALUE: + skip_next = True + continue + if arg.startswith("-"): + continue + if "::" in arg or arg.endswith(".py") or Path(arg).is_dir(): + return True + return False + + +def _user_set_xdist(pytest_args: list[str]) -> tuple[bool, bool]: + """Return (user_set_numprocesses, user_set_dist) from user-supplied pytest args. + + Lets `vip verify` supply default ``-n``/``--dist`` without overriding an + explicit user choice (including ``-p no:xdist``, which disables xdist + entirely and so counts as the user managing both). + """ + set_n = False + set_dist = False + for a in pytest_args: + if a in ("-n", "--numprocesses") or a.startswith(("-n", "--numprocesses=")): + set_n = True + if a.startswith(("--dist", "no:xdist")) or a == "no:xdist": + set_dist = True + if "no:xdist" in pytest_args or any(x.startswith("no:xdist") for x in pytest_args): + set_n = set_dist = True + return set_n, set_dist + + +def _generate_temp_config(args: argparse.Namespace) -> str: + """Write a minimal vip.toml from CLI URL arguments. Returns temp file path.""" + lines = ["[general]", 'deployment_name = "Posit Team"', ""] + + if args.connect_url: + lines.extend(["[connect]", f"url = {json.dumps(args.connect_url)}"]) + connect_version = getattr(args, "connect_version", None) + if connect_version: + lines.append(f"version = {json.dumps(connect_version)}") + lines.append("") + else: + lines.extend(["[connect]", "enabled = false", ""]) + + if args.workbench_url: + lines.extend(["[workbench]", f"url = {json.dumps(args.workbench_url)}"]) + workbench_version = getattr(args, "workbench_version", None) + if workbench_version: + lines.append(f"version = {json.dumps(workbench_version)}") + lines.append("") + else: + lines.extend(["[workbench]", "enabled = false", ""]) + + if args.package_manager_url: + lines.extend(["[package_manager]", f"url = {json.dumps(args.package_manager_url)}"]) + package_manager_version = getattr(args, "package_manager_version", None) + if package_manager_version: + lines.append(f"version = {json.dumps(package_manager_version)}") + lines.append("") + else: + lines.extend(["[package_manager]", "enabled = false", ""]) + + idp = getattr(args, "idp", None) + inherited_provider: str | None = None + + # Inherit from an existing vip.toml so ``vip verify --workbench-url ... + # --headless-auth`` can pick up the [auth] section the user already + # configured. Done best-effort: a malformed vip.toml should not break a + # URL-driven command that doesn't depend on it. + env = os.environ.get("VIP_CONFIG") + default_path = Path(env) if env else Path("vip.toml") + if default_path.is_file(): + from vip.config import load_config + + try: + existing = load_config(default_path) + except Exception: # noqa: BLE001 + existing = None + if existing is not None: + if not idp and existing.auth.idp: + idp = existing.auth.idp + if existing.auth.provider and existing.auth.provider != "password": + inherited_provider = existing.auth.provider + + # Resolve the provider: + # - An explicit --provider always wins, overriding both --idp's implied + # "oidc" and anything inherited from vip.toml. + # - Otherwise, with --idp set, the user wants IdP-based auth. Keep an + # inherited IdP-class value (saml/oauth2) so specific declarations + # survive; but ignore inherited non-IdP providers (ldap) that would + # contradict the CLI intent — vip.auth's flow selection keys off + # provider, not idp. + # - Without --provider or --idp, just honour whatever vip.toml declared. + explicit_provider = getattr(args, "provider", None) + if explicit_provider: + auth_provider: str | None = explicit_provider + elif idp: + auth_provider = inherited_provider if inherited_provider in _IDP_PROVIDERS else "oidc" + else: + auth_provider = inherited_provider + + if auth_provider or idp: + lines.append("[auth]") + if auth_provider: + lines.append(f'provider = "{auth_provider}"') + if idp: + lines.append(f'idp = "{idp}"') + lines.append("") + + insecure = getattr(args, "insecure", False) + ca_bundle = getattr(args, "ca_bundle", None) + effective_ca_bundle = _resolve_effective_ca_bundle(insecure, ca_bundle) + if insecure or effective_ca_bundle: + lines.append("[tls]") + if insecure: + lines.append("insecure = true") + if effective_ca_bundle: + lines.append(f"ca_bundle = {json.dumps(str(effective_ca_bundle))}") + lines.append("") + + # Proxy: --proxy sets an explicit proxy URL; --no-proxy lists bypass hosts. + # An empty --no-proxy with no --proxy means "proxying off" (enabled=false), + # which forces every request direct regardless of the ambient environment. + proxy_url = getattr(args, "proxy", None) + no_proxy = getattr(args, "no_proxy", None) + if proxy_url or no_proxy is not None: + # Parse the bypass list once, stripping tokens; a value that is empty or + # only whitespace/commas yields no hosts. + hosts = [h.strip() for h in no_proxy.split(",") if h.strip()] if no_proxy else [] + lines.append("[proxy]") + if proxy_url: + lines.append(f"url = {json.dumps(proxy_url)}") + elif not hosts: + # No proxy URL and no bypass hosts (--no-proxy '' or whitespace-only): + # disable proxying entirely, ignoring any ambient proxy env vars. + lines.append("enabled = false") + if hosts: + lines.append(f"no_proxy = {json.dumps(hosts)}") + lines.append("") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write("\n".join(lines) + "\n") + return f.name + + +# --------------------------------------------------------------------------- +# vip verify +# --------------------------------------------------------------------------- + + +def run_verify(args: argparse.Namespace) -> None: + """Run VIP tests locally against URL args or a vip.toml config.""" + provider = getattr(args, "provider", None) + if provider and provider not in _IDP_PROVIDERS: + raise ConfigError( + f"unknown --provider value: {provider}. Valid: {', '.join(_IDP_PROVIDERS)}.", + exit_code=2, + ) + + config_path = args.config + temp_config = None + + proxy_flag_set = getattr(args, "proxy", None) or getattr(args, "no_proxy", None) is not None + if not config_path and (args.connect_url or args.workbench_url or args.package_manager_url): + temp_config = _generate_temp_config(args) + config_path = temp_config + elif proxy_flag_set: + # --proxy/--no-proxy are only woven into the generated temp config (via + # _generate_temp_config); any run that loads a config file instead has no + # consumer for them, so the pytest subprocess would load the file's + # [proxy] (or none) and silently ignore the flags. This mirrors + # --insecure/--ca-bundle on the same branch. Rather than swallow the + # flag, tell the user how to make it take effect. (Ambient + # HTTP(S)_PROXY still works on a config run.) + # + # Keyed on "no temp config was generated", NOT on ``config_path``: the + # default-resolution path (a ./vip.toml with no --config and no URL + # flags -- the documented normal setup) still has config_path=None here + # and would slip through a ``config_path and ...`` test entirely. + print( + ">>> Warning: --proxy/--no-proxy are ignored when a config file is used. " + "Put the proxy under a [proxy] section in your config file " + "(url = ..., no_proxy = [...], or enabled = false), or set " + "HTTP_PROXY/HTTPS_PROXY/NO_PROXY in the environment.", + file=sys.stderr, + ) + + # Fail fast when a config file is expected but doesn't exist. + if config_path and not Path(config_path).is_file(): + raise ConfigError(f"config file not found: {config_path}") + if not config_path: + # No explicit config and no URL args — check the default resolution. + env = os.environ.get("VIP_CONFIG") + default = Path(env) if env else Path("vip.toml") + if not default.is_file(): + raise ConfigError( + f"config file not found: {default}\n" + "Provide a config file with --config, or pass product URLs directly " + "(e.g. --connect-url https://connect.example.com)." + ) + # Pin the resolved default so pytest loads the same file the CLI + # validated, regardless of pytest's rootdir or subprocess CWD. + config_path = str(default.resolve()) + + # Resolve explicit paths too so --vip-config always gets an absolute path. + config_path = str(Path(config_path).resolve()) + + if args.interactive_auth and args.headless_auth: + raise ConfigError("--interactive-auth and --headless-auth are mutually exclusive.") + + if args.no_auth and args.api_auth: + raise ConfigError("--no-auth and --api-auth are mutually exclusive.") + + if getattr(args, "ci", False) and (args.interactive_auth or args.headless_auth): + raise ConfigError( + "--ci requires non-interactive execution and cannot be combined " + "with --interactive-auth/--headless-auth." + ) + + if args.api_auth and _config_idp(config_path) == "snowflake": + raise ConfigError( + "--api-auth is not supported with the Snowflake identity provider.\n" + "A Posit Team Native App authenticates through the Snowpark Container " + "Services ingress and has no standalone product API key for --api-auth to " + "use.\n" + "Use --headless-auth to run the full suite, or --no-auth for the stateless " + "checks that do not require a login." + ) + + # Print notes for products that are not configured so the user knows + # upfront which categories will be skipped. + _print_skip_notes(config_path) + if not args.no_auth and not args.api_auth: + _check_credentials( + config_path, + interactive_auth=args.interactive_auth or args.headless_auth, + categories=args.categories, + ) + + cmd = [sys.executable, "-m", "pytest", "-v", "--no-header"] + + # Resolve the installed vip_tests package so pytest finds tests even + # when running outside the source tree (e.g. ``pip install posit-vip``). + # Skip when the user already passed explicit test targets after ``--``. + if not _has_explicit_test_targets(args.pytest_args): + from importlib.util import find_spec + + _spec = find_spec("vip_tests") + if _spec and _spec.submodule_search_locations: + cmd.append(_spec.submodule_search_locations[0]) + + if config_path: + cmd.append(f"--vip-config={config_path}") + if args.report: + cmd.append(f"--vip-report={args.report}") + + fmt = "json,junit,sarif" if getattr(args, "ci", False) else getattr(args, "format", "json") + requested = [f.strip().lower() for f in fmt.split(",") if f.strip()] + unknown = [f for f in requested if f not in VALID_FORMATS] + if unknown: + raise ConfigError( + f"unknown --format value(s): {', '.join(unknown)}. " + f"Valid: {', '.join(sorted(VALID_FORMATS))}.", + exit_code=2, + ) + cmd.append(f"--vip-format={','.join(requested)}") + if args.interactive_auth: + cmd.append("--interactive-auth") + if args.headless_auth: + cmd.append("--headless-auth") + if args.no_auth: + cmd.append("--no-auth") + if args.api_auth: + cmd.append("--api-auth") + if getattr(args, "allow_unproven", False): + cmd.append("--vip-allow-unproven") + cmd.extend(f"--vip-extensions={ext}" for ext in args.extensions or []) + if args.categories: + marker_expr = _normalize_categories(args.categories) + else: + marker_expr = _default_marker_expr(_extra_keep_from_args(args)) + if getattr(args, "basic", False): + marker_expr = f"({marker_expr}) and not slow" if marker_expr else "not slow" + cmd.extend(["-m", marker_expr]) + if args.filter_expr: + cmd.extend(["-k", args.filter_expr]) + + if args.verbose: + cmd.append("--vip-verbose") + cmd.append("-s") + + # Default to a conservative parallel-by-group run so pip-installed users + # get grouping too -- pyproject.toml's `addopts = "-n auto --dist + # loadgroup"` only applies when pytest's rootdir is this repo. 2 workers + # is a safe default: product tests log real sessions in against a shared + # deployment and a single shared test account, and higher default + # concurrency intermittently storms the OIDC IdP (`?error=2`) and exceeds + # small deployments' concurrent-session capacity. Users raise it with + # `-- -n N` when their deployment can handle more. Respect an explicit + # user choice for either flag (including `-p no:xdist`, which disables + # xdist and thus both). + _set_n, _set_dist = _user_set_xdist(args.pytest_args) + if not _set_n: + cmd.extend(["-n", "2"]) + if not _set_dist: + cmd.extend(["--dist", "loadgroup"]) + + if getattr(args, "ci", False): + cmd.append("--tb=short") + + cmd.extend(args.pytest_args) + if args.headless_auth: + # MFA prompting needs stdin; always append -s last so it + # overrides any conflicting --capture args from user or verbose. + cmd.append("-s") + + # Reconcile the child environment with the resolved proxy so the + # env-honoring egress in the suites (bare httpx.get probes, the load engine, + # Chromium's own detection) takes the same route as the pooled clients that + # read the config directly. Without this an explicit [proxy] url proxies the + # clients but not the probes, and enabled=false disables the clients while + # the probes stay on the ambient proxy. Best-effort: any config-load error + # falls through to the ambient environment unchanged (env=None). + subprocess_env: dict[str, str] | None = None + if config_path: + try: + from vip.config import load_config + from vip.proxy import proxy_env_for_subprocess + + subprocess_env = proxy_env_for_subprocess(load_config(config_path).proxy, os.environ) + except Exception: # noqa: BLE001 + subprocess_env = None + + try: + result = subprocess.run(cmd, timeout=args.test_timeout, env=subprocess_env, check=False) + sys.exit(result.returncode) + except subprocess.TimeoutExpired: + raise VipError( + f"tests timed out after {args.test_timeout} seconds. " + "Increase with --test-timeout or investigate hung tests." + ) from None + finally: + if temp_config: + Path(temp_config).unlink(missing_ok=True) diff --git a/src/vip/cli/version.py b/src/vip/cli/version.py new file mode 100644 index 00000000..28156012 --- /dev/null +++ b/src/vip/cli/version.py @@ -0,0 +1,26 @@ +"""``vip version``: print the vip version and the Posit Team support floor.""" + +from __future__ import annotations + +import argparse + + +def _format_version_details() -> str: + """Render the vip version and the minimum supported Posit Team release. + + VIP's own version and the Posit Team support floor are both calendar-versioned + (e.g. ``2026.7.0``) but are unrelated numbers, so each line is labeled + explicitly to avoid a reader mistaking one for the other. + """ + from vip import __version__ + from vip.version import MINIMUM_SUPPORTED_POSIT_TEAM + + return ( + f"VIP version: {__version__}\n" + f"Supported Posit Team versions: {MINIMUM_SUPPORTED_POSIT_TEAM} and newer" + ) + + +def run_version(_args: argparse.Namespace) -> None: + """Print the vip version and the minimum supported Posit Team version.""" + print(_format_version_details()) diff --git a/src/vip/plugin.py b/src/vip/plugin.py index bd132b73..b868b6ce 100644 --- a/src/vip/plugin.py +++ b/src/vip/plugin.py @@ -1425,7 +1425,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: session_start = session.config.stash.get(_session_start_key, None) run_duration_seconds = time.monotonic() - session_start if session_start is not None else None # There is no dedicated "--basic" flag on the plugin side — `vip verify - # --basic` (cli.py) maps to the generic pytest `-m` marker expression, + # --basic` (vip/cli/verify.py) maps to the generic pytest `-m` marker expression, # appending "not slow" to whatever categories/markers were already # selected. Detecting that from here means reading the resolved # expression back rather than a purpose-built flag, but it is also the