diff --git a/CHANGELOG.md b/CHANGELOG.md index d67501e..d2b15c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v0.3.0 - 2026-09-10 + +### Features + +- Added filtering, YAML/JSON/plain output, and offset/limit paging to all list commands. +- Added public/private/both visibility selection for view and subset listings. +- Added options to skip control cubes, dimensions, and TI processes consistently. +- Added structured view and subset records containing the parent object, hierarchy where + applicable, name, and visibility type. + +### Fixes + +- Fixed `view list` handling of TM1py's `(private, public)` return value. +- Fixed `threads --beautify` when the current session has no threads. +- Invalid list filters now fail with a clear CLI parameter error. +- `--filter` now performs literal case-insensitive substring matching; regular expressions + are available explicitly through `--regex`. + +### Breaking changes + +- `view list CUBE_NAME` is now `view list --cube CUBE_NAME`. +- `subset list DIMENSION_NAME` is now `subset list --dimension DIMENSION_NAME`. +- List commands now default to YAML list output (`- Name`) instead of one raw name per line. +- `dimension list --skip-control-cubes` is replaced by `--skip-control-dims`. +- Scripts that require one raw name per line should use the global `--output-raw` flag or + `--output plain`. + ## v0.2.0 - 2026-07-15 ### Features diff --git a/CLAUDE.md b/CLAUDE.md index 2962ba3..877a379 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Guidance for AI assistants (Claude Code) working in this repository. **tm1cli** is a Python command-line interface for interacting with IBM Planning Analytics / TM1 servers, built on top of [TM1py](https://github.com/cubewise-code/tm1py) and [Typer](https://typer.tiangolo.com/). It's a small, published PyPI package -(`pip install tm1cli`), currently at version `0.1.7` (see `pyproject.toml`). +(`pip install tm1cli`), currently at version `0.3.0` (see `pyproject.toml`). ## Tech stack @@ -96,9 +96,10 @@ Conventions to follow when adding or modifying commands: `tm1cli/utils/watch.py` and take `watch`/`interval` parameters — mark them `# pylint: disable=unused-argument` since the decorator consumes them via `**kwargs`, not the function body. -- Use `rich.print` (imported `# pylint: disable=redefined-builtin`) for normal output, and - `print_error_and_exit(msg)` from `tm1cli/utils/various.py` for user-facing errors — it prints in - bold red and raises `typer.Exit(code=1)`. Don't raise raw exceptions for expected error paths. +- Use `rich.print` (imported `# pylint: disable=redefined-builtin`) for normal output. List + commands use `typer.echo` deliberately so YAML/JSON names containing Rich markup are not + interpreted. Use `print_error_and_exit(msg)` from `tm1cli/utils/various.py` for user-facing + errors — it prints in bold red and raises `typer.Exit(code=1)`. - Reuse `DATABASE_OPTION`, `WATCH_OPTION`, `INTERVAL_OPTION` from `tm1cli/utils/cli_param.py` instead of redefining `typer.Option(...)` inline. diff --git a/README.md b/README.md index 603aeb8..54881cb 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ tm1cli cube exists --watch tm1cli dimension list tm1cli dimension exists -w -tm1cli view list +tm1cli view list --cube tm1cli view exists -tm1cli subset list +tm1cli subset list --dimension tm1cli subset exists ``` @@ -84,6 +84,15 @@ value instead, e.g. for scripting: tm1cli --output-raw cube exists ``` +List commands support the same literal substring `--filter`, explicit regular-expression +`--regex`, `--limit`, and `--offset` options and can render YAML, JSON, or plain output with `--output yaml|json|plain`. The global +`--output-raw` flag forces plain one-name-per-line output for scripting. + +Views and subsets support `--type public|private|both`. When no `--cube` or +`--dimension` is supplied, they list objects across all cubes or dimensions. Use +`--skip-control-cubes` or `--skip-control-dims` to exclude control objects from those +default scans. + ### Configuration Connection settings are stored in a _databases.yaml_ file. Here's an example: diff --git a/pyproject.toml b/pyproject.toml index 05a8422..8293683 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tm1cli" -version = "0.2.0" +version = "0.3.0" 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..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..6429505 100644 --- a/tests/test_cmd_cubes.py +++ b/tests/test_cmd_cubes.py @@ -13,9 +13,30 @@ 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\n- Cube2\n" + + +def test_cube_list_raw_output(mocker): + mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["--output-raw", "cube", "list"]) + assert result.exit_code == 0 assert result.stdout == "Cube1\nCube2\n" +def test_cube_list_rejects_invalid_filter(mocker): + mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["cube", "list", "--regex", "["]) + assert result.exit_code == 2 + assert "Invalid regular expression" in result.output + + +def test_cube_list_rejects_negative_limit(mocker): + mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["cube", "list", "--limit", "-1"]) + assert result.exit_code == 2 + assert "Invalid value for '--limit'" in result.output + + @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..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..f1d0aed 100644 --- a/tests/test_cmd_subset.py +++ b/tests/test_cmd_subset.py @@ -10,12 +10,27 @@ @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 "dimension: Dimension1" in result.stdout + assert "name: Subset1" in result.stdout + assert "type: public" in result.stdout + + +def test_subset_list_raw_output(mocker): + mocker.patch("tm1cli.commands.subset.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["--output-raw", "subset", "list", "--dimension", "Dimension1"]) + assert result.exit_code == 0 assert result.stdout == "Subset1\nSubset2\nSubset3\n" +def test_subset_hierarchy_requires_dimension(): + result = runner.invoke(app, ["subset", "list", "--hierarchy", "Leaves"]) + assert result.exit_code == 1 + assert "--hierarchy requires --dimension" in result.output + + @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..173c6a9 100644 --- a/tests/test_cmd_view.py +++ b/tests/test_cmd_view.py @@ -32,8 +32,17 @@ 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 "cube: example_cube" in result.stdout + assert "name: View1" in result.stdout + assert "type: public" in result.stdout + + +def test_view_list_raw_output(mocker): + mocker.patch("tm1cli.commands.view.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["--output-raw", "view", "list", "--cube", "example_cube"]) + assert result.exit_code == 0 assert result.stdout == "View1\nView2\nView3\n" 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..e87888b 100644 --- a/tm1cli/commands/cube.py +++ b/tm1cli/commands/cube.py @@ -1,11 +1,14 @@ -from typing import Annotated +from typing import Annotated, Optional 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.list_utils import ( + OutputFormat, + render_names, +) from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -16,23 +19,35 @@ @app.command(name="list") def list_cube( ctx: typer.Context, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, skip_control_cubes: Annotated[ bool, typer.Option( "-s", "--skip-control-cubes", - help="Flag for not printing control cubes.", + help="Exclude control cubes (names starting with '}').", ), ] = False, + name_filter: Annotated[ + Optional[str], typer.Option("--filter", "-f", help="Literal substring filter (case-insensitive).") + ] = None, + regex_filter: Annotated[ + Optional[str], typer.Option("--regex", help="Case-insensitive regular-expression filter.") + ] = None, + output_format: Annotated[ + OutputFormat, typer.Option("--output", "-o", help="Output format: yaml, json, or plain.") + ] = OutputFormat.yaml, + limit: Annotated[ + Optional[int], typer.Option("--limit", help="Maximum number of results to return.", min=1) + ] = 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) + typer.echo(render_names(names, name_filter, regex_filter, limit, offset, ctx, output_format)) @app.command() @@ -40,7 +55,7 @@ def list_cube( def exists( ctx: typer.Context, cube_name: str, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, watch: Annotated[bool, WATCH_OPTION] = False, # pylint: disable=unused-argument interval: Annotated[int, INTERVAL_OPTION] = 5, # pylint: disable=unused-argument ): diff --git a/tm1cli/commands/dimension.py b/tm1cli/commands/dimension.py index 72629e5..b519b0a 100644 --- a/tm1cli/commands/dimension.py +++ b/tm1cli/commands/dimension.py @@ -1,11 +1,14 @@ -from typing import Annotated +from typing import Annotated, Optional 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.list_utils import ( + OutputFormat, + render_names, +) from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -16,23 +19,35 @@ @app.command(name="list") def list_dimension( ctx: typer.Context, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, skip_control_dims: Annotated[ 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, + name_filter: Annotated[ + Optional[str], typer.Option("--filter", "-f", help="Literal substring filter (case-insensitive).") + ] = None, + regex_filter: Annotated[ + Optional[str], typer.Option("--regex", help="Case-insensitive regular-expression filter.") + ] = None, + output_format: Annotated[ + OutputFormat, typer.Option("--output", "-o", help="Output format: yaml, json, or plain.") + ] = OutputFormat.yaml, + limit: Annotated[ + Optional[int], typer.Option("--limit", help="Maximum number of results to return.", min=1) + ] = 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) + typer.echo(render_names(names, name_filter, regex_filter, limit, offset, ctx, output_format)) @app.command() @@ -40,7 +55,7 @@ def list_dimension( def exists( ctx: typer.Context, dimension_name: str, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, watch: Annotated[bool, WATCH_OPTION] = False, # pylint: disable=unused-argument interval: Annotated[int, INTERVAL_OPTION] = 5, # pylint: disable=unused-argument ): diff --git a/tm1cli/commands/process.py b/tm1cli/commands/process.py index bbe363d..6b09885 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,10 @@ 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, + render_names, +) 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 @@ -28,15 +32,35 @@ def _get_process(name: str, database_config: dict) -> Process: @app.command(name="list") def list_process( ctx: typer.Context, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, + skip_control_tis: Annotated[ + bool, + typer.Option( + "-s", + "--skip-control-tis", + help="Exclude control TI processes (names starting with '}').", + ), + ] = False, + name_filter: Annotated[ + Optional[str], typer.Option("--filter", "-f", help="Literal substring filter (case-insensitive).") + ] = None, + regex_filter: Annotated[ + Optional[str], typer.Option("--regex", help="Case-insensitive regular-expression filter.") + ] = None, + output_format: Annotated[ + OutputFormat, typer.Option("--output", "-o", help="Output format: yaml, json, or plain.") + ] = OutputFormat.yaml, + limit: Annotated[ + Optional[int], typer.Option("--limit", help="Maximum number of results to return.", min=1) + ] = 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) + typer.echo(render_names(names, name_filter, regex_filter, limit, offset, ctx, output_format)) @app.command() @@ -44,7 +68,7 @@ def list_process( def exists( ctx: typer.Context, name: str, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, watch: Annotated[bool, WATCH_OPTION] = False, # pylint: disable=unused-argument interval: Annotated[int, INTERVAL_OPTION] = 5, # pylint: disable=unused-argument ): @@ -109,7 +133,7 @@ def dump( dump_format: Annotated[ str, typer.Option("--format", help="Specify the output format of ") ] = "yaml", - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, ): """ Dumps a process from a TM1 database to a file @@ -148,7 +172,7 @@ def load( load_format: Annotated[ str, typer.Option("--format", help="Specify the input format") ] = "yaml", - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, ): """ Loads a process from a file into a TM1 database diff --git a/tm1cli/commands/subset.py b/tm1cli/commands/subset.py index 33bed3d..cd4c135 100644 --- a/tm1cli/commands/subset.py +++ b/tm1cli/commands/subset.py @@ -1,12 +1,19 @@ -from typing import Annotated +from typing import Annotated, Optional 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.list_utils import ( + OutputFormat, + VisibilityType, + apply_paging, + effective_output, + filter_records, + render_output, +) +from tm1cli.utils.various import print_error_and_exit, resolve_database from tm1cli.utils.watch import watch_option app = typer.Typer() @@ -16,17 +23,61 @@ @app.command(name="list") def list_subset( ctx: typer.Context, - dimension_name: str, - # hierarchy_name: str = None, - database: Annotated[str, DATABASE_OPTION] = 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, + visibility: Annotated[ + VisibilityType, + typer.Option("--type", "-t", help="Visibility filter: public, private, or both."), + ] = VisibilityType.public, + skip_control_dims: Annotated[ + bool, + typer.Option( + "-s", + "--skip-control-dims", + help="Exclude control dimensions when no dimension is specified.", + ), + ] = False, + database: Annotated[Optional[str], DATABASE_OPTION] = None, + name_filter: Annotated[ + Optional[str], typer.Option("--filter", "-f", help="Literal substring filter (case-insensitive).") + ] = None, + regex_filter: Annotated[ + Optional[str], typer.Option("--regex", help="Case-insensitive regular-expression filter.") + ] = None, + output_format: Annotated[ + OutputFormat, typer.Option("--output", "-o", help="Output format: yaml, json, or plain.") + ] = OutputFormat.yaml, + limit: Annotated[ + Optional[int], typer.Option("--limit", help="Maximum number of results to return.", min=1) + ] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, +): # pylint: disable=too-many-locals """ - List subsets + List subsets. Scanning all dimensions performs one request per selected visibility. """ - + if hierarchy and not dimension: + print_error_and_exit("--hierarchy requires --dimension.") + include_public = visibility in (VisibilityType.public, VisibilityType.both) + include_private = visibility in (VisibilityType.private, VisibilityType.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=skip_control_dims) + 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"}) + results = filter_records(results, name_filter, regex_filter) + typer.echo(render_output(apply_paging(results, limit, offset), effective_output(ctx, output_format))) @app.command() @@ -38,7 +89,7 @@ def exists( is_private: Annotated[ bool, typer.Option("-p", "--private", help="Flag to specify if view is private") ] = False, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, watch: Annotated[bool, WATCH_OPTION] = False, # pylint: disable=unused-argument interval: Annotated[int, INTERVAL_OPTION] = 5, # pylint: disable=unused-argument ): diff --git a/tm1cli/commands/view.py b/tm1cli/commands/view.py index c5f3e2e..ba2ce1d 100644 --- a/tm1cli/commands/view.py +++ b/tm1cli/commands/view.py @@ -1,11 +1,18 @@ -from typing import Annotated +from typing import Annotated, Optional 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.list_utils import ( + OutputFormat, + VisibilityType, + apply_paging, + effective_output, + filter_records, + render_output, +) from tm1cli.utils.various import resolve_database from tm1cli.utils.watch import watch_option @@ -16,16 +23,52 @@ @app.command(name="list") def list_view( ctx: typer.Context, - cube_name: str, - database: Annotated[str, DATABASE_OPTION] = None, -): + cube: Annotated[Optional[str], typer.Option("--cube", "-c", help="Scope to a specific cube name.")] = None, + visibility: Annotated[ + VisibilityType, + typer.Option("--type", "-t", help="Visibility filter: public, private, or both."), + ] = VisibilityType.public, + skip_control_cubes: Annotated[ + bool, + typer.Option( + "-s", + "--skip-control-cubes", + help="Exclude control cubes when no cube is specified.", + ), + ] = False, + database: Annotated[Optional[str], DATABASE_OPTION] = None, + name_filter: Annotated[ + Optional[str], typer.Option("--filter", "-f", help="Literal substring filter (case-insensitive).") + ] = None, + regex_filter: Annotated[ + Optional[str], typer.Option("--regex", help="Case-insensitive regular-expression filter.") + ] = None, + output_format: Annotated[ + OutputFormat, typer.Option("--output", "-o", help="Output format: yaml, json, or plain.") + ] = OutputFormat.yaml, + limit: Annotated[ + Optional[int], typer.Option("--limit", help="Maximum number of results to return.", min=1) + ] = None, + offset: Annotated[int, typer.Option("--offset", help="Number of results to skip (for paging).", min=0)] = 0, +): # pylint: disable=too-many-locals """ - List views + List views. Scanning all cubes performs two requests per cube in TM1py 2.x. """ - + include_public = visibility in (VisibilityType.public, VisibilityType.both) + include_private = visibility in (VisibilityType.private, VisibilityType.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=skip_control_cubes) + 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"}) + results = filter_records(results, name_filter, regex_filter) + typer.echo(render_output(apply_paging(results, limit, offset), effective_output(ctx, output_format))) @app.command() @@ -37,7 +80,7 @@ def exists( is_private: Annotated[ bool, typer.Option("-p", "--private", help="Flag to specify if view is private") ] = False, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, watch: Annotated[bool, WATCH_OPTION] = False, # pylint: disable=unused-argument interval: Annotated[int, INTERVAL_OPTION] = 5, # pylint: disable=unused-argument ): diff --git a/tm1cli/main.py b/tm1cli/main.py index 7b6f987..786a0f5 100644 --- a/tm1cli/main.py +++ b/tm1cli/main.py @@ -1,7 +1,7 @@ import importlib.metadata import json import os -from typing import Annotated +from typing import Annotated, Optional import typer import yaml @@ -70,7 +70,7 @@ def version(): @app.command() -def tm1_version(ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None): +def tm1_version(ctx: typer.Context, database: Annotated[Optional[str], DATABASE_OPTION] = None): """ Shows the TM1 database version """ @@ -81,7 +81,7 @@ def tm1_version(ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = @app.command() -def whoami(ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None): +def whoami(ctx: typer.Context, database: Annotated[Optional[str], DATABASE_OPTION] = None): """ Shows the currently logged in TM1 user """ @@ -93,7 +93,7 @@ def whoami(ctx: typer.Context, database: Annotated[str, DATABASE_OPTION] = None) @app.command() def threads( ctx: typer.Context, - database: Annotated[str, DATABASE_OPTION] = None, + database: Annotated[Optional[str], DATABASE_OPTION] = None, beautify: Annotated[ bool, typer.Option("--beautify", "-b", help="Flag for printing a table."), @@ -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..c3ac037 --- /dev/null +++ b/tm1cli/utils/list_utils.py @@ -0,0 +1,103 @@ +"""Shared utilities for list commands: filtering, paging, and output rendering.""" +# Enum members intentionally match the public CLI values. +# pylint: disable=invalid-name +from __future__ import annotations + +import json +import re +from enum import Enum +from typing import Any, Optional + +import typer +import yaml + + +class OutputFormat(str, Enum): # pylint: disable=invalid-name + yaml = "yaml" + json = "json" + plain = "plain" + + +class VisibilityType(str, Enum): # pylint: disable=invalid-name + public = "public" + private = "private" + both = "both" + + +def apply_filter(names: list[str], pattern: Optional[str]) -> list[str]: + """Return names containing *pattern* (case-insensitive substring).""" + if not pattern: + return names + needle = pattern.casefold() + return [name for name in names if needle in name.casefold()] + + +def apply_regex(names: list[str], pattern: Optional[str]) -> list[str]: + """Return names matching *pattern* as a case-insensitive regular expression.""" + if not pattern: + return names + try: + rx = re.compile(pattern, re.IGNORECASE) + return [name for name in names if rx.search(name)] + except re.error as error: + raise typer.BadParameter(f"Invalid regular expression: {pattern}") from error + + +def filter_records( + records: list[dict[str, Any]], + pattern: Optional[str], + regex_pattern: Optional[str] = None, +) -> list[dict[str, Any]]: + """Return records whose ``name`` matches *pattern*.""" + if not pattern and not regex_pattern: + return records + names = [record["name"] for record in records] + matching_names = set(apply_filter(names, pattern)) + matching_names = set(apply_regex(list(matching_names), regex_pattern)) + return [record for record in records if record["name"] in matching_names] + + +def effective_output(ctx: Any, output_format: OutputFormat) -> OutputFormat: + """Use plain output when the global raw-output flag is enabled.""" + if ctx.obj.get("raw"): + return OutputFormat.plain + return output_format + + +def render_names( + names: list[str], + name_filter: Optional[str], + regex_filter: Optional[str], + limit: Optional[int], + offset: int, + ctx: Any, + output_format: OutputFormat, +) -> str: + """Filter, page, and render a list of names.""" + names = apply_filter(names, name_filter) + names = apply_regex(names, regex_filter) + return render_output( + apply_paging(names, limit, offset), + effective_output(ctx, output_format), + ) + + +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 == OutputFormat.json: + return json.dumps(data, indent=2, ensure_ascii=False) + if fmt == OutputFormat.plain: + return "\n".join( + item if isinstance(item, str) else str(item.get("name", item)) + for item in data + ) + return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False).rstrip() diff --git a/tm1cli/utils/various.py b/tm1cli/utils/various.py index 19fa3a9..0b8dff8 100644 --- a/tm1cli/utils/various.py +++ b/tm1cli/utils/various.py @@ -1,8 +1,10 @@ +from typing import Optional + import typer from rich import print as rich_print -def resolve_database(ctx: typer.Context, database_name: str) -> dict: +def resolve_database(ctx: typer.Context, database_name: Optional[str]) -> dict: """ Resolves the database name to its configuration. If no database is specified, use the default database.