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.
Files changed (61) hide show
  1. pbi_cli/__init__.py +3 -0
  2. pbi_cli/_audit.py +57 -0
  3. pbi_cli/_snapshot.py +95 -0
  4. pbi_cli/backends/__init__.py +1 -0
  5. pbi_cli/backends/mock_backend.py +323 -0
  6. pbi_cli/backends/pbir_backend.py +813 -0
  7. pbi_cli/backends/protocol.py +52 -0
  8. pbi_cli/backends/tom_backend.py +650 -0
  9. pbi_cli/backends/xmla_backend.py +627 -0
  10. pbi_cli/cli.py +332 -0
  11. pbi_cli/commands/__init__.py +1 -0
  12. pbi_cli/commands/_doctor.py +84 -0
  13. pbi_cli/commands/_shared.py +88 -0
  14. pbi_cli/commands/calendar_cmd.py +186 -0
  15. pbi_cli/commands/connections.py +153 -0
  16. pbi_cli/commands/custom_visual.py +325 -0
  17. pbi_cli/commands/database.py +76 -0
  18. pbi_cli/commands/dax.py +174 -0
  19. pbi_cli/commands/deploy.py +193 -0
  20. pbi_cli/commands/docs.py +57 -0
  21. pbi_cli/commands/filter_cmd.py +235 -0
  22. pbi_cli/commands/govern.py +124 -0
  23. pbi_cli/commands/layout.py +104 -0
  24. pbi_cli/commands/measure.py +185 -0
  25. pbi_cli/commands/model.py +499 -0
  26. pbi_cli/commands/partition.py +89 -0
  27. pbi_cli/commands/repl.py +209 -0
  28. pbi_cli/commands/report.py +561 -0
  29. pbi_cli/commands/security.py +90 -0
  30. pbi_cli/commands/server_cmd.py +30 -0
  31. pbi_cli/commands/skills_cmd.py +168 -0
  32. pbi_cli/commands/source.py +581 -0
  33. pbi_cli/commands/theme.py +60 -0
  34. pbi_cli/commands/trace.py +142 -0
  35. pbi_cli/commands/visual.py +507 -0
  36. pbi_cli/commands/watch.py +145 -0
  37. pbi_cli/docs_gen/__init__.py +1 -0
  38. pbi_cli/docs_gen/confluence.py +24 -0
  39. pbi_cli/docs_gen/markdown.py +36 -0
  40. pbi_cli/governance/__init__.py +1 -0
  41. pbi_cli/governance/engine.py +70 -0
  42. pbi_cli/governance/rules/__init__.py +85 -0
  43. pbi_cli/governance/rules/measure_brackets.py +27 -0
  44. pbi_cli/governance/rules/measure_description.py +41 -0
  45. pbi_cli/governance/rules/measure_format.py +38 -0
  46. pbi_cli/governance/rules/measure_naming.py +93 -0
  47. pbi_cli/governance/rules/table_pascal_case.py +44 -0
  48. pbi_cli/intelligence/__init__.py +1 -0
  49. pbi_cli/intelligence/layout_engine.py +192 -0
  50. pbi_cli/intelligence/measure_generator.py +40 -0
  51. pbi_cli/intelligence/theme_generator.py +193 -0
  52. pbi_cli/intelligence/visual_builder.py +429 -0
  53. pbi_cli/intelligence/visual_recommender.py +42 -0
  54. pbi_cli/server/__init__.py +1 -0
  55. pbi_cli/server/api.py +185 -0
  56. pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
  57. pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
  58. pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
  59. pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  60. pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  61. 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")