
Pbip
- 59 installs
- 836 repo stars
- Updated July 29, 2026
- data-goblin/power-bi-agentic-development
Work with the Power BI Project (PBIP) file format, including project structure, renames, forking, and converting or extracting PBIX files.
About
Explains the PBIP developer-mode file format that decomposes a PBIX binary into text files for source control and external editing. A developer uses it for project structure, cascade renames, forking a project, and PBIX-to-PBIP conversion.
- Explains PBIP folder structure and thin vs thick reports
- Covers cascade renames, forking, and PBIX extraction
Pbip by the numbers
- 59 all-time installs (skills.sh)
- Ranked #901 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/data-goblin/power-bi-agentic-development --skill pbipAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 59 |
|---|---|
| repo stars | ★ 836 |
| Last updated | July 29, 2026 |
| Repository | data-goblin/power-bi-agentic-development ↗ |
What it does
Work with the Power BI Project (PBIP) file format, including project structure, renames, forking, and converting or extracting PBIX files.
Files
PBIP Project Format
PBIP (Power BI Project) is the developer-mode file format for Power BI. It decomposes a .pbix binary into human-readable text files organized in folders, enabling source control, external editing, and multi-author collaboration.
General, critical guidance
- This skill covers project structure, not file editing. To modify TMDL files (semantic model), load the
tmdlskill. To modify PBIR JSON files (report), load thepbir-formatskill -- or preferably use thepbirCLI with thepbir-cliskill if available. Install withuv tool install pbir-cliorpip install pbir-cli; check withpbir --version. If the required skill is not loaded, ask the user to install the appropriate plugin before proceeding. - PBIX is a black box; PBIP is transparent. PBIX is a single binary that cannot be diffed or edited externally. PBIP splits the same content into text files. Convert between them with File > Save As in PBI Desktop.
- Thick vs thin reports: A thick report bundles
.Report/+.SemanticModel/in the same project (definition.pbirusesbyPath). A thin report has.Report/only, connecting to a remote model viabyConnection. Thin reports are preferred for managed/shared BI. - A project can contain multiple items. Multiple
.Report/and.SemanticModel/folders can coexist. The.pbipfile is optional -- opendefinition.pbirdirectly. - UTF-8 without BOM. All files must be saved as UTF-8 without BOM. A BOM prefix causes parse errors in some tools.
- Git line endings: PBI Desktop writes CRLF. Configure
core.autocrlfor* text=autoin.gitattributesto normalize. - 260-char Windows path limit. Use short root paths. Deep nesting of page/visual GUIDs can exceed this limit.
- PBI Desktop does not detect external changes. Close and reopen PBI Desktop after editing files externally.
- Rename cascades are cross-cutting. Renaming a table, measure, or column requires updating references in TMDL files, visual JSONs, report extensions, culture files, DAX queries, and diagram layouts. Missing even one location causes broken visuals or DAX errors.
- SparklineData metadata selectors embed Entity references in compact strings that do not follow the standard
SourceRef.EntityJSON structure. Easy to miss. - DAX query files exist in TWO locations:
<Name>.SemanticModel/DAXQueries/and<Name>.Report/DAXQueries/. Always check both during renames.
Working with PBIX Files
A .pbix file is a ZIP archive following the OPC (Open Packaging Convention) standard. It can be extracted with standard zip tools to inspect its contents or manually assemble a PBIP from the extracted files.
PBIX Internal Structure
Thick PBIX -- contains an embedded semantic model (DataModel binary). The report and model are bundled together:
ThickReport.pbix (ZIP archive)
+-- [Content_Types].xml # OPC manifest (UTF-8 with BOM)
+-- Version # Power BI version string (UTF-16LE)
+-- Settings # Query settings JSON (UTF-16LE)
+-- Metadata # Creation timestamp JSON (UTF-16LE)
+-- SecurityBindings # Binary (empty for new reports)
+-- DataModel # <-- THIS MAKES IT THICK: binary ABF blob (opaque, not programmatically readable)
+-- Report/
| +-- definition/ # PBIR report definition (modern PBIX)
| | +-- report.json
| | +-- pages/
| | +-- ...
| +-- Layout # Legacy monolithic JSON (legacy PBIX, UTF-16LE)
| +-- StaticResources/ # Themes, imagesThin PBIX -- no embedded model. Uses a Connections file to reference a remote semantic model:
ThinReport.pbix (ZIP archive)
+-- [Content_Types].xml # OPC manifest (UTF-8 with BOM)
+-- Version # Power BI version string (UTF-16LE)
+-- Settings # Query settings JSON (UTF-16LE)
+-- Metadata # Creation timestamp JSON (UTF-16LE)
+-- SecurityBindings # Binary (empty for new reports)
+-- Connections # <-- Remote model reference (UTF-8 JSON, contains connection string)
+-- Report/
| +-- definition/ # PBIR report definition (modern PBIX)
| | +-- report.json
| | +-- pages/
| | +-- ...
| +-- Layout # Legacy monolithic JSON (legacy PBIX, UTF-16LE)
| +-- StaticResources/ # Themes, imagesA PBIX is thick if DataModel exists in the ZIP; thin if it has Connections instead. The Report/ folder structure is the same in both cases. A PBIX will have either Report/definition/ (modern PBIR format) or Report/Layout (legacy format), not both.
Thick vs Thin PBIX
A thick PBIX contains a DataModel entry -- a binary ABF (Analysis Services Backup) blob with the semantic model data and metadata baked in. A thin PBIX has no DataModel and instead has a Connections file (UTF-8 JSON) pointing to a remote semantic model. The DataModel binary cannot be deserialized programmatically -- thick PBIX semantic models are opaque.
Legacy vs Modern PBIX
Legacy PBIX files (pre-PBIR) store the report as a single Report/Layout file encoded in UTF-16LE -- a monolithic JSON blob with nested JSON strings (e.g. config, filters, query are JSON-encoded strings inside the outer JSON). Modern PBIX files store the report in Report/definition/ using the PBIR JSON format with separate files per page and visual. Detect legacy format by checking for the Report/Layout entry in the ZIP.
Encoding
PBIX internal files use mixed encodings:
| File | Encoding |
|---|---|
Version, Settings, Metadata | UTF-16LE |
Connections | UTF-8 |
Report/definition/ contents | UTF-8 |
Report/Layout (legacy) | UTF-16LE |
[Content_Types].xml | UTF-8 with BOM |
SecurityBindings, DataModel | Binary |
Mismatched encoding when reading or writing these files causes parse failures.
Extracting a PBIX
import zipfile
from pathlib import Path
pbix_path = Path("MyReport.pbix")
output_dir = Path("MyReport_extracted")
with zipfile.ZipFile(pbix_path, "r") as z:
# Safety: validate no entries escape the target directory (Zip Slip protection)
resolved_output = output_dir.resolve()
for member in z.infolist():
member_path = (output_dir / member.filename).resolve()
if not member_path.is_relative_to(resolved_output):
raise ValueError(f"Zip entry escapes target: {member.filename}")
z.extractall(output_dir)
# Detect PBIX type
is_thick = (output_dir / "DataModel").exists()
is_legacy = (output_dir / "Report" / "Layout").exists()
is_modern = (output_dir / "Report" / "definition" / "report.json").exists()# Quick extraction via CLI
unzip MyReport.pbix -d MyReport_extracted/
# Check contents without extracting
unzip -l MyReport.pbixAssembling a PBIP from an Extracted Thin PBIX
For thin PBIX files (no DataModel), a PBIP can be assembled from the extracted contents:
1. Extract the PBIX ZIP 2. Create the PBIP folder structure:
MyReport/
+-- MyReport.pbip
+-- MyReport.Report/
| +-- definition.pbir
| +-- definition/ # Copy from extracted Report/definition/
| +-- StaticResources/ # Copy from extracted Report/StaticResources/
| +-- .platform3. Create MyReport.pbip:
{
"version": "1.0",
"artifacts": [
{ "report": { "path": "MyReport.Report" } }
],
"settings": { "enableAutoRecovery": true }
}4. Create definition.pbir with byConnection derived from the extracted Connections file. The Connections file contains a JSON array with connection string details:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json",
"version": "4.0",
"datasetReference": {
"byConnection": {
"connectionString": "Data Source=powerbi://api.powerbi.com/v1.0/myorg/WorkspaceName;Initial Catalog=ModelName"
}
}
}5. Create .platform with a new logicalId GUID and "type": "Report"
This only works for thin PBIX with modern PBIR format. Thick PBIX files require the semantic model to be handled separately (e.g. exported via XMLA/TOM, or deployed from a model.bim or TMDL source). Legacy PBIX report content (Report/Layout) is not compatible with the PBIR definition/ structure.
PBIX vs PBIP
| Aspect | PBIX | PBIP |
|---|---|---|
| Format | Single binary file | Folder of text files |
| Source control | Not diff-friendly | Git-ready, human-readable diffs |
| Collaboration | Single author at a time | Multiple authors, merge-friendly |
| External editing | Not supported | VS Code, Tabular Editor, scripts |
| Deployment | File > Publish | Git integration, Fabric APIs, fabric-cicd |
| Data | Contains cached data | cache.abf is gitignored; metadata only in Git |
| Convert | File > Save As > PBIP | File > Save As > PBIX |
Project Structure
The .Report/ folder is required in a PBIP. The .SemanticModel/ folder is optional — a thin report has only .Report/ and points at a remote semantic model via definition.pbir byConnection.
The .pbi/ subfolders in both items (localSettings.json, editorSettings.json, cache.abf, etc.) are all optional — they are per-user/per-machine runtime state generated by Power BI Desktop. A freshly authored PBIP from an external tool may not have any of them, and the project still opens fine in Desktop.
<ProjectName>/
+-- <Name>.pbip # Entry point (references .Report folder) — optional
+-- .gitignore # Recommended; excludes .pbi/localSettings.json and cache.abf
+-- <Name>.SemanticModel/ # OPTIONAL — absent for thin reports
| +-- .pbi/ # All contents OPTIONAL, per-user runtime state
| | +-- localSettings.json # User-specific (gitignored)
| | +-- editorSettings.json # Editor settings (committed)
| | +-- cache.abf # Data cache (gitignored)
| | +-- unappliedChanges.json # Pending Power Query changes
| | +-- daxQueries.json # DAX query view tab settings
| | +-- tmdlscripts.json # TMDL view script tab settings
| +-- definition.pbism # SM entry point (required if .SemanticModel exists)
| +-- definition/ # TMDL format (preferred) — see tmdl skill
| +-- model.bim # TMSL format (legacy alt to definition/, mutually exclusive)
| +-- diagramLayout.json # SM diagram (no external edit)
| +-- DAXQueries/ # .dax files from DAX query view
| +-- TMDLScripts/ # .tmdl files from TMDL view
| +-- Copilot/ # Copilot tooling metadata
| +-- .platform # Fabric identity (displayName, logicalId)
+-- <Name>.Report/ # REQUIRED
| +-- .pbi/ # OPTIONAL runtime state
| | +-- localSettings.json # User-specific (gitignored)
| +-- definition.pbir # Report entry point (required for PBIR format)
| +-- definition/ # PBIR format — see pbir-format skill
| | +-- report.json
| | +-- version.json
| | +-- pages/
| | | +-- pages.json
| | | +-- <page-slug>/ # See "Page folder naming" below
| | | | +-- page.json
| | | | +-- visuals/...
| +-- report.json # PBIR-Legacy format (legacy alt to definition/)
| +-- mobileState.json # Mobile layout (no external edit)
| +-- semanticModelDiagramLayout.json # Diagram positions (table renames)
| +-- CustomVisuals/ # Private custom visual metadata
| +-- StaticResources/
| | +-- SharedResources/ # Base themes, shared resources
| | | +-- BaseThemes/<name>.json # Resolution path: <report>/StaticResources/SharedResources/<item.path>
| | +-- RegisteredResources/ # Custom themes, images, .pbiviz files
| +-- DAXQueries/ # .dax files from report DAX query view
| +-- .platform # Fabric identityPage folder naming
Power BI Desktop uses opaque 20-character hex slugs for new page, visual, bookmark, and filter folders by default (e.g. 847663d71e27e0840063). Per Microsoft docs, these can be renamed to friendly names but the replacement must satisfy:
- Regex:
^[\w-]+$— word characters (letters, digits, underscore) or hyphen only. - No spaces, no dots, no other punctuation. Names outside this set are silently ignored by Power BI Desktop and the page/visual vanishes from the loaded report. This is the hardest bug class to diagnose because there is no error dialog.
- Folder name and `name` field must match exactly (case-sensitive). The folder may be bare (
<slug>/) or suffixed (<slug>.Page/) — both forms are valid on disk. pbir-cli uses the.Pagesuffix in its CLI path syntax; current Desktop saves omit the suffix. - `pages.json.pageOrder` entries must reference the slug, not the display name.
activePageNamemust be one of the entries inpageOrder. - Restart Desktop after external rename. PBI Desktop does not detect file changes while open.
If you rename a page from a hex slug to a friendly name, you must also update every reference to the old slug in visual JSONs, filter configs, bookmarks, sparkline metadata, and DAX queries — see references/rename-cascade.md.
SharedResources path resolution
Items listed in resourcePackages[] are resolved relative to:
<Report>/StaticResources/<package_type>/<item.path>For a SharedResources package with an item { "path": "BaseThemes/Fluent2-CY26SU03.json" }, Power BI Desktop looks for:
<Report>/StaticResources/SharedResources/BaseThemes/Fluent2-CY26SU03.jsonMissing resource files are a common blocking error. If report.json declares themeCollection.baseTheme.type = "SharedResources" and points at a resource that doesn't exist on disk, the report will not open. validate_pbip.py checks this explicitly.
What to Read for Common Tasks
| Task | Read |
|---|---|
| Inspect or extract a PBIX file | Working with PBIX Files section above -- internal structure, thick vs thin detection, encoding, extraction, assembling a PBIP from extracted contents |
| Understand entry point file structure | `references/pbip-file-types.md` -- .pbip, .pbir, .pbism, .platform JSON structure, version properties, byPath vs byConnection |
| Rename a table, measure, or column | `references/rename-cascade.md` -- before/after examples for every cascade location. See also pbir-format skill's references/rename-patterns.md for visual JSON patterns |
| Fork / duplicate a PBIP project | `references/pbip-file-types.md` -- update .pbip path, .pbir byPath, .platform logicalId and displayName |
| Work with Copilot tooling files | `references/copilot-folder.md` -- AI instructions, verified answers, schema, example prompts |
| Edit TMDL model files | `tmdl` skill -- syntax, authoring, column properties, naming conventions |
| Edit PBIR report files | `pbir-format` skill -- visual.json, theme, filters, report extensions, page layout |
| Verify no broken references after rename | Grep commands below |
Forking a PBIP Project
1. Copy the project folder -- duplicate the entire root folder with a new name. 2. Rename artifact folders -- rename .Report/ and .SemanticModel/ subfolders to match the new project name. 3. Rename and update `.pbip` -- rename the .pbip file and update artifacts[].report.path to point to the renamed .Report folder. 4. Update `.pbir` -- if the report uses byPath, update the path to point to the renamed .SemanticModel folder. 5. Update `.platform` files -- set displayName to the new project name in each .platform file. Regenerate logicalId (new GUID) if deploying as a separate Fabric item.
Verification
Two tools for validation, used together:
1. `scripts/validate_pbip.py` — project-level validator for cross-cutting concerns: .pbip root file, .platform identity, semantic model format (TMDL vs TMSL), datasetReference resolution, theme resource resolution on disk, orphan page folders, and the silent-ignore page name regex rule (page names outside ^[\w-]+$ are silently ignored by Power BI Desktop). Delegates deep .Report schema validation to pbir validate if it is on PATH.
python3 scripts/validate_pbip.py <path-to-.pbip-or-project> # validate
python3 scripts/validate_pbip.py <path> --fix # scaffold .gitignore
python3 scripts/validate_pbip.py <path> --json # machine-readable
python3 scripts/validate_pbip.py <path> --no-pbir-cli # skip delegationExit codes: 0 clean, 1 warnings only, 2 errors, 3 usage error.
2. `pbir validate <Report.Report>` (from the pbir-cli skill) — canonical JSON schema + PBIR structure validator for the .Report folder. Covers JSON syntax, schema compliance, required fields, and optional --qa / --fields / --strict checks.
3. `pbip-validator` agent — use for interactive, LLM-driven checking of orphaned references after renames, when you need reasoning over the whole project rather than a deterministic report.
Known gotcha with `pbir validate`: if the project's .pbi/localSettings.json uses a schema version newer than the one bundled in pbir-cli, pbir validate returns SCHEMA_UNSUPPORTED. Pass --allow-download-schemas to let it fetch the missing schema on demand, or ignore .pbi/ files (they are per-user runtime state and not part of the committed definition).
After any rename or fork operation, verify no old references remain.
# Search for old name across all project files
grep -r "Old Name" "Project.Report/" "Project.SemanticModel/" --include="*.json" --include="*.tmdl" --include="*.dax"
# Search with word boundaries to avoid partial matches
grep -rP "\bOld Name\b" "Project.Report/" "Project.SemanticModel/"
# Look for old name in single-quoted DAX references
grep -r "'Old Name'" --include="*.tmdl" --include="*.dax"Common missed locations: 1. SparklineData metadata -- compact string format outside standard JSON structure 2. Conditional formatting expressions -- Entity refs nested in Conditional.Cases 3. Filter config -- page-level and visual-level filters in filterConfig sections 4. Sort definitions -- sortDefinition blocks in visual JSON 5. DAX queries in Report folder -- the second DAX query location 6. Culture file linguisticMetadata -- ConceptualEntity and ConceptualProperty inside embedded JSON
Related Skills
Within this plugin:
- `tmdl` -- TMDL syntax, authoring, and editing rules for direct
.tmdlfile editing - `pbir-format` -- PBIR JSON format, visual.json, theme, filters, report extensions
Other plugins:
- `semantic-models` plugin -- tooling and workflows for semantic model development (naming conventions, model quality). Use for working with the actual model content, not just its file format.
- `pbi-desktop` plugin -- connecting to Power BI Desktop's local Analysis Services instance via TOM/ADOMD.NET
- `tabular-editor` plugin -- Tabular Editor CLI, C# scripting, BPA rules, documentation search
References
Project structure:
- `references/pbip-file-types.md` -- Entry point file structures (
.pbip,.pbir,.pbism,.platform),.pbi/subfolder,DAXQueries/,TMDLScripts/,model.bim,.gitignore, version properties, JSON examples - `references/copilot-folder.md` -- Copilot/ folder structure (AI instructions, verified answers, schema, example prompts)
Rename operations:
- `references/rename-cascade.md` -- Detailed before/after examples for each rename cascade location (TMDL + report files)
Fetching Docs: To retrieve current PBIP reference docs, use microsoft_docs_search + microsoft_docs_fetch (MCP) if available, otherwise mslearn search + mslearn fetch (CLI). Search based on the user's request and run multiple searches as needed to ensure sufficient context before proceeding.
External references:
Copilot Folder Reference
Archival note: This documentation was removed from the official Microsoft Learn page
(projects-dataset.md)
on 2026-03-25 in commit `facaed5`
("Remove Copilot tooling details from projects dataset").
The folder structure remains present in PBIP projects saved with Copilot tooling enabled.
Content below is sourced from the pre-removal version of the docs
(`f806de7`).
The Copilot/ folder lives inside the .SemanticModel/ folder and contains all Copilot tooling metadata and settings configured for the semantic model via Prep data for AI.
Folder Structure
<Name>.SemanticModel/
Copilot/
├── Instructions/
│ ├── instructions.md
│ └── version.json
├── VerifiedAnswers/
│ ├── definitions/
│ │ └── [verified-answer-ID]/
│ │ ├── definition.json
│ │ ├── filters.json
│ │ └── visualSource.json
│ └── version.json
├── schema.json
├── examplePrompts.json
├── settings.json
└── version.jsonFile Descriptions
Instructions/instructions.md
Contains the AI instructions configured for the semantic model, stored as a markdown file. These provide Copilot with business context, terminology, and analytical priorities.
AI instructions:
- Are saved at the semantic model level (not report level)
- Are limited to 10,000 characters
- Are unstructured guidance that the LLM interprets (no guarantee of exact adherence)
- Affect Copilot capabilities but do not extend to general conversations
Instructions/version.json
Tracks the version of the Instructions file structure. Updated whenever the file representation changes.
schema.json
Contains the AI data schema selection and field synonyms configured for the semantic model. Controls which tables and columns are visible to Copilot and provides alternative names for fields.
For more information, see the schema.json schema document.
VerifiedAnswers/ folder
Contains configured Verified answers for the semantic model. Each verified answer is stored in its own subfolder within definitions/ using PBIR format:
| File | Purpose |
|---|---|
definition.json | Verified answer metadata (trigger phrases, description) |
filters.json | Filter configuration applied to the visual |
visualSource.json | Visual definition that renders the answer |
VerifiedAnswers/version.json
Tracks the version of the VerifiedAnswers file structure.
settings.json
Contains top-level Copilot tooling settings.
For more information, see the settings.json schema document.
examplePrompts.json
Contains example prompts set up for the semantic model, used by Copilot Zero Prompt experiences (the suggested questions shown when a user first opens Copilot).
For more information, see the examplePrompts.json schema document.
version.json (root)
Tracks the version of the overall Copilot feature file structure. The version is updated whenever the file representation changes (e.g. when a new file is added to the folder).
For more information, see the version.json schema document.
Authoring and Consumption
- Authoring of AI instructions, AI data schema, and verified answers is available in
both Power BI Desktop and the Power BI service via the Prep data for AI button on the Home ribbon.
- Consumption of these features is available everywhere that Copilot in Power BI exists.
- Power BI Desktop supports Prep data for AI with Import, DirectQuery, and Composite (local)
connection types only.
- All model types can use Prep data for AI within the Power BI service.
Git and Deployment Notes
- AI instructions and AI data schemas also save to the LSDL (Linguistic Schema Definition
Language) and can be edited through that path as well.
- When making LSDL or Copilot tooling edits through Git or deployment pipelines, a model
refresh in the Power BI service is required to sync changes after deployment:
- Import models: refresh required after deployment
- DirectQuery models: refresh required, but only once per day
- Direct Lake models: refresh required, but only once per day
- The
Copilot/folder is committed to Git by default. Include or exclude individual files
as needed via .gitignore.
Marking a Model as Approved for Copilot
After configuring Copilot tooling, mark the semantic model as Approved for Copilot in the Power BI service (Settings > Approved for Copilot). This removes friction treatment from the standalone Copilot experience for that model and its associated reports.
PBIP File Types Reference
Structure and purpose of each entry-point and project-level file in a Power BI Project (PBIP).
Verify against the current Microsoft docs for the latest schema versions:
- PBIP overview
- PBIP semantic model folder
- PBIP report folder
.pbip (Project Entry Point)
The root file that references the report folder. One per project:
{
"version": "1.0",
"artifacts": [
{
"report": {
"path": "My Report.Report"
}
}
],
"settings": {
"enableAutoRecovery": true
}
}When forking a project, update the path to match the renamed .Report/ folder.
This file is optional -- open definition.pbir directly to load the report without a .pbip wrapper.
Schema: pbipProperties
.platform (Item Metadata)
Found inside each .Report/ and .SemanticModel/ folder. Contains the item's display name, type, and a logicalId used by Fabric for identity:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "SemanticModel",
"displayName": "SpaceParts OTC Full"
},
"config": {
"version": "2.0",
"logicalId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Critical rules:
logicalIdis the unique identity of the item in Fabric. Never change it on an existing item -- this would break the connection to the deployed item.- When forking (creating a copy),
logicalIdmust be changed to a new GUID to avoid conflicts with the original item. displayNameis what appears in the Fabric workspace. Update it when forking to distinguish the copy.typevalues:SemanticModel,Report
definition.pbir (Report Entry Point)
Found inside the .Report/ folder. References the semantic model the report is connected to.
byPath (Local Reference)
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json",
"version": "4.0",
"datasetReference": {
"byPath": {
"path": "../My Model.SemanticModel"
}
}
}The path is relative to the .Report/ folder. ../ navigates up to the project root.
byConnection (Remote Model) — Current Form
For new reports connected to a remote semantic model, use the connectionString-only form. This is the current recommended format:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json",
"version": "4.0",
"datasetReference": {
"byConnection": {
"connectionString": "Data Source=powerbi://api.powerbi.com/v1.0/myorg/WorkspaceName;Initial Catalog=ModelName"
}
}
}When deploying via Fabric REST API, use the semanticmodelid form:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json",
"version": "4.0",
"datasetReference": {
"byConnection": {
"connectionString": "semanticmodelid=[SemanticModelId]"
}
}
}byConnection Legacy Form
Older reports may use the verbose six-property form. Do not use this for new reports — prefer the connectionString-only form above:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json",
"version": "4.0",
"datasetReference": {
"byConnection": {
"connectionString": "Data Source=powerbi://api.powerbi.com/v1.0/myorg/WorkspaceName;Initial Catalog=ModelName",
"pbiServiceModelId": null,
"pbiModelVirtualServerName": "sobe_wowvirtualserver",
"pbiModelDatabaseName": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "EntityDataSource",
"connectionType": "pbiServiceXmlaStyleLive"
}
}
}Multiple .pbir Files
Fabric Git Integration only processes definition.pbir; other .pbir files are ignored but can coexist (e.g. definition-liveConnect.pbir for forcing live connect mode).
version Property
| Version | Supported formats |
|---|---|
| 1.0 | PBIR-Legacy only (report.json) |
| 4.0+ | PBIR-Legacy (report.json) OR PBIR (definition/ folder) |
Schema: definitionProperties
definition.pbism (Semantic Model Entry Point)
Found inside the .SemanticModel/ folder:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/semanticModel/definitionProperties/1.0.0/schema.json",
"version": "4.2",
"settings": {}
}version Property
| Version | Supported formats |
|---|---|
| 1.0 | TMSL only (model.bim) |
| 4.0+ | TMSL (model.bim) OR TMDL (definition/ folder) |
Schema: definitionProperties
.pbi/ Subfolder
Per-item subfolder containing local settings, caches, and editor state. Present in both .Report/ and .SemanticModel/ folders.
localSettings.json
User/machine-specific settings, gitignored by default. Each user gets their own local state (e.g. last-opened page, query editor state). Present in both report and semantic model folders.
Schema: localSettings
editorSettings.json
Editor settings saved with the model definition, committed to Git. Shared across all users of the project.
Schema: editorSettings
cache.abf
Analysis Services Backup file, local data cache, gitignored. PBI Desktop can open the project without it -- the model loads without data. If present, data is loaded and the model definition is overwritten from project files.
unappliedChanges.json
Pending Power Query changes from the Transform Data editor. Committed to Git by default (can exclude).
WARNING: If this file exists and changes are applied, it overwrites queries in model metadata.
Schema: unappliedChanges
daxQueries.json
DAX query view editor settings (tab order, default tab).
tmdlscripts.json
TMDL view script tab settings.
DAXQueries/ Folder
Contains .dax files, one per DAX query view tab, named [Tab name].dax. Exists in both .SemanticModel/ and .Report/ folders. DAX queries are saved when saving from PBI Desktop. Settings stored in .pbi/daxQueries.json.
TMDLScripts/ Folder
Contains .tmdl files, one per TMDL view script tab, named [Tab name].tmdl. Only in .SemanticModel/ folder. Settings stored in .pbi/tmdlscripts.json.
model.bim (TMSL Legacy)
Present only when saving in TMSL format (mutually exclusive with definition/ folder). Single large JSON file containing the complete model definition. TMDL is the recommended format for source control (better diffs, per-table files).
.gitignore
Default content auto-generated by PBI Desktop:
**/.pbi/localSettings.json
**/.pbi/cache.abfAuto-generated only if one does not already exist in the save folder or parent Git repo.
Other Report Folder Files
Brief overview of remaining files in the .Report/ and .SemanticModel/ folders. For PBIR report definition details, see the pbir-format skill.
| File/Folder | Purpose | External Edit Support |
|---|---|---|
definition/ | PBIR report definition (pages, visuals, bookmarks) | Yes (JSON schema validated) |
report.json | PBIR-Legacy report definition | No |
mobileState.json | Mobile layout settings | No |
semanticModelDiagramLayout.json | Diagram node positions (contains table names) — needs verification: this filename is not confirmed in Microsoft docs or the K201 example; verify against a live PBI Desktop export | Limited (table renames only) |
CustomVisuals/ | Private custom visual metadata (org/AppSource visuals load automatically) | No |
StaticResources/RegisteredResources/ | Custom themes, images, .pbiviz files | Yes (for already-registered resources) |
diagramLayout.json (in SM folder) | Semantic model diagram metadata | No |
CustomVisuals/ details: Contains metadata for private custom visuals (.pbiviz packages loaded by the user). Organizational store and AppSource visuals are NOT stored here -- they load automatically. Each custom visual gets a subfolder named with its GUID (e.g., PBI_CV_a1b2c3d4-...).
Rename Cascade Reference
Detailed before/after examples for every location that must be updated when renaming tables, measures, or columns in a PBIP project.
Table Rename Examples
The examples below show renaming a table from Customers to Customer.
TMDL File Name
# Before
tables/Customers.tmdl
# After
tables/Customer.tmdlTable Declaration
// Before
table Customers
lineageTag: abc-123
// After
table Customer
lineageTag: abc-123Quoting rules: Only quote names that contain spaces or special characters. Simple names like Customer are unquoted. Names with spaces use single quotes: 'Invoice Document Type'. Underscore-prefixed names without spaces are unquoted: _Measures.
Partition Name
The partition name typically matches the table name:
// Before
partition Customers = m
mode: import
source = ...
// After
partition Customer = m
mode: import
source = ...Model.tmdl Ref Entries
// Before
ref table Customers
// After
ref table CustomerAlso update `PBI_QueryOrder` annotation if it contains the table name:
// Before
annotation PBI_QueryOrder = ["Brand","Customers","Product"]
// After
annotation PBI_QueryOrder = ["Brand","Customer","Product"]Relationships.tmdl
Both fromColumn and toColumn use the format TableName.'Column Name':
// Before
relationship abc-123
fromColumn: Invoices.'Customer Key'
toColumn: Customers.'Customer Key'
// After
relationship abc-123
fromColumn: Invoices.'Customer Key'
toColumn: Customer.'Customer Key'DAX Expressions Across All TMDL Files
Table references in DAX appear in single quotes when the name contains spaces, otherwise unquoted. After a rename, update every occurrence across all .tmdl files:
// Before (table name with no spaces - appears in single quotes in DAX anyway)
CALCULATE (
[Sales Amount],
Customers[Account Type] = "Enterprise"
)
// After
CALCULATE (
[Sales Amount],
Customer[Account Type] = "Enterprise"
)Edge case — unquoted vs. quoted in DAX:
DAX always allows single-quoting table names, even simple ones. Both forms are valid:
// Both valid DAX:
Customer[Column]
'Customer'[Column]When doing bulk find-and-replace, search for both patterns:
Customers[(unquoted)'Customers'[(quoted)'Customers'(standalone reference in CALCULATE filters)
Visual JSON Entity References
SourceRef.Entity
// Before
{
"Expression": {
"SourceRef": {
"Entity": "Customers"
}
}
}
// After
{
"Expression": {
"SourceRef": {
"Entity": "Customer"
}
}
}queryRef
The queryRef format is TableName.ColumnOrMeasureName:
// Before
"queryRef": "Customers.Account Type"
// After
"queryRef": "Customer.Account Type"nativeQueryRef
The nativeQueryRef contains only the column/measure name (no table prefix). It does not change during table renames — only during column/measure renames.
Filter Config Entity References
Filter configurations in visual and page JSON files contain From[].Entity references:
// Before (in visual.json or page.json filterConfig)
"From": [
{ "Name": "p", "Entity": "Customers", "Type": 0 }
]
// After
"From": [
{ "Name": "p", "Entity": "Customer", "Type": 0 }
]Conditional Formatting Entity References
Conditional formatting rules embed SourceRef.Entity inside expression trees:
// Before (nested in objects.*.properties.*.expr.Conditional.Cases)
"Measure": {
"Expression": {
"SourceRef": { "Entity": "Customers" }
},
"Property": "Some Measure"
}
// After
"Measure": {
"Expression": {
"SourceRef": { "Entity": "Customer" }
},
"Property": "Some Measure"
}Bookmark Filter Snapshots
Bookmark JSON files (in definition/bookmarks/) contain filter state snapshots. Each filter entry has two distinct Entity references that must both be updated:
1. filter.From[].Entity — the aliased filter predicate 2. expression.Column.Expression.SourceRef.Entity — the top-level expression field
// Before (in .bookmark.json explorationState.filters.byExpr[])
{
"expression": {
"Column": {
"Expression": {"SourceRef": {"Entity": "Customers"}}, // ← must update
"Property": "Account Name"
}
},
"filter": {
"Version": 2,
"From": [{"Name": "c", "Entity": "Customers", "Type": 0}], // ← must update
"Where": [...]
}
}
// After (renaming Customers → Customer)
{
"expression": {
"Column": {
"Expression": {"SourceRef": {"Entity": "Customer"}},
"Property": "Account Name"
}
},
"filter": {
"Version": 2,
"From": [{"Name": "c", "Entity": "Customer", "Type": 0}],
"Where": [...]
}
}Bookmark Highlight Blocks
Bookmarks may also contain highlight blocks with dataMap keys in "Table.Column" format that must be updated on table or column renames:
// Before (in .bookmark.json explorationState.sections.<page>.visualContainers.<visual>)
{
"highlight": {
"dataMap": {
"Customers.Key Account Name": {...}, // ← key string must update
"Sales.Revenue": {...}
},
"filterExpressionMetadata": {
"expressions": [{
"Column": {
"Expression": {"SourceRef": {"Entity": "Customers"}}, // ← must update
"Property": "Key Account Name"
}
}]
}
}
}
// After (renaming Customers → Customer)
{
"highlight": {
"dataMap": {
"Customer.Key Account Name": {...},
"Sales.Revenue": {...}
},
"filterExpressionMetadata": {
"expressions": [{
"Column": {
"Expression": {"SourceRef": {"Entity": "Customer"}},
"Property": "Key Account Name"
}
}]
}
}
}Both dataMap key strings AND filterExpressionMetadata.expressions[].Column.Expression.SourceRef.Entity must be updated.
SparklineData Metadata Selectors
SparklineData metadata selectors embed table names in a compact string format:
// Before
{
"selector": {
"metadata": "SparklineData(Customers.Customer Count_[Date.Date Hierarchy.Date])"
}
}
// After
{
"selector": {
"metadata": "SparklineData(Customer.Customer Count_[Date.Date Hierarchy.Date])"
}
}Format breakdown:
SparklineData(<MeasureTable>.<MeasureName>_[<GroupingTable>.<Hierarchy>.<Level>])SparklineData also appears in query projections as structured JSON:
// Before
{
"field": {
"SparklineData": {
"Measure": {
"Measure": {
"Expression": {
"SourceRef": { "Entity": "Customers" }
},
"Property": "Customer Count"
}
},
"Groupings": [
{
"HierarchyLevel": {
"Expression": {
"Hierarchy": {
"Expression": {
"SourceRef": { "Entity": "Date" }
},
"Hierarchy": "Date Hierarchy"
}
},
"Level": "Date"
}
}
]
}
}
}Both the Entity in the structured JSON and the table name in the metadata selector string must be updated.
SemanticModelDiagramLayout.json
// Before
{
"nodeIndex": "Customers",
"size": { "height": 300, "width": 234 }
}
// After
{
"nodeIndex": "Customer",
"size": { "height": 300, "width": 234 }
}ReportExtensions.json
// Before
{
"entities": [
{
"name": "Customers",
"measures": [
{
"name": "Customer Color",
"expression": "IF ( [Customer Count] > 100, \"blue\", \"gray\" )",
"references": {
"measures": [
{ "entity": "Customers", "name": "Customer Count" }
]
}
}
]
}
]
}
// After
{
"entities": [
{
"name": "Customer",
"measures": [
{
"name": "Customer Color",
"expression": "IF ( [Customer Count] > 100, \"blue\", \"gray\" )",
"references": {
"measures": [
{ "entity": "Customer", "name": "Customer Count" }
]
}
}
]
}
]
}Note: The entity field in references.measures entries must also be updated — not just the top-level name.
Culture Files
The linguisticMetadata JSON inside culture .tmdl files contains ConceptualEntity and ConceptualProperty references:
// Before (inside cultures/en-US.tmdl)
{
"customers.account_type": {
"Definition": {
"Binding": {
"ConceptualEntity": "Customers",
"ConceptualProperty": "Account Type"
}
}
}
}
// After
{
"customers.account_type": {
"Definition": {
"Binding": {
"ConceptualEntity": "Customer",
"ConceptualProperty": "Account Type"
}
}
}
}Note: The dictionary key (customers.account_type) is an auto-generated lookup key. It does not need to match the actual table name, but the ConceptualEntity value does.
DAX Query Files
Check both locations:
<Name>.SemanticModel/DAXQueries/*.dax
<Name>.Report/DAXQueries/*.dax// Before
EVALUATE TOPN(100, Customers)
// After
EVALUATE TOPN(100, Customer)Measure Rename Examples
Renaming a measure from # Customers to # Active Customers.
TMDL Measure Declaration
// Before
measure '# Customers' =
COUNTROWS ( Customer )
formatString: #,##0
displayFolder: Measures
lineageTag: abc-123
// After
measure '# Active Customers' =
COUNTROWS ( Customer )
formatString: #,##0
displayFolder: Measures
lineageTag: abc-123DAX References in Other Measures
// Before
measure '% Customer Growth' =
DIVIDE ( [# Customers], [# Customers PY] )
// After
measure '% Customer Growth' =
DIVIDE ( [# Active Customers], [# Customers PY] )Visual JSON Property and queryRef
// Before
{
"Measure": {
"Expression": { "SourceRef": { "Entity": "Customer" } },
"Property": "# Customers"
}
},
"queryRef": "Customer.# Customers",
"nativeQueryRef": "# Customers"
// After
{
"Measure": {
"Expression": { "SourceRef": { "Entity": "Customer" } },
"Property": "# Active Customers"
}
},
"queryRef": "Customer.# Active Customers",
"nativeQueryRef": "# Active Customers"ReportExtensions.json
// Before
{ "entity": "Customer", "name": "# Customers" }
// After
{ "entity": "Customer", "name": "# Active Customers" }Sort Definitions
sortDefinition blocks inside visual.json contain SourceRef.Entity references that must be updated during table or column renames. These are commonly missed because they live outside the queryState projections.
// Before (in visual.json query.sortDefinition)
"sortDefinition": {
"sort": [{
"field": {
"Measure": {
"Expression": {
"SourceRef": {"Entity": "Customers"}
},
"Property": "Revenue"
}
},
"direction": "Descending"
}],
"isDefaultSort": true
}
// After
"sortDefinition": {
"sort": [{
"field": {
"Measure": {
"Expression": {
"SourceRef": {"Entity": "Customer"}
},
"Property": "Revenue"
}
},
"direction": "Descending"
}],
"isDefaultSort": true
}Note: sortDefinition does not use queryRef — only SourceRef.Entity and Property. Search for "sortDefinition" across all visual.json files to find every instance.
Edge Cases
Names with Special Characters
DAX measure names with special characters (parentheses, percentage signs, delta symbols) are common:
Orders Target vs. Net Orders (Δ)
Sales Target MTD vs. Actuals (%)
OTD % (Value; PY REPT)When searching for these in JSON files, remember that some characters may be escaped or may need regex escaping in grep patterns.
Unquoted Names in TMDL
Simple names (no spaces, no special characters) are unquoted in TMDL declarations:
table Product // unquoted - no spaces
table _Measures // unquoted - underscore prefix, no spaces
table 'Budget Rate' // quoted - contains space
table '1) Selected Metric' // quoted - starts with digit, contains special charsMultiple Tables with Similar Names
When renaming Order in a model that also has Orders and Order Status, use word-boundary-aware search to avoid false matches:
# Use word boundaries to match exact table name
grep -rP "\bOrder\b" --include="*.tmdl" --include="*.json"
# Or search for the specific TMDL/JSON patterns
grep -r "table Order$" --include="*.tmdl"
grep -r '"Entity": "Order"' --include="*.json"Bulk Rename Strategy
For large-scale renames (e.g., applying SQLBI naming conventions to all tables):
1. Build a rename mapping (old name → new name) 2. Sort by longest name first to avoid substring collisions 3. Process one table at a time, running verification after each 4. Use a script for consistency to avoid manual errors
#!/usr/bin/env python3
"""Validate a Power BI Project (PBIP).
Focuses on cross-cutting PBIP concerns that `pbir validate` does NOT cover:
the `.pbip` root file, `.platform` files, `.SemanticModel` folder format
(TMDL vs TMSL), theme resource resolution on disk, orphan page folders, and
the silent-ignore page name regex rule. Deep `.Report` structure + JSON
schema compliance is delegated to `pbir validate` if it is on PATH.
Usage:
validate_pbip.py <path> text output
validate_pbip.py <path> --json machine-readable output
validate_pbip.py <path> --fix create the handful of things that are
safe to scaffold (currently: .gitignore)
validate_pbip.py <path> --no-pbir-cli
skip delegation to `pbir validate`
validate_pbip.py <path> --quiet hide informational lines
Exit codes:
0 clean
1 warnings only
2 errors
3 script usage error
Notes:
- The `.SemanticModel` folder is optional (thin reports have only `.Report`).
The `.Report` folder is required for anything called a PBIP.
- Semantic model format can be TMDL (`definition/` folder, preferred) or
TMSL (`model.bim`, legacy). The two are mutually exclusive.
- Page folder names can be opaque 20-char slugs or readable names, but the
name MUST match ^[\\w-]+$ (word chars or hyphen). Names with spaces,
dots, or other punctuation are silently ignored by Power BI Desktop.
- Page folder name with or without the `.Page` suffix is acceptable; both
forms appear in the wild.
"""
#region Imports
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
#endregion
#region Constants
GUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
PAGE_NAME_RE = re.compile(r"^[\w-]+$")
DEFAULT_GITIGNORE = """**/.pbi/localSettings.json
**/.pbi/cache.abf
"""
ERROR = "error"
WARN = "warn"
INFO = "info"
#endregion
#region Result types
@dataclass
class Finding:
level: str
code: str
message: str
path: str | None = None
@dataclass
class Result:
project_root: Path
project_type: str = "unknown"
report_dir: Path | None = None
semantic_model_dir: Path | None = None
findings: list[Finding] = field(default_factory=list)
fixes_applied: list[str] = field(default_factory=list)
pbir_cli_output: str | None = None
pbir_cli_exit: int | None = None
def add(self, level: str, code: str, message: str, path: Path | None = None) -> None:
self.findings.append(Finding(level, code, message, str(path) if path else None))
def errors(self) -> list[Finding]:
return [f for f in self.findings if f.level == ERROR]
def warnings(self) -> list[Finding]:
return [f for f in self.findings if f.level == WARN]
#endregion
#region IO helpers
def load_json(path: Path, result: Result, code_prefix: str) -> dict | None:
"""Load and parse a JSON file. Records an error on the result if missing
or malformed. Returns the parsed dict, or None on failure."""
if not path.exists():
result.add(ERROR, f"{code_prefix}_missing", f"{path.name} is missing", path)
return None
try:
raw = path.read_bytes()
except OSError as e:
result.add(ERROR, f"{code_prefix}_read", f"{path.name}: {e}", path)
return None
if raw.startswith(b"\xef\xbb\xbf"):
result.add(WARN, f"{code_prefix}_bom",
f"{path.name} has a UTF-8 BOM; remove it (some tools reject BOMs)", path)
raw = raw[3:]
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as e:
result.add(ERROR, f"{code_prefix}_invalid_json",
f"{path.name}: {e.msg} (line {e.lineno} col {e.colno})", path)
return None
#endregion
#region Discovery
def discover(path: Path, result: Result) -> None:
"""Populate result.project_type / report_dir / semantic_model_dir from a
user-supplied path. Accepts a `.pbip` file, a `.Report` or
`.SemanticModel` directory, or a project root directory."""
path = path.resolve()
if path.is_file() and path.suffix == ".pbip":
result.project_root = path.parent
data = load_json(path, result, "pbip")
if data:
for artifact in data.get("artifacts") or []:
rp = (artifact.get("report") or {}).get("path")
if rp:
target = (path.parent / rp).resolve()
if target.is_dir():
result.report_dir = target
else:
result.add(ERROR, "pbip_report_path_missing",
f".pbip artifacts[].report.path '{rp}' does not resolve", path)
result.semantic_model_dir = sibling_semantic_model(result.report_dir, path.parent)
result.project_type = "thick-pbip" if result.semantic_model_dir else "thin-pbip"
return
if path.is_dir():
if path.name.endswith(".Report"):
result.project_root = path.parent
result.report_dir = path
result.semantic_model_dir = sibling_semantic_model(path, path.parent)
result.project_type = "thick-bare" if result.semantic_model_dir else "report-only"
return
if path.name.endswith(".SemanticModel"):
result.project_root = path.parent
result.semantic_model_dir = path
result.project_type = "semantic-model-only"
return
result.project_root = path
pbips = sorted(path.glob("*.pbip"))
if pbips:
discover(pbips[0], result)
if len(pbips) > 1:
result.add(WARN, "multiple_pbip",
f"{len(pbips)} .pbip files found; validated {pbips[0].name}", path)
return
reports = sorted(p for p in path.iterdir() if p.is_dir() and p.name.endswith(".Report"))
sms = sorted(p for p in path.iterdir() if p.is_dir() and p.name.endswith(".SemanticModel"))
if reports:
result.report_dir = reports[0]
if sms:
result.semantic_model_dir = sms[0]
if result.report_dir and result.semantic_model_dir:
result.project_type = "thick-bare"
elif result.report_dir:
result.project_type = "report-only"
elif result.semantic_model_dir:
result.project_type = "semantic-model-only"
else:
result.add(ERROR, "no_pbip_components",
"no .pbip, .Report, or .SemanticModel found at this path", path)
return
result.add(ERROR, "invalid_path", f"path does not exist: {path}", path)
def sibling_semantic_model(report_dir: Path | None, project_root: Path) -> Path | None:
"""Find a sibling .SemanticModel folder next to a .Report folder. Prefers
same-basename pairing; falls back to the first match in the project root."""
if not report_dir:
return None
base = report_dir.name[: -len(".Report")]
direct = project_root / f"{base}.SemanticModel"
if direct.is_dir():
return direct
for p in project_root.iterdir():
if p.is_dir() and p.name.endswith(".SemanticModel"):
return p
return None
#endregion
#region Validation — .platform
def validate_platform(dir_: Path, expected_type: str, result: Result) -> None:
"""Validate a .platform file: JSON validity, type match, GUID logicalId.
Never auto-creates — wrong logicalId is a Fabric identity conflict."""
platform_path = dir_ / ".platform"
data = load_json(platform_path, result, "platform")
if not data:
return
meta = data.get("metadata") or {}
if meta.get("type") != expected_type:
result.add(ERROR, "platform_type_mismatch",
f".platform metadata.type is {meta.get('type')!r}, expected {expected_type!r}",
platform_path)
if not meta.get("displayName"):
result.add(WARN, "platform_no_display_name",
".platform metadata.displayName is empty", platform_path)
logical_id = (data.get("config") or {}).get("logicalId")
if not logical_id:
result.add(ERROR, "platform_no_logical_id",
".platform config.logicalId is missing", platform_path)
elif not GUID_RE.match(logical_id):
result.add(ERROR, "platform_bad_guid",
f".platform config.logicalId is not a GUID: {logical_id}", platform_path)
#endregion
#region Validation — report folder
def validate_report(report_dir: Path, result: Result) -> None:
"""Validate a .Report folder. Covers only what pbir-cli cannot: the
definition.pbir datasetReference, orphan page folders, page name regex,
and theme resource resolution."""
if not report_dir.exists():
result.add(ERROR, "report_missing", f"report folder does not exist: {report_dir}", report_dir)
return
validate_platform(report_dir, "Report", result)
pbir_path = report_dir / "definition.pbir"
def_dir = report_dir / "definition"
legacy_report_json = report_dir / "report.json"
if legacy_report_json.exists() and not def_dir.exists():
result.add(WARN, "report_legacy_format",
"report.json at the root (no definition/ folder) is the legacy PBIR-Legacy "
"format. Open in Power BI Desktop and re-save to migrate to PBIR.",
legacy_report_json)
return
if not (pbir_path.exists() or def_dir.exists()):
result.add(ERROR, "report_no_definition",
"report folder has neither definition.pbir nor definition/",
report_dir)
return
validate_pbir_entry(pbir_path, report_dir, result)
if def_dir.exists():
validate_pages_and_themes(def_dir, result)
def validate_pbir_entry(pbir_path: Path, report_dir: Path, result: Result) -> None:
data = load_json(pbir_path, result, "pbir")
if not data:
return
version = data.get("version")
if not version:
result.add(ERROR, "pbir_no_version", "definition.pbir missing version", pbir_path)
elif version == "1.0":
result.add(WARN, "pbir_legacy_version",
"definition.pbir version 1.0 only supports PBIR-Legacy; upgrade to 4.0+",
pbir_path)
ds = data.get("datasetReference") or {}
if "byPath" in ds:
by_path = (ds["byPath"] or {}).get("path")
if not by_path:
result.add(ERROR, "pbir_bypath_empty",
"datasetReference.byPath has no path", pbir_path)
else:
target = (report_dir / by_path).resolve()
if not target.is_dir():
result.add(ERROR, "pbir_bypath_missing",
f"datasetReference.byPath.path '{by_path}' does not resolve "
f"to a directory", pbir_path)
elif "byConnection" in ds:
if not (ds["byConnection"] or {}).get("connectionString"):
result.add(ERROR, "pbir_byconnection_no_cs",
"datasetReference.byConnection is missing connectionString", pbir_path)
else:
result.add(ERROR, "pbir_no_dataset_ref",
"definition.pbir datasetReference must have byPath or byConnection",
pbir_path)
def validate_pages_and_themes(def_dir: Path, result: Result) -> None:
"""Validate pages (orphans + name regex) and theme resource resolution.
Deep schema/structure checks are the job of `pbir validate`."""
report_json_path = def_dir / "report.json"
pages_dir = def_dir / "pages"
pages_json_path = pages_dir / "pages.json"
if pages_dir.exists() and pages_json_path.exists():
validate_pages(pages_dir, pages_json_path, result)
report_json = load_json(report_json_path, result, "report_json") if report_json_path.exists() else None
if report_json:
validate_theme_resources(def_dir.parent, report_json, report_json_path, result)
def validate_pages(pages_dir: Path, pages_json_path: Path, result: Result) -> None:
pages_json = load_json(pages_json_path, result, "pages_json")
if not pages_json:
return
page_order = pages_json.get("pageOrder") or []
active = pages_json.get("activePageName")
if active and active not in page_order:
result.add(WARN, "active_page_not_in_order",
f"activePageName '{active}' is not in pageOrder (Power BI Desktop "
f"will auto-fix this but it indicates stale state)",
pages_json_path)
for name in page_order:
if not PAGE_NAME_RE.match(name):
result.add(ERROR, "page_name_invalid_chars",
f"page name '{name}' contains characters outside [A-Za-z0-9_-]. "
f"Power BI Desktop will SILENTLY IGNORE this page folder.",
pages_json_path)
on_disk: dict[str, Path] = {}
for child in pages_dir.iterdir():
if not child.is_dir():
continue
slug = child.name[:-len(".Page")] if child.name.endswith(".Page") else child.name
on_disk[slug] = child
for name in page_order:
folder = on_disk.get(name)
if not folder:
result.add(ERROR, "page_folder_missing",
f"pageOrder lists '{name}' but no matching folder exists "
f"(looked for {pages_dir / name} and {pages_dir / (name + '.Page')})",
pages_dir)
continue
page_json_path = folder / "page.json"
data = load_json(page_json_path, result, "page_json")
if not data:
continue
if data.get("name") != name:
result.add(ERROR, "page_name_mismatch",
f"page.json name='{data.get('name')}' does not match folder slug "
f"'{name}' (match is case-sensitive)", page_json_path)
for slug, folder in on_disk.items():
if slug not in page_order:
result.add(WARN, "orphan_page_folder",
f"page folder '{folder.name}' is not in pages.json pageOrder. "
f"Delete it or add '{slug}' to pageOrder.", folder)
def validate_theme_resources(report_dir: Path, report_json: dict, report_json_path: Path, result: Result) -> None:
"""Verify resourcePackages items actually resolve on disk.
Resolution path: <report>/StaticResources/<package_type>/<item.path>"""
packages = report_json.get("resourcePackages") or []
for pkg in packages:
pkg_type = pkg.get("type")
if pkg_type not in ("SharedResources", "RegisteredResources"):
continue
for item in pkg.get("items") or []:
item_path = item.get("path")
if not item_path:
continue
target = report_dir / "StaticResources" / pkg_type / item_path
if not target.exists():
rel = target.relative_to(report_dir)
result.add(ERROR, "resource_missing",
f"resourcePackages {pkg_type} item '{item.get('name')}' references "
f"missing file: {rel}", report_json_path)
base = (report_json.get("themeCollection") or {}).get("baseTheme") or {}
if base.get("type") == "SharedResources" and (base_name := base.get("name")):
matched = any(
i.get("name") == base_name
for pkg in packages if pkg.get("type") == "SharedResources"
for i in pkg.get("items") or []
)
if not matched:
result.add(ERROR, "base_theme_not_in_packages",
f"themeCollection.baseTheme '{base_name}' has type SharedResources "
f"but no matching entry in resourcePackages",
report_json_path)
#endregion
#region Validation — semantic model folder
def validate_semantic_model(sm_dir: Path, result: Result) -> None:
"""Validate a .SemanticModel folder: .platform, .pbism, TMDL-vs-TMSL."""
if not sm_dir.exists():
return
validate_platform(sm_dir, "SemanticModel", result)
pbism_path = sm_dir / "definition.pbism"
data = load_json(pbism_path, result, "pbism")
if data and not data.get("version"):
result.add(ERROR, "pbism_no_version", "definition.pbism missing version", pbism_path)
tmdl_def = sm_dir / "definition"
model_bim = sm_dir / "model.bim"
has_tmdl = tmdl_def.is_dir() and (tmdl_def / "model.tmdl").exists()
has_bim = model_bim.exists()
if has_tmdl and has_bim:
result.add(ERROR, "both_tmdl_and_bim",
"both definition/ (TMDL) and model.bim (TMSL) are present — mutually exclusive",
sm_dir)
elif has_bim and not has_tmdl:
result.add(WARN, "sm_tmsl_format",
"model.bim (TMSL) is the legacy format; prefer TMDL for source control",
model_bim)
load_json(model_bim, result, "bim")
elif has_tmdl:
check_tmdl_presence(tmdl_def, result)
else:
result.add(ERROR, "sm_no_definition",
"semantic model has neither definition/ (TMDL) nor model.bim (TMSL)",
sm_dir)
def check_tmdl_presence(def_dir: Path, result: Result) -> None:
"""Lightweight TMDL presence checks. Does not parse TMDL syntax; that's
the tmdl skill's job."""
if not (def_dir / "model.tmdl").exists():
result.add(ERROR, "model_tmdl_missing",
"definition/model.tmdl is missing", def_dir / "model.tmdl")
return
tables_dir = def_dir / "tables"
if tables_dir.is_dir() and not any(tables_dir.glob("*.tmdl")):
result.add(WARN, "tmdl_tables_empty",
"definition/tables/ has no .tmdl files", tables_dir)
for optional in ("database.tmdl", "relationships.tmdl", "expressions.tmdl"):
if not (def_dir / optional).exists():
key = optional.replace(".tmdl", "")
result.add(INFO, f"tmdl_{key}_absent",
f"definition/{optional} absent (optional)", def_dir / optional)
check_m_table_name_collisions(def_dir, result)
#region TMDL declaration parsing
def _tmdl_decl_name(line: str, keyword: str) -> str | None:
"""Parse `<keyword> <name> [...]` at the start of a TMDL line.
Returns the bare name (quotes stripped) or None if the line is not a
declaration. Handles single-quoted, double-quoted, escaped (`#"name"`),
and bare identifier forms. The TMDL grammar allows annotations or
sub-clauses after the name on the same line, separated by whitespace;
we only consume the first token after the keyword.
"""
stripped = line.lstrip()
prefix = f"{keyword} "
if not stripped.startswith(prefix):
return None
rest = stripped[len(prefix):].strip()
if not rest:
return None
# Quoted form: capture up to the matching closing quote
quoted = re.match(r"""^#?(['"])(.+?)\1""", rest)
if quoted:
return quoted.group(2)
# Bare identifier: first whitespace-delimited token
return rest.split(None, 1)[0]
def _collect_tmdl_declarations(file_path: Path, keyword: str) -> set[str]:
"""Read a TMDL file and collect all top-level `<keyword> <name>` names.
Top-level means the declaration is at the start of a line (no leading
indentation), which is how TMDL distinguishes object declarations from
nested properties. Returns an empty set if the file does not exist.
"""
if not file_path.is_file():
return set()
names: set[str] = set()
try:
text = file_path.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
return names
for raw in text.splitlines():
# Top-level declarations live at column 0
if raw and raw[0].isspace():
continue
name = _tmdl_decl_name(raw, keyword)
if name:
names.add(name)
return names
#endregion
def check_m_table_name_collisions(def_dir: Path, result: Result) -> None:
"""Detect M-expression names that collide with table names.
Power BI Desktop puts M shared expressions and tables in the same
member namespace. A duplicate name triggers a fatal load error:
'Microsoft.Data.Mashup.Preview; This document contains a
duplicate member <name>.'
Resolution: rename the M expression (common pattern: append " Query"
or " Source") and update any partition that references it via M
escaped-identifier syntax: Source = #"Renamed Expression".
"""
expressions_file = def_dir / "expressions.tmdl"
tables_dir = def_dir / "tables"
expr_names = _collect_tmdl_declarations(expressions_file, "expression")
if not expr_names:
return
table_names: set[str] = set()
if tables_dir.is_dir():
for tmdl_file in sorted(tables_dir.glob("*.tmdl")):
table_names |= _collect_tmdl_declarations(tmdl_file, "table")
collisions = sorted(expr_names & table_names)
for name in collisions:
result.add(
ERROR,
"m_table_name_collision",
(f"M expression '{name}' collides with table '{name}'. "
f"PBI Desktop will fail to load the model with "
f"'duplicate member {name}'. Rename the M expression "
f"(e.g. '{name} Query') and update dependent partitions "
f"to reference it via #\"{name} Query\"."),
expressions_file,
)
#endregion
#region pbir-cli delegation
def run_pbir_validate(report_dir: Path, result: Result) -> None:
"""Shell out to `pbir validate` for deep .Report validation. Captures
stdout/stderr for display and the exit code for final status."""
if not shutil.which("pbir"):
result.add(INFO, "pbir_cli_absent",
"pbir CLI not found on PATH. Install for deeper .Report validation: "
"`uv tool install pbir-cli`", None)
return
try:
proc = subprocess.run(
["pbir", "validate", str(report_dir), "--quiet"],
capture_output=True, text=True, timeout=60,
)
except subprocess.TimeoutExpired:
result.add(WARN, "pbir_cli_timeout",
"pbir validate timed out after 60s", report_dir)
return
except OSError as e:
result.add(WARN, "pbir_cli_error", f"pbir validate failed to spawn: {e}", report_dir)
return
result.pbir_cli_output = (proc.stdout or "") + (proc.stderr or "")
result.pbir_cli_exit = proc.returncode
if proc.returncode not in (0, 1):
result.add(ERROR, "pbir_cli_reported_errors",
"pbir validate reported errors (see output below)", report_dir)
elif proc.returncode == 1:
result.add(WARN, "pbir_cli_reported_warnings",
"pbir validate reported warnings (see output below)", report_dir)
#endregion
#region Fix mode
def ensure_gitignore(project_root: Path, result: Result, fix: bool) -> None:
"""Scaffold a minimal .gitignore if absent. Only pbip runtime state, no
implication that those files are required."""
gi = project_root / ".gitignore"
if gi.exists():
return
if fix:
gi.write_text(DEFAULT_GITIGNORE, encoding="utf-8")
result.fixes_applied.append(f"created {gi}")
else:
result.add(INFO, "gitignore_absent",
".gitignore not present. Suggested contents: "
"'**/.pbi/localSettings.json' and '**/.pbi/cache.abf'",
gi)
#endregion
#region Rendering
def render_text(result: Result, quiet: bool) -> str:
lines: list[str] = []
lines.append(f"PBIP Validation: {result.project_root}")
lines.append(f" type: {result.project_type}")
if result.report_dir:
lines.append(f" report: {result.report_dir.name}")
if result.semantic_model_dir:
lines.append(f" semantic model: {result.semantic_model_dir.name}")
lines.append("")
for f in result.findings:
if f.level == INFO and quiet:
continue
icon = {ERROR: "ERR ", WARN: "WARN", INFO: "info"}[f.level]
lines.append(f" {icon} [{f.code}] {f.message}")
if f.path:
lines.append(f" at {relpath(f.path, result.project_root)}")
for fix in result.fixes_applied:
lines.append(f" FIX {fix}")
if result.pbir_cli_output:
lines.append("")
lines.append("pbir validate output:")
lines.append("=" * 40)
lines.append(result.pbir_cli_output.rstrip())
lines.append("=" * 40)
n_err = len(result.errors())
n_warn = len(result.warnings())
lines.append("")
lines.append(f"Result: {n_err} error(s), {n_warn} warning(s)")
return "\n".join(lines)
def render_json(result: Result) -> str:
return json.dumps({
"project_root": str(result.project_root),
"project_type": result.project_type,
"report_dir": str(result.report_dir) if result.report_dir else None,
"semantic_model_dir": str(result.semantic_model_dir) if result.semantic_model_dir else None,
"findings": [
{"level": f.level, "code": f.code, "message": f.message, "path": f.path}
for f in result.findings
],
"fixes_applied": result.fixes_applied,
"pbir_cli_exit": result.pbir_cli_exit,
"pbir_cli_output": result.pbir_cli_output,
"error_count": len(result.errors()),
"warning_count": len(result.warnings()),
}, indent=2)
def relpath(path_str: str, base: Path) -> str:
try:
return str(Path(path_str).relative_to(base))
except ValueError:
return path_str
#endregion
#region Main
def main() -> int:
parser = argparse.ArgumentParser(description="Validate a Power BI Project (PBIP)")
parser.add_argument("path", help=".pbip file, .Report / .SemanticModel dir, or project root")
parser.add_argument("--fix", action="store_true",
help="scaffold missing files that are safe to create (currently only .gitignore)")
parser.add_argument("--json", action="store_true", dest="as_json",
help="machine-readable output")
parser.add_argument("--quiet", action="store_true", help="hide informational lines in text output")
parser.add_argument("--no-pbir-cli", action="store_true",
help="skip delegation to `pbir validate` for .Report folder validation")
args = parser.parse_args()
path = Path(args.path)
if not path.exists():
print(f"error: path does not exist: {path}", file=sys.stderr)
return 3
result = Result(project_root=path if path.is_dir() else path.parent)
discover(path, result)
if result.report_dir:
validate_report(result.report_dir, result)
if not args.no_pbir_cli:
run_pbir_validate(result.report_dir, result)
elif result.project_type != "semantic-model-only":
result.add(ERROR, "no_report_folder",
"PBIP must have a .Report folder (only .SemanticModel is optional)",
result.project_root)
if result.semantic_model_dir:
validate_semantic_model(result.semantic_model_dir, result)
ensure_gitignore(result.project_root, result, args.fix)
if args.as_json:
print(render_json(result))
else:
print(render_text(result, quiet=args.quiet))
if result.errors():
return 2
if result.warnings():
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#endregion