Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ tm1cli cube exists <cube_name> --watch
tm1cli dimension list
tm1cli dimension exists <dimension_name> -w

tm1cli view list <cube_name>
tm1cli view list --cube <cube_name>
tm1cli view exists <cube_name> <view_name>

tm1cli subset list <dimension_name>
tm1cli subset list --dimension <dimension_name>
tm1cli subset exists <dimension_name> <subset_name>
```

Expand All @@ -84,6 +84,15 @@ value instead, e.g. for scripting:
tm1cli --output-raw cube exists <cube_name>
```

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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <onefloid@gmx.de>"]
license = "MIT License"
Expand Down
11 changes: 8 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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():
Expand All @@ -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):
Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions tests/test_cmd_cubes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cmd_dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
49 changes: 48 additions & 1 deletion tests/test_cmd_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
17 changes: 16 additions & 1 deletion tests/test_cmd_subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down
11 changes: 10 additions & 1 deletion tests/test_cmd_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 8 additions & 2 deletions tests/test_tm1cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
from typer.testing import CliRunner

from tests.conftest import MockedTM1Service
from tm1cli.main import app

runner = CliRunner()
Expand Down Expand Up @@ -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

Expand Down
31 changes: 23 additions & 8 deletions tm1cli/commands/cube.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -16,31 +19,43 @@
@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()
@watch_option
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
):
Expand Down
Loading