pbip-compiler 0.2.0__py3-none-any.whl
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.
- pbip_compiler/__init__.py +38 -0
- pbip_compiler/cli.py +33 -0
- pbip_compiler/compiler.py +88 -0
- pbip_compiler/datamodel/__init__.py +10 -0
- pbip_compiler/datamodel/builder.py +67 -0
- pbip_compiler/datamodel/mpatch.py +39 -0
- pbip_compiler/discovery.py +50 -0
- pbip_compiler/models.py +37 -0
- pbip_compiler/pbix/__init__.py +7 -0
- pbip_compiler/pbix/assembler.py +229 -0
- pbip_compiler/pbix/constants.py +83 -0
- pbip_compiler/report/__init__.py +9 -0
- pbip_compiler/report/layout.py +51 -0
- pbip_compiler/report/pbir.py +359 -0
- pbip_compiler/report/resources.py +30 -0
- pbip_compiler/semantic_model/__init__.py +9 -0
- pbip_compiler/semantic_model/loader.py +30 -0
- pbip_compiler/semantic_model/tmdl.py +102 -0
- pbip_compiler/semantic_model/tmdl_table.py +413 -0
- pbip_compiler/semantic_model/tmsl.py +75 -0
- pbip_compiler/semantic_model/types.py +35 -0
- pbip_compiler-0.2.0.dist-info/METADATA +201 -0
- pbip_compiler-0.2.0.dist-info/RECORD +26 -0
- pbip_compiler-0.2.0.dist-info/WHEEL +4 -0
- pbip_compiler-0.2.0.dist-info/entry_points.txt +2 -0
- pbip_compiler-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""pbip_compiler — compile a Power BI Project (.pbip) folder into a full .pbix file.
|
|
2
|
+
|
|
3
|
+
Pure Python — no CLI tools, no Power BI Desktop, no Windows required.
|
|
4
|
+
|
|
5
|
+
Public API
|
|
6
|
+
──────────
|
|
7
|
+
from pbip_compiler import compile_pbip
|
|
8
|
+
compile_pbip("./MyProject", "./MyReport.pbix")
|
|
9
|
+
|
|
10
|
+
Package layout
|
|
11
|
+
──────────────
|
|
12
|
+
discovery – locate the .Report / .SemanticModel folders
|
|
13
|
+
semantic_model/ – parse TMSL (model.bim) and TMDL into a normalised model
|
|
14
|
+
datamodel/ – build the VertiPaq DataModel via pbix-mcp (+ data fetch)
|
|
15
|
+
report/ – compile the PBIR report into a legacy Report/Layout
|
|
16
|
+
pbix/ – assemble the final .pbix ZIP
|
|
17
|
+
compiler – orchestrates the above (compile_pbip)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .compiler import PbipCompiler
|
|
23
|
+
from .models import (
|
|
24
|
+
Column,
|
|
25
|
+
Measure,
|
|
26
|
+
Relationship,
|
|
27
|
+
SemanticModel,
|
|
28
|
+
Table,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"PbipCompiler",
|
|
33
|
+
"SemanticModel",
|
|
34
|
+
"Table",
|
|
35
|
+
"Column",
|
|
36
|
+
"Measure",
|
|
37
|
+
"Relationship",
|
|
38
|
+
]
|
pbip_compiler/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Command-line entry point — compile a .pbip folder into a .pbix file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from argparse import ArgumentParser, RawDescriptionHelpFormatter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import PbipCompiler
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
parser: ArgumentParser = ArgumentParser(
|
|
14
|
+
description = "Compile a .pbip folder into a .pbix file (pure Python).",
|
|
15
|
+
formatter_class = RawDescriptionHelpFormatter,
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("--pbip", required=True, help="Path to the .pbip project folder")
|
|
18
|
+
parser.add_argument("--output", required=True, help="Destination .pbix path")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
compiler: PbipCompiler = PbipCompiler(args.pbip)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
result: Path = compiler.compile(args.output)
|
|
25
|
+
print(f"\n✅ Success → {result}")
|
|
26
|
+
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
print(f"\n❌ Error: {exc}", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Public entry point — orchestrate the .pbip → .pbix compilation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
from .datamodel import PbixMcpDataModelBuilder
|
|
9
|
+
from .discovery import (
|
|
10
|
+
find_report_folder,
|
|
11
|
+
find_semantic_model_folder,
|
|
12
|
+
read_live_connection,
|
|
13
|
+
)
|
|
14
|
+
from .pbix import PbixAssembler
|
|
15
|
+
from .report import ReportLayoutLoader
|
|
16
|
+
from .semantic_model import SemanticModelLoader
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PbipCompiler:
|
|
20
|
+
"""Compile a .pbip project folder into a .pbix file."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, pbip_path : Union[Path, str]) -> None:
|
|
23
|
+
self._model_loader = SemanticModelLoader()
|
|
24
|
+
self._layout_loader = ReportLayoutLoader()
|
|
25
|
+
self._datamodel_builder = PbixMcpDataModelBuilder()
|
|
26
|
+
self._assembler = PbixAssembler()
|
|
27
|
+
|
|
28
|
+
self.pbip_path : Path = Path(pbip_path).resolve()
|
|
29
|
+
|
|
30
|
+
def compile(self, output: Union[Path, str]) -> Path:
|
|
31
|
+
"""Compile the project and write the .pbix to ``output``."""
|
|
32
|
+
output : Path = Path(output).resolve()
|
|
33
|
+
self._compile(output)
|
|
34
|
+
return output
|
|
35
|
+
|
|
36
|
+
def compile_to_bytes(self) -> bytes:
|
|
37
|
+
"""Compile the project and return the .pbix as bytes (no file written)."""
|
|
38
|
+
return self._compile(None)
|
|
39
|
+
|
|
40
|
+
def _compile(self, output: Optional[Path]) -> bytes:
|
|
41
|
+
|
|
42
|
+
print(f"[pbip→pbix]")
|
|
43
|
+
print(f" source : {self.pbip_path}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
report_folder = find_report_folder(self.pbip_path)
|
|
47
|
+
print(f" report : {report_folder.relative_to(self.pbip_path)}/")
|
|
48
|
+
|
|
49
|
+
# Live connection (report bound to a published semantic model): no local
|
|
50
|
+
# model — embed the PBIR report + a Connections part and we're done.
|
|
51
|
+
connection_string = read_live_connection(report_folder)
|
|
52
|
+
if connection_string:
|
|
53
|
+
print(f" model : live connection (remote semantic model)")
|
|
54
|
+
return self._assembler.assemble_live_connection(
|
|
55
|
+
output, report_folder, connection_string,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
semantic_folder = find_semantic_model_folder(self.pbip_path)
|
|
59
|
+
print(f" model : {semantic_folder.relative_to(self.pbip_path) if semantic_folder else '(none)'}/")
|
|
60
|
+
|
|
61
|
+
# report layout
|
|
62
|
+
layout = self._layout_loader.load(report_folder)
|
|
63
|
+
print(f" pages : {len(layout.get('sections', []))}")
|
|
64
|
+
|
|
65
|
+
# base pbix
|
|
66
|
+
base_pbix = self._build_base_pbix(semantic_folder)
|
|
67
|
+
|
|
68
|
+
# Assemble
|
|
69
|
+
return self._assembler.assemble(output, layout, base_pbix, report_folder=report_folder)
|
|
70
|
+
|
|
71
|
+
def _build_base_pbix(self, semantic_folder: Optional[Path]) -> Optional[bytes]:
|
|
72
|
+
if not semantic_folder:
|
|
73
|
+
print(" data : no SemanticModel folder — thin report")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
model = self._model_loader.load(semantic_folder)
|
|
77
|
+
if not model or not model.tables:
|
|
78
|
+
print(" data : semantic model empty — thin report")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
n_t = len(model.tables)
|
|
82
|
+
n_m = sum(len(t.measures) for t in model.tables)
|
|
83
|
+
n_r = len(model.relationships)
|
|
84
|
+
print(f" data : building DataModel "
|
|
85
|
+
f"({n_t} tables, {n_m} measures, {n_r} relationships)")
|
|
86
|
+
|
|
87
|
+
return self._datamodel_builder.build(model)
|
|
88
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from pbix_mcp.builder import PBIXBuilder
|
|
2
|
+
from ..models import Column, SemanticModel, Table
|
|
3
|
+
from .mpatch import patch_partition_m
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
class DataModelBuilder(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def build(self, model: SemanticModel) -> bytes:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
class PbixMcpDataModelBuilder(DataModelBuilder):
|
|
14
|
+
|
|
15
|
+
def _sentinel_row(columns: list[Column]) -> dict:
|
|
16
|
+
"""Return one placeholder row with zero/empty values per column type."""
|
|
17
|
+
row: dict = {}
|
|
18
|
+
for c in columns:
|
|
19
|
+
if c.data_type == "String":
|
|
20
|
+
row[c.name] = " "
|
|
21
|
+
elif c.data_type == "Boolean":
|
|
22
|
+
row[c.name] = False
|
|
23
|
+
else:
|
|
24
|
+
row[c.name] = 0
|
|
25
|
+
return row
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build(self, model: SemanticModel) -> bytes:
|
|
29
|
+
|
|
30
|
+
builder : PBIXBuilder = PBIXBuilder()
|
|
31
|
+
|
|
32
|
+
for table in model.tables:
|
|
33
|
+
self._add_table(builder, table)
|
|
34
|
+
for measure in table.measures:
|
|
35
|
+
builder.add_measure(table.name, measure.name, measure.expression)
|
|
36
|
+
|
|
37
|
+
for relationship in model.relationships:
|
|
38
|
+
builder.add_relationship(
|
|
39
|
+
relationship.from_table, relationship.from_column, relationship.to_table, relationship.to_column,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
with warnings.catch_warnings():
|
|
43
|
+
warnings.simplefilter("ignore")
|
|
44
|
+
pbix_bytes : bytes = builder.build()
|
|
45
|
+
|
|
46
|
+
# Overwrite pbix-mcp's placeholder partition query with the real M, so
|
|
47
|
+
# Refresh loads the data. Tables without an M keep the placeholder.
|
|
48
|
+
m_by_table = {t.name: t.m_expression for t in model.tables if t.m_expression}
|
|
49
|
+
for table in model.tables:
|
|
50
|
+
if table.m_expression:
|
|
51
|
+
print(f" [data] {table.name}: M preserved → refreshable "
|
|
52
|
+
f"(Refresh in Power BI loads the data)")
|
|
53
|
+
else:
|
|
54
|
+
print(f" [warn] {table.name}: no partition M → empty table")
|
|
55
|
+
|
|
56
|
+
return patch_partition_m(pbix_bytes, m_by_table)
|
|
57
|
+
|
|
58
|
+
def _add_table(self, builder: PBIXBuilder, t: Table) -> None:
|
|
59
|
+
# No source_db: pbix-mcp writes a placeholder #table query, which build()
|
|
60
|
+
# overwrites with the table's real M. One sentinel row keeps VertiPaq's
|
|
61
|
+
# column store valid (rows=[] corrupts it).
|
|
62
|
+
builder.add_table(
|
|
63
|
+
name = t.name,
|
|
64
|
+
columns = [c.model_dump(include={"name", "data_type"}) for c in t.columns],
|
|
65
|
+
rows = [PbixMcpDataModelBuilder._sentinel_row(t.columns)],
|
|
66
|
+
hidden = t.is_hidden,
|
|
67
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pbix_mcp.formats.datamodel_roundtrip import compress_datamodel, decompress_datamodel
|
|
2
|
+
from pbix_mcp.formats import abf_rebuild
|
|
3
|
+
from zipfile import ZipFile, ZipInfo, ZIP_STORED, ZIP_DEFLATED
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from sqlite3 import Connection
|
|
6
|
+
|
|
7
|
+
def patch_partition_m(pbix_bytes: bytes, m_by_table: dict[str, str]) -> bytes:
|
|
8
|
+
|
|
9
|
+
if not m_by_table:
|
|
10
|
+
return pbix_bytes
|
|
11
|
+
|
|
12
|
+
with ZipFile(BytesIO(pbix_bytes)) as zf:
|
|
13
|
+
datamodel : bytes= zf.read("DataModel")
|
|
14
|
+
|
|
15
|
+
abf : bytes = decompress_datamodel(datamodel)
|
|
16
|
+
|
|
17
|
+
def modifier(conn: Connection) -> None:
|
|
18
|
+
for table_name, m_expr in m_by_table.items():
|
|
19
|
+
conn.execute(
|
|
20
|
+
"UPDATE [Partition] SET QueryDefinition = ? "
|
|
21
|
+
"WHERE Name = ? AND QueryDefinition IS NOT NULL",
|
|
22
|
+
(m_expr, table_name),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
new_abf : bytes = abf_rebuild.rebuild_abf_with_modified_sqlite(abf, modifier)
|
|
26
|
+
new_datamodel : bytes = compress_datamodel(new_abf)
|
|
27
|
+
|
|
28
|
+
buf : BytesIO = BytesIO()
|
|
29
|
+
with ZipFile(BytesIO(pbix_bytes)) as zf_in:
|
|
30
|
+
with ZipFile(buf, "w", ZIP_DEFLATED) as zf_out:
|
|
31
|
+
for name in zf_in.namelist():
|
|
32
|
+
info : ZipInfo = ZipInfo(filename=name)
|
|
33
|
+
if name == "DataModel":
|
|
34
|
+
info.compress_type = ZIP_STORED
|
|
35
|
+
zf_out.writestr(info, new_datamodel)
|
|
36
|
+
else:
|
|
37
|
+
info.compress_type = ZIP_DEFLATED
|
|
38
|
+
zf_out.writestr(info, zf_in.read(name))
|
|
39
|
+
return buf.getvalue()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def find_report_folder(pbip_root: Path) -> Path:
|
|
7
|
+
"""Return the *.Report folder (or any folder that looks like a report)."""
|
|
8
|
+
for d in pbip_root.glob("*.Report"):
|
|
9
|
+
if d.is_dir():
|
|
10
|
+
return d
|
|
11
|
+
for d in pbip_root.iterdir():
|
|
12
|
+
if d.is_dir() and (
|
|
13
|
+
(d / "definition.pbir").exists()
|
|
14
|
+
or (d / "report.json").exists()
|
|
15
|
+
or (d / "definition").is_dir()
|
|
16
|
+
):
|
|
17
|
+
return d
|
|
18
|
+
raise FileNotFoundError(
|
|
19
|
+
f"No *.Report folder found inside {pbip_root}.\n"
|
|
20
|
+
"Expected: MyProject/MyProject.Report/ (with report.json or definition/)"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_semantic_model_folder(pbip_root: Path) -> Optional[Path]:
|
|
25
|
+
"""Return the *.SemanticModel folder, or None for a report-only project."""
|
|
26
|
+
for d in pbip_root.glob("*.SemanticModel"):
|
|
27
|
+
if d.is_dir():
|
|
28
|
+
return d
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_live_connection(report_folder: Path) -> Optional[str]:
|
|
33
|
+
"""Return the live-connection string if the report binds to a remote model.
|
|
34
|
+
|
|
35
|
+
A "connect to a published semantic model" report has, in its
|
|
36
|
+
definition.pbir, datasetReference.byConnection.connectionString. Returns
|
|
37
|
+
that string, or None for a local-model (byPath) / model-less report.
|
|
38
|
+
"""
|
|
39
|
+
pbir = report_folder / "definition.pbir"
|
|
40
|
+
if not pbir.exists():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
with open(pbir, encoding="utf-8-sig") as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
except (json.JSONDecodeError, OSError):
|
|
46
|
+
return None
|
|
47
|
+
by_conn = data.get("datasetReference", {}).get("byConnection")
|
|
48
|
+
if isinstance(by_conn, dict):
|
|
49
|
+
return by_conn.get("connectionString")
|
|
50
|
+
return None
|
pbip_compiler/models.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Column(BaseModel):
|
|
5
|
+
"""A storage (non-calculated) column of a table."""
|
|
6
|
+
name: str
|
|
7
|
+
data_type: str = "String" # pbix-mcp data_type string
|
|
8
|
+
source_column: str = "" # source field name (defaults to name)
|
|
9
|
+
|
|
10
|
+
def model_post_init(self, __context) -> None:
|
|
11
|
+
if not self.source_column:
|
|
12
|
+
object.__setattr__(self, "source_column", self.name)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Measure(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
expression: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Relationship(BaseModel):
|
|
21
|
+
from_table: str
|
|
22
|
+
from_column: str
|
|
23
|
+
to_table: str
|
|
24
|
+
to_column: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Table(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
columns: list[Column] = Field(default_factory=list)
|
|
30
|
+
measures: list[Measure] = Field(default_factory=list)
|
|
31
|
+
is_hidden: bool = False
|
|
32
|
+
m_expression: str = "" # partition Power Query (M) source
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SemanticModel(BaseModel):
|
|
36
|
+
tables: list[Table] = Field(default_factory=list)
|
|
37
|
+
relationships: list[Relationship] = Field(default_factory=list)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from pbip_compiler.report.resources import StaticResourceCollector
|
|
2
|
+
from .constants import LIVE_CONTENT_TYPES, LIVE_METADATA, LIVE_SETTINGS, LIVE_VERSION, THIN_CONTENT_TYPES, THIN_DIAGRAM, THIN_METADATA, THIN_SETTINGS, THIN_VERSION
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import zipfile
|
|
10
|
+
|
|
11
|
+
class PbixAssembler:
|
|
12
|
+
"""Write the final .pbix file from a layout and an optional base PBIX."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._resources = StaticResourceCollector()
|
|
16
|
+
|
|
17
|
+
def assemble(
|
|
18
|
+
self,
|
|
19
|
+
output_path: Optional[Path],
|
|
20
|
+
layout: dict,
|
|
21
|
+
base_pbix: Optional[bytes],
|
|
22
|
+
report_folder: Optional[Path] = None, # for copying theme/static resources
|
|
23
|
+
) -> bytes:
|
|
24
|
+
"""Build the .pbix bytes. Writes them to ``output_path`` when given,
|
|
25
|
+
otherwise just returns them (in-memory compilation)."""
|
|
26
|
+
name = output_path.name if output_path is not None else "(in-memory)"
|
|
27
|
+
print(f"\n[build] Assembling {name} …")
|
|
28
|
+
|
|
29
|
+
if base_pbix is not None:
|
|
30
|
+
raw = self._patch_layout_into_pbix(base_pbix, layout, report_folder)
|
|
31
|
+
else:
|
|
32
|
+
raw = self._build_thin_pbix(layout)
|
|
33
|
+
|
|
34
|
+
if output_path is not None:
|
|
35
|
+
output_path.write_bytes(raw)
|
|
36
|
+
print(f"\n[done] {output_path} ({len(raw) / 1024:,.1f} KB)")
|
|
37
|
+
else:
|
|
38
|
+
print(f"\n[done] in-memory ({len(raw) / 1024:,.1f} KB)")
|
|
39
|
+
return raw
|
|
40
|
+
|
|
41
|
+
def _patch_layout_into_pbix(
|
|
42
|
+
self,
|
|
43
|
+
base_pbix: bytes,
|
|
44
|
+
layout: dict,
|
|
45
|
+
report_folder: Optional[Path],
|
|
46
|
+
) -> bytes:
|
|
47
|
+
"""Replace Report/Layout in a pbix-mcp-generated PBIX with our own layout,
|
|
48
|
+
and embed any StaticResources (themes) referenced by resourcePackages.
|
|
49
|
+
|
|
50
|
+
Without the theme files the report engine raises "report load failed"
|
|
51
|
+
even though the DataModel loads fine — the layout references them by path.
|
|
52
|
+
"""
|
|
53
|
+
layout_bytes = json.dumps(
|
|
54
|
+
layout, ensure_ascii=False, separators=(",", ":")
|
|
55
|
+
).encode("utf-16-le")
|
|
56
|
+
|
|
57
|
+
extra_files: dict[str, bytes] = {}
|
|
58
|
+
if report_folder is not None:
|
|
59
|
+
extra_files = self._resources.collect(report_folder, layout)
|
|
60
|
+
|
|
61
|
+
buf = BytesIO()
|
|
62
|
+
with zipfile.ZipFile(BytesIO(base_pbix)) as zf_in:
|
|
63
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf_out:
|
|
64
|
+
for name in zf_in.namelist():
|
|
65
|
+
if name == "Report/Layout":
|
|
66
|
+
info = zipfile.ZipInfo(filename=name)
|
|
67
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
68
|
+
zf_out.writestr(info, layout_bytes)
|
|
69
|
+
print(f" ✓ {name:<55} {len(layout_bytes):>10,} bytes (from PBIP)")
|
|
70
|
+
elif name == "DataModel":
|
|
71
|
+
raw = zf_in.read(name)
|
|
72
|
+
info = zipfile.ZipInfo(filename=name)
|
|
73
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
74
|
+
zf_out.writestr(info, raw)
|
|
75
|
+
print(f" ✓ {name:<55} {len(raw):>10,} bytes (from pbix-mcp)")
|
|
76
|
+
else:
|
|
77
|
+
raw = zf_in.read(name)
|
|
78
|
+
info = zipfile.ZipInfo(filename=name)
|
|
79
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
80
|
+
zf_out.writestr(info, raw)
|
|
81
|
+
print(f" ✓ {name:<55} {len(raw):>10,} bytes")
|
|
82
|
+
|
|
83
|
+
for zip_key, data in extra_files.items():
|
|
84
|
+
info = zipfile.ZipInfo(filename=zip_key)
|
|
85
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
86
|
+
zf_out.writestr(info, data)
|
|
87
|
+
|
|
88
|
+
return buf.getvalue()
|
|
89
|
+
|
|
90
|
+
def _build_thin_pbix(self, layout: dict) -> bytes:
|
|
91
|
+
"""Build a minimal PBIX with no DataModel (thin/report-only).
|
|
92
|
+
|
|
93
|
+
Opens in Power BI Desktop as an empty report that can connect to
|
|
94
|
+
an external semantic model (Live Connection or DirectQuery).
|
|
95
|
+
"""
|
|
96
|
+
layout_bytes = json.dumps(
|
|
97
|
+
layout, ensure_ascii=False, separators=(",", ":")
|
|
98
|
+
).encode("utf-16-le")
|
|
99
|
+
|
|
100
|
+
buf = BytesIO()
|
|
101
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
102
|
+
def add(name: str, data: bytes, compress: bool = True) -> None:
|
|
103
|
+
info = zipfile.ZipInfo(filename=name)
|
|
104
|
+
info.compress_type = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
|
|
105
|
+
zf.writestr(info, data)
|
|
106
|
+
print(f" ✓ {name:<55} {len(data):>10,} bytes")
|
|
107
|
+
|
|
108
|
+
add("Version", THIN_VERSION, compress=False)
|
|
109
|
+
add("[Content_Types].xml", THIN_CONTENT_TYPES)
|
|
110
|
+
add("DiagramLayout", THIN_DIAGRAM)
|
|
111
|
+
add("Settings", THIN_SETTINGS)
|
|
112
|
+
add("Metadata", THIN_METADATA)
|
|
113
|
+
add("Report/Layout", layout_bytes)
|
|
114
|
+
print(" – DataModel (omitted — thin report)")
|
|
115
|
+
return buf.getvalue()
|
|
116
|
+
|
|
117
|
+
# ── Live connection (connect to a published semantic model) ──────────────
|
|
118
|
+
def assemble_live_connection(
|
|
119
|
+
self,
|
|
120
|
+
output_path: Optional[Path],
|
|
121
|
+
report_folder: Path,
|
|
122
|
+
connection_string: str,
|
|
123
|
+
) -> bytes:
|
|
124
|
+
"""Build a live-connection .pbix: no DataModel, a Connections part, and
|
|
125
|
+
the PBIR report (definition/ + StaticResources/) embedded verbatim under
|
|
126
|
+
Report/. The report binds to the remote model named in connection_string.
|
|
127
|
+
|
|
128
|
+
Writes to ``output_path`` when given, otherwise just returns the bytes.
|
|
129
|
+
"""
|
|
130
|
+
name = output_path.name if output_path is not None else "(in-memory)"
|
|
131
|
+
print(f"\n[build] Assembling {name} (live connection) …")
|
|
132
|
+
|
|
133
|
+
buf = BytesIO()
|
|
134
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
135
|
+
def add(name: str, data: bytes, compress: bool = True) -> None:
|
|
136
|
+
info = zipfile.ZipInfo(filename=name)
|
|
137
|
+
info.compress_type = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
|
|
138
|
+
zf.writestr(info, data)
|
|
139
|
+
print(f" ✓ {name:<60} {len(data):>10,} bytes")
|
|
140
|
+
|
|
141
|
+
add("Version", LIVE_VERSION, compress=False)
|
|
142
|
+
add("[Content_Types].xml", LIVE_CONTENT_TYPES)
|
|
143
|
+
add("Settings", LIVE_SETTINGS)
|
|
144
|
+
add("Metadata", LIVE_METADATA)
|
|
145
|
+
add("Connections", self._build_connections(connection_string))
|
|
146
|
+
|
|
147
|
+
# Embed the PBIR report verbatim: definition/ + StaticResources/.
|
|
148
|
+
# (definition.pbir, .platform, .pbi … stay out — not PBIX parts.)
|
|
149
|
+
for sub in ("definition", "StaticResources"):
|
|
150
|
+
base = report_folder / sub
|
|
151
|
+
if not base.is_dir():
|
|
152
|
+
continue
|
|
153
|
+
for f in sorted(base.rglob("*")):
|
|
154
|
+
if f.is_file():
|
|
155
|
+
rel = f.relative_to(report_folder).as_posix()
|
|
156
|
+
add(f"Report/{rel}", f.read_bytes())
|
|
157
|
+
|
|
158
|
+
print(" – DataModel (omitted — live connection)")
|
|
159
|
+
|
|
160
|
+
raw = buf.getvalue()
|
|
161
|
+
if output_path is not None:
|
|
162
|
+
output_path.write_bytes(raw)
|
|
163
|
+
print(f"\n[done] {output_path} ({len(raw) / 1024:,.1f} KB)")
|
|
164
|
+
else:
|
|
165
|
+
print(f"\n[done] in-memory ({len(raw) / 1024:,.1f} KB)")
|
|
166
|
+
return raw
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _build_connections(connection_string: str) -> bytes:
|
|
170
|
+
"""Turn a PBIP byConnection string into the PBIX Connections part (UTF-8).
|
|
171
|
+
|
|
172
|
+
The PBIP connectionString carries the standard AS/Power BI keys plus
|
|
173
|
+
extra semanticmodelid/modelid params; the PBIX Connections part keeps the
|
|
174
|
+
four standard keys and lifts modelid → PbiServiceModelId, initial catalog
|
|
175
|
+
→ PbiModelDatabaseName.
|
|
176
|
+
"""
|
|
177
|
+
pairs = PbixAssembler._parse_conn_string(connection_string)
|
|
178
|
+
|
|
179
|
+
def unquote(s: str) -> str:
|
|
180
|
+
return s[1:-1] if len(s) >= 2 and s[0] == '"' and s[-1] == '"' else s
|
|
181
|
+
|
|
182
|
+
ds = pairs.get("data source", "")
|
|
183
|
+
ic = pairs.get("initial catalog", "")
|
|
184
|
+
ip = unquote(pairs.get("identity provider", ""))
|
|
185
|
+
isec = pairs.get("integrated security", "ClaimsToken")
|
|
186
|
+
|
|
187
|
+
clean = (f'Data Source={ds};Initial Catalog={ic};'
|
|
188
|
+
f'Identity Provider="{ip}";Integrated Security={isec}')
|
|
189
|
+
|
|
190
|
+
conn: dict = {
|
|
191
|
+
"Name": "EntityDataSource",
|
|
192
|
+
"ConnectionString": clean,
|
|
193
|
+
"ConnectionType": "pbiServiceLive",
|
|
194
|
+
}
|
|
195
|
+
if "modelid" in pairs:
|
|
196
|
+
try:
|
|
197
|
+
conn["PbiServiceModelId"] = int(pairs["modelid"])
|
|
198
|
+
except ValueError:
|
|
199
|
+
pass
|
|
200
|
+
conn["PbiModelVirtualServerName"] = "sobe_wowvirtualserver"
|
|
201
|
+
conn["PbiModelDatabaseName"] = ic
|
|
202
|
+
|
|
203
|
+
doc = {"Version": 2, "Connections": [conn]}
|
|
204
|
+
return json.dumps(doc, separators=(",", ":")).encode("utf-8")
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _parse_conn_string(cs: str) -> dict:
|
|
208
|
+
"""Parse a `key=value;…` connection string into a lowercased-key dict,
|
|
209
|
+
respecting double-quoted values (which may contain commas)."""
|
|
210
|
+
parts: list[str] = []
|
|
211
|
+
buf, in_quotes = "", False
|
|
212
|
+
for ch in cs:
|
|
213
|
+
if ch == '"':
|
|
214
|
+
in_quotes = not in_quotes
|
|
215
|
+
buf += ch
|
|
216
|
+
elif ch == ";" and not in_quotes:
|
|
217
|
+
parts.append(buf)
|
|
218
|
+
buf = ""
|
|
219
|
+
else:
|
|
220
|
+
buf += ch
|
|
221
|
+
if buf.strip():
|
|
222
|
+
parts.append(buf)
|
|
223
|
+
|
|
224
|
+
pairs: dict = {}
|
|
225
|
+
for p in parts:
|
|
226
|
+
if "=" in p:
|
|
227
|
+
k, v = p.split("=", 1)
|
|
228
|
+
pairs[k.strip().lower()] = v.strip()
|
|
229
|
+
return pairs
|