diff --git a/tests/conftest.py b/tests/conftest.py index 4d618f0..641f2ab 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 = False): return self.cubes def exists(self, cube_name: str): @@ -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(): @@ -35,7 +35,7 @@ def exists(self, dimension_name: str): class MockedSubsetService: - def get_all_names(self, dimension_name: str): + def get_all_names(self, dimension_name: str, hierarchy_name: str = None, private: bool = False): return ["Subset1", "Subset2", "Subset3"] def exists(self, dimension_name: str, subset_name: str, private: bool): @@ -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, skip_control_processes: bool = False): + 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..f38f212 100644 --- a/tests/test_cmd_cubes.py +++ b/tests/test_cmd_cubes.py @@ -13,7 +13,7 @@ def test_cube_list(mocker, command): result = runner.invoke(app, ["cube", command]) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "Cube1\nCube2\n" + assert result.stdout == "- Cube1\n- Cube2\n" @pytest.mark.parametrize( diff --git a/tests/test_cmd_dimension.py b/tests/test_cmd_dimension.py index 4161963..8bfba5d 100644 --- a/tests/test_cmd_dimension.py +++ b/tests/test_cmd_dimension.py @@ -13,7 +13,7 @@ def test_dimension_list(mocker, command): result = runner.invoke(app, ["dimension", command]) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "Dimension1\nDimension2\nDimension3\n" + assert result.stdout == "- Dimension1\n- Dimension2\n- Dimension3\n" @pytest.mark.parametrize( diff --git a/tests/test_cmd_process.py b/tests/test_cmd_process.py index e26d05a..7180091 100644 --- a/tests/test_cmd_process.py +++ b/tests/test_cmd_process.py @@ -9,7 +9,7 @@ @pytest.mark.parametrize( "raw_option,expected_output", - [(None, "✅ Process exists!\n"), ("--output-raw", "True\n")], + [(None, "\u2705 Process exists!\n"), ("--output-raw", "True\n")], ) def test_process_exists(mocker, raw_option, expected_output): mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) @@ -20,3 +20,50 @@ def test_process_exists(mocker, raw_option, expected_output): assert result.exit_code == 0 assert isinstance(result.stdout, str) assert result.stdout == expected_output + +@pytest.mark.parametrize("command", ["list", "ls"]) +def test_process_list(mocker, command): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["process", command]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == "- Process1\n- Process2\n" + + +def test_process_list_json(mocker): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["process", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"Process1"' in result.stdout + assert '"Process2"' in result.stdout + + +def test_process_list_filter(mocker): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["process", "list", "--filter", "Process1"]) + assert result.exit_code == 0 + assert "Process1" in result.stdout + assert "Process2" not in result.stdout + + +def test_process_list_limit(mocker): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["process", "list", "--limit", "1"]) + assert result.exit_code == 0 + assert "Process1" in result.stdout + assert "Process2" not in result.stdout + + +def test_process_list_offset(mocker): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["process", "list", "--offset", "1"]) + assert result.exit_code == 0 + assert "Process1" not in result.stdout + assert "Process2" in result.stdout + + +def test_process_not_exists(mocker): + mocker.patch("tm1cli.utils.generic.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["--output-raw", "process", "exists", "process_notfound"]) + assert result.exit_code == 0 + assert result.stdout == "False\n" diff --git a/tests/test_cmd_subset.py b/tests/test_cmd_subset.py index df63f30..cc4077b 100644 --- a/tests/test_cmd_subset.py +++ b/tests/test_cmd_subset.py @@ -10,10 +10,12 @@ @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"]) + result = runner.invoke(app, ["subset", command, "--dimension", "Dimension1"]) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "Subset1\nSubset2\nSubset3\n" + assert "dimension: Dimension1" in result.stdout + assert "name: Subset1" in result.stdout + assert "type: public" in result.stdout @pytest.mark.parametrize( diff --git a/tests/test_cmd_view.py b/tests/test_cmd_view.py index c9d728c..bf0ad5a 100644 --- a/tests/test_cmd_view.py +++ b/tests/test_cmd_view.py @@ -32,8 +32,10 @@ def test_view_exists(mocker, view_name, private_flag, exists_result, raw_option) def test_view_list(mocker): mocker.patch("tm1cli.commands.view.TM1Service", MockedTM1Service) - result = runner.invoke(app, ["view", "list", "example_cube"]) + result = runner.invoke(app, ["view", "list", "--cube", "example_cube"]) assert result.exit_code == 0 assert isinstance(result.stdout, str) - assert result.stdout == "View1\nView2\nView3\n" + assert "cube: example_cube" in result.stdout + assert "name: View1" in result.stdout + assert "type: public" in result.stdout diff --git a/tests/test_tm1cli.py b/tests/test_tm1cli.py index 2a7c8d7..c0092c0 100644 --- a/tests/test_tm1cli.py +++ b/tests/test_tm1cli.py @@ -1,6 +1,7 @@ import pytest from typer.testing import CliRunner +from tests.conftest import MockedTM1Service from tm1cli.main import app runner = CliRunner() @@ -59,8 +60,13 @@ def test_process_clone_missing_from_to(): ) -def test_process_clone_not_exists(): - result = runner.invoke(app, ["process", "clone", "example", "--to", "remotedb"]) +def test_process_clone_not_exists(mocker): + mocker.patch("tm1cli.commands.process.TM1Service", MockedTM1Service) + mocker.patch( + "tm1cli.commands.process.resolve_database", + side_effect=lambda ctx, db: {"address": "target", "port": 8080} if db == "remotedb" else {"address": "source", "port": 8005}, + ) + result = runner.invoke(app, ["process", "clone", "process_notfound", "--to", "remotedb"]) assert result.exit_code == 1 assert "Error: Process does not exist in source database!" in result.output diff --git a/tm1cli/commands/cube.py b/tm1cli/commands/cube.py index 19d2726..fd8a0ba 100644 --- a/tm1cli/commands/cube.py +++ b/tm1cli/commands/cube.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Optional import typer from rich import print # pylint: disable=redefined-builtin @@ -6,6 +6,7 @@ from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import OutputFormat, apply_filter, apply_paging, render_output from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -22,17 +23,22 @@ def list_cube( typer.Option( "-s", "--skip-control-cubes", - help="Flag for not printing control cubes.", + help="Exclude control cubes (names starting with '}').", ), ] = False, + filter: Annotated[Optional[str], typer.Option("--filter", "-f", help="Regex/substring filter (case-insensitive).")] = None, + output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format: yaml (default) or json.")] = "yaml", + limit: Annotated[Optional[int], typer.Option("--limit", help="Maximum number of results to return.")] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, ): """ List cubes """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for cube in tm1.cubes.get_all_names(skip_control_cubes): - print(cube) + names = tm1.cubes.get_all_names(skip_control_cubes=skip_control_cubes) + names = apply_filter(names, filter) + result = apply_paging(names, limit, offset) + typer.echo(render_output(result, output)) @app.command() diff --git a/tm1cli/commands/dimension.py b/tm1cli/commands/dimension.py index 72629e5..8618d92 100644 --- a/tm1cli/commands/dimension.py +++ b/tm1cli/commands/dimension.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Optional import typer from rich import print # pylint: disable=redefined-builtin @@ -6,6 +6,7 @@ from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import OutputFormat, apply_filter, apply_paging, render_output from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -21,18 +22,23 @@ def list_dimension( bool, typer.Option( "-s", - "--skip-control-cubes", - help="Flag for not printing control cubes.", + "--skip-control-dims", + help="Exclude control dimensions (names starting with '}').", ), ] = False, + filter: Annotated[Optional[str], typer.Option("--filter", "-f", help="Regex/substring filter (case-insensitive).")] = None, + output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format: yaml (default) or json.")] = "yaml", + limit: Annotated[Optional[int], typer.Option("--limit", help="Maximum number of results to return.")] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, ): """ List dimensions """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for dim in tm1.dimensions.get_all_names(skip_control_dims): - print(dim) + names = tm1.dimensions.get_all_names(skip_control_dims=skip_control_dims) + names = apply_filter(names, filter) + result = apply_paging(names, limit, offset) + typer.echo(render_output(result, output)) @app.command() diff --git a/tm1cli/commands/process.py b/tm1cli/commands/process.py index bbe363d..2743bc4 100644 --- a/tm1cli/commands/process.py +++ b/tm1cli/commands/process.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional import typer from rich import print # pylint: disable=redefined-builtin @@ -9,6 +9,7 @@ from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import OutputFormat, apply_filter, apply_paging, render_output 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,14 +30,27 @@ def _get_process(name: str, database_config: dict) -> Process: def list_process( ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None, + skip_control_tis: Annotated[ + bool, + typer.Option( + "-s", + "--skip-control-tis", + help="Exclude control TI processes (names starting with '}').", + ), + ] = False, + filter: Annotated[Optional[str], typer.Option("--filter", "-f", help="Regex/substring filter (case-insensitive).")] = None, + output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format: yaml (default) or json.")] = "yaml", + limit: Annotated[Optional[int], typer.Option("--limit", help="Maximum number of results to return.")] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, ): """ List processes """ - with TM1Service(**resolve_database(ctx, database)) as tm1: - for process in tm1.processes.get_all_names(): - print(process) + names = tm1.processes.get_all_names(skip_control_processes=skip_control_tis) + names = apply_filter(names, filter) + result = apply_paging(names, limit, offset) + typer.echo(render_output(result, output)) @app.command() diff --git a/tm1cli/commands/subset.py b/tm1cli/commands/subset.py index 33bed3d..0d0a038 100644 --- a/tm1cli/commands/subset.py +++ b/tm1cli/commands/subset.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Optional import typer from rich import print # pylint: disable=redefined-builtin @@ -6,6 +6,7 @@ from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import OutputFormat, VisibilityType, apply_filter, apply_paging, render_output from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -16,17 +17,44 @@ @app.command(name="list") def list_subset( ctx: typer.Context, - dimension_name: str, - # hierarchy_name: str = None, + dimension: Annotated[ + Optional[str], + typer.Option("--dimension", help="Scope to a specific dimension name."), + ] = None, + hierarchy: Annotated[ + Optional[str], + typer.Option("--hierarchy", help="Hierarchy name (defaults to dimension name)."), + ] = None, + type: Annotated[ + VisibilityType, + typer.Option("--type", "-t", help="Visibility filter: public, private, or both."), + ] = "public", database: Annotated[str, DATABASE_OPTION] = None, + filter: Annotated[Optional[str], typer.Option("--filter", "-f", help="Regex/substring filter (case-insensitive).")] = None, + output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format: yaml (default) or json.")] = "yaml", + limit: Annotated[Optional[int], typer.Option("--limit", help="Maximum number of results to return.")] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, ): """ List subsets """ - + include_public = type in ("public", "both") + include_private = type in ("private", "both") with TM1Service(**resolve_database(ctx, database)) as tm1: - for subset in tm1.subsets.get_all_names(dimension_name): - print(subset) + dim_names = [dimension] if dimension else tm1.dimensions.get_all_names(skip_control_dims=True) + results: list[dict] = [] + for dim in dim_names: + hier = hierarchy or dim + if include_public: + for name in tm1.subsets.get_all_names(dim, hier, private=False): + results.append({"dimension": dim, "hierarchy": hier, "name": name, "type": "public"}) + if include_private: + for name in tm1.subsets.get_all_names(dim, hier, private=True): + results.append({"dimension": dim, "hierarchy": hier, "name": name, "type": "private"}) + if filter: + matching = set(apply_filter([r["name"] for r in results], filter)) + results = [r for r in results if r["name"] in matching] + typer.echo(render_output(apply_paging(results, limit, offset), output)) @app.command() diff --git a/tm1cli/commands/view.py b/tm1cli/commands/view.py index c5f3e2e..a95cd10 100644 --- a/tm1cli/commands/view.py +++ b/tm1cli/commands/view.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Optional import typer from rich import print # pylint: disable=redefined-builtin @@ -6,6 +6,7 @@ from tm1cli.utils.cli_param import DATABASE_OPTION, INTERVAL_OPTION, WATCH_OPTION from tm1cli.utils.generic import execute_exists +from tm1cli.utils.list_utils import OutputFormat, VisibilityType, apply_filter, apply_paging, render_output from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -16,16 +17,37 @@ @app.command(name="list") def list_view( ctx: typer.Context, - cube_name: str, + cube: Annotated[Optional[str], typer.Option("--cube", "-c", help="Scope to a specific cube name.")] = None, + type: Annotated[ + VisibilityType, + typer.Option("--type", "-t", help="Visibility filter: public, private, or both."), + ] = "public", database: Annotated[str, DATABASE_OPTION] = None, + filter: Annotated[Optional[str], typer.Option("--filter", "-f", help="Regex/substring filter (case-insensitive).")] = None, + output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format: yaml (default) or json.")] = "yaml", + limit: Annotated[Optional[int], typer.Option("--limit", help="Maximum number of results to return.")] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, ): """ List views """ - + include_public = type in ("public", "both") + include_private = type in ("private", "both") with TM1Service(**resolve_database(ctx, database)) as tm1: - for view in tm1.views.get_all_names(cube_name): - print(view) + cube_names = [cube] if cube else tm1.cubes.get_all_names(skip_control_cubes=True) + results: list[dict] = [] + for cube_name in cube_names: + private_names, public_names = tm1.views.get_all_names(cube_name) + if include_public: + for name in public_names: + results.append({"cube": cube_name, "name": name, "type": "public"}) + if include_private: + for name in private_names: + results.append({"cube": cube_name, "name": name, "type": "private"}) + if filter: + matching = set(apply_filter([r["name"] for r in results], filter)) + results = [r for r in results if r["name"] in matching] + typer.echo(render_output(apply_paging(results, limit, offset), output)) @app.command() diff --git a/tm1cli/main.py b/tm1cli/main.py index 7b6f987..ad62938 100644 --- a/tm1cli/main.py +++ b/tm1cli/main.py @@ -106,10 +106,13 @@ def threads( with TM1Service(**db_config) as tm1: threads = tm1.sessions.get_threads_for_current() if beautify: - table = Table(*threads[0].keys(), title="Threads") - for thread in threads: - table.add_row(*[str(value) for value in thread.values()]) - console.print(table) + if not threads: + typer.echo("No threads.") + else: + table = Table(*threads[0].keys(), title="Threads") + for thread in threads: + table.add_row(*[str(value) for value in thread.values()]) + console.print(table) else: threads = json.dumps(threads, indent=4) print(threads) diff --git a/tm1cli/utils/list_utils.py b/tm1cli/utils/list_utils.py new file mode 100644 index 0000000..24a6c01 --- /dev/null +++ b/tm1cli/utils/list_utils.py @@ -0,0 +1,48 @@ +"""Shared utilities for list commands: filtering, paging, and output rendering.""" +from __future__ import annotations + +import json +import re +from enum import Enum +from typing import Optional + +import yaml + + +class OutputFormat(str, Enum): + yaml = "yaml" + json = "json" + + +class VisibilityType(str, Enum): + public = "public" + private = "private" + both = "both" + + +def apply_filter(names: list[str], pattern: Optional[str]) -> list[str]: + """Return names matching *pattern* (case-insensitive regex or substring).""" + if not pattern: + return names + try: + rx = re.compile(pattern, re.IGNORECASE) + return [n for n in names if rx.search(n)] + except re.error: + needle = pattern.lower() + return [n for n in names if needle in n.lower()] + + +def apply_paging(items: list, limit: Optional[int], offset: int) -> list: + """Slice *items* by *offset* and *limit*.""" + if offset: + items = items[offset:] + if limit is not None: + items = items[:limit] + return items + + +def render_output(data: list, fmt: OutputFormat) -> str: + """Serialise *data* to a YAML or JSON string.""" + if fmt == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False).rstrip()