tmdl-lens 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tmdl_lens-0.4.0/LICENSE +21 -0
- tmdl_lens-0.4.0/PKG-INFO +144 -0
- tmdl_lens-0.4.0/README.md +121 -0
- tmdl_lens-0.4.0/pyproject.toml +37 -0
- tmdl_lens-0.4.0/setup.cfg +4 -0
- tmdl_lens-0.4.0/tests/test_config.py +49 -0
- tmdl_lens-0.4.0/tests/test_hidden_references.py +115 -0
- tmdl_lens-0.4.0/tests/test_parse.py +155 -0
- tmdl_lens-0.4.0/tests/test_parser_units.py +190 -0
- tmdl_lens-0.4.0/tests/test_pipeline_units.py +93 -0
- tmdl_lens-0.4.0/tests/test_readme.py +67 -0
- tmdl_lens-0.4.0/tests/test_resolver_units.py +150 -0
- tmdl_lens-0.4.0/tmdl_lens/__init__.py +0 -0
- tmdl_lens-0.4.0/tmdl_lens/app.py +985 -0
- tmdl_lens-0.4.0/tmdl_lens/cli.py +163 -0
- tmdl_lens-0.4.0/tmdl_lens/config.py +151 -0
- tmdl_lens-0.4.0/tmdl_lens/pipeline.py +295 -0
- tmdl_lens-0.4.0/tmdl_lens/readme_generator.py +1436 -0
- tmdl_lens-0.4.0/tmdl_lens/source_resolver.py +514 -0
- tmdl_lens-0.4.0/tmdl_lens/tmdl_parser.py +1384 -0
- tmdl_lens-0.4.0/tmdl_lens/version.py +8 -0
- tmdl_lens-0.4.0/tmdl_lens/watcher.py +163 -0
- tmdl_lens-0.4.0/tmdl_lens.egg-info/PKG-INFO +144 -0
- tmdl_lens-0.4.0/tmdl_lens.egg-info/SOURCES.txt +26 -0
- tmdl_lens-0.4.0/tmdl_lens.egg-info/dependency_links.txt +1 -0
- tmdl_lens-0.4.0/tmdl_lens.egg-info/entry_points.txt +2 -0
- tmdl_lens-0.4.0/tmdl_lens.egg-info/requires.txt +4 -0
- tmdl_lens-0.4.0/tmdl_lens.egg-info/top_level.txt +1 -0
tmdl_lens-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcin Mozol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tmdl_lens-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tmdl-lens
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Automated documentation generator for Power BI projects.
|
|
5
|
+
Author-email: Marcin Mozol <marcin.mozol@pm.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: powerbi,pbip,tmdl,power-bi,documentation,dax
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Documentation
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: gui
|
|
20
|
+
Requires-Dist: customtkinter>=5.2.0; extra == "gui"
|
|
21
|
+
Requires-Dist: watchdog>=4.0.0; extra == "gui"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# tmdl-lens
|
|
25
|
+
|
|
26
|
+
Automated documentation generator for Power BI projects.
|
|
27
|
+
|
|
28
|
+
Reads TMDL files from a folder of `.pbip` reports and generates structured HTML or Markdown documentation for each one - covering data sources, tables, measures, relationships, M parameters, security roles, and calculation groups.
|
|
29
|
+
|
|
30
|
+
Runs as a standalone Windows desktop app. Output can be HTML (opens in any browser) or Markdown (README.md for GitHub). A live file watcher regenerates documentation automatically on every save.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## What it documents
|
|
35
|
+
|
|
36
|
+
See a [real example output](sample/README.md) generated from the included sample report.
|
|
37
|
+
|
|
38
|
+
Each generated README includes:
|
|
39
|
+
|
|
40
|
+
- **Data Sources** - connector type and resolved source detail for every query, covering 30+ connectors including SQL, Dataflows, SharePoint, Excel, OData, Azure storage, Fabric, Databricks, Snowflake, Salesforce, and more. Unknown connectors are auto-detected and flagged for manual labelling.
|
|
41
|
+
- **Table Details** - table type, source detail, and column list
|
|
42
|
+
- **Measures** - full DAX (optional), display folder, and format string
|
|
43
|
+
- **Relationships** - cardinality, cross-filter direction, and active/inactive state
|
|
44
|
+
- **M Parameters** - current value and which source expressions reference each parameter
|
|
45
|
+
- **Security Roles** - static and dynamic RLS rules per table, flagged by type
|
|
46
|
+
- **Calculation Groups** - calculation items with DAX and ordinal order
|
|
47
|
+
- **Model Statistics** - table, column, measure, and relationship counts
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
### Option A - Run from source
|
|
54
|
+
|
|
55
|
+
Requires Python 3.12+.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
git clone https://github.com/nox-magistralis/tmdl-lens.git
|
|
59
|
+
cd tmdl-lens
|
|
60
|
+
pip install -r requirements.txt
|
|
61
|
+
python main.py
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Option B - Standalone executable
|
|
65
|
+
|
|
66
|
+
Download the latest `tmdl-lens.exe` from the [Releases](https://github.com/nox-magistralis/tmdl-lens/releases) page. No Python or pip required - just run the `.exe` directly.
|
|
67
|
+
|
|
68
|
+
A `config.json` file will be created alongside the executable on first run to store your settings.
|
|
69
|
+
|
|
70
|
+
> **Note:** Windows may show a SmartScreen warning on first run. Click **More info** then **Run anyway**. This is expected for unsigned applications.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
1. Launch the app
|
|
77
|
+
2. Under **Configure**, set the **Reports folder** to the root of your `.pbip` projects
|
|
78
|
+
3. Set an **Output folder** if you want READMEs written somewhere other than next to each `.pbip`
|
|
79
|
+
4. Press **Run Now** to generate documentation for all reports
|
|
80
|
+
5. Enable **Watch for TMDL changes** to auto-regenerate on every save
|
|
81
|
+
|
|
82
|
+
If no output folder is set, each `README.md` is written next to its `.pbip` file. If a separate output folder is set, each report gets its own named subfolder inside it - for example `docs/SalesReport/README.md`, `docs/FinanceReport/README.md` - so multiple reports never collide.
|
|
83
|
+
|
|
84
|
+
Optional documentation metadata (owner, team, refresh schedule) is set in the **Configure** tab and stored in `config.json` next to the app. Values left blank are omitted from the generated output.
|
|
85
|
+
|
|
86
|
+
By default the app skips reports whose TMDL files and documentation settings have not changed since the last run - each report is hashed, and only reports that actually moved are regenerated. Turn this off with **Skip reports with no TMDL changes** in **Configure**.
|
|
87
|
+
|
|
88
|
+
On startup the app validates the config and logs a warning for a missing or empty reports folder, a missing output folder, a watch debounce outside 1-300, or an output format other than `html` or `md`.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
| Package | Version |
|
|
95
|
+
|---|---|
|
|
96
|
+
| customtkinter | >= 5.2.0 |
|
|
97
|
+
| watchdog | >= 4.0.0 |
|
|
98
|
+
|
|
99
|
+
Python 3.12 or later required when running from source.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
pip install -r requirements-dev.txt
|
|
107
|
+
python -m pytest
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`requirements-dev.txt` adds dev-only dependencies (pytest, pyinstaller) on top of the base `requirements.txt`. The test suite runs against the bundled sample report and covers the TMDL parser, source resolution, config validation, and README/HTML generation.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Project structure
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
tmdl-lens/
|
|
118
|
+
main.py entry point
|
|
119
|
+
requirements.txt
|
|
120
|
+
requirements-dev.txt dev-only dependencies (pytest, pyinstaller)
|
|
121
|
+
pytest.ini
|
|
122
|
+
src/
|
|
123
|
+
app.py CustomTkinter UI
|
|
124
|
+
pipeline.py documentation generation pipeline
|
|
125
|
+
tmdl_parser.py TMDL file parser
|
|
126
|
+
source_resolver.py M expression source resolver
|
|
127
|
+
readme_generator.py README markdown generator
|
|
128
|
+
watcher.py file watcher (watchdog)
|
|
129
|
+
config.py app settings + documentation metadata (config.json)
|
|
130
|
+
sample/ sample .pbip project for testing
|
|
131
|
+
tests/ pytest suite
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Author
|
|
137
|
+
|
|
138
|
+
**Marcin Mozol** - [github.com/nox-magistralis](https://github.com/nox-magistralis)
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT - see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# tmdl-lens
|
|
2
|
+
|
|
3
|
+
Automated documentation generator for Power BI projects.
|
|
4
|
+
|
|
5
|
+
Reads TMDL files from a folder of `.pbip` reports and generates structured HTML or Markdown documentation for each one - covering data sources, tables, measures, relationships, M parameters, security roles, and calculation groups.
|
|
6
|
+
|
|
7
|
+
Runs as a standalone Windows desktop app. Output can be HTML (opens in any browser) or Markdown (README.md for GitHub). A live file watcher regenerates documentation automatically on every save.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it documents
|
|
12
|
+
|
|
13
|
+
See a [real example output](sample/README.md) generated from the included sample report.
|
|
14
|
+
|
|
15
|
+
Each generated README includes:
|
|
16
|
+
|
|
17
|
+
- **Data Sources** - connector type and resolved source detail for every query, covering 30+ connectors including SQL, Dataflows, SharePoint, Excel, OData, Azure storage, Fabric, Databricks, Snowflake, Salesforce, and more. Unknown connectors are auto-detected and flagged for manual labelling.
|
|
18
|
+
- **Table Details** - table type, source detail, and column list
|
|
19
|
+
- **Measures** - full DAX (optional), display folder, and format string
|
|
20
|
+
- **Relationships** - cardinality, cross-filter direction, and active/inactive state
|
|
21
|
+
- **M Parameters** - current value and which source expressions reference each parameter
|
|
22
|
+
- **Security Roles** - static and dynamic RLS rules per table, flagged by type
|
|
23
|
+
- **Calculation Groups** - calculation items with DAX and ordinal order
|
|
24
|
+
- **Model Statistics** - table, column, measure, and relationship counts
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
### Option A - Run from source
|
|
31
|
+
|
|
32
|
+
Requires Python 3.12+.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
git clone https://github.com/nox-magistralis/tmdl-lens.git
|
|
36
|
+
cd tmdl-lens
|
|
37
|
+
pip install -r requirements.txt
|
|
38
|
+
python main.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Option B - Standalone executable
|
|
42
|
+
|
|
43
|
+
Download the latest `tmdl-lens.exe` from the [Releases](https://github.com/nox-magistralis/tmdl-lens/releases) page. No Python or pip required - just run the `.exe` directly.
|
|
44
|
+
|
|
45
|
+
A `config.json` file will be created alongside the executable on first run to store your settings.
|
|
46
|
+
|
|
47
|
+
> **Note:** Windows may show a SmartScreen warning on first run. Click **More info** then **Run anyway**. This is expected for unsigned applications.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
1. Launch the app
|
|
54
|
+
2. Under **Configure**, set the **Reports folder** to the root of your `.pbip` projects
|
|
55
|
+
3. Set an **Output folder** if you want READMEs written somewhere other than next to each `.pbip`
|
|
56
|
+
4. Press **Run Now** to generate documentation for all reports
|
|
57
|
+
5. Enable **Watch for TMDL changes** to auto-regenerate on every save
|
|
58
|
+
|
|
59
|
+
If no output folder is set, each `README.md` is written next to its `.pbip` file. If a separate output folder is set, each report gets its own named subfolder inside it - for example `docs/SalesReport/README.md`, `docs/FinanceReport/README.md` - so multiple reports never collide.
|
|
60
|
+
|
|
61
|
+
Optional documentation metadata (owner, team, refresh schedule) is set in the **Configure** tab and stored in `config.json` next to the app. Values left blank are omitted from the generated output.
|
|
62
|
+
|
|
63
|
+
By default the app skips reports whose TMDL files and documentation settings have not changed since the last run - each report is hashed, and only reports that actually moved are regenerated. Turn this off with **Skip reports with no TMDL changes** in **Configure**.
|
|
64
|
+
|
|
65
|
+
On startup the app validates the config and logs a warning for a missing or empty reports folder, a missing output folder, a watch debounce outside 1-300, or an output format other than `html` or `md`.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
| Package | Version |
|
|
72
|
+
|---|---|
|
|
73
|
+
| customtkinter | >= 5.2.0 |
|
|
74
|
+
| watchdog | >= 4.0.0 |
|
|
75
|
+
|
|
76
|
+
Python 3.12 or later required when running from source.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
pip install -r requirements-dev.txt
|
|
84
|
+
python -m pytest
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`requirements-dev.txt` adds dev-only dependencies (pytest, pyinstaller) on top of the base `requirements.txt`. The test suite runs against the bundled sample report and covers the TMDL parser, source resolution, config validation, and README/HTML generation.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Project structure
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
tmdl-lens/
|
|
95
|
+
main.py entry point
|
|
96
|
+
requirements.txt
|
|
97
|
+
requirements-dev.txt dev-only dependencies (pytest, pyinstaller)
|
|
98
|
+
pytest.ini
|
|
99
|
+
src/
|
|
100
|
+
app.py CustomTkinter UI
|
|
101
|
+
pipeline.py documentation generation pipeline
|
|
102
|
+
tmdl_parser.py TMDL file parser
|
|
103
|
+
source_resolver.py M expression source resolver
|
|
104
|
+
readme_generator.py README markdown generator
|
|
105
|
+
watcher.py file watcher (watchdog)
|
|
106
|
+
config.py app settings + documentation metadata (config.json)
|
|
107
|
+
sample/ sample .pbip project for testing
|
|
108
|
+
tests/ pytest suite
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Author
|
|
114
|
+
|
|
115
|
+
**Marcin Mozol** - [github.com/nox-magistralis](https://github.com/nox-magistralis)
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT - see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tmdl-lens"
|
|
7
|
+
description = "Automated documentation generator for Power BI projects."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Marcin Mozol", email = "marcin.mozol@pm.me" }]
|
|
13
|
+
keywords = ["powerbi", "pbip", "tmdl", "power-bi", "documentation", "dax"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Documentation",
|
|
23
|
+
]
|
|
24
|
+
dynamic = ["version"]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
gui = ["customtkinter>=5.2.0", "watchdog>=4.0.0"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
tmdl-lens = "tmdl_lens.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.dynamic]
|
|
34
|
+
version = { attr = "tmdl_lens.version.__version__" }
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
include = ["tmdl_lens*"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_config.py — pytest tests for src.config.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from tmdl_lens.config import validate_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_valid_config_no_warnings(tmp_path):
|
|
9
|
+
warnings = validate_config({
|
|
10
|
+
"reports_folder": str(tmp_path),
|
|
11
|
+
"output_folder": str(tmp_path),
|
|
12
|
+
"watch_debounce": 10,
|
|
13
|
+
"output_format": "md",
|
|
14
|
+
})
|
|
15
|
+
assert warnings == []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_empty_reports_folder_warns():
|
|
19
|
+
warnings = validate_config({"reports_folder": ""})
|
|
20
|
+
assert any("reports_folder" in w for w in warnings)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_missing_reports_folder_warns(tmp_path):
|
|
24
|
+
missing = str(tmp_path / "does-not-exist")
|
|
25
|
+
warnings = validate_config({"reports_folder": missing})
|
|
26
|
+
assert any("does not exist" in w for w in warnings)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_missing_output_folder_warns(tmp_path):
|
|
30
|
+
warnings = validate_config({
|
|
31
|
+
"reports_folder": str(tmp_path),
|
|
32
|
+
"output_folder": str(tmp_path / "does-not-exist"),
|
|
33
|
+
})
|
|
34
|
+
assert any("output_folder" in w for w in warnings)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_watch_debounce_out_of_range_warns():
|
|
38
|
+
warnings = validate_config({"watch_debounce": 500})
|
|
39
|
+
assert any("watch_debounce" in w for w in warnings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_watch_debounce_non_numeric_warns():
|
|
43
|
+
warnings = validate_config({"watch_debounce": "abc"})
|
|
44
|
+
assert any("watch_debounce" in w for w in warnings)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_invalid_output_format_warns():
|
|
48
|
+
warnings = validate_config({"output_format": "pdf"})
|
|
49
|
+
assert any("output_format" in w for w in warnings)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_hidden_references.py — pytest tests for hidden-object reference
|
|
3
|
+
detection used by the README/HTML generators.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from tmdl_lens.readme_generator import (
|
|
7
|
+
_build_hidden_reference_map,
|
|
8
|
+
_find_hidden_references,
|
|
9
|
+
_hidden_reference_note,
|
|
10
|
+
)
|
|
11
|
+
from tmdl_lens.tmdl_parser import Column, Measure, SemanticModel, Table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_test_model() -> SemanticModel:
|
|
15
|
+
"""A small model with hidden columns, hidden tables and ambiguous names."""
|
|
16
|
+
sales = Table(
|
|
17
|
+
name="Sales",
|
|
18
|
+
table_type="fact",
|
|
19
|
+
columns=[
|
|
20
|
+
Column(name="Visible", data_type="int64"),
|
|
21
|
+
Column(name="Secret", data_type="int64", is_hidden=True),
|
|
22
|
+
],
|
|
23
|
+
measures=[
|
|
24
|
+
Measure(name="Visible Measure", dax_expression="SUM('Sales'[Visible])"),
|
|
25
|
+
Measure(
|
|
26
|
+
name="Hidden Measure",
|
|
27
|
+
dax_expression="SUM('Sales'[Visible])",
|
|
28
|
+
is_hidden=True,
|
|
29
|
+
),
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
helper = Table(
|
|
33
|
+
name="Helper",
|
|
34
|
+
table_type="helper",
|
|
35
|
+
is_hidden=True,
|
|
36
|
+
columns=[Column(name="Key", data_type="int64")],
|
|
37
|
+
measures=[Measure(name="Open Count", dax_expression="COUNTROWS('Helper')")],
|
|
38
|
+
)
|
|
39
|
+
t1 = Table(
|
|
40
|
+
name="T1",
|
|
41
|
+
table_type="dim",
|
|
42
|
+
measures=[Measure(name="Amb", dax_expression="1", is_hidden=True)],
|
|
43
|
+
)
|
|
44
|
+
t2 = Table(
|
|
45
|
+
name="T2",
|
|
46
|
+
table_type="dim",
|
|
47
|
+
measures=[Measure(name="Amb", dax_expression="1", is_hidden=True)],
|
|
48
|
+
)
|
|
49
|
+
return SemanticModel(report_name="Test", tables=[sales, helper, t1, t2])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _maps(model):
|
|
53
|
+
return _build_hidden_reference_map(model)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_build_map_hidden_objects():
|
|
57
|
+
tables, hidden_measures, hidden_columns = _maps(_build_test_model())
|
|
58
|
+
assert hidden_measures["Hidden Measure"] == "Sales"
|
|
59
|
+
assert hidden_measures["Open Count"] == "Helper" # via hidden table
|
|
60
|
+
assert set(hidden_measures["Amb"]) == {"T1", "T2"} # ambiguous
|
|
61
|
+
assert hidden_columns[("Sales", "Secret")] is True
|
|
62
|
+
assert hidden_columns[("Helper", "Key")] is True # via hidden table
|
|
63
|
+
assert ("Sales", "Visible") not in hidden_columns
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_hidden_column_reference():
|
|
67
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
68
|
+
refs = _find_hidden_references("X = 'Sales'[Secret]", tables, hm, hc)
|
|
69
|
+
assert refs == [("Sales[Secret]", "Sales")]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_hidden_table_column_reference():
|
|
73
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
74
|
+
refs = _find_hidden_references("X = 'Helper'[Key]", tables, hm, hc)
|
|
75
|
+
assert refs == [("Helper[Key]", "Helper")]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_hidden_measure_reference():
|
|
79
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
80
|
+
refs = _find_hidden_references("X = [Hidden Measure]", tables, hm, hc)
|
|
81
|
+
assert refs == [("[Hidden Measure]", "Sales")]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_ambiguous_measure_skipped():
|
|
85
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
86
|
+
refs = _find_hidden_references("X = [Amb]", tables, hm, hc)
|
|
87
|
+
assert refs == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_string_literals_ignored():
|
|
91
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
92
|
+
dax = '"Sales[Secret]" & [Hidden Measure]'
|
|
93
|
+
refs = _find_hidden_references(dax, tables, hm, hc)
|
|
94
|
+
assert refs == [("[Hidden Measure]", "Sales")]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_visible_references_not_flagged():
|
|
98
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
99
|
+
dax = "'Sales'[Visible] + [Visible Measure]"
|
|
100
|
+
refs = _find_hidden_references(dax, tables, hm, hc)
|
|
101
|
+
assert refs == []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_empty_expression():
|
|
105
|
+
tables, hm, hc = _maps(_build_test_model())
|
|
106
|
+
assert _find_hidden_references("", tables, hm, hc) == []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_hidden_reference_note():
|
|
110
|
+
refs = [("Sales[Secret]", "Sales"), ("[Hidden Measure]", "Sales")]
|
|
111
|
+
note = _hidden_reference_note(refs)
|
|
112
|
+
assert note.startswith("⚠ References hidden:")
|
|
113
|
+
assert "`Sales[Secret]`" in note
|
|
114
|
+
assert "`[Hidden Measure]`" in note
|
|
115
|
+
assert _hidden_reference_note([]) == ""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_parse.py — pytest tests for tmdl_parser and source_resolver.
|
|
3
|
+
|
|
4
|
+
Run from the repo root: python -m pytest
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_report_name(sample_model):
|
|
9
|
+
assert sample_model.report_name == "tmdl-lens Test Report"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_tables_count(sample_model):
|
|
13
|
+
assert len(sample_model.tables) == 8
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_table_names(sample_model):
|
|
17
|
+
names = sorted(t.name for t in sample_model.tables)
|
|
18
|
+
assert names == [
|
|
19
|
+
"_measures",
|
|
20
|
+
"cg-time-intelligence",
|
|
21
|
+
"dim-date",
|
|
22
|
+
"dim-product",
|
|
23
|
+
"fact-sales",
|
|
24
|
+
"helper-order-lookup",
|
|
25
|
+
"param-metric-selector",
|
|
26
|
+
"source-sql-staging",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_table_classification(sample_model):
|
|
31
|
+
types = {t.name: t.table_type for t in sample_model.tables}
|
|
32
|
+
assert types == {
|
|
33
|
+
"_measures": "measures_only",
|
|
34
|
+
"cg-time-intelligence": "calc_group",
|
|
35
|
+
"dim-date": "calculated",
|
|
36
|
+
"dim-product": "dim",
|
|
37
|
+
"fact-sales": "fact",
|
|
38
|
+
"helper-order-lookup": "helper",
|
|
39
|
+
"param-metric-selector": "field_parameter",
|
|
40
|
+
"source-sql-staging": "staging",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hidden_and_not_loaded_tables(sample_model):
|
|
45
|
+
helper = next(t for t in sample_model.tables if t.name == "helper-order-lookup")
|
|
46
|
+
assert helper.is_hidden is True
|
|
47
|
+
staging = next(t for t in sample_model.tables if t.name == "source-sql-staging")
|
|
48
|
+
assert staging.is_loaded is False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_m_parameters(sample_model):
|
|
52
|
+
assert len(sample_model.m_parameters) == 2
|
|
53
|
+
by_name = {p.name: p for p in sample_model.m_parameters}
|
|
54
|
+
assert by_name["ServerName"].value == "fake-server.database.windows.net"
|
|
55
|
+
assert by_name["DatabaseName"].value == "SalesDB"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_source_expressions(sample_model):
|
|
59
|
+
assert len(sample_model.source_expressions) == 21
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_relationships(sample_model):
|
|
63
|
+
assert len(sample_model.relationships) == 3
|
|
64
|
+
pairs = {
|
|
65
|
+
(r.from_table, r.from_column, r.to_table, r.to_column)
|
|
66
|
+
for r in sample_model.relationships
|
|
67
|
+
}
|
|
68
|
+
assert ("fact-sales", "order_date", "dim-date", "Date") in pairs
|
|
69
|
+
assert ("fact-sales", "customer_id", "dim-product", "product_id") in pairs
|
|
70
|
+
assert ("fact-sales", "ship_date", "dim-date", "Date") in pairs
|
|
71
|
+
assert sum(1 for r in sample_model.relationships if not r.is_active) == 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_measures(sample_model):
|
|
75
|
+
by_table = {t.name: t.measures for t in sample_model.tables}
|
|
76
|
+
assert len(by_table["_measures"]) == 4
|
|
77
|
+
assert len(by_table["fact-sales"]) == 2
|
|
78
|
+
assert len(by_table["helper-order-lookup"]) == 1
|
|
79
|
+
names = {m.name for m in by_table["_measures"]}
|
|
80
|
+
assert names == {
|
|
81
|
+
"Total Sales Amount",
|
|
82
|
+
"Order Count",
|
|
83
|
+
"Avg Order Value",
|
|
84
|
+
"Sales YTD",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_resolved_sources_tiers(sample_resolved):
|
|
89
|
+
assert len(sample_resolved) == 21
|
|
90
|
+
tier1 = sum(1 for rs in sample_resolved.values() if rs.resolution_tier == 1)
|
|
91
|
+
tier2 = sum(1 for rs in sample_resolved.values() if rs.resolution_tier == 2)
|
|
92
|
+
tier3 = sum(1 for rs in sample_resolved.values() if rs.resolution_tier == 3)
|
|
93
|
+
assert tier1 == 18
|
|
94
|
+
assert tier2 == 1
|
|
95
|
+
assert tier3 == 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_resolved_sql_source(sample_resolved):
|
|
99
|
+
rs = sample_resolved["source-sql-direct"]
|
|
100
|
+
assert rs.source_type == "connector"
|
|
101
|
+
assert rs.resolution_tier == 1
|
|
102
|
+
assert "SalesDB" in rs.label
|
|
103
|
+
assert "dbo.orders" in rs.label
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_resolved_derived_chain(sample_resolved):
|
|
107
|
+
rs = sample_resolved["source-derived"]
|
|
108
|
+
assert rs.resolution_tier == 2
|
|
109
|
+
assert rs.derived_from == "source-sql-direct"
|
|
110
|
+
assert rs.chain[0] == "source-sql-direct"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_tier3_are_unresolved(sample_resolved):
|
|
114
|
+
tier3 = [rs for rs in sample_resolved.values() if rs.resolution_tier == 3]
|
|
115
|
+
assert len(tier3) == 2
|
|
116
|
+
assert all(rs.unresolved for rs in tier3)
|
|
117
|
+
names = sorted(rs.expression_name for rs in tier3)
|
|
118
|
+
assert names == ["source-dynamic", "source-via-custom-function"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_security_roles(sample_model):
|
|
122
|
+
assert len(sample_model.security_roles) == 5
|
|
123
|
+
names = {r.name for r in sample_model.security_roles}
|
|
124
|
+
assert names == {
|
|
125
|
+
"Regional Managers",
|
|
126
|
+
"Employees",
|
|
127
|
+
"Legacy Users",
|
|
128
|
+
"Area Supervisors",
|
|
129
|
+
"Administrators",
|
|
130
|
+
}
|
|
131
|
+
dynamic = {
|
|
132
|
+
r.name: r.dynamic_function
|
|
133
|
+
for r in sample_model.security_roles
|
|
134
|
+
if r.is_dynamic
|
|
135
|
+
}
|
|
136
|
+
assert dynamic == {
|
|
137
|
+
"Employees": "USERPRINCIPALNAME",
|
|
138
|
+
"Legacy Users": "USERNAME",
|
|
139
|
+
}
|
|
140
|
+
admins = next(r for r in sample_model.security_roles if r.name == "Administrators")
|
|
141
|
+
assert admins.table_filters == []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_calculation_groups(sample_model):
|
|
145
|
+
cgs = [t for t in sample_model.tables if t.table_type == "calc_group"]
|
|
146
|
+
assert len(cgs) == 1
|
|
147
|
+
cg = cgs[0]
|
|
148
|
+
assert cg.name == "cg-time-intelligence"
|
|
149
|
+
assert len(cg.calculation_items) == 5
|
|
150
|
+
assert [i.name for i in cg.calculation_items] == [
|
|
151
|
+
"YTD", "MTD", "Rolling 12M", "Prior Year", "YoY %",
|
|
152
|
+
]
|
|
153
|
+
ytd = cg.calculation_items[0]
|
|
154
|
+
assert "TOTALYTD" in ytd.dax_expression
|
|
155
|
+
assert ytd.format_string_expression == "SELECTEDMEASUREFORMATSTRING()"
|