diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f5a9fe..7d9136e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -428,6 +428,7 @@ jobs: python -m venv "${smoke_root}/venv" local smoke_python="${smoke_root}/venv/bin/python" local smoke_cli="${smoke_root}/venv/bin/sccfm-cli" + local smoke_interactive="${smoke_root}/venv/bin/sccfm-cli-interactive" cd "${smoke_root}" "${smoke_python}" -I -m pip install --no-cache-dir "${artifact_path}" @@ -469,11 +470,15 @@ jobs: for entry in distribution("cisco-sccfm-devkit").entry_points if entry.group == "console_scripts" } - expected = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + expected = { + "sccfm-cli": "cisco_sccfm_cli.cli:cli", + "sccfm-cli-interactive": "cisco_sccfm_cli.interactive:main", + } if console_scripts != expected: raise SystemExit(f"unexpected public console scripts: {console_scripts}") PY "${smoke_cli}" --help >/dev/null + "${smoke_interactive}" --help >/dev/null "${smoke_cli}" schema export --format json | "${smoke_python}" -I -c \ 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); assert payload.get("version") == version("cisco-sccfm-devkit"); assert isinstance(commands, list) and len(commands) == 57' } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42a7a8ca..16c78872 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -271,6 +271,7 @@ jobs: "cisco-sccfm-devkit==${RELEASE_VERSION}" "${INSTALL_ROOT}/venv/bin/python" -I -m pip check "${INSTALL_ROOT}/venv/bin/sccfm-cli" --help >/dev/null + "${INSTALL_ROOT}/venv/bin/sccfm-cli-interactive" --help >/dev/null "${INSTALL_ROOT}/venv/bin/sccfm-cli" schema export --format json \ | "${INSTALL_ROOT}/venv/bin/python" -I -c \ 'from importlib.metadata import version; import json, sys; payload=json.load(sys.stdin); assert payload.get("version") == version("cisco-sccfm-devkit"); assert len(payload.get("commands", [])) == 57' diff --git a/AGENTS.md b/AGENTS.md index c86bc5d6..b0ef1138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ sccfm-cli status sccfm-cli inventory devices list --format table # Interactive developer menu (test, lint, format, build collection, etc.) -sccfm-cli-interactive +sccfm-devkit ``` ## Credential configuration diff --git a/INSTALL.md b/INSTALL.md index 844df62e..54e205da 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -27,7 +27,7 @@ These are instructions to install the CLI and Python library from PyPI, plus the ## Installing the CLI Follow these steps to install the `cisco-sccfm-devkit` Python package, which provides the -`sccfm-cli` command and the `cisco_sccfm_core` Python library. +`sccfm-cli` and `sccfm-cli-interactive` commands and the `cisco_sccfm_core` Python library. ### Prerequisites @@ -47,7 +47,7 @@ pyenv global 3.12 ### Install with pipx `pipx` is the recommended install method for the CLI. It keeps the Python environment -isolated while exposing `sccfm-cli` on your `PATH`. +isolated while exposing `sccfm-cli` and `sccfm-cli-interactive` on your `PATH`. ```bash pipx install cisco-sccfm-devkit diff --git a/README.md b/README.md index e10ac7a9..86ef5668 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Cisco Security Cloud Control Firewall Manager (SCCFM) DevKit ![CI](https://github.com/CiscoDevNet/sccfm-devkit/actions/workflows/ci.yml/badge.svg) Toolkit for interacting with Security Cloud Control Firewall Manager (SCCFM): a Python -package with the `sccfm-cli` command, a reusable `cisco_sccfm_core` automation library, -and an Ansible collection. Shared business logic lives in `cisco_sccfm_core` so the CLI, -Python scripts, and collection can reuse the same SDK integrations. +package with the `sccfm-cli` and `sccfm-cli-interactive` commands, a reusable +`cisco_sccfm_core` automation library, and an Ansible collection. Shared business logic lives in +`cisco_sccfm_core` so the CLI, Python scripts, and collection can reuse the same SDK integrations. **Documentation:** [Generated CLI and Ansible reference](https://ciscodevnet.github.io/sccfm-devkit/) @@ -29,7 +29,8 @@ Python scripts, and collection can reuse the same SDK integrations. cisco_sccfm_scripts/setup_environment.sh # installs pyenv, Python 3.12.4, Poetry deps source cisco_sccfm_scripts/activate.sh # activates the project virtualenv sccfm-cli --help # the main SCCFM CLI -sccfm-cli-interactive # interactive CLI and developer workflow menu +sccfm-cli-interactive # customer-facing interactive CLI menu +sccfm-devkit # repository development workflow menu ``` `setup_environment.sh` keeps everything local to the repository: pyenv provides Python 3.12.4, `.venv/` hosts the runtime, and Poetry installs the project plus dev dependencies. @@ -45,6 +46,10 @@ sccfm-cli-interactive # interactive CLI and developer workflow menu Set the active profile once via the global option: `sccfm-cli --profile lab status`. Every command lives in `cisco_sccfm_cli/commands/` as a concrete implementation of the command-pattern friendly `BaseCommand`, keeping files small and behavior isolated. +`sccfm-cli-interactive` provides customer-facing shortcuts to configure or manage profiles and +to discover and run `sccfm-cli` commands. Repository maintenance tasks are kept separate in the +development-only `sccfm-devkit` menu. + By default, configuration is stored in `~/.sccfm-cli/config.json`. On POSIX systems the CLI requires mode `0700` on `~/.sccfm-cli` and `0600` on the configuration file. Read-only commands fail without changing metadata when those modes are unsafe; `sccfm-cli configure` repairs them @@ -52,7 +57,7 @@ while updating a profile. Custom configuration files must also use mode `0600`, not change an existing custom parent directory. On Windows, keep the configuration in your user profile and rely on the filesystem's per-user access controls. -Generated CLI reference docs can be previewed locally: +From an activated source checkout, generated CLI reference docs can be previewed locally: ```bash generate-cli-docs @@ -93,7 +98,8 @@ The package root exports the supported public service classes and response model ## Ansible collection - macOS: `brew install ansible` (this includes `ansible-galaxy`; verify with `ansible-galaxy --version`). -- Build and install the collection locally: `build-ansible-collection`. +- From an activated source checkout, build and install the collection with + `build-ansible-collection`. - Configure profiles interactively: run `sccfm-cli-interactive` and select **configure-profile**. - For IDEs/mypy, add `sccfm-ansible` to `ANSIBLE_COLLECTIONS_PATH` (or mark it as a source root) so imports under `ansible_collections.cisco.sccfm` resolve without installing. - Ansible modules and inventory select the same named SCCFM profile; they do not duplicate its region or API token in environment variables, playbooks, or Ansible Vault. @@ -103,15 +109,18 @@ The package root exports the supported public service classes and response model or group variable. Do not use inventory output modes that render vars when your own `group_vars` or `host_vars` contain secrets. - A starter playbook is in `sccfm-ansible/examples/show_devices.yml`; it runs against the SCCFM devices discovered by the inventory plugin. -- Generated Ansible reference docs can be previewed locally with `generate-ansible-docs`; see [docs/README.md](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/docs/README.md) for details. +- From an activated source checkout, generate Ansible reference docs with + `generate-ansible-docs`; see + [docs/README.md](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/docs/README.md) + for details. ## Development -All common development tasks are available through the interactive CLI menu: +All common development tasks are available through the repository-only interactive menu: ```bash source cisco_sccfm_scripts/activate.sh -sccfm-cli-interactive +sccfm-devkit ``` This presents an interactive selector with the following tasks: @@ -120,8 +129,8 @@ This presents an interactive selector with the following tasks: |------|-------------| | **configure-profile** | Create or replace a canonical SCCFM profile | | **manage-profiles** | Update or remove SCCFM profiles | -| **import-legacy-vault** | Copy profiles from the former vault token store without modifying it | | **run-cli** | Discover and run an `sccfm-cli` command interactively | +| **import-legacy-vault** | Copy profiles from the former vault token store without modifying it (source only) | | **run-ansible** | Select and run an example playbook | | **build-collection** | Build the cisco.sccfm Ansible collection tarball | | **generate-ansible-docs** | Generate Ansible reference docs from ansible-doc output | @@ -130,7 +139,8 @@ This presents an interactive selector with the following tasks: | **install-cli-man-docs** | Install generated CLI man pages for local man lookup | | **setup-env** | Bootstrap environment (pyenv, venv, Poetry deps) | | **test** | Run the test suite (pytest), with optional filter & verbose | -| **lint** | Run mypy + flake8 | +| **run-e2e** | Run Ansible e2e tests against a real SCCFM tenant | +| **lint** | Run black + isort + mypy | | **format** | Auto-format code with black + isort | After a task completes you're returned to the menu — select **Exit** when done. @@ -151,7 +161,7 @@ See `CONTRIBUTING.md` for commit guidelines (Commitizen) and contribution expect For contributors, use the repository environment from [Getting started](#getting-started). For end users, install the published PyPI package with `pipx` when possible. `pipx` -keeps the CLI isolated while exposing `sccfm-cli` on `PATH`: +keeps the CLI isolated while exposing `sccfm-cli` and `sccfm-cli-interactive` on `PATH`: ```bash pipx install cisco-sccfm-devkit diff --git a/RELEASING.md b/RELEASING.md index d0ed9bfd..6ece7085 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -85,13 +85,14 @@ The successful deployment run is the authoritative publication record. Confirm t For an independent clean-install check: ```bash -RELEASE_VERSION=0.39.1 +RELEASE_VERSION=X.Y.Z RELEASE_CHECK_ROOT="$(mktemp -d)" python3.12 -m venv "${RELEASE_CHECK_ROOT}/venv" "${RELEASE_CHECK_ROOT}/venv/bin/python" -m pip install \ "cisco-sccfm-devkit==${RELEASE_VERSION}" \ "ansible-core>=2.20,<2.22" "${RELEASE_CHECK_ROOT}/venv/bin/sccfm-cli" --help +"${RELEASE_CHECK_ROOT}/venv/bin/sccfm-cli-interactive" --help "${RELEASE_CHECK_ROOT}/venv/bin/ansible-galaxy" collection install \ "cisco.sccfm:==${RELEASE_VERSION}" \ --collections-path "${RELEASE_CHECK_ROOT}/collections" diff --git a/cisco_sccfm_cli/commands/tests/test_interactive.py b/cisco_sccfm_cli/commands/tests/test_interactive.py new file mode 100644 index 00000000..705b97a0 --- /dev/null +++ b/cisco_sccfm_cli/commands/tests/test_interactive.py @@ -0,0 +1,395 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import click +import pytest +from click.core import ParameterSource +from click.testing import CliRunner +from pytest import CaptureFixture, MonkeyPatch + +from cisco_sccfm_cli import interactive +from cisco_sccfm_cli.interactive_commands import ( + InteractiveCommand, + InteractiveParameter, + build_command_tree, +) +from cisco_sccfm_cli.models import Config +from cisco_sccfm_cli.option_metadata import sensitive_option +from cisco_sccfm_cli.services import ConfigService + + +def test_customer_tasks_are_limited_to_customer_workflows(monkeypatch: MonkeyPatch) -> None: + actions: list[tuple[str, str]] = [] + monkeypatch.setattr( + interactive, + "configure_profile", + lambda profile: actions.append(("configure-profile", profile)), + ) + monkeypatch.setattr( + interactive, + "manage_profiles", + lambda profile: actions.append(("manage-profiles", profile)), + ) + monkeypatch.setattr( + interactive, + "run_cli", + lambda profile: actions.append(("run-cli", profile)), + ) + + tasks = interactive.customer_tasks("lab") + for task in tasks: + task.action() + + assert [task.name for task in tasks] == [ + "configure-profile", + "manage-profiles", + "run-cli", + ] + assert actions == [ + ("configure-profile", "lab"), + ("manage-profiles", "lab"), + ("run-cli", "lab"), + ] + + +def test_main_passes_global_profile_to_the_menu(monkeypatch: MonkeyPatch) -> None: + menu = MagicMock() + monkeypatch.setattr(interactive, "_interactive_menu", menu) + + result = CliRunner().invoke(interactive.main, ["--profile", "lab"]) + + assert result.exit_code == 0, result.output + menu.assert_called_once_with("lab") + + +def test_configure_profile_masks_token_and_uses_selected_profile( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + monkeypatch.setenv("SCCFM_CONFIG", str(config_path)) + monkeypatch.setattr(interactive.questionary, "text", _prompt_factory("lab")) + monkeypatch.setattr(interactive.questionary, "select", _prompt_factory("eu")) + monkeypatch.setattr(interactive.questionary, "password", _prompt_factory("secret-token")) + printed = MagicMock() + monkeypatch.setattr(interactive.console, "print", printed) + + interactive.configure_profile("lab") + + stored = ConfigService(config_path).load("lab") + assert stored is not None + assert stored.region == "eu" + assert stored.api_token == "secret-token" + assert "secret-token" not in str(printed.call_args_list) + + +def test_manage_profiles_updates_selected_profile( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + service = ConfigService(config_path) + service.save(Config(profile="lab", region="us", api_token="old-token")) + monkeypatch.setenv("SCCFM_CONFIG", str(config_path)) + monkeypatch.setattr(interactive, "_ask", MagicMock(side_effect=["update", "lab"])) + monkeypatch.setattr(interactive.questionary, "select", _prompt_factory("eu")) + monkeypatch.setattr(interactive.questionary, "password", _prompt_factory("new-token")) + printed = MagicMock() + monkeypatch.setattr(interactive.console, "print", printed) + + interactive.manage_profiles("lab") + + stored = service.load("lab") + assert stored is not None + assert stored.region == "eu" + assert stored.api_token == "new-token" + assert "old-token" not in str(printed.call_args_list) + assert "new-token" not in str(printed.call_args_list) + + +def test_manage_profiles_removes_selected_profile( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + service = ConfigService(config_path) + service.save(Config(profile="lab", region="us", api_token="stored-token")) + monkeypatch.setenv("SCCFM_CONFIG", str(config_path)) + monkeypatch.setattr(interactive, "_ask", MagicMock(side_effect=["remove", "lab"])) + monkeypatch.setattr(interactive.questionary, "confirm", _prompt_factory(True)) + printed = MagicMock() + monkeypatch.setattr(interactive.console, "print", printed) + + interactive.manage_profiles("lab") + + assert service.load("lab") is None + assert "stored-token" not in str(printed.call_args_list) + + +def test_command_tree_works_outside_repository_and_marks_secrets( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + + tree = build_command_tree() + configure = next(node for node in tree if node.name == "configure") + + assert isinstance(configure, InteractiveCommand) + api_token = next( + parameter for parameter in configure.parameters if parameter.flag == "--api-token" + ) + assert api_token.sensitive is True + + +def test_installed_command_tree_can_configure_selected_profile_in_process( + tmp_path: Path, + monkeypatch: MonkeyPatch, + capsys: CaptureFixture[str], +) -> None: + config_path = tmp_path / "config.json" + monkeypatch.setenv("SCCFM_CONFIG", str(config_path)) + configure = next(node for node in build_command_tree() if node.name == "configure") + assert isinstance(configure, InteractiveCommand) + monkeypatch.setattr( + interactive, + "_prompt_parameter", + MagicMock(side_effect=["eu", "profile-secret"]), + ) + monkeypatch.setattr(interactive.questionary, "confirm", _prompt_factory(True)) + + interactive._execute_command(configure, "wheel-user") + + stored = ConfigService(config_path).load("wheel-user") + assert stored is not None + assert stored.region == "eu" + assert stored.api_token == "profile-secret" + output = capsys.readouterr() + assert "profile-secret" not in output.out + assert "profile-secret" not in output.err + assert "may expose it" not in output.err + + +def test_execute_command_keeps_secret_out_of_click_args_and_output( + monkeypatch: MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def callback(region: str, api_token: str) -> None: + context = click.get_current_context() + captured["region"] = region + captured["api_token"] = api_token + captured["profile"] = context.obj["profile"] + captured["source"] = context.get_parameter_source("api_token") + + command_option = sensitive_option( + click.Option(["--api-token"], required=True, type=str, hide_input=True) + ) + click_command = click.Command( + "configure", + callback=callback, + params=[ + click.Option(["--region"], required=True, type=str), + command_option, + ], + ) + command = InteractiveCommand( + name="configure", + description="Configure a profile", + path=("configure",), + click_command=click_command, + parameters=( + InteractiveParameter( + name="region", + label="Region", + flag="--region", + required=True, + is_flag=False, + multiple=False, + sensitive=False, + choices=(), + ), + InteractiveParameter( + name="api_token", + label="API token", + flag="--api-token", + required=True, + is_flag=False, + multiple=False, + sensitive=True, + choices=(), + ), + ), + ) + monkeypatch.setattr(interactive, "_prompt_parameter", MagicMock(side_effect=["us", "secret"])) + monkeypatch.setattr(interactive.questionary, "confirm", _prompt_factory(True)) + printed = MagicMock() + monkeypatch.setattr(interactive.console, "print", printed) + click_args: list[str] = [] + original_make_context = click_command.make_context + + def make_context(info_name: str, args: list[str], **kwargs: Any) -> click.Context: + click_args.extend(args) + return original_make_context(info_name, args, **kwargs) + + monkeypatch.setattr(click_command, "make_context", make_context) + original_sys_argv = ["unrelated-program", "--existing-value"] + monkeypatch.setattr(sys, "argv", original_sys_argv.copy()) + + interactive._execute_command(command, "lab") + + assert captured == { + "region": "us", + "api_token": "secret", + "profile": "lab", + "source": ParameterSource.PROMPT, + } + assert click_args == [ + "--region", + "us", + "--api-token", + interactive._SECRET_PLACEHOLDER, + ] + assert "secret" not in str(printed.call_args_list) + assert "'***'" in str(printed.call_args_list) + assert sys.argv == original_sys_argv + + +def test_execute_command_requires_confirmation(monkeypatch: MonkeyPatch) -> None: + command_callback = MagicMock() + click_command = click.Command("status", callback=command_callback) + command = InteractiveCommand( + name="status", + description="Check connectivity", + path=("status",), + click_command=click_command, + ) + monkeypatch.setattr(interactive.questionary, "confirm", _prompt_factory(False)) + + interactive._execute_command(command, "default") + + command_callback.assert_not_called() + + +@pytest.mark.parametrize( + "path", + [ + ("policies", "access-rule", "create"), + ("policies", "access-rule", "update"), + ], +) +def test_dual_boolean_options_offer_the_negative_flag( + path: tuple[str, ...], + monkeypatch: MonkeyPatch, +) -> None: + command = _command_at_path(path) + active = next(parameter for parameter in command.parameters if parameter.name == "active") + monkeypatch.setattr(interactive, "_ask", MagicMock(return_value="--inactive")) + option_args: list[str] = [] + + accepted = interactive._collect_parameter(active, option_args, {}) + + assert accepted is True + assert active.flag == "--active" + assert active.secondary_flag == "--inactive" + assert option_args == ["--inactive"] + + +def test_sensitive_prompt_preserves_leading_and_trailing_spaces( + monkeypatch: MonkeyPatch, +) -> None: + parameter = InteractiveParameter( + name="password", + label="Password", + flag="--password", + required=True, + is_flag=False, + multiple=False, + sensitive=True, + choices=(), + ) + monkeypatch.setattr(interactive.questionary, "password", _prompt_factory(" pass phrase ")) + + value = interactive._prompt_parameter(parameter, "Password", allow_empty=False) + + assert value == " pass phrase " + + +def test_optional_choice_can_use_the_click_default(monkeypatch: MonkeyPatch) -> None: + parameter = _parameter(required=False, multiple=False) + select = _recording_select([""]) + monkeypatch.setattr(interactive.questionary, "select", select) + + answer = interactive._prompt_parameter(parameter, "Output format", allow_empty=True) + + choices = select.call_args.kwargs["choices"] + assert answer == "" + assert isinstance(choices[0], interactive.questionary.Choice) + assert choices[0].title == "" + assert choices[0].value == "" + + +def test_required_multiple_choice_offers_done_after_first_value( + monkeypatch: MonkeyPatch, +) -> None: + parameter = _parameter(required=True, multiple=True) + select = _recording_select(["BASE", ""]) + monkeypatch.setattr(interactive.questionary, "select", select) + option_args: list[str] = [] + + accepted = interactive._collect_multiple(parameter, option_args, {}) + + first_choices = select.call_args_list[0].kwargs["choices"] + second_choices = select.call_args_list[1].kwargs["choices"] + assert accepted is True + assert first_choices == ["BASE", "CARRIER"] + assert isinstance(second_choices[0], interactive.questionary.Choice) + assert second_choices[0].title == "" + assert second_choices[0].value == "" + assert option_args == ["--licenses", "BASE"] + + +def _prompt_factory(answer: object) -> MagicMock: + prompt = MagicMock() + prompt.unsafe_ask.return_value = answer + return MagicMock(return_value=prompt) + + +def _recording_select(answers: list[str]) -> MagicMock: + prompts: list[MagicMock] = [] + for answer in answers: + prompt = MagicMock() + prompt.unsafe_ask.return_value = answer + prompts.append(prompt) + return MagicMock(side_effect=prompts) + + +def _parameter(*, required: bool, multiple: bool) -> InteractiveParameter: + return InteractiveParameter( + name="licenses", + label="Licenses", + flag="--licenses", + required=required, + is_flag=False, + multiple=multiple, + sensitive=False, + choices=("BASE", "CARRIER"), + ) + + +def _command_at_path(path: tuple[str, ...]) -> InteractiveCommand: + children = build_command_tree() + node: object | None = None + for component in path: + node = next(candidate for candidate in children if candidate.name == component) + children = node.children if hasattr(node, "children") else () + assert isinstance(node, InteractiveCommand) + return node diff --git a/cisco_sccfm_cli/interactive.py b/cisco_sccfm_cli/interactive.py new file mode 100644 index 00000000..35d7af1a --- /dev/null +++ b/cisco_sccfm_cli/interactive.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Customer-facing interactive entry point for ``sccfm-cli``.""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass +from functools import partial +from typing import Callable, Final, TypeAlias + +import click +import questionary +from click.core import ParameterSource +from rich.console import Console +from rich.panel import Panel + +from cisco_sccfm_cli.interactive_commands import ( + InteractiveCommand, + InteractiveGroup, + InteractiveParameter, + build_command_tree, +) +from cisco_sccfm_cli.models import Config +from cisco_sccfm_cli.services import ConfigService +from cisco_sccfm_core.constants import SCCFM_REGIONS + +console = Console() + +_SECRET_PLACEHOLDER: Final[str] = "__SCCFM_INTERACTIVE_SECRET__" +_SecretValue: TypeAlias = str | tuple[str, ...] + + +@dataclass(frozen=True) +class CustomerTask: + """A customer-safe task shared by the public and development menus.""" + + name: str + description: str + action: Callable[[], None] + + +def customer_tasks(profile: str = "default") -> tuple[CustomerTask, ...]: + """Return the customer-facing tasks bound to the selected profile.""" + return ( + CustomerTask( + "configure-profile", + "Create or replace an SCCFM profile", + partial(configure_profile, profile), + ), + CustomerTask( + "manage-profiles", + "Update or remove SCCFM profiles", + partial(manage_profiles, profile), + ), + CustomerTask( + "run-cli", + "Run an sccfm-cli command interactively", + partial(run_cli, profile), + ), + ) + + +def configure_profile(profile: str = "default") -> None: + """Create or replace a profile in the canonical SCCFM profile store.""" + profile_answer = questionary.text("Profile name:", default=profile).unsafe_ask() + selected_profile = _normalized_answer(profile_answer) + if not selected_profile: + console.print("[red]Profile name cannot be empty.[/red]") + return + + region_answer = questionary.select( + "SCCFM region:", + choices=list(SCCFM_REGIONS), + default="us", + ).unsafe_ask() + region = _normalized_answer(region_answer) + if not region: + console.print("[dim]Cancelled.[/dim]") + return + + token_answer = questionary.password("SCCFM API token:").unsafe_ask() + api_token = _normalized_answer(token_answer) + if not api_token: + console.print("[red]API token cannot be empty.[/red]") + return + + ConfigService().save(Config(profile=selected_profile, region=region, api_token=api_token)) + console.print(f"[green]Profile '{selected_profile}' configured for region '{region}'.[/green]") + + +def manage_profiles(profile: str = "default") -> None: + """Update or remove profiles in the canonical SCCFM profile store.""" + answer = _ask(("update", "remove", "back"), "Manage profiles:") + if answer == "update": + _update_profile(profile) + elif answer == "remove": + _remove_profile(profile) + + +def run_cli(profile: str = "default") -> None: + """Navigate and invoke installed ``sccfm-cli`` commands.""" + _navigate(build_command_tree(), "sccfm-cli", profile) + + +def _update_profile(preferred_profile: str) -> None: + selected = _select_profile("Select a profile to update:", preferred_profile) + if selected is None: + return + + region_answer = questionary.select( + "SCCFM region:", + choices=list(SCCFM_REGIONS), + default=selected.region, + ).unsafe_ask() + region = _normalized_answer(region_answer) + if not region: + console.print("[dim]Cancelled.[/dim]") + return + + token_answer = questionary.password( + "New SCCFM API token (leave blank to keep the current token):" + ).unsafe_ask() + api_token = _normalized_answer(token_answer) or selected.api_token + ConfigService().save(Config(profile=selected.profile, region=region, api_token=api_token)) + console.print(f"[green]Profile '{selected.profile}' updated.[/green]") + + +def _remove_profile(preferred_profile: str) -> None: + selected = _select_profile("Select a profile to remove:", preferred_profile) + if selected is None: + return + + confirmed = questionary.confirm( + f"Remove profile '{selected.profile}' (region={selected.region})?", + default=False, + ).unsafe_ask() + if not confirmed: + console.print("[dim]Cancelled.[/dim]") + return + + ConfigService().remove(selected.profile) + console.print(f"[green]Profile '{selected.profile}' removed.[/green]") + + +def _select_profile(message: str, preferred_profile: str) -> Config | None: + profiles = ConfigService().list_profiles() + if not profiles: + console.print("[yellow]No SCCFM profiles configured.[/yellow]") + return None + + choices = [ + questionary.Choice(title=f"{item.profile} ({item.region})", value=item.profile) + for item in profiles + ] + choices.append(questionary.Choice(title="back", value="back")) + default = ( + preferred_profile if any(item.profile == preferred_profile for item in profiles) else None + ) + answer = _ask(choices, message, default=default) + if answer is None or answer == "back": + return None + return next((item for item in profiles if item.profile == answer), None) + + +def _navigate( + children: tuple[InteractiveGroup | InteractiveCommand, ...], + title: str, + profile: str, +) -> None: + while True: + choices = [ + questionary.Choice( + title=f"{node.name:20s} {node.description}", + value=node.name, + ) + for node in children + ] + choices.append(questionary.Choice(title="back", value="back")) + answer = _ask(choices, f"{title}:") + if answer is None or answer == "back": + return + + node = next((candidate for candidate in children if candidate.name == answer), None) + if isinstance(node, InteractiveGroup): + _navigate(node.children, f"{title} > {node.name}", profile) + elif isinstance(node, InteractiveCommand): + _execute_command(node, profile) + + +def _execute_command(command: InteractiveCommand, profile: str) -> None: + option_args: list[str] = [] + secret_values: dict[str, _SecretValue] = {} + for parameter in command.parameters: + if not _collect_parameter(parameter, option_args, secret_values): + return + + display_args = ["sccfm-cli", "--profile", profile, *command.path, *option_args] + console.print(f"$ {_render_command(display_args)}", style="bold cyan", markup=False) + confirmed = questionary.confirm("Run this command?", default=False).unsafe_ask() + if not confirmed: + console.print("[dim]Cancelled.[/dim]") + return + _invoke_command(command, profile, option_args, secret_values) + + +def _collect_parameter( + parameter: InteractiveParameter, + option_args: list[str], + secret_values: dict[str, _SecretValue], +) -> bool: + if parameter.is_flag: + return _collect_flag(parameter, option_args) + if parameter.multiple: + return _collect_multiple(parameter, option_args, secret_values) + return _collect_single(parameter, option_args, secret_values) + + +def _collect_flag(parameter: InteractiveParameter, option_args: list[str]) -> bool: + if parameter.secondary_flag is None: + enabled = questionary.confirm(f"{parameter.label}?", default=False).unsafe_ask() + if enabled: + option_args.append(parameter.flag) + return True + + choices: list[questionary.Choice] = [] + if not parameter.required: + choices.append(questionary.Choice(title="", value="")) + choices.extend( + questionary.Choice(title=flag.removeprefix("--"), value=flag) + for flag in (parameter.flag, parameter.secondary_flag) + ) + selected = _ask(choices, f"{parameter.label}:") + if selected: + option_args.append(selected) + return True + + +def _collect_multiple( + parameter: InteractiveParameter, + option_args: list[str], + secret_values: dict[str, _SecretValue], +) -> bool: + console.print(f"[dim]{parameter.label} — enter one value per line, blank to finish:[/dim]") + values: list[str] = [] + while True: + value = _prompt_parameter( + parameter, + f" {parameter.flag}", + allow_empty=bool(values) or not parameter.required, + ) + if not value: + break + values.append(value) + option_args.extend([parameter.flag, _argument_value(parameter, value)]) + if parameter.required and not values: + console.print(f"[red]{parameter.label} is required.[/red]") + return False + if parameter.sensitive and values: + secret_values[parameter.name] = tuple(values) + return True + + +def _collect_single( + parameter: InteractiveParameter, + option_args: list[str], + secret_values: dict[str, _SecretValue], +) -> bool: + suffix = "" if parameter.required else " (leave blank to skip)" + value = _prompt_parameter( + parameter, + f"{parameter.label}{suffix}", + allow_empty=not parameter.required, + ) + if not value: + if parameter.required: + console.print(f"[red]{parameter.label} is required.[/red]") + return False + return True + + option_args.extend([parameter.flag, _argument_value(parameter, value)]) + if parameter.sensitive: + secret_values[parameter.name] = value + return True + + +def _argument_value(parameter: InteractiveParameter, value: str) -> str: + return _SECRET_PLACEHOLDER if parameter.sensitive else value + + +def _prompt_parameter( + parameter: InteractiveParameter, + message: str, + *, + allow_empty: bool, +) -> str: + if parameter.sensitive: + answer = questionary.password(message).unsafe_ask() + elif parameter.choices: + choices: list[questionary.Choice | str] = list(parameter.choices) + if allow_empty: + empty_title = "" if parameter.multiple else "" + choices.insert(0, questionary.Choice(title=empty_title, value="")) + answer = questionary.select(message, choices=choices).unsafe_ask() + else: + answer = questionary.text(message).unsafe_ask() + if parameter.sensitive and isinstance(answer, str): + return answer if answer.strip() else "" + return _normalized_answer(answer) + + +def _invoke_command( + command: InteractiveCommand, + profile: str, + option_args: list[str], + secret_values: dict[str, _SecretValue], +) -> None: + from cisco_sccfm_cli.cli import cli + + root_context = click.Context( + cli, + info_name="sccfm-cli", + obj={"profile": profile, "silent": False}, + ) + try: + with root_context: + with command.click_command.make_context( + command.name, + option_args, + parent=root_context, + ) as command_context: + _inject_secrets(command_context, secret_values) + command.click_command.invoke(command_context) + except click.ClickException as exc: + exc.show() + except click.Abort: + console.print("[yellow]Cancelled.[/yellow]") + except click.exceptions.Exit: + return + except SystemExit: + return + + +def _inject_secrets(context: click.Context, secret_values: dict[str, _SecretValue]) -> None: + for name, value in secret_values.items(): + context.params[name] = value + context.set_parameter_source(name, ParameterSource.PROMPT) + + +def _render_command(arguments: list[str]) -> str: + redacted = ["***" if value == _SECRET_PLACEHOLDER else value for value in arguments] + return shlex.join(redacted) + + +def _normalized_answer(answer: object) -> str: + return answer.strip() if isinstance(answer, str) else "" + + +def _ask( + choices: list[questionary.Choice | str] | tuple[str, ...], + message: str, + *, + default: str | None = None, +) -> str | None: + answer = questionary.select( + message, + choices=choices, + default=default, + use_search_filter=True, + use_jk_keys=False, + ).unsafe_ask() + return answer if isinstance(answer, str) else None + + +def _interactive_menu(profile: str) -> None: + tasks = customer_tasks(profile) + console.print( + Panel( + "[bold]SCCFM CLI Interactive[/bold]\nSelect a task to run.", + border_style="cyan", + ) + ) + choices = [ + questionary.Choice( + title=f"{task.name:20s} {task.description}", + value=task.name, + ) + for task in tasks + ] + choices.append(questionary.Choice(title="exit", value="exit")) + task_by_name = {task.name: task for task in tasks} + + while True: + try: + answer = _ask(choices, "What would you like to do?") + if answer is None or answer == "exit": + break + console.print() + task = task_by_name.get(answer) + if task is not None: + task.action() + console.print() + except KeyboardInterrupt: + console.print("\n[yellow]Exiting.[/yellow]") + break + console.print("[dim]Bye![/dim]") + + +@click.command(help="Open the customer-facing interactive SCCFM CLI menu.") +@click.option( + "--profile", + default="default", + show_default=True, + help="Configuration profile to use when running sccfm-cli commands.", +) +def main(profile: str) -> None: + """Open the customer-facing interactive SCCFM CLI menu.""" + _interactive_menu(profile) + + +if __name__ == "__main__": + main() diff --git a/cisco_sccfm_cli/interactive_commands.py b/cisco_sccfm_cli/interactive_commands.py new file mode 100644 index 00000000..e133a106 --- /dev/null +++ b/cisco_sccfm_cli/interactive_commands.py @@ -0,0 +1,125 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Discover the installed ``sccfm-cli`` command tree for interactive use.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import click + +from cisco_sccfm_cli.option_metadata import is_sensitive_option + +_SKIPPED_PARAMETERS = {"config_path", "help"} + + +@dataclass(frozen=True) +class InteractiveParameter: + """A Click option rendered as an interactive prompt.""" + + name: str + label: str + flag: str + required: bool + is_flag: bool + multiple: bool + sensitive: bool + choices: tuple[str, ...] + secondary_flag: str | None = None + + +@dataclass(frozen=True) +class InteractiveCommand: + """A leaf command that can be invoked without a shell subprocess.""" + + name: str + description: str + path: tuple[str, ...] + click_command: click.Command = field(repr=False, compare=False) + parameters: tuple[InteractiveParameter, ...] = () + + +@dataclass(frozen=True) +class InteractiveGroup: + """A navigable group of interactive commands.""" + + name: str + description: str + children: tuple[InteractiveGroup | InteractiveCommand, ...] = () + + +def build_command_tree() -> tuple[InteractiveGroup | InteractiveCommand, ...]: + """Build the command tree from the installed Click application.""" + from cisco_sccfm_cli.cli import cli + + return _build_children(cli, ()) + + +def _build_children( + group: click.Group, + path: tuple[str, ...], +) -> tuple[InteractiveGroup | InteractiveCommand, ...]: + children: list[InteractiveGroup | InteractiveCommand] = [] + for name, command in sorted(group.commands.items()): + command_path = (*path, name) + if isinstance(command, click.Group): + children.append( + InteractiveGroup( + name=name, + description=_first_line(command.help), + children=_build_children(command, command_path), + ) + ) + continue + children.append( + InteractiveCommand( + name=name, + description=_first_line(command.help), + path=command_path, + click_command=command, + parameters=_parameters(command), + ) + ) + return tuple(children) + + +def _parameters(command: click.Command) -> tuple[InteractiveParameter, ...]: + parameters: list[InteractiveParameter] = [] + for parameter in command.params: + if not isinstance(parameter, click.Option) or parameter.name in _SKIPPED_PARAMETERS: + continue + flag = next((option for option in parameter.opts if option.startswith("--")), None) + if flag is None: + continue + name = parameter.name or flag.lstrip("-").replace("-", "_") + parameters.append( + InteractiveParameter( + name=name, + label=_first_line(parameter.help) or name.replace("_", " ").title(), + flag=flag, + required=bool(parameter.required), + is_flag=bool(parameter.is_flag), + multiple=bool(parameter.multiple), + sensitive=is_sensitive_option(parameter), + choices=_choices(parameter), + secondary_flag=next( + (option for option in parameter.secondary_opts if option.startswith("--")), + None, + ), + ) + ) + return tuple(parameters) + + +def _choices(parameter: click.Option) -> tuple[str, ...]: + if not isinstance(parameter.type, click.Choice): + return () + return tuple(str(choice) for choice in parameter.type.choices) + + +def _first_line(text: str | None) -> str: + if not text: + return "" + return text.strip().splitlines()[0].rstrip(".") diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index b0a89532..aaf5ad85 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -38,7 +38,10 @@ def test_published_package_contract_is_cli_and_core_only() -> None: "cisco_sccfm_cli", "cisco_sccfm_core", } - assert _project_config()["scripts"] == {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + assert _project_config()["scripts"] == { + "sccfm-cli": "cisco_sccfm_cli.cli:cli", + "sccfm-cli-interactive": "cisco_sccfm_cli.interactive:main", + } def test_published_packages_exclude_repository_only_code() -> None: @@ -59,13 +62,17 @@ def test_generated_sdk_is_pinned_to_the_verified_compatible_version() -> None: assert "scc-firewall-manager-sdk==1.17.27" in _project_config()["dependencies"] -def test_interactive_entrypoint_is_completely_renamed() -> None: - with (PROJECT_ROOT / "devtools" / "pyproject.toml").open("rb") as pyproject_file: - scripts = tomllib.load(pyproject_file)["project"]["scripts"] +def test_interactive_entrypoint_is_published_from_the_cli_package() -> None: + scripts = _project_config()["scripts"] + + assert scripts["sccfm-cli-interactive"] == "cisco_sccfm_cli.interactive:main" + + +def test_interactive_prompt_dependency_is_installed_for_users() -> None: + pyproject = _pyproject() - assert scripts["sccfm-cli-interactive"] == "cisco_sccfm_scripts.interactive_cli:main" - assert "devkit" not in scripts - assert "change-tokens" not in scripts + assert "questionary>=2.1.1,<3" in _project_config()["dependencies"] + assert "questionary" not in pyproject["tool"]["poetry"]["group"]["dev"]["dependencies"] def test_user_guidance_only_references_canonical_profile_configuration() -> None: diff --git a/cisco_sccfm_scripts/cli_commands.py b/cisco_sccfm_scripts/cli_commands.py deleted file mode 100644 index 2e537363..00000000 --- a/cisco_sccfm_scripts/cli_commands.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2026 Cisco Systems, Inc. and its affiliates -# -# SPDX-License-Identifier: Apache-2.0 - -"""Dynamic sccfm-cli command tree built by introspecting the Click group. - -Any command added to sccfm-cli is automatically available in the interactive CLI -interactive runner — no changes to this file are required. - -Infrastructure options that are not useful in an interactive session -(``--help``, ``--config-path``) are filtered out automatically. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -import click - -# Options that are wired up at the infrastructure level and should not be -# prompted for interactively. -_SKIP_PARAMS = {"help", "config_path"} - - -@dataclass -class CliParam: - label: str - flag: str # e.g. "--query" - required: bool - is_flag: bool = False # True for boolean toggle options (e.g. --check) - multiple: bool = False # True for repeatable options (e.g. --labels, --tags) - hide_input: bool = False # True for secrets (e.g. --api-token, --password) - - -@dataclass -class CliCommand: - name: str - description: str - args: list[str] - params: list[CliParam] = field(default_factory=list) - - -@dataclass -class CliGroup: - name: str - description: str - children: list[CliGroup | CliCommand] = field(default_factory=list) - - -def _first_line(text: str | None) -> str: - """Return the first non-empty line of a help string.""" - if not text: - return "" - return text.strip().splitlines()[0].rstrip(".") - - -def _build_tree(group: click.Group, args_prefix: list[str]) -> list[CliGroup | CliCommand]: - """Recursively build a CliGroup/CliCommand tree from a Click group.""" - result: list[CliGroup | CliCommand] = [] - for name, cmd in sorted(group.commands.items()): - if isinstance(cmd, click.Group): - children = _build_tree(cmd, args_prefix + [name]) - result.append( - CliGroup( - name=name, - description=_first_line(cmd.help), - children=children, - ) - ) - elif isinstance(cmd, click.BaseCommand): - params: list[CliParam] = [] - for param in cmd.params: - if not isinstance(param, click.Option): - continue - if param.name in _SKIP_PARAMS: - continue - # Prefer the long form (--name) over the short form (-n) for clarity. - flag = next((o for o in param.opts if o.startswith("--")), param.opts[0]) - label = _first_line(param.help) or (param.name or flag).replace("_", " ").title() - params.append( - CliParam( - label=label, - flag=flag, - required=bool(param.required), - is_flag=bool(param.is_flag), - multiple=bool(param.multiple), - hide_input=bool(getattr(param, "hide_input", False)), - ) - ) - result.append( - CliCommand( - name=name, - description=_first_line(cmd.help), - args=args_prefix + [name], - params=params, - ) - ) - return result - - -def build_cli_tree() -> list[CliGroup | CliCommand]: - """Return the full sccfm-cli command tree by introspecting the Click CLI.""" - from cisco_sccfm_cli.cli import cli as _root - - return _build_tree(_root, []) diff --git a/cisco_sccfm_scripts/interactive_cli.py b/cisco_sccfm_scripts/interactive_cli.py index b81dcfe6..649efeea 100644 --- a/cisco_sccfm_scripts/interactive_cli.py +++ b/cisco_sccfm_scripts/interactive_cli.py @@ -4,10 +4,10 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Interactive entry point for SCCFM CLI and development workflows. +"""Interactive entry point for SCCFM repository development workflows. Usage: - sccfm-cli-interactive + sccfm-devkit """ from __future__ import annotations @@ -23,6 +23,8 @@ from rich.console import Console from rich.panel import Panel +from cisco_sccfm_cli.interactive import customer_tasks + console = Console() @@ -51,38 +53,6 @@ def _ask( # ── Task implementations ───────────────────────────────────────── -def _configure_profile() -> None: - """Create or replace a profile in the canonical SCCFM config store.""" - from cisco_sccfm_cli.models import Config - from cisco_sccfm_cli.services import ConfigService - from cisco_sccfm_core.constants import SCCFM_REGIONS - - profile_answer = questionary.text("Profile name:", default="default").unsafe_ask() - profile = (profile_answer or "").strip() - if not profile: - console.print("[red]Profile name cannot be empty.[/red]") - return - - region_answer = questionary.select( - "SCCFM region:", - choices=list(SCCFM_REGIONS), - default="us", - ).unsafe_ask() - region = region_answer if isinstance(region_answer, str) else "" - if not region: - console.print("[dim]Cancelled.[/dim]") - return - - token_answer = questionary.password("SCCFM API token:").unsafe_ask() - api_token = (token_answer or "").strip() - if not api_token: - console.print("[red]API token cannot be empty.[/red]") - return - - ConfigService().save(Config(profile=profile, region=region, api_token=api_token)) - console.print(f"[green]Profile '{profile}' configured for region '{region}'.[/green]") - - def _import_legacy_vault() -> None: """Import SCCFM profiles from the former Ansible Vault token store.""" from cisco_sccfm_scripts.import_legacy_vault import main as _import @@ -201,119 +171,6 @@ def _run_e2e() -> None: subprocess.call(["bash", str(script)], cwd=root) -# ── Run CLI commands ────────────────────────────────────────────── - - -def _prompt_param(message: str, hide_input: bool) -> str | None: - """Prompt for a parameter value, masking input for secrets.""" - prompt = questionary.password(message) if hide_input else questionary.text(message) - answer: object = prompt.unsafe_ask() - return answer if isinstance(answer, str) else None - - -def _render_command(argv: list[str], secret_flags: set[str]) -> str: - """Render *argv* for display with secret option values replaced by ``***``.""" - rendered = list(argv) - for index, token in enumerate(rendered[:-1]): - if token in secret_flags: - rendered[index + 1] = "***" - return shlex.join(rendered) - - -def _invoke_cli(argv: list[str], secret_flags: set[str]) -> None: - """Invoke secret-bearing commands without exposing values through OS argv.""" - if secret_flags.intersection(argv): - from cisco_sccfm_cli.cli import cli - - try: - cli.main(args=argv[1:], prog_name=argv[0], standalone_mode=False) - except click.ClickException as exc: - exc.show() - return - subprocess.call(argv, cwd=_project_root()) - - -def _execute_cli_command(cmd: object) -> None: - """Prompt for params and run an sccfm-cli leaf command.""" - from cisco_sccfm_scripts.cli_commands import CliCommand, CliParam - - if not isinstance(cmd, CliCommand): - return - argv: list[str] = ["sccfm-cli", *cmd.args] - secret_flags = {p.flag for p in cmd.params if isinstance(p, CliParam) and p.hide_input} - for param in cmd.params: - if not isinstance(param, CliParam): - continue - if param.is_flag: - confirmed = questionary.confirm(f"{param.label}?", default=False).unsafe_ask() - if confirmed: - argv.append(param.flag) - elif param.multiple: - console.print(f"[dim]{param.label} — enter one value per line, blank to finish:[/dim]") - has_value = False - while True: - value: str | None = _prompt_param(f" {param.flag}", param.hide_input) - normalized_value = (value or "").strip() - if not normalized_value: - break - argv.extend([param.flag, normalized_value]) - has_value = True - if param.required and not has_value: - console.print(f"[red]{param.label} is required.[/red]") - return - else: - prompt = f"{param.label}{'' if param.required else ' (leave blank to skip)'}" - single: str | None = _prompt_param(prompt, param.hide_input) - normalized_value = (single or "").strip() - if normalized_value: - argv.extend([param.flag, normalized_value]) - elif param.required: - console.print(f"[red]{param.label} is required.[/red]") - return - - console.print(f"[bold cyan]$ {_render_command(argv, secret_flags)}[/bold cyan]") - _invoke_cli(argv, secret_flags) - - -def _navigate_cli(children: list[object], title: str) -> None: - """Recursively navigate a CliGroup/CliCommand tree.""" - from cisco_sccfm_scripts.cli_commands import CliCommand, CliGroup - - nodes: list[CliCommand | CliGroup] = [ - c for c in children if isinstance(c, (CliCommand, CliGroup)) - ] - - while True: - choices: list[questionary.Choice | str] = [ - questionary.Choice( - title=f"{n.name:20s} {n.description}", - value=n.name, - ) - for n in nodes - ] - choices.append(questionary.Choice(title="back", value="back")) - - answer = _ask(choices, f"{title}:") - if answer is None or answer == "back": - return - - node = next((n for n in nodes if n.name == answer), None) - if node is None: - continue - - if isinstance(node, CliGroup): - _navigate_cli(node.children, f"{title} > {node.name}") - elif isinstance(node, CliCommand): - _execute_cli_command(node) - - -def _run_cli_commands() -> None: - """Interactively navigate and run sccfm-cli commands.""" - from cisco_sccfm_scripts.cli_commands import build_cli_tree - - _navigate_cli(build_cli_tree(), "sccfm-cli") - - # ── Run Ansible examples ────────────────────────────────────────── @@ -369,101 +226,23 @@ def _run_ansible_examples() -> None: subprocess.call(cmd, cwd=str(examples_dir)) -# ── Manage tokens ───────────────────────────────────────────────── - - -def _select_profile(message: str) -> object | None: - """Select a configured SCCFM profile, returning its config.""" - from cisco_sccfm_cli.services import ConfigService - - profiles = ConfigService().list_profiles() - if not profiles: - console.print("[yellow]No SCCFM profiles configured.[/yellow]") - return None - - choices: list[questionary.Choice | str] = [ - questionary.Choice(title=f"{item.profile} ({item.region})", value=item.profile) - for item in profiles - ] - choices.append("back") - answer = _ask(choices, message) - if answer is None or answer == "back": - return None - return next((item for item in profiles if item.profile == answer), None) - - -def _update_profile() -> None: - """Update the region and API token for an existing profile.""" - from cisco_sccfm_cli.models import Config - from cisco_sccfm_cli.services import ConfigService - from cisco_sccfm_core.constants import SCCFM_REGIONS - - selected = _select_profile("Select a profile to update:") - if not isinstance(selected, Config): - return - - region_answer = questionary.select( - "SCCFM region:", - choices=list(SCCFM_REGIONS), - default=selected.region, - ).unsafe_ask() - region = region_answer if isinstance(region_answer, str) else "" - if not region: - console.print("[dim]Cancelled.[/dim]") - return - - token_answer = questionary.password( - "New SCCFM API token (leave blank to keep the current token):" - ).unsafe_ask() - api_token = (token_answer or "").strip() or selected.api_token - ConfigService().save(Config(profile=selected.profile, region=region, api_token=api_token)) - console.print(f"[green]Profile '{selected.profile}' updated.[/green]") - - -def _remove_profile() -> None: - """Remove an SCCFM profile from the canonical config store.""" - from cisco_sccfm_cli.models import Config - from cisco_sccfm_cli.services import ConfigService - - selected = _select_profile("Select a profile to remove:") - if not isinstance(selected, Config): - return - - confirmed = questionary.confirm( - f"Remove profile '{selected.profile}' (region={selected.region})?", - default=False, - ).unsafe_ask() - if not confirmed: - console.print("[dim]Cancelled.[/dim]") - return - - ConfigService().remove(selected.profile) - console.print(f"[green]Profile '{selected.profile}' removed.[/green]") - +# ── Menu definition ─────────────────────────────────────────────── -def _manage_profiles() -> None: - """Profile management sub-menu.""" - answer = _ask(["update", "remove", "back"], "Manage profiles:") - if answer is None or answer == "back": - return +Task = tuple[str, str, Callable[[], None]] - if answer == "update": - _update_profile() - elif answer == "remove": - _remove_profile() +def _customer_task_entries() -> list[Task]: + """Return public customer tasks in the development menu's tuple format.""" + return [(task.name, task.description, task.action) for task in customer_tasks()] -# ── Menu definition ─────────────────────────────────────────────── -_TASKS: list[tuple[str, str, Callable[[], None]]] = [ - ("configure-profile", "Create or replace an SCCFM profile", _configure_profile), - ("manage-profiles", "Update or remove SCCFM profiles", _manage_profiles), +_TASKS: list[Task] = [ + *_customer_task_entries(), ( "import-legacy-vault", "Import profiles from the former Ansible Vault token store", _import_legacy_vault, ), - ("run-cli", "Run an sccfm-cli command interactively", _run_cli_commands), ("run-ansible", "Run an Ansible example playbook", _run_ansible_examples), ("build-collection", "Build the cisco.sccfm Ansible collection tarball", _run_build_collection), ( @@ -512,7 +291,7 @@ def _interactive_menu() -> None: """Show an interactive menu and run the selected task.""" console.print( Panel( - "[bold]SCCFM CLI Interactive[/bold]\n" "Select a task to run.", + "[bold]SCCFM DevKit[/bold]\n" "Select a task to run.", border_style="cyan", ) ) @@ -549,7 +328,7 @@ def _interactive_menu() -> None: # ── Entry-point ─────────────────────────────────────────────────── -@click.command(help="Open the interactive SCCFM CLI and development workflow menu.") +@click.command(help="Open the SCCFM repository development workflow menu.") def main() -> None: try: _interactive_menu() diff --git a/cisco_sccfm_scripts/prepare_ansible_release.py b/cisco_sccfm_scripts/prepare_ansible_release.py index 58083c3b..231e2397 100644 --- a/cisco_sccfm_scripts/prepare_ansible_release.py +++ b/cisco_sccfm_scripts/prepare_ansible_release.py @@ -29,6 +29,7 @@ r"(?P(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$" ) _IMMUTABLE_INITIAL_SEED_VERSION = "0.39.0" +_MIN_RST_RELEASE_UNDERLINE = 8 _MAINTAINER_GUIDANCE = "prepare the Ansible changelog in source before releasing" @@ -275,8 +276,9 @@ def _rst_headings(lines: list[str]) -> dict[str, int]: if match is None: continue version = match.group("version") - expected = len(f"v{version}") - if index + 1 >= len(lines) or lines[index + 1].rstrip("\n") != "=" * expected: + expected_length = len(f"v{version}") + underline = lines[index + 1].rstrip("\n") if index + 1 < len(lines) else "" + if len(underline) < expected_length or set(underline) != {"="}: raise AnsibleReleaseError(f"invalid RST release heading; {_MAINTAINER_GUIDANCE}") if version in headings: raise AnsibleReleaseError(f"duplicate RST release heading; {_MAINTAINER_GUIDANCE}") @@ -290,7 +292,8 @@ def _retarget_rst_heading(lines: list[str], index: int, release_version: str) -> underline_newline = "\n" if lines[index + 1].endswith("\n") else "" heading = f"v{release_version}" lines[index] = f"{heading}{heading_newline}" - lines[index + 1] = f"{'=' * len(heading)}{underline_newline}" + underline_length = max(len(heading), _MIN_RST_RELEASE_UNDERLINE) + lines[index + 1] = f"{'=' * underline_length}{underline_newline}" def _write_changed(path: Path, content: str, original: str) -> bool: diff --git a/cisco_sccfm_scripts/test_interactive_cli.py b/cisco_sccfm_scripts/test_interactive_cli.py index b2d5d457..bb67d804 100644 --- a/cisco_sccfm_scripts/test_interactive_cli.py +++ b/cisco_sccfm_scripts/test_interactive_cli.py @@ -9,10 +9,8 @@ from pytest import MonkeyPatch -from cisco_sccfm_cli.models import Config -from cisco_sccfm_cli.services import ConfigService +from cisco_sccfm_cli.interactive import customer_tasks from cisco_sccfm_scripts import interactive_cli -from cisco_sccfm_scripts.cli_commands import CliCommand, CliParam, build_cli_tree def test_interactive_task_list_preserves_workflows_under_new_command() -> None: @@ -21,8 +19,8 @@ def test_interactive_task_list_preserves_workflows_under_new_command() -> None: assert task_names == [ "configure-profile", "manage-profiles", - "import-legacy-vault", "run-cli", + "import-legacy-vault", "run-ansible", "build-collection", "generate-ansible-docs", @@ -36,116 +34,8 @@ def test_interactive_task_list_preserves_workflows_under_new_command() -> None: "format", ] - -def test_update_profile_uses_canonical_config_service( - tmp_path: Path, - monkeypatch: MonkeyPatch, -) -> None: - config_path = tmp_path / "config.json" - service = ConfigService(config_path) - original = Config(profile="lab", region="us", api_token="old-token") - service.save(original) - - monkeypatch.setattr(interactive_cli, "_select_profile", lambda _: original) - monkeypatch.setattr( - "cisco_sccfm_cli.services.ConfigService", - lambda: service, - ) - region_prompt = MagicMock() - region_prompt.unsafe_ask.return_value = "eu" - token_prompt = MagicMock() - token_prompt.unsafe_ask.return_value = "new-token" - monkeypatch.setattr(interactive_cli.questionary, "select", lambda *a, **k: region_prompt) - monkeypatch.setattr(interactive_cli.questionary, "password", lambda *a, **k: token_prompt) - - interactive_cli._update_profile() - - assert service.load("lab") == Config(profile="lab", region="eu", api_token="new-token") - - -def test_remove_profile_uses_canonical_config_service( - tmp_path: Path, - monkeypatch: MonkeyPatch, -) -> None: - config_path = tmp_path / "config.json" - service = ConfigService(config_path) - existing = Config(profile="lab", region="us", api_token="token") - service.save(existing) - - monkeypatch.setattr(interactive_cli, "_select_profile", lambda _: existing) - monkeypatch.setattr("cisco_sccfm_cli.services.ConfigService", lambda: service) - confirmation = MagicMock() - confirmation.unsafe_ask.return_value = True - monkeypatch.setattr(interactive_cli.questionary, "confirm", lambda *a, **k: confirmation) - - interactive_cli._remove_profile() - - assert service.load("lab") is None - - -def test_configure_command_exposes_hidden_input_for_api_token() -> None: - tree = build_cli_tree() - configure = next(node for node in tree if node.name == "configure") - assert isinstance(configure, CliCommand) - - api_token = next(param for param in configure.params if param.flag == "--api-token") - - assert api_token.hide_input is True - - -def test_execute_cli_command_masks_secrets_and_uses_password_prompt( - monkeypatch: MonkeyPatch, -) -> None: - command = CliCommand( - name="configure", - description="Configure a profile", - args=["configure"], - params=[ - CliParam(label="Region", flag="--region", required=True), - CliParam(label="API token", flag="--api-token", required=True, hide_input=True), - ], - ) - prompt = MagicMock() - prompt.unsafe_ask.side_effect = ["us", "super-secret"] - password_prompt = MagicMock(return_value=prompt) - monkeypatch.setattr(interactive_cli.questionary, "text", MagicMock(return_value=prompt)) - monkeypatch.setattr(interactive_cli.questionary, "password", password_prompt) - printed: list[str] = [] - monkeypatch.setattr(interactive_cli.console, "print", lambda message: printed.append(message)) - call = MagicMock(return_value=0) - monkeypatch.setattr(interactive_cli.subprocess, "call", call) - cli_main = MagicMock() - monkeypatch.setattr("cisco_sccfm_cli.cli.cli.main", cli_main) - - interactive_cli._execute_cli_command(command) - - password_prompt.assert_called_once() - call.assert_not_called() - cli_main.assert_called_once_with( - args=["configure", "--region", "us", "--api-token", "super-secret"], - prog_name="sccfm-cli", - standalone_mode=False, - ) - rendered = " ".join(printed) - assert "super-secret" not in rendered - assert "--api-token '***'" in rendered - - -def test_execute_cli_command_without_secrets_uses_subprocess( - monkeypatch: MonkeyPatch, -) -> None: - command = CliCommand( - name="status", - description="Show status", - args=["status"], - params=[], - ) - call = MagicMock(return_value=0) - monkeypatch.setattr(interactive_cli.subprocess, "call", call) - - interactive_cli._execute_cli_command(command) - - call.assert_called_once_with(["sccfm-cli", "status"], cwd=interactive_cli._project_root()) + public_task_names = [task.name for task in customer_tasks()] + assert task_names[: len(public_task_names)] == public_task_names def test_run_ansible_examples_omits_vault_argument_when_not_required( diff --git a/cisco_sccfm_scripts/verify_python_artifacts.py b/cisco_sccfm_scripts/verify_python_artifacts.py index 08a18d3a..7f94ddf4 100644 --- a/cisco_sccfm_scripts/verify_python_artifacts.py +++ b/cisco_sccfm_scripts/verify_python_artifacts.py @@ -48,7 +48,10 @@ "SECURITY.md", } ) -_EXPECTED_SCRIPTS = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} +_EXPECTED_SCRIPTS = { + "sccfm-cli": "cisco_sccfm_cli.cli:cli", + "sccfm-cli-interactive": "cisco_sccfm_cli.interactive:main", +} _EXPECTED_LICENSE_FILES = ("LICENSE", "LICENSES/Apache-2.0.txt") _APACHE_2_LICENSE_SHA256 = "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" _MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(\s*(?:<(?P[^>]+)>|(?P[^\s)]+))") @@ -227,10 +230,12 @@ def _entry_points(raw: bytes) -> dict[tuple[str, str], str]: def _verify_entry_points(raw: bytes) -> None: - """Require the sole supported public console entry point.""" + """Require exactly the supported public console entry points.""" expected = {("console_scripts", name): target for name, target in _EXPECTED_SCRIPTS.items()} if _entry_points(raw) != expected: - raise PythonArtifactVerificationError("wheel does not expose exactly the sccfm-cli command") + raise PythonArtifactVerificationError( + "wheel does not expose exactly the supported public commands" + ) def _verify_markdown_links(text: str, source: str) -> None: @@ -303,7 +308,9 @@ def _verify_sdist_pyproject(raw: bytes) -> None: scripts = project.get("scripts") if scripts != _EXPECTED_SCRIPTS: - raise PythonArtifactVerificationError("sdist does not expose exactly the sccfm-cli command") + raise PythonArtifactVerificationError( + "sdist does not expose exactly the supported public commands" + ) def _verify_wheel(path: Path, version: str) -> int: diff --git a/dev/consistency-checklists/claude-consistency.md b/dev/consistency-checklists/claude-consistency.md index f1eb0181..d16b6f36 100644 --- a/dev/consistency-checklists/claude-consistency.md +++ b/dev/consistency-checklists/claude-consistency.md @@ -400,8 +400,9 @@ ### 16.1 Poetry / `pyproject.toml` - **Invariants:** - Dependencies added via `poetry add`; dev deps in `[tool.poetry.group.dev.dependencies]`. - - Public entry point: `sccfm-cli`; `sccfm-cli-interactive` and other maintainer entry points - come from the local `devtools/` package in the development dependency group. + - Public entry points: `sccfm-cli` and the customer-only `sccfm-cli-interactive` menu. + - `sccfm-devkit` and other maintainer entry points come from the local `devtools/` package in + the development dependency group. - Tool configs (black, isort, mypy, pytest, coverage) all live in `pyproject.toml`. ### 16.2 Pre-commit diff --git a/dev/consistency-checklists/codex-consistency.md b/dev/consistency-checklists/codex-consistency.md index f597976c..676f573a 100644 --- a/dev/consistency-checklists/codex-consistency.md +++ b/dev/consistency-checklists/codex-consistency.md @@ -431,14 +431,14 @@ Main test locations: ## 22. Interactive CLI and discoverability -- [ ] New CLI commands remain discoverable through Click introspection, which - powers the `sccfm-cli-interactive` runner. -- [ ] New example playbooks remain runnable through the interactive example runner. +- [ ] New CLI commands remain discoverable through Click introspection, which powers the public + `sccfm-cli-interactive` customer menu and the development-only `sccfm-devkit` menu. +- [ ] New example playbooks remain runnable through the `sccfm-devkit` example runner. - [ ] Setup/lint/test/build workflows stay aligned between scripts and docs. Primary locations: +- `cisco_sccfm_cli/interactive.py` - `cisco_sccfm_scripts/interactive_cli.py` -- `cisco_sccfm_scripts/cli_commands.py` - `cisco_sccfm_scripts/setup_environment.sh` - `README.md` - `CONTRIBUTING.md` diff --git a/devtools/pyproject.toml b/devtools/pyproject.toml index 2cd6aa76..46e1e7c3 100644 --- a/devtools/pyproject.toml +++ b/devtools/pyproject.toml @@ -9,7 +9,7 @@ description = "Local-only console entry points for SCCFM maintainers" requires-python = ">=3.12,<4.0" [project.scripts] -sccfm-cli-interactive = "cisco_sccfm_scripts.interactive_cli:main" +sccfm-devkit = "cisco_sccfm_scripts.interactive_cli:main" build-ansible-collection = "cisco_sccfm_scripts.build_ansible_collection:main" generate-ansible-docs = "cisco_sccfm_scripts.generate_ansible_docs:main" generate-cli-docs = "cisco_sccfm_scripts.generate_cli_docs:main" diff --git a/poetry.lock b/poetry.lock index eb6596a3..032d7c4c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1268,7 +1268,7 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, @@ -1698,7 +1698,7 @@ version = "2.1.1" description = "Python library to build pretty command line user prompts ⭐️" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59"}, {file = "questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d"}, @@ -1897,7 +1897,7 @@ version = "0.2.14" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, @@ -1906,4 +1906,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "881e29072275a61cf5f391e2de12831144d985912c88efec4b52aec3597ce337" +content-hash = "654c3c4d58e46300af3859b2f4534286df028d0406abb676013970ea5da3a08f" diff --git a/pyproject.toml b/pyproject.toml index a3c3954c..0eb536ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "click>=8.3.3,<9", "rich>=14.2.0,<15", "click-option-group>=0.5.9,<0.6", + "questionary>=2.1.1,<3", "scc-firewall-manager-sdk==1.17.27", "paramiko>=5.0.0,<6", "cryptography>=50.0.0,<51", @@ -40,6 +41,7 @@ Changelog = "https://github.com/CiscoDevNet/sccfm-devkit/releases" [project.scripts] sccfm-cli = "cisco_sccfm_cli.cli:cli" +sccfm-cli-interactive = "cisco_sccfm_cli.interactive:main" [tool.poetry] packages = [ @@ -77,7 +79,6 @@ pre-commit = "^4.5.0" flake8 = "^7.1.1" commitizen = "^3.27.0" reuse = "^6.2.0" -questionary = "^2.1.1" [build-system] requires = ["poetry-core>=2.2.0,<3.0.0"] diff --git a/sccfm-ansible/CHANGELOG.rst b/sccfm-ansible/CHANGELOG.rst index 5f79a3d8..13a38b33 100644 --- a/sccfm-ansible/CHANGELOG.rst +++ b/sccfm-ansible/CHANGELOG.rst @@ -4,6 +4,14 @@ Cisco SCCFM Collection Release Notes .. contents:: Topics +v0.39.2 +======== + +Bugfixes +-------- + +- Added the documented ``sccfm-cli-interactive`` command to the paired Python package while keeping repository maintenance tasks out of public artifacts. + v0.39.1 ======= diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index c1ed0468..300a67fb 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -55,9 +55,11 @@ See instructions in the [INSTALL.md](INSTALL.md) file. ### Local Development +Run these commands from an activated repository checkout. + **Build and install (recommended):** ```bash -sccfm-cli-interactive +sccfm-devkit # then select "build-collection" from the menu ``` @@ -94,14 +96,16 @@ Windows, the store inherits the user's profile-directory ACLs. Ansible modules and inventory load the selected profile directly; do not duplicate its API token in environment variables or Ansible Vault. -If you used a release that stored SCCFM tokens in Ansible Vault, import them without -modifying the source vault: +When developing from a source checkout, profiles from the former Ansible Vault token store can +be imported without modifying the source vault: ```bash -sccfm-cli-interactive +sccfm-devkit # select "import-legacy-vault" ``` +This migration helper is development-only and is not included in the public Python package. +
Set up Ansible-specific device secrets @@ -184,7 +188,7 @@ Each device gets the following variables: ## Modules -Generated module and inventory reference docs can be previewed locally. Generate them with: +From an activated repository checkout, generate module and inventory reference docs with: ```bash generate-ansible-docs diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml index e46a6832..6d8a3824 100644 --- a/sccfm-ansible/changelogs/changelog.yaml +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -2,6 +2,13 @@ ancestor: null # sccfm-release-retarget-seed: 0.39.0 releases: + 0.39.2: + changes: + bugfixes: + - Added the documented ``sccfm-cli-interactive`` command to the paired Python + package while keeping repository maintenance tasks out of public artifacts. + fragments: [] + release_date: '2026-08-19' 0.39.1: changes: release_summary: >- diff --git a/tests/test_development_commands.py b/tests/test_development_commands.py index 044382ca..a586e4ab 100644 --- a/tests/test_development_commands.py +++ b/tests/test_development_commands.py @@ -22,7 +22,7 @@ "generate-cli-docs": "cisco_sccfm_scripts.generate_cli_docs:main", "generate-cli-man-docs": "cisco_sccfm_scripts.generate_cli_man_docs:main", "install-cli-man-docs": "cisco_sccfm_scripts.install_cli_man_docs:main", - "sccfm-cli-interactive": "cisco_sccfm_scripts.interactive_cli:main", + "sccfm-devkit": "cisco_sccfm_scripts.interactive_cli:main", "sync-docs-readme": "cisco_sccfm_scripts.sync_docs_readme:main", } DOCUMENTATION_COMMANDS = ( diff --git a/tests/test_prepare_ansible_release.py b/tests/test_prepare_ansible_release.py index 9a0daf12..bdb4528f 100644 --- a/tests/test_prepare_ansible_release.py +++ b/tests/test_prepare_ansible_release.py @@ -101,7 +101,7 @@ def test_retargets_only_the_initial_release_metadata(tmp_path: Path) -> None: assert release["fragments"] == ["1.0.0.yml"] assert release["changes"] == {"release_summary": _SUMMARY} rst = (root / "CHANGELOG.rst").read_text(encoding="utf-8") - assert "v1.0.0\n======" in rst + assert "v1.0.0\n========" in rst assert "v0.39.0" not in rst assert _SUMMARY in rst @@ -366,6 +366,20 @@ def test_rejects_malformed_rst_release_heading(tmp_path: Path) -> None: prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) +def test_accepts_rst_release_underline_longer_than_heading(tmp_path: Path) -> None: + rst_content = _rst_release().replace("v0.39.0\n=======\n", "v0.39.0\n========\n") + root = _collection(tmp_path, rst_content=rst_content) + + result = prepare_ansible_release( + root, + _INITIAL_VERSION, + _RELEASE_VERSION, + _RELEASE_DATE, + ) + + assert result.changed + + def test_rejects_duplicate_yaml_keys(tmp_path: Path) -> None: yaml_content = _yaml_release().replace( " release_date: '2026-07-27'", diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index e73a556f..1baaacba 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -226,6 +226,9 @@ def test_workflows_separate_automatic_preparation_from_manual_deployment() -> No assert prepare.count("verify_python_distribution \\") == 2 assert 'steps.artifacts.outputs.wheel_path }}" wheel' in prepare assert 'steps.artifacts.outputs.sdist_path }}" sdist' in prepare + assert 'local smoke_interactive="${smoke_root}/venv/bin/sccfm-cli-interactive"' in prepare + assert '"sccfm-cli-interactive": "cisco_sccfm_cli.interactive:main"' in prepare + assert '"${smoke_interactive}" --help >/dev/null' in prepare assert "git push --atomic" in prepare assert "actions/upload-artifact" in prepare @@ -271,6 +274,7 @@ def test_workflows_separate_automatic_preparation_from_manual_deployment() -> No assert "pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33" in pypi assert "pypa/gh-action-pypi-publish@release/v1" not in pypi assert "secrets.PYPI_API_TOKEN" in pypi + assert '"${INSTALL_ROOT}/venv/bin/sccfm-cli-interactive" --help >/dev/null' in pypi assert "skip-existing:" not in pypi assert 'MISSING_FILES="${PYPI_VERIFICATION##* missing=}"' in pypi assert 'test "$(find dist -mindepth 1 -maxdepth 1 -type f' in pypi diff --git a/tests/test_verify_python_artifacts.py b/tests/test_verify_python_artifacts.py index 32d5de87..0f5a7569 100644 --- a/tests/test_verify_python_artifacts.py +++ b/tests/test_verify_python_artifacts.py @@ -20,7 +20,11 @@ _VERSION = "1.2.3" _DIST_INFO = f"cisco_sccfm_devkit-{_VERSION}.dist-info" -_ENTRY_POINTS = b"[console_scripts]\nsccfm-cli=cisco_sccfm_cli.cli:cli\n" +_ENTRY_POINTS = ( + b"[console_scripts]\n" + b"sccfm-cli=cisco_sccfm_cli.cli:cli\n" + b"sccfm-cli-interactive=cisco_sccfm_cli.interactive:main\n" +) _DESCRIPTION = b"# Synthetic package\n\nSee [documentation](https://example.com/docs).\n" _LICENSE = (Path(__file__).resolve().parents[1] / "LICENSE").read_bytes() _METADATA_HEADERS = ( @@ -42,6 +46,7 @@ [project.scripts] sccfm-cli = "cisco_sccfm_cli.cli:cli" +sccfm-cli-interactive = "cisco_sccfm_cli.interactive:main" [tool.poetry] packages = [ @@ -157,24 +162,22 @@ def test_verifier_rejects_non_public_members(tmp_path: Path, artifact: str, memb def test_verifier_rejects_additional_wheel_entry_point(tmp_path: Path) -> None: - entry_points = ( - _ENTRY_POINTS + b"sccfm-cli-interactive=cisco_sccfm_scripts.interactive_cli:main\n" - ) + entry_points = _ENTRY_POINTS + b"sccfm-maintainer=maintainer:main\n" wheel, sdist = _build_artifacts(tmp_path, entry_points=entry_points) - with pytest.raises(PythonArtifactVerificationError, match="exactly the sccfm-cli"): + with pytest.raises(PythonArtifactVerificationError, match="supported public commands"): verify_python_artifacts(wheel, sdist) def test_verifier_rejects_additional_sdist_entry_point(tmp_path: Path) -> None: pyproject = _PYPROJECT.replace( - b'sccfm-cli = "cisco_sccfm_cli.cli:cli"\n', - b'sccfm-cli = "cisco_sccfm_cli.cli:cli"\n' - b'sccfm-cli-interactive = "cisco_sccfm_scripts.interactive_cli:main"\n', + b'sccfm-cli-interactive = "cisco_sccfm_cli.interactive:main"\n', + b'sccfm-cli-interactive = "cisco_sccfm_cli.interactive:main"\n' + b'sccfm-maintainer = "maintainer:main"\n', ) wheel, sdist = _build_artifacts(tmp_path, pyproject=pyproject) - with pytest.raises(PythonArtifactVerificationError, match="exactly the sccfm-cli"): + with pytest.raises(PythonArtifactVerificationError, match="supported public commands"): verify_python_artifacts(wheel, sdist)