diff --git a/TM1py/Exceptions/Exceptions.py b/TM1py/Exceptions/Exceptions.py index 90050273..37caf8ee 100644 --- a/TM1py/Exceptions/Exceptions.py +++ b/TM1py/Exceptions/Exceptions.py @@ -23,6 +23,15 @@ def __str__(self): return f"Function '{self.function}' requires TM1 server version >= '{self.required_version}'" +class TM1pyVersionDeprecationException(Exception): + def __init__(self, function: str, deprecated_in_version): + self.function = function + self.deprecated_in_version = deprecated_in_version + + def __str__(self): + return f"Function '{self.function}' has been deprecated in TM1 server version >= '{self.deprecated_in_version}'" + + class TM1pyNotAdminException(Exception): def __init__(self, function: str): self.function = function diff --git a/TM1py/Objects/Chore.py b/TM1py/Objects/Chore.py index 862320fe..7523e2a5 100644 --- a/TM1py/Objects/Chore.py +++ b/TM1py/Objects/Chore.py @@ -31,7 +31,7 @@ def __init__(self, name: str, start_time: ChoreStartTime, dst_sensitivity: bool, def from_json(cls, chore_as_json: str) -> 'Chore': """ Alternative constructor - :param chore_as_json: string, JSON. Response of /api/v1/Chores('x')/Tasks?$expand=* + :param chore_as_json: string, JSON. Response of /Chores('x')/Tasks?$expand=* :return: Chore, an instance of this class """ chore_as_dict = json.loads(chore_as_json) diff --git a/TM1py/Objects/GitProject.py b/TM1py/Objects/GitProject.py index fe8a713c..22e6d349 100644 --- a/TM1py/Objects/GitProject.py +++ b/TM1py/Objects/GitProject.py @@ -274,7 +274,7 @@ def remove_deployment(self, deployment_name: str): @classmethod def from_json(cls, tm1project_as_json: str) -> 'TM1Project': """ - :param tm1project_as_json: response of /api/v1/!tm1project + :param tm1project_as_json: response of /!tm1project :return: an instance of this class """ tm1project_as_dict = json.loads(tm1project_as_json) diff --git a/TM1py/Objects/Process.py b/TM1py/Objects/Process.py index f061a705..9a32afec 100644 --- a/TM1py/Objects/Process.py +++ b/TM1py/Objects/Process.py @@ -123,7 +123,7 @@ def __init__(self, @classmethod def from_json(cls, process_as_json: str) -> 'Process': """ - :param process_as_json: response of /api/v1/Processes('x')?$expand=* + :param process_as_json: response of /Processes('x')?$expand=* :return: an instance of this class """ process_as_dict = json.loads(process_as_json) diff --git a/TM1py/Objects/Server.py b/TM1py/Objects/Server.py index f3d67db1..684dd1c2 100644 --- a/TM1py/Objects/Server.py +++ b/TM1py/Objects/Server.py @@ -6,7 +6,7 @@ class Server: """ Abstraction of the TM1 Server :Notes: - contains the information you get from http://localhost:5895/api/v1/Servers + contains the information you get from http://localhost:5895/Servers no methods so far """ def __init__(self, server_as_dict: Dict): diff --git a/TM1py/Objects/User.py b/TM1py/Objects/User.py index 33ad26f7..fc13e841 100644 --- a/TM1py/Objects/User.py +++ b/TM1py/Objects/User.py @@ -136,7 +136,7 @@ def from_dict(cls, user_as_dict: Dict) -> 'User': """ return cls(name=user_as_dict['Name'], friendly_name=user_as_dict['FriendlyName'], - enabled=user_as_dict["Enabled"], + enabled=user_as_dict.get('Enabled', None), user_type=user_as_dict["Type"], groups=[group["Name"] for group in user_as_dict['Groups']]) diff --git a/TM1py/Services/AnnotationService.py b/TM1py/Services/AnnotationService.py index 877e42d5..5235c059 100644 --- a/TM1py/Services/AnnotationService.py +++ b/TM1py/Services/AnnotationService.py @@ -24,7 +24,7 @@ def get_all(self, cube_name: str, **kwargs) -> List[Annotation]: :param cube_name: """ - url = format_url("/api/v1/Cubes('{}')/Annotations?$expand=DimensionalContext($select=Name)", cube_name) + url = format_url("/Cubes('{}')/Annotations?$expand=DimensionalContext($select=Name)", cube_name) response = self._rest.GET(url, **kwargs) annotations_as_dict = response.json()['value'] @@ -36,7 +36,7 @@ def create(self, annotation: Annotation, **kwargs) -> Response: :param annotation: instance of TM1py.Annotation """ - url = "/api/v1/Annotations" + url = "/Annotations" from TM1py import CubeService cube_dimensions = CubeService(self._rest).get_dimension_names( @@ -65,7 +65,7 @@ def create_many(self, annotations: Iterable[Annotation], **kwargs) -> Response: cube_dimensions[annotation.object_name] = dimension_names payload.append(annotation.construct_body_for_post(dimension_names)) - response = self._rest.POST("/api/v1/Annotations", json.dumps(payload), **kwargs) + response = self._rest.POST("/Annotations", json.dumps(payload), **kwargs) return response @@ -74,7 +74,7 @@ def get(self, annotation_id: str, **kwargs) -> Annotation: :param annotation_id: String, the id of the annotation """ - request = format_url("/api/v1/Annotations('{}')?$expand=DimensionalContext($select=Name)", annotation_id) + request = format_url("/Annotations('{}')?$expand=DimensionalContext($select=Name)", annotation_id) response = self._rest.GET(url=request, **kwargs) return Annotation.from_json(response.text) @@ -84,7 +84,7 @@ def update(self, annotation: Annotation, **kwargs) -> Response: :param annotation: instance of TM1py.Annotation """ - url = format_url("/api/v1/Annotations('{}')", annotation.id) + url = format_url("/Annotations('{}')", annotation.id) return self._rest.PATCH(url=url, data=annotation.body, **kwargs) def delete(self, annotation_id: str, **kwargs) -> Response: @@ -92,5 +92,5 @@ def delete(self, annotation_id: str, **kwargs) -> Response: :param annotation_id: string, the id of the annotation """ - url = format_url("/api/v1/Annotations('{}')", annotation_id) + url = format_url("/Annotations('{}')", annotation_id) return self._rest.DELETE(url=url, **kwargs) diff --git a/TM1py/Services/ApplicationService.py b/TM1py/Services/ApplicationService.py index 4a2a2653..d70d2da1 100644 --- a/TM1py/Services/ApplicationService.py +++ b/TM1py/Services/ApplicationService.py @@ -9,7 +9,7 @@ Application from TM1py.Services import RestService from TM1py.Services.ObjectService import ObjectService -from TM1py.Utils import format_url +from TM1py.Utils import format_url, verify_version class ApplicationService(ObjectService): @@ -24,6 +24,21 @@ def __init__(self, tm1_rest: RestService): super().__init__(tm1_rest) self._rest = tm1_rest + def get_all_public_root_names(self, **kwargs): + + url = "/Contents('Applications')/Contents" + response = self._rest.GET(url, **kwargs) + applications = list(application['Name'] for application in response.json()['value']) + return applications + + def get_all_private_root_names(self, **kwargs): + + url = "/Contents('Applications')/PrivateContents" + response = self._rest.GET(url, **kwargs) + applications = list(application['Name'] for application in response.json()['value']) + return applications + + def get(self, path: str, application_type: Union[str, ApplicationTypes], name: str, private: bool = False, **kwargs) -> Application: """ Retrieve Planning Analytics Application @@ -41,7 +56,7 @@ def get(self, path: str, application_type: Union[str, ApplicationTypes], name: s if application_type == ApplicationTypes.DOCUMENT: return self.get_document(path=path, name=name, private=private, **kwargs) - if not application_type == ApplicationTypes.FOLDER: + if not application_type == ApplicationTypes.FOLDER and not verify_version(required_version='12', version=self.version): name += application_type.suffix contents = 'PrivateContents' if private else 'Contents' @@ -50,7 +65,7 @@ def get(self, path: str, application_type: Union[str, ApplicationTypes], name: s mid = "".join([format_url("/Contents('{}')", element) for element in path.split('/')]) base_url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{application_name}')", + "/Contents('Applications')" + mid + "/" + contents + "('{application_name}')", application_name=name) if application_type == ApplicationTypes.CUBE: @@ -112,19 +127,19 @@ def get_document(self, path: str, name: str, private: bool = False, **kwargs) -> :param private: boolean :return: Return DocumentApplication """ - if not name.endswith(ApplicationTypes.DOCUMENT.suffix): + if not name.endswith(ApplicationTypes.DOCUMENT.suffix) and not verify_version(required_version='12', version=self.version): name += ApplicationTypes.DOCUMENT.suffix contents = 'PrivateContents' if private else 'Contents' mid = "".join([format_url("/Contents('{}')", element) for element in path.split('/')]) url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{name}')/Document/Content", + "/Contents('Applications')" + mid + "/" + contents + "('{name}')/Document/Content", name=name) content = self._rest.GET(url, **kwargs).content url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{name}')/Document", + "/Contents('Applications')" + mid + "/" + contents + "('{name}')/Document", name=name) document_fields = self._rest.GET(url, **kwargs).json() @@ -150,7 +165,7 @@ def delete(self, path: str, application_type: Union[str, ApplicationTypes], appl # raise ValueError if not a valid ApplicationType application_type = ApplicationTypes(application_type) - if not application_type == ApplicationTypes.FOLDER: + if not application_type == ApplicationTypes.FOLDER and not verify_version(required_version='12', version=self.version): application_name += application_type.suffix contents = 'PrivateContents' if private else 'Contents' @@ -159,7 +174,7 @@ def delete(self, path: str, application_type: Union[str, ApplicationTypes], appl mid = "".join([format_url("/Contents('{}')", element) for element in path.split('/')]) url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{application_name}')", + "/Contents('Applications')" + mid + "/" + contents + "('{application_name}')", application_name=application_name) return self._rest.DELETE(url, **kwargs) @@ -168,7 +183,7 @@ def rename(self, path: str, application_type: Union[str, ApplicationTypes], appl # raise ValueError if not a valid ApplicationType application_type = ApplicationTypes(application_type) - if not application_type == ApplicationTypes.FOLDER: + if not application_type == ApplicationTypes.FOLDER and not verify_version(required_version='12', version=self.version): application_name += application_type.suffix contents = 'PrivateContents' if private else 'Contents' @@ -177,7 +192,7 @@ def rename(self, path: str, application_type: Union[str, ApplicationTypes], appl mid = "".join([format_url("/Contents('{}')", element) for element in path.split('/')]) url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{application_name}')/tm1.Move", + "/Contents('Applications')" + mid + "/" + contents + "('{application_name}')/tm1.Move", application_name=application_name) data = {"Name": new_application_name} @@ -196,14 +211,15 @@ def create(self, application: Union[Application, DocumentApplication], private: mid = "" if application.path.strip() != '': mid = "".join([format_url("/Contents('{}')", element) for element in application.path.split('/')]) - url = "/api/v1/Contents('Applications')" + mid + "/" + contents + url = "/Contents('Applications')" + mid + "/" + contents response = self._rest.POST(url, application.body, **kwargs) if application.application_type == ApplicationTypes.DOCUMENT: url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{name}.blob')/Document/Content", - name=application.name) - response = self._rest.PUT(url, application.content, headers=self.BINARY_HTTP_HEADER, **kwargs) + "/Contents('Applications')" + mid + "/" + contents + "('{name}{suffix}')/Document/Content", + name=application.name, + suffix='.blob' if not verify_version(required_version='12', version=self.version) else '') + response = self._rest.PUT(url, application.content, headers=self.binary_http_header, **kwargs) return response @@ -223,11 +239,15 @@ def update(self, application: Union[Application, DocumentApplication], private: if application.application_type == ApplicationTypes.DOCUMENT: url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{name}.blob')/Document/Content", + "/Contents('Applications')" + mid + "/" + contents + "('{name}.blob')/Document/Content", name=application.name) - response = self._rest.PATCH(url, application.content, headers=self.BINARY_HTTP_HEADER, **kwargs) + response = self._rest.PATCH( + url=url, + data=application.content, + headers=self.binary_http_header, + **kwargs) else: - url = "/api/v1/Contents('Applications')" + mid + "/" + contents + url = "/Contents('Applications')" + mid + "/" + contents response = self._rest.POST(url, application.body, **kwargs) return response @@ -265,7 +285,7 @@ def exists(self, path: str, application_type: Union[str, ApplicationTypes], name # raise ValueError if not a valid ApplicationType application_type = ApplicationTypes(application_type) - if not application_type == ApplicationTypes.FOLDER: + if not application_type == ApplicationTypes.FOLDER and not verify_version(required_version='12', version=self.version): name += application_type.suffix contents = 'PrivateContents' if private else 'Contents' @@ -274,7 +294,7 @@ def exists(self, path: str, application_type: Union[str, ApplicationTypes], name mid = "".join(["/Contents('{}')".format(element) for element in path.split('/')]) url = format_url( - "/api/v1/Contents('Applications')" + mid + "/" + contents + "('{application_name}')", + "/Contents('Applications')" + mid + "/" + contents + "('{application_name}')", application_name=name) return self._exists(url, **kwargs) diff --git a/TM1py/Services/CellService.py b/TM1py/Services/CellService.py index 45158e36..d7ca0876 100644 --- a/TM1py/Services/CellService.py +++ b/TM1py/Services/CellService.py @@ -35,7 +35,7 @@ case_and_space_insensitive_equals, get_cube, resembles_mdx, require_admin, extract_compact_json_cellset, \ cell_is_updateable, build_mdx_from_cellset, build_mdx_and_values_from_cellset, \ dimension_names_from_element_unique_names, frame_to_significant_digits, build_dataframe_from_csv, \ - drop_dimension_properties, decohints + drop_dimension_properties, decohints, verify_version try: import pandas as pd @@ -114,11 +114,11 @@ def manage_changeset(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): - if kwargs.get("use_changeset", False): + use_changeset = kwargs.pop("use_changeset", False) + if use_changeset: + changeset = self.begin_changeset() try: - changeset = self.begin_changeset() - kwargs["changeset"] = changeset - return func(self, *args, **kwargs) + return func(self, changeset=changeset, *args, **kwargs) finally: self.end_changeset(changeset) else: @@ -343,7 +343,7 @@ def trace_cell_calculation(self, cube_name: str, component_fields = f'{component_depth}/Type, {component_depth}/Value, {component_depth}/Statements' select_query = ','.join([select_query, component_fields]) - url = format_url("/api/v1/Cubes('{}')/tm1.TraceCellCalculation?$select=Type,Value,Statements" + url = format_url("/Cubes('{}')/tm1.TraceCellCalculation?$select=Type,Value,Statements" "{}&$expand=Tuple($select=Name, UniqueName, Type) {}", cube_name, select_query, expand_query) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) @@ -388,7 +388,7 @@ def trace_cell_feeders(self, cube_name: str, :return: feeder trace """ - url = format_url("/api/v1/Cubes('{}')/tm1.TraceFeeders?$select=Statements,FedCells" + url = format_url("/Cubes('{}')/tm1.TraceFeeders?$select=Statements,FedCells" "&$expand=FedCells/Tuple($select=Name,UniqueName,Type), " "FedCells/Cube($select=Name)", cube_name) @@ -434,7 +434,7 @@ def check_cell_feeders(self, cube_name: str, :return: fed cell descriptor """ - url = format_url("/api/v1/Cubes('{}')/tm1.CheckFeeders" + url = format_url("/Cubes('{}')/tm1.CheckFeeders" "?$select=Fed" "&$expand=Tuple($select=Name,UniqueName,Type),Cube($select=Name)", cube_name) @@ -608,7 +608,7 @@ def _post_against_cellset(self, cellset_id: str, payload: Dict, sandbox_name: st :param kwargs: :return: """ - url = format_url("/api/v1/Cellsets('{}')/tm1.Update", cellset_id) + url = format_url("/Cellsets('{}')/tm1.Update", cellset_id) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) return self._rest.POST(url=url, data=json.dumps(payload), **kwargs) @@ -831,7 +831,7 @@ def write_value(self, value: Union[str, float], cube_name: str, element_tuple: I """ if not dimensions: dimensions = self.get_dimension_names_for_writing(cube_name=cube_name) - url = format_url("/api/v1/Cubes('{}')/tm1.Update", cube_name) + url = format_url("/Cubes('{}')/tm1.Update", cube_name) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) body_as_dict = OrderedDict() body_as_dict["Cells"] = [{}] @@ -1114,12 +1114,16 @@ def write_through_blob(self, cube_name: str, cellset_as_dict: dict, increment: b def _build_blob_to_cube_process(self, cube_name: str, process_name: str, blob_filename: str, dimensions: List[str], increment: bool, skip_non_updateable: bool, sandbox_name: str, allow_spread: bool, clear_view: str) -> Process: + + # v11 automatically adds blb file extensions to documents created via the contents api + if not verify_version(required_version="12", version=self.version): + blob_filename += ".blb" dataload_process = Process( name=process_name, datasource_type='ASCII', datasource_ascii_header_records=0, - datasource_data_source_name_for_server=f"{blob_filename}.blb", - datasource_data_source_name_for_client=f"{blob_filename}.blb", + datasource_data_source_name_for_server=f"{blob_filename}", + datasource_data_source_name_for_client=f"{blob_filename}", datasource_ascii_delimiter_char=',', datasource_ascii_decimal_separator='.', datasource_ascii_thousand_separator='', @@ -1231,6 +1235,10 @@ def _build_cube_to_blob_process(self, cube: str, variables: List[str], skip_vari datasource_ascii_decimal_separator='.', datasource_ascii_thousand_separator='') + # v11 automatically adds blb file extensions to documents created via the contents api + if not verify_version(required_version="12", version=self.version): + file_name += ".blb" + # Create variables in process data source as all String for variable in variables: process.add_variable(name=variable, variable_type='String') @@ -1250,14 +1258,14 @@ def _build_cube_to_blob_process(self, cube: str, variables: List[str], skip_vari comma_sep_variables = ",".join(sorted(set(variables) - set(skip_variables), key=lambda v: int(v[1:]))) data_procedure_pre = f""" IF (nRecord = 0); - SetOutputCharacterSet('{file_name}.blb','TM1CS_UTF8'); + SetOutputCharacterSet('{file_name}','TM1CS_UTF8'); ENDIF; nRecord = nRecord + 1; """ if header_line: data_procedure_pre += f""" IF (nRecord = 1); - TextOutput('{file_name}.blb',{header_line}); + TextOutput('{file_name}',{header_line}); ENDIF; """ if top: @@ -1275,8 +1283,15 @@ def _build_cube_to_blob_process(self, cube: str, variables: List[str], skip_vari ENDIF; """ + # v12 occasionally produces tiny numbers (e.g. 4.94066e-324) instead of 0 + data_procedure_pre += f""" + IF (ISUNDEFINEDCELLVALUE(NVALUE,'{cube}') = 1); + SVALUE ='0'; + ENDIF; + """ + data_procedure = f""" - TextOutput('{file_name}.blb',{comma_sep_variables},SVALUE); + TextOutput('{file_name}',{comma_sep_variables},SVALUE); """ process.data_procedure = data_procedure_pre + data_procedure @@ -1512,7 +1527,7 @@ def write_values(self, cube_name: str, cellset_as_dict: Dict, dimensions: Iterab """ if not dimensions: dimensions = self.get_dimension_names_for_writing(cube_name=cube_name, **kwargs) - url = format_url("/api/v1/Cubes('{}')/tm1.Update", cube_name) + url = format_url("/Cubes('{}')/tm1.Update", cube_name) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) url = add_url_parameters(url, **{"!ChangeSet": changeset}) @@ -1591,7 +1606,7 @@ def update_cellset(self, cellset_id: str, values: Iterable, sandbox_name: str = :return: """ - url = format_url("/api/v1/Cellsets('{}')/Cells", cellset_id) + url = format_url("/Cellsets('{}')/Cells", cellset_id) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) url = add_url_parameters(url, **{"!ChangeSet": changeset}) data = [] @@ -2764,7 +2779,7 @@ def extract_cellset_raw_response( # if top_cells is set to N => it will be sufficient to get only the first N tuples in Axes, top_tuples does this # if skip_cells is used => trick not applicable, all tuples must be extracted - url = "/api/v1/Cellsets('{cellset_id}')?$expand=" \ + url = "/Cellsets('{cellset_id}')?$expand=" \ "Cube($select=Name;$expand=Dimensions($select=Name))," \ "Axes({filter_axis}$expand={hierarchies}Tuples($expand=Members({select_member_properties}" \ "{expand_elem_properties}){top_tuples}))," \ @@ -2892,7 +2907,7 @@ def extract_cellset_metadata_raw( # if top_cells is set to N => it will be sufficient to get only the first N tuples in Axes, top_tuples does this # if skip_cells is used => trick not applicable, all tuples must be extracted - url = "/api/v1/Cellsets('{cellset_id}')?$expand=" \ + url = "/Cellsets('{cellset_id}')?$expand=" \ "Cube($select=Name;$expand=Dimensions($select=Name))," \ "Axes({filter_axis}$expand={hierarchies}Tuples($expand=Members({select_member_properties}" \ "{expand_elem_properties}){top_tuples}))" \ @@ -2919,7 +2934,7 @@ def extract_cellset_partition(self, cellset_id: str, sandbox_name: str = None) -> Dict: """ Method to extract a cellset partition. Cellset partitions are a collection of cellset cells where they have - a defined top left boundary, and bottom right boundary. + a defined top left boundary, and bottom right boundary. Read More: https://www.ibm.com/docs/en/planning-analytics/2.0.0?topic=data-cellsets#dg_tm1_odata_get_cells__title__1 :param partition_start_ordinal: top left cell boundary :param partition_end_ordinal: bottom right cell boundary @@ -2956,7 +2971,7 @@ def extract_cellset_partition(self, cellset_id: str, filter_cells = " and ".join(filters) - url = ("/api/v1/Cellsets('{cellset_id}')/tm1.GetPartition{cell_partition}?$select={cell_properties}{" + url = ("/Cellsets('{cellset_id}')/tm1.GetPartition{cell_partition}?$select={cell_properties}{" "top_cells}{skip_cells}{filter_cells}") \ .format(cellset_id=cellset_id, cell_partition=f"(Begin={partition_start_ordinal}, End={partition_end_ordinal})", @@ -3008,7 +3023,7 @@ def extract_cellset_cells_raw( filter_cells = " and ".join(filters) - url = "/api/v1/Cellsets('{cellset_id}')?$expand=" \ + url = "/Cellsets('{cellset_id}')?$expand=" \ "Cells($select={cell_properties}{top_cells}{skip_cells}{filter_cells})" \ .format(cellset_id=cellset_id, cell_properties=",".join(cell_properties), @@ -3049,10 +3064,11 @@ def extract_cellset_values(self, cellset_id: str, sandbox_name: str = None, use_ filter_cells = " and ".join(filters) url = format_url( - "/api/v1/Cellsets('{}')?$expand=Cells($select=Value{})", + "/Cellsets('{}')?$expand=Cells($select=Value{})", cellset_id, f";$filter={filter_cells}" if filter_cells else "") - url = add_url_parameters(url, **{"!sandbox": sandbox_name}) + if sandbox_name: + url = add_url_parameters(url, **{"!sandbox": sandbox_name}) response = self._rest.GET(url=url, **kwargs) if not use_compact_json: @@ -3072,7 +3088,7 @@ def extract_cellset_rows_and_values(self, cellset_id: str, element_unique_names: :param sandbox_name: str :return: """ - url = "/api/v1/Cellsets('{}')?$expand=" \ + url = "/Cellsets('{}')?$expand=" \ "Axes($filter=Ordinal eq 1;$expand=Tuples(" \ "$expand=Members($select=Element;$expand=Element($select={}))))," \ "Cells($select=Value)".format(cellset_id, "UniqueName" if element_unique_names else "Name") @@ -3116,7 +3132,7 @@ def extract_cellset_composition( :param sandbox_name: str :return: """ - url = "/api/v1/Cellsets('{}')?$expand=" \ + url = "/Cellsets('{}')?$expand=" \ "Cube($select=Name)," \ "Axes($expand=Hierarchies($select=UniqueName))".format(cellset_id) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) @@ -3146,7 +3162,7 @@ def extract_cellset_cellcount(self, cellset_id: str, sandbox_name: str = None, * :param kwargs: :return: """ - url = "/api/v1/Cellsets('{}')/Cells/$count".format(cellset_id) + url = "/Cellsets('{}')/Cells/$count".format(cellset_id) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) response = self._rest.GET(url, **kwargs) return int(response.content) @@ -3453,7 +3469,7 @@ def extract_cellset_dataframe_shaped(self, cellset_id: str, sandbox_name: str = :param infer_dtype: bool, if True, lets pandas infer dtypes, otherwise all columns will be of type str. """ - url = "/api/v1/Cellsets('{}')?$expand=" \ + url = "/Cellsets('{}')?$expand=" \ "Axes($filter=Ordinal eq 0 or Ordinal eq 1;$expand=Tuples(" \ "$expand=Members($select=Name{})),Hierarchies($select=Name,Dimension;$expand=Dimension($select=Name)))," \ "Cells($select=Value)".format(cellset_id, ',Attributes' if display_attribute else '') @@ -3629,7 +3645,7 @@ def create_cellset(self, mdx: Union[str, MdxBuilder], sandbox_name: str = None, :param sandbox_name: str :return: """ - url = '/api/v1/ExecuteMDX' + url = '/ExecuteMDX' url = add_url_parameters(url, **{"!sandbox": sandbox_name}) data = { 'MDX': mdx.to_mdx() if isinstance(mdx, MdxBuilder) else mdx @@ -3649,7 +3665,7 @@ def create_cellset_from_view(self, cube_name: str, view_name: str, private: bool :param sandbox_name: str :return: """ - url = format_url("/api/v1/Cubes('{cube_name}')/{views}('{view_name}')/tm1.Execute", + url = format_url("/Cubes('{cube_name}')/{views}('{view_name}')/tm1.Execute", cube_name=cube_name, views='PrivateViews' if private else 'Views', view_name=view_name) @@ -3690,7 +3706,7 @@ def begin_changeset(self) -> str: :return: Change set ID """ - url = "/api/v1/BeginChangeSet" + url = "/BeginChangeSet" return self._rest.POST(url).json()['value'] def end_changeset(self, change_set: str) -> Response: @@ -3699,7 +3715,7 @@ def end_changeset(self, change_set: str) -> Response: :return: Change set ID """ - url = "/api/v1/EndChangeSet" + url = "/EndChangeSet" data = {"ChangeSetID": change_set} return self._rest.POST(url, data=json.dumps(data, ensure_ascii=False)) @@ -3709,7 +3725,7 @@ def undo_changeset(self, changeset: str) -> Response: :return: Change set ID """ - url = "/api/v1/UndoChangeSet" + url = "/UndoChangeSet" data = {"ChangeSetID": changeset} return self._rest.POST(url, data=json.dumps(data, ensure_ascii=False)) @@ -3720,7 +3736,7 @@ def delete_cellset(self, cellset_id: str, sandbox_name: str = None, **kwargs) -> :param sandbox_name: str :return: """ - url = "/api/v1/Cellsets('{}')".format(cellset_id) + url = "/Cellsets('{}')".format(cellset_id) url = add_url_parameters(url, **{"!sandbox": sandbox_name}) return self._rest.DELETE(url, **kwargs) diff --git a/TM1py/Services/ChoreService.py b/TM1py/Services/ChoreService.py index d89d48af..1821f215 100644 --- a/TM1py/Services/ChoreService.py +++ b/TM1py/Services/ChoreService.py @@ -76,7 +76,7 @@ def get(self, chore_name: str, **kwargs) -> Chore: :return: instance of TM1py.Chore """ url = format_url( - "/api/v1/Chores('{}')?$expand=Tasks($expand=*,Process($select=Name),Chore($select=Name))", + "/Chores('{}')?$expand=Tasks($expand=*,Process($select=Name),Chore($select=Name))", chore_name) response = self._rest.GET(url, **kwargs) return Chore.from_dict(response.json()) @@ -85,7 +85,7 @@ def get_all(self, **kwargs) -> List[Chore]: """ get a List of all Chores :return: List of TM1py.Chore """ - url = "/api/v1/Chores?$expand=Tasks($expand=*,Process($select=Name),Chore($select=Name))" + url = "/Chores?$expand=Tasks($expand=*,Process($select=Name),Chore($select=Name))" response = self._rest.GET(url, **kwargs) return [Chore.from_dict(chore_as_dict) for chore_as_dict in response.json()['value']] @@ -93,7 +93,7 @@ def get_all_names(self, **kwargs) -> List[str]: """ get a List of all Chores :return: List of TM1py.Chore """ - url = "/api/v1/Chores?$select=Name" + url = "/Chores?$select=Name" response = self._rest.GET(url, **kwargs) return [chore['Name'] for chore in response.json()['value']] @@ -102,7 +102,7 @@ def create(self, chore: Chore, **kwargs) -> Response: :param chore: instance of TM1py.Chore :return: """ - url = "/api/v1/Chores" + url = "/Chores" response = self._rest.POST(url=url, data=chore.body, **kwargs) if chore.dst_sensitivity: @@ -117,7 +117,7 @@ def delete(self, chore_name: str, **kwargs) -> Response: :param chore_name: :return: response """ - url = format_url("/api/v1/Chores('{}')", chore_name) + url = format_url("/Chores('{}')", chore_name) response = self._rest.DELETE(url, **kwargs) return response @@ -127,7 +127,7 @@ def exists(self, chore_name: str, **kwargs) -> bool: :param chore_name: :return: """ - url = format_url("/api/v1/Chores('{}')", chore_name) + url = format_url("/Chores('{}')", chore_name) return self._exists(url, **kwargs) def search_for_process_name(self, process_name: str, **kwargs) -> List[Chore]: @@ -136,7 +136,7 @@ def search_for_process_name(self, process_name: str, **kwargs) -> List[Chore]: :param process_name: string, a valid ti process name; spaces will be elimniated """ url = format_url( - "/api/v1/Chores?$filter=Tasks/any(t: replace(tolower(t/Process/Name), ' ', '') eq '{}')" + "/Chores?$filter=Tasks/any(t: replace(tolower(t/Process/Name), ' ', '') eq '{}')" "&$expand=Tasks($expand=*,Chore($select=Name),Process($select=Name))", process_name.lower().replace(' ', '') ) @@ -150,7 +150,7 @@ def search_for_parameter_value(self, parameter_value: str, **kwargs) -> List[Cho :param parameter_value: string, will search wildcard for string in parameter value using Contains(string) """ url = format_url( - "/api/v1/Chores?" + "/Chores?" "$filter=Tasks/any(t: t/Parameters/any(p: isof(p/Value, Edm.String) and contains(tolower(p/Value), '{}')))" "&$expand=Tasks($expand=*,Process($select=Name),Chore($select=Name))", parameter_value.lower() @@ -166,7 +166,7 @@ def update(self, chore: Chore, **kwargs): :return: """ # Update StartTime, ExecutionMode, Frequency - url = format_url("/api/v1/Chores('{}')", chore.name) + url = format_url("/Chores('{}')", chore.name) # Remove Tasks from Body. Tasks to be managed individually chore_dict_without_tasks = chore.body_as_dict chore_dict_without_tasks.pop("Tasks") @@ -198,7 +198,7 @@ def activate(self, chore_name: str, **kwargs) -> Response: :param chore_name: :return: response """ - url = format_url("/api/v1/Chores('{}')/tm1.Activate", chore_name) + url = format_url("/Chores('{}')/tm1.Activate", chore_name) return self._rest.POST(url, '', **kwargs) def deactivate(self, chore_name: str, **kwargs) -> Response: @@ -206,7 +206,7 @@ def deactivate(self, chore_name: str, **kwargs) -> Response: :param chore_name: :return: response """ - url = format_url("/api/v1/Chores('{}')/tm1.Deactivate", chore_name) + url = format_url("/Chores('{}')/tm1.Deactivate", chore_name) return self._rest.POST(url, '', **kwargs) @deactivate_activate @@ -216,7 +216,7 @@ def set_local_start_time(self, chore_name: str, date_time: datetime, **kwargs) - :param date_time: :return: """ - url = format_url("/api/v1/Chores('{}')/tm1.SetServerLocalStartTime", chore_name) + url = format_url("/Chores('{}')/tm1.SetServerLocalStartTime", chore_name) data = { "StartDate": "{}-{}-{}".format( date_time.year, date_time.month, date_time.day), @@ -230,14 +230,14 @@ def execute_chore(self, chore_name: str, **kwargs) -> Response: :param chore_name: String, name of the chore to be executed :return: the response """ - return self._rest.POST(format_url("/api/v1/Chores('{}')/tm1.Execute", chore_name), '', **kwargs) + return self._rest.POST(format_url("/Chores('{}')/tm1.Execute", chore_name), '', **kwargs) def _get_tasks_count(self, chore_name: str, **kwargs) -> int: """ Query Chore tasks count on TM1 Server :param chore_name: name of Chore to count tasks :return: int """ - url = format_url("/api/v1/Chores('{}')/Tasks/$count", chore_name) + url = format_url("/Chores('{}')/Tasks/$count", chore_name) response = self._rest.GET(url, **kwargs) return int(response.text) @@ -248,7 +248,7 @@ def _get_task(self, chore_name: str, step: int, **kwargs) -> ChoreTask: :return: instance of TM1py.ChoreTask """ url = format_url( - "/api/v1/Chores('{}')/Tasks({})?$expand=*,Process($select=Name),Chore($select=Name)", chore_name, str(step)) + "/Chores('{}')/Tasks({})?$expand=*,Process($select=Name),Chore($select=Name)", chore_name, str(step)) response = self._rest.GET(url, **kwargs) return ChoreTask.from_dict(response.json()) @@ -258,7 +258,7 @@ def _delete_task(self, chore_name: str, step: int, **kwargs) -> Response: :param step: integer :return: response """ - url = format_url("/api/v1/Chores('{}')/Tasks({})", chore_name, str(step)) + url = format_url("/Chores('{}')/Tasks({})", chore_name, str(step)) response = self._rest.DELETE(url, **kwargs) return response @@ -272,7 +272,7 @@ def _add_task(self, chore_name: str, chore_task: ChoreTask, **kwargs) -> Respons if chore.active: self.deactivate(chore_name, **kwargs) try: - url = format_url("/api/v1/Chores('{}')/Tasks", chore_name) + url = format_url("/Chores('{}')/Tasks", chore_name) response = self._rest.POST(url, chore_task.body, **kwargs) except Exception as e: raise e @@ -287,7 +287,7 @@ def _update_task(self, chore_name: str, chore_task: ChoreTask, **kwargs): :param chore_task: instance TM1py.ChoreTask :return: response """ - url = format_url("/api/v1/Chores('{}')/Tasks({})", chore_name, str(chore_task.step)) + url = format_url("/Chores('{}')/Tasks({})", chore_name, str(chore_task.step)) return self._rest.PATCH(url, chore_task.body, **kwargs) @staticmethod diff --git a/TM1py/Services/CubeService.py b/TM1py/Services/CubeService.py index 406c4047..9fbb2241 100644 --- a/TM1py/Services/CubeService.py +++ b/TM1py/Services/CubeService.py @@ -32,7 +32,7 @@ def create(self, cube: Cube, **kwargs) -> Response: :param cube: instance of TM1py.Cube :return: response """ - url = "/api/v1/Cubes" + url = "/Cubes" return self._rest.POST(url=url, data=cube.body, **kwargs) def get(self, cube_name: str, **kwargs) -> Cube: @@ -41,7 +41,7 @@ def get(self, cube_name: str, **kwargs) -> Cube: :param cube_name: :return: instance of TM1py.Cube """ - url = format_url("/api/v1/Cubes('{}')?$expand=Dimensions($select=Name)", cube_name) + url = format_url("/Cubes('{}')?$expand=Dimensions($select=Name)", cube_name) response = self._rest.GET(url=url, **kwargs) cube = Cube.from_json(response.text) # cater for potential EnableSandboxDimension=T setup @@ -50,7 +50,7 @@ def get(self, cube_name: str, **kwargs) -> Cube: return cube def get_last_data_update(self, cube_name: str, **kwargs) -> str: - url = format_url("/api/v1/Cubes('{}')/LastDataUpdate/$value", cube_name) + url = format_url("/Cubes('{}')/LastDataUpdate/$value", cube_name) response = self._rest.GET(url=url, **kwargs) return response.text @@ -59,7 +59,7 @@ def get_all(self, **kwargs) -> List[Cube]: :return: List of TM1py.Cube instances """ - url = "/api/v1/Cubes?$expand=Dimensions($select=Name)" + url = "/Cubes?$expand=Dimensions($select=Name)" response = self._rest.GET(url, **kwargs) cubes = [Cube.from_dict(cube_as_dict=cube) for cube in response.json()['value']] return cubes @@ -69,7 +69,7 @@ def get_model_cubes(self, **kwargs) -> List[Cube]: :return: List of TM1py.Cube instances """ - url = "/api/v1/ModelCubes()?$expand=Dimensions($select=Name)" + url = "/ModelCubes()?$expand=Dimensions($select=Name)" response = self._rest.GET(url, **kwargs) cubes = [Cube.from_dict(cube_as_dict=cube) for cube in response.json()['value']] return cubes @@ -79,7 +79,7 @@ def get_control_cubes(self, **kwargs) -> List[Cube]: :return: List of TM1py.Cube instances """ - url = "/api/v1/ControlCubes()?$expand=Dimensions($select=Name)" + url = "/ControlCubes()?$expand=Dimensions($select=Name)" response = self._rest.GET(url, **kwargs) cubes = [Cube.from_dict(cube_as_dict=cube) for cube in response.json()['value']] return cubes @@ -91,14 +91,14 @@ def get_number_of_cubes(self, skip_control_cubes: bool = False, **kwargs) -> int :return: int, count """ if skip_control_cubes: - response = self._rest.GET(url=format_url("/api/v1/ModelCubes()?$select=Name&$top=0&$count"), **kwargs) + response = self._rest.GET(url=format_url("/ModelCubes()?$select=Name&$top=0&$count"), **kwargs) return int(response.json()['@odata.count']) - return int(self._rest.GET(url=format_url("/api/v1/Cubes/$count"), **kwargs).text) + 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( - "/api/v1/Cubes('{}')/Dimensions?$select=Name", + "/Cubes('{}')/Dimensions?$select=Name", cube_name) response = self._rest.GET(url, **kwargs) return response.json()['value'][-1]['Name'] @@ -109,7 +109,7 @@ def update(self, cube: Cube, **kwargs) -> Response: :param cube: instance of TM1py.Cube :return: response """ - url = format_url("/api/v1/Cubes('{}')", cube.name) + url = format_url("/Cubes('{}')", cube.name) return self._rest.PATCH(url, cube.body, **kwargs) def update_or_create(self, cube: Cube, **kwargs) -> Response: @@ -129,7 +129,7 @@ def check_rules(self, cube_name: str, **kwargs) -> Response: :param cube_name: name of a cube :return: response """ - url = format_url("/api/v1/Cubes('{}')/tm1.CheckRules", cube_name) + url = format_url("/Cubes('{}')/tm1.CheckRules", cube_name) response = self._rest.POST(url, **kwargs) errors = response.json()["value"] @@ -142,7 +142,7 @@ def delete(self, cube_name: str, **kwargs) -> Response: :param cube_name: :return: response """ - url = format_url("/api/v1/Cubes('{}')", cube_name) + url = format_url("/Cubes('{}')", cube_name) return self._rest.DELETE(url, **kwargs) def exists(self, cube_name: str, **kwargs) -> bool: @@ -151,7 +151,7 @@ def exists(self, cube_name: str, **kwargs) -> bool: :param cube_name: :return: Boolean """ - url = format_url("/api/v1/Cubes('{}')", cube_name) + url = format_url("/Cubes('{}')", cube_name) return self._exists(url, **kwargs) def get_all_names(self, skip_control_cubes: bool = False, **kwargs) -> List[str]: @@ -161,7 +161,7 @@ def get_all_names(self, skip_control_cubes: bool = False, **kwargs) -> List[str] :return: List of Strings """ url = format_url( - "/api/v1/{}?$select=Name", + "/{}?$select=Name", 'ModelCubes()' if skip_control_cubes else 'Cubes' ) @@ -176,7 +176,7 @@ def get_all_names_with_rules(self, skip_control_cubes: bool = False, **kwargs) - :return: List of Strings """ url = format_url( - "/api/v1/{}?$select=Name,Rules&$filter=Rules ne null", + "/{}?$select=Name,Rules&$filter=Rules ne null", 'ModelCubes()' if skip_control_cubes else 'Cubes' ) @@ -191,7 +191,7 @@ def get_all_names_without_rules(self, skip_control_cubes: bool = False, **kwargs """ url = format_url( - "/api/v1/{}?$select=Name,Rules&$filter=Rules eq null", + "/{}?$select=Name,Rules&$filter=Rules eq null", 'ModelCubes()' if skip_control_cubes else 'Cubes' ) @@ -206,7 +206,7 @@ def get_dimension_names(self, cube_name: str, skip_sandbox_dimension: bool = Tru :param skip_sandbox_dimension: :return: List : [dim1, dim2, dim3, etc.] """ - url = format_url("/api/v1/Cubes('{}')/Dimensions?$select=Name", cube_name) + url = format_url("/Cubes('{}')/Dimensions?$select=Name", cube_name) response = self._rest.GET(url, **kwargs) dimension_names = [element['Name'] for element in response.json()['value']] if skip_sandbox_dimension and dimension_names[0] == CellService.SANDBOX_DIMENSION: @@ -221,7 +221,7 @@ def search_for_dimension(self, dimension_name: str, skip_control_cubes: bool = F :param skip_control_cubes: bool, True will exclude control cubes from result """ url = format_url( - "/api/v1/{}?$select=Name&$filter=Dimensions/any(d: replace(tolower(d/Name), ' ', '') eq '{}')", + "/{}?$select=Name&$filter=Dimensions/any(d: replace(tolower(d/Name), ' ', '') eq '{}')", 'ModelCubes()' if skip_control_cubes else 'Cubes', dimension_name.lower().replace(' ', '') ) @@ -239,7 +239,7 @@ def search_for_dimension_substring(self, substring: str, skip_control_cubes: boo substring = substring.lower().replace(' ', '') url = format_url( - "/api/v1/{}?$select=Name&$filter=Dimensions/any(d: contains(replace(tolower(d/Name), ' ', ''),'{}'))" + + "/{}?$select=Name&$filter=Dimensions/any(d: contains(replace(tolower(d/Name), ' ', ''),'{}'))" + "&$expand=Dimensions($select=Name;$filter=contains(replace(tolower(Name), ' ', ''), '{}'))", 'ModelCubes()' if skip_control_cubes else 'Cubes', substring, @@ -271,7 +271,7 @@ def search_for_rule_substring(self, substring: str, skip_control_cubes: bool = F else: url_filter += format_url("Rules,'{}')", substring) - url = f"/api/v1/{'ModelCubes()' if skip_control_cubes else 'Cubes'}?$filter={url_filter}" \ + url = f"/{'ModelCubes()' if skip_control_cubes else 'Cubes'}?$filter={url_filter}" \ f"&$expand=Dimensions($select=Name)" response = self._rest.GET(url, **kwargs) @@ -285,7 +285,7 @@ def get_storage_dimension_order(self, cube_name: str, **kwargs) -> List[str]: :param cube_name: :return: List of dimension names """ - url = format_url("/api/v1/Cubes('{}')/tm1.DimensionsStorageOrder()?$select=Name", cube_name) + url = format_url("/Cubes('{}')/tm1.DimensionsStorageOrder()?$select=Name", cube_name) response = self._rest.GET(url, **kwargs) return [dimension["Name"] for dimension in response.json()["value"]] @@ -298,7 +298,7 @@ def update_storage_dimension_order(self, cube_name: str, dimension_names: Iterab :param dimension_names: :return: Float: -23.076489699337078 (percent change in memory usage) """ - url = format_url("/api/v1/Cubes('{}')/tm1.ReorderDimensions", cube_name) + url = format_url("/Cubes('{}')/tm1.ReorderDimensions", cube_name) payload = dict() payload['Dimensions@odata.bind'] = [format_url("Dimensions('{}')", dimension) for dimension @@ -314,7 +314,7 @@ def load(self, cube_name: str, **kwargs) -> Response: :param cube_name: :return: """ - url = format_url("/api/v1/Cubes('{}')/tm1.Load", cube_name) + url = format_url("/Cubes('{}')/tm1.Load", cube_name) return self._rest.POST(url=url, **kwargs) @require_admin @@ -325,7 +325,7 @@ def unload(self, cube_name: str, **kwargs) -> Response: :param cube_name: :return: """ - url = format_url("/api/v1/Cubes('{}')/tm1.Unload", cube_name) + url = format_url("/Cubes('{}')/tm1.Unload", cube_name) return self._rest.POST(url=url, **kwargs) def lock(self, cube_name: str, **kwargs) -> Response: @@ -334,7 +334,7 @@ def lock(self, cube_name: str, **kwargs) -> Response: :param cube_name: :return: """ - url = format_url("/api/v1/Cubes('{}')/tm1.Lock", cube_name) + url = format_url("/Cubes('{}')/tm1.Lock", cube_name) return self._rest.POST(url=url, **kwargs) def unlock(self, cube_name: str, **kwargs) -> Response: @@ -343,7 +343,7 @@ def unlock(self, cube_name: str, **kwargs) -> Response: :param cube_name: :return: """ - url = format_url("/api/v1/Cubes('{}')/tm1.Unlock", cube_name) + url = format_url("/Cubes('{}')/tm1.Unlock", cube_name) return self._rest.POST(url=url, **kwargs) @require_admin diff --git a/TM1py/Services/DimensionService.py b/TM1py/Services/DimensionService.py index ea20d5e9..b76eed80 100644 --- a/TM1py/Services/DimensionService.py +++ b/TM1py/Services/DimensionService.py @@ -37,7 +37,7 @@ def create(self, dimension: Dimension, **kwargs) -> Response: # If not all subsequent calls successful -> undo everything that has been done in this function try: # Create Dimension, Hierarchies, Elements, Edges. - url = "/api/v1/Dimensions" + url = "/Dimensions" response = self._rest.POST(url, dimension.body, **kwargs) # Create ElementAttributes for hierarchy in dimension: @@ -56,7 +56,7 @@ def get(self, dimension_name: str, **kwargs) -> Dimension: :param dimension_name: :return: """ - url = format_url("/api/v1/Dimensions('{}')?$expand=Hierarchies($expand=*)", dimension_name) + url = format_url("/Dimensions('{}')?$expand=Hierarchies($expand=*)", dimension_name) response = self._rest.GET(url, **kwargs) return Dimension.from_json(response.text) @@ -118,7 +118,7 @@ def delete(self, dimension_name: str, **kwargs) -> Response: :param dimension_name: Name of the dimension :return: """ - url = format_url("/api/v1/Dimensions('{}')", dimension_name) + url = format_url("/Dimensions('{}')", dimension_name) return self._rest.DELETE(url, **kwargs) def exists(self, dimension_name: str, **kwargs) -> bool: @@ -126,7 +126,7 @@ def exists(self, dimension_name: str, **kwargs) -> bool: :return: """ - url = format_url("/api/v1/Dimensions('{}')", dimension_name) + url = format_url("/Dimensions('{}')", dimension_name) return self._exists(url, **kwargs) def get_all_names(self, skip_control_dims: bool = False, **kwargs) -> List[str]: @@ -137,7 +137,7 @@ def get_all_names(self, skip_control_dims: bool = False, **kwargs) -> List[str]: List of Strings """ url = format_url( - "/api/v1/{}?$select=Name", + "/{}?$select=Name", 'ModelDimensions()' if skip_control_dims else 'Dimensions' ) @@ -154,10 +154,10 @@ def get_number_of_dimensions(self, skip_control_dims: bool = False, **kwargs) -> """ if skip_control_dims: - response = self._rest.GET("/api/v1/ModelDimensions()?$select=Name&$top=0&$count", **kwargs) + response = self._rest.GET("/ModelDimensions()?$select=Name&$top=0&$count", **kwargs) return response.json()['@odata.count'] - return int(self._rest.GET("/api/v1/Dimensions/$count", **kwargs).text) + return int(self._rest.GET("/Dimensions/$count", **kwargs).text) def execute_mdx(self, dimension_name: str, mdx: str, **kwargs) -> List: """ Execute MDX against Dimension. @@ -176,7 +176,7 @@ def execute_mdx(self, dimension_name: str, mdx: str, **kwargs) -> List: "{{ [}}ElementAttributes_{}].DefaultMember }} ON COLUMNS " \ "FROM [}}ElementAttributes_{}]" mdx_full = mdx_skeleton.format(mdx, dimension_name, dimension_name) - request = '/api/v1/ExecuteMDX?$expand=Axes(' \ + request = '/ExecuteMDX?$expand=Axes(' \ '$filter=Ordinal eq 1;' \ '$expand=Tuples($expand=Members($select=Ordinal;$expand=Element($select=Name))))' payload = {"MDX": mdx_full} diff --git a/TM1py/Services/ElementService.py b/TM1py/Services/ElementService.py index 7a3b943b..0bfbf90e 100644 --- a/TM1py/Services/ElementService.py +++ b/TM1py/Services/ElementService.py @@ -21,8 +21,9 @@ from TM1py.Services.RestService import RestService from TM1py.Utils import CaseAndSpaceInsensitiveDict, format_url, CaseAndSpaceInsensitiveSet, require_admin, \ dimension_hierarchy_element_tuple_from_unique_name, require_pandas, require_version -from TM1py.Utils import build_element_unique_names, CaseAndSpaceInsensitiveTuplesDict - +from TM1py.Utils import build_element_unique_names, CaseAndSpaceInsensitiveTuplesDict, verify_version +from itertools import islice +from collections import OrderedDict class MDXDrillMethod(Enum): TM1DRILLDOWNMEMBER = 1 @@ -39,21 +40,21 @@ def __init__(self, rest: RestService): def get(self, dimension_name: str, hierarchy_name: str, element_name: str, **kwargs) -> Element: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')?$expand=*", + "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')?$expand=*", dimension_name, hierarchy_name, element_name) response = self._rest.GET(url, **kwargs) return Element.from_dict(response.json()) def create(self, dimension_name: str, hierarchy_name: str, element: Element, **kwargs) -> Response: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements", + "/Dimensions('{}')/Hierarchies('{}')/Elements", dimension_name, hierarchy_name) return self._rest.POST(url, element.body, **kwargs) def update(self, dimension_name: str, hierarchy_name: str, element: Element, **kwargs) -> Response: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')", + "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')", dimension_name, hierarchy_name, element.name) @@ -61,7 +62,7 @@ def update(self, dimension_name: str, hierarchy_name: str, element: Element, **k def exists(self, dimension_name: str, hierarchy_name: str, element_name: str, **kwargs) -> bool: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')", + "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')", dimension_name, hierarchy_name, element_name) @@ -69,7 +70,7 @@ def exists(self, dimension_name: str, hierarchy_name: str, element_name: str, ** def delete(self, dimension_name: str, hierarchy_name: str, element_name: str, **kwargs) -> Response: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')", + "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')", dimension_name, hierarchy_name, element_name) @@ -143,7 +144,7 @@ def escape_single_quote(text): def get_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[Element]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?select=Name,Type", + "/Dimensions('{}')/Hierarchies('{}')/Elements?select=Name,Type", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -222,37 +223,71 @@ def get_elements_dataframe(self, dimension_name: str = None, hierarchy_name: str calculated_members_definition = list() calculated_members_selection = list() + levels_dict = {} if not skip_parents: levels = self.get_levels_count(dimension_name, hierarchy_name) - # potential custom parent names + #Generic Level names can't be used directly as a Calculated Member name as they conflict with an internal name + #Therefore, we create a map that relates the level name to the calculated member name L000 = level000 if not level_names: level_names = self.get_level_names(dimension_name, hierarchy_name, descending=True) + level_calculated_member_names = [] + + #Create a map of MDX Calculated Member Names and Desired Pandas Names + for level in reversed(range(levels)): + level_calculated_member_names.append(f"L{str(level).zfill(3)}") + all_level_dict = OrderedDict(zip(level_calculated_member_names, level_names)) + + #if a specific parent names are provided the calculated member name is = to the data frame column name + else: + all_level_dict = OrderedDict(zip(level_names, level_names)) + + # Remove the highest level (leafs) to create proper MDX calculated members + levels_dict = {k: all_level_dict[k] for k in list(all_level_dict)[1:]} + #iterate the map of levels to create an MDX calculation and a related column axis definition parent_members = list() weight_members = list() - for parent in range(1, levels, 1): - name_or_attribute = f"Properties('{parent_attribute}')" if parent_attribute else "Name" - member = f""" - MEMBER [{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_names[parent]}] - AS [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * parent}{name_or_attribute} - """ + depth = 0 + for calculated_name, level_name in levels_dict.items(): + depth += 1 + name_or_attribute = f"Properties('{parent_attribute}')" if parent_attribute else "NAME" + if not verify_version(required_version='12', version=self.version): + member = f""" + MEMBER [{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{calculated_name}] + AS [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * depth}{name_or_attribute} + """ + else: + member = f""" + MEMBER [{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{calculated_name}] + AS [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * depth}PROPERTIES('{name_or_attribute}') + """ calculated_members_definition.append(member) - parent_members.append(f"[{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_names[parent]}]") + parent_members.append(f"[{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{calculated_name}]") if not skip_weights: - member_weight = f""" - MEMBER [{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_names[parent]}_Weight] - AS IIF( - [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * (parent - 1)}Properties('MEMBER_WEIGHT') = '', - 0, - [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * (parent - 1)}Properties('MEMBER_WEIGHT')) - """ + if not verify_version(required_version='12', version=self.version): + member_weight = f""" + MEMBER [{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_name}_Weight] + AS IIF( + [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * (depth - 1)}Properties('MEMBER_WEIGHT') = '', + 0, + [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * (depth - 1)}Properties('MEMBER_WEIGHT')) + """ + else: + member_weight = f""" + MEMBER [{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_name}_Weight] + AS IIF( + [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * ( depth - 1)}Properties('MEMBER_WEIGHT') = '', + 0, + [{dimension_name}].[{hierarchy_name}].CurrentMember.{'Parent.' * ( depth - 1)}Properties('MEMBER_WEIGHT')) + """ calculated_members_definition.append(member_weight) weight_members.append( - f"[{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_names[parent]}_Weight]") + f"[{self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name}].[{level_name}_Weight]") + calculated_members_selection.extend(weight_members) calculated_members_selection.extend(parent_members) @@ -265,7 +300,11 @@ def get_elements_dataframe(self, dimension_name: str = None, hierarchy_name: str in attributes) + "}" if calculated_members_selection: - column_selection = column_selection + " + {" + ",".join(calculated_members_selection) + "}" + if column_selection == "{}": + column_selection = "{" + ",".join(calculated_members_selection) + "}" + else: + column_selection = column_selection + " + {" + ",".join(calculated_members_selection) + "}" + elements = ",".join( member["UniqueName"] for member @@ -284,12 +323,25 @@ def get_elements_dataframe(self, dimension_name: str = None, hierarchy_name: str """ cell_service = self._get_cell_service() + # responses are similar but not equivalent. Therefor only use execute_mdx_dataframe when use_blob=True if use_blob: df_data = cell_service.execute_mdx_dataframe(mdx, shaped=True, use_blob=True, **kwargs) else: df_data = cell_service.execute_mdx_dataframe_shaped(mdx, **kwargs) + if levels_dict: + # rename level names to conform sto strandard levels "1" -> "level0001" + df_data.rename(columns=levels_dict, inplace=True) + + #format weights + # Find columns with certain names + cols_to_format = [col for col in df_data.columns if '_Weight' in col] + + # Format the columns + df_data[cols_to_format] = df_data[cols_to_format].apply(pd.to_numeric) + df_data[cols_to_format] = df_data[cols_to_format].applymap(lambda x: '{:.6f}'.format(x)) + # override columns. hierarchy name with dimension and prefix attributes column_renaming = dict() if attributes and attribute_column_prefix: @@ -304,7 +356,8 @@ def get_elements_dataframe(self, dimension_name: str = None, hierarchy_name: str # iterative approach for _ in level_columns: - rows_to_shift = df_data[df_data[level_columns[-1]] == ''].index + + rows_to_shift = df_data[df_data[level_columns[-1]].isin(['', None])].index if rows_to_shift.empty: break shifted_cols = df_data.iloc[rows_to_shift, -len(level_columns):].shift(1, axis=1) @@ -329,7 +382,7 @@ def get_elements_dataframe(self, dimension_name: str = None, hierarchy_name: str def get_edges(self, dimension_name: str, hierarchy_name: str, **kwargs) -> Dict[Tuple[str, str], int]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Edges?select=ParentName,ComponentName,Weight", + "/Dimensions('{}')/Hierarchies('{}')/Edges?select=ParentName,ComponentName,Weight", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -338,14 +391,14 @@ def get_edges(self, dimension_name: str, hierarchy_name: str, **kwargs) -> Dict[ def get_leaf_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[Element]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type ne 3", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type ne 3", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) return [Element.from_dict(element) for element in response.json()["value"]] def get_leaf_element_names(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[str]: - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type ne 3", + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type ne 3", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -353,14 +406,14 @@ def get_leaf_element_names(self, dimension_name: str, hierarchy_name: str, **kwa def get_consolidated_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[Element]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type eq 3", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type eq 3", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) return [Element.from_dict(element) for element in response.json()["value"]] def get_consolidated_element_names(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[str]: - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type eq 3", + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type eq 3", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -368,14 +421,14 @@ def get_consolidated_element_names(self, dimension_name: str, hierarchy_name: st def get_numeric_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[Element]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type eq 1", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type eq 1", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) return [Element.from_dict(element) for element in response.json()["value"]] def get_numeric_element_names(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[str]: - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type eq 1", + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type eq 1", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -383,14 +436,14 @@ def get_numeric_element_names(self, dimension_name: str, hierarchy_name: str, ** def get_string_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[Element]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type eq 2", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$expand=*&$filter=Type eq 2", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) return [Element.from_dict(element) for element in response.json()["value"]] def get_string_element_names(self, dimension_name: str, hierarchy_name: str, **kwargs) -> List[str]: - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type eq 2", + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Type eq 2", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -404,7 +457,7 @@ def get_element_names(self, dimension_name: str, hierarchy_name: str, **kwargs) :return: Generator of element-names """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -412,7 +465,7 @@ def get_element_names(self, dimension_name: str, hierarchy_name: str, **kwargs) def get_number_of_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> int: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements/$count", + "/Dimensions('{}')/Hierarchies('{}')/Elements/$count", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -420,7 +473,7 @@ def get_number_of_elements(self, dimension_name: str, hierarchy_name: str, **kwa def get_number_of_consolidated_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> int: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type eq 3", + "/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type eq 3", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -428,7 +481,7 @@ def get_number_of_consolidated_elements(self, dimension_name: str, hierarchy_nam def get_number_of_leaf_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> int: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type ne 3", + "/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type ne 3", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -436,7 +489,7 @@ def get_number_of_leaf_elements(self, dimension_name: str, hierarchy_name: str, def get_number_of_numeric_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> int: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type eq 1", + "/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type eq 1", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -444,7 +497,7 @@ def get_number_of_numeric_elements(self, dimension_name: str, hierarchy_name: st def get_number_of_string_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> int: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type eq 2", + "/Dimensions('{}')/Hierarchies('{}')/Elements/$count?$filter=Type eq 2", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -470,7 +523,7 @@ def get_elements_by_level(self, dimension_name: str, hierarchy_name: str, level: :return: List of element names """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Level eq {}", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Level eq {}", dimension_name, hierarchy_name, str(level)) @@ -491,7 +544,7 @@ def get_elements_filtered_by_wildcard(self, dimension_name: str, hierarchy_name: if level is not None: filter_elements = filter_elements + f" and Level eq {level}" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=" + filter_elements, + "/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=" + filter_elements, dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -605,7 +658,7 @@ def _extract_dict_from_rows_and_values( def get_level_names(self, dimension_name: str, hierarchy_name: str, descending: bool = True, **kwargs) -> List[str]: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Levels?$select=Name", + "/Dimensions('{}')/Hierarchies('{}')/Levels?$select=Name", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -615,14 +668,14 @@ def get_level_names(self, dimension_name: str, hierarchy_name: str, descending: return [level["Name"] for level in response.json()["value"]] def get_levels_count(self, dimension_name: str, hierarchy_name: str, **kwargs) -> int: - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Levels/$count", dimension_name, hierarchy_name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Levels/$count", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) return int(response.text) def get_element_types(self, dimension_name: str, hierarchy_name: str, skip_consolidations: bool = False, **kwargs) -> CaseAndSpaceInsensitiveDict: url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name,Type", + "/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name,Type", dimension_name, hierarchy_name) if skip_consolidations: @@ -637,7 +690,7 @@ def get_element_types(self, dimension_name: str, hierarchy_name: str, def get_element_types_from_all_hierarchies( self, dimension_name: str, skip_consolidations: bool = False, **kwargs) -> CaseAndSpaceInsensitiveDict: url = format_url( - "/api/v1/Dimensions('{}')?$expand=Hierarchies($select=Elements;$expand=Elements($select=Name,Type", + "/Dimensions('{}')?$expand=Hierarchies($select=Elements;$expand=Elements($select=Name,Type", dimension_name) url += ";$filter=Type ne 3))" if skip_consolidations else "))" response = self._rest.GET(url, **kwargs) @@ -649,7 +702,7 @@ def get_element_types_from_all_hierarchies( return result def attribute_cube_exists(self, dimension_name: str, **kwargs) -> bool: - url = format_url("/api/v1/Cubes('{}')", self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name) + url = format_url("/Cubes('{}')", self.ELEMENT_ATTRIBUTES_PREFIX + dimension_name) return self._exists(url, **kwargs) def _retrieve_mdx_rows_and_cell_values_as_string_set(self, mdx: str, exclude_empty_cells=True, **kwargs): @@ -680,7 +733,7 @@ def get_element_attributes(self, dimension_name: str, hierarchy_name: str, **kwa :return: """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/ElementAttributes", + "/Dimensions('{}')/Hierarchies('{}')/ElementAttributes", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -695,7 +748,7 @@ def get_element_attribute_names(self, dimension_name: str, hierarchy_name: str, :return: """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/ElementAttributes?$select=Name", + "/Dimensions('{}')/Hierarchies('{}')/ElementAttributes?$select=Name", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -719,11 +772,13 @@ def get_elements_filtered_by_attribute(self, dimension_name: str, hierarchy_name if isinstance(attribute_value, str): mdx = ( f"{{FILTER({{TM1SUBSETALL([{dimension_name}].[{hierarchy_name}])}}," - f"[{dimension_name}].[{hierarchy_name}].[{attribute_name}] = \"{attribute_value}\")}}") + f"[{dimension_name}].[{hierarchy_name}].CURRENTMEMBER.PROPERTIES(\"{attribute_name}\") = \"{attribute_value}\")}}") else: mdx = ( f"{{FILTER({{TM1SUBSETALL([{dimension_name}].[{hierarchy_name}])}}," - f"[{dimension_name}].[{hierarchy_name}].[{attribute_name}] = {attribute_value})}}") + f"(IIF([{dimension_name}].[{hierarchy_name}].CURRENTMEMBER.PROPERTIES(\"{attribute_name}\")=\"\", 0.0," + f"STRTOVALUE([{dimension_name}].[{hierarchy_name}].CURRENTMEMBER.PROPERTIES(\"{attribute_name}\"))) = 1))}}" + ) elems = self.execute_set_mdx( mdx=mdx, @@ -742,7 +797,7 @@ def create_element_attribute(self, dimension_name: str, hierarchy_name: str, ele :return: """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/ElementAttributes", + "/Dimensions('{}')/Hierarchies('{}')/ElementAttributes", dimension_name, hierarchy_name) return self._rest.POST(url, element_attribute.body, **kwargs) @@ -757,7 +812,7 @@ def delete_element_attribute(self, dimension_name: str, hierarchy_name: str, ele :return: """ url = format_url( - "/api/v1/Dimensions('}}ElementAttributes_{}')/Hierarchies('}}ElementAttributes_{}')/Elements('{}')", + "/Dimensions('}}ElementAttributes_{}')/Hierarchies('}}ElementAttributes_{}')/Elements('{}')", dimension_name, hierarchy_name, element_attribute) @@ -798,7 +853,7 @@ def get_edges_under_consolidation(self, dimension_name: str, hierarchy_name: str edges = CaseAndSpaceInsensitiveTuplesDict() # build url - bare_url = "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')?" + bare_url = "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')?" url = format_url(bare_url, dimension_name, hierarchy_name, consolidation) for d in range(depth): if d == 0: @@ -839,7 +894,7 @@ def get_members_under_consolidation(self, dimension_name: str, hierarchy_name: s # members to return members = [] # build url - bare_url = "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')?$select=Name,Type&$expand=Components(" + bare_url = "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')?$select=Name,Type&$expand=Components(" url = format_url(bare_url, dimension_name, hierarchy_name, consolidation) for _ in range(depth): url += "$select=Name,Type;$expand=Components(" @@ -926,7 +981,7 @@ def execute_set_mdx( else: expand_properties = "" - url = f'/api/v1/ExecuteMDXSetExpression?$expand=Tuples({top}' \ + url = f'/ExecuteMDXSetExpression?$expand=Tuples({top}' \ f'$expand=Members({select_member_properties}' \ f'{expand_properties}))' @@ -946,7 +1001,7 @@ def remove_edge(self, dimension_name: str, hierarchy_name: str, parent: str, com """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements('{}')/Edges(ParentName='{}',ComponentName='{}')", + "/Dimensions('{}')/Hierarchies('{}')/Elements('{}')/Edges(ParentName='{}',ComponentName='{}')", dimension_name, hierarchy_name, parent, @@ -967,7 +1022,7 @@ def add_edges(self, dimension_name: str, hierarchy_name: str = None, edges: Dict if not hierarchy_name: hierarchy_name = dimension_name - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Edges", dimension_name, hierarchy_name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Edges", dimension_name, hierarchy_name) body = [{"ParentName": parent, "ComponentName": component, "Weight": float(weight)} for (parent, component), weight in edges.items()] @@ -982,7 +1037,7 @@ def add_elements(self, dimension_name: str, hierarchy_name: str, elements: Itera :param elements: :return: """ - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/Elements", dimension_name, hierarchy_name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')/Elements", dimension_name, hierarchy_name) body = [element.body_as_dict for element in elements] return self._rest.POST(url=url, data=json.dumps(body), **kwargs) @@ -996,14 +1051,14 @@ def add_element_attributes(self, dimension_name: str, hierarchy_name: str, :param element_attributes: :return: """ - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/ElementAttributes", dimension_name, hierarchy_name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')/ElementAttributes", dimension_name, hierarchy_name) body = [element_attribute.body_as_dict for element_attribute in element_attributes] return self._rest.POST(url=url, data=json.dumps(body), **kwargs) def get_parents(self, dimension_name: str, hierarchy_name: str, element_name: str, **kwargs) -> List[str]: url = format_url( - "/api/v1/Dimensions('{dimension_name}')/Hierarchies('{hierarchy_name}')/Elements('{element_name}')/Parents" + "/Dimensions('{dimension_name}')/Hierarchies('{hierarchy_name}')/Elements('{element_name}')/Parents" f"?$select=Name", dimension_name=dimension_name, hierarchy_name=hierarchy_name, @@ -1015,7 +1070,7 @@ def get_parents(self, dimension_name: str, hierarchy_name: str, element_name: st def get_parents_of_all_elements(self, dimension_name: str, hierarchy_name: str, **kwargs) -> Dict[str, List[str]]: url = format_url( - f"/api/v1/Dimensions('{dimension_name}')/Hierarchies('{hierarchy_name}')/Elements?$select=Name" + f"/Dimensions('{dimension_name}')/Hierarchies('{hierarchy_name}')/Elements?$select=Name" f"&$expand=Parents($select=Name)", ) response = self._rest.GET(url=url, **kwargs) @@ -1027,10 +1082,10 @@ def get_element_principal_name(self, dimension_name: str, hierarchy_name: str, e return element.name def _get_mdx_set_cardinality(self, mdx: str) -> int: - url = format_url("/api/v1/ExecuteMDXSetExpression?$select=Cardinality") + url = format_url("/ExecuteMDXSetExpression?$select=Cardinality") payload = {"MDX": mdx} response = self._rest.POST(url, json.dumps(payload, ensure_ascii=False)) - return response.json()['Cardinality'] + return (response.json()).get('Cardinality') @staticmethod def _build_drill_intersection_mdx(dimension_name: str, hierarchy_name: str, first_element_name: str, @@ -1106,7 +1161,17 @@ def element_is_ancestor(self, dimension_name: str, hierarchy_name: str, ancestor if not self.hierarchy_exists(dimension_name, hierarchy_name): raise TM1pyException(f"Hierarchy '{hierarchy_name}' does not exist in dimension '{dimension_name}'") - # case element doesn't exist + # case element or ancestor doesn't exist + return False + + if method.upper() == "TM1DRILLDOWNMEMBER": + if not self.exists(dimension_name, hierarchy_name, element_name): + + # case dimension or hierarchy doesn't exist + if not self.hierarchy_exists(dimension_name, hierarchy_name): + raise TM1pyException(f"Hierarchy '{hierarchy_name}' does not exist in dimension '{dimension_name}'") + + # case element or ancestor doesn't exist return False mdx = self._build_drill_intersection_mdx( diff --git a/TM1py/Services/FileService.py b/TM1py/Services/FileService.py index fcf6dd9c..868df7a6 100644 --- a/TM1py/Services/FileService.py +++ b/TM1py/Services/FileService.py @@ -4,6 +4,7 @@ from TM1py.Services import RestService from TM1py.Services.ObjectService import ObjectService from TM1py.Utils import format_url +from TM1py.Utils.Utils import verify_version class FileService(ObjectService): @@ -15,16 +16,32 @@ def __init__(self, tm1_rest: RestService): """ super().__init__(tm1_rest) self._rest = tm1_rest + if verify_version(required_version="12", version=self.version): + self.version_content_path = 'Files' + else: + self.version_content_path = 'Blobs' + + def get_names(self, **kwargs) -> bytes: + + url = format_url( + "/Contents('{version_content_path}')/Contents?$select=Name", + version_content_path=self.version_content_path) + + return self._rest.GET(url, **kwargs).content def get(self, file_name: str, **kwargs) -> bytes: + url = format_url( - "/api/v1/Contents('Blobs')/Contents('{name}')/Content", - name=file_name) + "/Contents('{version_content_path}')/Contents('{name}')/Content", + name=file_name, + version_content_path=self.version_content_path) return self._rest.GET(url, **kwargs).content def create(self, file_name: str, file_content: bytes, **kwargs): - url = "/api/v1/Contents('Blobs')/Contents" + url = format_url( + "/Contents('{version_content_path}')/Contents", + version_content_path=self.version_content_path) body = { "@odata.type": "#ibm.tm1.api.v1.Document", "ID": file_name, @@ -33,17 +50,19 @@ def create(self, file_name: str, file_content: bytes, **kwargs): self._rest.POST(url, json.dumps(body), **kwargs) url = format_url( - "/api/v1/Contents('Blobs')/Contents('{name}')/Content", - name=file_name) + "/Contents('{version_content_path}')/Contents('{name}')/Content", + name=file_name, + version_content_path=self.version_content_path) - return self._rest.PUT(url, file_content, headers=self.BINARY_HTTP_HEADER, **kwargs) + return self._rest.PUT(url, file_content, headers=self.binary_http_header, **kwargs) def update(self, file_name: str, file_content: bytes, **kwargs): url = format_url( - "/api/v1/Contents('Blobs')/Contents('{name}')/Content", - name=file_name) + "/Contents('{version_content_path}')/Contents('{name}')/Content", + name=file_name, + version_content_path=self.version_content_path) - return self._rest.PUT(url, file_content, headers=self.BINARY_HTTP_HEADER, **kwargs) + return self._rest.PUT(url, file_content, headers=self.binary_http_header, **kwargs) def update_or_create(self, file_name: str, file_content: bytes, **kwargs): if self.exists(file_name, **kwargs): @@ -53,14 +72,16 @@ def update_or_create(self, file_name: str, file_content: bytes, **kwargs): def exists(self, file_name: str, **kwargs): url = format_url( - "/api/v1/Contents('Blobs')/Contents('{name}')", - name=file_name) + "/Contents('{version_content_path}')/Contents('{name}')", + name=file_name, + version_content_path=self.version_content_path) return self._exists(url, **kwargs) def delete(self, file_name: str, **kwargs): url = format_url( - "/api/v1/Contents('Blobs')/Contents('{name}')", - name=file_name) + "/Contents('{version_content_path}')/Contents('{name}')", + name=file_name, + version_content_path=self.version_content_path) return self._rest.DELETE(url, **kwargs) diff --git a/TM1py/Services/GitService.py b/TM1py/Services/GitService.py index e4adaefa..09013f8d 100644 --- a/TM1py/Services/GitService.py +++ b/TM1py/Services/GitService.py @@ -25,7 +25,7 @@ def __init__(self, rest: RestService): def tm1project_get(self) -> TM1Project: """_summary_ """ - url = '/api/v1/!tm1project' + url = '/!tm1project' response = self._rest.GET(url) if not response.content: return None @@ -33,7 +33,7 @@ def tm1project_get(self) -> TM1Project: return TM1Project.from_dict(response.json()) def tm1project_delete(self): - url = '/api/v1/!tm1project' + url = '/!tm1project' empty_dict = {} body_json = json.dumps(empty_dict) @@ -41,7 +41,7 @@ def tm1project_delete(self): return TM1Project.from_dict(response.json()) def tm1project_put(self, tm1_project: TM1Project) -> TM1Project: - url = '/api/v1/!tm1project' + url = '/!tm1project' body_json = tm1_project.body # we need to ensure that async_requests_mode=False for this specific request as the response will not include @@ -63,7 +63,7 @@ def git_init(self, git_url: str, deployment: str, username: str = None, password :param force: reset git context on True :param config: Dictionary containing git configuration parameters """ - url = "/api/v1/GitInit" + url = "/GitInit" body = {'URL': git_url, 'Deployment': deployment} for key, value in locals().items(): @@ -80,7 +80,7 @@ def git_uninit(self, force: bool = False, **kwargs): :param force: clean up git context when True """ - url = "/api/v1/GitUninit" + url = "/GitUninit" body = json.dumps(force) return self._rest.POST(url=url, data=body, **kwargs) @@ -93,7 +93,7 @@ def git_status(self, username: str = None, password: str = None, public_key: str :param private_key: SSH private key, available from PAA V2.0.9.4 :param passphrase: Passphrase for decrypting private key, if set """ - url = "/api/v1/GitStatus" + url = "/GitStatus" body = {} for key, value in locals().items(): @@ -124,7 +124,7 @@ def git_push(self, message: str, author: str, email: str, branch: str = None, ne :param execute: Executes the plan right away if True """ - url = "/api/v1/GitPush" + url = "/GitPush" body = {} for key, value in locals().items(): @@ -152,7 +152,7 @@ def git_pull(self, branch: str, force: bool = None, execute: bool = None, userna :param private_key: SSH private key, available from PAA V2.0.9.4 :param passphrase: Passphrase for decrypting private key, if set """ - url = "/api/v1/GitPull" + url = "/GitPull" body = {} for key, value in locals().items(): @@ -172,13 +172,13 @@ def git_execute_plan(self, plan_id: str, **kwargs) -> Response: """ Executes a plan based on the planid :param plan_id: GitPlan id """ - url = format_url("/api/v1/GitPlans('{}')/tm1.Execute", plan_id) + url = format_url("/GitPlans('{}')/tm1.Execute", plan_id) return self._rest.POST(url=url, **kwargs) def git_get_plans(self, **kwargs) -> List[GitPlan]: """ Gets a list of currently available GIT plans """ - url = "/api/v1/GitPlans" + url = "/GitPlans" plans = [] response = self._rest.GET(url=url, **kwargs) diff --git a/TM1py/Services/HierarchyService.py b/TM1py/Services/HierarchyService.py index 8f64e114..6cf3563a 100644 --- a/TM1py/Services/HierarchyService.py +++ b/TM1py/Services/HierarchyService.py @@ -21,7 +21,7 @@ from TM1py.Services.RestService import RestService from TM1py.Services.SubsetService import SubsetService from TM1py.Utils.Utils import case_and_space_insensitive_equals, format_url, CaseAndSpaceInsensitiveDict, \ - CaseAndSpaceInsensitiveSet, CaseAndSpaceInsensitiveTuplesDict, require_pandas, require_admin + CaseAndSpaceInsensitiveSet, CaseAndSpaceInsensitiveTuplesDict, require_pandas, require_admin, verify_version class HierarchyService(ObjectService): @@ -85,7 +85,7 @@ def create(self, hierarchy: Hierarchy, **kwargs): :param hierarchy: :return: """ - url = format_url("/api/v1/Dimensions('{}')/Hierarchies", hierarchy.dimension_name) + url = format_url("/Dimensions('{}')/Hierarchies", hierarchy.dimension_name) response = self._rest.POST(url, hierarchy.body, **kwargs) self.update_element_attributes(hierarchy, **kwargs) @@ -100,7 +100,7 @@ def get(self, dimension_name: str, hierarchy_name: str, **kwargs) -> Hierarchy: :return: """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')?$expand=Edges,Elements,ElementAttributes,Subsets,DefaultMember", + "/Dimensions('{}')/Hierarchies('{}')?$expand=Edges,Elements,ElementAttributes,Subsets,DefaultMember", dimension_name, hierarchy_name) response = self._rest.GET(url, **kwargs) @@ -112,7 +112,7 @@ def get_all_names(self, dimension_name: str, **kwargs) -> List[str]: :param dimension_name: :return: """ - url = format_url("/api/v1/Dimensions('{}')/Hierarchies?$select=Name", dimension_name) + url = format_url("/Dimensions('{}')/Hierarchies?$select=Name", dimension_name) response = self._rest.GET(url, **kwargs) return [hierarchy["Name"] for hierarchy in response.json()["value"]] @@ -131,7 +131,7 @@ def update(self, hierarchy: Hierarchy, keep_existing_attributes=False, **kwargs) # functions returns multiple responses responses = list() # 1. Update Hierarchy - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')", hierarchy.dimension_name, hierarchy.name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')", hierarchy.dimension_name, hierarchy.name) # Workaround EDGES: Handle Issue, that Edges cant be created in one batch with the Hierarchy in certain versions hierarchy_body = hierarchy.body_as_dict if self.version[0:8] in self.EDGES_WORKAROUND_VERSIONS: @@ -177,7 +177,7 @@ def exists(self, dimension_name: str, hierarchy_name: str, **kwargs) -> bool: :param hierarchy_name: :return: """ - url = format_url("/api/v1/Dimensions('{}')/Hierarchies?$select=Name", dimension_name) + url = format_url("/Dimensions('{}')/Hierarchies?$select=Name", dimension_name) try: response = self._rest.GET(url, **kwargs) @@ -193,13 +193,13 @@ def exists(self, dimension_name: str, hierarchy_name: str, **kwargs) -> bool: return hierarchy_name in existing_hierarchies def delete(self, dimension_name: str, hierarchy_name: str, **kwargs) -> Response: - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')", dimension_name, hierarchy_name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')", dimension_name, hierarchy_name) return self._rest.DELETE(url, **kwargs) def get_hierarchy_summary(self, dimension_name: str, hierarchy_name: str, **kwargs) -> Dict[str, int]: hierarchy_properties = ("Elements", "Edges", "ElementAttributes", "Members", "Levels") url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')?$expand=Edges/$count,Elements/$count," + "/Dimensions('{}')/Hierarchies('{}')?$expand=Edges/$count,Elements/$count," "ElementAttributes/$count,Members/$count,Levels/$count&$select=Cardinality", dimension_name, hierarchy_name) @@ -278,7 +278,7 @@ def get_default_member(self, dimension_name: str, hierarchy_name: str = None, ** :return: String, name of Member """ url = format_url( - "/api/v1/Dimensions('{dimension}')/Hierarchies('{hierarchy}')/DefaultMember", + "/Dimensions('{dimension}')/Hierarchies('{hierarchy}')/DefaultMember", dimension=dimension_name, hierarchy=hierarchy_name if hierarchy_name else dimension_name) response = self._rest.GET(url=url, **kwargs) @@ -287,16 +287,9 @@ def get_default_member(self, dimension_name: str, hierarchy_name: str = None, ** return None return response.json()["Name"] - def update_default_member(self, dimension_name: str, hierarchy_name: str = None, member_name: str = "", - **kwargs) -> Response: - """ Update the default member of a hierarchy. - Currently implemented through TI, since TM1 API does not supports default member updates yet. - - :param dimension_name: - :param hierarchy_name: - :param member_name: - :return: - """ + def _update_default_member_via_props_cube(self, dimension_name: str, hierarchy_name: str = None, + member_name: str = "", + **kwargs) -> Response: from TM1py import ProcessService, CellService if hierarchy_name and not case_and_space_insensitive_equals(dimension_name, hierarchy_name): dimension = "{}:{}".format(dimension_name, hierarchy_name) @@ -314,10 +307,35 @@ def update_default_member(self, dimension_name: str, hierarchy_name: str = None, lines_prolog=format_url("RefreshMdxHierarchy('{}');", dimension_name), **kwargs) + def _update_default_member_via_api(self, dimension_name: str, hierarchy_name: str = None, member_name: str = "", + **kwargs) -> Response: + + url = format_url("/Dimensions('{dimension}')/Hierarchies('{hierarchy}')", + dimension=dimension_name, + hierarchy=hierarchy_name if hierarchy_name else dimension_name) + + payload = {"DefaultMemberName": member_name} + + return self._rest.PATCH(url=url, data=json.dumps(payload)) + + def update_default_member(self, dimension_name: str, hierarchy_name: str = None, member_name: str = "", + **kwargs) -> Response: + """ Update the default member of a hierarchy. + + :param dimension_name: + :param hierarchy_name: + :param member_name: + :return: + """ + if verify_version(required_version='12', version=self.version): + return self._update_default_member_via_api(dimension_name, hierarchy_name, member_name) + else: + return self._update_default_member_via_props_cube(dimension_name, hierarchy_name, member_name) + def remove_all_edges(self, dimension_name: str, hierarchy_name: str = None, **kwargs) -> Response: if not hierarchy_name: hierarchy_name = dimension_name - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')", dimension_name, hierarchy_name) + url = format_url("/Dimensions('{}')/Hierarchies('{}')", dimension_name, hierarchy_name) body = { "Edges": [] } @@ -384,7 +402,7 @@ def is_balanced(self, dimension_name: str, hierarchy_name: str, **kwargs): :return: """ url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/Structure/$value", + "/Dimensions('{}')/Hierarchies('{}')/Structure/$value", dimension_name, hierarchy_name) structure = int(self._rest.GET(url, **kwargs).text) diff --git a/TM1py/Services/ManageService.py b/TM1py/Services/ManageService.py new file mode 100644 index 00000000..c53e9ee9 --- /dev/null +++ b/TM1py/Services/ManageService.py @@ -0,0 +1,161 @@ +import json +import requests +from requests.auth import HTTPBasicAuth + + +class ManageService: + """ Manage service to interact with the manage endpoint. + The manage endpoint uses basic auth using the root client and secret + + """ + + def __init__(self, domain, root_client, root_secret): + self._domain = domain + self._root_client = root_client + self._root_secret = root_secret + self._auth_header = HTTPBasicAuth(self._root_client, self._root_secret) + self._root_url = f"{self._domain}/manage/v1" + + def __enter__(self): + return self + + def __exit__(self, exception_type, exception_value, traceback): + pass + + def get_instances(self): + url = f"{self._root_url}/Instances" + response = requests.get(url=url, auth=self._auth_header) + return json.loads(response.content).get('value') + + def get_instance(self, instance_name): + url = f"{self._root_url}/Instances('{instance_name}')" + response = requests.get(url=url, auth=self._auth_header) + return json.loads(response.content) + + def create_instance(self, instance_name): + url = f"{self._root_url}/Instances" + payload = {"Name": instance_name} + response = requests.post(url=url, json=payload, auth=self._auth_header) + return response + + def delete_instance(self, instance_name): + url = f"{self._root_url}/Instances('{instance_name}')" + response = requests.delete(url=url, auth=self._auth_header) + return response + + def instance_exists(self, instance_name): + url = f"{self._root_url}/Instances('{instance_name}')" + response = requests.get(url=url, auth=self._auth_header) + if response.ok: + return True + else: + return False + + def get_databases(self, instance_name): + url = f"{self._root_url}/Instances('{instance_name}')/Databases" + response = requests.get(url=url, auth=self._auth_header) + return json.loads(response.content).get('value') + + def get_database(self, instance_name, database_name): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')" + response = requests.get(url=url, auth=self._auth_header) + return json.loads(response.content) + + def create_database(self, + instance_name, + database_name, + number_replicas, + product_version="12.0.0-alpha.1", + cpu_requests="1000m", + cpu_limits="2000m", + memory_requests="1G", + memory_limits="2G", + storage_size="20Gi"): + + url = f"{self._root_url}/Instances('{instance_name}')/Databases" + + payload = {"Name": database_name, + "Replicas": number_replicas, + "ProductVersion": product_version, + "Resources": { + "Replica": { + "CPU": { + "Requests": cpu_requests, + "Limits": cpu_limits + }, + "Memory": { + "Requests": memory_requests, + "Limits": memory_limits + } + }, + "Storage": { + "Size": storage_size + } + } + } + response = requests.post(url=url, json=payload, auth=self._auth_header) + + return response + + def delete_database(self, instance_name, database_name): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')" + response = requests.delete(url=url, auth=self._auth_header) + return response + + def database_exists(self, instance_name, database_name): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')" + response = requests.get(url=url, auth=self._auth_header) + if response.ok: + return True + else: + return False + + def upgrade_database(self, instance_name: str, database_name: str, target_version: str = ""): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')/tm1s.Upgrade" + payload = {"ProductVersion": target_version} + response = requests.post(url=url, json=payload, auth=self._auth_header) + return response + + def create_database_backup(self, instance_name: str, database_name: str, backup_url: str): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')/tm1s.Backup" + payload = {"URL": backup_url} + response = requests.post(url=url, json=payload, auth=self._auth_header) + return response + + def create_and_upload_database_backup_set_file(self, instance_name: str, database_name: str, backup_set_name: str): + create_url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')" \ + f"/Contents('Files')/Contents('.backupsets')/Contents" + payload = {"@odata.type": "#ibm.tm1.api.v1.Document", "Name": f"{backup_set_name}.tgz"} + requests.post(url=create_url, json=payload, auth=self._auth_header) + + upload_url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')" \ + f"/Contents('Files')/Contents('.backupsets')/Contents('{backup_set_name}.tgz')/Content" + response = requests.post(url=upload_url, json=payload, auth=self._auth_header) + return response + + def restore_database(self, instance_name: str, database_name: str, backup_url: str): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')/tm1s.Restore" + payload = {"URL": backup_url} + response = requests.post(url=url, json=payload, auth=self._auth_header) + return response + + def scale_database(self, instance_name: str, database_name: str, replicas: int): + url = f"{self._root_url}/Instances('{instance_name}')/Databases('{database_name}')" + payload = {"Replicas": replicas} + response = requests.patch(url=url, json=payload, auth=self._auth_header) + return response + + def get_applications(self, instance_name): + url = f"{self._root_url}/Instances('{instance_name}')/Applications" + response = requests.get(url=url, auth=self._auth_header) + return json.loads(response.content) + + def create_application(self, instance_name, application_name): + url = f"{self._root_url}/Instances('{instance_name}')/Applications" + payload = {"Name": application_name} + response = requests.post(url=url, json=payload, auth=self._auth_header) + response_json = json.loads(response.content) + return response_json['ClientID'], response_json['ClientSecret'] + + + diff --git a/TM1py/Services/MonitoringService.py b/TM1py/Services/MonitoringService.py index 181da087..bd90f78f 100644 --- a/TM1py/Services/MonitoringService.py +++ b/TM1py/Services/MonitoringService.py @@ -6,7 +6,7 @@ from TM1py.Objects.User import User from TM1py.Services.ObjectService import ObjectService from TM1py.Services.RestService import RestService -from TM1py.Utils import format_url, case_and_space_insensitive_equals, require_admin +from TM1py.Utils import format_url, case_and_space_insensitive_equals, require_admin, deprecated_in_version class MonitoringService(ObjectService): @@ -17,36 +17,40 @@ class MonitoringService(ObjectService): def __init__(self, rest: RestService): super().__init__(rest) + @deprecated_in_version(version="12.0.0") def get_threads(self, **kwargs) -> List: """ Return a dict of the currently running threads from the TM1 Server :return: dict: the response """ - url = '/api/v1/Threads' + url = '/Threads' response = self._rest.GET(url, **kwargs) return response.json()['value'] + @deprecated_in_version(version="12.0.0") def get_active_threads(self, **kwargs): """Return a list of non-idle threads from the TM1 Server :return: list: TM1 threads as dict """ - url = "/api/v1/Threads?$filter=Function ne 'GET /api/v1/Threads' and State ne 'Idle'" + url = "/Threads?$filter=Function ne 'GET /Threads' and State ne 'Idle'" response = self._rest.GET(url, **kwargs) return response.json()['value'] + @deprecated_in_version(version="12.0.0") def cancel_thread(self, thread_id: int, **kwargs) -> Response: """ Kill a running thread :param thread_id: :return: """ - url = format_url("/api/v1/Threads('{}')/tm1.CancelOperation", str(thread_id)) + url = format_url("/Threads('{}')/tm1.CancelOperation", str(thread_id)) response = self._rest.POST(url, **kwargs) return response + @deprecated_in_version(version="12.0.0") def cancel_all_running_threads(self, **kwargs) -> list: running_threads = self.get_threads(**kwargs) canceled_threads = list() @@ -57,7 +61,7 @@ def cancel_all_running_threads(self, **kwargs) -> list: continue if thread["Name"] == "Pseudo": continue - if thread["Function"] == "GET /api/v1/Threads": + if thread["Function"] == "GET /Threads": continue self.cancel_thread(thread["ID"], **kwargs) canceled_threads.append(thread) @@ -68,7 +72,7 @@ def get_active_users(self, **kwargs) -> List[User]: :return: List of TM1py.User instances """ - url = '/api/v1/Users?$filter=IsActive eq true&$expand=Groups' + url = '/Users?$filter=IsActive eq true&$expand=Groups' response = self._rest.GET(url, **kwargs) users = [User.from_dict(user) for user in response.json()['value']] return users @@ -79,7 +83,7 @@ def user_is_active(self, user_name: str, **kwargs) -> bool: :param user_name: :return: Boolean """ - url = format_url("/api/v1/Users('{}')/IsActive", user_name) + url = format_url("/Users('{}')/IsActive", user_name) response = self._rest.GET(url, **kwargs) return bool(response.json()['value']) @@ -89,12 +93,12 @@ def disconnect_user(self, user_name: str, **kwargs) -> Response: :param user_name: :return: """ - url = format_url("/api/v1/Users('{}')/tm1.Disconnect", user_name) + url = format_url("/Users('{}')/tm1.Disconnect", user_name) response = self._rest.POST(url, **kwargs) return response def get_active_session_threads(self, exclude_idle: bool = True, **kwargs): - url = "/api/v1/ActiveSession/Threads?$filter=Function ne 'GET /api/v1/ActiveSession/Threads'" + url = "/ActiveSession/Threads?$filter=Function ne 'GET /ActiveSession/Threads'" if exclude_idle: url += " and State ne 'Idle'" @@ -102,7 +106,7 @@ def get_active_session_threads(self, exclude_idle: bool = True, **kwargs): return response.json()['value'] def get_sessions(self, include_user: bool = True, include_threads: bool = True, **kwargs) -> List: - url = "/api/v1/Sessions" + url = "/Sessions" if include_user or include_threads: expands = list() if include_user: @@ -126,7 +130,7 @@ def disconnect_all_users(self, **kwargs) -> list: return disconnected_users def close_session(self, session_id, **kwargs) -> Response: - url = format_url(f"/api/v1/Sessions('{session_id}')/tm1.Close") + url = format_url(f"/Sessions('{session_id}')/tm1.Close") return self._rest.POST(url, **kwargs) @require_admin diff --git a/TM1py/Services/ObjectService.py b/TM1py/Services/ObjectService.py index eb979412..6101ed23 100644 --- a/TM1py/Services/ObjectService.py +++ b/TM1py/Services/ObjectService.py @@ -5,7 +5,7 @@ from TM1py.Exceptions import TM1pyRestException from TM1py.Services import RestService -from TM1py.Utils import format_url +from TM1py.Utils import format_url, verify_version class ObjectService: @@ -16,7 +16,8 @@ class ObjectService: ELEMENT_ATTRIBUTES_PREFIX = "}ElementAttributes_" SANDBOX_DIMENSION = "Sandboxes" - BINARY_HTTP_HEADER = {'Content-Type': 'application/octet-stream; odata.streaming=true'} + BINARY_HTTP_HEADER_PRE_V12 = {'Content-Type': 'application/octet-stream; odata.streaming=true'} + BINARY_HTTP_HEADER = {'Content-Type': 'application/json;charset=UTF-8'} def __init__(self, rest_service: RestService): """ Constructor, Create an instance of ObjectService @@ -24,6 +25,10 @@ def __init__(self, rest_service: RestService): :param rest_service: """ self._rest = rest_service + if verify_version("12", self.version): + self.binary_http_header = self.BINARY_HTTP_HEADER + else: + self.binary_http_header = self.BINARY_HTTP_HEADER_PRE_V12 def suggest_unique_object_name(self, random_seed: float = None) -> str: """ @@ -38,7 +43,7 @@ def suggest_unique_object_name(self, random_seed: float = None) -> str: def determine_actual_object_name(self, object_class: str, object_name: str, **kwargs) -> str: url = format_url( - "/api/v1/{}?$filter=tolower(replace(Name, ' ', '')) eq '{}'", + "/{}?$filter=tolower(replace(Name, ' ', '')) eq '{}'", object_class, object_name.replace(" ", "").lower()) response = self._rest.GET(url, **kwargs) diff --git a/TM1py/Services/ProcessService.py b/TM1py/Services/ProcessService.py index 199098bd..589a09e0 100644 --- a/TM1py/Services/ProcessService.py +++ b/TM1py/Services/ProcessService.py @@ -14,7 +14,7 @@ from TM1py.Services.ObjectService import ObjectService from TM1py.Services.RestService import RestService from TM1py.Utils import format_url, require_admin -from TM1py.Utils.Utils import require_version +from TM1py.Utils.Utils import require_version, deprecated_in_version class ProcessService(ObjectService): @@ -32,7 +32,7 @@ def get(self, name_process: str, **kwargs) -> Process: :return: Instance of the TM1py.Process """ url = format_url( - "/api/v1/Processes('{}')?$select=*,UIData,VariablesUIData," + "/Processes('{}')?$select=*,UIData,VariablesUIData," "DataSource/dataSourceNameForServer," "DataSource/dataSourceNameForClient," "DataSource/asciiDecimalSeparator," @@ -58,7 +58,7 @@ def get_all(self, skip_control_processes: bool = False, **kwargs) -> List[Proces """ model_process_filter = "&$filter=startswith(Name,'}') eq false and startswith(Name,'{') eq false" - url = "/api/v1/Processes?$select=*,UIData,VariablesUIData," \ + url = "/Processes?$select=*,UIData,VariablesUIData," \ "DataSource/dataSourceNameForServer," \ "DataSource/dataSourceNameForClient," \ "DataSource/asciiDecimalSeparator," \ @@ -86,7 +86,7 @@ def get_all_names(self, skip_control_processes: bool = False, **kwargs) -> List[ List of Strings """ model_process_filter = "&$filter=startswith(Name,'}') eq false and startswith(Name,'{') eq false" - url = "/api/v1/Processes?$select=Name{}".format(model_process_filter if skip_control_processes else "") + url = "/Processes?$select=Name{}".format(model_process_filter if skip_control_processes else "") response = self._rest.GET(url, **kwargs) processes = list(process['Name'] for process in response.json()['value']) @@ -103,7 +103,7 @@ def search_string_in_code(self, search_string: str, skip_control_processes: bool """ search_string = search_string.lower().replace(' ', '') model_process_filter = "and (startswith(Name,'}') eq false and startswith(Name,'{') eq false)" - url = format_url("/api/v1/Processes?$select=Name&$filter=" + url = format_url("/Processes?$select=Name&$filter=" "contains(tolower(replace(PrologProcedure, ' ', '')),'{}') " "or contains(tolower(replace(MetadataProcedure, ' ', '')),'{}') " "or contains(tolower(replace(DataProcedure, ' ', '')),'{}') " @@ -130,7 +130,7 @@ def search_string_in_name(self, name_startswith: str = None, name_contains: Iter if name_contains_operator not in ("and", "or"): raise ValueError("'name_contains_operator' must be either 'AND' or 'OR'") - url = "/api/v1/Processes?$select=Name" + url = "/Processes?$select=Name" name_filters = [] if name_startswith: @@ -159,7 +159,7 @@ def create(self, process: Process, **kwargs) -> Response: :param process: Instance of TM1py.Process class :return: Response """ - url = "/api/v1/Processes" + url = "/Processes" # Adjust process body if TM1 version is lower than 11 due to change in Process Parameters structure # https://www.ibm.com/developerworks/community/forums/html/topic?id=9188d139-8905-4895-9229-eaaf0e7fa683 if int(self.version[0:2]) < 11: @@ -173,7 +173,7 @@ def update(self, process: Process, **kwargs) -> Response: :param process: Instance of TM1py.Process class :return: Response """ - url = format_url("/api/v1/Processes('{}')", process.name) + url = format_url("/Processes('{}')", process.name) # Adjust process body if TM1 version is lower than 11 due to change in Process Parameters structure # https://www.ibm.com/developerworks/community/forums/html/topic?id=9188d139-8905-4895-9229-eaaf0e7fa683 if int(self.version[0:2]) < 11: @@ -198,7 +198,7 @@ def delete(self, name: str, **kwargs) -> Response: :param name: :return: Response """ - url = format_url("/api/v1/Processes('{}')", name) + url = format_url("/Processes('{}')", name) response = self._rest.DELETE(url, **kwargs) return response @@ -208,7 +208,7 @@ def exists(self, name: str, **kwargs) -> bool: :param name: :return: """ - url = format_url("/api/v1/Processes('{}')", name) + url = format_url("/Processes('{}')", name) return self._exists(url, **kwargs) def compile(self, name: str, **kwargs) -> List: @@ -217,7 +217,7 @@ def compile(self, name: str, **kwargs) -> List: :param name: :return: """ - url = format_url("/api/v1/Processes('{}')/tm1.Compile", name) + url = format_url("/Processes('{}')/tm1.Compile", name) response = self._rest.POST(url, **kwargs) syntax_errors = response.json()["value"] return syntax_errors @@ -228,7 +228,7 @@ def compile_process(self, process: Process, **kwargs) -> List: :param process: :return: """ - url = "/api/v1/CompileProcess" + url = "/CompileProcess" payload = json.loads('{"Process":' + process.body + '}') @@ -251,7 +251,7 @@ def execute(self, process_name: str, parameters: Dict = None, timeout: float = N :param cancel_at_timeout: Abort operation in TM1 when timeout is reached :return: """ - url = format_url("/api/v1/Processes('{}')/tm1.Execute", process_name) + url = format_url("/Processes('{}')/tm1.Execute", process_name) if not parameters: if kwargs: parameters = {"Parameters": []} @@ -273,7 +273,7 @@ def execute_process_with_return(self, process: Process, timeout: float = None, c :param kwargs: dictionary of process parameters and values :return: success (boolean), status (String), error_log_file (String) """ - url = "/api/v1/ExecuteProcessWithReturn?$expand=*" + url = "/ExecuteProcessWithReturn?$expand=*" if kwargs: for parameter_name, parameter_value in kwargs.items(): process.remove_parameter(name=parameter_name) @@ -290,16 +290,10 @@ def execute_process_with_return(self, process: Process, timeout: float = None, c cancel_at_timeout=cancel_at_timeout, **kwargs) - execution_summary = response.json() - - success = execution_summary["ProcessExecuteStatusCode"] == "CompletedSuccessfully" - status = execution_summary["ProcessExecuteStatusCode"] - error_log_file = None if execution_summary["ErrorLogFile"] is None else execution_summary["ErrorLogFile"][ - "Filename"] - return success, status, error_log_file + return self._execute_with_return_parse_response(response) - def execute_with_return(self, process_name: str, timeout: float = None, cancel_at_timeout: bool = False, - **kwargs) -> Tuple[bool, str, str]: + def execute_with_return(self, process_name: str = None, timeout: float = None, cancel_at_timeout: bool = False, + return_async_id: bool = False, **kwargs) -> Tuple[bool, str, str]: """ Ask TM1 Server to execute a process. pass process parameters as keyword arguments to this function. E.g: @@ -310,10 +304,12 @@ def execute_with_return(self, process_name: str, timeout: float = None, cancel_a :param process_name: name of the TI process :param timeout: Number of seconds that the client will wait to receive the first byte. :param cancel_at_timeout: Abort operation in TM1 when timeout is reached + :param return_async_id: return async_id instead of (success, status, error_log_file) :param kwargs: dictionary of process parameters and values :return: success (boolean), status (String), error_log_file (String) """ - url = format_url("/api/v1/Processes('{}')/tm1.ExecuteWithReturn?$expand=*", process_name) + + url = format_url("/Processes('{}')/tm1.ExecuteWithReturn?$expand=*", process_name) parameters = dict() if kwargs: parameters = {"Parameters": []} @@ -325,7 +321,27 @@ def execute_with_return(self, process_name: str, timeout: float = None, cancel_a data=json.dumps(parameters, ensure_ascii=False), timeout=timeout, cancel_at_timeout=cancel_at_timeout, + return_async_id=return_async_id, **kwargs) + + if return_async_id: + return response + + return self._execute_with_return_parse_response(response) + + def poll_execute_with_return(self, async_id: str): + + response = self._rest.retrieve_async_response(async_id=async_id) + if response.status_code not in [200, 201]: + return None + + # response transformation necessary in TM1 < v11. Not required for v12 + if response.content.startswith(b"HTTP/"): + response = self._rest.build_response_from_binary_response(response.content) + + return self._execute_with_return_parse_response(response) + + def _execute_with_return_parse_response(self, response): execution_summary = response.json() success = execution_summary["ProcessExecuteStatusCode"] == "CompletedSuccessfully" status = execution_summary["ProcessExecuteStatusCode"] @@ -353,25 +369,29 @@ def execute_ti_code(self, lines_prolog: Iterable[str], lines_epilog: Iterable[st finally: self.delete(process_name, **kwargs) - def search_error_log_filenames(self, search_string: str, top: int=0, descending: bool=False, **kwargs) -> str: + def search_error_log_filenames(self, search_string: str, top: int = 0, descending: bool = False, + **kwargs) -> List[str]: """ Search error log filenames for given search string like a datestamp e.g. 20231201 - :param process_name: valid TI name + :param search_string: substring to contain in file names :param top: top n filenames :param descending: default sort is ascending, descending=True would have most recent at the top of list :return: list of filenames """ - url = format_url("/api/v1/ErrorLogFiles?select=Filename&$filter=contains(tolower(Filename), tolower('{}'))", search_string) + url = format_url( + "/ErrorLogFiles?select=Filename&$filter=contains(tolower(Filename), tolower('{}'))", + search_string) url += "&$top={}".format(top) if top > 0 else "" - url += "&$orderby=Filename desc" if descending==True else "" + url += "&$orderby=Filename desc" if descending == True else "" response = self._rest.GET(url=url, **kwargs) return [log['Filename'] for log in response.json()['value']] - - def get_error_log_filenames(self, process_name: str=None, top: int=0, descending: bool=False, **kwargs) -> str: + + def get_error_log_filenames(self, process_name: str = None, top: int = 0, descending: bool = False, + **kwargs) -> List[str]: """ Get error log filenames for specified TI process :param process_name: valid TI name, leave blank to return all error log filenames @@ -382,10 +402,10 @@ def get_error_log_filenames(self, process_name: str=None, top: int=0, descending if process_name: if not self.exists(name=process_name, **kwargs): raise ValueError(f"'{process_name}' is not a valid process") - search_string = '{}.log'.format(process_name) + search_string = '{}'.format(process_name) else: - search_string='' - + search_string = '' + return self.search_error_log_filenames(search_string=search_string, top=top, descending=descending, **kwargs) def get_error_log_file_content(self, file_name: str, **kwargs) -> str: @@ -394,7 +414,7 @@ def get_error_log_file_content(self, file_name: str, **kwargs) -> str: :param file_name: name of the error log file in the TM1 log directory :return: String, content of the file """ - url = format_url("/api/v1/ErrorLogFiles('{}')/Content", file_name) + url = format_url("/ErrorLogFiles('{}')/Content", file_name) response = self._rest.GET(url=url, **kwargs) return response.text @@ -404,10 +424,11 @@ def get_processerrorlogs(self, process_name: str, **kwargs) -> List: :param process_name: name of the process :return: list - Collection of ProcessErrorLogs """ - url = format_url("/api/v1/Processes('{}')/ErrorLogs", process_name) + url = format_url("/Processes('{}')/ErrorLogs", process_name) response = self._rest.GET(url=url, **kwargs) return response.json()['value'] + @deprecated_in_version(version="12") def get_last_message_from_processerrorlog(self, process_name: str, **kwargs) -> str: """ Get the latest ProcessErrorLog from a process entity @@ -418,7 +439,7 @@ def get_last_message_from_processerrorlog(self, process_name: str, **kwargs) -> logs_as_list = self.get_processerrorlogs(process_name, **kwargs) if len(logs_as_list) > 0: timestamp = logs_as_list[-1]['Timestamp'] - url = format_url("/api/v1/Processes('{}')/ErrorLogs('{}')/Content", process_name, timestamp) + url = format_url("/Processes('{}')/ErrorLogs('{}')/Content", process_name, timestamp) # response is plain text - due to entity type Edm.Stream response = self._rest.GET(url=url, **kwargs) return response.text @@ -427,7 +448,7 @@ def debug_process(self, process_name: str, timeout: float = None, **kwargs) -> D """ Start debug session for specified process; debug session id is returned in response """ - raw_url = "/api/v1/Processes('{}')/tm1.Debug?$expand=Breakpoints," \ + raw_url = "/Processes('{}')/tm1.Debug?$expand=Breakpoints," \ "Thread,CallStack($expand=Variables,Process($select=Name))" url = format_url(raw_url, process_name) @@ -449,14 +470,14 @@ def debug_step_over(self, debug_id: str, **kwargs) -> Dict: Runs a single statement in the process If ExecuteProcess is next function, will NOT debug child process """ - url = format_url("/api/v1/ProcessDebugContexts('{}')/tm1.StepOver", debug_id) + url = format_url("/ProcessDebugContexts('{}')/tm1.StepOver", debug_id) self._rest.POST(url, **kwargs) # digest time necessary for TM1 <= 11.8 # ToDo: remove in later versions of TM1 once issue in TM1 server is resolved time.sleep(0.1) - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=Breakpoints," \ + raw_url = "/ProcessDebugContexts('{}')?$expand=Breakpoints," \ "Thread,CallStack($expand=Variables,Process($select=Name))" url = format_url(raw_url, debug_id) response = self._rest.GET(url, **kwargs) @@ -468,14 +489,14 @@ def debug_step_in(self, debug_id: str, **kwargs) -> Dict: Runs a single statement in the process If ExecuteProcess is next function, will pause at first statement inside child process """ - url = format_url("/api/v1/ProcessDebugContexts('{}')/tm1.StepIn", debug_id) + url = format_url("/ProcessDebugContexts('{}')/tm1.StepIn", debug_id) self._rest.POST(url, **kwargs) # digest time necessary for TM1 <= 11.8 # ToDo: remove in later versions of TM1 once issue in TM1 server is resolved time.sleep(0.1) - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=Breakpoints," \ + raw_url = "/ProcessDebugContexts('{}')?$expand=Breakpoints," \ "Thread,CallStack($expand=Variables,Process($select=Name))" url = format_url(raw_url, debug_id) response = self._rest.GET(url, **kwargs) @@ -486,14 +507,14 @@ def debug_step_out(self, debug_id: str, **kwargs) -> Dict: """ Resumes execution and runs until current process has finished. """ - url = format_url("/api/v1/ProcessDebugContexts('{}')/tm1.StepOut", debug_id) + url = format_url("/ProcessDebugContexts('{}')/tm1.StepOut", debug_id) self._rest.POST(url, **kwargs) # digest time necessary for TM1 <= 11.8 # ToDo: remove in later versions of TM1 once issue in TM1 server is resolved time.sleep(0.1) - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=Breakpoints," \ + raw_url = "/ProcessDebugContexts('{}')?$expand=Breakpoints," \ "Thread,CallStack($expand=Variables,Process($select=Name))" url = format_url(raw_url, debug_id) response = self._rest.GET(url, **kwargs) @@ -505,14 +526,14 @@ def debug_continue(self, debug_id: str, **kwargs) -> Dict: Resumes execution until next breakpoint """ - url = format_url("/api/v1/ProcessDebugContexts('{}')/tm1.Continue", debug_id) + url = format_url("/ProcessDebugContexts('{}')/tm1.Continue", debug_id) self._rest.POST(url, **kwargs) # digest time necessary for TM1 <= 11.8 # ToDo: remove in later versions of TM1 once issue in TM1 server is resolved time.sleep(0.1) - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=Breakpoints," \ + raw_url = "/ProcessDebugContexts('{}')?$expand=Breakpoints," \ "Thread,CallStack($expand=Variables,Process($select=Name))" url = format_url(raw_url, debug_id) response = self._rest.GET(url, **kwargs) @@ -520,7 +541,7 @@ def debug_continue(self, debug_id: str, **kwargs) -> Dict: return response.json() def debug_get_breakpoints(self, debug_id: str, **kwargs) -> List[ProcessDebugBreakpoint]: - url = format_url("/api/v1/ProcessDebugContexts('{}')/Breakpoints", debug_id) + url = format_url("/ProcessDebugContexts('{}')/Breakpoints", debug_id) response = self._rest.GET(url, **kwargs) return [ProcessDebugBreakpoint.from_dict(b) for b in response.json()['value']] @@ -530,7 +551,7 @@ def debug_add_breakpoint(self, debug_id: str, break_point: ProcessDebugBreakpoin def debug_add_breakpoints(self, debug_id: str, break_points: Iterable[ProcessDebugBreakpoint] = None, **kwargs) -> Response: - url = format_url("/api/v1/ProcessDebugContexts('{}')/Breakpoints", debug_id) + url = format_url("/ProcessDebugContexts('{}')/Breakpoints", debug_id) body = json.dumps([break_point.body_as_dict for break_point in break_points]) @@ -538,14 +559,14 @@ def debug_add_breakpoints(self, debug_id: str, break_points: Iterable[ProcessDeb return response def debug_remove_breakpoint(self, debug_id: str, breakpoint_id: int, **kwargs) -> Response: - url = format_url("/api/v1/ProcessDebugContexts('{}')/Breakpoints('{}')", debug_id, str(breakpoint_id)) + url = format_url("/ProcessDebugContexts('{}')/Breakpoints('{}')", debug_id, str(breakpoint_id)) response = self._rest.DELETE(url, **kwargs) return response def debug_update_breakpoint(self, debug_id: str, break_point: ProcessDebugBreakpoint, **kwargs) -> Response: url = format_url( - "/api/v1/ProcessDebugContexts('{}')/Breakpoints('{}')", + "/ProcessDebugContexts('{}')/Breakpoints('{}')", debug_id, str(break_point.breakpoint_id)) @@ -553,7 +574,7 @@ def debug_update_breakpoint(self, debug_id: str, break_point: ProcessDebugBreakp return response def debug_get_variable_values(self, debug_id: str, **kwargs) -> CaseInsensitiveDict: - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=" \ + raw_url = "/ProcessDebugContexts('{}')?$expand=" \ "CallStack($expand=Variables)" url = format_url(raw_url, debug_id) @@ -564,7 +585,7 @@ def debug_get_variable_values(self, debug_id: str, **kwargs) -> CaseInsensitiveD return CaseInsensitiveDict({entry["Name"]: entry["Value"] for entry in call_stack}) def debug_get_single_variable_value(self, debug_id: str, variable_name: str, **kwargs) -> str: - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=" \ + raw_url = "/ProcessDebugContexts('{}')?$expand=" \ "CallStack($expand=Variables($filter=tolower(Name) eq '{}';$select=Value))" url = format_url(raw_url, debug_id, variable_name.lower()) @@ -576,7 +597,7 @@ def debug_get_single_variable_value(self, debug_id: str, variable_name: str, **k raise ValueError(f"'{variable_name}' not found in collection") def debug_get_process_procedure(self, debug_id: str, **kwargs) -> str: - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=" \ + raw_url = "/ProcessDebugContexts('{}')?$expand=" \ "CallStack($select=Procedure)" url = format_url(raw_url, debug_id) @@ -584,7 +605,7 @@ def debug_get_process_procedure(self, debug_id: str, **kwargs) -> str: return response.json()['CallStack'][0]['Procedure'] def debug_get_process_line_number(self, debug_id: str, **kwargs) -> str: - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=" \ + raw_url = "/ProcessDebugContexts('{}')?$expand=" \ "CallStack($select=LineNumber)" url = format_url(raw_url, debug_id) @@ -592,7 +613,7 @@ def debug_get_process_line_number(self, debug_id: str, **kwargs) -> str: return response.json()['CallStack'][0]['LineNumber'] def debug_get_record_number(self, debug_id: str, **kwargs) -> str: - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=" \ + raw_url = "/ProcessDebugContexts('{}')?$expand=" \ "CallStack($select=RecordNumber)" url = format_url(raw_url, debug_id) @@ -600,7 +621,7 @@ def debug_get_record_number(self, debug_id: str, **kwargs) -> str: return response.json()['CallStack'][0]['RecordNumber'] def debug_get_current_breakpoint(self, debug_id: str, **kwargs) -> ProcessDebugBreakpoint: - raw_url = "/api/v1/ProcessDebugContexts('{}')?$expand=CurrentBreakpoint" + raw_url = "/ProcessDebugContexts('{}')?$expand=CurrentBreakpoint" url = format_url(raw_url, debug_id) diff --git a/TM1py/Services/RestService.py b/TM1py/Services/RestService.py index 077752a9..8fd74401 100644 --- a/TM1py/Services/RestService.py +++ b/TM1py/Services/RestService.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- -import functools import json import re import socket import time import warnings from base64 import b64encode, b64decode +from enum import Enum from http.client import HTTPResponse +from http.cookies import SimpleCookie from io import BytesIO from json import JSONDecodeError from typing import Union, Dict, Tuple, Optional @@ -15,101 +16,36 @@ import urllib3 from requests import Timeout, Response, ConnectionError, Session from requests.adapters import HTTPAdapter +from requests.auth import HTTPBasicAuth from urllib3._collections import HTTPHeaderDict # SSO not supported for Linux -from TM1py.Exceptions.Exceptions import TM1pyTimeout -from TM1py.Utils import case_and_space_insensitive_equals, CaseAndSpaceInsensitiveSet, HTTPAdapterWithSocketOptions, \ - decohints +from TM1py.Exceptions.Exceptions import TM1pyTimeout, TM1pyVersionDeprecationException +from TM1py.Utils import case_and_space_insensitive_equals, CaseAndSpaceInsensitiveSet, HTTPAdapterWithSocketOptions try: from requests_negotiate_sspi import HttpNegotiateAuth except ImportError: warnings.warn("requests_negotiate_sspi failed to import. SSO will not work", ImportWarning) -from TM1py.Exceptions import TM1pyRestException +from TM1py.Exceptions import TM1pyRestException, TM1pyException import http.client as http_client -@decohints -def httpmethod(func): - """ Higher Order Function to wrap the GET, POST, PATCH, PUT, DELETE methods - Takes care of: - - encoding of url and payload - - verifying response. Throws TM1pyException if StatusCode of Response is not OK - """ - - @functools.wraps(func) - def wrapper(self, url: str, data: str = '', encoding='utf-8', async_requests_mode: Optional[bool] = None, **kwargs): - # url encoding - url, data = self._url_and_body( - url=url, - data=data, - encoding=encoding) - - # execute request - try: - # determine async_requests_mode - if async_requests_mode is None: - async_requests_mode = self._async_requests_mode - - if not async_requests_mode: - response = func(self, url, data, **kwargs) - if self._re_connect_on_session_timeout and response.status_code == 401: - self.connect() - response = func(self, url, data, **kwargs) - - else: - additional_header = {'Prefer': 'respond-async'} - http_headers = kwargs.get('headers', dict()) - http_headers.update(additional_header) - kwargs['headers'] = http_headers - response = func(self, url, data, **kwargs) - # reconnect in case of session timeout - if self._re_connect_on_session_timeout and response.status_code == 401: - self.connect() - response = func(self, url, data, **kwargs) - self.verify_response(response=response) - - if 'Location' not in response.headers or "'" not in response.headers['Location']: - raise ValueError(f"Failed to retrieve async_id from request {func.__name__} '{url}'") - async_id = response.headers.get('Location').split("'")[1] - - for wait in RestService.wait_time_generator(kwargs.get('timeout', self._timeout)): - response = self.retrieve_async_response(async_id) - if response.status_code in [200, 201]: - break - time.sleep(wait) - - # all wait times consumed and still no 200 - if response.status_code not in [200, 201]: - if kwargs.get("cancel_at_timeout", self._cancel_at_timeout): - self.cancel_async_operation(async_id) - raise TM1pyTimeout(method=func.__name__, url=url, timeout=kwargs['timeout']) - - response = self.build_response_from_raw_bytes(response.content) - - # verify - self.verify_response(response=response) - - # response encoding - response.encoding = encoding - return response - - except Timeout: - if kwargs.get("cancel_at_timeout", self._cancel_at_timeout): - self.cancel_running_operation() - raise TM1pyTimeout(method=func.__name__, url=url, timeout=kwargs.get('timeout', self._timeout)) +class AuthenticationMode(Enum): + BASIC = 1 + WIA = 2 + CAM = 3 + CAM_SSO = 4 + IBM_CLOUD_API_KEY = 5 + SERVICE_TO_SERVICE = 6 - except ConnectionError as e: - # cater for issue in requests library: https://github.com/psf/requests/issues/5430 - if re.search('Read timed out', str(e), re.IGNORECASE): - if kwargs.get("cancel_at_timeout", False): - self.cancel_running_operation() - raise TM1pyTimeout(method=func.__name__, url=url, timeout=kwargs.get('timeout', self._timeout)) - - return wrapper + @property + def use_v12_auth(self): + if self.value < 5: + return False + return True class RestService: @@ -128,32 +64,47 @@ class RestService: Based on requests module """ - HEADERS = {'Connection': 'keep-alive', - 'User-Agent': 'TM1py', - 'Content-Type': 'application/json; odata.streaming=true; charset=utf-8', - 'Accept': 'application/json;odata.metadata=none,text/plain', - 'TM1-SessionContext': 'TM1py'} + HEADERS = { + 'Connection': 'keep-alive', + 'User-Agent': 'TM1py', + 'Content-Type': 'application/json; odata.streaming=true; charset=utf-8', + 'Accept': 'application/json;odata.metadata=none,text/plain', + 'TM1-SessionContext': 'TM1py' + } + + DEFAULT_CONNECTION_POOL_SIZE = 10 - # You can reset the following TCP socket options based on your own use cases when tcp_keepalive is eanbled + # You can reset the following TCP socket options based on your own use cases when tcp_keepalive is enabled # TCP_KEEPIDLE: Time in seconds until the first keepalive is sent # TCP_KEEPINTVL: How often should the keepalive packet be sent # TCP_KEEPCNT: The max number of keepalive packets to send - TCP_SOCKET_OPTIONS = {'TCP_KEEPIDLE': 30, - 'TCP_KEEPINTVL': 15, - 'TCP_KEEPCNT': 60} + TCP_SOCKET_OPTIONS = { + 'TCP_KEEPIDLE': 30, + 'TCP_KEEPINTVL': 15, + 'TCP_KEEPCNT': 60 + } def __init__(self, **kwargs): """ Create an instance of RESTService :param address: String - address of the TM1 instance :param port: Int - HTTPPortNumber as specified in the tm1s.cfg - :param base_url - base url e.g. https://localhost:12354/api/v1 + :param ssl: boolean - as specified in the tm1s.cfg + :param instance: string - planing analytics engine (v12) instance name + :param database: string - planing analytics engine (v12) database name + :param base_url - base url + :param auth_url - auth url for planning analytics engine (v12) :param user: String - name of the user :param password String - password of the user :param decode_b64 - whether password argument is b64 encoded :param namespace String - optional CAM namespace - :param ssl: boolean - as specified in the tm1s.cfg :param cam_passport: String - the cam passport :param session_id: String - TM1SessionId e.g. q7O6e1w49AixeuLVxJ1GZg + :param application_client_id - planning analytics engine (v12) named application client ID created via manage service + :param application_client_secret - planning analytics engine (v12) named application secret created via manage service + :param api_key: String - planing analytics engine (v12) API Key from https://cloud.ibm.com/iam/apikeys + :param iam_url: String - planing analytics engine (v12) IBM Cloud IAM URL. Default: "https://iam.cloud.ibm.com" + :param pa_url: String - planing analytics engine (v12) PA URL e.g., "https://us-east-2.aws.planninganalytics.ibm.com" + :param tenant: String - planing analytics engine (v12) Tenant e.g., YC4B2M1AG2Y6 :param session_context: String - Name of the Application. Controls "Context" column in Arc / TM1top. If None, use default: TM1py :param verify: path to .cer file or 'True' / True / 'False' / False (if no ssl verification is required) @@ -182,64 +133,41 @@ def __init__(self, **kwargs): # store kwargs for future use e.g. re_connect on 401 session timeout self._kwargs = kwargs + # core arguments for connection self._ssl = self.translate_to_boolean(kwargs.get('ssl', True)) self._address = kwargs.get('address', None) self._port = kwargs.get('port', None) - self._verify = False + self._base_url = kwargs.get('base_url', None) + self._auth_url = kwargs.get('auth_url', None) + self._instance = kwargs.get('instance', None) + self._database = kwargs.get('database', None) + self._api_key = kwargs.get('api_key', None) + self._iam_url = kwargs.get('iam_url', None) + self._pa_url = kwargs.get('pa_url', None) + self._tenant = kwargs.get('tenant', None) + + # other arguments + self._auth_mode = self._determine_auth_mode() self._timeout = None if kwargs.get('timeout', None) is None else float(kwargs.get('timeout')) self._cancel_at_timeout = kwargs.get('cancel_at_timeout', False) self._async_requests_mode = self.translate_to_boolean(kwargs.get('async_requests_mode', False)) # Set tcp_keepalive to False explicitly to turn it off when async_requests_mode is enabled - self._tcp_keepalive = self.translate_to_boolean( - kwargs.get('tcp_keepalive', False)) \ - if self._async_requests_mode is not True \ - else False - self._connection_pool_size = kwargs.get('connection_pool_size', None) + self._tcp_keepalive = self._determine_tcp_keepalive(kwargs.get('tcp_keepalive', False)) + self._connection_pool_size = kwargs.get('connection_pool_size', self.DEFAULT_CONNECTION_POOL_SIZE) self._re_connect_on_session_timeout = kwargs.get('re_connect_on_session_timeout', True) + # is retrieved on demand and then cached + self._sandboxing_disabled = None + # optional verbose logging to stdout + self.handle_logging(kwargs.get('logging', False)) - # Logging - if 'logging' in kwargs: - if self.translate_to_boolean(value=kwargs['logging']): - http_client.HTTPConnection.debuglevel = 1 + self._proxies = self._handle_proxies(kwargs.get('proxies', None)) - self._proxies = kwargs.get('proxies', None) - # handle invalid types and potential string argument - if not isinstance(self._proxies, (dict, str, type(None))): - raise ValueError("Argument of 'proxies' must be None, dictionary or JSON string") - elif isinstance(self._proxies, str): - try: - self._proxies = json.loads(self._proxies) - except JSONDecodeError: - raise ValueError("Invalid JSON passed for argument 'proxies': %s", self._proxies) + # populated later on the fly for users with the name different from 'Admin' + self._is_admin = self._determine_is_admin(kwargs.get('user', None)) - # populated on the fly - if kwargs.get('user'): - self._is_admin = True if case_and_space_insensitive_equals(kwargs.get('user'), 'ADMIN') else None - else: - self._is_admin = None - - if 'verify' in kwargs: - if isinstance(kwargs['verify'], str): - if kwargs['verify'].upper() == 'FALSE': - self._verify = False - elif kwargs['verify'].upper() == 'TRUE': - self._verify = True - # path to .cer file - else: - self._verify = kwargs.get('verify') - elif isinstance(kwargs['verify'], bool): - self._verify = kwargs['verify'] - else: - raise ValueError("verify argument must be of type str or bool") + self._verify = self._determine_verify(kwargs.get('verify', None)) - if 'base_url' in kwargs: - self._base_url = kwargs['base_url'] - self._ssl = self._determine_ssl_based_on_base_url() - else: - self._base_url = "http{}://{}:{}".format( - 's' if self._ssl else '', - 'localhost' if len(self._address) == 0 else self._address, - self._port) + self._base_url, self._auth_url = self._construct_service_and_auth_root() self._version = None self._headers = self.HEADERS.copy() @@ -252,16 +180,154 @@ def __init__(self, **kwargs): if self._proxies: self._s.proxies = self._proxies + # First contact with TM1 self.connect() - if not self._version: self.set_version() - # is retrieved on demand and cached - self._sandboxing_disabled = None + self._manage_http_adapter() + + def _determine_is_admin(self, user: [None, str]) -> [None, bool]: + if user is None: + return None + + return True if case_and_space_insensitive_equals(user, 'ADMIN') else None + + def _determine_tcp_keepalive(self, tcp_keepalive: bool): + return self.translate_to_boolean(tcp_keepalive) if self._async_requests_mode is not True else False + + def _determine_verify(self, verify: [bool, str] = None) -> [bool, str]: + if verify is None: + # Default SSL verification in v12 is True + if self._auth_mode in [AuthenticationMode.IBM_CLOUD_API_KEY, AuthenticationMode.SERVICE_TO_SERVICE]: + return True + else: + return False + + if isinstance(verify, str): + if verify.upper() == 'FALSE': + return False + elif verify.upper() == 'TRUE': + return True + + # path to .cer file + else: + return verify + + elif isinstance(verify, bool): + return verify + + raise ValueError("'verify' argument must be of type str or bool") + + def handle_logging(self, logging: Union[str, bool]): + if logging: + if self.translate_to_boolean(value=logging): + http_client.HTTPConnection.debuglevel = 1 + + def _handle_proxies(self, proxies: Union[Dict, str]): + if proxies is None or isinstance(proxies, dict): + return proxies + + elif isinstance(proxies, str): + try: + return json.loads(proxies) + except JSONDecodeError: + raise ValueError("Invalid JSON passed for argument 'proxies': %s", proxies) + + # handle invalid type + raise ValueError("Argument of 'proxies' must be None, dictionary or JSON string") + + def request( + self, + method: str, + url: str, + data: str = '', + encoding='utf-8', + async_requests_mode: Optional[bool] = None, + return_async_id=False, + timeout: float = None, + cancel_at_timeout: bool = False, + **kwargs): + + url, data = self._url_and_body( + url=url, + data=data, + encoding=encoding) + + try: + # determine async_requests_mode + if async_requests_mode is None: + async_requests_mode = self._async_requests_mode + + if not async_requests_mode: + response = self._s.request(method=method, url=url, data=data, verify=self._verify, timeout=timeout, + **kwargs) + if self._re_connect_on_session_timeout and response.status_code == 401: + self.connect() + response = self._s.request(method=method, url=url, data=data, verify=self._verify, timeout=timeout, + **kwargs) - if self._tcp_keepalive or self._connection_pool_size is not None: - self._manage_http_adapter() + else: + additional_header = {'Prefer': 'respond-async'} + http_headers = kwargs.get('headers', dict()) + http_headers.update(additional_header) + kwargs['headers'] = http_headers + response = self._s.request(method=method, url=url, data=data, verify=self._verify, timeout=timeout, + **kwargs) + # reconnect in case of session timeout + if self._re_connect_on_session_timeout and response.status_code == 401: + self.connect() + response = self._s.request(method=method, url=url, data=data, verify=self._verify, timeout=timeout, + **kwargs) + self.verify_response(response=response) + + if 'Location' not in response.headers or "'" not in response.headers['Location']: + raise ValueError(f"Failed to retrieve async_id from request {method} '{url}'") + async_id = response.headers.get('Location').split("'")[1] + if return_async_id: + return async_id + + for wait in RestService.wait_time_generator(kwargs.get('timeout', self._timeout)): + response = self.retrieve_async_response(async_id) + if response.status_code in [200, 201]: + break + time.sleep(wait) + + # all wait times consumed and still no 200 + if response.status_code not in [200, 201]: + if cancel_at_timeout or (cancel_at_timeout is None and self._cancel_at_timeout): + self.cancel_async_operation(async_id) + raise TM1pyTimeout(method=method, url=url, timeout=timeout) + + # response transformation necessary in TM1 < v11. Not required for v12 + if response.content.startswith(b"HTTP/"): + response = self.build_response_from_binary_response(response.content) + else: + # In v12 status_code must be set explicitly, as it is 200 by default + response.status_code = int(response.headers['asyncresult']) + + # verify + self.verify_response(response=response) + + # response encoding + response.encoding = encoding + + return response + + except Timeout: + if cancel_at_timeout or (cancel_at_timeout is None and self._cancel_at_timeout): + self.cancel_running_operation() + raise TM1pyTimeout(method=method, url=url, timeout=kwargs.get('timeout', self._timeout)) + + except ConnectionError as e: + # cater for issue in requests library: https://github.com/psf/requests/issues/5430 + if re.search('Read timed out', str(e), re.IGNORECASE): + if cancel_at_timeout or (cancel_at_timeout is None and self._cancel_at_timeout): + self.cancel_running_operation() + raise TM1pyTimeout(method=method, url=url, timeout=kwargs.get('timeout', self._timeout)) + + # A connection error that requires attention (e.g. SSL) + raise e def connect(self): if "session_id" in self._kwargs: @@ -279,7 +345,90 @@ def connect(self): integrated_login_service=self._kwargs.get("integrated_login_service"), integrated_login_host=self._kwargs.get("integrated_login_host"), integrated_login_delegate=self._kwargs.get("integrated_login_delegate"), - impersonate=self._kwargs.get("impersonate", None)) + impersonate=self._kwargs.get("impersonate", None), + application_client_id=self._kwargs.get("application_client_id", None), + application_client_secret=self._kwargs.get("application_client_secret", None)) + + def _construct_ibm_cloud_service_and_auth_root(self): + if not all([self._address, self._tenant, self._database]): + raise ValueError("'address', 'tenant' and 'database' must be provided to connect to TM1 > v12 in IBM Cloud") + + if not self._ssl: + raise ValueError("'ssl' must be True to connect to TM1 > v12 in IBM Cloud") + + base_url = f"https://{self._address}/api/{self._tenant}/v0/tm1/{self._database}" + auth_url = f"{base_url}/Configuration/ProductVersion/$value" + + return base_url, auth_url + + def _construct_s2s_service_and_auth_root(self) -> Tuple[str, str]: + if not all([self._instance, self._database]): + raise ValueError("'Instance' and 'Database' arguments are required for v12 authentication with 'address'") + + # URL Format: http{ssl}://{address}:{port}/{instance}/api/v1/Databases('{database}') + base_url = "http{}://{}{}/{}/api/v1/Databases('{}')".format( + 's' if self._ssl else '', + 'localhost' if len(self._address) == 0 else self._address, + f':{self._port}' if self._port is not None else '', + self._instance, + self._database) + + auth_url = 'http{}://{}{}/{}/auth/v1/session'.format( + 's' if self._ssl else '', + 'localhost' if len(self._address) == 0 else self._address, + f':{self._port}' if self._port is not None else '', + self._instance) + + return base_url, auth_url + + def _construct_v11_service_and_auth_root(self) -> Tuple[str, str]: + # URL Format: http{ssl}://{address}:{port}/api/v1 + base_url = "http{}://{}{}/api/v1".format( + 's' if self._ssl else '', + 'localhost' if len(self._address) == 0 else self._address, + f':{self._port}') + auth_url = f"{base_url}/Configuration/ProductVersion/$value" + + return base_url, auth_url + + def _construct_all_version_service_and_auth_root_from_base_url(self) -> Tuple[str, str]: + if self._address is not None: + raise ValueError('Base URL and Address can not be specified at the same time') + + # v12 requires an auth URL be provided if a base URL is specified + elif "api/v1/Databases" in self._base_url: + if not self._auth_url: + raise ValueError("Auth_url missing, when connecting to planning analytics engine and using the " + "base_url" + " you must specify a corresponding auth url") + + elif self._base_url.endswith("/api/v1"): + self._auth_url = f"{self._base_url}/Configuration/ProductVersion/$value" + + else: + self._base_url += "/api/v1" + self._auth_url = f"{self._base_url}/Configuration/ProductVersion/$value" + + return self._base_url, self._auth_url + + def _construct_service_and_auth_root(self) -> Tuple[str, str]: + """ Create the service root URL (base_url) for all versions of TM1 + If a base_url is passed then it is assumed to be the complete service root + for accessing the API + """ + if not self._auth_mode.use_v12_auth: + if self._base_url is None: + return self._construct_v11_service_and_auth_root() + else: + # if the base URL is provided when the REST service is created + return self._construct_all_version_service_and_auth_root_from_base_url() + + if self._auth_mode is AuthenticationMode.IBM_CLOUD_API_KEY: + return self._construct_ibm_cloud_service_and_auth_root() + + # If an address and database and instances are specified then we create a CP4D connection + elif self._auth_mode is AuthenticationMode.SERVICE_TO_SERVICE: + return self._construct_s2s_service_and_auth_root() def _manage_http_adapter(self): if self._tcp_keepalive: @@ -300,7 +449,7 @@ def _manage_http_adapter(self): else: adapter = HTTPAdapterWithSocketOptions( - pool_connections=int(self._connection_pool_size), + pool_connections=int(self._connection_pool_size or self.DEFAULT_CONNECTION_POOL_SIZE), pool_maxsize=int(self._connection_pool_size)) self._s.mount(self._base_url, adapter) @@ -311,85 +460,182 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): self.logout() - @httpmethod - def GET(self, url: str, data: Union[str, bytes] = '', headers: Dict = None, timeout: float = None, **kwargs): + def GET( + self, + url: str, + data: Union[str, bytes] = '', + headers: Dict = None, + async_requests_mode: bool = None, + return_async_id: bool = False, + timeout: float = None, + cancel_at_timeout: bool = False, + encoding: str = 'utf-8', + **kwargs): """ Perform a GET request against TM1 instance :param url: :param data: the payload :param headers: custom headers + :param async_requests_mode changes internal REST execution mode to avoid 60s timeout on IBM cloud + :param return_async_id: If True function will return async_id after initiation and not await the execution :param timeout: Number of seconds that the client will wait to receive the first byte. - :return: response object + :param cancel_at_timeout: Abort operation in TM1 when timeout is reached + :param encoding: + :return: response object or async_id """ - return self._s.get( - url=url, + + return self.request( + method='get', headers={**self._headers, **headers} if headers else self._headers, + url=url, data=data, - verify=self._verify, - timeout=timeout if timeout else self._timeout) + async_requests_mode=async_requests_mode, + return_async_id=return_async_id, + timeout=timeout if timeout else self._timeout, + cancel_at_timeout=cancel_at_timeout, + encoding=encoding + ) - @httpmethod - def POST(self, url: str, data: Union[str, bytes], headers: Dict = None, timeout: float = None, **kwargs): - """ POST request against the TM1 instance + def POST( + self, + url: str, + data: Union[str, bytes] = '', + headers: Dict = None, + async_requests_mode: bool = None, + return_async_id: bool = False, + timeout: float = None, + cancel_at_timeout: bool = False, + encoding: str = 'utf-8', + **kwargs): + """ Perform a GET request against TM1 instance :param url: :param data: the payload :param headers: custom headers + :param async_requests_mode changes internal REST execution mode to avoid 60s timeout on IBM cloud + :param return_async_id: If True function will return async_id after initiation and not await the execution :param timeout: Number of seconds that the client will wait to receive the first byte. - :return: response object + :param cancel_at_timeout: Abort operation in TM1 when timeout is reached + :param encoding: + :return: response object or async_id """ - return self._s.post( - url=url, + + response = self.request( + method='post', headers={**self._headers, **headers} if headers else self._headers, + url=url, data=data, - verify=self._verify, - timeout=timeout if timeout else self._timeout) + async_requests_mode=async_requests_mode, + return_async_id=return_async_id, + timeout=timeout if timeout else self._timeout, + cancel_at_timeout=cancel_at_timeout, + encoding=encoding + ) - @httpmethod - def PATCH(self, url: str, data: Union[str, bytes], headers: Dict = None, timeout: float = None, **kwargs): - """ PATCH request against the TM1 instance - :param url: String, for instance : /api/v1/Dimensions('plan_business_unit') + return response + + def PATCH( + self, + url: str, + data: Union[str, bytes] = '', + headers: Dict = None, + async_requests_mode: bool = None, + return_async_id: bool = False, + timeout: float = None, + cancel_at_timeout: bool = False, + encoding: str = 'utf-8', + **kwargs): + """ Perform a GET request against TM1 instance + :param url: :param data: the payload :param headers: custom headers + :param async_requests_mode changes internal REST execution mode to avoid 60s timeout on IBM cloud + :param return_async_id: If True function will return async_id after initiation and not await the execution :param timeout: Number of seconds that the client will wait to receive the first byte. - :return: response object + :param cancel_at_timeout: Abort operation in TM1 when timeout is reached + :param encoding: + :return: response object or async_id """ - return self._s.patch( - url=url, + + return self.request( + method='patch', headers={**self._headers, **headers} if headers else self._headers, + url=url, data=data, - verify=self._verify, - timeout=timeout if timeout else self._timeout) + async_requests_mode=async_requests_mode, + return_async_id=return_async_id, + timeout=timeout if timeout else self._timeout, + cancel_at_timeout=cancel_at_timeout, + encoding=encoding + ) - @httpmethod - def PUT(self, url: str, data: Union[str, bytes], headers: Dict = None, timeout: float = None, **kwargs): - """ PUT request against the TM1 instance - :param url: String, for instance : /api/v1/Dimensions('plan_business_unit') + def PUT( + self, + url: str, + data: Union[str, bytes] = '', + headers: Dict = None, + async_requests_mode: bool = None, + return_async_id: bool = False, + timeout: float = None, + cancel_at_timeout: bool = False, + encoding: str = 'utf-8', + **kwargs): + """ Perform a GET request against TM1 instance + :param url: :param data: the payload :param headers: custom headers + :param async_requests_mode changes internal REST execution mode to avoid 60s timeout on IBM cloud + :param return_async_id: If True function will return async_id after initiation and not await the execution :param timeout: Number of seconds that the client will wait to receive the first byte. - :return: response object + :param cancel_at_timeout: Abort operation in TM1 when timeout is reached + :param encoding: + :return: response object or async_id """ - return self._s.put( - url=url, + + return self.request( + method='put', headers={**self._headers, **headers} if headers else self._headers, + url=url, data=data, - verify=self._verify, - timeout=timeout if timeout else self._timeout) + async_requests_mode=async_requests_mode, + return_async_id=return_async_id, + timeout=timeout if timeout else self._timeout, + cancel_at_timeout=cancel_at_timeout, + encoding=encoding + ) - @httpmethod - def DELETE(self, url: str, data: Union[str, bytes], headers: Dict = None, timeout: float = None, **kwargs): - """ Delete request against TM1 instance - :param url: String, for instance : /api/v1/Dimensions('plan_business_unit') + def DELETE( + self, + url: str, + data: Union[str, bytes] = '', + headers: Dict = None, + async_requests_mode: bool = None, + return_async_id: bool = False, + timeout: float = None, + cancel_at_timeout: bool = False, + encoding: str = 'utf-8', + **kwargs): + """ Perform a GET request against TM1 instance + :param url: :param data: the payload :param headers: custom headers + :param async_requests_mode changes internal REST execution mode to avoid 60s timeout on IBM cloud + :param return_async_id: If True function will return async_id after initiation and not await the execution :param timeout: Number of seconds that the client will wait to receive the first byte. - :return: response object + :param cancel_at_timeout: Abort operation in TM1 when timeout is reached + :param encoding: + :return: response object or async_id """ - return self._s.delete( - url=url, + + return self.request( + method='delete', headers={**self._headers, **headers} if headers else self._headers, + url=url, data=data, - verify=self._verify, - timeout=timeout if timeout else self._timeout) + async_requests_mode=async_requests_mode, + return_async_id=return_async_id, + timeout=timeout if timeout else self._timeout, + cancel_at_timeout=cancel_at_timeout, + encoding=encoding + ) def logout(self, timeout: float = None, **kwargs): """ End TM1 Session and HTTP session @@ -397,7 +643,7 @@ def logout(self, timeout: float = None, **kwargs): # Easier to ask for forgiveness than permission try: # ProductVersion >= TM1 10.2.2 FP 6 - self.POST('/api/v1/ActiveSession/tm1.Close', '', headers={"Connection": "close"}, timeout=timeout, + self.POST('/ActiveSession/tm1.Close', '', headers={"Connection": "close"}, timeout=timeout, async_requests_mode=False, **kwargs) except TM1pyRestException: # ProductVersion < TM1 10.2.2 FP 6 @@ -405,22 +651,42 @@ def logout(self, timeout: float = None, **kwargs): finally: self._s.close() + @staticmethod + def _extract_tm1_session_id_from_set_cookie_header(auth_response_headers: object) -> str: + if auth_response_headers["set-cookie"]: + cookie = SimpleCookie() + # remove invalid domain from cookie + cookie.load(auth_response_headers["set-cookie"].split(";")[0]) + tm1_session_id = cookie['TM1SessionId'].value + return tm1_session_id + else: + return None + def _start_session(self, user: str, password: str, decode_b64: bool = False, namespace: str = None, gateway: str = None, cam_passport: str = None, integrated_login: bool = None, integrated_login_domain: str = None, integrated_login_service: str = None, integrated_login_host: str = None, integrated_login_delegate: bool = None, - impersonate: str = None): + impersonate: str = None, + application_client_id: str = None, application_client_secret: str = None): """ perform a simple GET request (Ask for the TM1 Version) to start a session """ # Authorization with integrated_login - if integrated_login: + if self._auth_mode == AuthenticationMode.WIA: self._s.auth = HttpNegotiateAuth( domain=integrated_login_domain, service=integrated_login_service, host=integrated_login_host, delegate=integrated_login_delegate) - # Authorization [Basic, CAM] through Headers + elif self._auth_mode == AuthenticationMode.SERVICE_TO_SERVICE: + application_auth = HTTPBasicAuth(application_client_id, application_client_secret) + self._s.auth = application_auth + + elif self._auth_mode == AuthenticationMode.IBM_CLOUD_API_KEY: + access_token = self._generate_ibm_iam_cloud_access_token() + self.add_http_header('Authorization', "Bearer " + access_token) + + # v11 authorization (Basic, CAM) through Headers else: token = self._build_authorization_token( user, @@ -431,25 +697,55 @@ def _start_session(self, user: str, password: str, decode_b64: bool = False, nam self._verify) self.add_http_header('Authorization', token) - url = '/api/v1/Configuration/ProductVersion/$value' - try: - additional_headers = dict() - if impersonate: - additional_headers["TM1-Impersonate"] = impersonate + # process additional headers + if impersonate: + if self._auth_mode.use_v12_auth: + raise TM1pyVersionDeprecationException('User Impersonation', '12') + else: + self.add_http_header('TM1-Impersonate', impersonate) + try: # skip re_connect to avoid infinite recursion in case of invalid credentials original_value = self._re_connect_on_session_timeout try: self._re_connect_on_session_timeout = False - response = self.GET(url=url, headers=additional_headers) + if self._auth_mode == AuthenticationMode.SERVICE_TO_SERVICE: + payload = {"User": user} + response = self._s.post( + url=self._auth_url, + headers=self._headers, + verify=self._verify, + timeout=self._timeout, + json=payload) + self.verify_response(response) + if 'TM1SessionId' not in self._s.cookies: + # if session had incorrect domain due to CP4D extract it and add it to cookie jar + self._s.cookies.set( + "TM1SessionId", + self._extract_tm1_session_id_from_set_cookie_header(auth_response_headers=response.headers)) + warnings.warn( + f"TM1SessionId has failed to be automatically added to the session cookies, future requests " + "using this TM1Service will use the session id extracted from the first response " + "Check the tm1-gateway domain settings are correct" + "in the container orchestrator ") + + else: + response = self._s.get( + url=self._auth_url, + headers=self._headers, + verify=self._verify, + timeout=self._timeout) + self.verify_response(response) + self._version = response.text + finally: self._re_connect_on_session_timeout = original_value if response is None: - raise ValueError(f"No response returned from URL: '{self._base_url + url}'. " + raise ValueError(f"No response returned from URL: '{self._auth_url}'. " f"Please double check your address and port number in the URL.") - self._version = response.text + finally: # After we have session cookie, drop the Authorization Header self.remove_http_header('Authorization') @@ -469,13 +765,13 @@ def is_connected(self) -> bool: Boolean """ try: - self.GET('/api/v1/Configuration/ServerName/$value') + self.GET('/Configuration/ServerName/$value') return True except: return False def set_version(self): - url = '/api/v1/Configuration/ProductVersion/$value' + url = '/Configuration/ProductVersion/$value' response = self.GET(url=url) self._version = response.text @@ -486,7 +782,7 @@ def version(self) -> str: @property def is_admin(self) -> bool: if self._is_admin is None: - response = self.GET("/api/v1/ActiveUser/Groups") + response = self.GET("/ActiveUser/Groups") self._is_admin = "ADMIN" in CaseAndSpaceInsensitiveSet( *[group["Name"] for group in response.json()["value"]]) @@ -495,14 +791,18 @@ def is_admin(self) -> bool: @property def sandboxing_disabled(self): if self._sandboxing_disabled is None: - value = self.GET("/api/v1/ActiveConfiguration/Administration/DisableSandboxing/$value") + value = self.GET("/ActiveConfiguration/Administration/DisableSandboxing/$value") self._sandboxing_disabled = value return self._sandboxing_disabled @property def session_id(self) -> str: - return self._s.cookies["TM1SessionId"] + try: + return self._s.cookies['TM1SessionId'] + # case v12 + except KeyError: + return self._s.cookies['paSession'] @staticmethod def translate_to_boolean(value) -> bool: @@ -607,12 +907,12 @@ def add_compact_json_header(self) -> str: return original_header def retrieve_async_response(self, async_id: str, **kwargs) -> Response: - url = self._base_url + f"/api/v1/_async('{async_id}')" - return self._s.get(url, **kwargs) + url = self._base_url + f"/_async('{async_id}')" + return self._s.get(url, verify=self._verify, **kwargs) def cancel_async_operation(self, async_id: str, **kwargs): - url = self._base_url + f"/api/v1/_async('{async_id}')" - response = self._s.delete(url, **kwargs) + url = self._base_url + f"/_async('{async_id}')" + response = self._s.delete(url, verify=self._verify, **kwargs) self.verify_response(response) def cancel_running_operation(self): @@ -654,7 +954,7 @@ def urllib3_response_from_bytes(data: bytes) -> HTTPResponse: return urllib3_http_response @staticmethod - def build_response_from_raw_bytes(data: bytes) -> Response: + def build_response_from_binary_response(data: bytes) -> Response: urllib_response = RestService.urllib3_response_from_bytes(data) adapter = HTTPAdapter() @@ -684,6 +984,48 @@ def _determine_ssl_based_on_base_url(self) -> bool: else: raise ValueError(f"Invalid base_url: '{self._base_url}'") + def _determine_auth_mode(self) -> AuthenticationMode: + if not any([ + self._auth_url, + self._instance, + self._database, + self._api_key, + self._iam_url, + self._pa_url, + self._tenant + ]): + # v11 + if not any([self._kwargs.get('namespace', None), self._kwargs.get('gateway', None)]): + return AuthenticationMode.BASIC + + if self._kwargs.get('gateway', None): + return AuthenticationMode.CAM_SSO + + if self._kwargs.get("integrated_login", False): + return AuthenticationMode.WIA + + return AuthenticationMode.CAM + + # v12 + if self._iam_url: + return AuthenticationMode.IBM_CLOUD_API_KEY + + return AuthenticationMode.SERVICE_TO_SERVICE + + def _generate_ibm_iam_cloud_access_token(self) -> str: + if not all([self._iam_url, self._api_key]): + raise ValueError("'iam_url' and 'api_key' must be provided to generate access token from IBM Cloud") + + payload = f'grant_type=urn%3Aibm%3Aparams%3Aoauth%3Agrant-type%3Aapikey&apikey={self._api_key}' + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + } + response = requests.request("POST", self._iam_url, headers=headers, data=payload) + if 'access_token' not in response.json(): + raise RuntimeError(f"Failed to generate access_token from URL: '{self._iam_url}'") + return response.json()["access_token"] + class BytesIOSocket: """ used in urllib3_response_from_bytes method to construct urllib3 response from raw bytes diff --git a/TM1py/Services/SandboxService.py b/TM1py/Services/SandboxService.py index d60f23a2..14b037c6 100644 --- a/TM1py/Services/SandboxService.py +++ b/TM1py/Services/SandboxService.py @@ -23,7 +23,7 @@ def get(self, sandbox_name: str, **kwargs) -> Sandbox: :param sandbox_name: str :return: instance of TM1py.Sandbox """ - url = format_url("/api/v1/Sandboxes('{}')", sandbox_name) + url = format_url("/Sandboxes('{}')", sandbox_name) response = self._rest.GET(url=url, **kwargs) sandbox = Sandbox.from_json(response.text) return sandbox @@ -33,7 +33,7 @@ def get_all(self, **kwargs) -> List[Sandbox]: :return: List of TM1py.Sandbox instances """ - url = "/api/v1/Sandboxes?$select=Name,IncludeInSandboxDimension,IsLoaded,IsActive,IsQueued" + url = "/Sandboxes?$select=Name,IncludeInSandboxDimension,IsLoaded,IsActive,IsQueued" response = self._rest.GET(url, **kwargs) sandboxes = [ Sandbox.from_dict(sandbox_as_dict=sandbox) @@ -47,7 +47,7 @@ def get_all_names(self, **kwargs) -> List[str]: :param kwargs: :return: """ - url = "/api/v1/Sandboxes?$select=Name" + url = "/Sandboxes?$select=Name" response = self._rest.GET(url, **kwargs) return [entry["Name"] for entry in response.json()["value"]] @@ -57,7 +57,7 @@ def create(self, sandbox: Sandbox, **kwargs) -> Response: :param sandbox: Sandbox :return: response """ - url = "/api/v1/Sandboxes" + url = "/Sandboxes" return self._rest.POST(url=url, data=sandbox.body, **kwargs) def update(self, sandbox: Sandbox, **kwargs) -> Response: @@ -66,7 +66,7 @@ def update(self, sandbox: Sandbox, **kwargs) -> Response: :param sandbox: :return: response """ - url = format_url("/api/v1/Sandboxes('{}')", sandbox.name) + url = format_url("/Sandboxes('{}')", sandbox.name) return self._rest.PATCH(url=url, data=sandbox.body, **kwargs) def delete(self, sandbox_name: str, **kwargs) -> Response: @@ -75,7 +75,7 @@ def delete(self, sandbox_name: str, **kwargs) -> Response: :param sandbox_name: :return: response """ - url = format_url("/api/v1/Sandboxes('{}')", sandbox_name) + url = format_url("/Sandboxes('{}')", sandbox_name) return self._rest.DELETE(url, **kwargs) def publish(self, sandbox_name: str, **kwargs) -> Response: @@ -84,7 +84,7 @@ def publish(self, sandbox_name: str, **kwargs) -> Response: :param sandbox_name: str :return: response """ - url = format_url("/api/v1/Sandboxes('{}')/tm1.Publish", sandbox_name) + url = format_url("/Sandboxes('{}')/tm1.Publish", sandbox_name) return self._rest.POST(url=url, **kwargs) def reset(self, sandbox_name: str, **kwargs) -> Response: @@ -93,7 +93,7 @@ def reset(self, sandbox_name: str, **kwargs) -> Response: :param sandbox_name: str :return: response """ - url = format_url("/api/v1/Sandboxes('{}')/tm1.DiscardChanges", sandbox_name) + url = format_url("/Sandboxes('{}')/tm1.DiscardChanges", sandbox_name) return self._rest.POST(url=url, **kwargs) def merge( @@ -110,7 +110,7 @@ def merge( :param clean_after: bool: Reset source sandbox after merging :return: response """ - url = format_url("/api/v1/Sandboxes('{}')/tm1.Merge", source_sandbox_name) + url = format_url("/Sandboxes('{}')/tm1.Merge", source_sandbox_name) payload = dict() payload["Target@odata.bind"] = format_url( "Sandboxes('{}')", target_sandbox_name @@ -124,7 +124,7 @@ def exists(self, sandbox_name: str, **kwargs) -> bool: :param sandbox_name: String :return: bool """ - url = format_url("/api/v1/Sandboxes('{}')", sandbox_name) + url = format_url("/Sandboxes('{}')", sandbox_name) return self._exists(url, **kwargs) def load(self, sandbox_name: str, **kwargs) -> Response: @@ -133,7 +133,7 @@ def load(self, sandbox_name: str, **kwargs) -> Response: :param sandbox_name: str :return: response """ - url = format_url("/api/v1/Sandboxes('{}')/tm1.Load", sandbox_name) + url = format_url("/Sandboxes('{}')/tm1.Load", sandbox_name) return self._rest.POST(url=url, **kwargs) def unload(self, sandbox_name: str, **kwargs) -> Response: @@ -142,5 +142,5 @@ def unload(self, sandbox_name: str, **kwargs) -> Response: :param sandbox_name: str :return: response """ - url = format_url("/api/v1/Sandboxes('{}')/tm1.Unload", sandbox_name) + url = format_url("/Sandboxes('{}')/tm1.Unload", sandbox_name) return self._rest.POST(url=url, **kwargs) diff --git a/TM1py/Services/SecurityService.py b/TM1py/Services/SecurityService.py index 4f6e4927..d7163bac 100644 --- a/TM1py/Services/SecurityService.py +++ b/TM1py/Services/SecurityService.py @@ -32,7 +32,7 @@ def create_user(self, user: User, **kwargs) -> Response: :param user: instance of TM1py.User :return: response """ - url = '/api/v1/Users' + url = '/Users' return self._rest.POST(url, user.body, **kwargs) @require_admin @@ -42,7 +42,7 @@ def create_group(self, group_name: str, **kwargs) -> Response: :param group_name: :return: """ - url = '/api/v1/Groups' + url = '/Groups' return self._rest.POST(url, json.dumps({"Name": group_name}), **kwargs) def get_user(self, user_name: str, **kwargs) -> User: @@ -53,7 +53,7 @@ def get_user(self, user_name: str, **kwargs) -> User: """ user_name = self.determine_actual_user_name(user_name, **kwargs) url = format_url( - "/api/v1/Users('{}')?$select=Name,FriendlyName,Password,Type,Enabled&$expand=Groups", + "/Users('{}')?$select=Name,FriendlyName,Password,Type,Enabled&$expand=Groups", user_name) response = self._rest.GET(url, **kwargs) return User.from_dict(response.json()) @@ -63,7 +63,7 @@ def get_current_user(self, **kwargs) -> User: :return: instance of TM1py.User """ - url = "/api/v1/ActiveUser?$select=Name,FriendlyName,Password,Type,Enabled&$expand=Groups" + url = "/ActiveUser?$select=Name,FriendlyName,Password,Type,Enabled&$expand=Groups" response = self._rest.GET(url, **kwargs) return User.from_dict(response.json()) @@ -78,11 +78,11 @@ def update_user(self, user: User, **kwargs) -> Response: for current_group in self.get_groups(user.name, **kwargs): if current_group not in user.groups: self.remove_user_from_group(current_group, user.name, **kwargs) - url = format_url("/api/v1/Users('{}')", user.name) + url = format_url("/Users('{}')", user.name) return self._rest.PATCH(url, user.body, **kwargs) def update_user_password(self, user_name: str, password: str, **kwargs) -> Response: - url = format_url("/api/v1/Users('{}')", user_name) + url = format_url("/Users('{}')", user_name) body = {"Password": password} return self._rest.PATCH(url, json.dumps(body), **kwargs) @@ -94,7 +94,7 @@ def delete_user(self, user_name: str, **kwargs) -> Response: :return: response """ user_name = self.determine_actual_user_name(user_name, **kwargs) - url = format_url("/api/v1/Users('{}')", user_name) + url = format_url("/Users('{}')", user_name) return self._rest.DELETE(url, **kwargs) @require_admin @@ -105,7 +105,7 @@ def delete_group(self, group_name: str, **kwargs) -> Response: :return: """ group_name = self.determine_actual_group_name(group_name, **kwargs) - url = format_url("/api/v1/Groups('{}')", group_name) + url = format_url("/Groups('{}')", group_name) return self._rest.DELETE(url, **kwargs) def get_all_users(self, **kwargs): @@ -113,7 +113,7 @@ def get_all_users(self, **kwargs): :return: List of TM1py.User instances """ - url = '/api/v1/Users?$select=Name,FriendlyName,Password,Type,Enabled&$expand=Groups' + url = '/Users?$select=Name,FriendlyName,Password,Type,Enabled&$expand=Groups' response = self._rest.GET(url, **kwargs) users = [User.from_dict(user) for user in response.json()['value']] return users @@ -123,7 +123,7 @@ def get_all_user_names(self, **kwargs): :return: List of TM1py.User instances """ - url = '/api/v1/Users?select=Name' + url = '/Users?select=Name' response = self._rest.GET(url, **kwargs) users = [user["Name"] for user in response.json()['value']] return users @@ -135,7 +135,7 @@ def get_users_from_group(self, group_name: str, **kwargs): :return: List of TM1py.User instances """ url = format_url( - "/api/v1/Groups('{}')?$expand=Users($select=Name,FriendlyName,Password,Type,Enabled;$expand=Groups)", + "/Groups('{}')?$expand=Users($select=Name,FriendlyName,Password,Type,Enabled;$expand=Groups)", group_name) response = self._rest.GET(url, **kwargs) users = [User.from_dict(user) for user in response.json()['Users']] @@ -147,7 +147,7 @@ def get_user_names_from_group(self, group_name: str, **kwargs) -> List[str]: :param group_name: :return: List of strings """ - url = format_url("/api/v1/Groups('{}')?$expand=Users($expand=Groups)", group_name) + url = format_url("/Groups('{}')?$expand=Users($expand=Groups)", group_name) response = self._rest.GET(url, **kwargs) users = [user["Name"] for user in response.json()['Users']] return users @@ -159,7 +159,7 @@ def get_groups(self, user_name: str, **kwargs) -> List[str]: :return: List of strings """ user_name = self.determine_actual_user_name(user_name, **kwargs) - url = format_url("/api/v1/Users('{}')/Groups", user_name) + url = format_url("/Users('{}')/Groups", user_name) response = self._rest.GET(url, **kwargs) return [group['Name'] for group in response.json()['value']] @@ -172,7 +172,7 @@ def add_user_to_groups(self, user_name: str, groups: Iterable[str], **kwargs) -> :return: response """ user_name = self.determine_actual_user_name(user_name, **kwargs) - url = format_url("/api/v1/Users('{}')", user_name) + url = format_url("/Users('{}')", user_name) body = { "Name": user_name, "Groups@odata.bind": [ @@ -192,7 +192,7 @@ def remove_user_from_group(self, group_name: str, user_name: str, **kwargs) -> R """ user_name = self.determine_actual_user_name(user_name, **kwargs) group_name = self.determine_actual_group_name(group_name, **kwargs) - url = format_url("/api/v1/Users('{}')/Groups?$id=Groups('{}')", user_name, group_name) + url = format_url("/Users('{}')/Groups?$id=Groups('{}')", user_name, group_name) return self._rest.DELETE(url, **kwargs) def get_all_groups(self, **kwargs) -> List[str]: @@ -200,7 +200,7 @@ def get_all_groups(self, **kwargs) -> List[str]: :return: List of strings """ - url = '/api/v1/Groups?$select=Name' + url = '/Groups?$select=Name' response = self._rest.GET(url, **kwargs) groups = [entry['Name'] for entry in response.json()['value']] return groups @@ -213,11 +213,11 @@ def security_refresh(self, **kwargs) -> Response: return process_service.execute_ti_code(ti, **kwargs) def user_exists(self, user_name: str, **kwargs) -> bool: - url = format_url("/api/v1/Users('{}')", user_name) + url = format_url("/Users('{}')", user_name) return self._exists(url, **kwargs) def group_exists(self, group_name: str, **kwargs) -> bool: - url = format_url("/api/v1/Groups('{}')", group_name) + url = format_url("/Groups('{}')", group_name) return self._exists(url, **kwargs) def get_custom_security_groups(self, **kwargs) -> List[str]: diff --git a/TM1py/Services/ServerService.py b/TM1py/Services/ServerService.py index 931dbc79..282d6f28 100644 --- a/TM1py/Services/ServerService.py +++ b/TM1py/Services/ServerService.py @@ -4,6 +4,7 @@ import json from collections.abc import Iterable from datetime import datetime +from enum import Enum from typing import Dict, Optional import pytz @@ -14,7 +15,16 @@ from TM1py.Services.RestService import RestService from TM1py.Utils import format_url from TM1py.Utils.Utils import CaseAndSpaceInsensitiveDict, CaseAndSpaceInsensitiveSet, require_admin, require_version, \ - decohints + decohints, deprecated_in_version + + +class LogLevel(Enum): + FATAL = "fatal" + ERROR = "error" + WARNING = "warning" + INFO = "info" + DEBUG = "debug" + OFF = "off" @decohints @@ -49,9 +59,10 @@ def __init__(self, rest: RestService): self.mlog_last_delta_request = None self.alog_last_delta_request = None + @deprecated_in_version(version="12.0.0") @odata_track_changes_header def initialize_transaction_log_delta_requests(self, filter=None, **kwargs): - url = "/api/v1/TailTransactionLog()" + url = "/TailTransactionLog()" if filter: url += "?$filter={}".format(filter) response = self._rest.GET(url=url, **kwargs) @@ -59,17 +70,19 @@ def initialize_transaction_log_delta_requests(self, filter=None, **kwargs): self.tlog_last_delta_request = response.text[response.text.rfind( "TransactionLogEntries/!delta('"):-2] + @deprecated_in_version(version="12.0.0") @odata_track_changes_header def execute_transaction_log_delta_request(self, **kwargs) -> Dict: response = self._rest.GET( - url="/api/v1/" + self.tlog_last_delta_request, **kwargs) + url="/" + self.tlog_last_delta_request, **kwargs) self.tlog_last_delta_request = response.text[response.text.rfind( "TransactionLogEntries/!delta('"):-2] return response.json()['value'] + @deprecated_in_version(version="12.0.0") @odata_track_changes_header def initialize_audit_log_delta_requests(self, filter=None, **kwargs): - url = "/api/v1/TailAuditLog()" + url = "/TailAuditLog()" if filter: url += "?$filter={}".format(filter) response = self._rest.GET(url=url, **kwargs) @@ -77,17 +90,19 @@ def initialize_audit_log_delta_requests(self, filter=None, **kwargs): self.alog_last_delta_request = response.text[response.text.rfind( "AuditLogEntries/!delta('"):-2] + @deprecated_in_version(version="12.0.0") @odata_track_changes_header def execute_audit_log_delta_request(self, **kwargs) -> Dict: response = self._rest.GET( - url="/api/v1/" + self.alog_last_delta_request, **kwargs) + url="/" + self.alog_last_delta_request, **kwargs) self.alog_last_delta_request = response.text[response.text.rfind( "AuditLogEntries/!delta('"):-2] return response.json()['value'] + @deprecated_in_version(version="12.0.0") @odata_track_changes_header def initialize_message_log_delta_requests(self, filter=None, **kwargs): - url = "/api/v1/TailMessageLog()" + url = "/TailMessageLog()" if filter: url += "?$filter={}".format(filter) response = self._rest.GET(url=url, **kwargs) @@ -95,14 +110,16 @@ def initialize_message_log_delta_requests(self, filter=None, **kwargs): self.mlog_last_delta_request = response.text[response.text.rfind( "MessageLogEntries/!delta('"):-2] + @deprecated_in_version(version="12.0.0") @odata_track_changes_header def execute_message_log_delta_request(self, **kwargs) -> Dict: response = self._rest.GET( - url="/api/v1/" + self.mlog_last_delta_request, **kwargs) + url="/" + self.mlog_last_delta_request, **kwargs) self.mlog_last_delta_request = response.text[response.text.rfind( "MessageLogEntries/!delta('"):-2] return response.json()['value'] + @deprecated_in_version(version="12.0.0") @require_admin def get_message_log_entries(self, reverse: bool = True, since: datetime = None, until: datetime = None, top: int = None, logger: str = None, @@ -127,7 +144,7 @@ def get_message_log_entries(self, reverse: bool = True, since: datetime = None, "'msg_contains_operator' must be either 'AND' or 'OR'") reverse = 'desc' if reverse else 'asc' - url = '/api/v1/MessageLogEntries?$orderby=TimeStamp {}'.format(reverse) + url = '/MessageLogEntries?$orderby=TimeStamp {}'.format(reverse) if since or until or logger or level or msg_contains: log_filters = [] @@ -204,6 +221,7 @@ def utc_localize_time(timestamp): timestamp_utc = timestamp.astimezone(pytz.utc) return timestamp_utc + @deprecated_in_version(version="12.0.0") @require_admin def get_transaction_log_entries(self, reverse: bool = True, user: str = None, cube: str = None, since: datetime = None, until: datetime = None, top: int = None, @@ -225,7 +243,7 @@ def get_transaction_log_entries(self, reverse: bool = True, user: str = None, cu raise NotImplementedError("Feature expected in upcoming releases of TM1, TM1py") reverse = 'desc' if reverse else 'asc' - url = '/api/v1/TransactionLogEntries?$orderby=TimeStamp {} '.format(reverse) + url = '/TransactionLogEntries?$orderby=TimeStamp {} '.format(reverse) # filter on user, cube, time and elements if any([user, cube, since, until, element_tuple_filter, element_position_filter]): @@ -257,6 +275,7 @@ def get_transaction_log_entries(self, reverse: bool = True, user: str = None, cu return response.json()['value'] @require_admin + @deprecated_in_version(version="12.0.0") @require_version(version="11.6") def get_audit_log_entries(self, user: str = None, object_type: str = None, object_name: str = None, since: datetime = None, until: datetime = None, top: int = None, **kwargs) -> Dict: @@ -270,7 +289,7 @@ def get_audit_log_entries(self, user: str = None, object_type: str = None, objec :return: """ - url = '/api/v1/AuditLogEntries?$expand=AuditDetails' + url = '/AuditLogEntries?$expand=AuditDetails' # filter on user, object_type, object_name and time if any([user, object_type, object_name, since, until]): log_filters = [] @@ -302,6 +321,7 @@ def get_audit_log_entries(self, user: str = None, object_type: str = None, objec return response.json()['value'] @require_admin + @deprecated_in_version(version="12.0.0") def get_last_process_message_from_messagelog(self, process_name: str, **kwargs) -> Optional[str]: """ Get the latest message log entry for a process @@ -309,7 +329,7 @@ def get_last_process_message_from_messagelog(self, process_name: str, **kwargs) :return: String - the message, for instance: "Ausführung normal beendet, verstrichene Zeit 0.03 Sekunden" """ url = format_url( - "/api/v1/MessageLog()?$orderby='TimeStamp'&$filter=Logger eq 'TM1.Process' and contains(Message, '{}')", + "/MessageLog()?$orderby='TimeStamp'&$filter=Logger eq 'TM1.Process' and contains(Message, '{}')", process_name) response = self._rest.GET(url=url, **kwargs) response_as_list = response.json()['value'] @@ -323,7 +343,7 @@ def get_server_name(self, **kwargs) -> str: :Returns: String, the server name """ - url = '/api/v1/Configuration/ServerName/$value' + url = '/Configuration/ServerName/$value' return self._rest.GET(url, **kwargs).text def get_product_version(self, **kwargs) -> str: @@ -332,19 +352,21 @@ def get_product_version(self, **kwargs) -> str: :Returns: String, the version """ - url = '/api/v1/Configuration/ProductVersion/$value' + url = '/Configuration/ProductVersion/$value' return self._rest.GET(url, **kwargs).text + @deprecated_in_version(version="12.0.0") def get_admin_host(self, **kwargs) -> str: - url = '/api/v1/Configuration/AdminHost/$value' + url = '/Configuration/AdminHost/$value' return self._rest.GET(url, **kwargs).text + @deprecated_in_version(version="12.0.0") def get_data_directory(self, **kwargs) -> str: - url = '/api/v1/Configuration/DataBaseDirectory/$value' + url = '/Configuration/DataBaseDirectory/$value' return self._rest.GET(url, **kwargs).text def get_configuration(self, **kwargs) -> Dict: - url = '/api/v1/Configuration' + url = '/Configuration' config = self._rest.GET(url, **kwargs).json() del config["@odata.context"] return config @@ -355,7 +377,7 @@ def get_static_configuration(self, **kwargs) -> Dict: :return: config as dictionary """ - url = '/api/v1/StaticConfiguration' + url = '/StaticConfiguration' config = self._rest.GET(url, **kwargs).json() del config["@odata.context"] return config @@ -366,11 +388,20 @@ def get_active_configuration(self, **kwargs) -> Dict: :return: config as dictionary """ - url = '/api/v1/ActiveConfiguration' + url = '/ActiveConfiguration' config = self._rest.GET(url, **kwargs).json() del config["@odata.context"] return config + def get_api_metadata(self, **kwargs): + """ Read effective(!) TM1 config settings as dictionary from TM1 Server + + :return: config as dictionary + """ + url = '/$metadata' + metadata = self._rest.GET(url, **kwargs).content.decode("utf-8") + return json.loads(metadata) + @require_admin def update_static_configuration(self, configuration: Dict) -> Response: """ Update the .cfg file and triggers TM1 to re-read the file. @@ -378,9 +409,10 @@ def update_static_configuration(self, configuration: Dict) -> Response: :param configuration: :return: Response """ - url = '/api/v1/StaticConfiguration' + url = '/StaticConfiguration' return self._rest.PATCH(url, json.dumps(configuration)) + @deprecated_in_version(version="12.0.0") @require_admin def save_data(self, **kwargs) -> Response: from TM1py.Services import ProcessService @@ -418,3 +450,27 @@ def activate_audit_log(self): def deactivate_audit_log(self): config = {'Administration': {'AuditLog': {'Enable': False}}} self.update_static_configuration(config) + + @require_admin + def update_message_logger_level(self, logger, level): + ''' + Updates tm1 message log levels + :param logger: + :param level: + :return: + ''' + + payload = {"Level": level} + url = f"/Loggers('{logger}')" + return self._rest.PATCH(url, json.dumps(payload)) + + @require_admin + def get_all_message_logger_level(self): + ''' + Get tm1 message log levels + :param logger: + :param level: + :return: + ''' + url = f"/Loggers" + return self._rest.GET(url).content diff --git a/TM1py/Services/SubsetService.py b/TM1py/Services/SubsetService.py index 5c80abaf..d932a106 100644 --- a/TM1py/Services/SubsetService.py +++ b/TM1py/Services/SubsetService.py @@ -30,7 +30,7 @@ def create(self, subset: Subset, private: bool = False, **kwargs) -> Response: """ subsets = "PrivateSubsets" if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}", + "/Dimensions('{}')/Hierarchies('{}')/{}", subset.dimension_name, subset.hierarchy_name, subsets) @@ -52,7 +52,7 @@ def get(self, subset_name: str, dimension_name: str, hierarchy_name: str = None, hierarchy_name = dimension_name subsets = "PrivateSubsets" if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}('{}')?$expand=Hierarchy($select=Dimension,Name)," + "/Dimensions('{}')/Hierarchies('{}')/{}('{}')?$expand=Hierarchy($select=Dimension,Name)," "Elements($select=Name)&$select=*,Alias", dimension_name, hierarchy_name, subsets, subset_name) response = self._rest.GET(url=url, **kwargs) return Subset.from_dict(response.json()) @@ -70,7 +70,7 @@ def get_all_names(self, dimension_name: str, hierarchy_name: str = None, private subsets = "PrivateSubsets" if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}?$select=Name", + "/Dimensions('{}')/Hierarchies('{}')/{}?$select=Name", dimension_name, hierarchy_name, subsets) response = self._rest.GET(url=url, **kwargs) subsets = response.json()['value'] @@ -92,7 +92,7 @@ def update(self, subset: Subset, private: bool = False, **kwargs) -> Response: **kwargs) subsets = "PrivateSubsets" if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}('{}')", + "/Dimensions('{}')/Hierarchies('{}')/{}('{}')", subset.dimension_name, subset.hierarchy_name, subsets, subset.name) return self._rest.PATCH(url=url, data=subset.body, **kwargs) @@ -113,7 +113,7 @@ def make_static(self, subset_name: str, dimension_name: str, hierarchy_name: str payload['MakePrivate'] = True if private else False payload['MakeStatic'] = True subsets = "PrivateSubsets" if private else "Subsets" - url = format_url("/api/v1/Dimensions('{}')/Hierarchies('{}')/{}('{}')/tm1.SaveAs", dimension_name, + url = format_url("/Dimensions('{}')/Hierarchies('{}')/{}('{}')/tm1.SaveAs", dimension_name, hierarchy_name, subsets, subset_name) return self._rest.POST(url=url, data=json.dumps(payload)) @@ -147,7 +147,7 @@ def delete(self, subset_name: str, dimension_name: str, hierarchy_name: str = No hierarchy_name = hierarchy_name if hierarchy_name else dimension_name subsets = "PrivateSubsets" if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}('{}')", + "/Dimensions('{}')/Hierarchies('{}')/{}('{}')", dimension_name, hierarchy_name, subsets, subset_name) response = self._rest.DELETE(url=url, **kwargs) return response @@ -165,7 +165,7 @@ def exists(self, subset_name: str, dimension_name: str, hierarchy_name: str = No hierarchy_name = hierarchy_name if hierarchy_name else dimension_name subset_type = 'PrivateSubsets' if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}('{}')", + "/Dimensions('{}')/Hierarchies('{}')/{}('{}')", dimension_name, hierarchy_name, subset_type, subset_name) return self._exists(url, **kwargs) @@ -173,7 +173,7 @@ def delete_elements_from_static_subset(self, dimension_name: str, hierarchy_name private: bool, **kwargs) -> Response: subsets = "PrivateSubsets" if private else "Subsets" url = format_url( - "/api/v1/Dimensions('{}')/Hierarchies('{}')/{}('{}')/Elements/$ref", + "/Dimensions('{}')/Hierarchies('{}')/{}('{}')/Elements/$ref", dimension_name, hierarchy_name, subsets, subset_name) return self._rest.DELETE(url=url, **kwargs) diff --git a/TM1py/Services/ViewService.py b/TM1py/Services/ViewService.py index 2fed4dc4..7647cb9b 100644 --- a/TM1py/Services/ViewService.py +++ b/TM1py/Services/ViewService.py @@ -31,7 +31,7 @@ def create(self, view: Union[MDXView, NativeView], private: bool = False, **kwar :return: Response """ view_type = "PrivateViews" if private else "Views" - url = format_url("/api/v1/Cubes('{}')/{}", view.cube, view_type) + url = format_url("/Cubes('{}')/{}", view.cube, view_type) return self._rest.POST(url, view.body, **kwargs) def exists(self, cube_name: str, view_name: str, private: bool = None, **kwargs): @@ -43,7 +43,7 @@ def exists(self, cube_name: str, view_name: str, private: bool = None, **kwargs) :return boolean tuple """ - url_template = "/api/v1/Cubes('{}')/{}('{}')" + url_template = "/Cubes('{}')/{}('{}')" if private is not None: url = format_url(url_template, cube_name, "PrivateViews" if private else "Views", view_name) return self._exists(url, **kwargs) @@ -63,7 +63,7 @@ def exists(self, cube_name: str, view_name: str, private: bool = None, **kwargs) def get(self, cube_name: str, view_name: str, private: bool = False, **kwargs) -> View: view_type = "PrivateViews" if private else "Views" - url = format_url("/api/v1/Cubes('{}')/{}('{}')?$expand=*", cube_name, view_type, view_name) + url = format_url("/Cubes('{}')/{}('{}')?$expand=*", cube_name, view_type, view_name) response = self._rest.GET(url, **kwargs) view_as_dict = response.json() if "MDX" in view_as_dict: @@ -82,7 +82,7 @@ def get_native_view(self, cube_name: str, view_name: str, private=False, **kwarg """ view_type = "PrivateViews" if private else "Views" url = format_url( - "/api/v1/Cubes('{}')/{}('{}')?$expand=" + "/Cubes('{}')/{}('{}')?$expand=" "tm1.NativeView/Rows/Subset($expand=Hierarchy($select=Name;" "$expand=Dimension($select=Name)),Elements($select=Name);" "$select=Expression,UniqueName,Name, Alias), " @@ -108,7 +108,7 @@ def get_mdx_view(self, cube_name: str, view_name: str, private: bool = False, ** :return: instance of TM1py.MDXView """ view_type = 'PrivateViews' if private else 'Views' - url = format_url("/api/v1/Cubes('{}')/{}('{}')?$expand=*", cube_name, view_type, view_name) + url = format_url("/Cubes('{}')/{}('{}')?$expand=*", cube_name, view_type, view_name) response = self._rest.GET(url, **kwargs) mdx_view = MDXView.from_json(view_as_json=response.text) return mdx_view @@ -125,7 +125,7 @@ def get_all(self, cube_name: str, include_elements: bool = True, **kwargs) -> Tu private_views, public_views = [], [] for view_type in ('PrivateViews', 'Views'): url = format_url( - "/api/v1/Cubes('{}')/{}?$expand=" + "/Cubes('{}')/{}?$expand=" "tm1.NativeView/Rows/Subset($expand=Hierarchy($select=Name;" "$expand=Dimension($select=Name)),Elements($select=Name{});" "$select=Expression,UniqueName,Name, Alias), " @@ -158,7 +158,7 @@ def get_all_names(self, cube_name: str, **kwargs) -> Tuple[List[str], List[str]] """ private_views, public_views = [], [] for view_type in ('PrivateViews', 'Views'): - url = format_url("/api/v1/Cubes('{}')/{}?$select=Name", cube_name, view_type) + url = format_url("/Cubes('{}')/{}?$select=Name", cube_name, view_type) response = self._rest.GET(url, **kwargs) response_as_list = response.json()['value'] @@ -178,7 +178,7 @@ def update(self, view: Union[MDXView, NativeView], private: bool = False, **kwar :return: response """ view_type = 'PrivateViews' if private else 'Views' - url = format_url("/api/v1/Cubes('{}')/{}('{}')", view.cube, view_type, view.name) + url = format_url("/Cubes('{}')/{}('{}')", view.cube, view_type, view.name) response = self._rest.PATCH(url, view.body, **kwargs) return response @@ -205,7 +205,7 @@ def delete(self, cube_name: str, view_name: str, private: bool = False, **kwargs :return: String, the response """ view_type = 'PrivateViews' if private else 'Views' - url = format_url("/api/v1/Cubes('{}')/{}('{}')", cube_name, view_type, view_name) + url = format_url("/Cubes('{}')/{}('{}')", cube_name, view_type, view_name) response = self._rest.DELETE(url, **kwargs) return response @@ -226,10 +226,10 @@ def search_subset_in_native_views(self, dimension_name: str = None, subset_name: element_filter = ";$top=0" if not include_elements else "" if cube_name: base_url = format_url( - "/api/v1/Cubes?$select=Name&$filter=replace(tolower(Name),' ', '') eq '{}'", + "/Cubes?$select=Name&$filter=replace(tolower(Name),' ', '') eq '{}'", cube_name.lower().replace(' ', '')) else: - base_url = "/api/v1/Cubes?$select=Name" + base_url = "/Cubes?$select=Name" private_views, public_views = [], [] for view_type in ('PrivateViews', 'Views'): @@ -272,7 +272,7 @@ def search_subset_in_native_views(self, dimension_name: str = None, subset_name: return private_views, public_views def is_mdx_view(self, cube_name: str, view_name: str, private=False, **kwargs): - url_template = "/api/v1/Cubes('{}')/{}('{}')?select=Name" + url_template = "/Cubes('{}')/{}('{}')?select=Name" if private is not None: url = format_url(url_template, cube_name, "PrivateViews" if private else "Views", view_name) diff --git a/TM1py/Services/__init__.py b/TM1py/Services/__init__.py index 8a7a0af0..f0d57361 100644 --- a/TM1py/Services/__init__.py +++ b/TM1py/Services/__init__.py @@ -18,3 +18,4 @@ from TM1py.Services.ViewService import ViewService from TM1py.Services.GitService import GitService from TM1py.Services.TM1Service import TM1Service +from TM1py.Services.ManageService import ManageService diff --git a/TM1py/Utils/Utils.py b/TM1py/Utils/Utils.py index 7b9e5837..96a2c71b 100644 --- a/TM1py/Utils/Utils.py +++ b/TM1py/Utils/Utils.py @@ -12,12 +12,11 @@ from io import StringIO from typing import Any, Dict, List, Tuple, Iterable, Optional, Generator, Union, Callable from urllib.parse import unquote - import requests from mdxpy import MdxBuilder, Member from requests.adapters import HTTPAdapter -from TM1py.Exceptions.Exceptions import TM1pyVersionException, TM1pyNotAdminException +from TM1py.Exceptions.Exceptions import TM1pyVersionException, TM1pyNotAdminException, TM1pyVersionDeprecationException try: import pandas as pd @@ -64,6 +63,22 @@ def wrapper(self, *args, **kwargs): return wrap +@decohints +def deprecated_in_version(version): + """ Higher order function to check required version for TM1py function + """ + + def wrap(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + if verify_version(required_version=version, version=self.version): + raise TM1pyVersionDeprecationException(func.__name__, version) + return func(self, *args, **kwargs) + + return wrapper + + return wrap + @decohints def require_pandas(func): @@ -78,6 +93,7 @@ def wrapper(self, *args, **kwargs): return wrapper + def get_all_servers_from_adminhost(adminhost='localhost', port=None, use_ssl=False) -> List: from TM1py.Objects import Server """ Ask Adminhost for TM1 Servers @@ -127,7 +143,7 @@ def create_server_on_adminhost(adminhost: str = 'localhost', server_as_dict: Dic if not adminhost: adminhost = 'localhost' - url = f"http://{adminhost}:5895/api/v1/Servers" + url = f"http://{adminhost}:5895/Servers" response = requests.post(url, data=json.dumps(server_as_dict), headers={'Content-Type': 'application/json'}) response.raise_for_status() @@ -141,7 +157,7 @@ def delete_server_on_adminhost(adminhost: str = None, server_name: str = None): if not adminhost: adminhost = 'localhost' - url = f"http://{adminhost}:5895/api/v1/Servers('{server_name}')" + url = f"http://{adminhost}:5895/Servers('{server_name}')" response = requests.delete(url, headers={'Content-Type': 'application/json'}) response.raise_for_status() @@ -171,7 +187,7 @@ def update_server_on_adminhost(adminhost: str = 'localhost', server_as_dict: Dic if not adminhost: adminhost = 'localhost' - url = f"http://{adminhost}:5895/api/v1/Servers" + url = f"http://{adminhost}:5895/Servers" response = requests.patch(url, body=json.dumps(server_as_dict), headers={'Content-Type': 'application/json'}) response.raise_for_status() @@ -208,7 +224,7 @@ def abbreviate_mdx(mdx: str, size=100) -> str: def integerize_version(version: str, precision: int = 4) -> int: - return int(version[:precision].replace(".", "")) + return int(version[:precision].replace(".", "").ljust(precision, "0")) def verify_version(required_version: str, version: str) -> bool: @@ -884,6 +900,10 @@ def extract_compact_json_cellset(context: str, response: Dict, return_as_dict: b if len(props) == 1: return [value[0] for value in cells_data] + if props == ['Ordinal', 'Value']: + return [value[1] for value in cells_data] + + return cells_data diff --git a/TM1py/__init__.py b/TM1py/__init__.py index 146d7306..fa77d51d 100644 --- a/TM1py/__init__.py +++ b/TM1py/__init__.py @@ -61,6 +61,7 @@ from TM1py.Services.SubsetService import SubsetService from TM1py.Services.TM1Service import TM1Service from TM1py.Services.ViewService import ViewService +from TM1py.Services.ManageService import ManageService from TM1py.Utils import Utils __version__ = "1.11.2" diff --git a/Tests/ApplicationService_test.py b/Tests/ApplicationService_test.py index 89127541..e8f98c88 100644 --- a/Tests/ApplicationService_test.py +++ b/Tests/ApplicationService_test.py @@ -8,7 +8,7 @@ Subset, Process, Chore, ChoreStartTime, ChoreFrequency, ChoreTask from TM1py.Objects.Application import CubeApplication, ApplicationTypes, ChoreApplication, DimensionApplication, \ FolderApplication, LinkApplication, ProcessApplication, SubsetApplication, ViewApplication, DocumentApplication -from .Utils import skip_if_insufficient_version +from .Utils import skip_if_insufficient_version, verify_version class TestApplicationService(unittest.TestCase): @@ -103,12 +103,11 @@ def setUpClass(cls) -> None: False) cls.tm1.dimensions.hierarchies.subsets.create(subset, False) + # Build process p1 = Process(name=cls.process_name) p1.add_parameter('pRegion', 'pRegion (String)', value='US') - if cls.tm1.processes.exists(p1.name): - cls.tm1.processes.delete(p1.name) - cls.tm1.processes.create(p1) + cls.tm1.processes.update_or_create(p1) # Build chore c1 = Chore( @@ -124,11 +123,15 @@ def setUpClass(cls) -> None: minutes=int(random.uniform(0, 59)), seconds=int(random.uniform(0, 59))), tasks=[ChoreTask(0, cls.process_name, parameters=[{'Name': 'pRegion', 'Value': 'UK'}])]) - cls.tm1.chores.create(c1) + cls.tm1.chores.update_or_create(c1) # create Folder app = FolderApplication("", cls.tm1py_app_folder) - cls.tm1.applications.create(application=app, private=False) + if cls.tm1.applications.exists(path=app.path, application_type=app.application_type, name=app.name, private=False): + cls.tm1.applications.delete(path=app.path, application_type=app.application_type, application_name=app.name, private=False) + cls.tm1.applications.create(application=app, private=False) + else: + cls.tm1.applications.create(application=app, private=False) @classmethod def tearDownClass(cls) -> None: @@ -250,7 +253,8 @@ def run_document_application(self, private): app_retrieved = self.tm1.applications.get(app.path, app.application_type, app.name, private=private) self.assertEqual(app_retrieved.last_updated[:10], datetime.today().strftime('%Y-%m-%d')) - self.assertIsNotNone(app_retrieved.file_id) + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNotNone(app_retrieved.file_id) self.assertIsNotNone(app_retrieved.file_name) self.assertEqual(app, app_retrieved) diff --git a/Tests/CellService_test.py b/Tests/CellService_test.py index e35dd307..8b1c9408 100644 --- a/Tests/CellService_test.py +++ b/Tests/CellService_test.py @@ -11,8 +11,8 @@ ElementAttribute, Hierarchy, MDXView, NativeView) from TM1py.Services import TM1Service from TM1py.Utils import Utils, element_names_from_element_unique_names, CaseAndSpaceInsensitiveDict, \ - CaseAndSpaceInsensitiveTuplesDict -from .Utils import skip_if_insufficient_version, skip_if_no_pandas + CaseAndSpaceInsensitiveTuplesDict, verify_version +from .Utils import skip_if_insufficient_version, skip_if_no_pandas, skip_if_deprecated_in_version try: import pandas as pd @@ -659,7 +659,12 @@ def test_write_through_unbound_process_attributes(self): query.add_member_tuple_to_columns( f"[{self.dimension_names[0]}].[element2]", f"[}}ElementAttributes_{self.dimension_names[0]}].[Attr3]") - self.assertEqual(self.tm1.cells.execute_mdx_values(mdx=query.to_mdx()), ['Text 1', 1, 2, "", None, None]) + values = self.tm1.cells.execute_mdx_values(mdx=query.to_mdx()) + + if verify_version(required_version="12", version=self.tm1.version): + self.assertEqual(values, ['Text 1', 1, 2, "", 0, 0]) + else: + self.assertEqual(values, ['Text 1', 1, 2, "", None, None]) def test_write_through_unbound_process_to_consolidation(self): cells = dict() @@ -828,7 +833,15 @@ def test_write_through_blob_attributes(self): query.add_member_tuple_to_columns( f"[{self.dimension_names[0]}].[element2]", f"[}}ElementAttributes_{self.dimension_names[0]}].[Attr3]") - self.assertEqual(self.tm1.cells.execute_mdx_values(mdx=query.to_mdx()), ['Text 1', 1, 2, "", None, None]) + + result = self.tm1.cells.execute_mdx_values(mdx=query.to_mdx()) + + self.assertEqual(result[0], "Text 1") + self.assertEqual(result[1], 1) + self.assertEqual(result[2], 2) + self.assertEqual(result[3], "") + self.assertIn(result[4], [0, None]) + self.assertIn(result[5], [0, None]) def test_write_through_blob_to_consolidation(self): cells = dict() @@ -1472,6 +1485,8 @@ def test_execute_mdx_without_rows(self): self.assertIn("[TM1py_Tests_Cell_Dimension2].", coordinates[1]) self.assertIn("[TM1py_Tests_Cell_Dimension3].", coordinates[2]) + @skip_if_deprecated_in_version(version="12") + # v12 does not support empty row sets def test_execute_mdx_with_empty_rows(self): # write cube content self.tm1.cubes.cells.write_values(self.cube_name, self.cellset) @@ -1500,6 +1515,7 @@ def test_execute_mdx_with_empty_rows(self): self.assertIn("[TM1py_Tests_Cell_Dimension2].", coordinates[1]) self.assertIn("[TM1py_Tests_Cell_Dimension3].", coordinates[2]) + @skip_if_deprecated_in_version(version="12") def test_execute_mdx_with_empty_columns(self): # write cube content self.tm1.cubes.cells.write_values(self.cube_name, self.cellset) @@ -2459,7 +2475,7 @@ def test_execute_mdx_csv_with_calculated_member_use_blob(self): .add_hierarchy_set_to_column_axis( MdxHierarchySet.member(Member.of(self.dimension_names[1], "Calculated Member"))) - csv = self.tm1.cubes.cells.execute_mdx_csv(mdx, use_blob=True, ) + csv = self.tm1.cubes.cells.execute_mdx_csv(mdx, use_blob=False) # check header header = csv.split('\r\n')[0] @@ -2658,15 +2674,19 @@ def test_execute_mdx_dataframe_include_attributes(self): """ df = self.tm1.cubes.cells.execute_mdx_dataframe(mdx, include_attributes=True) + # integerize numeric columns because v12 attribute numbers are different from v11 ('2.0' vs '2') + df[['Attr3', 'Attr2', 'Value']] = df[['Attr3', 'Attr2', 'Value']].apply( + lambda col: pd.to_numeric(col).fillna(0).astype(int)) expected = { 'TM1py_Tests_Cell_Dimension3': {0: 'Element 1'}, - 'Attr3': {0: '3'}, + 'Attr3': {0: 3}, 'TM1py_Tests_Cell_Dimension2': {0: 'Element 1'}, - 'Attr2': {0: '2'}, + 'Attr2': {0: 2}, 'TM1py_Tests_Cell_Dimension1': {0: 'Element 1'}, 'Attr1': {0: 'TM1py'}, 'Value': {0: 1.0}} + self.assertEqual(expected, df.to_dict()) @skip_if_no_pandas @@ -2683,15 +2703,18 @@ def test_execute_mdx_dataframe_include_attributes_iter_json(self): """ df = self.tm1.cubes.cells.execute_mdx_dataframe(mdx, include_attributes=True, use_iterative_json=True) + # integerize numeric columns because v12 attribute numbers are different from v11 ('2.0' vs '2') + df[['Attr3', 'Attr2', 'Value']] = df[['Attr3', 'Attr2', 'Value']].apply( + lambda col: pd.to_numeric(col).fillna(0).astype(int)) expected = { 'TM1py_Tests_Cell_Dimension3': {0: 'Element 1'}, - 'Attr3': {0: '3'}, + 'Attr3': {0: 3}, 'TM1py_Tests_Cell_Dimension2': {0: 'Element 1'}, - 'Attr2': {0: '2'}, + 'Attr2': {0: 2}, 'TM1py_Tests_Cell_Dimension1': {0: 'Element 1'}, 'Attr1': {0: 'TM1py'}, - 'Value': {0: 1.0}} + 'Value': {0: 1}} self.assertEqual(expected, df.to_dict()) @skip_if_no_pandas @@ -2707,16 +2730,20 @@ def test_execute_mdx_dataframe_include_attributes_iter_json_all_columns(self): """ df = self.tm1.cubes.cells.execute_mdx_dataframe(mdx, include_attributes=True, use_iterative_json=True) + # integerize numeric columns because v12 attribute numbers are different from v11 ('2.0' vs '2') + df[['Attr3', 'Attr2', 'Value']] = df[['Attr3', 'Attr2', 'Value']].apply( + lambda col: pd.to_numeric(col).fillna(0).astype(int)) - expected = { + df_test = pd.DataFrame({ 'TM1py_Tests_Cell_Dimension1': {0: 'Element 1'}, 'Attr1': {0: 'TM1py'}, 'TM1py_Tests_Cell_Dimension2': {0: 'Element 1'}, - 'Attr2': {0: '2'}, + 'Attr2': {0: 2}, 'TM1py_Tests_Cell_Dimension3': {0: 'Element 1'}, - 'Attr3': {0: '3'}, - 'Value': {0: 1.0}} - self.assertEqual(expected, df.to_dict()) + 'Attr3': {0: 3}, + 'Value': {0: 1}}) + + self.assertEquals(df_test.to_dict(), df.to_dict()) @skip_if_no_pandas def test_execute_mdx_dataframe_include_attributes_iter_json_no_attributes(self): @@ -2860,8 +2887,18 @@ def test_execute_mdx_dataframe_use_blob_with_top_skip(self): self.assertEqual(expected_df.to_csv(), df.to_csv()) @skip_if_no_pandas - def test_execute_mdx_dataframe_async(self): + def test_execute_mdx_dataframe_async_max_workers_2(self): + self.run_test_execute_mdx_dataframe_async(max_workers=2) + + @skip_if_no_pandas + def test_execute_mdx_dataframe_async_max_workers_4(self): + self.run_test_execute_mdx_dataframe_async(max_workers=4) + + @skip_if_no_pandas + def test_execute_mdx_dataframe_async_max_workers_8(self): + self.run_test_execute_mdx_dataframe_async(max_workers=8) + def run_test_execute_mdx_dataframe_async(self, max_workers): # build a reference "single-threaded" df for comparison mdx = MdxBuilder.from_cube(self.cube_name) \ .rows_non_empty() \ @@ -2872,13 +2909,10 @@ def test_execute_mdx_dataframe_async(self): .add_hierarchy_set_to_column_axis( MdxHierarchySet.all_members(self.dimension_names[2], self.dimension_names[2])) \ .to_mdx() - df = self.tm1.cubes.cells.execute_mdx_dataframe(mdx) - # build 4 non-empty + 1 empty mdx queries to pass to async df mdx_list = [] chunk_size = int(len(self.target_coordinates) / 4) - for chunk in range(5): mdx = MdxBuilder.from_cube(self.cube_name) \ .rows_non_empty() \ @@ -2891,21 +2925,12 @@ def test_execute_mdx_dataframe_async(self): MdxHierarchySet.all_members(self.dimension_names[2], self.dimension_names[2])) \ .to_mdx() mdx_list.append(mdx) - # check execution with different max_worker parameter - df_async1 = self.tm1.cubes.cells.execute_mdx_dataframe_async(mdx_list, max_workers=2) - df_async2 = self.tm1.cubes.cells.execute_mdx_dataframe_async(mdx_list, max_workers=5) - df_async3 = self.tm1.cubes.cells.execute_mdx_dataframe_async(mdx_list, max_workers=8) - + df_async = self.tm1.cubes.cells.execute_mdx_dataframe_async(mdx_list, max_workers=max_workers) # check type - self.assertIsInstance(df_async1, pd.DataFrame) - self.assertIsInstance(df_async2, pd.DataFrame) - self.assertIsInstance(df_async3, pd.DataFrame) - + self.assertIsInstance(df_async, pd.DataFrame) # check async df are equal to reference df - self.assertTrue(df_async1.equals(df)) - self.assertTrue(df_async2.equals(df)) - self.assertTrue(df_async3.equals(df)) + self.assertTrue(df_async.equals(df)) @skip_if_no_pandas def test_execute_mdx_dataframe_async_use_blob(self): @@ -3656,17 +3681,23 @@ def test_execute_view_dataframe_pivot_two_row_one_column_dimensions(self): dimension_name=self.dimension_names[0], subset=AnonymousSubset( dimension_name=self.dimension_names[0], - expression='{ HEAD ( {[' + self.dimension_names[0] + '].Members}, 10) } }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[0], + self.dimension_names[0]).head(10).to_mdx())) view.add_row( dimension_name=self.dimension_names[1], subset=AnonymousSubset( dimension_name=self.dimension_names[1], - expression='{ HEAD ( { [' + self.dimension_names[1] + '].Members}, 10 ) }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[1], + self.dimension_names[1]).head(10).to_mdx())) view.add_column( dimension_name=self.dimension_names[2], subset=AnonymousSubset( dimension_name=self.dimension_names[2], - expression='{ HEAD ( {[' + self.dimension_names[2] + '].Members}, 10 ) }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[2], + self.dimension_names[2]).head(10).to_mdx())) self.tm1.cubes.views.update_or_create(view, private=False) pivot = self.tm1.cubes.cells.execute_view_dataframe_pivot( @@ -3686,17 +3717,23 @@ def test_execute_view_dataframe_pivot_one_row_two_column_dimensions(self): dimension_name=self.dimension_names[0], subset=AnonymousSubset( dimension_name=self.dimension_names[0], - expression='{ HEAD ( {[' + self.dimension_names[0] + '].Members}, 10) } }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[0], + self.dimension_names[0]).head(10).to_mdx())) view.add_column( dimension_name=self.dimension_names[1], subset=AnonymousSubset( dimension_name=self.dimension_names[1], - expression='{ HEAD ( { [' + self.dimension_names[1] + '].Members}, 10 ) }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[1], + self.dimension_names[1]).head(10).to_mdx())) view.add_column( dimension_name=self.dimension_names[2], subset=AnonymousSubset( dimension_name=self.dimension_names[2], - expression='{ HEAD ( {[' + self.dimension_names[2] + '].Members}, 10 ) }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[2], + self.dimension_names[2]).head(10).to_mdx())) self.tm1.cubes.views.update_or_create( view=view, private=False) @@ -3714,16 +3751,25 @@ def test_execute_view_dataframe_pivot_one_row_one_column_dimensions(self): view_name=view_name, suppress_empty_columns=False, suppress_empty_rows=False) + view.add_row( dimension_name=self.dimension_names[0], subset=AnonymousSubset( dimension_name=self.dimension_names[0], - expression='{ HEAD ( {[' + self.dimension_names[0] + '].Members}, 10) } }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[0], + self.dimension_names[0]).head(10).to_mdx() + ) + ) view.add_column( dimension_name=self.dimension_names[1], subset=AnonymousSubset( dimension_name=self.dimension_names[1], - expression='{ HEAD ( { [' + self.dimension_names[1] + '].Members}, 10 ) }')) + expression=MdxHierarchySet.all_members( + self.dimension_names[1], + self.dimension_names[1]).head(10).to_mdx() + ) + ) view.add_title( dimension_name=self.dimension_names[2], selection="Element 1", @@ -3820,6 +3866,7 @@ def test_write_values_through_cellset(self): values = self.tm1.cubes.cells.execute_mdx_values(mdx) self.assertEqual(values[0], 1.5) + @skip_if_deprecated_in_version(version='12') def test_write_values_through_cellset_deactivate_transaction_log(self): query = MdxBuilder.from_cube(self.cube_name) query = query.add_hierarchy_set_to_row_axis( @@ -3836,6 +3883,7 @@ def test_write_values_through_cellset_deactivate_transaction_log(self): self.assertFalse(self.tm1.cells.transaction_log_is_active(self.cube_name)) + @skip_if_deprecated_in_version(version='12') def test_write_values_through_cellset_deactivate_transaction_log_reactivate_transaction_log(self): mdx = MdxBuilder.from_cube(self.cube_name) \ .add_hierarchy_set_to_row_axis(MdxHierarchySet.member(Member.of(self.dimension_names[0], "element2"))) \ @@ -3855,6 +3903,7 @@ def test_write_values_through_cellset_deactivate_transaction_log_reactivate_tran self.assertEqual(values[0], 1.5) self.assertTrue(self.tm1.cells.transaction_log_is_active(self.cube_name)) + @skip_if_deprecated_in_version(version='12') def test_deactivate_transaction_log(self): self.tm1.cubes.cells.write_value(value="YES", cube_name="}CubeProperties", @@ -3863,6 +3912,7 @@ def test_deactivate_transaction_log(self): value = self.tm1.cubes.cells.get_value("}CubeProperties", "{},LOGGING".format(self.cube_name)) self.assertEqual("NO", value.upper()) + @skip_if_deprecated_in_version(version='12') def test_activate_transaction_log(self): self.tm1.cubes.cells.write_value(value="NO", cube_name="}CubeProperties", @@ -3975,6 +4025,8 @@ def test_clear_happy_case(self): self.assertEqual(value, None) @skip_if_insufficient_version(version="11.7") + @skip_if_deprecated_in_version(version="12") + # skip if version 12 as invalid element names do not raise an exception def test_clear_invalid_element_name(self): with self.assertRaises(TM1pyException) as e: @@ -3994,15 +4046,11 @@ def test_clear_with_mdx_invalid_query(self): with self.assertRaises(TM1pyException) as e: mdx = f""" SELECT - {{[{self.dimension_names[0]}].[NotExistingElement]}} ON 0 + {{[{self.dimension_names[0]}].MissingSquareBracket]}} ON 0 FROM [{self.cube_name}] """ self.tm1.cells.clear_with_mdx(cube=self.cube_name, mdx=mdx) - self.assertIn( - '\\"NotExistingElement\\" :', - str(e.exception.message)) - def test_clear_with_mdx_unsupported_version(self): with self.assertRaises(TM1pyVersionException) as e: @@ -4024,6 +4072,8 @@ def test_clear_with_mdx_unsupported_version(self): str(e.exception), str(TM1pyVersionException(function="clear_with_mdx", required_version="11.7"))) + self.tm1._tm1_rest.set_version() + def test_execute_mdx_with_skip(self): mdx = MdxBuilder.from_cube(self.cube_name) \ .add_hierarchy_set_to_row_axis(MdxHierarchySet.tm1_subset_all(self.dimension_names[0]).head(2)) \ @@ -4053,16 +4103,19 @@ def test_execute_mdx_with_top_skip(self): elements = element_names_from_element_unique_names(list(cells.keys())[0]) self.assertEqual(elements, ("Element 2", "Element 1", "Element 1")) + @skip_if_deprecated_in_version(version='12') def test_transaction_log_is_active_false(self): self.tm1.cells.deactivate_transactionlog(self.cube_name) self.assertFalse(self.tm1.cells.transaction_log_is_active(self.cube_name)) + @skip_if_deprecated_in_version(version='12') def test_transaction_log_is_active_true(self): self.tm1.cells.activate_transactionlog(self.cube_name) self.assertTrue(self.tm1.cells.transaction_log_is_active(self.cube_name)) + @skip_if_deprecated_in_version(version='12') def test_manage_transaction_log_deactivate_reactivate(self): self.tm1.cubes.cells.write_values( self.cube_name, @@ -4072,6 +4125,7 @@ def test_manage_transaction_log_deactivate_reactivate(self): self.assertTrue(self.tm1.cells.transaction_log_is_active(self.cube_name)) + @skip_if_deprecated_in_version(version='12') def test_manage_transaction_log_not_deactivate_not_reactivate(self): pre_state = self.tm1.cells.transaction_log_is_active(self.cube_name) @@ -4083,6 +4137,7 @@ def test_manage_transaction_log_not_deactivate_not_reactivate(self): self.assertEqual(pre_state, self.tm1.cells.transaction_log_is_active(self.cube_name)) + @skip_if_deprecated_in_version(version='12') def test_manage_transaction_log_deactivate_not_reactivate(self): self.tm1.cubes.cells.write_values( self.cube_name, @@ -4175,7 +4230,7 @@ def test_trace_cell_calculation_no_depth_iterable(self): cube_name=self.cube_with_rules_name, elements=["Element1", "Element1", "Element1"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_cell_calculation_shallow_depth_iterable(self): shallow_depth = 1 @@ -4184,7 +4239,7 @@ def test_trace_cell_calculation_shallow_depth_iterable(self): elements=["Element3", "Element1", "Element1"], depth=shallow_depth) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) components = result["Components"] self.assertNotIn("Components", components) @@ -4196,7 +4251,7 @@ def test_trace_cell_calculation_deep_depth_iterable(self): elements=["Element3", "Element1", "Element1"], depth=shallow_depth) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) components = result["Components"] for _ in range(shallow_depth - 1): components = components[0]["Components"] @@ -4209,14 +4264,14 @@ def test_trace_cell_calculation_dimensions_iterable(self): elements=["Element1", "Element1", "Element1"], dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_cell_calculation_no_depth_string(self): result = self.tm1.cells.trace_cell_calculation( cube_name=self.cube_with_rules_name, elements="Element1,Element1,Element1") - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_cell_calculation_shallow_depth_string(self): shallow_depth = 2 @@ -4226,7 +4281,7 @@ def test_trace_cell_calculation_shallow_depth_string(self): elements="Element3,Element1,Element1", depth=shallow_depth) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) components = result["Components"] for _ in range(shallow_depth - 1): components = components[0]["Components"] @@ -4237,9 +4292,9 @@ def test_trace_cell_calculation_deep_depth_string(self): result = self.tm1.cells.trace_cell_calculation( cube_name=self.cube_with_rules_name, elements="Element1,Element1,Element1", - depth=25) + depth=10) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_cell_calculation_dimensions_string(self): result = self.tm1.cells.trace_cell_calculation( @@ -4247,7 +4302,7 @@ def test_trace_cell_calculation_dimensions_string(self): elements="Element1,Element1,Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_cell_calculation_dimensions_string_hierarchy(self): result = self.tm1.cells.trace_cell_calculation( @@ -4257,7 +4312,7 @@ def test_trace_cell_calculation_dimensions_string_hierarchy(self): "TM1py_Tests_Cell_Dimension3::Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_cell_calculation_dimensions_string_multi_hierarchy(self): result = self.tm1.cells.trace_cell_calculation( @@ -4267,14 +4322,14 @@ def test_trace_cell_calculation_dimensions_string_multi_hierarchy(self): "TM1py_Tests_Cell_Dimension3::Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.CalculationComponent') + self.assertIn('../$metadata#ibm.tm1.api.v1.CalculationComponent', result['@odata.context']) def test_trace_feeders_string(self): result = self.tm1.cells.trace_cell_feeders( cube_name=self.cube_with_rules_name, elements="Element1,Element1,Element1") - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.FeederTrace') + self.assertIn('../$metadata#ibm.tm1.api.v1.FeederTrace', result['@odata.context']) def test_trace_feeders_dimensions_string(self): result = self.tm1.cells.trace_cell_feeders( @@ -4282,7 +4337,7 @@ def test_trace_feeders_dimensions_string(self): elements="Element1,Element1,Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.FeederTrace') + self.assertIn('../$metadata#ibm.tm1.api.v1.FeederTrace', result['@odata.context']) def test_trace_feeders_dimensions_string_hierarchy(self): result = self.tm1.cells.trace_cell_feeders( @@ -4292,7 +4347,7 @@ def test_trace_feeders_dimensions_string_hierarchy(self): "TM1py_Tests_Cell_Dimension3::Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.FeederTrace') + self.assertIn('../$metadata#ibm.tm1.api.v1.FeederTrace', result['@odata.context']) def test_trace_feeders_dimensions_string_multi_hierarchy(self): result = self.tm1.cells.trace_cell_feeders( @@ -4302,14 +4357,14 @@ def test_trace_feeders_dimensions_string_multi_hierarchy(self): "TM1py_Tests_Cell_Dimension3::Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#ibm.tm1.api.v1.FeederTrace') + self.assertIn('../$metadata#ibm.tm1.api.v1.FeederTrace', result['@odata.context']) def test_check_feeders_string(self): result = self.tm1.cells.check_cell_feeders( cube_name=self.cube_with_rules_name, elements="Element1,Element1,Element1") - self.assertEqual(result['@odata.context'], '../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)') + self.assertIn('../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)', result['@odata.context']) def test_check_feeders_dimensions_string(self): result = self.tm1.cells.check_cell_feeders( @@ -4317,7 +4372,7 @@ def test_check_feeders_dimensions_string(self): elements="Element1,Element1,Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)') + self.assertIn('../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)', result['@odata.context']) def test_check_feeders_dimensions_string_hierarchy(self): result = self.tm1.cells.check_cell_feeders( @@ -4327,7 +4382,7 @@ def test_check_feeders_dimensions_string_hierarchy(self): "TM1py_Tests_Cell_Dimension3::Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)') + self.assertIn('../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)', result['@odata.context']) def test_check_feeders_dimensions_string_multi_hierarchy(self): result = self.tm1.cells.check_cell_feeders( @@ -4337,7 +4392,7 @@ def test_check_feeders_dimensions_string_multi_hierarchy(self): "TM1py_Tests_Cell_Dimension3::Element1", dimensions=["TM1py_Tests_Cell_Dimension1", "TM1py_Tests_Cell_Dimension2", "TM1py_Tests_Cell_Dimension3"]) - self.assertEqual(result['@odata.context'], '../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)') + self.assertIn('../$metadata#Collection(ibm.tm1.api.v1.FedCellDescriptor)', result['@odata.context']) def test_execute_mdx_csv_mdx_headers(self): self.tm1.cubes.cells.write_values( @@ -4465,27 +4520,26 @@ def test_extract_cellset_partition(self): MdxHierarchySet.all_members(self.dimension_names[2], self.dimension_names[2])) \ .to_mdx() - #create cellset + # create cellset cellset = self.tm1.cells.create_cellset(mdx) - partition = self.tm1.cells.extract_cellset_partition(cellset_id=cellset, - partition_start_ordinal=0, - partition_end_ordinal=1) + partition = self.tm1.cells.extract_cellset_partition( + cellset_id=cellset, + partition_start_ordinal=0, + partition_end_ordinal=1) expected_result = [{'Ordinal': 0, 'Value': 1}, {'Ordinal': 1, 'Value': None}] self.assertEqual(partition, expected_result) - partition_skip_zero = self.tm1.cells.extract_cellset_partition(cellset_id=cellset, - partition_start_ordinal=0, - partition_end_ordinal=1, - skip_zeros=True) + partition_skip_zero = self.tm1.cells.extract_cellset_partition( + cellset_id=cellset, + partition_start_ordinal=0, + partition_end_ordinal=1, + skip_zeros=True) expected_result_skip_zero = [{'Ordinal': 0, 'Value': 1}] self.assertEqual(partition_skip_zero, expected_result_skip_zero) - - - # Delete Cube and Dimensions @classmethod def tearDownClass(cls): diff --git a/Tests/CubeService_test.py b/Tests/CubeService_test.py index 8133237f..1b7bd2d6 100644 --- a/Tests/CubeService_test.py +++ b/Tests/CubeService_test.py @@ -7,7 +7,7 @@ from TM1py.Objects import Cube from TM1py.Objects import Rules from TM1py.Services import TM1Service -from .Utils import skip_if_insufficient_version +from .Utils import skip_if_insufficient_version, skip_if_deprecated_in_version class TestCubeService(unittest.TestCase): @@ -110,7 +110,7 @@ def test_create_delete_cube(self): cube = Cube(cube_name, dimension_names) all_cubes_before = self.tm1.cubes.get_all_names() - self.tm1.cubes.create(cube) + self.tm1.cubes.update_or_create(cube) all_cubes_after = self.tm1.cubes.get_all_names() self.assertEqual( len(all_cubes_before) + 1, @@ -137,7 +137,7 @@ def test_get_all_names(self): cube_name = self.prefix + "Some_Other_Name" dimension_names = self.tm1.dimensions.get_all_names()[1:3] cube = Cube(cube_name, dimension_names) - self.tm1.cubes.create(cube) + self.tm1.cubes.update_or_create(cube) self.assertEqual(len(cubes_without_rules) + 1, len(self.tm1.cubes.get_all_names_without_rules())) self.assertEqual(len(cubes_with_rules), len(self.tm1.cubes.get_all_names_with_rules())) @@ -149,7 +149,7 @@ def test_get_all_names(self): self.tm1.cubes.delete(cube_name) cube = self.tm1.cubes.get(self.control_cube_name) - cube.rules = "#find_control_comment" + cube.rules = "SKIPCHECK" self.tm1.cubes.update(cube) self.assertNotEqual(self.tm1.cubes.get_all_names_with_rules(), self.tm1.cubes.get_all_names_with_rules(skip_control_cubes=True)) @@ -203,10 +203,16 @@ def test_search_for_dimension_substring_skip_control_cubes_true(self): cubes = self.tm1.cubes.search_for_dimension_substring(substring="}cubes", skip_control_cubes=True) self.assertEqual({}, cubes) - def test_search_for_dimension_substring_skip_control_cubes_false(self): + @skip_if_deprecated_in_version(version="12") + def test_search_for_dimension_substring_skip_control_cubes_false_v11(self): cubes = self.tm1.cubes.search_for_dimension_substring(substring="}cubes", skip_control_cubes=False) self.assertEqual(cubes['}CubeProperties'], ['}Cubes']) + @skip_if_insufficient_version(version="12") + def test_search_for_dimension_substring_skip_control_cubes_false_v12(self): + cubes = self.tm1.cubes.search_for_dimension_substring(substring="}cubes", skip_control_cubes=False) + self.assertEqual(cubes['}CubeSecurity'], ['}Cubes']) + def test_get_number_of_cubes(self): number_of_cubes = self.tm1.cubes.get_number_of_cubes() self.assertIsInstance(number_of_cubes, int) @@ -222,11 +228,13 @@ def test_update_storage_dimension_order(self): self.dimension_names) @skip_if_insufficient_version(version="11.6") + @skip_if_deprecated_in_version(version="12") def test_load(self): response = self.tm1.cubes.load(cube_name=self.cube_name) self.assertTrue(response.ok) @skip_if_insufficient_version(version="11.6") + @skip_if_deprecated_in_version(version="12") def test_unload(self): response = self.tm1.cubes.unload(cube_name=self.cube_name) self.assertTrue(response.ok) diff --git a/Tests/FileService_test.py b/Tests/FileService_test.py index f3976664..90751665 100644 --- a/Tests/FileService_test.py +++ b/Tests/FileService_test.py @@ -6,7 +6,7 @@ from .Utils import skip_if_insufficient_version -class TestApplicationService(unittest.TestCase): +class TestFileService(unittest.TestCase): tm1: TM1Service FILE_NAME1 = "TM1py_unittest_file1" @@ -27,7 +27,7 @@ def setUp(cls) -> None: @skip_if_insufficient_version(version="11.4") def test_create_get(self): with open(Path(__file__).parent.joinpath('resources', 'file.csv'), "rb") as original_file: - self.tm1.files.create(self.FILE_NAME2, original_file.read()) + self.tm1.files.update_or_create(self.FILE_NAME1, original_file.read()) created_file = self.tm1.files.get(self.FILE_NAME1) diff --git a/Tests/HierarchyService_test.py b/Tests/HierarchyService_test.py index 16b9a4f7..8d61f3ae 100644 --- a/Tests/HierarchyService_test.py +++ b/Tests/HierarchyService_test.py @@ -39,6 +39,7 @@ def teardown_class(cls): @classmethod def setUp(cls): + cls.delete_dimensions() cls.create_dimension() cls.create_subset() @@ -64,7 +65,8 @@ def create_dimension(cls): @classmethod def delete_dimensions(cls): with suppress(TM1pyRestException): - cls.tm1.dimensions.delete(cls.dimension_name) + if cls.tm1.dimensions.exists(cls.dimension_name): + cls.tm1.dimensions.delete(cls.dimension_name) with suppress(TM1pyRestException): cls.tm1.dimensions.delete(cls.region_dimension_name) with suppress(TM1pyRestException): diff --git a/Tests/MonitoringService_test.py b/Tests/MonitoringService_test.py index 874bc5fd..96d4a09b 100644 --- a/Tests/MonitoringService_test.py +++ b/Tests/MonitoringService_test.py @@ -4,6 +4,7 @@ from TM1py.Services import TM1Service from TM1py.Utils import case_and_space_insensitive_equals +from .Utils import skip_if_deprecated_in_version class TestMonitoringService(unittest.TestCase): @@ -20,9 +21,12 @@ def setUpClass(cls): cls.config.read(Path(__file__).parent.joinpath('config.ini')) cls.tm1 = TM1Service(**cls.config['tm1srv01']) + @skip_if_deprecated_in_version(version="12.0.0") def test_get_threads(self): threads = self.tm1.monitoring.get_threads() - self.assertTrue(any(thread["Function"] == "GET /api/v1/Threads" for thread in threads)) + self.assertTrue(any(thread["Function"] == "GET /api/v1/Threads" for thread in threads) + or + any(thread["Function"] == "GET /Threads" for thread in threads)) def test_get_active_users(self): current_user = self.tm1.security.get_current_user() @@ -39,6 +43,7 @@ def test_close_all_sessions(self): def test_disconnect_all_users(self): self.tm1.monitoring.disconnect_all_users() + @skip_if_deprecated_in_version(version="12.0.0") def test_cancel_all_running_threads(self): self.tm1.monitoring.cancel_all_running_threads() diff --git a/Tests/ProcessService_test.py b/Tests/ProcessService_test.py index 332a9390..c29df4e5 100644 --- a/Tests/ProcessService_test.py +++ b/Tests/ProcessService_test.py @@ -9,7 +9,8 @@ from TM1py.Exceptions import TM1pyException from TM1py.Objects import Process, Subset, ProcessDebugBreakpoint, BreakPointType, HitMode from TM1py.Services import TM1Service -from .Utils import skip_if_insufficient_version +from .Utils import skip_if_insufficient_version, skip_if_deprecated_in_version +from TM1py.Utils import verify_version class TestProcessService(unittest.TestCase): @@ -27,10 +28,10 @@ class TestProcessService(unittest.TestCase): datasource_ascii_header_records=2, datasource_ascii_quote_character='^', datasource_ascii_thousand_separator='~', - prolog_procedure="sTestProlog = 'test prolog procedure'", - metadata_procedure="sTestMeta = 'test metadata procedure'", - data_procedure="sTestData = 'test data procedure'", - epilog_procedure="sTestEpilog = 'test epilog procedure'", + prolog_procedure="sTestProlog = 'test prolog procedure';", + metadata_procedure="sTestMeta = 'test metadata procedure';", + data_procedure="sTestData = 'test data procedure';", + epilog_procedure="sTestEpilog = 'test epilog procedure';", datasource_data_source_name_for_server=r'C:\Data\file.csv', datasource_data_source_name_for_client=r'C:\Data\file.csv') p_ascii.add_variable('v_1', 'Numeric') @@ -107,7 +108,7 @@ def tearDown(cls): cls.tm1.processes.delete(cls.p_view.name) cls.tm1.processes.delete(cls.p_odbc.name) cls.tm1.processes.delete(cls.p_subset.name) - cls.tm1.processes.update_or_create(cls.p_debug) + cls.tm1.processes.delete(cls.p_debug.name) def test_update_or_create(self): if self.tm1.processes.exists(self.p_bedrock_server_wait.name): @@ -149,28 +150,30 @@ def test_execute_process(self): @skip_if_insufficient_version(version="11.4") def test_execute_with_return_success(self): process = self.p_bedrock_server_wait - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return( process_name=process.name, pWaitSec=2) self.assertTrue(success) self.assertEqual(status, "CompletedSuccessfully") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) # without parameters success, status, error_log_file = self.tm1.processes.execute_with_return( process_name=process.name) self.assertTrue(success) self.assertEqual(status, "CompletedSuccessfully") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) def test_execute_with_return_compile_error(self): process = Process(name=str(uuid.uuid4())) process.prolog_procedure = "sText = 'text';sText = 2;" + self.tm1.processes.update_or_create(process) - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return(process_name=process.name) self.assertFalse(success) @@ -183,8 +186,7 @@ def test_execute_with_return_with_item_reject(self): process = Process(name=str(uuid.uuid4())) process.epilog_procedure = "ItemReject('Not Relevant');" - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return(process_name=process.name) self.assertFalse(success) @@ -198,14 +200,15 @@ def test_execute_with_return_with_process_break(self): process = Process(name=str(uuid.uuid4())) process.prolog_procedure = "sText = 'Something'; ProcessBreak;" - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return( process_name=process.name) self.assertTrue(success) self.assertEqual(status, "CompletedSuccessfully") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) self.tm1.processes.delete(process.name) @@ -214,14 +217,15 @@ def test_execute_with_return_with_process_quit(self): process = Process(name=str(uuid.uuid4())) process.prolog_procedure = "sText = 'Something'; ProcessQuit;" - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return( process_name=process.name) self.assertFalse(success) self.assertEqual(status, "QuitCalled") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) self.tm1.processes.delete(process.name) @@ -229,7 +233,7 @@ def test_compile_success(self): p_good = Process( name=str(uuid.uuid4()), prolog_procedure="nPro = DimSiz('}Processes');") - self.tm1.processes.create(p_good) + self.tm1.processes.update_or_create(p_good) errors = self.tm1.processes.compile(p_good.name) self.assertTrue(len(errors) == 0) self.tm1.processes.delete(p_good.name) @@ -238,7 +242,7 @@ def test_compile_with_errors(self): p_bad = Process( name=str(uuid.uuid4()), prolog_procedure="nPro = DimSize('}Processes');") - self.tm1.processes.create(p_bad) + self.tm1.processes.update_or_create(p_bad) errors = self.tm1.processes.compile(p_bad.name) self.assertTrue(len(errors) == 1) self.assertIn("\"dimsize\"", errors[0]["Message"]) @@ -252,7 +256,9 @@ def test_execute_process_with_return_success(self): success, status, error_log_file = self.tm1.processes.execute_process_with_return(process) self.assertTrue(success) self.assertEqual(status, "CompletedSuccessfully") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) def test_execute_process_with_return_compile_error(self): process = Process(name=str(uuid.uuid4())) @@ -280,7 +286,9 @@ def test_execute_process_with_return_with_process_break(self): success, status, error_log_file = self.tm1.processes.execute_process_with_return(process) self.assertTrue(success) self.assertEqual(status, "CompletedSuccessfully") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) @skip_if_insufficient_version(version="11.4") def test_execute_process_with_return_with_process_quit(self): @@ -290,7 +298,9 @@ def test_execute_process_with_return_with_process_quit(self): success, status, error_log_file = self.tm1.processes.execute_process_with_return(process) self.assertFalse(success) self.assertEqual(status, "QuitCalled") - self.assertIsNone(error_log_file) + # v12 returns a log file for every process execution + if not verify_version(required_version="12", version=self.tm1.version): + self.assertIsNone(error_log_file) def test_compile_process_success(self): p_good = Process( @@ -314,20 +324,34 @@ def test_get_process(self): p_none_orig = copy.deepcopy(self.p_none) p_view_orig = copy.deepcopy(self.p_view) p_subset_orig = copy.deepcopy(self.p_subset) - p_odbc_orig = copy.deepcopy(self.p_odbc) p1 = self.tm1.processes.get(p_ascii_orig.name) - self.assertEqual(p1.body, p_ascii_orig.body) + p1._ui_data = p_ascii_orig._ui_data = None + self.assertEqual(p_ascii_orig.body, p1.body) + p2 = self.tm1.processes.get(p_none_orig.name) - self.assertEqual(p2.body, p_none_orig.body) + p2._ui_data = p_none_orig._ui_data = None + self.assertEqual(p_none_orig.body, p2.body) + p3 = self.tm1.processes.get(p_view_orig.name) - self.assertEqual(p3.body, p_view_orig.body) - p4 = self.tm1.processes.get(p_odbc_orig.name) - p4.datasource_password = None + p3._ui_data = p_view_orig._ui_data = None + self.assertEqual(p_view_orig.body, p3.body) + + p4 = self.tm1.processes.get(p_subset_orig.name) + p4._ui_data = p_subset_orig._ui_data = None + self.assertEqual(p_subset_orig.body, p4.body) + + @skip_if_deprecated_in_version("12") + def test_get_process_odbc(self): + p_odbc_orig = copy.deepcopy(self.p_odbc) + + p = self.tm1.processes.get(p_odbc_orig.name) + # edge cases + p.datasource_password = None p_odbc_orig.datasource_password = None - self.assertEqual(p4.body, p_odbc_orig.body) - p5 = self.tm1.processes.get(p_subset_orig.name) - self.assertEqual(p5.body, p_subset_orig.body) + p_odbc_orig._ui_data = p._ui_data = None + + self.assertEqual(p.body, p_odbc_orig.body) def test_update_process(self): # get @@ -345,8 +369,7 @@ def test_get_error_log_file_content(self): process = Process(name=str(uuid.uuid4())) process.epilog_procedure = "ItemReject('Not Relevant');" - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return(process_name=process.name) self.assertFalse(success) @@ -358,12 +381,12 @@ def test_get_error_log_file_content(self): self.tm1.processes.delete(process.name) + @skip_if_deprecated_in_version(version='12') def test_get_last_message_from_processerrorlog(self): process = Process(name=str(uuid.uuid4())) process.epilog_procedure = "ItemReject('Not Relevant');" - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) # with parameters success, status, error_log_file = self.tm1.processes.execute_with_return(process_name=process.name) self.assertFalse(success) @@ -413,8 +436,7 @@ def test_search_error_log_filenames(self): def test_delete_process(self): process = self.p_bedrock_server_wait process.name = str(uuid.uuid4()) - if not self.tm1.processes.exists(process.name): - self.tm1.processes.create(process) + self.tm1.processes.update_or_create(process) self.tm1.processes.delete(process.name) def test_search_string_in_name_no_match_startswith(self): @@ -448,11 +470,14 @@ def test_search_string_in_code_space_insensitive(self): self.assertEqual([self.p_ascii.name], process_names) def test_get_all_names(self): + process = Process(name='}' + f'{self.prefix}_ControlProcess') + process.epilog_procedure = "#Empty Process" + self.tm1.processes.update_or_create(process) self.assertNotEqual(self.tm1.processes.get_all_names(), self.tm1.processes.get_all_names(skip_control_processes=True)) self.assertNotEqual('}', self.tm1.processes.get_all_names(skip_control_processes=True)[-1][0][0]) self.assertEqual('}', self.tm1.processes.get_all_names()[-1][0][0]) - self.assertNotEqual(self.tm1.processes.get_all(), self.tm1.processes.get_all(skip_control_processes=True)) + self.tm1.processes.delete(process.name) def test_ti_formula(self): result = self.tm1.processes.evaluate_ti_expression("2+2") diff --git a/Tests/RestService_test.py b/Tests/RestService_test.py index a2bd104f..5e213fc4 100644 --- a/Tests/RestService_test.py +++ b/Tests/RestService_test.py @@ -45,7 +45,7 @@ def test_build_response_from_async_response_ok(self): b'gzip\r\nCache-Control: no-cache\r\nContent-Type: text/plain; charset=utf-8\r\n' \ b'OData-Version: 4.0\r\n\r\n\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x0b34\xd43\xd730000' \ b'\xd23\x04\x00\xf4\x1c\xa0j\x0c\x00\x00\x00' - response = RestService.build_response_from_raw_bytes(response_content) + response = RestService.build_response_from_binary_response(response_content) self.assertEqual(response.status_code, 200) self.assertEqual(response.headers.get("Content-Length"), "32") self.assertEqual(response.headers.get("Connection"), "keep-alive") @@ -63,7 +63,7 @@ def test_build_response_from_async_response_not_found(self): b'\x07\x17\xc5Sx\x05M\xa3\x14\xdaD\xda:\x94\xe2\xdd\xc5\xb7\xbdN\x92\xb3eZ;\xb1y\xa1\x95' \ b'\xa6y\xa1\x81\x92\x94\xb2_\xff\x9d\x7fRj\x0e\xbc+\xd4*\x0e\xc1i\x8fz\x04\x05[\x8c\xc25' \ b'\x98\xc2N\xd4v\x0b\xdc\x96\x8d\xa5\x147\xd2\xfb~Od\xf8E^\x00\x00\x00' - response = RestService.build_response_from_raw_bytes(response_content) + response = RestService.build_response_from_binary_response(response_content) self.assertEqual(response.status_code, 404) self.assertEqual(response.headers.get("Content-Length"), "105") self.assertEqual(response.headers.get("Connection"), "keep-alive") diff --git a/Tests/SandboxService_test.py b/Tests/SandboxService_test.py index 54f616da..e031048a 100644 --- a/Tests/SandboxService_test.py +++ b/Tests/SandboxService_test.py @@ -194,8 +194,8 @@ def test_unload_sandbox(self): self.tm1.sandboxes.create(sandbox3) time.sleep(1) self.tm1.sandboxes.unload(sandbox3.name) - - loaded = (self.tm1.sandboxes.get(self.sandbox_name3)).loaded + sandbox = self.tm1.sandboxes.get(self.sandbox_name3) + loaded = sandbox.loaded self.assertFalse(loaded) def test_load_sandbox(self): diff --git a/Tests/SecurityService_test.py b/Tests/SecurityService_test.py index 641dd6b4..c5fd1396 100644 --- a/Tests/SecurityService_test.py +++ b/Tests/SecurityService_test.py @@ -7,23 +7,24 @@ from TM1py.Objects import User from TM1py.Objects.User import UserType from TM1py.Services import TM1Service -from TM1py.Utils.Utils import CaseAndSpaceInsensitiveSet, case_and_space_insensitive_equals +from TM1py.Utils.Utils import CaseAndSpaceInsensitiveSet, case_and_space_insensitive_equals, verify_version +from .Utils import skip_if_deprecated_in_version, skip_if_insufficient_version class TestSecurityService(unittest.TestCase): tm1: TM1Service prefix = "TM1py_Tests_" - user_name = prefix + "Us'er1" + user_name = prefix + "User1" read_only_user_name = prefix + "Read_Only_user" user_name_exotic_password = "UserWithExoticPassword" enabled = True user = User(name=user_name, groups=[], password='TM1py', enabled=enabled) read_only_user = User(name=read_only_user_name, groups=[], password="TM1py", enabled=True) - group_name1 = prefix + "Gro'up1" + group_name1 = prefix + "Group1" group_name2 = prefix + "Group2" user.add_group(group_name1) - + @classmethod def setUpClass(cls): """ @@ -51,9 +52,10 @@ def setUp(self): self.tm1.security.create_user(self.user) if not self.tm1.security.user_exists(self.read_only_user.name): self.tm1.security.create_user(self.read_only_user) - self.tm1.cells.write_values( - cube_name="}ClientProperties", - cellset_as_dict={(self.read_only_user_name, "ReadOnlyUser"): 1}) + if not verify_version(required_version='12', version=self.tm1.version): + self.tm1.cells.write_values( + cube_name="}ClientProperties", + cellset_as_dict={(self.read_only_user_name, "ReadOnlyUser"): 1}) def tearDown(self): self.tm1.security.delete_user(self.user_name) @@ -70,11 +72,15 @@ def test_get_user(self): u.friendly_name = None self.assertEqual(u.body, self.user.body) + def test_get_current_user(self): me = self.tm1.security.get_current_user() - self.assertTrue(case_and_space_insensitive_equals(me.name, self.config['tm1srv01']['User'])) + self.assertIsNotNone(me.enabled) + self.assertIsNotNone(me.friendly_name) + self.assertIsNotNone(me.groups) + self.assertIsNotNone(me.name) - user = self.tm1.security.get_user(self.config['tm1srv01']['User']) + user = self.tm1.security.get_user(me.name) self.assertEqual(me, user) def test_update_user(self): @@ -121,6 +127,7 @@ def test_user_type_derive_operations_admin_from_groups(self): user = User("not_relevant", groups=["OperationsAdmin"], user_type=None) self.assertEqual(user.user_type, UserType.OperationsAdmin) + @skip_if_deprecated_in_version(version='12') def test_update_user_properties(self): # get user u = self.tm1.security.get_user(self.user_name) @@ -141,6 +148,7 @@ def test_update_user_properties(self): self.assertEqual(u.user_type, UserType.DataAdmin) self.assertIn("DataAdmin", u.groups) + @skip_if_deprecated_in_version(version='12') def test_update_user_properties_with_type_as_str(self): # get user u = self.tm1.security.get_user(self.user_name) @@ -221,6 +229,7 @@ def test_security_refresh(self): response = self.tm1.security.security_refresh() self.assertTrue(response.ok) + @skip_if_deprecated_in_version(version="12") def test_auth_with_exotic_characters_in_password(self): exotic_password = "d'8!?:Y4" @@ -263,6 +272,7 @@ def test_create_and_delete_group(self): self.assertIn(group, groups_before_delete) self.assertNotIn(group, groups_after_delete) + @skip_if_deprecated_in_version(version='12') def test_tm1service_with_encrypted_password_decode_b64_as_string(self): user_name = "TM1py user name" user = User(name=user_name, groups=["ADMIN"], password="apple") @@ -281,6 +291,7 @@ def test_tm1service_with_encrypted_password_decode_b64_as_string(self): self.tm1.security.delete_user(user.name) + @skip_if_deprecated_in_version(version='12') def test_tm1service_without_encrypted_password(self): user_name = "TM1py user name" user = User(name=user_name, groups=["ADMIN"], password="apple") @@ -299,6 +310,7 @@ def test_tm1service_without_encrypted_password(self): self.tm1.security.delete_user(user.name) + @skip_if_deprecated_in_version(version='12') def test_tm1service_with_encrypted_password(self): user_name = "TM1py user name" user = User(name=user_name, groups=["ADMIN"], password="apple") @@ -317,6 +329,7 @@ def test_tm1service_with_encrypted_password(self): self.tm1.security.delete_user(user.name) + @skip_if_deprecated_in_version(version='12') def test_tm1service_with_encrypted_password_fail(self): user_name = "TM1py user name" user = User(name=user_name, groups=["ADMIN"], password="apple") @@ -333,6 +346,7 @@ def test_tm1service_with_encrypted_password_fail(self): self.tm1.security.delete_user(user.name) + @skip_if_deprecated_in_version(version='12') def test_tm1service_with_plain_password(self): user_name = "TM1py user name" user = User(name=user_name, groups=["ADMIN"], password="apple") @@ -349,6 +363,7 @@ def test_tm1service_with_plain_password(self): pass self.tm1.security.delete_user(user.name) + @skip_if_deprecated_in_version(version='12') def test_tm1service_with_plain_password_fail(self): user_name = "TM1py user name" user = User(name=user_name, groups=["ADMIN"], password="apple") @@ -376,6 +391,7 @@ def test_group_exists_true(self): def test_group_exists_false(self): self.assertFalse(self.tm1.security.group_exists(group_name="NotAValidName")) + @skip_if_deprecated_in_version(version='12') def test_impersonate(self): tm1 = TM1Service(**self.config['tm1srv01']) self.assertNotEqual(self.user_name, tm1.whoami.name) @@ -397,12 +413,14 @@ def test_get_custom_security_groups(self): self.assertNotIn("NotExistingGroup", CaseAndSpaceInsensitiveSet(*custom_groups)) + @skip_if_deprecated_in_version(version='12') def test_get_read_only_users(self): read_only_users = self.tm1.security.get_read_only_users() self.assertEqual(1, len(read_only_users)) self.assertEqual(self.read_only_user_name, read_only_users[0]) + @skip_if_deprecated_in_version(version='12') def test_update_user_password(self): self.tm1.security.update_user_password(user_name=self.user.name, password="new_password123") diff --git a/Tests/ServerService_test.py b/Tests/ServerService_test.py index 84da7dad..b5c2534d 100644 --- a/Tests/ServerService_test.py +++ b/Tests/ServerService_test.py @@ -427,7 +427,7 @@ def test_get_message_log_with_contains_filter_or_2(self): def test_session_context_default(self): threads = self.tm1.monitoring.get_threads() for thread in threads: - if "GET /api/v1/Threads" in thread["Function"] and thread["Name"] == self.config['tm1srv01']['user']: + if "GET /Threads" in thread["Function"] and thread["Name"] == self.config['tm1srv01']['user']: self.assertTrue(thread["Context"] == "TM1py") return raise Exception("Did not find my own Thread") @@ -437,7 +437,7 @@ def test_session_context_custom(self): with TM1Service(**self.config['tm1srv01'], session_context=app_name) as tm1: threads = tm1.monitoring.get_threads() for thread in threads: - if "GET /api/v1/Threads" in thread["Function"] and thread["Name"] == self.config['tm1srv01']['user']: + if "GET /Threads" in thread["Function"] and thread["Name"] == self.config['tm1srv01']['user']: self.assertTrue(thread["Context"] == app_name) return raise Exception("Did not find my own Thread") diff --git a/Tests/Utils.py b/Tests/Utils.py index f33eb3e1..3ad958a9 100644 --- a/Tests/Utils.py +++ b/Tests/Utils.py @@ -30,7 +30,27 @@ def wrap(func): def wrapper(self, *args, **kwargs): if not verify_version(required_version=version, version=self.tm1.version): return self.skipTest( - f"Function '{ func.__name__, }' requires TM1 server version >= '{ version }'" + f"Function '{func.__name__,}' requires TM1 server version >= '{version}'" + ) + else: + return func(self, *args, **kwargs) + + return wrapper + + return wrap + + +def skip_if_deprecated_in_version(version): + """ + Checks whether TM1 Function Has Been Deprecated + """ + + def wrap(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + if verify_version(required_version=version, version=self.tm1.version): + return self.skipTest( + f"Function '{func.__name__,}' requires TM1 server version < '{version}'" ) else: return func(self, *args, **kwargs) diff --git a/Tests/Utils_test.py b/Tests/Utils_test.py index e9c50a5c..77c63d2d 100644 --- a/Tests/Utils_test.py +++ b/Tests/Utils_test.py @@ -12,6 +12,7 @@ map_cell_properties_to_compact_json_response, frame_to_significant_digits, drop_dimension_properties ) +from .Utils import skip_if_deprecated_in_version class TestUtilsMethods(unittest.TestCase): tm1: TM1Service @@ -27,6 +28,7 @@ def setUpClass(cls): cls.config.read(Path(__file__).parent.joinpath("config.ini")) cls.tm1 = TM1Service(**cls.config["tm1srv01"]) + @skip_if_deprecated_in_version(version="12") def test_get_instances_from_adminhost(self): servers = Utils.get_all_servers_from_adminhost( self.config["tm1srv01"]["address"] @@ -36,35 +38,35 @@ def test_get_instances_from_adminhost(self): def test_integerize_version(self): version = "11.0.00000.918" integerized_version = integerize_version(version) - self.assertEqual(110, integerized_version) + self.assertEqual(1100, integerized_version) version = "11.0.00100.927-0" integerized_version = integerize_version(version) - self.assertEqual(110, integerized_version) + self.assertEqual(1100, integerized_version) version = "11.1.00004.2" integerized_version = integerize_version(version) - self.assertEqual(111, integerized_version) + self.assertEqual(1110, integerized_version) version = "11.2.00000.27" integerized_version = integerize_version(version) - self.assertEqual(112, integerized_version) + self.assertEqual(1120, integerized_version) version = "11.3.00003.1" integerized_version = integerize_version(version) - self.assertEqual(113, integerized_version) + self.assertEqual(1130, integerized_version) version = "11.4.00003.8" integerized_version = integerize_version(version) - self.assertEqual(114, integerized_version) + self.assertEqual(1140, integerized_version) version = "11.7.00002.1" integerized_version = integerize_version(version) - self.assertEqual(117, integerized_version) + self.assertEqual(1170, integerized_version) version = "11.8.00000.33" integerized_version = integerize_version(version) - self.assertEqual(118, integerized_version) + self.assertEqual(1180, integerized_version) def test_verify_version_true(self): required_version = "11.7.00002.1" @@ -229,71 +231,71 @@ def test_resemble_mdx_with_member(self): self.assertTrue(resembles_mdx(mdx)) def test_format_url_args_no_single_quote(self): - url = "/api/v1/Processes('{}')/tm1.ExecuteWithReturn?$expand=*" + url = "/Processes('{}')/tm1.ExecuteWithReturn?$expand=*" process_name = "process" escaped_url = format_url(url, process_name) self.assertEqual( - "/api/v1/Processes('process')/tm1.ExecuteWithReturn?$expand=*", escaped_url + "/Processes('process')/tm1.ExecuteWithReturn?$expand=*", escaped_url ) def test_format_url_args_one_single_quote(self): - url = "/api/v1/Processes('{}')/tm1.ExecuteWithReturn?$expand=*" + url = "/Processes('{}')/tm1.ExecuteWithReturn?$expand=*" process_name = "pro'cess" escaped_url = format_url(url, process_name) self.assertEqual( - "/api/v1/Processes('pro''cess')/tm1.ExecuteWithReturn?$expand=*", + "/Processes('pro''cess')/tm1.ExecuteWithReturn?$expand=*", escaped_url, ) def test_format_url_args_multi_single_quote(self): - url = "/api/v1/Processes('{}')/tm1.ExecuteWithReturn?$expand=*" + url = "/Processes('{}')/tm1.ExecuteWithReturn?$expand=*" process_name = "pro'ces's" escaped_url = format_url(url, process_name) self.assertEqual( - "/api/v1/Processes('pro''ces''s')/tm1.ExecuteWithReturn?$expand=*", + "/Processes('pro''ces''s')/tm1.ExecuteWithReturn?$expand=*", escaped_url, ) def test_format_url_kwargs_no_single_quote(self): - url = "/api/v1/Processes('{process_name}')/tm1.ExecuteWithReturn?$expand=*" + url = "/Processes('{process_name}')/tm1.ExecuteWithReturn?$expand=*" process_name = "process" escaped_url = format_url(url, process_name=process_name) self.assertEqual( - "/api/v1/Processes('process')/tm1.ExecuteWithReturn?$expand=*", escaped_url + "/Processes('process')/tm1.ExecuteWithReturn?$expand=*", escaped_url ) def test_format_url_kwargs_one_single_quote(self): - url = "/api/v1/Processes('{process_name}')/tm1.ExecuteWithReturn?$expand=*" + url = "/Processes('{process_name}')/tm1.ExecuteWithReturn?$expand=*" process_name = "pro'cess" escaped_url = format_url(url, process_name=process_name) self.assertEqual( - "/api/v1/Processes('pro''cess')/tm1.ExecuteWithReturn?$expand=*", + "/Processes('pro''cess')/tm1.ExecuteWithReturn?$expand=*", escaped_url, ) def test_format_url_kwargs_multi_single_quote(self): - url = "/api/v1/Processes('{process_name}')/tm1.ExecuteWithReturn?$expand=*" + url = "/Processes('{process_name}')/tm1.ExecuteWithReturn?$expand=*" process_name = "pro'ces's" escaped_url = format_url(url, process_name=process_name) self.assertEqual( - "/api/v1/Processes('pro''ces''s')/tm1.ExecuteWithReturn?$expand=*", + "/Processes('pro''ces''s')/tm1.ExecuteWithReturn?$expand=*", escaped_url, ) def test_url_parameters_add(self): - url = "/api/v1/Cubes('cube')/tm1.Update" + url = "/Cubes('cube')/tm1.Update" url = add_url_parameters(url, **{"!sandbox": "sandbox1"}) self.assertEqual( - "/api/v1/Cubes('cube')/tm1.Update?!sandbox=sandbox1", + "/Cubes('cube')/tm1.Update?!sandbox=sandbox1", url) def test_url_parameters_add_with_query_options(self): - url = "/api/v1/Cellsets('abcd')?$expand=Cells($select=Value)" + url = "/Cellsets('abcd')?$expand=Cells($select=Value)" url = add_url_parameters(url, **{"!sandbox": "sandbox1"}) self.assertEqual( - "/api/v1/Cellsets('abcd')?$expand=Cells($select=Value)&!sandbox=sandbox1", + "/Cellsets('abcd')?$expand=Cells($select=Value)&!sandbox=sandbox1", url) def test_get_seconds_from_duration(self): diff --git a/Tests/config.ini b/Tests/config.ini index a6f923e8..40e49d2a 100644 --- a/Tests/config.ini +++ b/Tests/config.ini @@ -1,13 +1,23 @@ [tm1srv01] +address= +api_key= +iam_url=https://iam.cloud.ibm.com/identity/token +tenant= +database=US Database 2 + +[tm1srv02] +address= +instance= +database= +application_client_id= +application_client_secret= +user= +ssl= + +[tm1srv03] address=localhost port=12354 -user=admin -password=apple ssl=True - -[tm1srv02] -address=awstm1-ux-d01 -port=8010 -user=Admin +user=admin password=apple -ssl=True +async_requests_mode=True \ No newline at end of file