From 4a81bc83d8eba23e6fba7c65d41bb7afd53c1577 Mon Sep 17 00:00:00 2001 From: Curtis Stallings Date: Sat, 14 Jan 2023 12:09:48 -0600 Subject: [PATCH] docstr-coverage poc --- .docstr.yaml | 4 + .github/workflows/docstr-coverage.yml | 31 +++++++ pyproject.toml | 2 +- pytabular/__init__.py | 6 ++ pytabular/best_practice_analyzer.py | 13 +++ pytabular/column.py | 20 ++++- pytabular/culture.py | 13 +-- pytabular/logic_utils.py | 2 + pytabular/measure.py | 12 +++ pytabular/object.py | 49 +++++++++++- pytabular/partition.py | 16 +++- pytabular/pbi_helper.py | 6 ++ pytabular/pytabular.py | 30 +++++-- pytabular/query.py | 4 + pytabular/refresh.py | 111 ++++++++++++++++++++------ pytabular/relationship.py | 30 ++++--- pytabular/table.py | 27 ++++--- pytabular/tabular_editor.py | 8 +- pytabular/tabular_tracing.py | 12 +++ 19 files changed, 329 insertions(+), 67 deletions(-) create mode 100644 .docstr.yaml create mode 100644 .github/workflows/docstr-coverage.yml diff --git a/.docstr.yaml b/.docstr.yaml new file mode 100644 index 0000000..14539d7 --- /dev/null +++ b/.docstr.yaml @@ -0,0 +1,4 @@ +paths: ["pytabular"] +verbose: 3 +skip_init: True +fail_under: 100 \ No newline at end of file diff --git a/.github/workflows/docstr-coverage.yml b/.github/workflows/docstr-coverage.yml new file mode 100644 index 0000000..cedae1e --- /dev/null +++ b/.github/workflows/docstr-coverage.yml @@ -0,0 +1,31 @@ +# This is a basic workflow to help you get started with Actions + +name: docstr-coverage + +# Controls when the workflow will run +on: + pull_request: + branches: [ master ] + push: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-python@v2 + - run: pip install --upgrade pip + - run: pip install docstr-coverage + - run: docstr-coverage \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ad4462e..735ecf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python_tabular" -version = "0.2.9" +version = "0.3.0" authors = [ { name="Curtis Stallings", email="curtisrstallings@gmail.com" }, ] diff --git a/pytabular/__init__.py b/pytabular/__init__.py index 9258181..5fcb03c 100644 --- a/pytabular/__init__.py +++ b/pytabular/__init__.py @@ -1,3 +1,9 @@ +""" +Welcome to PyTabular. +__init__.py will start to setup the basics. +It will setup logging and make sure Pythonnet is good to go. +Then it will begin to import specifics of the module. +""" # flake8: noqa import logging import os diff --git a/pytabular/best_practice_analyzer.py b/pytabular/best_practice_analyzer.py index ddcc342..6b4103a 100644 --- a/pytabular/best_practice_analyzer.py +++ b/pytabular/best_practice_analyzer.py @@ -1,3 +1,10 @@ +""" +best_practice_analyzer is currently just a POC. +You can call the BPA class to download or specify your own BPA file. +It is used with tabular_editor.py to run BPA. +I did not want to re-invent the wheel, +so just letting TE2 work it's magic. +""" import logging import requests as r import atexit @@ -42,6 +49,12 @@ class BPA: """Setting BPA Class for future work...""" def __init__(self, File_Path: str = "Default") -> None: + """BPA class to be used with the TE2 class. + + Args: + File_Path (str, optional): See `Download_BPA_File()`. Defaults to "Default". + If "Default, then will run `Download_BPA_File()` without args. + """ logger.debug(f"Initializing BPA Class:: {File_Path}") if File_Path == "Default": self.Location: str = Download_BPA_File() diff --git a/pytabular/column.py b/pytabular/column.py index 55bda6f..1bfa456 100644 --- a/pytabular/column.py +++ b/pytabular/column.py @@ -1,3 +1,6 @@ +"""`column.py` houses the main `PyColumn` and `PyColumns` class. +Once connected to your model, interacting with column(s) will be done through these classes. +""" import logging import pandas as pd from object import PyObject, PyObjects @@ -7,8 +10,11 @@ class PyColumn(PyObject): - """Wrapper for [Microsoft.AnalysisServices.Tabular.Column](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.column?view=analysisservices-dotnet). - With a few other bells and whistles added to it. WIP + """Wrapper for [Column](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.column?view=analysisservices-dotnet). + With a few other bells and whistles added to it. + Notice the `PyObject` magic method `__getattr__()` will search in `self._object` + if it is unable to find it in the default attributes. + This let's you also easily check the default .Net properties. Args: Table: Parent Table to the Column @@ -89,11 +95,19 @@ def Values(self) -> pd.DataFrame: class PyColumns(PyObjects): + """Groups together multiple columns. See `PyObjects` class for what more it can do. + You can interact with `PyColumns` straight from model. For ex: `model.Columns`. + Or through individual tables `model.Tables[TABLE_NAME].Columns`. + You can even filter down with `.Find()`. For example find all columns with `Key` in name. + `model.Columns.Find('Key')`. + """ + def __init__(self, objects) -> None: super().__init__(objects) def Query_All(self, query_function: str = "COUNTROWS(VALUES(_))") -> pd.DataFrame: - """This will dynamically create a query to pull all columns from the model and run the query function. It will replace the _ with the column to run. + """This will dynamically create a query to pull all columns from the model and run the query function. + It will replace the _ with the column to run. Args: query_function (str, optional): Dax query is dynamically building a query with the UNION & ROW DAX Functions. diff --git a/pytabular/culture.py b/pytabular/culture.py index 31ee431..5fa7bca 100644 --- a/pytabular/culture.py +++ b/pytabular/culture.py @@ -1,3 +1,5 @@ +"""`culture.py` is used to house the `PyCulture`, `PyCultures`, and `PyObjectTranslations` classes. +""" import logging from object import PyObject, PyObjects @@ -5,7 +7,7 @@ class PyCulture(PyObject): - """Wrapper for [Microsoft.AnalysisServices.Cultures] + """Wrapper for [Cultures](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.culture?view=analysisservices-dotnet). Args: Table: Parent Table to the Object Translations @@ -24,10 +26,7 @@ def __init__(self, object, model) -> None: class PyObjectTranslation(PyObject): - """Wrapper for [Microsoft.AnalysisServices.Cultures] - Args: - Table: Child item of the Culture. - """ + """Wrapper for [ObjectTranslation](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.objecttranslation?view=analysisservices-dotnet)""" def __init__(self, object, culture) -> None: self.Name = object.Object.Name @@ -40,10 +39,14 @@ def __init__(self, object, culture) -> None: class PyCultures(PyObjects): + """Houses grouping of `PyCulture`.""" + def __init__(self, objects) -> None: super().__init__(objects) class PyObjectTranslations(PyObjects): + """Houses grouping of `PyObjectTranslation`.""" + def __init__(self, objects) -> None: super().__init__(objects) diff --git a/pytabular/logic_utils.py b/pytabular/logic_utils.py index 59d16f7..656a739 100644 --- a/pytabular/logic_utils.py +++ b/pytabular/logic_utils.py @@ -1,3 +1,5 @@ +"""`logic_utils` used to store multiple functions that are used in many different files. +""" import logging import datetime import os diff --git a/pytabular/measure.py b/pytabular/measure.py index 6975083..e54c553 100644 --- a/pytabular/measure.py +++ b/pytabular/measure.py @@ -1,3 +1,7 @@ +""" +`measure.py` houses the main `PyMeasure` and `PyMeasures` class. +Once connected to your model, interacting with measure(s) will be done through these classes. +""" import logging import pandas as pd from object import PyObject, PyObjects @@ -29,5 +33,13 @@ def get_dependencies(self) -> pd.DataFrame: class PyMeasures(PyObjects): + """ + Groups together multiple measures. See `PyObjects` class for what more it can do. + You can interact with `PyMeasures` straight from model. For ex: `model.Measures`. + Or through individual tables `model.Tables[TABLE_NAME].Measures`. + You can even filter down with `.Find()`. For example find all measures with `ratio` in name. + `model.Measures.Find('ratio')`. + """ + def __init__(self, objects) -> None: super().__init__(objects) diff --git a/pytabular/object.py b/pytabular/object.py index 3f2049c..79204ca 100644 --- a/pytabular/object.py +++ b/pytabular/object.py @@ -1,3 +1,7 @@ +""" +`object.py` stores the main parent classes `PyObject` and `PyObjects`. +These classes are used with the others (Tables, Columns, Measures, Partitions, etc.). +""" from abc import ABC from rich.console import Console from rich.table import Table @@ -5,6 +9,14 @@ class PyObject(ABC): + """The main parent class for your (Tables, Columns, Measures, Partitions, etc.). + Notice the magic methods. `__rich_repr__()` starts the baseline for displaying your model. + It uses the amazing `rich` python package and + builds your display from the `self._display`. + `__getattr__()` will check in `self._object`, if unable to find anything in `self`. + This will let you pull properties from the main .Net class. + """ + def __init__(self, object) -> None: self._object = object self._display = Table(title=self.Name) @@ -24,9 +36,11 @@ def __init__(self, object) -> None: ) def __rich_repr__(self) -> str: + """See [Rich Repr Protocol](https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol)""" Console().print(self._display) def __getattr__(self, attr): + """Searches in `self.__dict__` first, then `self._object`.""" if attr in self.__dict__: return getattr(self, attr) else: @@ -34,6 +48,14 @@ def __getattr__(self, attr): class PyObjects: + """ + The main parent class for grouping your (Tables, Columns, Measures, Partitions, etc.). + Notice the magic methods. `__rich_repr__()` starts the baseline for displaying your model. + It uses the amazing `rich` python package and + builds your display from the `self._display`. + Still building out the magic methods to give `PyObjects` more flexibility. + """ + def __init__(self, objects) -> None: self._objects = objects self._display = Table(title=str(self.__class__.mro()[0])) @@ -41,9 +63,13 @@ def __init__(self, objects) -> None: self._display.add_row(str(index), obj.Name) def __rich_repr__(self) -> str: + """See [Rich Repr Protocol](https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol)""" Console().print(self._display) def __getitem__(self, object): + """Checks if item is str or int. If string will iterate through and try to find matching name. + Otherwise, will call into `self._objects[int]` to retrieve item. + """ if isinstance(object, str): return [pyobject for pyobject in self._objects if object == pyobject.Name][ -1 @@ -52,12 +78,22 @@ def __getitem__(self, object): return self._objects[object] def __iter__(self): + """Default `__iter__()` to iterate through `PyObjects`.""" yield from self._objects - def __len__(self): + def __len__(self) -> int: + """Default `__len__()` + + Returns: + int: Number of PyObject in PyObjects + """ return len(self._objects) def __iadd__(self, obj): + """ + Add a `PyObject` or `PyObjects` to your current `PyObjects` class. + This is useful for building out a custom `PyObjects` class to work with. + """ if isinstance(obj, Iterable): self._objects.__iadd__(obj._objects) else: @@ -66,7 +102,16 @@ def __iadd__(self, obj): self.__init__(self._objects) return self - def Find(self, object_str): + def Find(self, object_str: str): + """Finds any or all `PyObject` inside of `PyObjects` that match the `object_str`. + It is case insensitive. + + Args: + object_str (str): str to lookup in `PyObjects` + + Returns: + PyObjects: Returns a `PyObjects` class with all `PyObject` where the `PyObject.Name` matches `object_str`. + """ items = [ object for object in self._objects diff --git a/pytabular/partition.py b/pytabular/partition.py index fff32db..395d469 100644 --- a/pytabular/partition.py +++ b/pytabular/partition.py @@ -1,3 +1,7 @@ +""" +`partition.py` houses the main `PyPartition` and `PyPartitions` class. +Once connected to your model, interacting with partition(s) will be done through these classes. +""" import logging from object import PyObject, PyObjects @@ -9,8 +13,8 @@ class PyPartition(PyObject): - """Wrapper for [Microsoft.AnalysisServices.Partition](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.partition?view=analysisservices-dotnet). - With a few other bells and whistles added to it. WIP + """Wrapper for [Partition](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.partition?view=analysisservices-dotnet). + With a few other bells and whistles added to it. Args: Table: Parent Table to the Column @@ -46,5 +50,13 @@ def Refresh(self, *args, **kwargs) -> pd.DataFrame: class PyPartitions(PyObjects): + """ + Groups together multiple partitions. See `PyObjects` class for what more it can do. + You can interact with `PyPartitions` straight from model. For ex: `model.Partitions`. + Or through individual tables `model.Tables[TABLE_NAME].Partitions`. + You can even filter down with `.Find()`. For example find all partition with `prev-year` in name. + `model.Partitions.Find('prev-year')`. + """ + def __init__(self, objects) -> None: super().__init__(objects) diff --git a/pytabular/pbi_helper.py b/pytabular/pbi_helper.py index 94ed09a..6c45035 100644 --- a/pytabular/pbi_helper.py +++ b/pytabular/pbi_helper.py @@ -1,3 +1,9 @@ +"""`pbi_helper.py` was reverse engineered from [DaxStudio PowerBiHelper.cs](https://github.com/DaxStudio/DaxStudio/blob/master/src/DaxStudio.UI/Utils/PowerBIHelper.cs). +So all credit and genius should go to DaxStudio. +I just wanted it in python... +The main function is `find_local_pbi_instances()`. +It will find any open PBIX files on your computer and spit out a connection string for you. +""" import pytabular as p import subprocess diff --git a/pytabular/pytabular.py b/pytabular/pytabular.py index 340a312..5d5d6a6 100644 --- a/pytabular/pytabular.py +++ b/pytabular/pytabular.py @@ -1,3 +1,7 @@ +""" +`pytabular.py` is where it all started. So there is a lot of old methods that will eventually be deprecated. +Main class is `Tabular()`. Use that for connecting with your models. +""" import logging from Microsoft.AnalysisServices.Tabular import ( @@ -186,8 +190,15 @@ def Update(self, UpdateOptions: UpdateOptions = UpdateOptions.ExpandFull) -> Non logger.debug("Running Update Request") return self.Database.Update(UpdateOptions) - def SaveChanges(self) -> bool: + def SaveChanges(self): + """Called after refreshes or any model changes. + Currently will return a named tuple of all changes detected. However a ton of room for improvement here. + """ + def property_changes(Property_Changes): + """ + Returns any property changes. + """ Property_Change = namedtuple( "Property_Change", "New_Value Object Original_Value Property_Name Property_Type", @@ -238,7 +249,8 @@ def property_changes(Property_Changes): ) def Backup_Table(self, table_str: str) -> bool: - """USE WITH CAUTION, EXPERIMENTAL. Backs up table in memory, brings with it measures, columns, hierarchies, relationships, roles, etc. + """Will be removed. This is experimental with no written pytest for it. + Backs up table in memory, brings with it measures, columns, hierarchies, relationships, roles, etc. It will add suffix '_backup' to all objects. Refresh is performed from source during backup. @@ -254,6 +266,7 @@ def Backup_Table(self, table_str: str) -> bool: logger.info("Beginning Renames") def rename(items): + """Iterates through items and requests rename.""" for item in items: item.RequestRename(f"{item.Name}_backup") logger.debug(f"Renamed - {item.Name}") @@ -294,6 +307,7 @@ def rename(items): self.Model.Relationships.Add(relationship) def clone_role_permissions(): + """Clones the role permissions for table.""" logger.info("Beginning to handle roles and permissions for table...") logger.debug("Finding Roles...") roles = [ @@ -337,7 +351,7 @@ def clone_role_permissions(): return True def Revert_Table(self, table_str: str) -> bool: - """USE WITH CAUTION, EXPERIMENTAL. This is used in conjunction with Backup_Table(). + """Will be removed. This is experimental with no written pytest for it. This is used in conjunction with Backup_Table(). It will take the 'TableName_backup' and replace with the original. Example scenario -> 1. model.Backup_Table('TableName') @@ -372,6 +386,9 @@ def Revert_Table(self, table_str: str) -> bool: ] def remove_role_permissions(): + """ + Removes role permissions from table. + """ logger.debug( f"Finding table and column permission in roles to remove from {table_str}" ) @@ -405,6 +422,9 @@ def remove_role_permissions(): remove_role_permissions() def dename(items): + """ + Denames all items. + """ for item in items: logger.debug(f"Removing Suffix for {item.Name}") item.RequestRename(remove_suffix(item.Name, "_backup")) @@ -462,7 +482,7 @@ def Query( def Query_Every_Column( self, query_function: str = "COUNTROWS(VALUES(_))" ) -> pd.DataFrame: - """This will dynamically create a query to pull all columns from the model and run the query function. It will replace the _ with the column to run. + """Will be removed. Use `Query_All()` in your `PyColumns` instead. This will dynamically create a query to pull all columns from the model and run the query function. It will replace the _ with the column to run. Args: query_function (str, optional): Dax query is dynamically building a query with the UNION & ROW DAX Functions. Returns: @@ -486,7 +506,7 @@ def Query_Every_Column( return self.Query(query_str) def Query_Every_Table(self, query_function: str = "COUNTROWS(_)") -> pd.DataFrame: - """This will dynamically create a query to pull all tables from the model and run the query function. + """Will be removed. Use `Query_All()` in your `PyTables` instead. This will dynamically create a query to pull all tables from the model and run the query function. It will replace the _ with the table to run. Args: query_function (str, optional): Dax query is dynamically building a query with the UNION & ROW DAX Functions. Defaults to 'COUNTROWS(_)'. diff --git a/pytabular/query.py b/pytabular/query.py index 226f472..c262c77 100644 --- a/pytabular/query.py +++ b/pytabular/query.py @@ -1,3 +1,7 @@ +""" +`query.py` houses a custom `Connection` class that uses the .Net AdomdConnection. +`Connection` is created automatically when connecting to your model. +""" import logging import os from typing import Union diff --git a/pytabular/refresh.py b/pytabular/refresh.py index 4e4f2c2..49a68c2 100644 --- a/pytabular/refresh.py +++ b/pytabular/refresh.py @@ -1,3 +1,6 @@ +""" +`refresh.py` is the main file to handle all the components of refreshing your model. +""" from tabular_tracing import Refresh_Trace, Base_Trace import logging from Microsoft.AnalysisServices.Tabular import ( @@ -17,14 +20,18 @@ class Refresh_Check(ABC): - def __init__(self, name, function, assertion=None) -> None: - """TODO DOCUMENTATION - - Args: - name (_type_): _description_ - function (_type_): _description_ - assertion (_type_, optional): _description_. Defaults to None. - """ + """`Refresh_Check` is an assertion you run after your refreshes. + It will run the given `function` before and after refreshes, + then run the assertion of before and after. The default given in a refresh is to check row count. + It will check row count before, and row count after. Then fail if row count after is zero. + + Args: + name (str): Name of Refresh_Check + function (function): Function to run before and after refresh. + assertion (function): Functino that takes a `pre` and `post` argument to output True or False. + """ + + def __init__(self, name: str, function, assertion=None) -> None: super().__init__() self._name = name self._function = function @@ -33,6 +40,9 @@ def __init__(self, name, function, assertion=None) -> None: self._post = None def __repr__(self) -> str: + """ + `__repre__` that returns details on `Refresh_Check`. + """ return f"{self.name} - {self.pre} - {self.post} - {str(self.function)}" @property @@ -100,7 +110,16 @@ def assertion(self, assertion): def assertion(self): del self._assertion - def _check(self, stage): + def _check(self, stage: str): + """Runs the given function and stores results. + Stored in either `self.pre` or `self.post` depending on `stage`. + + Args: + stage (str): Either 'Pre' or 'Post' + + Returns: + object: Returns the results of the pre or post check. + """ logger.debug(f"Running {stage}-Check for {self.name}") results = self.function() if stage == "Pre": @@ -111,15 +130,20 @@ def _check(self, stage): return results def Pre_Check(self): + """Runs `self._check("Pre")`""" self._check("Pre") pass def Post_Check(self): + """Runs `self._check("Post")` then `self.Assertion()`""" self._check("Post") self.Assertion() pass def Assertion(self): + """Runs the given self.assertion function with `self.pre` and `self.post`. + So, `self.assertion(self.pre, self.post)`. + """ if self.assertion is None: logger.debug("Skipping assertion none given") else: @@ -135,30 +159,50 @@ def Assertion(self): class Refresh_Check_Collection: - def __init__(self, refresh_checks: Refresh_Check = []) -> None: - """TODO Documentation + """Groups together your `Refresh_Checks` to handle multiple types of checks in a single refresh.""" - Args: - refresh_checks (Refresh_Check, optional): _description_. Defaults to []. - """ + def __init__(self, refresh_checks: Refresh_Check = []) -> None: self._refresh_checks = refresh_checks pass def __iter__(self): + """Basic iteration through the different `Refresh_Check`(s).""" for refresh_check in self._refresh_checks: yield refresh_check def add_refresh_check(self, refresh_check: Refresh_Check): + """Add a Refresh_Check + + Args: + refresh_check (Refresh_Check): `Refresh_Check` class. + """ self._refresh_checks.append(refresh_check) def remove_refresh_check(self, refresh_check: Refresh_Check): + """Remove a Refresh_Check + + Args: + refresh_check (Refresh_Check): `Refresh_Check` class. + """ self._refresh_checks.remove(refresh_check) def clear_refresh_checks(self): + """Clear Refresh Checks.""" self._refresh_checks.clear() class PyRefresh: + """PyRefresh Class to handle refreshes of model. + + Args: + model (Tabular): Main Tabular Class + object (Union[str, PyTable, PyPartition, Dict[str, Any]]): Designed to handle a few different ways of selecting a refresh. Can be a string of 'Table Name' or dict of {'Table Name': 'Partition Name'} or even some combination with the actual PyTable and PyPartition classes. + trace (Base_Trace, optional): Set to `None` if no Tracing is desired, otherwise you can use default trace or create your own. Defaults to Refresh_Trace. + refresh_checks (Refresh_Check_Collection, optional): Add your `Refresh_Check`'s into a `Refresh_Check_Collection`. Defaults to Refresh_Check_Collection(). + default_row_count_check (bool, optional): Quick built in check will fail the refresh if post check row count is zero. Defaults to True. + refresh_type (RefreshType, optional): Input RefreshType desired. Defaults to RefreshType.Full. + """ + def __init__( self, model, @@ -168,16 +212,6 @@ def __init__( default_row_count_check: bool = True, refresh_type: RefreshType = RefreshType.Full, ) -> None: - """PyRefresh Class to handle refreshes of model. - - Args: - model (Tabular): Main Tabular Class - object (Union[str, PyTable, PyPartition, Dict[str, Any]]): Designed to handle a few different ways of selecting a refresh. Can be a string of 'Table Name' or dict of {'Table Name': 'Partition Name'} or even some combination with the actual PyTable and PyPartition classes. - trace (Base_Trace, optional): Set to `None` if no Tracing is desired, otherwise you can use default trace or create your own. Defaults to Refresh_Trace. - refresh_checks (Refresh_Check_Collection, optional): Add your `Refresh_Check`'s into a `Refresh_Check_Collection`. Defaults to Refresh_Check_Collection(). - default_row_count_check (bool, optional): Quick built in check will fail the refresh if post check row count is zero. Defaults to True. - refresh_type (RefreshType, optional): Input RefreshType desired. Defaults to RefreshType.Full. - """ self.model = model self.object = object self.trace = trace @@ -191,6 +225,9 @@ def __init__( pass def _pre_checks(self): + """Checks if any `Base_Trace` classes are needed from `Tabular_Tracing.py`. + Then checks if any `Refresh_Checks` are needed, along with the default `Row_Count` check. + """ logger.debug("Running Pre-checks") if self.trace is not None: logger.debug("Getting Trace") @@ -206,6 +243,7 @@ def _pre_checks(self): ] def row_count_assertion(pre, post): + """Checks if table refreshed zero rows.""" post = 0 if post is None else post return post > 0 @@ -219,6 +257,9 @@ def row_count_assertion(pre, post): pass def _post_checks(self): + """If traces are running it Stops and Drops it. + Runs through any `Post_Checks()` in `Refresh_Checks`. + """ if self.trace is not None: self.trace.Stop() self.trace.Drop() @@ -229,9 +270,11 @@ def _post_checks(self): pass def _get_trace(self) -> Base_Trace: + """Creates Trace and creates it in model.""" return self.trace(self.model) def _find_table(self, table_str: str) -> Table: + """Finds table in `PyTables` class.""" try: result = self.model.Tables[table_str] except Exception: @@ -240,6 +283,7 @@ def _find_table(self, table_str: str) -> Table: return result def _find_partition(self, table: Table, partition_str: str) -> Partition: + """Finds partition in `PyPartitions` class.""" try: result = table.Partitions[partition_str] except Exception: @@ -248,6 +292,7 @@ def _find_partition(self, table: Table, partition_str: str) -> Partition: return result def _refresh_table(self, table: PyTable) -> None: + """Runs .Net `RequestRefresh()` on table.""" logging.info(f"Requesting refresh for {table.Name}") self._objects_to_refresh += [ {table: [partition for partition in table.Partitions]} @@ -255,15 +300,21 @@ def _refresh_table(self, table: PyTable) -> None: table.RequestRefresh(self.refresh_type) def _refresh_partition(self, partition: PyPartition) -> None: + """Runs .Net `RequestRefresh()` on partition.""" logging.info(f"Requesting refresh for {partition.Table.Name}|{partition.Name}") self._objects_to_refresh += [{partition.Table: [partition]}] partition.RequestRefresh(self.refresh_type) def _refresh_dict(self, partition_dict: Dict) -> None: + """Handles refreshes if argument given was a dictionary.""" for table in partition_dict.keys(): table_object = self._find_table(table) if isinstance(table, str) else table def handle_partitions(object): + """ + Figures out if partition argument given is a str or an actual `PyPartition`. + Then will run `self._refresh_partition()` appropriately. + """ if isinstance(object, str): self._refresh_partition(self._find_partition(table_object, object)) elif isinstance(object, PyPartition): @@ -274,6 +325,7 @@ def handle_partitions(object): handle_partitions(partition_dict[table]) def _request_refresh(self, object): + """Base method to parse through argument and figure out what needs to be refreshed. Someone please make this better...""" logger.debug(f"Requesting Refresh for {object}") if isinstance(object, str): self._refresh_table(self._find_table(object)) @@ -289,6 +341,14 @@ def _request_refresh(self, object): [self._request_refresh(obj) for obj in object] def _refresh_report(self, Property_Changes) -> pd.DataFrame: + """Builds a DataFrame that displays details on the refresh. + + Args: + Property_Changes: Which is returned from `model.SaveChanges()` + + Returns: + pd.DataFrame: DataFrame of refresh details. + """ logger.debug("Running Refresh Report...") refresh_data = [] for property_change in Property_Changes: @@ -310,6 +370,9 @@ def _refresh_report(self, Property_Changes) -> pd.DataFrame: ) def Run(self) -> None: + """Brings it all together. When ready, executes all the pre checks. + Then refreshes. Then runs all the post checks. + """ if self.model.Server.Connected is False: logger.info(f"{self.Server.Name} - Reconnecting...") self.model.Server.Reconnect() diff --git a/pytabular/relationship.py b/pytabular/relationship.py index 153f6c9..0eb26c0 100644 --- a/pytabular/relationship.py +++ b/pytabular/relationship.py @@ -1,3 +1,7 @@ +""" +`relationship.py` houses the main `PyRelationship` and `PyRelationships` class. +Once connected to your model, interacting with relationship(s) will be done through these classes. +""" import logging from object import PyObject from pytabular.object import PyObjects @@ -14,14 +18,7 @@ class PyRelationship(PyObject): - """Wrapper for [Microsoft.AnalysisServices.Tabular.Table](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.table?view=analysisservices-dotnet). - With a few other bells and whistles added to it. You can use the table to access the nested Columns and Partitions. WIP - - Attributes: - Model: Reference to Tabular class - Partitions: Reference to Table Partitions - Columns: Reference to Table Columns - """ + """Wrapper for [Relationship Class](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.relationship?view=analysisservices-dotnet).""" def __init__(self, object, model) -> None: super().__init__(object) @@ -48,16 +45,23 @@ def __init__(self, object, model) -> None: class PyRelationships(PyObjects): - """Iterator to handle tables. Accessible via `Tables` attribute in Tabular class. - - Args: - PyTable: PyTable class + """ + Groups together multiple relationships. See `PyObjects` class for what more it can do. + You can interact with `PyRelationships` straight from model. For ex: `model.Relationships`. """ def __init__(self, objects) -> None: super().__init__(objects) - def Related(self, object: Union[PyTable, str]): + def Related(self, object: Union[PyTable, str]) -> PyTables: + """Finds related tables of a given table. + + Args: + object (Union[PyTable, str]): `PyTable` or str of table name to find related tables for. + + Returns: + PyTables: Returns `PyTables` class of the tables in question. + """ table_to_find = object if isinstance(object, str) else object.Name to_tables = [ rel.To_Table diff --git a/pytabular/table.py b/pytabular/table.py index e825c1a..d273ba2 100644 --- a/pytabular/table.py +++ b/pytabular/table.py @@ -1,3 +1,7 @@ +""" +`table.py` houses the main `PyTable` and `PyTables` class. +Once connected to your model, interacting with table(s) will be done through these classes. +""" import logging from object import PyObject import pandas as pd @@ -12,13 +16,11 @@ class PyTable(PyObject): - """Wrapper for [Microsoft.AnalysisServices.Tabular.Table](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.table?view=analysisservices-dotnet). - With a few other bells and whistles added to it. You can use the table to access the nested Columns and Partitions. WIP - - Attributes: - Model: Reference to Tabular class - Partitions: Reference to Table Partitions - Columns: Reference to Table Columns + """Wrapper for [Table Class](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.table?view=analysisservices-dotnet). + With a few other bells and whistles added to it. + Notice the `PyObject` magic method `__getattr__()` will search in `self._object` + if it is unable to find it in the default attributes. + This let's you also easily check the default .Net properties. """ def __init__(self, object, model) -> None: @@ -84,20 +86,23 @@ def Last_Refresh(self) -> datetime: return max(partition_refreshes) def Related(self): + """Returns tables with a relationship with the table in question.""" return self.Model.Relationships.Related(self) class PyTables(PyObjects): - """Iterator to handle tables. Accessible via `Tables` attribute in Tabular class. - - Args: - PyTable: PyTable class + """ + Groups together multiple tables. See `PyObjects` class for what more it can do. + You can interact with `PyTables` straight from model. For ex: `model.Tables`. + You can even filter down with `.Find()`. For example find all tables with `fact` in name. + `model.Tables.Find('fact')`. """ def __init__(self, objects) -> None: super().__init__(objects) def Refresh(self, *args, **kwargs): + """Refreshes all `PyTable`(s) in class.""" model = self._objects[0].Model return model.Refresh(self, *args, **kwargs) diff --git a/pytabular/tabular_editor.py b/pytabular/tabular_editor.py index 3c87f15..186d4df 100644 --- a/pytabular/tabular_editor.py +++ b/pytabular/tabular_editor.py @@ -1,3 +1,7 @@ +""" +This has a `Tabular_Editor` class which will download TE2 from a default location. +Or you can input your own location. +""" import logging import os import requests as r @@ -42,7 +46,9 @@ def Download_Tabular_Editor( class Tabular_Editor: - """Setting Tabular_Editor Class for future work.""" + """Setting Tabular_Editor Class for future work. + Mainly runs `Download_Tabular_Editor()` + """ def __init__(self, EXE_File_Path: str = "Default") -> None: logger.debug(f"Initializing Tabular Editor Class:: {EXE_File_Path}") diff --git a/pytabular/tabular_tracing.py b/pytabular/tabular_tracing.py index e5c2589..fd29817 100644 --- a/pytabular/tabular_tracing.py +++ b/pytabular/tabular_tracing.py @@ -1,3 +1,7 @@ +"""`tabular_tracing.py` handles all tracing capabilities in your model. +It also includes some pre built traces to make life easier. +Feel free to build your own. +""" import logging import random import xmltodict @@ -73,6 +77,7 @@ def Build(self) -> bool: [self.Trace.get_Events().Add(te) for te in TE] def add_column(trace_event, trace_event_column): + """Adds the column to trace event.""" try: trace_event.Columns.Add(trace_event_column) except Exception: @@ -98,6 +103,7 @@ def Arguments( Trace_Event_Columns: List[TraceColumn], Handler: Callable, ): + """Raises NotImplementedError. Arguments must be created in order for Trace to work.""" raise NotImplementedError def Add(self) -> int: @@ -184,6 +190,9 @@ def _Query_DMV_For_Event_Categories(self): def _refresh_handler(source, args): + """Default function called when `Refresh_Trace` is used. + It will log various steps of the refresh process. + """ TextData = args.TextData.replace("", "").replace("", "") if ( @@ -281,6 +290,9 @@ def __init__( def _query_monitor_handler(source, args): + """ + Default function used with the `Query_Monitor` trace. + """ total_secs = args.Duration / 1000 domain_site = args.NTUserName.find("\\") if domain_site > 0: