pbi-enterprise-cli 0.1.0.dev0__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.
- pbi_cli/__init__.py +3 -0
- pbi_cli/_audit.py +57 -0
- pbi_cli/_snapshot.py +95 -0
- pbi_cli/backends/__init__.py +1 -0
- pbi_cli/backends/mock_backend.py +323 -0
- pbi_cli/backends/pbir_backend.py +813 -0
- pbi_cli/backends/protocol.py +52 -0
- pbi_cli/backends/tom_backend.py +650 -0
- pbi_cli/backends/xmla_backend.py +627 -0
- pbi_cli/cli.py +332 -0
- pbi_cli/commands/__init__.py +1 -0
- pbi_cli/commands/_doctor.py +84 -0
- pbi_cli/commands/_shared.py +88 -0
- pbi_cli/commands/calendar_cmd.py +186 -0
- pbi_cli/commands/connections.py +153 -0
- pbi_cli/commands/custom_visual.py +325 -0
- pbi_cli/commands/database.py +76 -0
- pbi_cli/commands/dax.py +174 -0
- pbi_cli/commands/deploy.py +193 -0
- pbi_cli/commands/docs.py +57 -0
- pbi_cli/commands/filter_cmd.py +235 -0
- pbi_cli/commands/govern.py +124 -0
- pbi_cli/commands/layout.py +104 -0
- pbi_cli/commands/measure.py +185 -0
- pbi_cli/commands/model.py +499 -0
- pbi_cli/commands/partition.py +89 -0
- pbi_cli/commands/repl.py +209 -0
- pbi_cli/commands/report.py +561 -0
- pbi_cli/commands/security.py +90 -0
- pbi_cli/commands/server_cmd.py +30 -0
- pbi_cli/commands/skills_cmd.py +168 -0
- pbi_cli/commands/source.py +581 -0
- pbi_cli/commands/theme.py +60 -0
- pbi_cli/commands/trace.py +142 -0
- pbi_cli/commands/visual.py +507 -0
- pbi_cli/commands/watch.py +145 -0
- pbi_cli/docs_gen/__init__.py +1 -0
- pbi_cli/docs_gen/confluence.py +24 -0
- pbi_cli/docs_gen/markdown.py +36 -0
- pbi_cli/governance/__init__.py +1 -0
- pbi_cli/governance/engine.py +70 -0
- pbi_cli/governance/rules/__init__.py +85 -0
- pbi_cli/governance/rules/measure_brackets.py +27 -0
- pbi_cli/governance/rules/measure_description.py +41 -0
- pbi_cli/governance/rules/measure_format.py +38 -0
- pbi_cli/governance/rules/measure_naming.py +93 -0
- pbi_cli/governance/rules/table_pascal_case.py +44 -0
- pbi_cli/intelligence/__init__.py +1 -0
- pbi_cli/intelligence/layout_engine.py +192 -0
- pbi_cli/intelligence/measure_generator.py +40 -0
- pbi_cli/intelligence/theme_generator.py +193 -0
- pbi_cli/intelligence/visual_builder.py +429 -0
- pbi_cli/intelligence/visual_recommender.py +42 -0
- pbi_cli/server/__init__.py +1 -0
- pbi_cli/server/api.py +185 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""pbi source — data source profiling and model scaffold commands (Epic A)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from pbi_cli._audit import write_audit_entry
|
|
13
|
+
from pbi_cli.commands._shared import (
|
|
14
|
+
dry_run_echo,
|
|
15
|
+
get_backend,
|
|
16
|
+
output_json_or_table,
|
|
17
|
+
snapshot_before_write,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
def source() -> None:
|
|
25
|
+
"""Profile data sources and scaffold star-schema models from them."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@source.command("profile")
|
|
29
|
+
@click.option(
|
|
30
|
+
"--type",
|
|
31
|
+
"source_type",
|
|
32
|
+
required=True,
|
|
33
|
+
type=click.Choice(["sql", "excel", "csv", "rest"]),
|
|
34
|
+
help="Source type.",
|
|
35
|
+
)
|
|
36
|
+
@click.option("--conn", default=None, help="SQL connection string.")
|
|
37
|
+
@click.option("--path", default=None, type=click.Path(), help="File path (Excel/CSV).")
|
|
38
|
+
@click.option("--url", default=None, help="REST API URL.")
|
|
39
|
+
@click.option("--tables", default=None, help="Comma-separated table names to include (SQL only).")
|
|
40
|
+
@click.option("--output", default=None, type=click.Path(), help="Save profile JSON to file.")
|
|
41
|
+
@click.option(
|
|
42
|
+
"--bearer-token",
|
|
43
|
+
default=None,
|
|
44
|
+
envvar="PBI_REST_BEARER",
|
|
45
|
+
help="Bearer token for REST auth (or set PBI_REST_BEARER env var).",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--api-key-header",
|
|
49
|
+
default=None,
|
|
50
|
+
help="Header name=value pair for REST API key auth (e.g. 'X-Api-Key=abc123').",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--rest-max-pages", default=5, show_default=True, help="Max pages to fetch for REST pagination."
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--rest-results-path",
|
|
57
|
+
default=None,
|
|
58
|
+
help="Dot-path to the records array in REST response (e.g. 'data.items').",
|
|
59
|
+
)
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def source_profile(
|
|
62
|
+
ctx: click.Context,
|
|
63
|
+
source_type: str,
|
|
64
|
+
conn: str | None,
|
|
65
|
+
path: str | None,
|
|
66
|
+
url: str | None,
|
|
67
|
+
tables: str | None,
|
|
68
|
+
output: str | None,
|
|
69
|
+
bearer_token: str | None,
|
|
70
|
+
api_key_header: str | None,
|
|
71
|
+
rest_max_pages: int,
|
|
72
|
+
rest_results_path: str | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Connect to a data source and return its schema profile as JSON.
|
|
75
|
+
|
|
76
|
+
The profile includes table names, column names, data types, row counts,
|
|
77
|
+
null rates, sample values, and distinct counts — the input to 'pbi source scaffold'.
|
|
78
|
+
|
|
79
|
+
\b
|
|
80
|
+
REST auth examples:
|
|
81
|
+
pbi source profile --type rest --url https://api.example.com/v1/orders \\
|
|
82
|
+
--bearer-token $MY_TOKEN
|
|
83
|
+
pbi source profile --type rest --url https://api.example.com/v1/products \\
|
|
84
|
+
--api-key-header "X-Api-Key=abc123"
|
|
85
|
+
"""
|
|
86
|
+
console.print(f"[cyan]Profiling {source_type} source...[/cyan]")
|
|
87
|
+
|
|
88
|
+
if source_type == "sql":
|
|
89
|
+
if not conn:
|
|
90
|
+
raise click.UsageError("--conn is required for --type sql")
|
|
91
|
+
profile = _profile_sql(conn, tables)
|
|
92
|
+
elif source_type in ("excel", "csv"):
|
|
93
|
+
if not path:
|
|
94
|
+
raise click.UsageError(f"--path is required for --type {source_type}")
|
|
95
|
+
profile = _profile_file(path, source_type)
|
|
96
|
+
elif source_type == "rest":
|
|
97
|
+
if not url:
|
|
98
|
+
raise click.UsageError("--url is required for --type rest")
|
|
99
|
+
profile = _profile_rest(
|
|
100
|
+
url,
|
|
101
|
+
bearer_token=bearer_token,
|
|
102
|
+
api_key_header=api_key_header,
|
|
103
|
+
max_pages=rest_max_pages,
|
|
104
|
+
results_path=rest_results_path,
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
raise click.UsageError(f"Unknown source type: {source_type}")
|
|
108
|
+
|
|
109
|
+
if output:
|
|
110
|
+
Path(output).write_text(json.dumps(profile, indent=2, default=str), encoding="utf-8")
|
|
111
|
+
console.print(f"[green]Profile saved to:[/green] {output}")
|
|
112
|
+
else:
|
|
113
|
+
output_json_or_table(profile, ctx, title="Source Profile")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _profile_sql(conn: str, tables_filter: str | None) -> list[dict[str, Any]]:
|
|
117
|
+
"""Profile SQL Server (or any SQLAlchemy-compatible) database."""
|
|
118
|
+
try:
|
|
119
|
+
from sqlalchemy import create_engine, inspect, text # type: ignore[import]
|
|
120
|
+
except ImportError:
|
|
121
|
+
raise click.ClickException(
|
|
122
|
+
"SQLAlchemy not installed. Run: pip install pbi-enterprise-cli[sources]"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
engine = create_engine(conn)
|
|
126
|
+
inspector = inspect(engine)
|
|
127
|
+
|
|
128
|
+
requested = {t.strip() for t in tables_filter.split(",")} if tables_filter else None
|
|
129
|
+
table_names = inspector.get_table_names()
|
|
130
|
+
if requested:
|
|
131
|
+
table_names = [t for t in table_names if t in requested]
|
|
132
|
+
|
|
133
|
+
profile = []
|
|
134
|
+
with engine.connect() as connection:
|
|
135
|
+
for table_name in table_names:
|
|
136
|
+
try:
|
|
137
|
+
row_count_result = connection.execute(text(f"SELECT COUNT(*) FROM [{table_name}]"))
|
|
138
|
+
row_count = row_count_result.scalar() or 0
|
|
139
|
+
except Exception:
|
|
140
|
+
row_count = -1
|
|
141
|
+
|
|
142
|
+
columns_info = inspector.get_columns(table_name)
|
|
143
|
+
columns = []
|
|
144
|
+
for col in columns_info:
|
|
145
|
+
col_profile: dict[str, Any] = {
|
|
146
|
+
"name": col["name"],
|
|
147
|
+
"dataType": str(col["type"]),
|
|
148
|
+
"nullable": col.get("nullable", True),
|
|
149
|
+
"nullRate": None,
|
|
150
|
+
"distinctCount": None,
|
|
151
|
+
"sampleValues": [],
|
|
152
|
+
}
|
|
153
|
+
try:
|
|
154
|
+
col_name = col["name"]
|
|
155
|
+
stats = connection.execute(
|
|
156
|
+
text(
|
|
157
|
+
f"SELECT COUNT(DISTINCT [{col_name}]), "
|
|
158
|
+
f"SUM(CASE WHEN [{col_name}] IS NULL THEN 1 ELSE 0 END) * 1.0 / COUNT(*) " # noqa: E501
|
|
159
|
+
f"FROM [{table_name}]"
|
|
160
|
+
)
|
|
161
|
+
).fetchone()
|
|
162
|
+
if stats:
|
|
163
|
+
col_profile["distinctCount"] = stats[0]
|
|
164
|
+
col_profile["nullRate"] = round(float(stats[1] or 0), 4)
|
|
165
|
+
samples = connection.execute(
|
|
166
|
+
text(
|
|
167
|
+
f"SELECT TOP 5 [{col_name}] FROM [{table_name}] WHERE [{col_name}] IS NOT NULL" # noqa: E501
|
|
168
|
+
)
|
|
169
|
+
).fetchall()
|
|
170
|
+
col_profile["sampleValues"] = [row[0] for row in samples]
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
columns.append(col_profile)
|
|
174
|
+
|
|
175
|
+
profile.append(
|
|
176
|
+
{
|
|
177
|
+
"tableName": table_name,
|
|
178
|
+
"rowCount": row_count,
|
|
179
|
+
"columns": columns,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return profile
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _profile_file(path: str, file_type: str) -> list[dict[str, Any]]:
|
|
187
|
+
"""Profile an Excel or CSV file."""
|
|
188
|
+
try:
|
|
189
|
+
import openpyxl # type: ignore[import]
|
|
190
|
+
except ImportError:
|
|
191
|
+
if file_type == "excel":
|
|
192
|
+
raise click.ClickException(
|
|
193
|
+
"openpyxl not installed. Run: pip install pbi-enterprise-cli[sources]"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
file_path = Path(path)
|
|
197
|
+
if not file_path.exists():
|
|
198
|
+
raise click.ClickException(f"File not found: {path}")
|
|
199
|
+
|
|
200
|
+
if file_type == "excel":
|
|
201
|
+
import openpyxl # type: ignore[import]
|
|
202
|
+
|
|
203
|
+
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
|
204
|
+
profile = []
|
|
205
|
+
for sheet_name in wb.sheetnames:
|
|
206
|
+
ws = wb[sheet_name]
|
|
207
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
208
|
+
if not rows:
|
|
209
|
+
continue
|
|
210
|
+
headers = [str(h) if h is not None else f"Column{i}" for i, h in enumerate(rows[0])]
|
|
211
|
+
data_rows = rows[1:]
|
|
212
|
+
columns = []
|
|
213
|
+
for i, header in enumerate(headers):
|
|
214
|
+
values = [r[i] for r in data_rows if r[i] is not None]
|
|
215
|
+
null_rate = 1.0 - len(values) / len(data_rows) if data_rows else 0.0
|
|
216
|
+
sample = values[:5]
|
|
217
|
+
data_types = {type(v).__name__ for v in values[:100]}
|
|
218
|
+
columns.append(
|
|
219
|
+
{
|
|
220
|
+
"name": header,
|
|
221
|
+
"dataType": list(data_types)[0] if len(data_types) == 1 else "Mixed",
|
|
222
|
+
"nullRate": round(null_rate, 4),
|
|
223
|
+
"distinctCount": len(set(values[:1000])),
|
|
224
|
+
"sampleValues": sample,
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
profile.append(
|
|
228
|
+
{"tableName": sheet_name, "rowCount": len(data_rows), "columns": columns}
|
|
229
|
+
)
|
|
230
|
+
wb.close()
|
|
231
|
+
return profile
|
|
232
|
+
else:
|
|
233
|
+
import csv
|
|
234
|
+
|
|
235
|
+
with open(path, encoding="utf-8", newline="") as f:
|
|
236
|
+
reader = csv.DictReader(f)
|
|
237
|
+
rows = list(reader)
|
|
238
|
+
if not rows:
|
|
239
|
+
return []
|
|
240
|
+
headers = list(rows[0].keys())
|
|
241
|
+
columns = []
|
|
242
|
+
for h in headers:
|
|
243
|
+
values = [r[h] for r in rows if r[h]]
|
|
244
|
+
null_rate = 1.0 - len(values) / len(rows) if rows else 0.0
|
|
245
|
+
columns.append(
|
|
246
|
+
{
|
|
247
|
+
"name": h,
|
|
248
|
+
"dataType": "String",
|
|
249
|
+
"nullRate": round(null_rate, 4),
|
|
250
|
+
"distinctCount": len(set(values[:1000])),
|
|
251
|
+
"sampleValues": values[:5],
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
return [{"tableName": Path(path).stem, "rowCount": len(rows), "columns": columns}]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _profile_rest(
|
|
258
|
+
url: str,
|
|
259
|
+
bearer_token: str | None = None,
|
|
260
|
+
api_key_header: str | None = None,
|
|
261
|
+
max_pages: int = 5,
|
|
262
|
+
results_path: str | None = None,
|
|
263
|
+
) -> list[dict[str, Any]]:
|
|
264
|
+
"""Profile a REST API with auth, pagination, and nested schema support.
|
|
265
|
+
|
|
266
|
+
Handles:
|
|
267
|
+
- Bearer token and API key header auth
|
|
268
|
+
- OData ``@odata.nextLink`` pagination
|
|
269
|
+
- JSON:API ``links.next`` pagination
|
|
270
|
+
- Page-number based pagination (``?page=N``)
|
|
271
|
+
- Nested ``results_path`` extraction (dot-path into response JSON)
|
|
272
|
+
- Flat type mapping to Power BI data types
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
import httpx # type: ignore[import]
|
|
276
|
+
except ImportError:
|
|
277
|
+
raise click.ClickException(
|
|
278
|
+
"httpx not installed. Run: pip install pbi-enterprise-cli[sources]"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Build auth headers
|
|
282
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
283
|
+
if bearer_token:
|
|
284
|
+
headers["Authorization"] = f"Bearer {bearer_token}"
|
|
285
|
+
if api_key_header:
|
|
286
|
+
if "=" in api_key_header:
|
|
287
|
+
hname, hval = api_key_header.split("=", 1)
|
|
288
|
+
headers[hname.strip()] = hval.strip()
|
|
289
|
+
else:
|
|
290
|
+
raise click.ClickException("--api-key-header must be 'HeaderName=value'")
|
|
291
|
+
|
|
292
|
+
all_records: list[dict[str, Any]] = []
|
|
293
|
+
next_url: str | None = url
|
|
294
|
+
page = 0
|
|
295
|
+
|
|
296
|
+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
|
297
|
+
while next_url and page < max_pages:
|
|
298
|
+
response = client.get(next_url, headers=headers)
|
|
299
|
+
response.raise_for_status()
|
|
300
|
+
data = response.json()
|
|
301
|
+
|
|
302
|
+
# Extract records from the response
|
|
303
|
+
records = _extract_records(data, results_path)
|
|
304
|
+
all_records.extend(records)
|
|
305
|
+
page += 1
|
|
306
|
+
|
|
307
|
+
# Detect next-page link (OData, JSON:API, or none)
|
|
308
|
+
next_url = _detect_next_link(data, next_url, page)
|
|
309
|
+
|
|
310
|
+
if not records:
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
if not all_records:
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
# Infer schema from a sample of records
|
|
317
|
+
sample = all_records[:50]
|
|
318
|
+
columns = _infer_columns(sample)
|
|
319
|
+
table_name = _url_to_table_name(url)
|
|
320
|
+
|
|
321
|
+
return [
|
|
322
|
+
{
|
|
323
|
+
"tableName": table_name,
|
|
324
|
+
"rowCount": len(all_records),
|
|
325
|
+
"paginatedPages": page,
|
|
326
|
+
"columns": columns,
|
|
327
|
+
}
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _extract_records(data: Any, results_path: str | None) -> list[dict[str, Any]]:
|
|
332
|
+
"""Navigate a dot-path into the response JSON to find the records array."""
|
|
333
|
+
if results_path:
|
|
334
|
+
node: Any = data
|
|
335
|
+
for part in results_path.split("."):
|
|
336
|
+
if isinstance(node, dict):
|
|
337
|
+
node = node.get(part)
|
|
338
|
+
else:
|
|
339
|
+
node = None
|
|
340
|
+
break
|
|
341
|
+
records = node
|
|
342
|
+
elif isinstance(data, dict):
|
|
343
|
+
if not data:
|
|
344
|
+
return []
|
|
345
|
+
# Common envelope keys: value (OData), data, results, items, records
|
|
346
|
+
for key in ("value", "data", "results", "items", "records"):
|
|
347
|
+
if key in data and isinstance(data[key], list):
|
|
348
|
+
records = data[key]
|
|
349
|
+
break
|
|
350
|
+
else:
|
|
351
|
+
records = [data]
|
|
352
|
+
elif isinstance(data, list):
|
|
353
|
+
records = data
|
|
354
|
+
else:
|
|
355
|
+
records = [data]
|
|
356
|
+
|
|
357
|
+
if not isinstance(records, list):
|
|
358
|
+
return []
|
|
359
|
+
return [r for r in records if isinstance(r, dict)]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _detect_next_link(data: Any, current_url: str, page: int) -> str | None:
|
|
363
|
+
"""Detect the next page URL from OData, JSON:API, or page-number patterns."""
|
|
364
|
+
if not isinstance(data, dict):
|
|
365
|
+
return None
|
|
366
|
+
# OData style
|
|
367
|
+
if "@odata.nextLink" in data:
|
|
368
|
+
return str(data["@odata.nextLink"])
|
|
369
|
+
# JSON:API links
|
|
370
|
+
if isinstance(data.get("links"), dict) and data["links"].get("next"):
|
|
371
|
+
return str(data["links"]["next"])
|
|
372
|
+
# No detected pagination
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _infer_columns(sample: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
377
|
+
"""Infer column schema from a sample of records, handling nested objects."""
|
|
378
|
+
# Flatten one level of nesting — nested objects become "parent.child" columns
|
|
379
|
+
flat_records = [_flatten_record(r) for r in sample]
|
|
380
|
+
if not flat_records:
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
all_keys = list(dict.fromkeys(k for r in flat_records for k in r))
|
|
384
|
+
columns = []
|
|
385
|
+
for key in all_keys:
|
|
386
|
+
values = [r[key] for r in flat_records if r.get(key) is not None]
|
|
387
|
+
null_count = sum(1 for r in flat_records if r.get(key) is None)
|
|
388
|
+
null_rate = round(null_count / len(flat_records), 4) if flat_records else 0.0
|
|
389
|
+
pbi_type = _infer_pbi_type(values)
|
|
390
|
+
columns.append(
|
|
391
|
+
{
|
|
392
|
+
"name": key,
|
|
393
|
+
"dataType": pbi_type,
|
|
394
|
+
"nullRate": null_rate,
|
|
395
|
+
"distinctCount": len(set(str(v) for v in values)),
|
|
396
|
+
"sampleValues": [str(v) for v in values[:3]],
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
return columns
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _flatten_record(record: dict[str, Any], prefix: str = "") -> dict[str, Any]:
|
|
403
|
+
"""Flatten one level of nested dict into dot-notation keys."""
|
|
404
|
+
flat: dict[str, Any] = {}
|
|
405
|
+
for k, v in record.items():
|
|
406
|
+
full_key = f"{prefix}{k}" if not prefix else f"{prefix}.{k}"
|
|
407
|
+
if isinstance(v, dict) and len(v) <= 5:
|
|
408
|
+
# Flatten shallow nested objects
|
|
409
|
+
flat.update(_flatten_record(v, prefix=full_key))
|
|
410
|
+
elif isinstance(v, list):
|
|
411
|
+
flat[full_key] = f"[Array({len(v)})]"
|
|
412
|
+
else:
|
|
413
|
+
flat[full_key] = v
|
|
414
|
+
return flat
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _infer_pbi_type(values: list[Any]) -> str:
|
|
418
|
+
"""Map Python runtime types to Power BI data type names."""
|
|
419
|
+
type_set = {type(v) for v in values if v is not None}
|
|
420
|
+
if not type_set:
|
|
421
|
+
return "String"
|
|
422
|
+
if type_set == {bool}:
|
|
423
|
+
return "Boolean"
|
|
424
|
+
if type_set <= {int}:
|
|
425
|
+
return "Int64"
|
|
426
|
+
if type_set <= {int, float}:
|
|
427
|
+
return "Decimal"
|
|
428
|
+
# Detect ISO date strings
|
|
429
|
+
if type_set <= {str}:
|
|
430
|
+
import re
|
|
431
|
+
|
|
432
|
+
date_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
|
433
|
+
if all(date_pattern.match(str(v)) for v in values[:5]):
|
|
434
|
+
return "DateTime"
|
|
435
|
+
return "String"
|
|
436
|
+
return "Mixed"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _url_to_table_name(url: str) -> str:
|
|
440
|
+
"""Derive a clean table name from the URL path."""
|
|
441
|
+
from urllib.parse import urlparse
|
|
442
|
+
|
|
443
|
+
path = urlparse(url).path.rstrip("/")
|
|
444
|
+
segment = path.split("/")[-1] if "/" in path else path
|
|
445
|
+
# Strip numeric version prefix (e.g. v1, v2)
|
|
446
|
+
import re
|
|
447
|
+
|
|
448
|
+
segment = re.sub(r"^v\d+$", "", segment)
|
|
449
|
+
return segment.title().replace("-", "").replace("_", "") or "RestResponse"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@source.command("scaffold")
|
|
453
|
+
@click.option(
|
|
454
|
+
"--profile",
|
|
455
|
+
"profile_path",
|
|
456
|
+
required=True,
|
|
457
|
+
type=click.Path(exists=True),
|
|
458
|
+
help="Path to source profile JSON.",
|
|
459
|
+
)
|
|
460
|
+
@click.pass_context
|
|
461
|
+
def source_scaffold(ctx: click.Context, profile_path: str) -> None:
|
|
462
|
+
"""Generate a star-schema model from a source profile JSON file."""
|
|
463
|
+
profile = json.loads(Path(profile_path).read_text(encoding="utf-8"))
|
|
464
|
+
schema = _infer_star_schema(profile)
|
|
465
|
+
|
|
466
|
+
if dry_run_echo(ctx, "scaffold star schema", json.dumps(schema, indent=2)):
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
backend = get_backend(ctx)
|
|
470
|
+
snapshot_before_write(ctx)
|
|
471
|
+
for table in schema["tables"]:
|
|
472
|
+
backend.table_add(table["name"])
|
|
473
|
+
for col in table["columns"]:
|
|
474
|
+
backend.column_add(table["name"], col["name"], col["dataType"])
|
|
475
|
+
for rel in schema["relationships"]:
|
|
476
|
+
parts = rel["from"].split("[")
|
|
477
|
+
ft, fc = parts[0], parts[1].rstrip("]")
|
|
478
|
+
parts2 = rel["to"].split("[")
|
|
479
|
+
tt, tc = parts2[0], parts2[1].rstrip("]")
|
|
480
|
+
backend.relationship_add(ft, fc, tt, tc)
|
|
481
|
+
|
|
482
|
+
write_audit_entry(
|
|
483
|
+
"source scaffold",
|
|
484
|
+
after={"tables": len(schema["tables"]), "relationships": len(schema["relationships"])},
|
|
485
|
+
)
|
|
486
|
+
is_json = ctx.obj and ctx.obj.get("output_json")
|
|
487
|
+
if not is_json:
|
|
488
|
+
console.print(
|
|
489
|
+
f"[green]Scaffolded[/green] {len(schema['tables'])} tables, {len(schema['relationships'])} relationships" # noqa: E501
|
|
490
|
+
)
|
|
491
|
+
output_json_or_table(schema, ctx, title="Star Schema")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _infer_star_schema(profile: list[dict]) -> dict[str, Any]:
|
|
495
|
+
"""Heuristic: largest table = fact, others = dims. Key columns detected by name."""
|
|
496
|
+
if not profile:
|
|
497
|
+
return {"tables": [], "relationships": [], "measures": []}
|
|
498
|
+
|
|
499
|
+
sorted_tables = sorted(profile, key=lambda t: t.get("rowCount", 0), reverse=True)
|
|
500
|
+
fact = sorted_tables[0]
|
|
501
|
+
dims = sorted_tables[1:]
|
|
502
|
+
|
|
503
|
+
key_suffixes = ("key", "id", "sk", "code")
|
|
504
|
+
relationships = []
|
|
505
|
+
|
|
506
|
+
for dim in dims:
|
|
507
|
+
dim_name = dim["tableName"]
|
|
508
|
+
dim_keys = [c["name"] for c in dim["columns"] if c["name"].lower().endswith(key_suffixes)]
|
|
509
|
+
if not dim_keys:
|
|
510
|
+
continue
|
|
511
|
+
dim_key = dim_keys[0]
|
|
512
|
+
fact_matching = [
|
|
513
|
+
c["name"]
|
|
514
|
+
for c in fact["columns"]
|
|
515
|
+
if c["name"].lower() == dim_key.lower()
|
|
516
|
+
or c["name"].lower() == f"{dim_name.lower()}{dim_key.lower()}"
|
|
517
|
+
]
|
|
518
|
+
if fact_matching:
|
|
519
|
+
relationships.append(
|
|
520
|
+
{
|
|
521
|
+
"from": f"{fact['tableName']}[{fact_matching[0]}]",
|
|
522
|
+
"to": f"{dim_name}[{dim_key}]",
|
|
523
|
+
"cardinality": "ManyToOne",
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
numeric_types = ("decimal", "double", "int64", "float", "int", "float64")
|
|
528
|
+
starter_measures = []
|
|
529
|
+
for col in fact["columns"]:
|
|
530
|
+
if any(t in col["dataType"].lower() for t in numeric_types):
|
|
531
|
+
starter_measures.append(
|
|
532
|
+
{
|
|
533
|
+
"table": fact["tableName"],
|
|
534
|
+
"name": f"Total {col['name']}",
|
|
535
|
+
"expression": f"SUM({fact['tableName']}[{col['name']}])",
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
"tables": [{"name": t["tableName"], "columns": t["columns"]} for t in profile],
|
|
541
|
+
"relationships": relationships,
|
|
542
|
+
"measures": starter_measures[:5],
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@source.command("suggest-joins")
|
|
547
|
+
@click.option("--profiles", required=True, help="Comma-separated paths to two profile JSON files.")
|
|
548
|
+
@click.pass_context
|
|
549
|
+
def source_suggest_joins(ctx: click.Context, profiles: str) -> None:
|
|
550
|
+
"""Suggest FK relationships between two profiled sources using column name heuristics."""
|
|
551
|
+
paths = [p.strip() for p in profiles.split(",")]
|
|
552
|
+
if len(paths) != 2:
|
|
553
|
+
raise click.UsageError("Provide exactly two comma-separated profile paths.")
|
|
554
|
+
|
|
555
|
+
profile_a = json.loads(Path(paths[0]).read_text(encoding="utf-8"))
|
|
556
|
+
profile_b = json.loads(Path(paths[1]).read_text(encoding="utf-8"))
|
|
557
|
+
|
|
558
|
+
suggestions = _suggest_joins(profile_a, profile_b)
|
|
559
|
+
output_json_or_table(suggestions, ctx, title="Suggested Joins")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _suggest_joins(profile_a: list, profile_b: list) -> list[dict]:
|
|
563
|
+
suggestions = []
|
|
564
|
+
for table_a in profile_a:
|
|
565
|
+
for table_b in profile_b:
|
|
566
|
+
cols_a = {c["name"].lower(): (table_a["tableName"], c) for c in table_a["columns"]}
|
|
567
|
+
cols_b = {c["name"].lower(): (table_b["tableName"], c) for c in table_b["columns"]}
|
|
568
|
+
for name, (tname_a, col_a) in cols_a.items():
|
|
569
|
+
if name in cols_b:
|
|
570
|
+
tname_b, col_b = cols_b[name]
|
|
571
|
+
suggestions.append(
|
|
572
|
+
{
|
|
573
|
+
"from": f"{tname_a}[{col_a['name']}]",
|
|
574
|
+
"to": f"{tname_b}[{col_b['name']}]",
|
|
575
|
+
"confidence": "high"
|
|
576
|
+
if col_a["dataType"] == col_b["dataType"]
|
|
577
|
+
else "medium",
|
|
578
|
+
"reason": "exact column name match",
|
|
579
|
+
}
|
|
580
|
+
)
|
|
581
|
+
return suggestions
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""pbi theme — theme generation and validation commands (Epic C)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from pbi_cli.commands._shared import dry_run_echo, output_json_or_table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def theme() -> None:
|
|
18
|
+
"""Generate and validate Power BI themes with WCAG accessibility compliance."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@theme.command("generate")
|
|
22
|
+
@click.option("--brand-color", required=True, help="Primary brand hex colour (e.g. #0078D4).")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--style", type=click.Choice(["corporate", "modern", "minimal", "dark"]), default="corporate"
|
|
25
|
+
)
|
|
26
|
+
@click.option("--output", default="theme.json", help="Output theme JSON file.")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def theme_generate(ctx: click.Context, brand_color: str, style: str, output: str) -> None:
|
|
29
|
+
"""Generate a complete Power BI theme JSON from a brand colour with WCAG compliance."""
|
|
30
|
+
from pbi_cli.intelligence.theme_generator import ThemeGenerator
|
|
31
|
+
|
|
32
|
+
console.print(f"[cyan]Generating {style} theme from:[/cyan] {brand_color}")
|
|
33
|
+
gen = ThemeGenerator()
|
|
34
|
+
theme_json = gen.generate(brand_color=brand_color, style=style)
|
|
35
|
+
validation = gen.validate_wcag(theme_json)
|
|
36
|
+
|
|
37
|
+
if not validation["passes"]:
|
|
38
|
+
console.print(
|
|
39
|
+
f"[yellow]WCAG issues:[/yellow] {len(validation['failures'])} contrast failures — auto-fixing..." # noqa: E501
|
|
40
|
+
)
|
|
41
|
+
theme_json = gen.fix_contrast(theme_json, validation["failures"])
|
|
42
|
+
|
|
43
|
+
if dry_run_echo(ctx, f"write theme to {output}"):
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
Path(output).write_text(json.dumps(theme_json, indent=2), encoding="utf-8")
|
|
47
|
+
console.print(f"[green]Theme written to:[/green] {output}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@theme.command("validate")
|
|
51
|
+
@click.argument("theme_file", type=click.Path(exists=True))
|
|
52
|
+
@click.pass_context
|
|
53
|
+
def theme_validate(ctx: click.Context, theme_file: str) -> None:
|
|
54
|
+
"""Check a theme JSON for WCAG AA contrast compliance."""
|
|
55
|
+
from pbi_cli.intelligence.theme_generator import ThemeGenerator
|
|
56
|
+
|
|
57
|
+
theme_json = json.loads(Path(theme_file).read_text(encoding="utf-8"))
|
|
58
|
+
gen = ThemeGenerator()
|
|
59
|
+
result = gen.validate_wcag(theme_json)
|
|
60
|
+
output_json_or_table(result, ctx, title="WCAG Validation")
|