From cc67084d19b996fa6512f964b58e603aab23ba71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Thu, 30 Apr 2026 13:57:33 +0200 Subject: [PATCH 1/4] [CDF-27845] Apply ToolkitPanel to deploy command output Replace bare rich.panel.Panel usage with ToolkitPanel from ui.py, consistent with the build v2 styling improvements. Also consolidates three separate clean-mode panels into one with dynamic flag text. Co-Authored-By: Claude Sonnet 4.6 --- cognite_toolkit/_cdf_tk/commands/deploy.py | 27 ++++++++-------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/commands/deploy.py b/cognite_toolkit/_cdf_tk/commands/deploy.py index 0cdf5a0d59..490cbdb2b7 100644 --- a/cognite_toolkit/_cdf_tk/commands/deploy.py +++ b/cognite_toolkit/_cdf_tk/commands/deploy.py @@ -6,7 +6,6 @@ from cognite.client.exceptions import CogniteAPIError, CogniteDuplicatedError from rich import print from rich.markup import escape -from rich.panel import Panel from cognite_toolkit._cdf_tk.client import ToolkitClient from cognite_toolkit._cdf_tk.client._resource_base import T_Identifier, T_RequestResource, T_ResponseResource @@ -56,6 +55,7 @@ LowSeverityWarning, ToolkitDependenciesIncludedWarning, ) +from cognite_toolkit._cdf_tk.ui import ToolkitPanel from cognite_toolkit._cdf_tk.utils import humanize_collection, read_yaml_file from cognite_toolkit._cdf_tk.utils.auth import EnvironmentVariables @@ -168,16 +168,11 @@ def _order_loaders( @staticmethod def _start_message(build_dir: Path, dry_run: bool, env_vars: EnvironmentVariables) -> None: - environment_vars = "" - if not _RUNNING_IN_BROWSER: - environment_vars = f"\n\nConnected to {env_vars.as_string()}" verb = "Checking" if dry_run else "Deploying" - print( - Panel( - f"[bold]{verb}[/]resource files from {build_dir} directory.{environment_vars}", - expand=False, - ) - ) + content = f"[bold]{verb}[/] resource files from {build_dir} directory." + if not _RUNNING_IN_BROWSER: + content += f"\n\nConnected to {env_vars.as_string()}" + print(ToolkitPanel(content, title="[bold]Deploy[/]", expand=False)) def clean_all_resources( self, @@ -193,14 +188,10 @@ def clean_all_resources( verbose: bool, ) -> None: # Drop has to be done in the reverse order of deploy. - if drop and drop_data: - print(Panel("[bold] Cleaning resources as --drop and --drop-data are passed[/]")) - elif drop: - print(Panel("[bold] Cleaning resources as --drop is passed[/]")) - elif drop_data: - print(Panel("[bold] Cleaning resources as --drop-data is passed[/]")) - else: + if not (drop or drop_data): return None + flags = "--drop and --drop-data" if (drop and drop_data) else ("--drop" if drop else "--drop-data") + print(ToolkitPanel(f"Cleaning resources as {flags} is passed", title="[bold]Clean[/]", expand=False)) for loader_cls in reversed(ordered_loaders): if not issubclass(loader_cls, ResourceIO): @@ -289,7 +280,7 @@ def deploy_all_resources( """ if verbose: - print(Panel("[bold]DEPLOYING resources...[/]")) + print(ToolkitPanel("[bold]Deploying resources...[/]", title="[bold]Deploy[/]", expand=False)) if ordered_loaders is None: selected_loaders = self._clean_command.get_selected_loaders(build_dir, set(), None) From f0468b00cf8491e159c412a823da3296e597107d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Mon, 4 May 2026 15:38:23 +0200 Subject: [PATCH 2/4] fix(cli): make questionary selection visible on macOS terminals Use reverse+bold for pointer/highlighted styles and merge toolkit questionary defaults so all prompts inherit the fix (CDF-27852). --- cognite_toolkit/_cdf.py | 3 +++ cognite_toolkit/_cdf_tk/ui.py | 32 ++++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/cognite_toolkit/_cdf.py b/cognite_toolkit/_cdf.py index 50ba87073c..b451487c9c 100755 --- a/cognite_toolkit/_cdf.py +++ b/cognite_toolkit/_cdf.py @@ -44,11 +44,14 @@ ) from cognite_toolkit._cdf_tk.feature_flags import Flags from cognite_toolkit._cdf_tk.plugins import Plugins +from cognite_toolkit._cdf_tk.ui import apply_questionary_toolkit_defaults from cognite_toolkit._cdf_tk.utils import ( sentry_exception_filter, ) from cognite_toolkit._version import __version__ as current_version +apply_questionary_toolkit_defaults() + if USE_SENTRY: import sentry_sdk diff --git a/cognite_toolkit/_cdf_tk/ui.py b/cognite_toolkit/_cdf_tk/ui.py index 282d71edd9..d21cbd54b2 100644 --- a/cognite_toolkit/_cdf_tk/ui.py +++ b/cognite_toolkit/_cdf_tk/ui.py @@ -1,8 +1,10 @@ from collections.abc import Sequence from enum import Enum -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, cast import questionary +from prompt_toolkit.styles import Style as PTStyle +from prompt_toolkit.styles import merge_styles from rich import box as rich_box from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderableType, RenderResult from rich.padding import Padding, PaddingDimensions @@ -11,7 +13,15 @@ from rich.table import Table from rich.text import Text -__all__ = ["QUESTIONARY_STYLE", "AuraColor", "ToolkitPanel", "ToolkitPanelSection", "ToolkitTable", "hanging_indent"] +__all__ = [ + "QUESTIONARY_STYLE", + "AuraColor", + "ToolkitPanel", + "ToolkitPanelSection", + "ToolkitTable", + "apply_questionary_toolkit_defaults", + "hanging_indent", +] # https://cognitedata.github.io/aura/primitives/colors @@ -120,13 +130,17 @@ def as_panel_detail(self) -> RenderableType: return Padding(self, (1, 0, 1, 2)) +# "reverse" keeps the active row visible when truecolor (fg:#...) is ignored or low-contrast +# (macOS Terminal / iTerm2); see CDF-27852. +_QUESTIONARY_POINTER_HIGHLIGHT = f"reverse bold {AuraColor.FJORD.fg}" + QUESTIONARY_STYLE = questionary.Style( [ ("qmark", AuraColor.FJORD.fg), # token in front of the question ("question", "bold"), # question text ("answer", f"{AuraColor.NORDIC.fg} bold"), # submitted answer text behind the question - ("pointer", f"{AuraColor.FJORD.fg} bold"), # pointer used in select and checkbox prompts - ("highlighted", f"{AuraColor.FJORD.fg} bold"), # pointed-at choice in select and checkbox prompts + ("pointer", _QUESTIONARY_POINTER_HIGHLIGHT), # pointer used in select and checkbox prompts + ("highlighted", _QUESTIONARY_POINTER_HIGHLIGHT), # pointed-at choice in select and checkbox prompts ("selected", AuraColor.NORDIC.fg), # style for a selected item of a checkbox ("separator", AuraColor.MOUNTAIN.fg), # separator in lists ("instruction", ""), # user instructions for select, rawselect, checkbox @@ -134,3 +148,13 @@ def as_panel_detail(self) -> RenderableType: ("disabled", f"{AuraColor.MOUNTAIN.fg} italic"), # disabled choices for select and checkbox prompts ] ) + + +def apply_questionary_toolkit_defaults() -> None: + """Merge Toolkit questionary styles into library defaults for every prompt.""" + import questionary.constants as qc + import questionary.styles as qstyles + + merged = cast(PTStyle, merge_styles([qc.DEFAULT_STYLE, QUESTIONARY_STYLE])) + qc.DEFAULT_STYLE = merged + qstyles.DEFAULT_STYLE = merged From 0bf07fab131f88fea561c3f35812a7f5760795f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Tue, 5 May 2026 13:38:10 +0200 Subject: [PATCH 3/4] savepoint --- cognite_toolkit/_cdf_tk/ui.py | 10 ++++++++++ cognite_toolkit/_cdf_tk/utils/auth.py | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cognite_toolkit/_cdf_tk/ui.py b/cognite_toolkit/_cdf_tk/ui.py index f797f27f3b..7f7b96cb72 100644 --- a/cognite_toolkit/_cdf_tk/ui.py +++ b/cognite_toolkit/_cdf_tk/ui.py @@ -20,6 +20,7 @@ "AuraColor", "ToolkitPanel", "ToolkitPanelSection", + "ToolkitQuestion", "ToolkitTable", "apply_questionary_toolkit_defaults", "hanging_indent", @@ -156,6 +157,15 @@ def as_panel_detail(self) -> RenderableType: ) +class ToolkitQuestion: + """Callables that build questionary prompts with :data:`QUESTIONARY_STYLE` (CDF-27852).""" + + @staticmethod + def select(*args: Any, **kwargs: Any) -> questionary.Question: + kwargs.setdefault("style", QUESTIONARY_STYLE) + return questionary.select(*args, **kwargs) + + def apply_questionary_toolkit_defaults() -> None: """Merge Toolkit questionary styles into library defaults for every prompt.""" merged = cast(PTStyle, merge_styles([qc.DEFAULT_STYLE, QUESTIONARY_STYLE])) diff --git a/cognite_toolkit/_cdf_tk/utils/auth.py b/cognite_toolkit/_cdf_tk/utils/auth.py index d6c2066508..9ad287bf2b 100644 --- a/cognite_toolkit/_cdf_tk/utils/auth.py +++ b/cognite_toolkit/_cdf_tk/utils/auth.py @@ -19,6 +19,7 @@ from cognite_toolkit._cdf_tk.client import ToolkitClient, ToolkitClientConfig from cognite_toolkit._cdf_tk.constants import TOOLKIT_CLIENT_ENTRA_ID from cognite_toolkit._cdf_tk.exceptions import AuthenticationError, ToolkitKeyError, ToolkitMissingValueError +from cognite_toolkit._cdf_tk.ui import ToolkitQuestion from cognite_toolkit._cdf_tk.utils import humanize_collection from cognite_toolkit._version import __version__ @@ -455,7 +456,7 @@ def create_dotenv_file(self) -> str: def prompt_user_environment_variables(current: EnvironmentVariables | None = None) -> EnvironmentVariables: - provider = questionary.select( + provider = ToolkitQuestion.select( "Choose the provider (Who authenticates you?)", choices=[ Choice(title=f"{provider}: {description}", value=provider) From 5c9d3949ccefe2dcb2d6f79f00e37c8cfbe53ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Tue, 5 May 2026 13:47:42 +0200 Subject: [PATCH 4/4] fix(cli): extend interactive prompts for macOS and checkbox selection --- .cursor/skills/send-it/SKILL.md | 14 +- cognite_toolkit/_cdf_tk/ui.py | 19 +- .../_cdf_tk/ui_checkbox_follow_pointer.py | 248 ++++++++++++++++++ pyproject.toml | 1 + uv.lock | 2 + 5 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 cognite_toolkit/_cdf_tk/ui_checkbox_follow_pointer.py diff --git a/.cursor/skills/send-it/SKILL.md b/.cursor/skills/send-it/SKILL.md index 3350d1de38..18bd70a2fb 100644 --- a/.cursor/skills/send-it/SKILL.md +++ b/.cursor/skills/send-it/SKILL.md @@ -39,9 +39,19 @@ If the user chose **Stage only**, stop here. - Run `git pull origin` to sync the current branch (ignore errors if the remote branch doesn't exist yet). - Run `git push -u origin HEAD` to push (sets upstream if needed). -## 5. PR creation (if needed) +## 5. PR creation or update (if needed) -- Check if a PR exists: `gh pr view --json url --repo cognitedata/toolkit 2>/dev/null`. +- Check if a PR exists: `gh pr view --json url,body --repo cognitedata/toolkit 2>/dev/null`. +- **If a PR already exists**, update its description to reflect the current state of the branch: + - Read the existing body, compare it to `git log main..HEAD --oneline` and the staged changes. + - If the description is stale or incomplete, propose an updated body and apply it with: + + ```bash + gh api repos/cognitedata/toolkit/pulls/ --method PATCH --field body='...' + ``` + + - Always use the GitHub API directly (`gh api`) rather than `gh pr edit` to avoid GraphQL + deprecation warnings causing false failures. - **If no PR exists**, suggest creating one: - Ask the user for the Jira ticket ID. - Propose a title: `[TICKET-ID] Description of changes`. diff --git a/cognite_toolkit/_cdf_tk/ui.py b/cognite_toolkit/_cdf_tk/ui.py index 7f7b96cb72..c2cbef78c2 100644 --- a/cognite_toolkit/_cdf_tk/ui.py +++ b/cognite_toolkit/_cdf_tk/ui.py @@ -23,6 +23,7 @@ "ToolkitQuestion", "ToolkitTable", "apply_questionary_toolkit_defaults", + "checkbox_follow_pointer", "hanging_indent", ] @@ -158,13 +159,29 @@ def as_panel_detail(self) -> RenderableType: class ToolkitQuestion: - """Callables that build questionary prompts with :data:`QUESTIONARY_STYLE` (CDF-27852).""" + """Namespace for questionary prompts using :data:`QUESTIONARY_STYLE` (CDF-27852). + + - :meth:`select` — single-choice lists. + - :meth:`checkbox_follow_pointer` — checkbox UI where the checked row follows the highlight. + """ @staticmethod def select(*args: Any, **kwargs: Any) -> questionary.Question: kwargs.setdefault("style", QUESTIONARY_STYLE) return questionary.select(*args, **kwargs) + @staticmethod + def checkbox_follow_pointer(*args: Any, **kwargs: Any) -> questionary.Question: + kwargs.setdefault("style", QUESTIONARY_STYLE) + from cognite_toolkit._cdf_tk.ui_checkbox_follow_pointer import _checkbox_follow_pointer + + return _checkbox_follow_pointer(*args, **kwargs) + + +def checkbox_follow_pointer(*args: Any, **kwargs: Any) -> questionary.Question: + """Checkbox prompt where the checked row follows the highlight (alias of :meth:`ToolkitQuestion.checkbox_follow_pointer`).""" + return ToolkitQuestion.checkbox_follow_pointer(*args, **kwargs) + def apply_questionary_toolkit_defaults() -> None: """Merge Toolkit questionary styles into library defaults for every prompt.""" diff --git a/cognite_toolkit/_cdf_tk/ui_checkbox_follow_pointer.py b/cognite_toolkit/_cdf_tk/ui_checkbox_follow_pointer.py new file mode 100644 index 0000000000..70cc68c4a6 --- /dev/null +++ b/cognite_toolkit/_cdf_tk/ui_checkbox_follow_pointer.py @@ -0,0 +1,248 @@ +"""Checkbox prompt where the checked row tracks the keyboard highlight. + +Implementation module for :meth:`ToolkitQuestion.checkbox_follow_pointer`. +""" + +import string +from collections.abc import Callable, Sequence +from typing import Any, Union + +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style +from questionary import utils +from questionary.constants import DEFAULT_QUESTION_PREFIX, DEFAULT_SELECTED_POINTER, INVALID_INPUT +from questionary.prompts import common +from questionary.prompts.common import Choice, InquirerControl, Separator +from questionary.question import Question +from questionary.styles import merge_styles_default + + +class PointerSyncedInquirerControl(InquirerControl): + """Keep ``selected_options`` equal to the highlighted row after each move.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + if self.is_selection_valid(): + self._sync_selection_to_pointer() + + def select_next(self) -> None: + super().select_next() + self._sync_selection_to_pointer() + + def select_previous(self) -> None: + super().select_previous() + self._sync_selection_to_pointer() + + def _sync_selection_to_pointer(self) -> None: + if not self.is_selection_valid(): + return + choice = self.get_pointed_at() + self.selected_options = [choice.value] + + +def _checkbox_follow_pointer( + message: str, + choices: Sequence[Union[str, Choice, dict[str, Any]]], + default: str | None = None, + validate: Callable[[list[str]], Union[bool, str]] = lambda a: True, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: str | None = DEFAULT_SELECTED_POINTER, + style: Style | None = None, + initial_choice: Union[str, Choice, dict[str, Any]] | None = None, + use_arrow_keys: bool = True, + use_jk_keys: bool = True, + use_emacs_keys: bool = True, + use_search_filter: Union[str, bool, None] = False, + instruction: str | None = None, + show_description: bool = True, + **kwargs: Any, +) -> Question: + """Like ``questionary.checkbox``, but moving the pointer updates the selection.""" + if not (use_arrow_keys or use_jk_keys or use_emacs_keys): + raise ValueError("Some option to move the selection is required. Arrow keys or j/k or Emacs keys.") + + if use_jk_keys and use_search_filter: + raise ValueError("Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix.") + + merged_style = merge_styles_default( + [ + Style([("bottom-toolbar", "noreverse")]), + style, + ] + ) + + if not callable(validate): + raise ValueError("validate must be callable") + + ic = PointerSyncedInquirerControl( + choices, + default, + pointer=pointer, + initial_choice=initial_choice, + show_description=show_description, + ) + + def get_prompt_tokens() -> list[tuple[str, str]]: + tokens = [] + + tokens.append(("class:qmark", qmark)) + tokens.append(("class:question", f" {message} ")) + + if ic.is_answered: + nbr_selected = len(ic.selected_options) + if nbr_selected == 0: + tokens.append(("class:answer", "done")) + elif nbr_selected == 1: + if isinstance(ic.get_selected_values()[0].title, list): + ts = ic.get_selected_values()[0].title + tokens.append( + ( + "class:answer", + "".join([token[1] for token in ts]), # type:ignore + ) + ) + else: + tokens.append( + ( + "class:answer", + f"[{ic.get_selected_values()[0].title}]", + ) + ) + else: + tokens.append(("class:answer", f"done ({nbr_selected} selections)")) + else: + if instruction is not None: + tokens.append(("class:instruction", instruction)) + else: + tokens.append( + ( + "class:instruction", + "(Arrow keys move; highlighted row is selected; " + " toggles; " + f"<{'ctrl-a' if use_search_filter else 'a'}> all; " + f"<{'ctrl-i' if use_search_filter else 'i'}> invert" + f"{', type to filter' if use_search_filter else ''})", + ) + ) + return tokens + + def get_selected_values() -> list[Any]: + return [c.value for c in ic.get_selected_values()] + + def perform_validation(selected_values: list[str]) -> bool: + verdict = validate(selected_values) + valid = verdict is True + + if not valid: + if verdict is False: + error_text = INVALID_INPUT + else: + error_text = str(verdict) + + error_message = FormattedText([("class:validation-toolbar", error_text)]) + + ic.error_message = ( + error_message if not valid and ic.submission_attempted else None # type: ignore[assignment] + ) + + return valid + + layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + @bindings.add(" ", eager=True) + def toggle(_event): + pointed_choice = ic.get_pointed_at().value + if pointed_choice in ic.selected_options: + ic.selected_options.remove(pointed_choice) + else: + ic.selected_options.append(pointed_choice) + + perform_validation(get_selected_values()) + + @bindings.add(Keys.ControlI if use_search_filter else "i", eager=True) + def invert(_event): + inverted_selection = [ + c.value + for c in ic.choices + if not isinstance(c, Separator) and c.value not in ic.selected_options and not c.disabled + ] + ic.selected_options = inverted_selection + + perform_validation(get_selected_values()) + + @bindings.add(Keys.ControlA if use_search_filter else "a", eager=True) + def all(_event): + all_selected = True + for c in ic.choices: + if not isinstance(c, Separator) and c.value not in ic.selected_options and not c.disabled: + ic.selected_options.append(c.value) + all_selected = False + if all_selected: + ic.selected_options = [] + + perform_validation(get_selected_values()) + + def move_cursor_down(event): + ic.select_next() + while not ic.is_selection_valid(): + ic.select_next() + + def move_cursor_up(event): + ic.select_previous() + while not ic.is_selection_valid(): + ic.select_previous() + + if use_search_filter: + + def search_filter(event): + ic.add_search_character(event.key_sequence[0].key) + + for character in string.printable: + if character in string.whitespace: + continue + bindings.add(character, eager=True)(search_filter) + bindings.add(Keys.Backspace, eager=True)(search_filter) + + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + bindings.add("j", eager=True)(move_cursor_down) + bindings.add("k", eager=True)(move_cursor_up) + + if use_emacs_keys: + bindings.add(Keys.ControlN, eager=True)(move_cursor_down) + bindings.add(Keys.ControlP, eager=True)(move_cursor_up) + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + selected_values = get_selected_values() + ic.submission_attempted = True + + if perform_validation(selected_values): + ic.is_answered = True + event.app.exit(result=selected_values) + + @bindings.add(Keys.Any) + def other(_event): + """Disallow inserting other text.""" + + return Question( + Application( + layout=layout, + key_bindings=bindings, + style=merged_style, + **utils.used_kwargs(kwargs, Application.__init__), + ) + ) diff --git a/pyproject.toml b/pyproject.toml index 3bfc290ea3..a04ec86f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "pytest-cov >=6.0.0", "setuptools >=75.0.0", "fastparquet >=2024.5.0", + "pyarrow>=20.0.0", "types-requests >=2.32.0.20241016", "types-python-dateutil>=2.9.0.20250708", "marko >=2.1.2", diff --git a/uv.lock b/uv.lock index 3287455b41..eabe6a9fc7 100644 --- a/uv.lock +++ b/uv.lock @@ -360,6 +360,7 @@ dev = [ { name = "marko" }, { name = "mypy" }, { name = "pre-commit" }, + { name = "pyarrow" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-freezegun" }, @@ -412,6 +413,7 @@ dev = [ { name = "marko", specifier = ">=2.1.2" }, { name = "mypy", specifier = ">=1.8.0" }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "pyarrow", specifier = ">=20.0.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-freezegun", specifier = ">=0.4.2" },