From 768cd3fd854c75915fb4e21999e3030eba419380 Mon Sep 17 00:00:00 2001 From: Florian Scherzberg <33193059+onefloid@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:29:54 +0200 Subject: [PATCH] feat: add glob filtering to list commands Extract the filtering work proposed in #9 into a focused, backwards-compatible change. Co-authored-by: Enis Isufi --- README.md | 8 ++++++++ tests/conftest.py | 7 ++++++- tests/test_cmd_cubes.py | 7 +++++++ tests/test_cmd_dimension.py | 7 +++++++ tests/test_cmd_process.py | 7 +++++++ tests/test_cmd_subset.py | 9 +++++++++ tests/test_cmd_view.py | 9 +++++++++ tests/test_list_utils.py | 19 +++++++++++++++++++ tm1cli/commands/cube.py | 12 ++++++++++-- tm1cli/commands/dimension.py | 12 ++++++++++-- tm1cli/commands/process.py | 12 ++++++++++-- tm1cli/commands/subset.py | 12 ++++++++++-- tm1cli/commands/view.py | 12 ++++++++++-- tm1cli/utils/cli_param.py | 4 ++++ tm1cli/utils/list_utils.py | 17 +++++++++++++++++ 15 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 tests/test_list_utils.py create mode 100644 tm1cli/utils/list_utils.py diff --git a/README.md b/README.md index 603aeb8..fa62ed2 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,14 @@ tm1cli subset list tm1cli subset exists ``` +All list commands support case-insensitive glob filtering. Quote patterns to prevent +your shell from expanding them: + +```bash +tm1cli cube list --filter 'Sales*' +tm1cli process list --filter '*Import*' +``` + ### All Available Commands Run the following to see all available commands: diff --git a/tests/conftest.py b/tests/conftest.py index 4d618f0..c6f08f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ def exists(self, cube_name: str): class MockedViewService: def get_all_names(self, cube_name: str): - return ["View1", "View2", "View3"] + return ([], ["View1", "View2", "View3"]) def exists(self, cube_name: str, view_name: str, private: bool): if "not" in view_name.lower(): @@ -46,6 +46,11 @@ def exists(self, dimension_name: str, subset_name: str, private: bool): class MockedProcessService: + processes = ["Process1", "Process2"] + + def get_all_names(self): + return self.processes + 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..9870126 100644 --- a/tests/test_cmd_cubes.py +++ b/tests/test_cmd_cubes.py @@ -16,6 +16,13 @@ def test_cube_list(mocker, command): assert result.stdout == "Cube1\nCube2\n" +def test_cube_list_filter(mocker): + mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["cube", "list", "--filter", "*2"]) + assert result.exit_code == 0 + assert result.stdout == "Cube2\n" + + @pytest.mark.parametrize( "raw_option,expected_output", [(None, "✅ Cube exists!\n"), ("--output-raw", "True\n")], diff --git a/tests/test_cmd_dimension.py b/tests/test_cmd_dimension.py index 4161963..23537fe 100644 --- a/tests/test_cmd_dimension.py +++ b/tests/test_cmd_dimension.py @@ -16,6 +16,13 @@ def test_dimension_list(mocker, command): assert result.stdout == "Dimension1\nDimension2\nDimension3\n" +def test_dimension_list_filter(mocker): + mocker.patch("tm1cli.commands.dimension.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["dimension", "list", "--filter", "*2"]) + assert result.exit_code == 0 + assert result.stdout == "Dimension2\n" + + @pytest.mark.parametrize( "raw_option,expected_output", [(None, "✅ Dimension exists!\n"), ("--output-raw", "True\n")], diff --git a/tests/test_cmd_process.py b/tests/test_cmd_process.py index e26d05a..5d633d4 100644 --- a/tests/test_cmd_process.py +++ b/tests/test_cmd_process.py @@ -7,6 +7,13 @@ runner = CliRunner() +def test_process_list_filter(mocker): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["process", "list", "--filter", "*2"]) + assert result.exit_code == 0 + assert result.stdout == "Process2\n" + + @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..e650edb 100644 --- a/tests/test_cmd_subset.py +++ b/tests/test_cmd_subset.py @@ -16,6 +16,15 @@ def test_subset_list(mocker, command): assert result.stdout == "Subset1\nSubset2\nSubset3\n" +def test_subset_list_filter(mocker): + mocker.patch("tm1cli.commands.subset.TM1Service", MockedTM1Service) + result = runner.invoke( + app, ["subset", "list", "Dimension1", "--filter", "*2"] + ) + assert result.exit_code == 0 + assert result.stdout == "Subset2\n" + + @pytest.mark.parametrize( "raw_option,expected_output", [(None, "✅ Subset exists!\n"), ("--output-raw", "True\n")], diff --git a/tests/test_cmd_view.py b/tests/test_cmd_view.py index c9d728c..a0e0742 100644 --- a/tests/test_cmd_view.py +++ b/tests/test_cmd_view.py @@ -37,3 +37,12 @@ def test_view_list(mocker): assert result.exit_code == 0 assert isinstance(result.stdout, str) assert result.stdout == "View1\nView2\nView3\n" + + +def test_view_list_filter(mocker): + mocker.patch("tm1cli.commands.view.TM1Service", MockedTM1Service) + result = runner.invoke( + app, ["view", "list", "example_cube", "--filter", "*2"] + ) + assert result.exit_code == 0 + assert result.stdout == "View2\n" diff --git a/tests/test_list_utils.py b/tests/test_list_utils.py new file mode 100644 index 0000000..65a9861 --- /dev/null +++ b/tests/test_list_utils.py @@ -0,0 +1,19 @@ +from tm1cli.utils.list_utils import apply_filter + + +def test_apply_filter_uses_case_insensitive_globs(): + names = ["Sales", "Sales_Budget", "Archive_Sales", "Costs"] + + assert apply_filter(names, "sales*") == ["Sales", "Sales_Budget"] + assert apply_filter(names, "*SALES*") == [ + "Sales", + "Sales_Budget", + "Archive_Sales", + ] + + +def test_apply_filter_without_wildcards_matches_exactly(): + names = ["Sales", "Sales_Budget"] + + assert apply_filter(names, "sales") == ["Sales"] + assert apply_filter(names, None) == names diff --git a/tm1cli/commands/cube.py b/tm1cli/commands/cube.py index 19d2726..1b4c0d4 100644 --- a/tm1cli/commands/cube.py +++ b/tm1cli/commands/cube.py @@ -4,8 +4,14 @@ 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.cli_param import ( + DATABASE_OPTION, + FILTER_OPTION, + INTERVAL_OPTION, + WATCH_OPTION, +) from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import apply_filter from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -17,6 +23,7 @@ def list_cube( ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None, + name_filter: Annotated[str, FILTER_OPTION] = None, skip_control_cubes: Annotated[ bool, typer.Option( @@ -31,7 +38,8 @@ def list_cube( """ with TM1Service(**resolve_database(ctx, database)) as tm1: - for cube in tm1.cubes.get_all_names(skip_control_cubes): + names = tm1.cubes.get_all_names(skip_control_cubes) + for cube in apply_filter(names, name_filter): print(cube) diff --git a/tm1cli/commands/dimension.py b/tm1cli/commands/dimension.py index 72629e5..098739c 100644 --- a/tm1cli/commands/dimension.py +++ b/tm1cli/commands/dimension.py @@ -4,8 +4,14 @@ 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.cli_param import ( + DATABASE_OPTION, + FILTER_OPTION, + INTERVAL_OPTION, + WATCH_OPTION, +) from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import apply_filter from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -17,6 +23,7 @@ def list_dimension( ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None, + name_filter: Annotated[str, FILTER_OPTION] = None, skip_control_dims: Annotated[ bool, typer.Option( @@ -31,7 +38,8 @@ def list_dimension( """ with TM1Service(**resolve_database(ctx, database)) as tm1: - for dim in tm1.dimensions.get_all_names(skip_control_dims): + names = tm1.dimensions.get_all_names(skip_control_dims) + for dim in apply_filter(names, name_filter): print(dim) diff --git a/tm1cli/commands/process.py b/tm1cli/commands/process.py index bbe363d..2a2e0e0 100644 --- a/tm1cli/commands/process.py +++ b/tm1cli/commands/process.py @@ -7,8 +7,14 @@ from TM1py.Objects import Process from TM1py.Services import TM1Service -from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION +from tm1cli.utils.cli_param import ( + DATABASE_OPTION, + FILTER_OPTION, + INTERVAL_OPTION, + WATCH_OPTION, +) from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import apply_filter 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 @@ -29,13 +35,15 @@ def _get_process(name: str, database_config: dict) -> Process: def list_process( ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None, + name_filter: Annotated[str, FILTER_OPTION] = None, ): """ List processes """ with TM1Service(**resolve_database(ctx, database)) as tm1: - for process in tm1.processes.get_all_names(): + names = tm1.processes.get_all_names() + for process in apply_filter(names, name_filter): print(process) diff --git a/tm1cli/commands/subset.py b/tm1cli/commands/subset.py index 33bed3d..f23d1cd 100644 --- a/tm1cli/commands/subset.py +++ b/tm1cli/commands/subset.py @@ -4,8 +4,14 @@ 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.cli_param import ( + DATABASE_OPTION, + FILTER_OPTION, + INTERVAL_OPTION, + WATCH_OPTION, +) from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import apply_filter from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -19,13 +25,15 @@ def list_subset( dimension_name: str, # hierarchy_name: str = None, database: Annotated[str, DATABASE_OPTION] = None, + name_filter: Annotated[str, FILTER_OPTION] = None, ): """ List subsets """ with TM1Service(**resolve_database(ctx, database)) as tm1: - for subset in tm1.subsets.get_all_names(dimension_name): + names = tm1.subsets.get_all_names(dimension_name) + for subset in apply_filter(names, name_filter): print(subset) diff --git a/tm1cli/commands/view.py b/tm1cli/commands/view.py index c5f3e2e..bbfbd1a 100644 --- a/tm1cli/commands/view.py +++ b/tm1cli/commands/view.py @@ -4,8 +4,14 @@ 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.cli_param import ( + DATABASE_OPTION, + FILTER_OPTION, + INTERVAL_OPTION, + WATCH_OPTION, +) from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import apply_filter from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -18,13 +24,15 @@ def list_view( ctx: typer.Context, cube_name: str, database: Annotated[str, DATABASE_OPTION] = None, + name_filter: Annotated[str, FILTER_OPTION] = None, ): """ List views """ with TM1Service(**resolve_database(ctx, database)) as tm1: - for view in tm1.views.get_all_names(cube_name): + private_views, public_views = tm1.views.get_all_names(cube_name) + for view in apply_filter(private_views + public_views, name_filter): print(view) diff --git a/tm1cli/utils/cli_param.py b/tm1cli/utils/cli_param.py index 9792fed..e791021 100644 --- a/tm1cli/utils/cli_param.py +++ b/tm1cli/utils/cli_param.py @@ -1,6 +1,10 @@ import typer DATABASE_OPTION = typer.Option("--database", "-d", help="Specify the database to use") +FILTER_OPTION = typer.Option( + "--filter", + help="Filter names using a case-insensitive glob pattern.", +) WATCH_OPTION = typer.Option( "--watch", "-w", help="Watch the command output periodically." ) diff --git a/tm1cli/utils/list_utils.py b/tm1cli/utils/list_utils.py new file mode 100644 index 0000000..8778590 --- /dev/null +++ b/tm1cli/utils/list_utils.py @@ -0,0 +1,17 @@ +"""Shared utilities for list commands.""" + +from fnmatch import fnmatchcase +from typing import Optional + + +def apply_filter(names: list[str], pattern: Optional[str]) -> list[str]: + """Filter names using a case-insensitive glob pattern.""" + if pattern is None: + return names + + normalized_pattern = pattern.casefold() + return [ + name + for name in names + if fnmatchcase(name.casefold(), normalized_pattern) + ]