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 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())
@@ -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.")
@@ -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)
@@ -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"]