diff --git a/pytabular/__init__.py b/pytabular/__init__.py index c431fb5..d29ff16 100644 --- a/pytabular/__init__.py +++ b/pytabular/__init__.py @@ -19,10 +19,10 @@ logger = logging.getLogger("PyTabular") logger.setLevel(logging.INFO) logger.info("Logging configured...") -logger.info(f"To update logging:") -logger.info(f">>> import logging") -logger.info(f">>> pytabular.logger.setLevel(level=logging.INFO)") -logger.info(f"See https://docs.python.org/3/library/logging.html#logging-levels") +logger.info("To update logging:") +logger.info(">>> import logging") +logger.info(">>> pytabular.logger.setLevel(level=logging.INFO)") +logger.info("See https://docs.python.org/3/library/logging.html#logging-levels") logger.info(f"Python Version::{sys.version}") @@ -35,7 +35,7 @@ sys.path.append(dll) sys.path.append(os.path.dirname(__file__)) -logger.info(f"Beginning CLR references...") +logger.info("Beginning CLR references...") import clr logger.info("Adding Reference Microsoft.AnalysisServices.AdomdClient") @@ -45,7 +45,7 @@ logger.info("Adding Reference Microsoft.AnalysisServices") clr.AddReference("Microsoft.AnalysisServices") -logger.info(f"Importing specifics in module...") +logger.info("Importing specifics in module...") from .pytabular import Tabular from .basic_checks import ( @@ -64,4 +64,4 @@ from .query import Connection from .pbi_helper import find_local_pbi_instances -logger.info(f"Import successful...") +logger.info("Import successful...") diff --git a/pytabular/column.py b/pytabular/column.py index a08f807..8eb3e7a 100644 --- a/pytabular/column.py +++ b/pytabular/column.py @@ -29,6 +29,23 @@ def __init__(self, object, table) -> None: self._display.add_row("State", str(self._object.State)) self._display.add_row("DisplayFolder", str(self._object.DisplayFolder)) + def get_sample_values(self, top_n: int = 3) -> pd.DataFrame: + """Get sample values of column.""" + column_to_sample = f"'{self.Table.Name}'[{self.Name}]" + dax_query = f"""EVALUATE + TOPNSKIP( + {top_n}, + 0, + FILTER( + VALUES({column_to_sample}), + NOT ISBLANK({column_to_sample}) && LEN({column_to_sample}) > 0 + ), + 1 + ) + ORDER BY {column_to_sample} + """ + return self.Table.Model.Query(dax_query) + def Distinct_Count(self, No_Blank=False) -> int: """Get [DISTINCTCOUNT](https://learn.microsoft.com/en-us/dax/distinctcount-function-dax) of Column. diff --git a/pytabular/culture.py b/pytabular/culture.py new file mode 100644 index 0000000..31ee431 --- /dev/null +++ b/pytabular/culture.py @@ -0,0 +1,49 @@ +import logging +from object import PyObject, PyObjects + +logger = logging.getLogger("PyTabular") + + +class PyCulture(PyObject): + """Wrapper for [Microsoft.AnalysisServices.Cultures] + + Args: + Table: Parent Table to the Object Translations + """ + + def __init__(self, object, model) -> None: + super().__init__(object) + self.Model = model + self._display.add_row("Culture Name", self._object.Name) + self.ObjectTranslations = PyObjectTranslations( + [ + PyObjectTranslation(translation, self) + for translation in self._object.ObjectTranslations.GetEnumerator() + ] + ) + + +class PyObjectTranslation(PyObject): + """Wrapper for [Microsoft.AnalysisServices.Cultures] + Args: + Table: Child item of the Culture. + """ + + def __init__(self, object, culture) -> None: + self.Name = object.Object.Name + self.ObjectType = object.Object.ObjectType + self.Parent = object.Object.Parent + super().__init__(object) + self.Culture = culture + self._display.add_row("Object Property", str(self._object.Property)) + self._display.add_row("Object Value", self._object.Value) + + +class PyCultures(PyObjects): + def __init__(self, objects) -> None: + super().__init__(objects) + + +class PyObjectTranslations(PyObjects): + def __init__(self, objects) -> None: + super().__init__(objects) diff --git a/pytabular/measure.py b/pytabular/measure.py index 4a7de03..4f68893 100644 --- a/pytabular/measure.py +++ b/pytabular/measure.py @@ -1,5 +1,5 @@ import logging - +import pandas as pd from object import PyObject, PyObjects logger = logging.getLogger("PyTabular") @@ -15,12 +15,30 @@ class PyMeasure(PyObject): def __init__(self, object, table) -> None: super().__init__(object) + self.Table = table + self.Dependancies = self.get_dependancies(object) self._display.add_row("Expression", self._object.Expression, end_section=True) self._display.add_row("DisplayFolder", self._object.DisplayFolder) self._display.add_row("IsHidden", str(self._object.IsHidden)) self._display.add_row("FormatString", self._object.FormatString) + def get_dependencies(self) -> pd.DataFrame: + """Returns the dependant columns of a measure""" + dmv_query = f"select * from $SYSTEM.DISCOVER_CALC_DEPENDENCY where [OBJECT] = '{self.Name}' and [TABLE] = '{self.Table.Name}'" + return self.Table.Model.Query(dmv_query) + + def get_dependancies(self, object) -> pd.DataFrame: + """Returns the dependant objects of a measure in a dataframe based on the information in $SYSTEM.DISCOVER_CALC_DEPENDENCY""" + return self.Table.Model.Adomd.Query( + f""" + select * + from $SYSTEM.DISCOVER_CALC_DEPENDENCY + where [OBJECT] = '{object.Name}' + and [TABLE] = '{object.Table.Name}' + """ + ) + class PyMeasures(PyObjects): def __init__(self, objects) -> None: diff --git a/pytabular/object.py b/pytabular/object.py index 2d1a7eb..3f2049c 100644 --- a/pytabular/object.py +++ b/pytabular/object.py @@ -12,13 +12,14 @@ def __init__(self, object) -> None: "Properties", justify="right", style="cyan", no_wrap=True ) self._display.add_column("", justify="left", style="magenta", no_wrap=False) - self._display.add_row("Name", self._object.Name) - self._display.add_row("ObjectType", str(self._object.ObjectType)) - if not str(self._object.ObjectType) == "Model": - self._display.add_row("ParentName", self._object.Parent.Name) + + self._display.add_row("Name", self.Name) + self._display.add_row("ObjectType", str(self.ObjectType)) + if str(self.ObjectType) not in "Model": + self._display.add_row("ParentName", self.Parent.Name) self._display.add_row( "ParentObjectType", - str(self._object.Parent.ObjectType), + str(self.Parent.ObjectType), end_section=True, ) @@ -28,7 +29,8 @@ def __rich_repr__(self) -> str: def __getattr__(self, attr): if attr in self.__dict__: return getattr(self, attr) - return getattr(self._object, attr) + else: + return getattr(self._object, attr) class PyObjects: @@ -50,8 +52,7 @@ def __getitem__(self, object): return self._objects[object] def __iter__(self): - for object in self._objects: - yield object + yield from self._objects def __len__(self): return len(self._objects) diff --git a/pytabular/pytabular.py b/pytabular/pytabular.py index 6af4db4..4ef475f 100644 --- a/pytabular/pytabular.py +++ b/pytabular/pytabular.py @@ -27,6 +27,7 @@ from partition import PyPartitions from column import PyColumns from measure import PyMeasures +from culture import PyCultures, PyCulture from relationship import PyRelationship, PyRelationships from object import PyObject from refresh import PyRefresh @@ -76,7 +77,7 @@ def __init__(self, CONNECTION_STR: str): self.Model = self.Database.Model logger.info(f"Connected to Model - {self.Model.Name}") self.Adomd: Connection = Connection(self.Server) - self.Effective_Users = dict() + self.Effective_Users: dict = {} self.PyRefresh = PyRefresh # Build PyObjects self.Reload_Model_Info() @@ -87,7 +88,7 @@ def __init__(self, CONNECTION_STR: str): # Building rich table display for repr self._display.add_row( "EstimatedSize", - str(round(self.Database.EstimatedSize / 1000000000, 2)) + " GB", + f"{round(self.Database.EstimatedSize / 1000000000, 2)} GB", end_section=True, ) self._display.add_row("# of Tables", str(len(self.Tables))) @@ -104,8 +105,6 @@ def __init__(self, CONNECTION_STR: str): logger.debug("Registering Disconnect on Termination...") atexit.register(self.Disconnect) - pass - def Reload_Model_Info(self) -> bool: """Runs on __init__ iterates through details, can be called after any model changes. Called in SaveChanges() @@ -114,6 +113,13 @@ def Reload_Model_Info(self) -> bool: """ self.Database.Refresh() + self.Cultures = PyCultures( + [ + PyCulture(culture, self) + for culture in self.Model.Cultures.GetEnumerator() + ] + ) + self.Tables = PyTables( [PyTable(table, self) for table in self.Model.Tables.GetEnumerator()] ) @@ -141,11 +147,7 @@ def Is_Process(self) -> bool: bool: True if DMV shows Process, False if not. """ _jobs_df = self.Query("select * from $SYSTEM.DISCOVER_JOBS") - return ( - True - if len(_jobs_df[_jobs_df["JOB_DESCRIPTION"] == "Process"]) > 0 - else False - ) + return len(_jobs_df[_jobs_df["JOB_DESCRIPTION"] == "Process"]) > 0 def Disconnect(self) -> bool: """Disconnects from Model @@ -446,16 +448,16 @@ def Query( """ if Effective_User is None: return self.Adomd.Query(Query_Str) - else: - try: - conn = self.Effective_Users[Effective_User] - if isinstance(conn, Connection): - conn.Query(Query_Str) - except Exception: - conn = Connection(self.Server, Effective_User=Effective_User) - self.Effective_Users[Effective_User] = conn - - return conn.Query(Query_Str) + + try: + conn = self.Effective_Users[Effective_User] + if isinstance(conn, Connection): + conn.Query(Query_Str) + except Exception: + conn = Connection(self.Server, Effective_User=Effective_User) + self.Effective_Users[Effective_User] = conn + + return conn.Query(Query_Str) def Query_Every_Column( self, query_function: str = "COUNTROWS(VALUES(_))" diff --git a/pytabular/utils.py b/pytabular/utils.py new file mode 100644 index 0000000..d8d304e --- /dev/null +++ b/pytabular/utils.py @@ -0,0 +1,47 @@ +def dataframe_to_dict(df): + """ + Convert to Dataframe to dictionary and + alter columns names with; + - Underscores (_) to spaces + - All Strings are converted to Title Case. + """ + list_of_dicts = df.to_dict("records") + return [ + {k.replace("_", " ").title(): v for k, v in dict.items()} + for dict in list_of_dicts + ] + + +def dict_to_markdown_table(list_of_dicts: list, columns_to_include: list = None): + """ + Description: Generate a Markdown table based on a list of dictionaries. + Args: + list_of_dicts -> List of Dictionaries that need to be converted + to a markdown table. + columns_to_include -> Default = None, and all colums are included. + If a list is supplied, those columns will be included. + Example: + columns = ['Referenced Object Type', 'Referenced Table', 'Referenced Object'] + dict_to_markdown_table(dependancies, columns) + + Result: + | Referenced Object Type | Referenced Table | Referenced Object | + | ---------------------- | ---------------- | ------------------------------- | + | TABLE | Cases | Cases | + | COLUMN | Cases | IsClosed | + | CALC_COLUMN | Cases | Resolution Time (Working Hours) | + + """ + keys = set().union(*[set(d.keys()) for d in list_of_dicts]) + + if columns_to_include is not None: + keys = list(keys.intersection(columns_to_include)) + + table_header = f"| {' | '.join(map(str, keys))} |" + table_header_separator = "|-----" * len(keys) + "|" + markdown_table = [table_header, table_header_separator] + + for row in list_of_dicts: + table_row = f"| {' | '.join(str(row.get(key, '')) for key in keys)} |" + markdown_table.append(table_row) + return "\n".join(markdown_table) diff --git a/test/adventureworks/AdventureWorks Sales.pbix b/test/adventureworks/AdventureWorks Sales.pbix index d4c7877..6f6bce3 100644 Binary files a/test/adventureworks/AdventureWorks Sales.pbix and b/test/adventureworks/AdventureWorks Sales.pbix differ