codepbi 0.1.7__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.
- codepbi-0.1.7/LICENSE +21 -0
- codepbi-0.1.7/PKG-INFO +73 -0
- codepbi-0.1.7/README.md +55 -0
- codepbi-0.1.7/pyproject.toml +25 -0
- codepbi-0.1.7/setup.cfg +4 -0
- codepbi-0.1.7/src/codepbi/__init__.py +17 -0
- codepbi-0.1.7/src/codepbi/ids.py +11 -0
- codepbi-0.1.7/src/codepbi/model.py +408 -0
- codepbi-0.1.7/src/codepbi/semantic/__init__.py +48 -0
- codepbi-0.1.7/src/codepbi/semantic/check_drift.py +48 -0
- codepbi-0.1.7/src/codepbi/semantic/check_schema_vs_sql.py +61 -0
- codepbi-0.1.7/src/codepbi/semantic/config.py +63 -0
- codepbi-0.1.7/src/codepbi/semantic/dbt_sql.py +69 -0
- codepbi-0.1.7/src/codepbi/semantic/schema_loader.py +138 -0
- codepbi-0.1.7/src/codepbi/semantic/sync_descriptions.py +147 -0
- codepbi-0.1.7/src/codepbi/semantic/sync_measures.py +127 -0
- codepbi-0.1.7/src/codepbi/semantic/sync_relationships.py +232 -0
- codepbi-0.1.7/src/codepbi/semantic/tom_utils.py +120 -0
- codepbi-0.1.7/src/codepbi/semantic/validate_tmdl.py +231 -0
- codepbi-0.1.7/src/codepbi/writer.py +238 -0
- codepbi-0.1.7/src/codepbi.egg-info/PKG-INFO +73 -0
- codepbi-0.1.7/src/codepbi.egg-info/SOURCES.txt +25 -0
- codepbi-0.1.7/src/codepbi.egg-info/dependency_links.txt +1 -0
- codepbi-0.1.7/src/codepbi.egg-info/requires.txt +8 -0
- codepbi-0.1.7/src/codepbi.egg-info/top_level.txt +1 -0
- codepbi-0.1.7/tests/test_semantic.py +94 -0
- codepbi-0.1.7/tests/test_writer.py +432 -0
codepbi-0.1.7/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Thomas
|
|
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.
|
codepbi-0.1.7/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codepbi
|
|
3
|
+
Version: 0.1.7
|
|
4
|
+
Summary: Code-first Power BI — define pages/visuals in Python and emit a working PBIP/PBIR report; sync a TMDL semantic model from a dbt project.
|
|
5
|
+
Author: Ryan Thomas
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ryantthomas/codepbi
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: semantic
|
|
12
|
+
Requires-Dist: pyyaml; extra == "semantic"
|
|
13
|
+
Requires-Dist: sqlglot; extra == "semantic"
|
|
14
|
+
Requires-Dist: pythonnet; extra == "semantic"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# codepbi
|
|
20
|
+
|
|
21
|
+
Code-first Power BI. Define pages and visuals in Python, get a working PBIP/PBIR
|
|
22
|
+
report that opens and edits normally in Power BI Desktop.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from codepbi import Field, Filter, Position, Report
|
|
26
|
+
|
|
27
|
+
report = Report(name="Sales Overview", semantic_model_path="../Sales.SemanticModel")
|
|
28
|
+
page = report.add_page("Overview")
|
|
29
|
+
page.add_slicer(Field("Orders", "Region"), Position(x=0, y=0, width=200, height=400))
|
|
30
|
+
page.add_table(
|
|
31
|
+
fields=[Field("Orders", "Region"), Field("Orders", "Order Amount")],
|
|
32
|
+
position=Position(x=220, y=0, width=800, height=400),
|
|
33
|
+
filters=[Filter.equals("Orders", "Status", "Active")],
|
|
34
|
+
)
|
|
35
|
+
report.save("output_dir")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## `codepbi.semantic`
|
|
39
|
+
|
|
40
|
+
A separate module for the other half of the workflow: syncing a Power BI TMDL
|
|
41
|
+
semantic model *from* a dbt project, via the Tabular Object Model (TOM). Treats
|
|
42
|
+
dbt's schema YAML as the source of truth for columns, and a dbt-semantic-layer-
|
|
43
|
+
style `semantic_models/*.yml` (entities + measures) as the source of truth for
|
|
44
|
+
joins and DAX measures.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from codepbi.semantic import TmdlProjectConfig, sync_descriptions, sync_measures, sync_relationships
|
|
48
|
+
|
|
49
|
+
config = TmdlProjectConfig(
|
|
50
|
+
dbt_project_dir="path/to/dbt_project",
|
|
51
|
+
semantic_model_definition_dir="path/to/Model.SemanticModel/definition",
|
|
52
|
+
tom_lib_dir="path/to/analysis-services-dlls",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
sync_measures.sync(config)
|
|
56
|
+
sync_descriptions.sync(config) # also validates the resulting model
|
|
57
|
+
sync_relationships.sync(config)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires the `semantic` extra (`pip install codepbi[semantic]`) plus the
|
|
61
|
+
`Microsoft.AnalysisServices.NetCore.retail.amd64` NuGet package's DLLs, obtained
|
|
62
|
+
separately and pointed at via `tom_lib_dir` (not redistributed here).
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install -e . # report/visual builder only
|
|
68
|
+
pip install -e ".[semantic]" # + the dbt-to-TMDL sync module
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
codepbi-0.1.7/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# codepbi
|
|
2
|
+
|
|
3
|
+
Code-first Power BI. Define pages and visuals in Python, get a working PBIP/PBIR
|
|
4
|
+
report that opens and edits normally in Power BI Desktop.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from codepbi import Field, Filter, Position, Report
|
|
8
|
+
|
|
9
|
+
report = Report(name="Sales Overview", semantic_model_path="../Sales.SemanticModel")
|
|
10
|
+
page = report.add_page("Overview")
|
|
11
|
+
page.add_slicer(Field("Orders", "Region"), Position(x=0, y=0, width=200, height=400))
|
|
12
|
+
page.add_table(
|
|
13
|
+
fields=[Field("Orders", "Region"), Field("Orders", "Order Amount")],
|
|
14
|
+
position=Position(x=220, y=0, width=800, height=400),
|
|
15
|
+
filters=[Filter.equals("Orders", "Status", "Active")],
|
|
16
|
+
)
|
|
17
|
+
report.save("output_dir")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## `codepbi.semantic`
|
|
21
|
+
|
|
22
|
+
A separate module for the other half of the workflow: syncing a Power BI TMDL
|
|
23
|
+
semantic model *from* a dbt project, via the Tabular Object Model (TOM). Treats
|
|
24
|
+
dbt's schema YAML as the source of truth for columns, and a dbt-semantic-layer-
|
|
25
|
+
style `semantic_models/*.yml` (entities + measures) as the source of truth for
|
|
26
|
+
joins and DAX measures.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from codepbi.semantic import TmdlProjectConfig, sync_descriptions, sync_measures, sync_relationships
|
|
30
|
+
|
|
31
|
+
config = TmdlProjectConfig(
|
|
32
|
+
dbt_project_dir="path/to/dbt_project",
|
|
33
|
+
semantic_model_definition_dir="path/to/Model.SemanticModel/definition",
|
|
34
|
+
tom_lib_dir="path/to/analysis-services-dlls",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
sync_measures.sync(config)
|
|
38
|
+
sync_descriptions.sync(config) # also validates the resulting model
|
|
39
|
+
sync_relationships.sync(config)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires the `semantic` extra (`pip install codepbi[semantic]`) plus the
|
|
43
|
+
`Microsoft.AnalysisServices.NetCore.retail.amd64` NuGet package's DLLs, obtained
|
|
44
|
+
separately and pointed at via `tom_lib_dir` (not redistributed here).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e . # report/visual builder only
|
|
50
|
+
pip install -e ".[semantic]" # + the dbt-to-TMDL sync module
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "codepbi"
|
|
3
|
+
version = "0.1.7"
|
|
4
|
+
description = "Code-first Power BI — define pages/visuals in Python and emit a working PBIP/PBIR report; sync a TMDL semantic model from a dbt project."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "Ryan Thomas" }]
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
# Only needed for codepbi.semantic (dbt -> TMDL sync). Requires the
|
|
13
|
+
# Microsoft.AnalysisServices .NET DLLs separately -- see codepbi/semantic/tom_utils.py.
|
|
14
|
+
semantic = ["pyyaml", "sqlglot", "pythonnet"]
|
|
15
|
+
dev = ["pytest"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/ryantthomas/codepbi"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["setuptools>=68"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
codepbi-0.1.7/setup.cfg
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .model import (
|
|
2
|
+
Field,
|
|
3
|
+
Filter,
|
|
4
|
+
Page,
|
|
5
|
+
Position,
|
|
6
|
+
Report,
|
|
7
|
+
Visual,
|
|
8
|
+
column_width,
|
|
9
|
+
drillthrough_binding,
|
|
10
|
+
field_ref,
|
|
11
|
+
visual_interaction,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Field", "Filter", "Page", "Position", "Report", "Visual",
|
|
16
|
+
"column_width", "drillthrough_binding", "field_ref", "visual_interaction",
|
|
17
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def hex_id(length: int = 20) -> str:
|
|
6
|
+
"""Matches the folder-name id style PBI Desktop generates for pages/visuals (e.g. '953e48bb4f7e4f9a82ce')."""
|
|
7
|
+
return secrets.token_hex(length // 2)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def guid() -> str:
|
|
11
|
+
return str(uuid.uuid4())
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from .ids import hex_id
|
|
6
|
+
|
|
7
|
+
# Single-role visuals: visualType -> the one query role every projected field goes under.
|
|
8
|
+
# slicer was wrongly "Field" in earlier versions -- confirmed "Values" (like everything else
|
|
9
|
+
# here) via a real Desktop-saved slicer. See docs/codepbi-lessons-learned.md in the project
|
|
10
|
+
# that found this.
|
|
11
|
+
SINGLE_ROLE_KEY = {
|
|
12
|
+
"slicer": "Values",
|
|
13
|
+
"tableEx": "Values",
|
|
14
|
+
"card": "Values",
|
|
15
|
+
"multiRowCard": "Values",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def field_ref(entity: str, property_: str, is_measure: bool = False) -> dict:
|
|
20
|
+
"""The {"Column": {...}} / {"Measure": {...}} expression wrapper every field reference
|
|
21
|
+
uses -- shared by projections, filters, and drillthrough page bindings."""
|
|
22
|
+
wrapper = "Measure" if is_measure else "Column"
|
|
23
|
+
return {
|
|
24
|
+
wrapper: {
|
|
25
|
+
"Expression": {"SourceRef": {"Entity": entity}},
|
|
26
|
+
"Property": property_,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def drillthrough_binding(fields: list[Field], name: str = "drillthrough") -> dict:
|
|
32
|
+
"""pageBinding for a page with page_type="Drillthrough". One BindingParameter per field
|
|
33
|
+
that should trigger/filter the drillthrough page. Structure confirmed against the
|
|
34
|
+
published page/2.1.0 JSON Schema (pageBinding/BindingParameter definitions), not inferred
|
|
35
|
+
from an example.
|
|
36
|
+
|
|
37
|
+
Usage: page.page_type = "Drillthrough"; page.page_binding = drillthrough_binding([field])
|
|
38
|
+
"""
|
|
39
|
+
return {
|
|
40
|
+
"name": name,
|
|
41
|
+
"type": "Drillthrough",
|
|
42
|
+
"parameters": [
|
|
43
|
+
{"name": f.query_ref, "fieldExpr": field_ref(f.entity, f.property, f.is_measure)}
|
|
44
|
+
for f in fields
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
INTERACTION_TYPES = {"Default", "DataFilter", "HighlightFilter", "NoFilter"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def visual_interaction(source: Visual, target: Visual, interaction_type: str) -> dict:
|
|
53
|
+
"""A page.visual_interactions entry overriding how selecting a data point on `source`
|
|
54
|
+
affects `target`. interaction_type is one of INTERACTION_TYPES. Structure confirmed
|
|
55
|
+
against the published page/2.1.0 JSON Schema (VisualInteraction definition).
|
|
56
|
+
|
|
57
|
+
Usage: page.visual_interactions.append(visual_interaction(chart_a, chart_b, "NoFilter"))
|
|
58
|
+
"""
|
|
59
|
+
if interaction_type not in INTERACTION_TYPES:
|
|
60
|
+
raise ValueError(f"interaction_type must be one of {sorted(INTERACTION_TYPES)}")
|
|
61
|
+
return {"source": source.name, "target": target.name, "type": interaction_type}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _literal(value: str) -> dict:
|
|
65
|
+
return {"expr": {"Literal": {"Value": value}}}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def column_width(query_ref: str, pixels: float) -> dict:
|
|
69
|
+
"""An `objects.columnWidth` entry for a table/matrix column, keyed by the column's
|
|
70
|
+
queryRef (e.g. "Organizations.Organization Name" -- the same string Field.query_ref
|
|
71
|
+
produces). Confirmed from a real Desktop-saved manual column resize: table/matrix
|
|
72
|
+
columns are NOT sized from container width alone -- Desktop persists an explicit
|
|
73
|
+
per-column pixel width once you drag one, and that's the only mechanical lever
|
|
74
|
+
available (there's no formula from container width + data, since actual column
|
|
75
|
+
width depends on live data this generator never sees).
|
|
76
|
+
|
|
77
|
+
Usage: matrix.objects["columnWidth"] = [column_width("Orders.Region", 200)]
|
|
78
|
+
"""
|
|
79
|
+
return {
|
|
80
|
+
"properties": {"value": {"expr": {"Literal": {"Value": f"{pixels}D"}}}},
|
|
81
|
+
"selector": {"metadata": query_ref},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Field:
|
|
87
|
+
"""A column or measure reference projected onto a visual, e.g.
|
|
88
|
+
Field("Projects", "Project Title") for a column, or
|
|
89
|
+
Field("Orders", "Total Sales", is_measure=True) for a measure."""
|
|
90
|
+
|
|
91
|
+
entity: str
|
|
92
|
+
property: str
|
|
93
|
+
native_name: str | None = None
|
|
94
|
+
is_measure: bool = False
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def query_ref(self) -> str:
|
|
98
|
+
return f"{self.entity}.{self.property}"
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def native_query_ref(self) -> str:
|
|
102
|
+
return self.native_name or self.property
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Position:
|
|
107
|
+
x: float
|
|
108
|
+
y: float
|
|
109
|
+
width: float
|
|
110
|
+
height: float
|
|
111
|
+
z: float = 0
|
|
112
|
+
tab_order: float | None = None
|
|
113
|
+
|
|
114
|
+
def __post_init__(self) -> None:
|
|
115
|
+
if self.tab_order is None:
|
|
116
|
+
self.tab_order = self.z
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Filter:
|
|
121
|
+
"""A filter pane entry on a visual. Use `categorical` to just expose a field as filterable,
|
|
122
|
+
or `equals` to pre-apply a fixed condition (matches what PBI Desktop itself emits)."""
|
|
123
|
+
|
|
124
|
+
entity: str
|
|
125
|
+
property: str
|
|
126
|
+
kind: str # "Categorical" or "Advanced"
|
|
127
|
+
value: str | None = None
|
|
128
|
+
name: str = field(default_factory=hex_id)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def categorical(entity: str, property: str) -> Filter:
|
|
132
|
+
return Filter(entity, property, kind="Categorical")
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def equals(entity: str, property: str, value: str) -> Filter:
|
|
136
|
+
return Filter(entity, property, kind="Advanced", value=value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Visual:
|
|
141
|
+
"""roles maps a visual-type-specific query role name (e.g. "Category", "Y", "Series" for a
|
|
142
|
+
chart; "Values" for a table/card; "Field" for a slicer) to the fields projected onto it.
|
|
143
|
+
Role names are NOT uniform across visual types.
|
|
144
|
+
|
|
145
|
+
objects / visual_container_objects are passed straight through to the visual's own JSON
|
|
146
|
+
(per-visual-type formatting and title/background/border respectively)."""
|
|
147
|
+
|
|
148
|
+
visual_type: str
|
|
149
|
+
roles: dict[str, list[Field]]
|
|
150
|
+
position: Position
|
|
151
|
+
filters: list[Filter] = field(default_factory=list)
|
|
152
|
+
name: str = field(default_factory=hex_id)
|
|
153
|
+
drill_filter_other_visuals: bool = True
|
|
154
|
+
objects: dict = field(default_factory=dict)
|
|
155
|
+
visual_container_objects: dict = field(default_factory=dict)
|
|
156
|
+
# If set, this container IS a group (visual_type/roles are ignored) -- confirmed via the
|
|
157
|
+
# visualContainer schema: {"displayName": str, "groupMode": "ScaleMode" | "ScrollMode"}.
|
|
158
|
+
visual_group: dict | None = None
|
|
159
|
+
# Name of the group Visual this one belongs to, if any.
|
|
160
|
+
parent_group_name: str | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class Page:
|
|
165
|
+
display_name: str
|
|
166
|
+
name: str = field(default_factory=hex_id)
|
|
167
|
+
height: float = 720
|
|
168
|
+
width: float = 1280
|
|
169
|
+
display_option: str = "FitToPage"
|
|
170
|
+
visuals: list[Visual] = field(default_factory=list)
|
|
171
|
+
# "Drillthrough" or "Tooltip"; pairs with page_binding. None = a normal page.
|
|
172
|
+
page_type: str | None = None
|
|
173
|
+
page_binding: dict | None = None
|
|
174
|
+
# Overrides for how selecting a data point on one visual affects another -- see
|
|
175
|
+
# visual_interaction(). Default (no entry) leaves Desktop's own inferred behavior.
|
|
176
|
+
visual_interactions: list[dict] = field(default_factory=list)
|
|
177
|
+
# Page-level formatting passthrough, e.g. {"background": [{"properties": {...}}]} --
|
|
178
|
+
# confirmed property names (pageInformation, pageSize, background, displayArea, outspace,
|
|
179
|
+
# outspacePane, filterCard, pageRefresh, personalizeVisual) via the published page schema.
|
|
180
|
+
objects: dict = field(default_factory=dict)
|
|
181
|
+
# Page-level filters, applied on top of visual-level filters and under report-level ones.
|
|
182
|
+
# Same FilterContainer shape as Visual.filters (confirmed via filterConfiguration schema).
|
|
183
|
+
filters: list[Filter] = field(default_factory=list)
|
|
184
|
+
|
|
185
|
+
def add_visual(
|
|
186
|
+
self,
|
|
187
|
+
visual_type: str,
|
|
188
|
+
roles: dict[str, list[Field]],
|
|
189
|
+
position: Position,
|
|
190
|
+
filters: list[Filter] | None = None,
|
|
191
|
+
objects: dict | None = None,
|
|
192
|
+
visual_container_objects: dict | None = None,
|
|
193
|
+
) -> Visual:
|
|
194
|
+
visual = Visual(
|
|
195
|
+
visual_type, roles, position, filters or [],
|
|
196
|
+
objects=objects or {}, visual_container_objects=visual_container_objects or {},
|
|
197
|
+
)
|
|
198
|
+
self.visuals.append(visual)
|
|
199
|
+
return visual
|
|
200
|
+
|
|
201
|
+
def add_group(self, display_name: str, position: Position, group_mode: str = "ScaleMode") -> Visual:
|
|
202
|
+
"""A grouping container -- add visuals normally, then set their `.parent_group_name`
|
|
203
|
+
to this group's `.name` to make them members."""
|
|
204
|
+
group = Visual(
|
|
205
|
+
visual_type="", roles={}, position=position,
|
|
206
|
+
visual_group={"displayName": display_name, "groupMode": group_mode},
|
|
207
|
+
)
|
|
208
|
+
self.visuals.append(group)
|
|
209
|
+
return group
|
|
210
|
+
|
|
211
|
+
def add_text_box(
|
|
212
|
+
self, text: str, position: Position, font_size: str | None = None, bold: bool = False,
|
|
213
|
+
) -> Visual:
|
|
214
|
+
"""A static text box -- confirmed shape from a real Desktop-saved textbox. No query
|
|
215
|
+
roles; content lives entirely in objects.general.paragraphs. Note the text `value` is
|
|
216
|
+
a plain string here, NOT the usual Literal/expr wrapper every other property uses.
|
|
217
|
+
There is no vertical-alignment property for this visual (checked, and confirmed absent
|
|
218
|
+
in a real example) -- center it relative to other visuals by sizing its box tightly
|
|
219
|
+
around the text and aligning box centers, not by looking for an alignment property."""
|
|
220
|
+
text_style = {}
|
|
221
|
+
if font_size:
|
|
222
|
+
text_style["fontSize"] = font_size
|
|
223
|
+
if bold:
|
|
224
|
+
text_style["fontWeight"] = "bold"
|
|
225
|
+
text_run = {"value": text}
|
|
226
|
+
if text_style:
|
|
227
|
+
text_run["textStyle"] = text_style
|
|
228
|
+
objects = {"general": [{"properties": {"paragraphs": [{"textRuns": [text_run]}]}}]}
|
|
229
|
+
return self.add_visual("textbox", {}, position, objects=objects)
|
|
230
|
+
|
|
231
|
+
def add_image(
|
|
232
|
+
self, resource_name: str, position: Position,
|
|
233
|
+
display_name: str | None = None, scaling: str = "Normal",
|
|
234
|
+
) -> Visual:
|
|
235
|
+
"""A static image, referencing a resource already registered via
|
|
236
|
+
Report.add_image_resource(resource_name, local_file_path). Confirmed shape from a real
|
|
237
|
+
Desktop-saved image visual (logo). No query roles."""
|
|
238
|
+
objects = {
|
|
239
|
+
"image": [{
|
|
240
|
+
"properties": {
|
|
241
|
+
"sourceType": _literal("'image'"),
|
|
242
|
+
"sourceFile": {
|
|
243
|
+
"image": {
|
|
244
|
+
"name": _literal(f"'{display_name or resource_name}'"),
|
|
245
|
+
"url": {
|
|
246
|
+
"expr": {
|
|
247
|
+
"ResourcePackageItem": {
|
|
248
|
+
"PackageName": "RegisteredResources",
|
|
249
|
+
"PackageType": 1,
|
|
250
|
+
"ItemName": resource_name,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
"scaling": _literal(f"'{scaling}'"),
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
}]
|
|
259
|
+
}
|
|
260
|
+
return self.add_visual("image", {}, position, objects=objects)
|
|
261
|
+
|
|
262
|
+
def _add_single_role(self, visual_type: str, fields: list[Field], position: Position,
|
|
263
|
+
filters: list[Filter] | None = None) -> Visual:
|
|
264
|
+
return self.add_visual(visual_type, {SINGLE_ROLE_KEY[visual_type]: fields}, position, filters)
|
|
265
|
+
|
|
266
|
+
def add_table(self, fields: list[Field], position: Position, filters: list[Filter] | None = None) -> Visual:
|
|
267
|
+
return self._add_single_role("tableEx", fields, position, filters)
|
|
268
|
+
|
|
269
|
+
def add_slicer(self, field_: Field, position: Position) -> Visual:
|
|
270
|
+
return self._add_single_role("slicer", [field_], position)
|
|
271
|
+
|
|
272
|
+
def add_card(self, field_: Field, position: Position) -> Visual:
|
|
273
|
+
return self._add_single_role("card", [field_], position)
|
|
274
|
+
|
|
275
|
+
def add_multi_row_card(self, fields: list[Field], position: Position) -> Visual:
|
|
276
|
+
return self._add_single_role("multiRowCard", fields, position)
|
|
277
|
+
|
|
278
|
+
def add_matrix(
|
|
279
|
+
self, rows: list[Field], values: list[Field], position: Position,
|
|
280
|
+
columns: list[Field] | None = None,
|
|
281
|
+
) -> Visual:
|
|
282
|
+
roles = {"Rows": rows, "Values": values}
|
|
283
|
+
if columns:
|
|
284
|
+
roles["Columns"] = columns
|
|
285
|
+
return self.add_visual("pivotTable", roles, position)
|
|
286
|
+
|
|
287
|
+
def add_bar_chart(
|
|
288
|
+
self, category: Field, values: list[Field], position: Position,
|
|
289
|
+
series: Field | None = None, clustered: bool = True,
|
|
290
|
+
) -> Visual:
|
|
291
|
+
roles = {"Category": [category], "Y": values}
|
|
292
|
+
if series:
|
|
293
|
+
roles["Series"] = [series]
|
|
294
|
+
visual_type = "clusteredBarChart" if clustered else "barChart"
|
|
295
|
+
return self.add_visual(visual_type, roles, position)
|
|
296
|
+
|
|
297
|
+
def add_column_chart(
|
|
298
|
+
self, category: Field, values: list[Field], position: Position,
|
|
299
|
+
series: Field | None = None, clustered: bool = True,
|
|
300
|
+
) -> Visual:
|
|
301
|
+
roles = {"Category": [category], "Y": values}
|
|
302
|
+
if series:
|
|
303
|
+
roles["Series"] = [series]
|
|
304
|
+
visual_type = "clusteredColumnChart" if clustered else "columnChart"
|
|
305
|
+
return self.add_visual(visual_type, roles, position)
|
|
306
|
+
|
|
307
|
+
def add_line_chart(
|
|
308
|
+
self, category: Field, values: list[Field], position: Position,
|
|
309
|
+
series: Field | None = None,
|
|
310
|
+
) -> Visual:
|
|
311
|
+
roles = {"Category": [category], "Y": values}
|
|
312
|
+
if series:
|
|
313
|
+
roles["Series"] = [series]
|
|
314
|
+
return self.add_visual("lineChart", roles, position)
|
|
315
|
+
|
|
316
|
+
def add_combo_chart(
|
|
317
|
+
self, category: Field, column_values: list[Field], line_values: list[Field],
|
|
318
|
+
position: Position,
|
|
319
|
+
) -> Visual:
|
|
320
|
+
roles = {"Category": [category], "Y": column_values, "Y2": line_values}
|
|
321
|
+
return self.add_visual("lineClusteredColumnComboChart", roles, position)
|
|
322
|
+
|
|
323
|
+
def add_pie_chart(
|
|
324
|
+
self, category: Field, values: list[Field], position: Position, donut: bool = False,
|
|
325
|
+
) -> Visual:
|
|
326
|
+
roles = {"Category": [category], "Y": values}
|
|
327
|
+
return self.add_visual("donutChart" if donut else "pieChart", roles, position)
|
|
328
|
+
|
|
329
|
+
def add_scatter_chart(
|
|
330
|
+
self, x: Field, y: Field, position: Position,
|
|
331
|
+
category: Field | None = None, size: Field | None = None,
|
|
332
|
+
) -> Visual:
|
|
333
|
+
# Desktop's UI labels this well "Details", but (like every other chart here) the
|
|
334
|
+
# underlying role key is "Category" -- see docs/codepbi-lessons-learned.md.
|
|
335
|
+
roles = {"X": [x], "Y": [y]}
|
|
336
|
+
if category:
|
|
337
|
+
roles["Category"] = [category]
|
|
338
|
+
if size:
|
|
339
|
+
roles["Size"] = [size]
|
|
340
|
+
return self.add_visual("scatterChart", roles, position)
|
|
341
|
+
|
|
342
|
+
def add_gauge(
|
|
343
|
+
self, value: Field, position: Position,
|
|
344
|
+
min_value: Field | None = None, max_value: Field | None = None, target_value: Field | None = None,
|
|
345
|
+
) -> Visual:
|
|
346
|
+
roles = {"Y": [value]}
|
|
347
|
+
if min_value:
|
|
348
|
+
roles["MinValue"] = [min_value]
|
|
349
|
+
if max_value:
|
|
350
|
+
roles["MaxValue"] = [max_value]
|
|
351
|
+
if target_value:
|
|
352
|
+
roles["TargetValue"] = [target_value]
|
|
353
|
+
return self.add_visual("gauge", roles, position)
|
|
354
|
+
|
|
355
|
+
def add_treemap(
|
|
356
|
+
self, group: Field, values: list[Field], position: Position, details: Field | None = None,
|
|
357
|
+
) -> Visual:
|
|
358
|
+
roles = {"Group": [group], "Values": values}
|
|
359
|
+
if details:
|
|
360
|
+
roles["Details"] = [details]
|
|
361
|
+
return self.add_visual("treemap", roles, position)
|
|
362
|
+
|
|
363
|
+
def add_filled_map(
|
|
364
|
+
self, location: Field, position: Position,
|
|
365
|
+
values: list[Field] | None = None, legend: Field | None = None,
|
|
366
|
+
) -> Visual:
|
|
367
|
+
roles = {"Category": [location]}
|
|
368
|
+
if values:
|
|
369
|
+
roles["Values"] = values
|
|
370
|
+
if legend:
|
|
371
|
+
roles["Legend"] = [legend]
|
|
372
|
+
return self.add_visual("filledMap", roles, position)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass
|
|
376
|
+
class Report:
|
|
377
|
+
name: str
|
|
378
|
+
semantic_model_path: str
|
|
379
|
+
pages: list[Page] = field(default_factory=list)
|
|
380
|
+
active_page_name: str | None = None
|
|
381
|
+
# A custom color theme -- Microsoft's public Report Theme JSON format
|
|
382
|
+
# (https://learn.microsoft.com/power-bi/create-reports/desktop-report-themes), e.g.
|
|
383
|
+
# {"name": "Brand", "dataColors": ["#005493", "#008fb4", ...]}. None = Desktop's default
|
|
384
|
+
# theme (still valid -- themeCollection is required but can be empty, see
|
|
385
|
+
# docs/codepbi-lessons-learned.md in the consuming project).
|
|
386
|
+
theme: dict | None = None
|
|
387
|
+
# Report-level filters, applied on top of page- and visual-level filters. Same
|
|
388
|
+
# FilterContainer shape as Visual/Page filters -- confirmed via the filterConfiguration
|
|
389
|
+
# schema (report.json's top-level "filterConfig" property).
|
|
390
|
+
filters: list[Filter] = field(default_factory=list)
|
|
391
|
+
# (resource_name, local_file_path) pairs bundled into the report's RegisteredResources
|
|
392
|
+
# package (type "Image") -- reference one from a page via Page.add_image(resource_name, ...).
|
|
393
|
+
images: list[tuple[str, str]] = field(default_factory=list)
|
|
394
|
+
|
|
395
|
+
def add_image_resource(self, name: str, file_path: str) -> None:
|
|
396
|
+
self.images.append((name, file_path))
|
|
397
|
+
|
|
398
|
+
def add_page(self, display_name: str, **kwargs) -> Page:
|
|
399
|
+
page = Page(display_name=display_name, **kwargs)
|
|
400
|
+
self.pages.append(page)
|
|
401
|
+
if self.active_page_name is None:
|
|
402
|
+
self.active_page_name = page.name
|
|
403
|
+
return page
|
|
404
|
+
|
|
405
|
+
def save(self, parent_dir: str) -> str:
|
|
406
|
+
from .writer import write_report
|
|
407
|
+
|
|
408
|
+
return write_report(self, parent_dir)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Sync a Power BI TMDL semantic model from a dbt project.
|
|
2
|
+
|
|
3
|
+
Treats dbt's schema YAML (`meta.entity` / `meta.grain` / `meta.display_name` / ...)
|
|
4
|
+
as the source of truth for columns, and a dbt-semantic-layer-style
|
|
5
|
+
`semantic_models/*.yml` (entities + measures) as the source of truth for joins
|
|
6
|
+
and DAX measures. Applies both to TMDL via the Tabular Object Model (TOM), so
|
|
7
|
+
Power BI Desktop keeps working normally against the result.
|
|
8
|
+
|
|
9
|
+
Typical flow:
|
|
10
|
+
|
|
11
|
+
from codepbi.semantic import TmdlProjectConfig, check_drift, sync_descriptions, \\
|
|
12
|
+
sync_measures, sync_relationships
|
|
13
|
+
|
|
14
|
+
config = TmdlProjectConfig(
|
|
15
|
+
dbt_project_dir="path/to/dbt_project",
|
|
16
|
+
semantic_model_definition_dir="path/to/Model.SemanticModel/definition",
|
|
17
|
+
tom_lib_dir="path/to/analysis-services-dlls",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
sync_measures.sync(config)
|
|
21
|
+
sync_descriptions.sync(config) # also runs validate_tmdl at the end
|
|
22
|
+
sync_relationships.sync(config)
|
|
23
|
+
check_drift.check(config)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from . import (
|
|
27
|
+
check_drift,
|
|
28
|
+
check_schema_vs_sql,
|
|
29
|
+
schema_loader,
|
|
30
|
+
sync_descriptions,
|
|
31
|
+
sync_measures,
|
|
32
|
+
sync_relationships,
|
|
33
|
+
tom_utils,
|
|
34
|
+
validate_tmdl,
|
|
35
|
+
)
|
|
36
|
+
from .config import TmdlProjectConfig
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"TmdlProjectConfig",
|
|
40
|
+
"check_drift",
|
|
41
|
+
"check_schema_vs_sql",
|
|
42
|
+
"schema_loader",
|
|
43
|
+
"sync_descriptions",
|
|
44
|
+
"sync_measures",
|
|
45
|
+
"sync_relationships",
|
|
46
|
+
"tom_utils",
|
|
47
|
+
"validate_tmdl",
|
|
48
|
+
]
|