From c808fd78f661021e2d191eb7cb24ec39480866be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 08:00:03 +0000 Subject: [PATCH] feat!: pretty-printed output for list commands `list` commands (cube, dimension, process, subset, view) now print each item as a `-` bullet by default, instead of one plain name per line. Pass --output-raw to get the old plain output back, e.g. for scripting. Extracted the shared "resolve database, open TM1Service, call get_all_names(), print" logic into a new generic_list() helper in tm1cli/utils/generic.py, mirroring execute_exists()'s pattern (including its connection-error handling). All five list commands now delegate to it; the per-command files no longer need TM1Service or resolve_database directly. Also fixes MockedCubeService.get_all_names()'s mocked parameter name (cube_name -> skip_control_cubes) to match the real TM1py signature now that generic_list() calls it by keyword, and adds a get_all_names mock for MockedProcessService plus a mocked test_process_list test (process list previously had no mocked coverage at all). BREAKING CHANGE: default stdout of `tm1cli list` (and its ls alias) is no longer plain one-item-per-line output; scripts relying on that must add --output-raw. Bump version to 0.2.1 and update CHANGELOG.md / README.md. --- CHANGELOG.md | 14 ++++++++++++++ README.md | 8 +++++--- pyproject.toml | 2 +- tests/conftest.py | 5 ++++- tests/test_cmd_cubes.py | 14 ++++++++++---- tests/test_cmd_dimension.py | 17 +++++++++++++---- tests/test_cmd_process.py | 18 ++++++++++++++++++ tests/test_cmd_subset.py | 17 +++++++++++++---- tests/test_cmd_view.py | 17 +++++++++++++---- tm1cli/commands/cube.py | 9 ++------- tm1cli/commands/dimension.py | 9 ++------- tm1cli/commands/process.py | 6 ++---- tm1cli/commands/subset.py | 9 ++------- tm1cli/commands/view.py | 9 ++------- tm1cli/utils/generic.py | 28 ++++++++++++++++++++++++++++ 15 files changed, 129 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d67501e..939b661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.2.1 - 2026-07-15 + +### Features + +- `list` commands (`cube`, `dimension`, `process`, `subset`, `view`) now print each + item as a `-` bullet by default, via a new shared `generic_list()` helper. +- `--output-raw` now also applies to `list` commands, printing the old plain, + one-item-per-line output. + +### Breaking changes + +- Default stdout of `tm1cli list` (and its `ls` alias) is no longer plain + one-item-per-line output. Scripts relying on that must add `--output-raw`. + ## v0.2.0 - 2026-07-15 ### Features diff --git a/README.md b/README.md index 603aeb8..d351a39 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,14 @@ tm1cli --help ### Output formatting -By default, `exists` commands print a human-readable result, e.g. `✅ Cube exists!`. -Pass the global `--output-raw` flag before the command to get the raw `True`/`False` -value instead, e.g. for scripting: +By default, `exists` commands print a human-readable result, e.g. `✅ Cube exists!`, +and `list` commands print each item as a `-` bullet, e.g. `- Cube1`. Pass the global +`--output-raw` flag before the command to get the raw `True`/`False` or plain, +one-item-per-line output instead, e.g. for scripting: ```bash tm1cli --output-raw cube exists +tm1cli --output-raw cube list ``` ### Configuration diff --git a/pyproject.toml b/pyproject.toml index 05a8422..036a5b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tm1cli" -version = "0.2.0" +version = "0.2.1" description = "A command-line interface (CLI) tool for interacting with TM1 servers using TM1py." authors = ["onefloid "] license = "MIT License" diff --git a/tests/conftest.py b/tests/conftest.py index 4d618f0..ab9b3b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ class MockedCubeService: cubes = ["Cube1", "Cube2"] - def get_all_names(self, cube_name: str): + def get_all_names(self, skip_control_cubes: bool): return self.cubes def exists(self, cube_name: str): @@ -46,6 +46,9 @@ def exists(self, dimension_name: str, subset_name: str, private: bool): class MockedProcessService: + def get_all_names(self): + return ["Process1", "Process2"] + def exists(self, name: str): return False if "not" in name else True diff --git a/tests/test_cmd_cubes.py b/tests/test_cmd_cubes.py index ec1649d..06e9e18 100644 --- a/tests/test_cmd_cubes.py +++ b/tests/test_cmd_cubes.py @@ -8,12 +8,18 @@ @pytest.mark.parametrize("command", ["list", "ls"]) -def test_cube_list(mocker, command): - mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) - result = runner.invoke(app, ["cube", command]) +@pytest.mark.parametrize( + "raw_option,expected_output", + [(None, "- Cube1\n- Cube2\n"), ("--output-raw", "Cube1\nCube2\n")], +) +def test_cube_list(mocker, command, raw_option, expected_output): + mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) + args = [raw_option] if raw_option else [] + args += ["cube", command] + result = runner.invoke(app, args) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "Cube1\nCube2\n" + assert result.stdout == expected_output @pytest.mark.parametrize( diff --git a/tests/test_cmd_dimension.py b/tests/test_cmd_dimension.py index 4161963..d8c5ed3 100644 --- a/tests/test_cmd_dimension.py +++ b/tests/test_cmd_dimension.py @@ -8,12 +8,21 @@ @pytest.mark.parametrize("command", ["list", "ls"]) -def test_dimension_list(mocker, command): - mocker.patch("tm1cli.commands.dimension.TM1Service", MockedTM1Service) - result = runner.invoke(app, ["dimension", command]) +@pytest.mark.parametrize( + "raw_option,expected_output", + [ + (None, "- Dimension1\n- Dimension2\n- Dimension3\n"), + ("--output-raw", "Dimension1\nDimension2\nDimension3\n"), + ], +) +def test_dimension_list(mocker, command, raw_option, expected_output): + mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) + args = [raw_option] if raw_option else [] + args += ["dimension", command] + result = runner.invoke(app, args) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "Dimension1\nDimension2\nDimension3\n" + assert result.stdout == expected_output @pytest.mark.parametrize( diff --git a/tests/test_cmd_process.py b/tests/test_cmd_process.py index e26d05a..c79fcdf 100644 --- a/tests/test_cmd_process.py +++ b/tests/test_cmd_process.py @@ -7,6 +7,24 @@ runner = CliRunner() +@pytest.mark.parametrize("command", ["list", "ls"]) +@pytest.mark.parametrize( + "raw_option,expected_output", + [ + (None, "- Process1\n- Process2\n"), + ("--output-raw", "Process1\nProcess2\n"), + ], +) +def test_process_list(mocker, command, raw_option, expected_output): + mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) + args = [raw_option] if raw_option else [] + args += ["process", command] + result = runner.invoke(app, args) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == expected_output + + @pytest.mark.parametrize( "raw_option,expected_output", [(None, "✅ Process exists!\n"), ("--output-raw", "True\n")], diff --git a/tests/test_cmd_subset.py b/tests/test_cmd_subset.py index df63f30..c102b77 100644 --- a/tests/test_cmd_subset.py +++ b/tests/test_cmd_subset.py @@ -8,12 +8,21 @@ @pytest.mark.parametrize("command", ["list", "ls"]) -def test_subset_list(mocker, command): - mocker.patch("tm1cli.commands.subset.TM1Service", MockedTM1Service) - result = runner.invoke(app, ["subset", command, "Dimension1"]) +@pytest.mark.parametrize( + "raw_option,expected_output", + [ + (None, "- Subset1\n- Subset2\n- Subset3\n"), + ("--output-raw", "Subset1\nSubset2\nSubset3\n"), + ], +) +def test_subset_list(mocker, command, raw_option, expected_output): + mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) + args = [raw_option] if raw_option else [] + args += ["subset", command, "Dimension1"] + result = runner.invoke(app, args) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "Subset1\nSubset2\nSubset3\n" + assert result.stdout == expected_output @pytest.mark.parametrize( diff --git a/tests/test_cmd_view.py b/tests/test_cmd_view.py index c9d728c..9734fbf 100644 --- a/tests/test_cmd_view.py +++ b/tests/test_cmd_view.py @@ -30,10 +30,19 @@ def test_view_exists(mocker, view_name, private_flag, exists_result, raw_option) assert result.stdout == f"{icon} View {word}!\n" -def test_view_list(mocker): - mocker.patch("tm1cli.commands.view.TM1Service", MockedTM1Service) - result = runner.invoke(app, ["view", "list", "example_cube"]) +@pytest.mark.parametrize( + "raw_option,expected_output", + [ + (None, "- View1\n- View2\n- View3\n"), + ("--output-raw", "View1\nView2\nView3\n"), + ], +) +def test_view_list(mocker, raw_option, expected_output): + mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) + args = [raw_option] if raw_option else [] + args += ["view", "list", "example_cube"] + result = runner.invoke(app, args) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "View1\nView2\nView3\n" + assert result.stdout == expected_output diff --git a/tm1cli/commands/cube.py b/tm1cli/commands/cube.py index 19d2726..2d8d433 100644 --- a/tm1cli/commands/cube.py +++ b/tm1cli/commands/cube.py @@ -1,12 +1,9 @@ from typing import Annotated import typer -from rich import print # pylint: disable=redefined-builtin -from TM1py.Services import TM1Service from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION -from tm1cli.utils.generic import execute_exists -from tm1cli.utils.various import resolve_database +from tm1cli.utils.generic import execute_exists, generic_list from tm1cli.utils.watch import watch_option app = typer.Typer() @@ -30,9 +27,7 @@ def list_cube( List cubes """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for cube in tm1.cubes.get_all_names(skip_control_cubes): - print(cube) + generic_list("cubes", ctx, database, skip_control_cubes=skip_control_cubes) @app.command() diff --git a/tm1cli/commands/dimension.py b/tm1cli/commands/dimension.py index 72629e5..31406b5 100644 --- a/tm1cli/commands/dimension.py +++ b/tm1cli/commands/dimension.py @@ -1,12 +1,9 @@ from typing import Annotated import typer -from rich import print # pylint: disable=redefined-builtin -from TM1py.Services import TM1Service from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION -from tm1cli.utils.generic import execute_exists -from tm1cli.utils.various import resolve_database +from tm1cli.utils.generic import execute_exists, generic_list from tm1cli.utils.watch import watch_option app = typer.Typer() @@ -30,9 +27,7 @@ def list_dimension( List dimensions """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for dim in tm1.dimensions.get_all_names(skip_control_dims): - print(dim) + generic_list("dimensions", ctx, database, skip_control_dims=skip_control_dims) @app.command() diff --git a/tm1cli/commands/process.py b/tm1cli/commands/process.py index bbe363d..7035f6d 100644 --- a/tm1cli/commands/process.py +++ b/tm1cli/commands/process.py @@ -8,7 +8,7 @@ from TM1py.Services import TM1Service from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION -from tm1cli.utils.generic import execute_exists +from tm1cli.utils.generic import execute_exists, generic_list from tm1cli.utils.tm1yaml import dump_process, load_process from tm1cli.utils.various import print_error_and_exit, resolve_database from tm1cli.utils.watch import watch_option @@ -34,9 +34,7 @@ def list_process( List processes """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for process in tm1.processes.get_all_names(): - print(process) + generic_list("processes", ctx, database) @app.command() diff --git a/tm1cli/commands/subset.py b/tm1cli/commands/subset.py index 33bed3d..1a8028d 100644 --- a/tm1cli/commands/subset.py +++ b/tm1cli/commands/subset.py @@ -1,12 +1,9 @@ from typing import Annotated import typer -from rich import print # pylint: disable=redefined-builtin -from TM1py.Services import TM1Service from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION -from tm1cli.utils.generic import execute_exists -from tm1cli.utils.various import resolve_database +from tm1cli.utils.generic import execute_exists, generic_list from tm1cli.utils.watch import watch_option app = typer.Typer() @@ -24,9 +21,7 @@ def list_subset( List subsets """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for subset in tm1.subsets.get_all_names(dimension_name): - print(subset) + generic_list("subsets", ctx, database, dimension_name=dimension_name) @app.command() diff --git a/tm1cli/commands/view.py b/tm1cli/commands/view.py index c5f3e2e..b799d5d 100644 --- a/tm1cli/commands/view.py +++ b/tm1cli/commands/view.py @@ -1,12 +1,9 @@ from typing import Annotated import typer -from rich import print # pylint: disable=redefined-builtin -from TM1py.Services import TM1Service from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION -from tm1cli.utils.generic import execute_exists -from tm1cli.utils.various import resolve_database +from tm1cli.utils.generic import execute_exists, generic_list from tm1cli.utils.watch import watch_option app = typer.Typer() @@ -23,9 +20,7 @@ def list_view( List views """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for view in tm1.views.get_all_names(cube_name): - print(view) + generic_list("views", ctx, database, cube_name=cube_name) @app.command() diff --git a/tm1cli/utils/generic.py b/tm1cli/utils/generic.py index 6eb706b..c7140cb 100644 --- a/tm1cli/utils/generic.py +++ b/tm1cli/utils/generic.py @@ -41,3 +41,31 @@ def execute_exists(attribute_name, ctx, database, **args): print(f":white_check_mark: {output_name} exists!") else: print(f":x: {output_name} does not exist!") + + +def generic_list(attribute_name, ctx, database, **args): + """ + Util function to execute a list function + """ + + database_config = resolve_database(ctx, database) + + try: + with TM1Service(**database_config) as tm1: + attribute = getattr(tm1, attribute_name) + output = attribute.get_all_names(**args) + except (AttributeError, TypeError): + # Programming errors (wrong attribute_name or mismatched kwargs), + # not TM1 runtime errors - let them raise as real tracebacks. + raise + except Exception as e: # pylint: disable=broad-except + print(f"[bold red]{type(e).__name__}:[/bold red] {e}") + raise typer.Exit(code=1) from e + + if ctx.obj["raw"]: + for item in output: + print(item) + return + + for item in output: + print(f"- {item}")