diff --git a/README.md b/README.md index 9b90c90..0220cec 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ the same command against a newer tag with `--force`. icebergsca scan ./myproject icebergsca scan ./myproject --format json --output report.json icebergsca scan ./myproject --include-dev -icebergsca scan ./myproject --ecosystem pypi,npm --exclude 'fixtures/**' +icebergsca scan ./myproject --ecosystem pypi --ecosystem npm --exclude 'fixtures/**' icebergsca scan ./requirements.txt # a single file works too icebergsca sbom ./myproject # CycloneDX 1.6, components only diff --git a/pyproject.toml b/pyproject.toml index fe1003f..6eec289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,9 @@ classifiers = [ "Topic :: Software Development :: Quality Assurance", ] dependencies = [ - "typer>=0.15", + # 0.26 is where Typer vendored Click. Below that floor an unrelated Click + # upgrade in the same environment can change how this CLI parses and exits. + "typer>=0.26", "rich>=13.0", "httpx>=0.27", "packaging>=24.0", diff --git a/src/icebergsca/.agents/skills/icebergsca/SKILL.md b/src/icebergsca/.agents/skills/icebergsca/SKILL.md index 9cf3c84..9775f9e 100644 --- a/src/icebergsca/.agents/skills/icebergsca/SKILL.md +++ b/src/icebergsca/.agents/skills/icebergsca/SKILL.md @@ -170,12 +170,15 @@ icebergsca scan . --include-dev # everything icebergsca scan . --scope runtime # only what ships ``` +`--scope` and `--ecosystem` take one value each and are repeated to widen the +selection: `--scope runtime --scope build`. + ## Narrowing a scan ```bash -icebergsca scan . --ecosystem pypi,npm # restrict ecosystems -icebergsca scan . --exclude 'tests/fixtures/**' # skip paths -icebergsca scan ./requirements.txt # a single file +icebergsca scan . --ecosystem pypi --ecosystem npm # restrict ecosystems +icebergsca scan . --exclude 'tests/fixtures/**' # skip paths +icebergsca scan ./requirements.txt # a single file ``` Deliberately vulnerable test fixtures are a common source of noise. Exclude them rather than diff --git a/src/icebergsca/cli/main.py b/src/icebergsca/cli/main.py index e29e5e5..2634160 100644 --- a/src/icebergsca/cli/main.py +++ b/src/icebergsca/cli/main.py @@ -15,7 +15,7 @@ from contextlib import contextmanager from enum import IntEnum from pathlib import Path -from typing import Annotated, NoReturn, TypeVar +from typing import Annotated, NoReturn import typer @@ -35,8 +35,9 @@ class ExitCode(IntEnum): vulnerabilities has done its job and exits :attr:`OK`; severity gating arrives later as an explicit ``--fail-on`` flag. - :attr:`USAGE` is 2 rather than 1 because Click reserves that code for usage - errors, and remapping it would mean overriding framework internals for nothing. + :attr:`USAGE` is 2 rather than 1 because Typer's vendored Click reserves that + code for usage errors, and remapping it would mean overriding framework + internals for nothing. """ OK = 0 @@ -51,12 +52,13 @@ class ExitCode(IntEnum): help="Software composition analysis: find dependencies, check them against OSV.", no_args_is_help=True, add_completion=False, + # Stated rather than left to the default so that help output does not change + # shape depending on what else happens to be installed. + rich_markup_mode="rich", ) logger = logging.getLogger("icebergsca") -EnumT = TypeVar("EnumT", Scope, EcosystemId) - def _fail(message: str) -> NoReturn: typer.secho(f"error: {message}", fg=typer.colors.RED, err=True) @@ -77,25 +79,6 @@ def _configure_logging(verbosity: int, quiet: bool) -> None: ) -def _parse_csv_option( - value: str | None, name: str, choices: type[EnumT] -) -> list[EnumT] | None: - """Split a comma-separated flag into enum members, reporting bad values clearly.""" - if not value: - return None - parsed: list[EnumT] = [] - valid = {member.value for member in choices} - for item in (part.strip() for part in value.split(",")): - if not item: - continue - if item not in valid: - raise typer.BadParameter( - f"unknown {name} '{item}' — choose from: {', '.join(sorted(valid))}" - ) - parsed.append(choices(item)) - return parsed or None - - def _version_callback(value: bool) -> None: if value: typer.echo(f"icebergsca {__version__}") @@ -142,15 +125,15 @@ def scan_command( ), ] = False, scopes: Annotated[ - str | None, + list[Scope] | None, typer.Option( "--scope", - help="Comma-separated scopes to include, overriding --include-dev.", + help="Scope to include, overriding --include-dev. Repeatable.", ), ] = None, ecosystems: Annotated[ - str | None, - typer.Option("--ecosystem", help="Comma-separated ecosystems to restrict to."), + list[EcosystemId] | None, + typer.Option("--ecosystem", help="Ecosystem to restrict to. Repeatable."), ] = None, exclude: Annotated[ list[str] | None, @@ -202,11 +185,8 @@ def scan_command( """Scan a project for dependencies and known vulnerabilities.""" _configure_logging(verbose, quiet) - selected_scopes = _parse_csv_option(scopes, "scope", Scope) - selected_ecosystems = _parse_csv_option(ecosystems, "ecosystem", EcosystemId) - - if selected_scopes is not None: - scope_set = frozenset(selected_scopes) + if scopes: + scope_set = frozenset(scopes) elif include_dev: scope_set = frozenset(Scope) else: @@ -220,7 +200,7 @@ def scan_command( exclude=tuple(exclude or ()), max_depth=max_depth, follow_symlinks=follow_symlinks, - ecosystems=frozenset(selected_ecosystems) if selected_ecosystems else None, + ecosystems=frozenset(ecosystems) if ecosystems else None, ), scopes=scope_set, offline=offline, @@ -297,13 +277,17 @@ def sbom_command( "and no network calls to OSV.", ), ] = False, - verbose: Annotated[int, typer.Option("-v", "--verbose", count=True)] = 0, - quiet: Annotated[bool, typer.Option("-q", "--quiet")] = False, + verbose: Annotated[ + int, typer.Option("-v", "--verbose", count=True, help="Increase log verbosity.") + ] = 0, + quiet: Annotated[ + bool, typer.Option("-q", "--quiet", help="Only log errors.") + ] = False, ) -> None: """Emit a CycloneDX 1.6 SBOM. - Equivalent to ``scan --format cyclonedx``, except that it defaults to components - only — an SBOM is often wanted for inventory rather than for findings. + Equivalent to [bold]scan --format cyclonedx[/bold], except that it defaults to + components only — an SBOM is often wanted for inventory rather than for findings. """ _configure_logging(verbose, quiet) @@ -340,7 +324,10 @@ def sbom_command( # cache # --------------------------------------------------------------------------- -cache_app = typer.Typer(help="Inspect and manage the on-disk OSV cache.") +cache_app = typer.Typer( + help="Inspect and manage the on-disk OSV cache.", + no_args_is_help=True, +) app.add_typer(cache_app, name="cache") diff --git a/tests/test_cli.py b/tests/test_cli.py index 9aeba30..bee7f73 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from pathlib import Path import pytest @@ -14,6 +15,19 @@ runner = CliRunner() +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def plain(text: str) -> str: + """Strip the styling Rich applies when it thinks it is writing to a terminal. + + Rich treats CI as a terminal, so help output is bare locally but styled under + GitHub Actions — and it styles each choice in a metavar separately, which puts + escape codes between every value. Anything asserting on rendered help has to + strip them or it passes only off CI. + """ + return _ANSI.sub("", text) + def project(tmp_path: Path) -> Path: (tmp_path / "requirements.txt").write_text("requests==2.31.0\nflask>=2.0\n") @@ -99,6 +113,29 @@ def test_ecosystem_filter(tmp_path: Path) -> None: assert {m["ecosystem"] for m in document["manifests"]} == {"PyPI"} +def test_scope_flag_is_repeatable(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nversion = "1"\ndependencies = ["httpx>=0.27"]\n\n' + '[project.optional-dependencies]\ndev = ["ruff>=0.9"]\n' + ) + result = runner.invoke( + app, + ["scan", str(tmp_path), "--format", "json", "--scope", "runtime"] + + ["--scope", "dev"], + ) + names = {e["package"]["name"] for e in json.loads(result.stdout)["dependencies"]} + assert names == {"httpx", "ruff"} + + +def test_enum_choices_are_listed_in_help() -> None: + """A bare ```` metavar tells a reader nothing about what is accepted.""" + # Wide enough that Rich does not wrap the metavar column mid-value. + result = runner.invoke(app, ["scan", "--help"], env={"COLUMNS": "200"}) + rendered = plain(result.stdout) + assert "" in rendered + assert "" in rendered + + def test_exclude_glob(tmp_path: Path) -> None: project(tmp_path) (tmp_path / "sub").mkdir() diff --git a/uv.lock b/uv.lock index 15f8575..e7d3553 100644 --- a/uv.lock +++ b/uv.lock @@ -285,7 +285,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, - { name = "typer", specifier = ">=0.15" }, + { name = "typer", specifier = ">=0.26" }, { name = "types-defusedxml", marker = "extra == 'dev'", specifier = ">=0.7" }, { name = "types-jsonschema", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, diff --git a/website/docs/cli.md b/website/docs/cli.md index d955a90..78e6073 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -25,15 +25,15 @@ icebergsca scan | Flag | Effect | |---|---| -| `--ecosystem ` | Comma-separated ecosystems to restrict to: `pypi`, `npm`, `maven`, `go`, `cargo`, `nuget`, `rubygems` | +| `--ecosystem ` | Ecosystem to restrict to: `pypi`, `npm`, `maven`, `go`, `cargo`, `nuget`, `rubygems`. Repeatable | | `--exclude ` | Glob to skip. Repeatable | | `--max-depth ` | Maximum directory depth to walk | | `--follow-symlinks` | Follow symlinked directories | | `--include-dev` | Include dev and test dependencies, which are excluded by default | -| `--scope ` | Comma-separated scopes to include, overriding `--include-dev`: `runtime`, `dev`, `test`, `build`, `optional` | +| `--scope ` | Scope to include, overriding `--include-dev`: `runtime`, `dev`, `test`, `build`, `optional`. Repeatable | ```bash -icebergsca scan . --ecosystem pypi,npm --exclude 'tests/fixtures/**' +icebergsca scan . --ecosystem pypi --ecosystem npm --exclude 'tests/fixtures/**' icebergsca scan . --scope runtime # only what ships icebergsca scan ./requirements.txt # a single file ``` diff --git a/website/docs/ecosystems.md b/website/docs/ecosystems.md index 80b9e74..ea0ec80 100644 --- a/website/docs/ecosystems.md +++ b/website/docs/ecosystems.md @@ -18,10 +18,10 @@ filename, so a file only has to be where a build tool would put it. | .NET | `nuget` | `*.csproj`, `packages.config` | `packages.lock.json` | yes | | Ruby | `rubygems` | `Gemfile`, `*.gemspec` | `Gemfile.lock` | yes | -Restrict a scan with a comma-separated list: +Restrict a scan by repeating the flag: ```bash -icebergsca scan . --ecosystem pypi,npm +icebergsca scan . --ecosystem pypi --ecosystem npm ``` !!! note "Three vocabularies that genuinely disagree"