diff --git a/CHANGELOG.md b/CHANGELOG.md index ed41929..2ee62e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v0.1.5 - 2025-16-01 + +### Features + +- New command: `cube exists` +- New command: `view exists` +- New command: `dimension list` +- New command: `dimension exists` +- New command: `subset list` +- New command: `subset exists` + + +### Chore + +- Improved testing + ## v0.1.4 - 2024-11-29 ### Features diff --git a/README.md b/README.md index bcf033b..99345aa 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,16 @@ tm1cli process dump --folder --format tm1cli process load --folder --format tm1cli cube list +tm1cli cube exists + +tm1cli dimension list +tm1cli dimension exists tm1cli view list +tm1cli view exists + +tm1cli subset list +tm1cli subset exists ``` ### All Available Commands diff --git a/poetry.lock b/poetry.lock index 69752ce..b8747c7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -531,6 +531,23 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytz" version = "2024.2" @@ -791,4 +808,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "02d8dcfcd2f62bd3c553c5f0265ace44448b750b2f3b3e9089ef412b8dc7400c" +content-hash = "4b8690168b6908e88cbe63502587650b8ae119baaa96914ab44afcb9e8912d81" diff --git a/pyproject.toml b/pyproject.toml index 0119a1a..4728bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tm1cli" -version = "0.1.4" +version = "0.1.5" description = "A command-line interface (CLI) tool for interacting with TM1 servers using TM1py." authors = ["onefloid "] license = "MIT License" @@ -17,6 +17,7 @@ pyyaml = "^6.0.2" isort = "^5.13.2" pytest = "^8.3.3" pylint = "^3.3.1" +pytest-mock = "^3.14.0" [tool.poetry.scripts] tm1cli = "tm1cli.main:app" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a347c38 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,77 @@ +from TM1py import Process + + +class MockedCubeService: + cubes = ["Cube1", "Cube2"] + + def get_all_names(self, cube_name: str): + return self.cubes + + def exists(self, cube_name: str): + return cube_name in self.cubes + + +class MockedViewService: + + def get_all_names(self, cube_name: str): + return ["View1", "View2", "View3"] + + def exists(self, cube_name: str, view_name: str, private: bool): + if "not" in view_name.lower(): + return False + else: + return True + + +class MockedDimensionService: + def get_all_names(self, skip_control_dims: bool): + return ["Dimension1", "Dimension2", "Dimension3"] + + def exists(self, dimension_name: str): + if "not" in dimension_name.lower(): + return False + else: + return True + + +class MockedSubsetService: + def get_all_names(self, dimension_name: str): + return ["Subset1", "Subset2", "Subset3"] + + def exists(self, dimension_name: str, subset_name: str, private: bool): + if "not" in subset_name.lower(): + return False + else: + return True + + +class MockedProcessService: + def exists(self, process_name: str): + return False if "not" in process_name else True + + def get(self, process_name: str): + return Process(process_name) + + def update_or_create(self, process: Process): + return f"Process {process.name} was tested mocked." + + +class MockedTM1Service: + def __init__(self, **kwargs) -> None: + self.views = MockedViewService() + self.processes = MockedProcessService() + self.cubes = MockedCubeService() + self.dimensions = MockedDimensionService() + self.subsets = MockedSubsetService() + + def __enter__(self): + """ + Context manager entry point. + """ + return self + + def __exit__(self, exc_type, exc_value, traceback): + """ + Context manager exit point. Clean up resources if needed. + """ + pass diff --git a/tests/test_cmd_cubes.py b/tests/test_cmd_cubes.py index 3298fd0..ed95515 100644 --- a/tests/test_cmd_cubes.py +++ b/tests/test_cmd_cubes.py @@ -1,13 +1,24 @@ import pytest from typer.testing import CliRunner +from tests.conftest import MockedTM1Service from tm1cli.main import app runner = CliRunner() @pytest.mark.parametrize("command", ["list", "ls"]) -def test_cube_list(command): +def test_cube_list(mocker, command): + mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) result = runner.invoke(app, ["cube", command]) assert result.exit_code == 0 assert isinstance(result.stdout, str) + assert result.stdout == "Cube1\nCube2\n" + + +def test_cube_exists(mocker): + mocker.patch("tm1cli.commands.cube.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["cube", "exists", "Cube1"]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == "True\n" diff --git a/tests/test_cmd_dimension.py b/tests/test_cmd_dimension.py new file mode 100644 index 0000000..e27e379 --- /dev/null +++ b/tests/test_cmd_dimension.py @@ -0,0 +1,24 @@ +import pytest +from typer.testing import CliRunner + +from tests.conftest import MockedTM1Service +from tm1cli.main import app + +runner = CliRunner() + + +@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]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == "Dimension1\nDimension2\nDimension3\n" + + +def test_dimension_exists(mocker): + mocker.patch("tm1cli.commands.dimension.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["dimension", "exists", "Dimension1"]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == "True\n" diff --git a/tests/test_cmd_subset.py b/tests/test_cmd_subset.py new file mode 100644 index 0000000..3d6340d --- /dev/null +++ b/tests/test_cmd_subset.py @@ -0,0 +1,24 @@ +import pytest +from typer.testing import CliRunner + +from tests.conftest import MockedTM1Service +from tm1cli.main import app + +runner = CliRunner() + + +@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"]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == "Subset1\nSubset2\nSubset3\n" + + +def test_subset_exists(mocker): + mocker.patch("tm1cli.commands.subset.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["subset", "exists", "Dimension1", "Subset1"]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == "True\n" diff --git a/tests/test_cmd_view.py b/tests/test_cmd_view.py index 467d79d..104ded4 100644 --- a/tests/test_cmd_view.py +++ b/tests/test_cmd_view.py @@ -3,18 +3,30 @@ from tm1cli.main import app +from .conftest import MockedTM1Service + runner = CliRunner() -@pytest.mark.parametrize("command", ["list", "ls"]) -def test_cube_list(command): - result = runner.invoke( - app, - [ - "view", - command, - "TM1py_tests_annotations_0f680909_74b1_11ef_b4ba_546ceb97bbfb", - ], - ) +@pytest.mark.parametrize( + "options", + [("not", "", "False"), ("example", "-p", "True"), ("not", "--private", "False")], +) +def test_view_exists(mocker, options): + mocker.patch("tm1cli.commands.view.TM1Service", MockedTM1Service) + if options[1]: + result = runner.invoke(app, ["view", "exists", "example_cube", options[0]]) + else: + result = runner.invoke(app, ["view", "exists", "example_cube", options[:1]]) + assert result.exit_code == 0 + assert isinstance(result.stdout, str) + assert result.stdout == f"{options[2]}\n" + + +def test_view_list(mocker): + mocker.patch("tm1cli.commands.view.TM1Service", MockedTM1Service) + result = runner.invoke(app, ["view", "list", "example_cube"]) + assert result.exit_code == 0 assert isinstance(result.stdout, str) + assert result.stdout == "View1\nView2\nView3\n" diff --git a/tm1cli/commands/__init__.py b/tm1cli/commands/__init__.py index 89de21b..3a1dc3a 100644 --- a/tm1cli/commands/__init__.py +++ b/tm1cli/commands/__init__.py @@ -1,3 +1,3 @@ -from . import cube, process, view +from . import cube, dimension, process, subset, view -__all__ = ["process", "cube", "view"] +__all__ = ["process", "cube", "view", "dimension", "subset"] diff --git a/tm1cli/commands/cube.py b/tm1cli/commands/cube.py index 15c336b..ba9c2d5 100644 --- a/tm1cli/commands/cube.py +++ b/tm1cli/commands/cube.py @@ -31,3 +31,16 @@ def list_cube( with TM1Service(**resolve_database(ctx, database)) as tm1: for cube in tm1.cubes.get_all_names(skip_control_cubes): print(cube) + + +@app.command() +def exists( + ctx: typer.Context, + cube_name: str, + database: Annotated[str, DATABASE_OPTION] = None, +): + """ + Check if cube exists + """ + with TM1Service(**resolve_database(ctx, database)) as tm1: + print(tm1.cubes.exists(cube_name)) diff --git a/tm1cli/commands/dimension.py b/tm1cli/commands/dimension.py new file mode 100644 index 0000000..203c9ce --- /dev/null +++ b/tm1cli/commands/dimension.py @@ -0,0 +1,46 @@ +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 +from tm1cli.utils.various import resolve_database + +app = typer.Typer() + + +@app.command(name="ls", help="Alias for list") +@app.command(name="list") +def list_dimension( + ctx: typer.Context, + database: Annotated[str, DATABASE_OPTION] = None, + skip_control_dims: Annotated[ + bool, + typer.Option( + "-s", + "--skip-control-cubes", + help="Flag for not printing control cubes.", + ), + ] = False, +): + """ + List dimensions + """ + + with TM1Service(**resolve_database(ctx, database)) as tm1: + for dim in tm1.dimensions.get_all_names(skip_control_dims): + print(dim) + + +@app.command() +def exists( + ctx: typer.Context, + dimension_name: str, + database: Annotated[str, DATABASE_OPTION] = None, +): + """ + Check if dimension exists + """ + with TM1Service(**resolve_database(ctx, database)) as tm1: + print(tm1.dimensions.exists(dimension_name)) diff --git a/tm1cli/commands/subset.py b/tm1cli/commands/subset.py new file mode 100644 index 0000000..32360c3 --- /dev/null +++ b/tm1cli/commands/subset.py @@ -0,0 +1,45 @@ +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 +from tm1cli.utils.various import resolve_database + +app = typer.Typer() + + +@app.command(name="ls", help="Alias for list") +@app.command(name="list") +def list_subset( + ctx: typer.Context, + dimension_name: str, + # hierarchy_name: str = None, + database: Annotated[str, DATABASE_OPTION] = None, +): + """ + List subsets + """ + + with TM1Service(**resolve_database(ctx, database)) as tm1: + for subset in tm1.subsets.get_all_names(dimension_name): + print(subset) + + +@app.command() +def exists( + ctx: typer.Context, + dimension_name: str, + subset_name: str, + is_private: Annotated[ + bool, typer.Option("-p", "--private", help="Flag to specify if view is private") + ] = False, + database: Annotated[str, DATABASE_OPTION] = None, +): + """ + Check if subset exists + """ + + with TM1Service(**resolve_database(ctx, database)) as tm1: + print(tm1.views.exists(dimension_name, subset_name, is_private)) diff --git a/tm1cli/commands/view.py b/tm1cli/commands/view.py index 4a4423f..dc72fbe 100644 --- a/tm1cli/commands/view.py +++ b/tm1cli/commands/view.py @@ -24,3 +24,21 @@ def list_view( with TM1Service(**resolve_database(ctx, database)) as tm1: for view in tm1.views.get_all_names(cube_name): print(view) + + +@app.command() +def exists( + ctx: typer.Context, + cube_name: str, + view_name: str, + is_private: Annotated[ + bool, typer.Option("-p", "--private", help="Flag to specify if view is private") + ] = False, + database: Annotated[str, DATABASE_OPTION] = None, +): + """ + Check if view exists + """ + + with TM1Service(**resolve_database(ctx, database)) as tm1: + print(tm1.views.exists(cube_name, view_name, is_private)) diff --git a/tm1cli/main.py b/tm1cli/main.py index 130b3ee..6c828e6 100644 --- a/tm1cli/main.py +++ b/tm1cli/main.py @@ -19,6 +19,8 @@ ("process", commands.process), ("cube", commands.cube), ("view", commands.view), + ("dimension", commands.dimension), + ("subset", commands.subset), ] for name, module in modules: app.add_typer(module.app, name=name)