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