pbigen 0.1.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.
- pbigen/__init__.py +39 -0
- pbigen/cli.py +114 -0
- pbigen/core/__init__.py +18 -0
- pbigen/core/design.py +184 -0
- pbigen/core/generator.py +69 -0
- pbigen/core/layout.py +88 -0
- pbigen/core/schema.py +64 -0
- pbigen/emit/__init__.py +12 -0
- pbigen/emit/model.py +101 -0
- pbigen/emit/pbir.py +187 -0
- pbigen/emit/visuals.py +179 -0
- pbigen/models/__init__.py +28 -0
- pbigen/models/base.py +30 -0
- pbigen/models/litellm_model.py +186 -0
- pbigen/models/null_model.py +21 -0
- pbigen/sources/__init__.py +55 -0
- pbigen/sources/base.py +64 -0
- pbigen/sources/bigquery.py +81 -0
- pbigen/sources/cube.py +93 -0
- pbigen/sources/lakehouse.py +145 -0
- pbigen/sources/sql_base.py +116 -0
- pbigen/sources/warehouses.py +190 -0
- pbigen/themes/__init__.py +54 -0
- pbigen/themes/aurora.json +25 -0
- pbigen/themes/midnight.json +31 -0
- pbigen/themes/slate.json +25 -0
- pbigen-0.1.0.dist-info/METADATA +543 -0
- pbigen-0.1.0.dist-info/RECORD +32 -0
- pbigen-0.1.0.dist-info/WHEEL +4 -0
- pbigen-0.1.0.dist-info/entry_points.txt +2 -0
- pbigen-0.1.0.dist-info/licenses/AUTHORS.md +7 -0
- pbigen-0.1.0.dist-info/licenses/LICENSE +21 -0
pbigen/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""pbigen — generate world-class Power BI dashboards from any data source.
|
|
2
|
+
|
|
3
|
+
Point it at a table (BigQuery, Snowflake, Redshift, Synapse/Fabric, Databricks, ClickHouse,
|
|
4
|
+
Athena, a Parquet/Iceberg/Delta lake on GCS/S3/ADLS, or a Cube semantic layer), and it
|
|
5
|
+
introspects the schema, reasons about the data shape, and writes an openable Power BI project:
|
|
6
|
+
a navigation sidebar, data-appropriate charts and filters, and usage notes.
|
|
7
|
+
|
|
8
|
+
import pbigen
|
|
9
|
+
|
|
10
|
+
result = pbigen.generate(
|
|
11
|
+
"bigquery",
|
|
12
|
+
source_config={"project": "my-proj", "dataset": "sales", "table": "orders"},
|
|
13
|
+
objective="Revenue and orders by region over time",
|
|
14
|
+
theme="midnight",
|
|
15
|
+
out_dir="out",
|
|
16
|
+
)
|
|
17
|
+
print(result.pbip_path)
|
|
18
|
+
|
|
19
|
+
The design defaults to a deterministic, no-key engine. Pass ``model="gpt-4o-mini"`` (or any
|
|
20
|
+
LiteLLM model id, hosted or local) to let a language model refine the design; only metadata is
|
|
21
|
+
ever sent to it.
|
|
22
|
+
|
|
23
|
+
Author: Arka Gupta
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .core.generator import GenerateResult, generate
|
|
28
|
+
from .sources import available_kinds, get_source
|
|
29
|
+
from .themes import available_themes, get_theme
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
__author__ = "Arka Gupta"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"generate", "GenerateResult",
|
|
36
|
+
"get_source", "available_kinds",
|
|
37
|
+
"get_theme", "available_themes",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
pbigen/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
pbigen generate --source bigquery --set project=p dataset=d table=t --theme midnight
|
|
4
|
+
pbigen sources # list available source kinds
|
|
5
|
+
pbigen themes # list built-in themes
|
|
6
|
+
pbigen test --source lakehouse --set uri=data.parquet fmt=parquet
|
|
7
|
+
|
|
8
|
+
Config values are passed as ``key=value`` pairs after ``--set`` and forwarded to the adapter.
|
|
9
|
+
Integers and booleans are coerced; everything else stays a string.
|
|
10
|
+
|
|
11
|
+
Author: Arka Gupta
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .sources import available_kinds, get_source
|
|
21
|
+
from .themes import available_themes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _coerce(value: str) -> Any:
|
|
25
|
+
low = value.lower()
|
|
26
|
+
if low in ("true", "false"):
|
|
27
|
+
return low == "true"
|
|
28
|
+
if value.isdigit():
|
|
29
|
+
return int(value)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_set(pairs: list[str]) -> dict:
|
|
34
|
+
cfg: dict[str, Any] = {}
|
|
35
|
+
for pair in pairs or []:
|
|
36
|
+
if "=" not in pair:
|
|
37
|
+
raise SystemExit(f"--set expects key=value, got {pair!r}")
|
|
38
|
+
key, _, value = pair.partition("=")
|
|
39
|
+
cfg[key.strip()] = _coerce(value.strip())
|
|
40
|
+
return cfg
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_generate(args: argparse.Namespace) -> int:
|
|
44
|
+
from .core.generator import generate
|
|
45
|
+
cfg = _parse_set(args.set)
|
|
46
|
+
result = generate(
|
|
47
|
+
args.source,
|
|
48
|
+
out_dir=args.out,
|
|
49
|
+
name=args.name,
|
|
50
|
+
objective=args.objective or "",
|
|
51
|
+
model=args.model,
|
|
52
|
+
theme=args.theme,
|
|
53
|
+
source_config=cfg,
|
|
54
|
+
)
|
|
55
|
+
print(f"Generated {result.n_pages} pages from {result.table} "
|
|
56
|
+
f"({result.n_columns} columns) using {result.model_name}.")
|
|
57
|
+
print(f"Open: {result.pbip_path}")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_sources(_: argparse.Namespace) -> int:
|
|
62
|
+
print("Available sources:")
|
|
63
|
+
for kind in available_kinds():
|
|
64
|
+
print(f" - {kind}")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cmd_themes(_: argparse.Namespace) -> int:
|
|
69
|
+
print("Built-in themes (or pass a path to your own Power BI theme JSON):")
|
|
70
|
+
for name in available_themes():
|
|
71
|
+
print(f" - {name}")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmd_test(args: argparse.Namespace) -> int:
|
|
76
|
+
src = get_source(args.source, **_parse_set(args.set))
|
|
77
|
+
result = src.test_connection()
|
|
78
|
+
print(result.message)
|
|
79
|
+
return 0 if result.ok else 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
83
|
+
p = argparse.ArgumentParser(prog="pbigen",
|
|
84
|
+
description="Generate Power BI dashboards from any data source.")
|
|
85
|
+
p.add_argument("--version", action="version", version=f"pbigen {__version__}")
|
|
86
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
87
|
+
|
|
88
|
+
g = sub.add_parser("generate", help="generate a Power BI project from a source")
|
|
89
|
+
g.add_argument("--source", required=True, help=f"source kind ({', '.join(available_kinds())})")
|
|
90
|
+
g.add_argument("--set", nargs="*", default=[], help="source config as key=value pairs")
|
|
91
|
+
g.add_argument("--objective", help="what the dashboard should answer")
|
|
92
|
+
g.add_argument("--model", help="LiteLLM model id (omit for the deterministic design)")
|
|
93
|
+
g.add_argument("--theme", help="built-in theme name or path to a Power BI theme JSON")
|
|
94
|
+
g.add_argument("--out", default="out", help="output directory (default: out)")
|
|
95
|
+
g.add_argument("--name", help="project name (default: derived from the table)")
|
|
96
|
+
g.set_defaults(func=_cmd_generate)
|
|
97
|
+
|
|
98
|
+
t = sub.add_parser("test", help="test connectivity + introspection for a source")
|
|
99
|
+
t.add_argument("--source", required=True)
|
|
100
|
+
t.add_argument("--set", nargs="*", default=[])
|
|
101
|
+
t.set_defaults(func=_cmd_test)
|
|
102
|
+
|
|
103
|
+
sub.add_parser("sources", help="list available source kinds").set_defaults(func=_cmd_sources)
|
|
104
|
+
sub.add_parser("themes", help="list built-in themes").set_defaults(func=_cmd_themes)
|
|
105
|
+
return p
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
args = build_parser().parse_args(argv if argv is not None else sys.argv[1:])
|
|
110
|
+
return args.func(args)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
raise SystemExit(main())
|
pbigen/core/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Core: the platform-neutral brain — canonical schema, design rules, layout, generator.
|
|
2
|
+
|
|
3
|
+
Nothing in ``core`` imports a database driver or a cloud SDK, so the design logic is fully
|
|
4
|
+
testable on its own and portable across every source and emitter.
|
|
5
|
+
|
|
6
|
+
Author: Arka Gupta
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .design import Design, Measure, Page, Visual, classify, design, propose_measures
|
|
11
|
+
from .generator import GenerateResult, generate
|
|
12
|
+
from .layout import pack
|
|
13
|
+
from .schema import Column, Schema
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Column", "Schema", "Measure", "Visual", "Page", "Design",
|
|
17
|
+
"classify", "propose_measures", "design", "pack", "generate", "GenerateResult",
|
|
18
|
+
]
|
pbigen/core/design.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The design brain: turn a schema + an objective into a logical dashboard design.
|
|
2
|
+
|
|
3
|
+
This module is deliberately deterministic and dependency-free. It classifies every column
|
|
4
|
+
(measure / date / category / geo / id), proposes sensible measures, and lays out a narrative
|
|
5
|
+
set of pages whose chart and filter choices are driven by the *data shape* (types and
|
|
6
|
+
distinct-value counts). A language model can refine this design (see ``blueprint``), but the
|
|
7
|
+
rules here always produce a complete, coherent dashboard on their own.
|
|
8
|
+
|
|
9
|
+
Author: Arka Gupta
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from .schema import DATETIME, NUMERIC_TYPES, Schema
|
|
17
|
+
|
|
18
|
+
# ---- column-role heuristics -------------------------------------------------
|
|
19
|
+
_DATE_HINT = re.compile(r"(date|dttm|_dt$|_ts$|timestamp|period|year_?month|yyyymm|mth|month|year|week|day)", re.I)
|
|
20
|
+
_ID_HINT = re.compile(r"(_id$|^id$|_key$|^key$|uuid|guid|_no$|_number$|_code$|ssn|tax_?id|passport|phone|email|account)", re.I)
|
|
21
|
+
_CAT_HINT = re.compile(r"(type|status|category|categ|segment|brand|region|area|zone|state|country|city|class|mode|method|group|flag|channel|source|reason|gender|department|company|product|service|tier|band|level)", re.I)
|
|
22
|
+
_GEO_HINT = re.compile(r"(lat|lon|lng|postal|zipcode|geohash)", re.I)
|
|
23
|
+
_MONEY_HINT = re.compile(r"(income|cost|amount|amt|revenue|sales|price|fee|charge|value|gmv|spend|profit|margin|billing|payment)", re.I)
|
|
24
|
+
# columns that are derived parts of a date — redundant as filters once a real date exists
|
|
25
|
+
_PERIOD_PART = re.compile(r"(year|month|week|quarter|day.?of|_dt$|period|_yr$)", re.I)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Measure:
|
|
30
|
+
"""A logical measure: an aggregation of a column, or a ratio/share of other measures.
|
|
31
|
+
|
|
32
|
+
The emitter turns these into the target BI expression (e.g. DAX for Power BI)."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
kind: str = "agg" # agg | ratio | share
|
|
36
|
+
column: str | None = None
|
|
37
|
+
agg: str = "SUM" # SUM | AVERAGE | MIN | MAX | COUNT | DISTINCTCOUNT
|
|
38
|
+
numerator: str | None = None
|
|
39
|
+
denominator: str | None = None
|
|
40
|
+
dimension: str | None = None
|
|
41
|
+
money: bool = False
|
|
42
|
+
percent: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Visual:
|
|
47
|
+
"""One visual on a page. Fields not relevant to a type are simply left unset."""
|
|
48
|
+
|
|
49
|
+
type: str # card|kpi|line|area|column|columnStacked|bar|barStacked|
|
|
50
|
+
# donut|pie|scatter|table|matrix|slicer
|
|
51
|
+
title: str = ""
|
|
52
|
+
measures: list[str] = field(default_factory=list)
|
|
53
|
+
category: str | None = None
|
|
54
|
+
series: str | None = None
|
|
55
|
+
columns: list[str] = field(default_factory=list)
|
|
56
|
+
x_measure: str | None = None
|
|
57
|
+
y_measure: str | None = None
|
|
58
|
+
size_measure: str | None = None
|
|
59
|
+
slicer_mode: str = "Dropdown"
|
|
60
|
+
# layout (filled by the layout packer)
|
|
61
|
+
x: int = 0
|
|
62
|
+
y: int = 0
|
|
63
|
+
w: int = 0
|
|
64
|
+
h: int = 0
|
|
65
|
+
z: int = 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Page:
|
|
70
|
+
name: str
|
|
71
|
+
visuals: list[Visual] = field(default_factory=list)
|
|
72
|
+
slicers: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Design:
|
|
77
|
+
measures: list[Measure] = field(default_factory=list)
|
|
78
|
+
pages: list[Page] = field(default_factory=list)
|
|
79
|
+
usage_notes: list[str] = field(default_factory=list)
|
|
80
|
+
rationale: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def classify(schema: Schema) -> dict[str, list[str]]:
|
|
84
|
+
"""Bucket columns into measures / dates / categories / geo / ids."""
|
|
85
|
+
out: dict[str, list[str]] = {"measures": [], "dates": [], "categories": [], "geo": [], "ids": []}
|
|
86
|
+
for c in schema.columns:
|
|
87
|
+
low = c.name.lower()
|
|
88
|
+
if _GEO_HINT.search(low):
|
|
89
|
+
out["geo"].append(c.name)
|
|
90
|
+
elif c.dtype == DATETIME or (_DATE_HINT.search(low) and (c.is_numeric or c.dtype == "string" or c.is_temporal)):
|
|
91
|
+
out["dates"].append(c.name)
|
|
92
|
+
elif _ID_HINT.search(low):
|
|
93
|
+
out["ids"].append(c.name)
|
|
94
|
+
elif c.dtype in NUMERIC_TYPES:
|
|
95
|
+
out["measures"].append(c.name)
|
|
96
|
+
else:
|
|
97
|
+
out["categories"].append(c.name)
|
|
98
|
+
out["categories"].sort(key=lambda n: (0 if _CAT_HINT.search(n.lower()) else 1, len(n)))
|
|
99
|
+
out["dates"].sort(key=lambda n: (0 if _DATE_HINT.search(n.lower()) else 1, len(n)))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def propose_measures(schema: Schema, limit: int = 6) -> list[Measure]:
|
|
104
|
+
"""Synthesise SUM measures from numeric columns when none are supplied."""
|
|
105
|
+
cls = classify(schema)
|
|
106
|
+
out: list[Measure] = []
|
|
107
|
+
for col in cls["measures"][:limit]:
|
|
108
|
+
pretty = "Total " + col.replace("_", " ").title()
|
|
109
|
+
out.append(Measure(pretty, "agg", column=col, agg="SUM", money=bool(_MONEY_HINT.search(col.lower()))))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _good_slicer(name: str, schema: Schema, real_dates: set[str]) -> bool:
|
|
114
|
+
if name in real_dates:
|
|
115
|
+
return True
|
|
116
|
+
if real_dates and _PERIOD_PART.search(name): # redundant with the date range
|
|
117
|
+
return False
|
|
118
|
+
col = schema.by_name(name)
|
|
119
|
+
card = col.cardinality if col else None
|
|
120
|
+
return card is None or card <= 50 # dropdowns only for low-cardinality
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def design(schema: Schema, objective: str = "", measures: list[Measure] | None = None) -> Design:
|
|
124
|
+
"""Deterministic, cardinality-aware dashboard design (the always-on baseline)."""
|
|
125
|
+
cls = classify(schema)
|
|
126
|
+
real_dates = {c.name for c in schema.columns if c.dtype == DATETIME}
|
|
127
|
+
measures = measures or propose_measures(schema)
|
|
128
|
+
headline = [m.name for m in measures][:6] or ["(no measure)"]
|
|
129
|
+
lead = headline[0]
|
|
130
|
+
|
|
131
|
+
date = cls["dates"][0] if cls["dates"] else None
|
|
132
|
+
dims = [d for d in cls["categories"]][:4]
|
|
133
|
+
dim0 = dims[0] if dims else None
|
|
134
|
+
dim1 = dims[1] if len(dims) > 1 else None
|
|
135
|
+
|
|
136
|
+
def low_card(col: str | None) -> bool:
|
|
137
|
+
c = schema.by_name(col) if col else None
|
|
138
|
+
return bool(col) and (c is None or c.cardinality is None or c.cardinality <= 8)
|
|
139
|
+
|
|
140
|
+
def breakdown(metric: str, dim: str, title: str) -> Visual:
|
|
141
|
+
# donut only for few categories, else a bar chart
|
|
142
|
+
return Visual("donut" if low_card(dim) else "bar", title, measures=[metric], category=dim)
|
|
143
|
+
|
|
144
|
+
slicers = [s for s in ([date] if date else []) + dims if _good_slicer(s, schema, real_dates)][:5]
|
|
145
|
+
pages: list[Page] = []
|
|
146
|
+
|
|
147
|
+
# 1) Executive Summary
|
|
148
|
+
v = [Visual("card", m, measures=[m]) for m in headline[:4]]
|
|
149
|
+
if date:
|
|
150
|
+
v.append(Visual("line", f"{lead} over time", measures=headline[:2], category=date))
|
|
151
|
+
if dim0:
|
|
152
|
+
v.append(breakdown(lead, dim0, f"{lead} by {dim0}"))
|
|
153
|
+
if dim1:
|
|
154
|
+
v.append(breakdown(lead, dim1, f"{lead} by {dim1}"))
|
|
155
|
+
pages.append(Page("Executive Summary", v, slicers))
|
|
156
|
+
|
|
157
|
+
# 2) Trends over time
|
|
158
|
+
if date:
|
|
159
|
+
v = [Visual("card", m, measures=[m]) for m in headline[:3]]
|
|
160
|
+
v.append(Visual("line", f"{lead} trend", measures=headline[:3], category=date))
|
|
161
|
+
if dim0:
|
|
162
|
+
v.append(Visual("column", f"{lead} by {dim0}", measures=headline[:2], category=dim0, series=dim1))
|
|
163
|
+
pages.append(Page("Trends Over Time", v, slicers))
|
|
164
|
+
|
|
165
|
+
# 3) Segmentation & drivers
|
|
166
|
+
if dim0:
|
|
167
|
+
v = [Visual("column", f"{lead} by {dim0}", measures=[lead], category=dim0)]
|
|
168
|
+
if dim1:
|
|
169
|
+
v.append(Visual("bar", f"{lead} by {dim1}", measures=[lead], category=dim1))
|
|
170
|
+
v.append(Visual("matrix", f"{dim0} × measures", measures=headline, category=dim0, series=dim1))
|
|
171
|
+
pages.append(Page("Segmentation & Drivers", v, slicers))
|
|
172
|
+
|
|
173
|
+
# 4) Detail / self-serve
|
|
174
|
+
detail_cols = ([date] if date else []) + dims
|
|
175
|
+
v = [Visual("table", "Detail (all fields)", measures=headline, columns=detail_cols)]
|
|
176
|
+
pages.append(Page("Detailed Data", v, slicers))
|
|
177
|
+
|
|
178
|
+
notes = [
|
|
179
|
+
"Use the filters on the left to focus the report (date range, then segment by the dropdowns).",
|
|
180
|
+
"KPI cards show headline totals; charts show trends and breakdowns; tables show the detail.",
|
|
181
|
+
"Move across the page tabs for the summary, trends, segmentation and detail.",
|
|
182
|
+
]
|
|
183
|
+
return Design(measures=measures, pages=pages, usage_notes=notes,
|
|
184
|
+
rationale="Deterministic, cardinality-aware design.")
|
pbigen/core/generator.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The generator: source -> schema+cardinality -> design -> Power BI project.
|
|
2
|
+
|
|
3
|
+
This is the one function that stitches the pieces together. It stays thin on purpose — all the
|
|
4
|
+
judgement lives in the source adapters, the design model and the emitter — so the pipeline is easy
|
|
5
|
+
to read and to test end to end.
|
|
6
|
+
|
|
7
|
+
Author: Arka Gupta
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ..models import Model, get_model
|
|
15
|
+
from ..sources import Source, get_source
|
|
16
|
+
from ..themes import get_theme, split_chrome
|
|
17
|
+
from .design import Design
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GenerateResult:
|
|
22
|
+
pbip_path: str
|
|
23
|
+
design: Design
|
|
24
|
+
table: str
|
|
25
|
+
n_columns: int
|
|
26
|
+
n_pages: int
|
|
27
|
+
model_name: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _project_name(raw: str) -> str:
|
|
31
|
+
name = re.sub(r"[^0-9A-Za-z_-]+", "-", raw).strip("-")
|
|
32
|
+
return name or "dashboard"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate(
|
|
36
|
+
source: Source | str,
|
|
37
|
+
*,
|
|
38
|
+
out_dir: str = "out",
|
|
39
|
+
name: str | None = None,
|
|
40
|
+
objective: str = "",
|
|
41
|
+
model: Model | str | None = None,
|
|
42
|
+
theme: str | None = None,
|
|
43
|
+
source_config: dict | None = None,
|
|
44
|
+
model_config: dict | None = None,
|
|
45
|
+
) -> GenerateResult:
|
|
46
|
+
"""Generate a Power BI project from a source.
|
|
47
|
+
|
|
48
|
+
``source`` may be a configured :class:`Source` or a kind string (with ``source_config``).
|
|
49
|
+
``model`` may be a :class:`Model`, a LiteLLM model id, or ``None`` for the deterministic design.
|
|
50
|
+
``theme`` may be a built-in name or a path to a Power BI theme JSON.
|
|
51
|
+
"""
|
|
52
|
+
src = source if isinstance(source, Source) else get_source(source, **(source_config or {}))
|
|
53
|
+
mdl = model if isinstance(model, Model) else get_model(model, **(model_config or {}))
|
|
54
|
+
|
|
55
|
+
schema = src.schema_with_cardinality()
|
|
56
|
+
design = mdl.design(schema, objective)
|
|
57
|
+
theme_doc, sidebar, accent = split_chrome(get_theme(theme))
|
|
58
|
+
|
|
59
|
+
from ..emit import write_project
|
|
60
|
+
project = name or _project_name(schema.display_name or schema.table)
|
|
61
|
+
pbip_path = write_project(
|
|
62
|
+
design, schema, src.power_query(), out_dir, project,
|
|
63
|
+
theme=theme_doc, sidebar_color=sidebar, accent=accent,
|
|
64
|
+
brand=schema.display_name,
|
|
65
|
+
)
|
|
66
|
+
return GenerateResult(
|
|
67
|
+
pbip_path=pbip_path, design=design, table=schema.table,
|
|
68
|
+
n_columns=len(schema.columns), n_pages=len(design.pages), model_name=mdl.name,
|
|
69
|
+
)
|
pbigen/core/layout.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Layout packer: place a page's visuals on a wide canvas with a left filter sidebar.
|
|
2
|
+
|
|
3
|
+
The design brain decides *what* each page shows; this module decides *where*, reliably:
|
|
4
|
+
a left sidebar for the logo + stacked filters + a "how to use" note, and a main area with a
|
|
5
|
+
KPI card row and charts/tables packed by footprint (wide pivots full-width, narrow ones paired).
|
|
6
|
+
|
|
7
|
+
Author: Arka Gupta
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .design import Page, Visual
|
|
12
|
+
from .schema import DATETIME, Schema
|
|
13
|
+
|
|
14
|
+
# canvas + sidebar geometry (a large canvas so dense layouts are not clipped)
|
|
15
|
+
PAGE_W, PAGE_H = 1920, 1080
|
|
16
|
+
SIDEBAR_W = 300
|
|
17
|
+
LOGO_ZONE_H = 200
|
|
18
|
+
TITLE_H = 84
|
|
19
|
+
CARD_H = 128
|
|
20
|
+
GAP = 16
|
|
21
|
+
MARGIN = 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_wide(v: Visual) -> bool:
|
|
25
|
+
"""A visual that shows many columns needs the full width."""
|
|
26
|
+
if v.type == "matrix":
|
|
27
|
+
return bool(v.series) or len(v.measures) >= 3
|
|
28
|
+
if v.type == "table":
|
|
29
|
+
return len(v.columns) + len(v.measures) >= 6
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pack(page: Page, schema: Schema) -> list[Visual]:
|
|
34
|
+
"""Return the page's visuals with x/y/w/h assigned, plus positioned slicer visuals."""
|
|
35
|
+
real_dates = {c.name for c in schema.columns if c.dtype == DATETIME}
|
|
36
|
+
placed: list[Visual] = []
|
|
37
|
+
|
|
38
|
+
# slicers stacked in the sidebar; real dates render as a range slider
|
|
39
|
+
sy = LOGO_ZONE_H
|
|
40
|
+
for col in page.slicers[:7]:
|
|
41
|
+
is_date = col in real_dates
|
|
42
|
+
h = 84 if is_date else 72
|
|
43
|
+
placed.append(Visual("slicer", title=col, category=col,
|
|
44
|
+
slicer_mode="Between" if is_date else "Dropdown",
|
|
45
|
+
x=20, y=sy, w=SIDEBAR_W - 40, h=h))
|
|
46
|
+
sy += h + 12
|
|
47
|
+
|
|
48
|
+
mx0 = SIDEBAR_W + MARGIN
|
|
49
|
+
mw = PAGE_W - mx0 - MARGIN
|
|
50
|
+
bottom = PAGE_H - MARGIN
|
|
51
|
+
|
|
52
|
+
cards = [v for v in page.visuals if v.type in ("card", "kpi")][:8]
|
|
53
|
+
body = [v for v in page.visuals if v.type not in ("card", "kpi")]
|
|
54
|
+
|
|
55
|
+
y = MARGIN + TITLE_H
|
|
56
|
+
if cards:
|
|
57
|
+
per = 4 if len(cards) > 3 else len(cards)
|
|
58
|
+
for i0 in range(0, len(cards), per):
|
|
59
|
+
row = cards[i0:i0 + per]
|
|
60
|
+
n = len(row)
|
|
61
|
+
w = (mw - (n - 1) * GAP) // n
|
|
62
|
+
for i, v in enumerate(row):
|
|
63
|
+
v.x, v.y, v.w, v.h = mx0 + i * (w + GAP), y, w, CARD_H
|
|
64
|
+
placed.append(v)
|
|
65
|
+
y += CARD_H + GAP
|
|
66
|
+
|
|
67
|
+
wide = [v for v in body if _is_wide(v)]
|
|
68
|
+
charts = [v for v in body if not _is_wide(v) and v.type not in ("table", "matrix")]
|
|
69
|
+
ntables = [v for v in body if not _is_wide(v) and v.type in ("table", "matrix")]
|
|
70
|
+
pairable: list[Visual] = [] # interleave so a narrow table pairs with a chart
|
|
71
|
+
while charts or ntables:
|
|
72
|
+
if charts:
|
|
73
|
+
pairable.append(charts.pop(0))
|
|
74
|
+
if ntables:
|
|
75
|
+
pairable.append(ntables.pop(0))
|
|
76
|
+
rows = [[wv] for wv in wide] + [pairable[i:i + 2] for i in range(0, len(pairable), 2)]
|
|
77
|
+
|
|
78
|
+
n = max(1, len(rows))
|
|
79
|
+
row_h = (bottom - y - (n - 1) * GAP) // n
|
|
80
|
+
for r in rows:
|
|
81
|
+
x = mx0
|
|
82
|
+
widths = [mw] if len(r) == 1 else [(mw - GAP) // 2] * 2
|
|
83
|
+
for w, v in zip(widths, r):
|
|
84
|
+
v.x, v.y, v.w, v.h = x, y, w, row_h
|
|
85
|
+
placed.append(v)
|
|
86
|
+
x += w + GAP
|
|
87
|
+
y += row_h + GAP
|
|
88
|
+
return placed
|
pbigen/core/schema.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Canonical, source-agnostic schema types.
|
|
2
|
+
|
|
3
|
+
Every data source adapter normalises its native metadata into these types, so the design
|
|
4
|
+
engine and the Power BI emitter never need to know which warehouse the data came from.
|
|
5
|
+
|
|
6
|
+
Author: Arka Gupta
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
# Canonical data-type vocabulary. Adapters map their native types onto these; the design
|
|
13
|
+
# engine reasons in these terms and the emitter maps them to the target BI model types.
|
|
14
|
+
STRING = "string"
|
|
15
|
+
INTEGER = "integer"
|
|
16
|
+
FLOAT = "float"
|
|
17
|
+
DECIMAL = "decimal"
|
|
18
|
+
BOOLEAN = "boolean"
|
|
19
|
+
DATE = "date"
|
|
20
|
+
DATETIME = "datetime"
|
|
21
|
+
TIME = "time"
|
|
22
|
+
|
|
23
|
+
NUMERIC_TYPES = frozenset({INTEGER, FLOAT, DECIMAL})
|
|
24
|
+
TEMPORAL_TYPES = frozenset({DATE, DATETIME, TIME})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Column:
|
|
29
|
+
"""A single column of a table/view, in canonical terms."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
dtype: str = STRING
|
|
33
|
+
#: approximate distinct-value count, when the source could cheaply provide it
|
|
34
|
+
cardinality: int | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_numeric(self) -> bool:
|
|
38
|
+
return self.dtype in NUMERIC_TYPES
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_temporal(self) -> bool:
|
|
42
|
+
return self.dtype in TEMPORAL_TYPES
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Schema:
|
|
47
|
+
"""The shape of a table/view plus a human label used for titles."""
|
|
48
|
+
|
|
49
|
+
table: str
|
|
50
|
+
columns: list[Column] = field(default_factory=list)
|
|
51
|
+
display_name: str | None = None
|
|
52
|
+
|
|
53
|
+
def names(self) -> list[str]:
|
|
54
|
+
return [c.name for c in self.columns]
|
|
55
|
+
|
|
56
|
+
def by_name(self, name: str) -> Column | None:
|
|
57
|
+
return next((c for c in self.columns if c.name == name), None)
|
|
58
|
+
|
|
59
|
+
def with_cardinality(self, counts: dict[str, int]) -> Schema:
|
|
60
|
+
"""Return a copy with distinct-value counts merged onto matching columns."""
|
|
61
|
+
cols = [
|
|
62
|
+
Column(c.name, c.dtype, counts.get(c.name, c.cardinality)) for c in self.columns
|
|
63
|
+
]
|
|
64
|
+
return Schema(self.table, cols, self.display_name)
|
pbigen/emit/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Emitters — turn a design into on-disk BI artifacts.
|
|
2
|
+
|
|
3
|
+
Today: Power BI (PBIP/PBIR report + TMDL semantic model). The seam is deliberately narrow
|
|
4
|
+
(``write_project``) so other targets can be added without touching the design brain.
|
|
5
|
+
|
|
6
|
+
Author: Arka Gupta
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .pbir import write_project
|
|
11
|
+
|
|
12
|
+
__all__ = ["write_project"]
|