Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ae4f71f
Try fix for click 8.2 issue
strickvl Oct 10, 2025
d28f68b
Fix typo
strickvl Oct 10, 2025
5d07137
Fixes for CI
strickvl Oct 20, 2025
5ce39d5
mypy fix
strickvl Oct 20, 2025
7476168
Darglint fixes
strickvl Oct 20, 2025
db1907a
Fix type hint for term_len in formatter.py
strickvl Oct 20, 2025
e891353
Merge branch 'feature/update-modal-step-operator' into feature/handle…
strickvl Oct 20, 2025
ad309a8
Fix CLI test failures with Click >=8.2 by using compatibility helper
strickvl Oct 20, 2025
515610f
Document CLI testing best practices for Click compatibility
strickvl Oct 20, 2025
37ba025
Add --test flag to init command tests to bypass email prompt
strickvl Oct 20, 2025
e331ce8
Improve exception handling and type hints in CLI formatter
strickvl Oct 21, 2025
92baa4d
Fix clean command test to properly use boolean flag
strickvl Oct 21, 2025
5fae563
Remove unused type: ignore comment in CLI formatter
strickvl Oct 21, 2025
65abb21
Add missing init file
strickvl Oct 21, 2025
0ba7fdf
Formatting
strickvl Oct 21, 2025
8cca137
Fix test failures by renaming utils test directory
strickvl Oct 21, 2025
be76ce5
Fix test collection failures in test_step_context
strickvl Oct 21, 2025
60f14ae
Fix failing windows DB lock in integration tests
strickvl Oct 21, 2025
6dd8121
Remove duplicated close method
strickvl Oct 21, 2025
de20aff
Define the close method on the ZenStoreInterface
strickvl Oct 22, 2025
37dd260
Improve comment clarity on Click 8.2+ help behavior
strickvl Oct 22, 2025
ff10561
Remove unnecessary formatter fallback branch
strickvl Oct 22, 2025
d9e6158
.gitignore updates
strickvl Oct 22, 2025
aac47c2
Simplify Click 8.2+ fix by reverting unnecessary formatter changes
strickvl Oct 22, 2025
9b2b3bf
Restore type ignore comment for write_dl call
strickvl Oct 22, 2025
f89bb14
Fix test_reusing_private_secret_name_succeeds on REST stores
strickvl Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ Use filesystem navigation tools to explore the codebase structure as needed.
- For full coverage, use CI (see CI section below)
- Some tests use: `bash scripts/test-coverage-xml.sh` (but this won't run all tests)

#### CLI Testing Best Practices
- When writing new CLI tests, use the `cli_runner()` helper from `tests/integration/functional/cli/utils.py` instead of directly instantiating `CliRunner()`
- The helper handles Click version compatibility (Click 8.2+ removed the `mix_stderr` parameter)
- Always check `result.exit_code` after invoking CLI commands to catch failures early
- Existing tests with `CliRunner()` (no arguments) are fine - only new tests need the helper

## Development Workflow

### Prerequisites
Expand Down
41 changes: 31 additions & 10 deletions src/zenml/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,29 @@ def format_commands(
help_ = cmd.get_short_help_str(limit=formatter.width)
rows.append((tag.value, subcommand, help_))
if rows:
colored_section_title = (
"[dim cyan]Available ZenML Commands (grouped)[/dim cyan]"
)
with formatter.section(colored_section_title):
formatter.write_dl(rows) # type: ignore[arg-type]


@click.group(cls=ZenMLCLI)
if isinstance(formatter, ZenFormatter):
section_title = "[dim cyan]Available ZenML Commands (grouped)[/dim cyan]"
with formatter.section(section_title):
formatter.write_dl(rows)
else:
# Fallback: use simple pairs without category and avoid rich markup in header
section_title = "Available ZenML Commands"
with formatter.section(section_title):
pair_rows: List[Tuple[str, str]] = [
(subcmd, help_) for _, subcmd, help_ in rows
]
formatter.write_dl(pair_rows)


@click.group(cls=ZenMLCLI, invoke_without_command=True)
@click.version_option(__version__, "--version", "-v")
def cli() -> None:
"""CLI base command for ZenML."""
@click.pass_context
def cli(ctx: click.Context) -> None:
"""CLI base command for ZenML.

Args:
ctx: The click context.
"""
set_root_verbosity()
source_context.set(SourceContextTypes.CLI)
repo_root = Client.find_repository()
Expand All @@ -158,6 +170,15 @@ def cli() -> None:
# packages directory
source_utils.set_custom_source_root(source_root=os.getcwd())

# Manually show help and exit with code 0 when invoked without a subcommand.
# Click 8.2+ raises NoArgsIsHelpError before the callback runs when
# no_args_is_help=True. By relying solely on invoke_without_command=True
# and handling help here, we ensure consistent behavior across Click
# versions while leveraging our custom help formatter in ZenMLCLI.get_help().
if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I really get why this is necessary. Why wouldn't click display the help anymore in newer versions with our previous setup?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue isn't that Click won't display help in newer versions (it will). The problem is that Click 8.2+ would display help using its default formatter instead of our custom ZenMLCLI.get_help() formatter.

(As far as I understand it, without invoke_without_command=True, Click defaults to no_args_is_help=True. In Click 8.2+, this causes Click to raise NoArgsIsHelpError before our cli() callback runs, meaning it handles the help display internally with its default formatter, completely bypassing our custom formatting (categories, rich colors, custom layout). By using invoke_without_command=True and manually checking for no subcommand in the callback, we ensure our custom formatter is always used across all Click versions.)

I will improve the comment to make it clearer maybe.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just that I can't reproduce it. I have click 8.2+, I run zenml or zenml --help, and everything shows up correct (on develop). So it uses the correct formatter I assume?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if the code that defined invoke_without_command=True gets executed, at that point anyway I should also know about our custom class and use that context/formatter I assume?

ctx.command.get_help(ctx)
ctx.exit(0)


if __name__ == "__main__":
cli()
241 changes: 146 additions & 95 deletions src/zenml/cli/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,52 @@
# permissions and limitations under the License.
"""Helper functions to format output for CLI."""

from typing import Dict, Iterable, Iterator, Optional, Sequence, Tuple
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Sequence,
Tuple,
)

from click import formatting
from click._compat import term_len

if TYPE_CHECKING:
term_len: Callable[[str], int]
else:
try:
from click.utils import term_len
except Exception:
from click._compat import term_len


def _safe_width(
width: Optional[int], max_width: Optional[int], default: int = 80
) -> int:
"""Return a non-None width value suitable for wrapping.

Click sets width to None in some non-interactive contexts. This helper
ensures we always get a valid integer width, preferring the explicit width
first, then max_width, then a sensible default.

Args:
width: Explicit terminal or content width to use when available.
max_width: Upper bound to apply if width is not set or invalid.
default: Fallback width used when neither width nor max_width is a
positive integer.

Returns:
The effective positive integer width used for wrapping.
"""
if isinstance(width, int) and width > 0:
return width
if isinstance(max_width, int) and max_width > 0:
return max_width
return default


def measure_table(rows: Iterable[Tuple[str, ...]]) -> Tuple[int, ...]:
Expand Down Expand Up @@ -79,105 +121,114 @@ def write_dl(
col_max: int = 30,
col_spacing: int = 2,
) -> None:
"""Writes a definition list into the buffer.
"""Writes a definition list into the formatter buffer.

This is how options and commands are usually formatted.
Click 8.2 tightened validation so that definition list entries must be
pairs. Our CLI groups commands in tagged triples, so we detect that
case and render it manually while delegating the standard behavior to
Click for the classic two-column output. This keeps the formatter
compatible with both older Click releases (<8.2) and the newer ones
without sacrificing the custom grouped layout.

Arguments:
rows: a list of items as tuples for the terms and values.
col_max: the maximum width of the first column.
col_spacing: the number of spaces between the first and
second column (and third).

The default behavior is to format the rows in a definition list
with rows of 2 columns following the format ``(term, value)``.
But for new CLI commands, we want to format the rows in a definition
list with rows of 3 columns following the format
``(term, value, description)``.
Args:
rows: A sequence of tuples that represent the definition list
entries.
col_max: The maximum width of the first column.
col_spacing: The number of spaces between columns.

Raises:
TypeError: if the number of columns is not 2 or 3.
TypeError: If the provided rows do not represent two or three
column entries.
"""
rows = list(rows)
normalized_rows: List[Tuple[str, ...]] = [tuple(row) for row in rows]

if not normalized_rows:
return

unique_lengths = {len(row) for row in normalized_rows}

if unique_lengths == {2}:
two_col_rows = [(row[0], row[1]) for row in normalized_rows]
super().write_dl(
two_col_rows, col_max=col_max, col_spacing=col_spacing
)
return

if unique_lengths == {3}:
self._write_triple_definition_list(
[(row[0], row[1], row[2]) for row in normalized_rows],
col_max=col_max,
col_spacing=col_spacing,
)
return

raise TypeError(
"Expected either two- or three-column definition list entries."
)

def _write_triple_definition_list(
self,
rows: List[Tuple[str, str, str]],
col_max: int,
col_spacing: int,
) -> None:
widths = measure_table(rows)

if len(widths) == 2:
first_col = min(widths[0], col_max) + col_spacing

for first, second in iter_rows(rows, len(widths)):
self.write(f"{'':>{self.current_indent}}{first}")
if not second:
self.write("\n")
continue
if term_len(first) <= first_col - col_spacing:
self.write(" " * (first_col - term_len(first)))
else:
self.write("\n")
self.write(" " * (first_col + self.current_indent))

text_width = max(self.width - first_col - 2, 10)
wrapped_text = formatting.wrap_text(
second, text_width, preserve_paragraphs=True
)
lines = wrapped_text.splitlines()

if lines:
self.write(f"{lines[0]}\n")

for line in lines[1:]:
self.write(
f"{'':>{first_col + self.current_indent}}{line}\n"
)
else:
self.write("\n")

elif len(widths) == 3:
first_col = min(widths[0], col_max) + col_spacing
second_col = min(widths[1], col_max) + col_spacing * 2

current_tag = None
for first, second, third in iter_rows(rows, len(widths)):
if current_tag != first:
current_tag = first
self.write("\n")
# Adding [#431d93] [/#431d93] makes the tag colorful when
# it is printed by rich print
self.write(
f"[#431d93]{'':>{self.current_indent}}{first}:[/#431d93]\n"
)

if not third:
self.write("\n")
continue

if term_len(first) <= first_col - col_spacing:
self.write(" " * self.current_indent * 2)
else:
self.write("\n")
self.write(" " * (first_col + self.current_indent))

self.write(f"{'':>{self.current_indent}}{second}")

text_width = max(self.width - second_col - 4, 10)
wrapped_text = formatting.wrap_text(
third, text_width, preserve_paragraphs=True
)
lines = wrapped_text.splitlines()

if lines:
self.write(
" "
* (second_col - term_len(second) + self.current_indent)
)
self.write(f"{lines[0]}\n")

for line in lines[1:]:
self.write(
f"{'':>{second_col + self.current_indent * 4}}{line}\n"
)
else:
self.write("\n")
else:
if len(widths) < 3:
raise TypeError(
"Expected either three or two columns for definition list"
"Expected three columns for tagged definition list entries."
)

# Compute target column offsets with spacing applied
first_col = min(widths[0], col_max) + col_spacing
second_col = min(widths[1], col_max) + col_spacing * 2

current_tag: Optional[str] = None

for first, second, third in iter_rows(rows, 3):
# Print a category header when the tag changes
if current_tag != first:
current_tag = first
self.write("\n")
self.write(
f"[#431d93]{'':>{self.current_indent}}{first}:[/#431d93]\n"
)

# Preserve behavior for empty third-column values
if not third:
self.write("\n")
continue

# Layout for the command name column
if term_len(first) <= first_col - col_spacing:
self.write(" " * (self.current_indent * 2))
else:
self.write("\n")
self.write(" " * (first_col + self.current_indent))

# Command name with current indentation
self.write(f"{'':>{self.current_indent}}{second}")

# Wrap and render the description using a safe width calculation
effective_width = _safe_width(
self.width, getattr(self, "max_width", None)
)
text_width = max(effective_width - second_col - 4, 10)
wrapped_text = formatting.wrap_text(
third, text_width, preserve_paragraphs=True
)
lines = wrapped_text.splitlines()

if lines:
# First line appears on the same row as the command name
self.write(
" " * (second_col - term_len(second) + self.current_indent)
)
self.write(f"{lines[0]}\n")

# Continuation lines align with the description column
for line in lines[1:]:
self.write(" " * (second_col + self.current_indent))
self.write(f"{line}\n")
else:
self.write("\n")
18 changes: 11 additions & 7 deletions tests/integration/functional/cli/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
from pathlib import Path

import pytest
from click.testing import CliRunner

from tests.integration.functional.cli.utils import cli_runner
from zenml.cli.base import ZENML_PROJECT_TEMPLATES, clean, init
from zenml.constants import CONFIG_FILE_NAME, REPOSITORY_DIRECTORY_NAME
from zenml.utils import yaml_utils
Expand All @@ -27,8 +27,9 @@

def test_init_creates_zen_folder(tmp_path: Path) -> None:
"""Check that init command creates a .zen folder."""
runner = CliRunner()
runner.invoke(init, ["--path", str(tmp_path)])
runner = cli_runner()
result = runner.invoke(init, ["--path", str(tmp_path), "--test"])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert (tmp_path / REPOSITORY_DIRECTORY_NAME).exists()


Expand All @@ -44,17 +45,19 @@ def test_init_creates_from_templates(
tmp_path: Path, template_name: str
) -> None:
"""Check that init command checks-out template."""
runner = CliRunner()
runner.invoke(
runner = cli_runner()
result = runner.invoke(
init,
[
"--path",
str(tmp_path),
"--template",
template_name,
"--template-with-defaults",
"--test",
],
)
assert result.exit_code == 0, f"Command failed: {result.output}"
assert (tmp_path / REPOSITORY_DIRECTORY_NAME).exists()
files_in_top_level = set([f.lower() for f in os.listdir(str(tmp_path))])
must_have_files = {
Expand All @@ -75,8 +78,9 @@ def test_clean_user_config(clean_client) -> None:
user_id = yaml_contents["user_id"]
analytics_opt_in = yaml_contents["analytics_opt_in"]
version = yaml_contents["version"]
runner = CliRunner()
runner.invoke(clean, ["--yes", True])
runner = cli_runner()
result = runner.invoke(clean, ["--yes", True])
assert result.exit_code == 0, f"Command failed: {result.output}"
new_yaml_contents = yaml_utils.read_yaml(str(global_zen_config_yaml))
assert user_id == new_yaml_contents["user_id"]
assert analytics_opt_in == new_yaml_contents["analytics_opt_in"]
Expand Down
Loading
Loading