From bdf26805aaee2ff9863683cca46e6defcc908d05 Mon Sep 17 00:00:00 2001 From: nicolasbisurgi Date: Thu, 4 Jun 2026 19:04:47 -0300 Subject: [PATCH 1/2] fix: get measure/time dimension from cube nav properties (closes #1412) get_measure_dimension previously returned the cube's last dimension, on the assumption that the measures dimension is always last. That is not guaranteed - a cube can have its measures dimension assigned to any dimension - so the method could return the wrong dimension. Read the cube's MeasuresDimension navigation property instead, which is available on both v11 and v12 and reflects the actual assignment. When no measures dimension is explicitly assigned, TM1 returns 204 and we fall back to the last dimension (TM1's own default), preserving prior behavior for that case. Also add: - get_time_dimension: reads the TimeDimension nav property (None if unset) - set_measure_dimension / set_time_dimension: bind the nav property via PATCH, validating the dimension belongs to the cube first Note: }CubeProperties (the v11 control cube) is not used, since it does not exist on v12; the navigation-property approach works on both. Tests added and verified live against v11 and v12. Co-Authored-By: Claude Opus 4.8 (1M context) --- TM1py/Services/CubeService.py | 69 +++++++++++++++++++++++++++++++++-- Tests/CubeService_test.py | 25 +++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/TM1py/Services/CubeService.py b/TM1py/Services/CubeService.py index ce565228..349374a5 100644 --- a/TM1py/Services/CubeService.py +++ b/TM1py/Services/CubeService.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import json import random -from typing import Dict, Iterable, List, Union +from typing import Dict, Iterable, List, Optional, Union from requests import Response @@ -102,9 +102,72 @@ def get_number_of_cubes(self, skip_control_cubes: bool = False, **kwargs) -> int return int(self._rest.GET(url=format_url("/Cubes/$count"), **kwargs).text) def get_measure_dimension(self, cube_name: str, **kwargs) -> str: - url = format_url("/Cubes('{}')/Dimensions?$select=Name", cube_name) + """Get the measures dimension of a cube. + + Reads the cube's MeasuresDimension navigation property (available on both v11 + and v12). When no measures dimension is explicitly assigned, TM1 treats the + last dimension of the cube as the measures dimension, so that is returned as + a fallback. + + :param cube_name: + :return: name of the measures dimension + """ + url = format_url("/Cubes('{}')/MeasuresDimension?$select=Name", cube_name) + response = self._rest.GET(url, **kwargs) + if response.status_code == 204 or not response.text: + return self.get_dimension_names(cube_name=cube_name, **kwargs)[-1] + return response.json()["Name"] + + def get_time_dimension(self, cube_name: str, **kwargs) -> Optional[str]: + """Get the time dimension of a cube. + + Reads the cube's TimeDimension navigation property (available on both v11 and + v12). Returns None when no time dimension is assigned to the cube. + + :param cube_name: + :return: name of the time dimension, or None if not assigned + """ + url = format_url("/Cubes('{}')/TimeDimension?$select=Name", cube_name) response = self._rest.GET(url, **kwargs) - return response.json()["value"][-1]["Name"] + if response.status_code == 204 or not response.text: + return None + return response.json()["Name"] + + @require_data_admin + def set_measure_dimension(self, cube_name: str, dimension_name: str, **kwargs) -> Response: + """Set the measures dimension of a cube. + + Binds the cube's MeasuresDimension navigation property to the given dimension. + + :param cube_name: + :param dimension_name: must be one of the cube's dimensions + :return: response + """ + self._validate_dimension_in_cube(cube_name=cube_name, dimension_name=dimension_name, **kwargs) + url = format_url("/Cubes('{}')", cube_name) + payload = {"MeasuresDimension@odata.bind": format_url("Dimensions('{}')", dimension_name)} + return self._rest.PATCH(url=url, data=json.dumps(payload), **kwargs) + + @require_data_admin + def set_time_dimension(self, cube_name: str, dimension_name: str, **kwargs) -> Response: + """Set the time dimension of a cube. + + Binds the cube's TimeDimension navigation property to the given dimension. + + :param cube_name: + :param dimension_name: must be one of the cube's dimensions + :return: response + """ + self._validate_dimension_in_cube(cube_name=cube_name, dimension_name=dimension_name, **kwargs) + url = format_url("/Cubes('{}')", cube_name) + payload = {"TimeDimension@odata.bind": format_url("Dimensions('{}')", dimension_name)} + return self._rest.PATCH(url=url, data=json.dumps(payload), **kwargs) + + def _validate_dimension_in_cube(self, cube_name: str, dimension_name: str, **kwargs) -> None: + """Raise ValueError if dimension_name is not a dimension of the cube""" + dimension_names = self.get_dimension_names(cube_name=cube_name, **kwargs) + if not any(case_and_space_insensitive_equals(dimension_name, dim) for dim in dimension_names): + raise ValueError(f"'{dimension_name}' is not a dimension of cube '{cube_name}'") def update(self, cube: Cube, **kwargs) -> Response: """Update existing cube on TM1 Server diff --git a/Tests/CubeService_test.py b/Tests/CubeService_test.py index 973b9977..62af11ae 100644 --- a/Tests/CubeService_test.py +++ b/Tests/CubeService_test.py @@ -337,6 +337,31 @@ def test_get_measure_dimension(self): self.assertEqual(self.dimension_names[-1], measure_dimension) + def test_set_and_get_measure_dimension(self): + # assign a non-last dimension as the measures dimension + self.tm1.cubes.set_measure_dimension(self.cube_name, self.dimension_names[0]) + + measure_dimension = self.tm1.cubes.get_measure_dimension(self.cube_name) + self.assertEqual(self.dimension_names[0], measure_dimension) + + def test_set_measure_dimension_invalid_dimension(self): + with self.assertRaises(ValueError): + self.tm1.cubes.set_measure_dimension(self.cube_name, "Not_A_Dimension_Of_The_Cube") + + def test_get_time_dimension_not_set(self): + time_dimension = self.tm1.cubes.get_time_dimension(self.cube_name) + self.assertIsNone(time_dimension) + + def test_set_and_get_time_dimension(self): + self.tm1.cubes.set_time_dimension(self.cube_name, self.dimension_names[1]) + + time_dimension = self.tm1.cubes.get_time_dimension(self.cube_name) + self.assertEqual(self.dimension_names[1], time_dimension) + + def test_set_time_dimension_invalid_dimension(self): + with self.assertRaises(ValueError): + self.tm1.cubes.set_time_dimension(self.cube_name, "Not_A_Dimension_Of_The_Cube") + def tearDown(self): self.tm1.cubes.delete(self.cube_name) if self.tm1.cubes.exists(self.cube_name_to_delete): From 3c26cb963d9ba9422770442defd697ebc7845af2 Mon Sep 17 00:00:00 2001 From: nicolasbisurgi Date: Wed, 10 Jun 2026 11:59:40 -0300 Subject: [PATCH 2/2] fix: gate measure/time dimension methods behind v11.8.018 The MeasuresDimension and TimeDimension navigation properties are not exposed before v11.8.018 (confirmed: fails on 11.8.01700.1, works on 11.8.02200.2+). Add @require_version("11.8.018") to get/set_measure_dimension and get/set_time_dimension so older servers raise a clear version error instead of failing on the missing endpoint. Docstrings updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- TM1py/Services/CubeService.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/TM1py/Services/CubeService.py b/TM1py/Services/CubeService.py index 349374a5..8f6913fb 100644 --- a/TM1py/Services/CubeService.py +++ b/TM1py/Services/CubeService.py @@ -101,13 +101,15 @@ def get_number_of_cubes(self, skip_control_cubes: bool = False, **kwargs) -> int return int(self._rest.GET(url=format_url("/Cubes/$count"), **kwargs).text) + @require_version(version="11.8.018") def get_measure_dimension(self, cube_name: str, **kwargs) -> str: """Get the measures dimension of a cube. - Reads the cube's MeasuresDimension navigation property (available on both v11 - and v12). When no measures dimension is explicitly assigned, TM1 treats the - last dimension of the cube as the measures dimension, so that is returned as - a fallback. + Reads the cube's MeasuresDimension navigation property. This property is + available from v11.8.018 onwards (and on v12); earlier versions do not expose + it. When no measures dimension is explicitly assigned, TM1 treats the last + dimension of the cube as the measures dimension, so that is returned as a + fallback. :param cube_name: :return: name of the measures dimension @@ -118,11 +120,13 @@ def get_measure_dimension(self, cube_name: str, **kwargs) -> str: return self.get_dimension_names(cube_name=cube_name, **kwargs)[-1] return response.json()["Name"] + @require_version(version="11.8.018") def get_time_dimension(self, cube_name: str, **kwargs) -> Optional[str]: """Get the time dimension of a cube. - Reads the cube's TimeDimension navigation property (available on both v11 and - v12). Returns None when no time dimension is assigned to the cube. + Reads the cube's TimeDimension navigation property. This property is available + from v11.8.018 onwards (and on v12); earlier versions do not expose it. Returns + None when no time dimension is assigned to the cube. :param cube_name: :return: name of the time dimension, or None if not assigned @@ -134,10 +138,12 @@ def get_time_dimension(self, cube_name: str, **kwargs) -> Optional[str]: return response.json()["Name"] @require_data_admin + @require_version(version="11.8.018") def set_measure_dimension(self, cube_name: str, dimension_name: str, **kwargs) -> Response: """Set the measures dimension of a cube. Binds the cube's MeasuresDimension navigation property to the given dimension. + This property is available from v11.8.018 onwards (and on v12). :param cube_name: :param dimension_name: must be one of the cube's dimensions @@ -149,10 +155,12 @@ def set_measure_dimension(self, cube_name: str, dimension_name: str, **kwargs) - return self._rest.PATCH(url=url, data=json.dumps(payload), **kwargs) @require_data_admin + @require_version(version="11.8.018") def set_time_dimension(self, cube_name: str, dimension_name: str, **kwargs) -> Response: """Set the time dimension of a cube. Binds the cube's TimeDimension navigation property to the given dimension. + This property is available from v11.8.018 onwards (and on v12). :param cube_name: :param dimension_name: must be one of the cube's dimensions