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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()"