diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66610b9..30678be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.0.0] - 2026-03-27
+
+### Breaking
+- Removed MCP server dependency entirely (no more `powerbi-modeling-mcp` binary)
+- Removed `connect-fabric` command (future work)
+- Removed per-object TMDL export (`table export-tmdl`, `measure export-tmdl`, etc.) -- use `pbi database export-tmdl`
+- Removed `model refresh` command
+- Removed `security-role export-tmdl` -- use `pbi database export-tmdl`
+
+### Added
+- Direct pythonnet/.NET TOM interop (in-process, sub-second commands)
+- Bundled Microsoft Analysis Services DLLs (~20MB, no external download needed)
+- 2 new Claude Code skills: Diagnostics and Partitions & Expressions (7 total)
+- New commands: `trace start/stop/fetch/export`, `transaction begin/commit/rollback`, `calendar list/mark`, `expression list/get/create/delete`, `partition list/create/delete/refresh`, `advanced culture list/get`
+- `connections last` command to show last-used connection
+- `pbi connect` now auto-installs skills (no separate `pbi skills install` needed)
+
+### Changed
+- `pbi setup` now verifies pythonnet + bundled DLLs (no longer downloads a binary)
+- Architecture: Click CLI -> tom_backend/adomd_backend -> pythonnet -> .NET TOM (in-process)
+- All 7 skills updated to reflect v2 commands and architecture
+- README rewritten for v2 architecture
+
+### Removed
+- MCP client/server architecture
+- Binary manager and auto-download from VS Code Marketplace
+- `$PBI_MCP_BINARY` environment variable
+- `~/.pbi-cli/bin/` binary directory
+
## [1.0.6] - 2026-03-26
### Fixed
diff --git a/README.md b/README.md
index b974dd3..730eb60 100644
--- a/README.md
+++ b/README.md
@@ -13,13 +13,13 @@ Install once, then just ask Claude to work with your semantic models.
## What is this?
-pbi-cli gives **Claude Code** (and other AI agents) the ability to manage Power BI semantic models. It ships with 5 skills that Claude discovers automatically. You ask in plain English, Claude uses the right `pbi` commands.
+pbi-cli gives **Claude Code** (and other AI agents) the ability to manage Power BI semantic models. It ships with 7 skills that Claude discovers automatically. You ask in plain English, Claude uses the right `pbi` commands.
```mermaid
graph LR
A["You
'Add a YTD measure
to the Sales table'"] --> B["Claude Code
Uses Power BI skills"]
B --> C["pbi-cli"]
- C --> D["Power BI
Desktop / Fabric"]
+ C --> D["Power BI
Desktop"]
style A fill:#1a1a2e,stroke:#f2c811,color:#fff
style B fill:#16213e,stroke:#4cc9f0,color:#fff
@@ -41,14 +41,14 @@ Install and set up pbi-cli from https://github.com/MinaSaad1/pbi-cli.git
```bash
pipx install pbi-cli-tool # 1. Install (handles PATH automatically)
-pbi connect # 2. Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # 2. Auto-detects Power BI Desktop and installs skills
```
That's it. Open Power BI Desktop with a `.pbix` file, run `pbi connect`, and everything is set up automatically. Open Claude Code and start asking.
You can also specify the port manually: `pbi connect -d localhost:54321`
-> **Requires:** Python 3.10+ and Power BI Desktop (local) or a Fabric workspace (cloud).
+> **Requires:** Windows with Python 3.10+ and Power BI Desktop running.
Using pip instead of pipx?
@@ -79,7 +79,7 @@ Then **restart your terminal**. We recommend `pipx` instead to avoid this entire
## Skills
-After running `pbi skills install`, Claude Code discovers **5 Power BI skills**. Each skill teaches Claude a different area of Power BI development. You don't need to memorize commands. Just describe what you want.
+After running `pbi connect`, Claude Code discovers **7 Power BI skills**. Each skill teaches Claude a different area of Power BI development. You don't need to memorize commands. Just describe what you want.
```mermaid
graph TD
@@ -90,6 +90,8 @@ graph TD
SK --> S3["Deployment"]
SK --> S4["Security"]
SK --> S5["Documentation"]
+ SK --> S6["Diagnostics"]
+ SK --> S7["Partitions"]
style YOU fill:#1a1a2e,stroke:#f2c811,color:#fff
style CC fill:#16213e,stroke:#4cc9f0,color:#fff
@@ -99,6 +101,8 @@ graph TD
style S3 fill:#1a1a2e,stroke:#7b61ff,color:#fff
style S4 fill:#1a1a2e,stroke:#06d6a0,color:#fff
style S5 fill:#1a1a2e,stroke:#ff6b6b,color:#fff
+ style S6 fill:#1a1a2e,stroke:#ffd166,color:#fff
+ style S7 fill:#1a1a2e,stroke:#a0c4ff,color:#fff
```
### Modeling
@@ -144,9 +148,9 @@ TOPN(
### Deployment
-> *"Export the model to Git and deploy it to the Staging workspace"*
+> *"Export the model to Git for version control"*
-Claude exports your model as TMDL files for version control, then imports them into another environment. Handles transactions for safe multi-step changes.
+Claude exports your model as TMDL files for version control and imports them back. Handles transactions for safe multi-step changes.
Example: what Claude runs behind the scenes
@@ -154,9 +158,7 @@ Claude exports your model as TMDL files for version control, then imports them i
```bash
pbi database export-tmdl ./model/
# ... you commit to git ...
-pbi connect-fabric --workspace "Staging" --model "Sales Model"
pbi database import-tmdl ./model/
-pbi model refresh --type Full
```
@@ -164,7 +166,7 @@ pbi model refresh --type Full
> *"Set up row-level security so regional managers only see their region"*
-Claude creates RLS roles with descriptions, sets up perspectives for different user groups, and exports role definitions for version control.
+Claude creates RLS roles with descriptions, sets up perspectives for different user groups, and exports the model for version control.
Example: what Claude runs behind the scenes
@@ -173,7 +175,7 @@ Claude creates RLS roles with descriptions, sets up perspectives for different u
pbi security-role create "Regional Manager" --description "Users see only their region's data"
pbi perspective create "Executive Dashboard"
pbi perspective create "Regional Detail"
-pbi security-role export-tmdl "Regional Manager"
+pbi database export-tmdl ./model-backup/
```
@@ -196,20 +198,56 @@ pbi database export-tmdl ./model-docs/
```
+### Diagnostics
+
+> *"Why is this DAX query so slow?"*
+
+Claude traces query execution, clears caches for clean benchmarks, checks model health, and verifies the environment.
+
+
+Example: what Claude runs behind the scenes
+
+```bash
+pbi dax clear-cache
+pbi trace start
+pbi dax execute "EVALUATE SUMMARIZECOLUMNS(...)" --timeout 300
+pbi trace stop
+pbi trace export ./trace.json
+```
+
+
+### Partitions & Expressions
+
+> *"Set up partitions for incremental refresh on the Sales table"*
+
+Claude manages table partitions, shared M/Power Query expressions, and calendar table configuration.
+
+
+Example: what Claude runs behind the scenes
+
+```bash
+pbi partition list --table Sales
+pbi partition create "Sales_2024" --table Sales --expression "..." --mode Import
+pbi expression create "ServerURL" --expression '"https://api.example.com"'
+pbi calendar mark Calendar --date-column Date
+```
+
+
---
## All Commands
-22 command groups covering every Power BI MCP server operation. You rarely need these directly when using Claude Code, but they're available for scripting, CI/CD, or manual use.
+22 command groups covering the full Power BI Tabular Object Model. You rarely need these directly when using Claude Code, but they're available for scripting, CI/CD, or manual use.
| Category | Commands |
|----------|----------|
| **Queries** | `dax execute`, `dax validate`, `dax clear-cache` |
| **Model** | `table`, `column`, `measure`, `relationship`, `hierarchy`, `calc-group` |
-| **Deploy** | `database export-tmdl`, `database import-tmdl`, `database export-tmsl`, `model refresh`, `transaction` |
+| **Deploy** | `database export-tmdl`, `database import-tmdl`, `database export-tmsl`, `transaction` |
| **Security** | `security-role`, `perspective` |
-| **Connect** | `connect`, `connect-fabric`, `disconnect`, `connections list` |
-| **Other** | `partition`, `expression`, `calendar`, `trace`, `advanced`, `model get`, `model stats` |
+| **Connect** | `connect`, `disconnect`, `connections list`, `connections last` |
+| **Data** | `partition`, `expression`, `calendar`, `advanced culture` |
+| **Diagnostics** | `trace start`, `trace stop`, `trace fetch`, `trace export`, `model stats` |
| **Tools** | `setup`, `repl`, `skills install`, `skills list` |
Use `--json` for machine-readable output (for scripts and AI agents):
@@ -225,7 +263,7 @@ Run `pbi --help` for full options.
## REPL Mode
-For interactive work, the REPL keeps the MCP server running between commands (skipping the ~2-3s startup each time):
+For interactive work, the REPL keeps a persistent connection alive between commands:
```
$ pbi repl
@@ -244,22 +282,23 @@ Tab completion, command history, and a dynamic prompt showing your active connec
## How It Works
-pbi-cli wraps Microsoft's official Power BI MCP server binary behind a CLI. The binary is downloaded automatically on first `pbi connect` from the VS Code Marketplace.
+pbi-cli connects directly to Power BI Desktop's Analysis Services engine via pythonnet and the .NET Tabular Object Model (TOM). No external binaries or MCP servers needed. Everything runs in-process for sub-second command execution.
```mermaid
graph TB
- subgraph CLI["pbi-cli"]
- A["CLI commands"] --> B["MCP client"]
+ subgraph CLI["pbi-cli (Python)"]
+ A["Click CLI"] --> B["tom_backend / adomd_backend"]
+ B --> C["pythonnet"]
end
- B -->|"stdio"| C["Power BI MCP Server
.NET binary"]
- C -->|"XMLA"| D["Power BI Desktop
or Fabric"]
+ C -->|"in-process .NET"| D["Bundled TOM DLLs"]
+ D -->|"XMLA"| E["Power BI Desktop
msmdsrv.exe"]
style CLI fill:#16213e,stroke:#4cc9f0,color:#fff
- style C fill:#0f3460,stroke:#7b61ff,color:#fff
- style D fill:#1a1a2e,stroke:#f2c811,color:#fff
+ style D fill:#0f3460,stroke:#7b61ff,color:#fff
+ style E fill:#1a1a2e,stroke:#f2c811,color:#fff
```
-**Why a CLI wrapper?** When an AI agent uses an MCP server directly, the tool schemas consume ~4,000+ tokens per tool in the context window. A `pbi` command costs ~30 tokens. Same capabilities, 100x less context.
+**Why a CLI?** When an AI agent uses an MCP server directly, the tool schemas consume ~4,000+ tokens per tool in the context window. A `pbi` command costs ~30 tokens. Same capabilities, 100x less context.
Configuration details
@@ -268,16 +307,17 @@ All config lives in `~/.pbi-cli/`:
```
~/.pbi-cli/
- config.json # Binary version, path, args
+ config.json # Default connection preference
connections.json # Named connections
repl_history # REPL command history
- bin/{version}/ # MCP server binary
```
-Binary resolution order:
-1. `$PBI_MCP_BINARY` env var (explicit override)
-2. `~/.pbi-cli/bin/` (auto-downloaded on first connect)
-3. VS Code extension fallback
+Bundled DLLs ship inside the Python package (`pbi_cli/dlls/`):
+- Microsoft.AnalysisServices.Tabular.dll
+- Microsoft.AnalysisServices.AdomdClient.dll
+- Microsoft.AnalysisServices.Core.dll
+- Microsoft.AnalysisServices.Tabular.Json.dll
+- Microsoft.AnalysisServices.dll
@@ -294,7 +334,7 @@ pip install -e ".[dev]"
```bash
ruff check src/ tests/ # Lint
mypy src/ # Type check
-pytest -m "not e2e" # Run 120 tests
+pytest -m "not e2e" # Run tests
```
---
diff --git a/README.pypi.md b/README.pypi.md
index 7131bed..570f2f9 100644
--- a/README.pypi.md
+++ b/README.pypi.md
@@ -13,11 +13,11 @@ Install once, then just ask Claude to work with your semantic models.
## What is this?
-pbi-cli gives **Claude Code** (and other AI agents) the ability to manage Power BI semantic models. It ships with 5 skills that Claude discovers automatically. You ask in plain English, Claude uses the right `pbi` commands.
+pbi-cli gives **Claude Code** (and other AI agents) the ability to manage Power BI semantic models. It ships with 7 skills that Claude discovers automatically. You ask in plain English, Claude uses the right `pbi` commands.
```
You Claude Code pbi-cli Power BI
- "Add a YTD measure ---> Uses Power BI ---> CLI commands ---> Desktop / Fabric
+ "Add a YTD measure ---> Uses Power BI ---> CLI commands ---> Desktop
to the Sales table" skills
```
@@ -35,14 +35,14 @@ Install and set up pbi-cli from https://github.com/MinaSaad1/pbi-cli.git
```bash
pipx install pbi-cli-tool # 1. Install (handles PATH automatically)
-pbi connect # 2. Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # 2. Auto-detects Power BI Desktop and installs skills
```
That's it. Open Power BI Desktop with a `.pbix` file, run `pbi connect`, and everything is set up automatically. Open Claude Code and start asking.
You can also specify the port manually: `pbi connect -d localhost:54321`
-> **Requires:** Python 3.10+ and Power BI Desktop (local) or a Fabric workspace (cloud).
+> **Requires:** Windows with Python 3.10+ and Power BI Desktop running.
Using pip instead of pipx?
@@ -73,7 +73,7 @@ Then **restart your terminal**. We recommend `pipx` instead to avoid this entire
## Skills
-After running `pbi skills install`, Claude Code discovers **5 Power BI skills**. Each skill teaches Claude a different area of Power BI development. You don't need to memorize commands. Just describe what you want.
+After running `pbi connect`, Claude Code discovers **7 Power BI skills**. Each skill teaches Claude a different area of Power BI development. You don't need to memorize commands. Just describe what you want.
```
You: "Set up RLS for regional managers"
@@ -86,6 +86,8 @@ Claude Code --> Picks the right skill
+-- Deployment
+-- Security
+-- Documentation
+ +-- Diagnostics
+ +-- Partitions
```
### Modeling
@@ -131,9 +133,9 @@ TOPN(
### Deployment
-> *"Export the model to Git and deploy it to the Staging workspace"*
+> *"Export the model to Git for version control"*
-Claude exports your model as TMDL files for version control, then imports them into another environment. Handles transactions for safe multi-step changes.
+Claude exports your model as TMDL files for version control and imports them back. Handles transactions for safe multi-step changes.
Example: what Claude runs behind the scenes
@@ -141,9 +143,7 @@ Claude exports your model as TMDL files for version control, then imports them i
```bash
pbi database export-tmdl ./model/
# ... you commit to git ...
-pbi connect-fabric --workspace "Staging" --model "Sales Model"
pbi database import-tmdl ./model/
-pbi model refresh --type Full
```
@@ -151,7 +151,7 @@ pbi model refresh --type Full
> *"Set up row-level security so regional managers only see their region"*
-Claude creates RLS roles with descriptions, sets up perspectives for different user groups, and exports role definitions for version control.
+Claude creates RLS roles with descriptions, sets up perspectives for different user groups, and exports the model for version control.
Example: what Claude runs behind the scenes
@@ -160,7 +160,7 @@ Claude creates RLS roles with descriptions, sets up perspectives for different u
pbi security-role create "Regional Manager" --description "Users see only their region's data"
pbi perspective create "Executive Dashboard"
pbi perspective create "Regional Detail"
-pbi security-role export-tmdl "Regional Manager"
+pbi database export-tmdl ./model-backup/
```
@@ -183,20 +183,56 @@ pbi database export-tmdl ./model-docs/
```
+### Diagnostics
+
+> *"Why is this DAX query so slow?"*
+
+Claude traces query execution, clears caches for clean benchmarks, checks model health, and verifies the environment.
+
+
+Example: what Claude runs behind the scenes
+
+```bash
+pbi dax clear-cache
+pbi trace start
+pbi dax execute "EVALUATE SUMMARIZECOLUMNS(...)" --timeout 300
+pbi trace stop
+pbi trace export ./trace.json
+```
+
+
+### Partitions & Expressions
+
+> *"Set up partitions for incremental refresh on the Sales table"*
+
+Claude manages table partitions, shared M/Power Query expressions, and calendar table configuration.
+
+
+Example: what Claude runs behind the scenes
+
+```bash
+pbi partition list --table Sales
+pbi partition create "Sales_2024" --table Sales --expression "..." --mode Import
+pbi expression create "ServerURL" --expression '"https://api.example.com"'
+pbi calendar mark Calendar --date-column Date
+```
+
+
---
## All Commands
-22 command groups covering every Power BI MCP server operation. You rarely need these directly when using Claude Code, but they're available for scripting, CI/CD, or manual use.
+22 command groups covering the full Power BI Tabular Object Model. You rarely need these directly when using Claude Code, but they're available for scripting, CI/CD, or manual use.
| Category | Commands |
|----------|----------|
| **Queries** | `dax execute`, `dax validate`, `dax clear-cache` |
| **Model** | `table`, `column`, `measure`, `relationship`, `hierarchy`, `calc-group` |
-| **Deploy** | `database export-tmdl`, `database import-tmdl`, `database export-tmsl`, `model refresh`, `transaction` |
+| **Deploy** | `database export-tmdl`, `database import-tmdl`, `database export-tmsl`, `transaction` |
| **Security** | `security-role`, `perspective` |
-| **Connect** | `connect`, `connect-fabric`, `disconnect`, `connections list` |
-| **Other** | `partition`, `expression`, `calendar`, `trace`, `advanced`, `model get`, `model stats` |
+| **Connect** | `connect`, `disconnect`, `connections list`, `connections last` |
+| **Data** | `partition`, `expression`, `calendar`, `advanced culture` |
+| **Diagnostics** | `trace start`, `trace stop`, `trace fetch`, `trace export`, `model stats` |
| **Tools** | `setup`, `repl`, `skills install`, `skills list` |
Use `--json` for machine-readable output (for scripts and AI agents):
@@ -212,7 +248,7 @@ Run `pbi --help` for full options.
## REPL Mode
-For interactive work, the REPL keeps the MCP server running between commands (skipping the ~2-3s startup each time):
+For interactive work, the REPL keeps a persistent connection alive between commands:
```
$ pbi repl
@@ -231,17 +267,17 @@ Tab completion, command history, and a dynamic prompt showing your active connec
## How It Works
-pbi-cli wraps Microsoft's official Power BI MCP server binary behind a CLI. The binary is downloaded automatically on first `pbi connect` from the VS Code Marketplace.
+pbi-cli connects directly to Power BI Desktop's Analysis Services engine via pythonnet and the .NET Tabular Object Model (TOM). No external binaries or MCP servers needed. Everything runs in-process for sub-second command execution.
```
-+------------------+ +----------------------+ +------------------+
-| pbi-cli | | Power BI MCP Server | | Power BI |
-| CLI commands -->-| stdio | (.NET binary) | XMLA | Desktop/Fabric |
-| MCP client |-------->| |-------->| |
-+------------------+ +----------------------+ +------------------+
++------------------+ +---------------------+ +------------------+
+| pbi-cli | | Bundled TOM DLLs | | Power BI |
+| (Python CLI) | pythnet | (.NET in-process) | XMLA | Desktop |
+| Click commands |-------->| TOM / ADOMD.NET |-------->| msmdsrv.exe |
++------------------+ +---------------------+ +------------------+
```
-**Why a CLI wrapper?** When an AI agent uses an MCP server directly, the tool schemas consume ~4,000+ tokens per tool in the context window. A `pbi` command costs ~30 tokens. Same capabilities, 100x less context.
+**Why a CLI?** When an AI agent uses an MCP server directly, the tool schemas consume ~4,000+ tokens per tool in the context window. A `pbi` command costs ~30 tokens. Same capabilities, 100x less context.
Configuration details
@@ -250,16 +286,17 @@ All config lives in `~/.pbi-cli/`:
```
~/.pbi-cli/
- config.json # Binary version, path, args
+ config.json # Default connection preference
connections.json # Named connections
repl_history # REPL command history
- bin/{version}/ # MCP server binary
```
-Binary resolution order:
-1. `$PBI_MCP_BINARY` env var (explicit override)
-2. `~/.pbi-cli/bin/` (auto-downloaded on first connect)
-3. VS Code extension fallback
+Bundled DLLs ship inside the Python package (`pbi_cli/dlls/`):
+- Microsoft.AnalysisServices.Tabular.dll
+- Microsoft.AnalysisServices.AdomdClient.dll
+- Microsoft.AnalysisServices.Core.dll
+- Microsoft.AnalysisServices.Tabular.Json.dll
+- Microsoft.AnalysisServices.dll
@@ -276,7 +313,7 @@ pip install -e ".[dev]"
```bash
ruff check src/ tests/ # Lint
mypy src/ # Type check
-pytest -m "not e2e" # Run 120 tests
+pytest -m "not e2e" # Run tests
```
---
diff --git a/pyproject.toml b/pyproject.toml
index cce1e67..58452e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "pbi-cli-tool"
-version = "1.0.6"
-description = "CLI for Power BI semantic models - wraps the Power BI MCP server for token-efficient AI agent usage"
+version = "2.0.0"
+description = "CLI for Power BI semantic models - direct .NET connection for token-efficient AI agent usage"
readme = "README.pypi.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "pbi-cli contributors"},
]
-keywords = ["power-bi", "cli", "mcp", "semantic-model", "dax", "claude-code"]
+keywords = ["power-bi", "cli", "semantic-model", "dax", "claude-code", "tom"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
@@ -29,10 +29,10 @@ classifiers = [
]
dependencies = [
"click>=8.0.0",
- "mcp>=1.20.0",
"rich>=13.0.0",
- "httpx>=0.24.0",
"prompt-toolkit>=3.0.0",
+ "pythonnet==3.1.0rc0",
+ "clr-loader>=0.2.6",
]
[project.scripts]
@@ -47,7 +47,6 @@ Issues = "https://github.com/MinaSaad1/pbi-cli/issues"
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
- "pytest-asyncio>=0.21",
"ruff>=0.4.0",
"mypy>=1.10",
]
@@ -57,6 +56,7 @@ where = ["src"]
[tool.setuptools.package-data]
"pbi_cli.skills" = ["**/*.md"]
+"pbi_cli.dlls" = ["*.dll"]
[tool.ruff]
target-version = "py310"
@@ -65,10 +65,19 @@ line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
+[tool.ruff.lint.per-file-ignores]
+# .NET interop code uses CamelCase names to match the .NET API surface
+"src/pbi_cli/core/adomd_backend.py" = ["N806"]
+"src/pbi_cli/core/session.py" = ["N806"]
+"src/pbi_cli/core/tom_backend.py" = ["N806", "N814"]
+"src/pbi_cli/core/dotnet_loader.py" = ["N806", "N814"]
+# Mock objects mirror .NET CamelCase API
+"tests/conftest.py" = ["N802", "N806"]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
- "e2e: end-to-end tests requiring real Power BI binary",
+ "e2e: end-to-end tests requiring running Power BI Desktop",
]
[tool.mypy]
diff --git a/src/pbi_cli/__init__.py b/src/pbi_cli/__init__.py
index 71c4ae9..25c669a 100644
--- a/src/pbi_cli/__init__.py
+++ b/src/pbi_cli/__init__.py
@@ -1,3 +1,3 @@
-"""pbi-cli: CLI for Power BI semantic models via MCP server."""
+"""pbi-cli: CLI for Power BI semantic models via direct .NET interop."""
-__version__ = "1.0.6"
+__version__ = "2.0.0"
diff --git a/src/pbi_cli/commands/_helpers.py b/src/pbi_cli/commands/_helpers.py
index f7cfbc0..9fbd101 100644
--- a/src/pbi_cli/commands/_helpers.py
+++ b/src/pbi_cli/commands/_helpers.py
@@ -2,100 +2,35 @@
from __future__ import annotations
-from typing import Any
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any
-from pbi_cli.core.errors import McpToolError
-from pbi_cli.core.mcp_client import PbiMcpClient, get_client
-from pbi_cli.core.output import format_mcp_result, print_error
-from pbi_cli.main import PbiContext
+from pbi_cli.core.errors import TomError
+from pbi_cli.core.output import format_result, print_error
+
+if TYPE_CHECKING:
+ from pbi_cli.main import PbiContext
-def resolve_connection_name(ctx: PbiContext) -> str | None:
- """Return the connection name from --connection flag or last-used store."""
- if ctx.connection:
- return ctx.connection
- from pbi_cli.core.connection_store import load_connections
-
- store = load_connections()
- return store.last_used or None
-
-
-def _auto_reconnect(client: PbiMcpClient, ctx: PbiContext) -> str | None:
- """Re-establish the saved connection on a fresh MCP server process.
-
- Each non-REPL command starts a new MCP server, so the connection
- must be re-established before running any tool that needs one.
- Returns the connection name, or None if no saved connection exists.
- """
- from pbi_cli.core.connection_store import (
- get_active_connection,
- load_connections,
- )
-
- store = load_connections()
- conn = get_active_connection(store, override=ctx.connection)
- if conn is None:
- return None
-
- # Build the appropriate Connect request
- if conn.workspace_name:
- request: dict[str, object] = {
- "operation": "ConnectFabric",
- "workspaceName": conn.workspace_name,
- "semanticModelName": conn.semantic_model_name,
- "tenantName": conn.tenant_name,
- }
- else:
- request = {
- "operation": "Connect",
- "dataSource": conn.data_source,
- }
- if conn.initial_catalog:
- request["initialCatalog"] = conn.initial_catalog
- if conn.connection_string:
- request["connectionString"] = conn.connection_string
-
- result = client.call_tool("connection_operations", request)
-
- # Use server-assigned connection name (e.g. "PBIDesktop-demo-57947")
- # instead of our locally saved name (e.g. "localhost-57947")
- server_name = None
- if isinstance(result, dict):
- server_name = result.get("connectionName") or result.get("ConnectionName")
- return server_name or conn.name
-
-
-def run_tool(
+def run_command(
ctx: PbiContext,
- tool_name: str,
- request: dict[str, Any],
+ fn: Callable[..., Any],
+ **kwargs: Any,
) -> Any:
- """Execute an MCP tool call with standard error handling.
+ """Execute a backend function with standard error handling.
- In non-REPL mode, automatically re-establishes the saved connection
- before running the tool (each invocation starts a fresh MCP server).
- Formats output based on --json flag. Returns the result or exits on error.
+ Calls ``fn(**kwargs)`` and formats the output based on the
+ ``--json`` flag. Returns the result or exits on error.
"""
- client = get_client(repl_mode=ctx.repl_mode)
try:
- # In non-REPL mode, re-establish connection on the fresh server
- if not ctx.repl_mode:
- conn_name = _auto_reconnect(client, ctx)
- else:
- conn_name = resolve_connection_name(ctx)
-
- if conn_name:
- request.setdefault("connectionName", conn_name)
-
- result = client.call_tool(tool_name, request)
- format_mcp_result(result, ctx.json_output)
+ result = fn(**kwargs)
+ format_result(result, ctx.json_output)
return result
except Exception as e:
print_error(str(e))
- raise McpToolError(tool_name, str(e))
- finally:
if not ctx.repl_mode:
- client.stop()
+ raise SystemExit(1)
+ raise TomError(fn.__name__, str(e))
def build_definition(
diff --git a/src/pbi_cli/commands/advanced.py b/src/pbi_cli/commands/advanced.py
index b9ed8e1..6083ff6 100644
--- a/src/pbi_cli/commands/advanced.py
+++ b/src/pbi_cli/commands/advanced.py
@@ -1,16 +1,16 @@
-"""Less common operations: culture, translation, function, query-group."""
+"""Less common operations: culture, translation."""
from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@click.group()
def advanced() -> None:
- """Advanced operations: cultures, translations, functions, query groups."""
+ """Advanced operations: cultures, translations."""
# --- Culture ---
@@ -25,7 +25,11 @@ def culture() -> None:
@pass_context
def culture_list(ctx: PbiContext) -> None:
"""List cultures."""
- run_tool(ctx, "culture_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import culture_list as _culture_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _culture_list, model=session.model)
@culture.command()
@@ -33,7 +37,11 @@ def culture_list(ctx: PbiContext) -> None:
@pass_context
def culture_create(ctx: PbiContext, name: str) -> None:
"""Create a culture."""
- run_tool(ctx, "culture_operations", {"operation": "Create", "definitions": [{"name": name}]})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import culture_create as _culture_create
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _culture_create, model=session.model, name=name)
@culture.command(name="delete")
@@ -41,110 +49,8 @@ def culture_create(ctx: PbiContext, name: str) -> None:
@pass_context
def culture_delete(ctx: PbiContext, name: str) -> None:
"""Delete a culture."""
- run_tool(ctx, "culture_operations", {"operation": "Delete", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import culture_delete as _culture_delete
-
-# --- Translation ---
-
-
-@advanced.group()
-def translation() -> None:
- """Manage object translations."""
-
-
-@translation.command(name="list")
-@click.option("--culture", "-c", required=True, help="Culture name.")
-@pass_context
-def translation_list(ctx: PbiContext, culture: str) -> None:
- """List translations for a culture."""
- run_tool(ctx, "object_translation_operations", {"operation": "List", "cultureName": culture})
-
-
-@translation.command()
-@click.option("--culture", "-c", required=True, help="Culture name.")
-@click.option("--object-name", required=True, help="Object to translate.")
-@click.option("--table", "-t", default=None, help="Table name (if translating table object).")
-@click.option("--translated-caption", default=None, help="Translated caption.")
-@click.option("--translated-description", default=None, help="Translated description.")
-@pass_context
-def create(
- ctx: PbiContext,
- culture: str,
- object_name: str,
- table: str | None,
- translated_caption: str | None,
- translated_description: str | None,
-) -> None:
- """Create an object translation."""
- definition = build_definition(
- required={"objectName": object_name, "cultureName": culture},
- optional={
- "tableName": table,
- "translatedCaption": translated_caption,
- "translatedDescription": translated_description,
- },
- )
- run_tool(
- ctx,
- "object_translation_operations",
- {
- "operation": "Create",
- "definitions": [definition],
- },
- )
-
-
-# --- Function ---
-
-
-@advanced.group()
-def function() -> None:
- """Manage model functions."""
-
-
-@function.command(name="list")
-@pass_context
-def function_list(ctx: PbiContext) -> None:
- """List functions."""
- run_tool(ctx, "function_operations", {"operation": "List"})
-
-
-@function.command()
-@click.argument("name")
-@click.option("--expression", "-e", required=True, help="Function expression.")
-@pass_context
-def function_create(ctx: PbiContext, name: str, expression: str) -> None:
- """Create a function."""
- run_tool(
- ctx,
- "function_operations",
- {
- "operation": "Create",
- "definitions": [{"name": name, "expression": expression}],
- },
- )
-
-
-# --- Query Group ---
-
-
-@advanced.group(name="query-group")
-def query_group() -> None:
- """Manage query groups."""
-
-
-@query_group.command(name="list")
-@pass_context
-def qg_list(ctx: PbiContext) -> None:
- """List query groups."""
- run_tool(ctx, "query_group_operations", {"operation": "List"})
-
-
-@query_group.command()
-@click.argument("name")
-@click.option("--folder", default=None, help="Folder path.")
-@pass_context
-def qg_create(ctx: PbiContext, name: str, folder: str | None) -> None:
- """Create a query group."""
- definition = build_definition(required={"name": name}, optional={"folder": folder})
- run_tool(ctx, "query_group_operations", {"operation": "Create", "definitions": [definition]})
+ session = get_session_for_command(ctx)
+ run_command(ctx, _culture_delete, model=session.model, name=name)
diff --git a/src/pbi_cli/commands/calc_group.py b/src/pbi_cli/commands/calc_group.py
index 8698725..7815e00 100644
--- a/src/pbi_cli/commands/calc_group.py
+++ b/src/pbi_cli/commands/calc_group.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def calc_group() -> None:
@pass_context
def cg_list(ctx: PbiContext) -> None:
"""List all calculation groups."""
- run_tool(ctx, "calculation_group_operations", {"operation": "ListGroups"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import calc_group_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, calc_group_list, model=session.model)
@calc_group.command()
@@ -27,17 +31,17 @@ def cg_list(ctx: PbiContext) -> None:
@pass_context
def create(ctx: PbiContext, name: str, description: str | None, precedence: int | None) -> None:
"""Create a calculation group."""
- definition = build_definition(
- required={"name": name},
- optional={"description": description, "calculationGroupPrecedence": precedence},
- )
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import calc_group_create
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "calculation_group_operations",
- {
- "operation": "CreateGroup",
- "definitions": [definition],
- },
+ calc_group_create,
+ model=session.model,
+ name=name,
+ description=description,
+ precedence=precedence,
)
@@ -46,7 +50,11 @@ def create(ctx: PbiContext, name: str, description: str | None, precedence: int
@pass_context
def delete(ctx: PbiContext, name: str) -> None:
"""Delete a calculation group."""
- run_tool(ctx, "calculation_group_operations", {"operation": "DeleteGroup", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import calc_group_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, calc_group_delete, model=session.model, name=name)
@calc_group.command(name="items")
@@ -54,14 +62,11 @@ def delete(ctx: PbiContext, name: str) -> None:
@pass_context
def list_items(ctx: PbiContext, group_name: str) -> None:
"""List calculation items in a group."""
- run_tool(
- ctx,
- "calculation_group_operations",
- {
- "operation": "ListItems",
- "calculationGroupName": group_name,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import calc_item_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, calc_item_list, model=session.model, group_name=group_name)
@calc_group.command(name="create-item")
@@ -74,16 +79,16 @@ def create_item(
ctx: PbiContext, item_name: str, group: str, expression: str, ordinal: int | None
) -> None:
"""Create a calculation item in a group."""
- definition = build_definition(
- required={"name": item_name, "expression": expression},
- optional={"ordinal": ordinal},
- )
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import calc_item_create
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "calculation_group_operations",
- {
- "operation": "CreateItem",
- "calculationGroupName": group,
- "definitions": [definition],
- },
+ calc_item_create,
+ model=session.model,
+ group_name=group,
+ name=item_name,
+ expression=expression,
+ ordinal=ordinal,
)
diff --git a/src/pbi_cli/commands/calendar.py b/src/pbi_cli/commands/calendar.py
index 0b48d00..37f26dd 100644
--- a/src/pbi_cli/commands/calendar.py
+++ b/src/pbi_cli/commands/calendar.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -16,27 +16,41 @@ def calendar() -> None:
@calendar.command(name="list")
@pass_context
def calendar_list(ctx: PbiContext) -> None:
- """List calendar tables."""
- run_tool(ctx, "calendar_operations", {"operation": "List"})
+ """List calendar/date tables (tables with DataCategory = Time)."""
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import _safe_str
+
+ session = get_session_for_command(ctx)
+ # Filter to tables marked as date/calendar tables
+ # Check DataCategory on the actual TOM objects
+ results = []
+ for table in session.model.Tables:
+ cat = _safe_str(table.DataCategory)
+ if cat.lower() in ("time", "date"):
+ results.append({
+ "name": str(table.Name),
+ "dataCategory": cat,
+ "columns": table.Columns.Count,
+ })
+ from pbi_cli.core.output import format_result
+
+ format_result(results, ctx.json_output)
-@calendar.command()
+@calendar.command(name="mark")
@click.argument("name")
-@click.option("--table", "-t", required=True, help="Target table name.")
-@click.option("--description", default=None, help="Calendar description.")
+@click.option("--date-column", required=True, help="Date column to use as key.")
@pass_context
-def create(ctx: PbiContext, name: str, table: str, description: str | None) -> None:
- """Create a calendar table."""
- definition = build_definition(
- required={"name": name, "tableName": table},
- optional={"description": description},
+def mark(ctx: PbiContext, name: str, date_column: str) -> None:
+ """Mark a table as a calendar/date table."""
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_mark_as_date
+
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ table_mark_as_date,
+ model=session.model,
+ table_name=name,
+ date_column=date_column,
)
- run_tool(ctx, "calendar_operations", {"operation": "Create", "definitions": [definition]})
-
-
-@calendar.command()
-@click.argument("name")
-@pass_context
-def delete(ctx: PbiContext, name: str) -> None:
- """Delete a calendar."""
- run_tool(ctx, "calendar_operations", {"operation": "Delete", "name": name})
diff --git a/src/pbi_cli/commands/column.py b/src/pbi_cli/commands/column.py
index 559a390..23735d4 100644
--- a/src/pbi_cli/commands/column.py
+++ b/src/pbi_cli/commands/column.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -18,7 +18,11 @@ def column() -> None:
@pass_context
def column_list(ctx: PbiContext, table: str) -> None:
"""List all columns in a table."""
- run_tool(ctx, "column_operations", {"operation": "List", "tableName": table})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import column_list as _column_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _column_list, model=session.model, table_name=table)
@column.command()
@@ -27,7 +31,11 @@ def column_list(ctx: PbiContext, table: str) -> None:
@pass_context
def get(ctx: PbiContext, name: str, table: str) -> None:
"""Get details of a specific column."""
- run_tool(ctx, "column_operations", {"operation": "Get", "name": name, "tableName": table})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import column_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, column_get, model=session.model, table_name=table, column_name=name)
@column.command()
@@ -58,19 +66,25 @@ def create(
is_key: bool,
) -> None:
"""Create a new column."""
- definition = build_definition(
- required={"name": name, "tableName": table, "dataType": data_type},
- optional={
- "sourceColumn": source_column,
- "expression": expression,
- "formatString": format_string,
- "description": description,
- "displayFolder": folder,
- "isHidden": hidden if hidden else None,
- "isKey": is_key if is_key else None,
- },
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import column_create
+
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ column_create,
+ model=session.model,
+ table_name=table,
+ name=name,
+ data_type=data_type,
+ source_column=source_column,
+ expression=expression,
+ format_string=format_string,
+ description=description,
+ display_folder=folder,
+ is_hidden=hidden,
+ is_key=is_key,
)
- run_tool(ctx, "column_operations", {"operation": "Create", "definitions": [definition]})
@column.command()
@@ -79,7 +93,11 @@ def create(
@pass_context
def delete(ctx: PbiContext, name: str, table: str) -> None:
"""Delete a column."""
- run_tool(ctx, "column_operations", {"operation": "Delete", "name": name, "tableName": table})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import column_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, column_delete, model=session.model, table_name=table, column_name=name)
@column.command()
@@ -89,30 +107,15 @@ def delete(ctx: PbiContext, name: str, table: str) -> None:
@pass_context
def rename(ctx: PbiContext, old_name: str, new_name: str, table: str) -> None:
"""Rename a column."""
- run_tool(
- ctx,
- "column_operations",
- {
- "operation": "Rename",
- "name": old_name,
- "newName": new_name,
- "tableName": table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import column_rename
-
-@column.command(name="export-tmdl")
-@click.argument("name")
-@click.option("--table", "-t", required=True, help="Table name.")
-@pass_context
-def export_tmdl(ctx: PbiContext, name: str, table: str) -> None:
- """Export a column as TMDL."""
- run_tool(
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "column_operations",
- {
- "operation": "ExportTMDL",
- "name": name,
- "tableName": table,
- },
+ column_rename,
+ model=session.model,
+ table_name=table,
+ old_name=old_name,
+ new_name=new_name,
)
diff --git a/src/pbi_cli/commands/connection.py b/src/pbi_cli/commands/connection.py
index 8b26dbd..4ee24f4 100644
--- a/src/pbi_cli/commands/connection.py
+++ b/src/pbi_cli/commands/connection.py
@@ -12,9 +12,9 @@ from pbi_cli.core.connection_store import (
remove_connection,
save_connections,
)
-from pbi_cli.core.mcp_client import get_client
from pbi_cli.core.output import (
print_error,
+ print_info,
print_json,
print_success,
print_table,
@@ -33,16 +33,12 @@ from pbi_cli.main import PbiContext, pass_context
@click.option(
"--name", "-n", default=None, help="Name for this connection (auto-generated if omitted)."
)
-@click.option(
- "--connection-string", default="", help="Full connection string (overrides data-source)."
-)
@pass_context
def connect(
ctx: PbiContext,
data_source: str | None,
catalog: str,
name: str | None,
- connection_string: str,
) -> None:
"""Connect to a Power BI instance via data source.
@@ -53,98 +49,34 @@ def connect(
if data_source is None:
data_source = _auto_discover_data_source()
- conn_name = name or _auto_name(data_source)
+ from pbi_cli.core.session import connect as session_connect
- request: dict[str, object] = {
- "operation": "Connect",
- "dataSource": data_source,
- }
- if catalog:
- request["initialCatalog"] = catalog
- if connection_string:
- request["connectionString"] = connection_string
-
- repl = ctx.repl_mode
- client = get_client(repl_mode=repl)
try:
- result = client.call_tool("connection_operations", request)
+ session = session_connect(data_source, catalog)
- # Use server-returned connectionName if available, otherwise our local name
- server_name = _extract_connection_name(result)
- effective_name = server_name or conn_name
+ # Use provided name, or the session's auto-generated name
+ effective_name = name or session.connection_name
info = ConnectionInfo(
name=effective_name,
data_source=data_source,
initial_catalog=catalog,
- connection_string=connection_string,
)
store = load_connections()
store = add_connection(store, info)
save_connections(store)
if ctx.json_output:
- print_json({"connection": effective_name, "status": "connected", "result": result})
+ print_json({
+ "connection": effective_name,
+ "status": "connected",
+ "dataSource": data_source,
+ })
else:
print_success(f"Connected: {effective_name} ({data_source})")
except Exception as e:
print_error(f"Connection failed: {e}")
raise SystemExit(1)
- finally:
- if not repl:
- client.stop()
-
-
-@click.command(name="connect-fabric")
-@click.option("--workspace", "-w", required=True, help="Fabric workspace name (exact match).")
-@click.option("--model", "-m", required=True, help="Semantic model name (exact match).")
-@click.option("--name", "-n", default=None, help="Name for this connection.")
-@click.option("--tenant", default="myorg", help="Tenant name for B2B scenarios.")
-@pass_context
-def connect_fabric(
- ctx: PbiContext, workspace: str, model: str, name: str | None, tenant: str
-) -> None:
- """Connect to a Fabric workspace semantic model."""
- _ensure_ready()
-
- conn_name = name or f"{workspace}/{model}"
-
- request: dict[str, object] = {
- "operation": "ConnectFabric",
- "workspaceName": workspace,
- "semanticModelName": model,
- "tenantName": tenant,
- }
-
- repl = ctx.repl_mode
- client = get_client(repl_mode=repl)
- try:
- result = client.call_tool("connection_operations", request)
-
- server_name = _extract_connection_name(result)
- effective_name = server_name or conn_name
-
- info = ConnectionInfo(
- name=effective_name,
- data_source=f"powerbi://api.powerbi.com/v1.0/{tenant}/{workspace}",
- workspace_name=workspace,
- semantic_model_name=model,
- tenant_name=tenant,
- )
- store = load_connections()
- store = add_connection(store, info)
- save_connections(store)
-
- if ctx.json_output:
- print_json({"connection": effective_name, "status": "connected", "result": result})
- else:
- print_success(f"Connected to Fabric: {workspace}/{model}")
- except Exception as e:
- print_error(f"Fabric connection failed: {e}")
- raise SystemExit(1)
- finally:
- if not repl:
- client.stop()
@click.command()
@@ -154,6 +86,8 @@ def connect_fabric(
@pass_context
def disconnect(ctx: PbiContext, name: str | None) -> None:
"""Disconnect from the active or named connection."""
+ from pbi_cli.core.session import disconnect as session_disconnect
+
store = load_connections()
target = name or store.last_used
@@ -161,30 +95,15 @@ def disconnect(ctx: PbiContext, name: str | None) -> None:
print_error("No active connection to disconnect.")
raise SystemExit(1)
- repl = ctx.repl_mode
- client = get_client(repl_mode=repl)
- try:
- client.call_tool(
- "connection_operations",
- {
- "operation": "Disconnect",
- "connectionName": target,
- },
- )
+ session_disconnect()
- store = remove_connection(store, target)
- save_connections(store)
+ store = remove_connection(store, target)
+ save_connections(store)
- if ctx.json_output:
- print_json({"connection": target, "status": "disconnected"})
- else:
- print_success(f"Disconnected: {target}")
- except Exception as e:
- print_error(f"Disconnect failed: {e}")
- raise SystemExit(1)
- finally:
- if not repl:
- client.stop()
+ if ctx.json_output:
+ print_json({"connection": target, "status": "disconnected"})
+ else:
+ print_success(f"Disconnected: {target}")
@click.group()
@@ -249,11 +168,7 @@ def connections_last(ctx: PbiContext) -> None:
def _auto_discover_data_source() -> str:
- """Auto-detect a running Power BI Desktop instance.
-
- Raises click.ClickException if no instance is found.
- """
- from pbi_cli.core.output import print_info
+ """Auto-detect a running Power BI Desktop instance."""
from pbi_cli.utils.platform import discover_pbi_port
port = discover_pbi_port()
@@ -270,30 +185,17 @@ def _auto_discover_data_source() -> str:
def _ensure_ready() -> None:
- """Auto-setup binary and skills if not already done.
+ """Auto-install skills if not already done.
Lets users go straight from install to connect in one step:
pipx install pbi-cli-tool
pbi connect -d localhost:54321
"""
- from pbi_cli.core.binary_manager import resolve_binary
-
- try:
- resolve_binary()
- except FileNotFoundError:
- from pbi_cli.core.binary_manager import download_and_extract
- from pbi_cli.core.output import print_info
-
- print_info("MCP binary not found. Running first-time setup...")
- download_and_extract()
-
from pbi_cli.commands.skills_cmd import SKILLS_TARGET_DIR, _get_bundled_skills
bundled = _get_bundled_skills()
any_missing = any(not (SKILLS_TARGET_DIR / name / "SKILL.md").exists() for name in bundled)
if bundled and any_missing:
- from pbi_cli.core.output import print_info
-
print_info("Installing Claude Code skills...")
for name, source in sorted(bundled.items()):
target_dir = SKILLS_TARGET_DIR / name
@@ -304,16 +206,3 @@ def _ensure_ready() -> None:
target_file = target_dir / "SKILL.md"
target_file.write_text(source_file.read_text(encoding="utf-8"), encoding="utf-8")
print_info("Skills installed.")
-
-
-def _extract_connection_name(result: object) -> str | None:
- """Extract connectionName from MCP server response, if present."""
- if isinstance(result, dict):
- return result.get("connectionName") or result.get("ConnectionName")
- return None
-
-
-def _auto_name(data_source: str) -> str:
- """Generate a connection name from a data source string."""
- cleaned = data_source.replace("://", "-").replace("/", "-").replace(":", "-")
- return cleaned[:50]
diff --git a/src/pbi_cli/commands/database.py b/src/pbi_cli/commands/database.py
index d3238e1..7e81f9d 100644
--- a/src/pbi_cli/commands/database.py
+++ b/src/pbi_cli/commands/database.py
@@ -1,10 +1,10 @@
-"""Database-level operations: list, TMDL import/export, Fabric deploy."""
+"""Database-level operations: list, TMDL import/export, TMSL export."""
from __future__ import annotations
import click
-from pbi_cli.commands._helpers import run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def database() -> None:
@pass_context
def database_list(ctx: PbiContext) -> None:
"""List all databases on the connected server."""
- run_tool(ctx, "database_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import database_list as _database_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _database_list, server=session.server)
@database.command(name="import-tmdl")
@@ -25,14 +29,11 @@ def database_list(ctx: PbiContext) -> None:
@pass_context
def import_tmdl(ctx: PbiContext, folder_path: str) -> None:
"""Import a model from a TMDL folder."""
- run_tool(
- ctx,
- "database_operations",
- {
- "operation": "ImportFromTmdlFolder",
- "tmdlFolderPath": folder_path,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import import_tmdl as _import_tmdl
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _import_tmdl, server=session.server, folder_path=folder_path)
@database.command(name="export-tmdl")
@@ -40,41 +41,19 @@ def import_tmdl(ctx: PbiContext, folder_path: str) -> None:
@pass_context
def export_tmdl(ctx: PbiContext, folder_path: str) -> None:
"""Export the model to a TMDL folder."""
- run_tool(
- ctx,
- "database_operations",
- {
- "operation": "ExportToTmdlFolder",
- "tmdlFolderPath": folder_path,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import export_tmdl as _export_tmdl
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _export_tmdl, database=session.database, folder_path=folder_path)
@database.command(name="export-tmsl")
@pass_context
def export_tmsl(ctx: PbiContext) -> None:
"""Export the model as TMSL."""
- run_tool(ctx, "database_operations", {"operation": "ExportTMSL"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import export_tmsl as _export_tmsl
-
-@database.command()
-@click.option("--workspace", "-w", required=True, help="Target Fabric workspace name.")
-@click.option("--new-name", default=None, help="New database name in target workspace.")
-@click.option("--tenant", default=None, help="Tenant name for B2B scenarios.")
-@pass_context
-def deploy(ctx: PbiContext, workspace: str, new_name: str | None, tenant: str | None) -> None:
- """Deploy the model to a Fabric workspace."""
- deploy_request: dict[str, object] = {"targetWorkspaceName": workspace}
- if new_name:
- deploy_request["newDatabaseName"] = new_name
- if tenant:
- deploy_request["targetTenantName"] = tenant
-
- run_tool(
- ctx,
- "database_operations",
- {
- "operation": "DeployToFabric",
- "deployToFabricRequest": deploy_request,
- },
- )
+ session = get_session_for_command(ctx)
+ run_command(ctx, _export_tmsl, database=session.database)
diff --git a/src/pbi_cli/commands/dax.py b/src/pbi_cli/commands/dax.py
index 07c99fc..0a54279 100644
--- a/src/pbi_cli/commands/dax.py
+++ b/src/pbi_cli/commands/dax.py
@@ -6,9 +6,8 @@ import sys
import click
-from pbi_cli.commands._helpers import _auto_reconnect, resolve_connection_name
-from pbi_cli.core.mcp_client import get_client
-from pbi_cli.core.output import format_mcp_result, print_error
+from pbi_cli.commands._helpers import run_command
+from pbi_cli.core.output import print_error
from pbi_cli.main import PbiContext, pass_context
@@ -23,10 +22,6 @@ def dax() -> None:
"--file", "-f", "query_file", type=click.Path(exists=True), help="Read query from file."
)
@click.option("--max-rows", type=int, default=None, help="Maximum rows to return.")
-@click.option("--metrics", is_flag=True, default=False, help="Include execution metrics.")
-@click.option(
- "--metrics-only", is_flag=True, default=False, help="Return metrics without row data."
-)
@click.option("--timeout", type=int, default=200, help="Query timeout in seconds.")
@pass_context
def execute(
@@ -34,8 +29,6 @@ def execute(
query: str,
query_file: str | None,
max_rows: int | None,
- metrics: bool,
- metrics_only: bool,
timeout: int,
) -> None:
"""Execute a DAX query.
@@ -53,33 +46,18 @@ def execute(
print_error("No query provided. Pass as argument, --file, or stdin.")
raise SystemExit(1)
- request: dict[str, object] = {
- "operation": "Execute",
- "query": resolved_query,
- "timeoutSeconds": timeout,
- "getExecutionMetrics": metrics or metrics_only,
- "executionMetricsOnly": metrics_only,
- }
- if max_rows is not None:
- request["maxRows"] = max_rows
+ from pbi_cli.core.adomd_backend import execute_dax
+ from pbi_cli.core.session import get_session_for_command
- client = get_client()
- try:
- if not ctx.repl_mode:
- conn_name = _auto_reconnect(client, ctx)
- else:
- conn_name = resolve_connection_name(ctx)
- if conn_name:
- request["connectionName"] = conn_name
-
- result = client.call_tool("dax_query_operations", request)
- format_mcp_result(result, ctx.json_output)
- except Exception as e:
- print_error(f"DAX execution failed: {e}")
- raise SystemExit(1)
- finally:
- if not ctx.repl_mode:
- client.stop()
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ execute_dax,
+ adomd_connection=session.adomd_connection,
+ query=resolved_query,
+ max_rows=max_rows,
+ timeout=timeout,
+ )
@dax.command()
@@ -96,54 +74,29 @@ def validate(ctx: PbiContext, query: str, query_file: str | None, timeout: int)
print_error("No query provided.")
raise SystemExit(1)
- request: dict[str, object] = {
- "operation": "Validate",
- "query": resolved_query,
- "timeoutSeconds": timeout,
- }
+ from pbi_cli.core.adomd_backend import validate_dax
+ from pbi_cli.core.session import get_session_for_command
- client = get_client()
- try:
- if not ctx.repl_mode:
- conn_name = _auto_reconnect(client, ctx)
- else:
- conn_name = resolve_connection_name(ctx)
- if conn_name:
- request["connectionName"] = conn_name
-
- result = client.call_tool("dax_query_operations", request)
- format_mcp_result(result, ctx.json_output)
- except Exception as e:
- print_error(f"DAX validation failed: {e}")
- raise SystemExit(1)
- finally:
- if not ctx.repl_mode:
- client.stop()
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ validate_dax,
+ adomd_connection=session.adomd_connection,
+ query=resolved_query,
+ timeout=timeout,
+ )
@dax.command(name="clear-cache")
@pass_context
-def clear_cache(ctx: PbiContext) -> None:
+def clear_cache_cmd(ctx: PbiContext) -> None:
"""Clear the DAX query cache."""
- request: dict[str, object] = {"operation": "ClearCache"}
+ from pbi_cli.core.adomd_backend import clear_cache
+ from pbi_cli.core.session import get_session_for_command
- client = get_client()
- try:
- if not ctx.repl_mode:
- conn_name = _auto_reconnect(client, ctx)
- else:
- conn_name = resolve_connection_name(ctx)
- if conn_name:
- request["connectionName"] = conn_name
-
- result = client.call_tool("dax_query_operations", request)
- format_mcp_result(result, ctx.json_output)
- except Exception as e:
- print_error(f"Cache clear failed: {e}")
- raise SystemExit(1)
- finally:
- if not ctx.repl_mode:
- client.stop()
+ session = get_session_for_command(ctx)
+ db_id = str(session.database.ID) if session.database else ""
+ run_command(ctx, clear_cache, adomd_connection=session.adomd_connection, database_id=db_id)
def _resolve_query(query: str, query_file: str | None) -> str:
diff --git a/src/pbi_cli/commands/expression.py b/src/pbi_cli/commands/expression.py
index c43a647..548ca74 100644
--- a/src/pbi_cli/commands/expression.py
+++ b/src/pbi_cli/commands/expression.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def expression() -> None:
@pass_context
def expression_list(ctx: PbiContext) -> None:
"""List all named expressions."""
- run_tool(ctx, "named_expression_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import expression_list as _expression_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _expression_list, model=session.model)
@expression.command()
@@ -25,27 +29,31 @@ def expression_list(ctx: PbiContext) -> None:
@pass_context
def get(ctx: PbiContext, name: str) -> None:
"""Get a named expression."""
- run_tool(ctx, "named_expression_operations", {"operation": "Get", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import expression_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, expression_get, model=session.model, name=name)
@expression.command()
@click.argument("name")
-@click.option("--expression", "-e", required=True, help="M expression.")
+@click.option("--expression", "-e", "expr", required=True, help="M expression.")
@click.option("--description", default=None, help="Expression description.")
@pass_context
-def create(ctx: PbiContext, name: str, expression: str, description: str | None) -> None:
+def create(ctx: PbiContext, name: str, expr: str, description: str | None) -> None:
"""Create a named expression."""
- definition = build_definition(
- required={"name": name, "expression": expression},
- optional={"description": description},
- )
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import expression_create
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "named_expression_operations",
- {
- "operation": "Create",
- "definitions": [definition],
- },
+ expression_create,
+ model=session.model,
+ name=name,
+ expression=expr,
+ description=description,
)
@@ -54,25 +62,8 @@ def create(ctx: PbiContext, name: str, expression: str, description: str | None)
@pass_context
def delete(ctx: PbiContext, name: str) -> None:
"""Delete a named expression."""
- run_tool(ctx, "named_expression_operations", {"operation": "Delete", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import expression_delete
-
-@expression.command(name="create-param")
-@click.argument("name")
-@click.option("--expression", "-e", required=True, help="Default value expression.")
-@click.option("--description", default=None, help="Parameter description.")
-@pass_context
-def create_param(ctx: PbiContext, name: str, expression: str, description: str | None) -> None:
- """Create a model parameter."""
- definition = build_definition(
- required={"name": name, "expression": expression},
- optional={"description": description},
- )
- run_tool(
- ctx,
- "named_expression_operations",
- {
- "operation": "CreateParameter",
- "definitions": [definition],
- },
- )
+ session = get_session_for_command(ctx)
+ run_command(ctx, expression_delete, model=session.model, name=name)
diff --git a/src/pbi_cli/commands/hierarchy.py b/src/pbi_cli/commands/hierarchy.py
index 1d00383..c1cefd2 100644
--- a/src/pbi_cli/commands/hierarchy.py
+++ b/src/pbi_cli/commands/hierarchy.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -18,10 +18,11 @@ def hierarchy() -> None:
@pass_context
def hierarchy_list(ctx: PbiContext, table: str | None) -> None:
"""List hierarchies."""
- request: dict[str, object] = {"operation": "List"}
- if table:
- request["tableName"] = table
- run_tool(ctx, "user_hierarchy_operations", request)
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import hierarchy_list as _hierarchy_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _hierarchy_list, model=session.model, table_name=table)
@hierarchy.command()
@@ -30,15 +31,11 @@ def hierarchy_list(ctx: PbiContext, table: str | None) -> None:
@pass_context
def get(ctx: PbiContext, name: str, table: str) -> None:
"""Get hierarchy details."""
- run_tool(
- ctx,
- "user_hierarchy_operations",
- {
- "operation": "Get",
- "name": name,
- "tableName": table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import hierarchy_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, hierarchy_get, model=session.model, table_name=table, name=name)
@hierarchy.command()
@@ -48,11 +45,18 @@ def get(ctx: PbiContext, name: str, table: str) -> None:
@pass_context
def create(ctx: PbiContext, name: str, table: str, description: str | None) -> None:
"""Create a hierarchy."""
- definition = build_definition(
- required={"name": name, "tableName": table},
- optional={"description": description},
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import hierarchy_create
+
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ hierarchy_create,
+ model=session.model,
+ table_name=table,
+ name=name,
+ description=description,
)
- run_tool(ctx, "user_hierarchy_operations", {"operation": "Create", "definitions": [definition]})
@hierarchy.command()
@@ -61,12 +65,8 @@ def create(ctx: PbiContext, name: str, table: str, description: str | None) -> N
@pass_context
def delete(ctx: PbiContext, name: str, table: str) -> None:
"""Delete a hierarchy."""
- run_tool(
- ctx,
- "user_hierarchy_operations",
- {
- "operation": "Delete",
- "name": name,
- "tableName": table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import hierarchy_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, hierarchy_delete, model=session.model, table_name=table, name=name)
diff --git a/src/pbi_cli/commands/measure.py b/src/pbi_cli/commands/measure.py
index c47349e..db43478 100644
--- a/src/pbi_cli/commands/measure.py
+++ b/src/pbi_cli/commands/measure.py
@@ -6,7 +6,7 @@ import sys
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -20,10 +20,11 @@ def measure() -> None:
@pass_context
def measure_list(ctx: PbiContext, table: str | None) -> None:
"""List all measures."""
- request: dict[str, object] = {"operation": "List"}
- if table:
- request["tableName"] = table
- run_tool(ctx, "measure_operations", request)
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_list as _measure_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _measure_list, model=session.model, table_name=table)
@measure.command()
@@ -32,15 +33,11 @@ def measure_list(ctx: PbiContext, table: str | None) -> None:
@pass_context
def get(ctx: PbiContext, name: str, table: str) -> None:
"""Get details of a specific measure."""
- run_tool(
- ctx,
- "measure_operations",
- {
- "operation": "Get",
- "name": name,
- "tableName": table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, measure_get, model=session.model, table_name=table, measure_name=name)
@measure.command()
@@ -66,22 +63,21 @@ def create(
if expression == "-":
expression = sys.stdin.read().strip()
- definition = build_definition(
- required={"name": name, "expression": expression, "tableName": table},
- optional={
- "formatString": format_string,
- "description": description,
- "displayFolder": folder,
- "isHidden": hidden if hidden else None,
- },
- )
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_create
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "measure_operations",
- {
- "operation": "Create",
- "definitions": [definition],
- },
+ measure_create,
+ model=session.model,
+ table_name=table,
+ name=name,
+ expression=expression,
+ format_string=format_string,
+ description=description,
+ display_folder=folder,
+ is_hidden=hidden,
)
@@ -106,22 +102,20 @@ def update(
if expression == "-":
expression = sys.stdin.read().strip()
- definition = build_definition(
- required={"name": name, "tableName": table},
- optional={
- "expression": expression,
- "formatString": format_string,
- "description": description,
- "displayFolder": folder,
- },
- )
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_update
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "measure_operations",
- {
- "operation": "Update",
- "definitions": [definition],
- },
+ measure_update,
+ model=session.model,
+ table_name=table,
+ name=name,
+ expression=expression,
+ format_string=format_string,
+ description=description,
+ display_folder=folder,
)
@@ -131,15 +125,11 @@ def update(
@pass_context
def delete(ctx: PbiContext, name: str, table: str) -> None:
"""Delete a measure."""
- run_tool(
- ctx,
- "measure_operations",
- {
- "operation": "Delete",
- "name": name,
- "tableName": table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, measure_delete, model=session.model, table_name=table, name=name)
@measure.command()
@@ -149,15 +139,17 @@ def delete(ctx: PbiContext, name: str, table: str) -> None:
@pass_context
def rename(ctx: PbiContext, old_name: str, new_name: str, table: str) -> None:
"""Rename a measure."""
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_rename
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "measure_operations",
- {
- "operation": "Rename",
- "name": old_name,
- "newName": new_name,
- "tableName": table,
- },
+ measure_rename,
+ model=session.model,
+ table_name=table,
+ old_name=old_name,
+ new_name=new_name,
)
@@ -168,30 +160,15 @@ def rename(ctx: PbiContext, old_name: str, new_name: str, table: str) -> None:
@pass_context
def move(ctx: PbiContext, name: str, table: str, to_table: str) -> None:
"""Move a measure to a different table."""
- run_tool(
- ctx,
- "measure_operations",
- {
- "operation": "Move",
- "name": name,
- "tableName": table,
- "destinationTableName": to_table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import measure_move
-
-@measure.command(name="export-tmdl")
-@click.argument("name")
-@click.option("--table", "-t", required=True, help="Table containing the measure.")
-@pass_context
-def export_tmdl(ctx: PbiContext, name: str, table: str) -> None:
- """Export a measure as TMDL."""
- run_tool(
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "measure_operations",
- {
- "operation": "ExportTMDL",
- "name": name,
- "tableName": table,
- },
+ measure_move,
+ model=session.model,
+ table_name=table,
+ name=name,
+ dest_table_name=to_table,
)
diff --git a/src/pbi_cli/commands/model.py b/src/pbi_cli/commands/model.py
index 56f1654..a673d3c 100644
--- a/src/pbi_cli/commands/model.py
+++ b/src/pbi_cli/commands/model.py
@@ -4,7 +4,9 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import run_tool
+from pbi_cli.commands._helpers import run_command
+from pbi_cli.core.session import get_session_for_command
+from pbi_cli.core.tom_backend import model_get, model_get_stats
from pbi_cli.main import PbiContext, pass_context
@@ -17,40 +19,13 @@ def model() -> None:
@pass_context
def get(ctx: PbiContext) -> None:
"""Get model metadata."""
- run_tool(ctx, "model_operations", {"operation": "Get"})
+ session = get_session_for_command(ctx)
+ run_command(ctx, model_get, model=session.model, database=session.database)
@model.command()
@pass_context
def stats(ctx: PbiContext) -> None:
"""Get model statistics."""
- run_tool(ctx, "model_operations", {"operation": "GetStats"})
-
-
-@model.command()
-@click.option(
- "--type",
- "refresh_type",
- type=click.Choice(["Automatic", "Full", "Calculate", "DataOnly", "Defragment"]),
- default="Automatic",
- help="Refresh type.",
-)
-@pass_context
-def refresh(ctx: PbiContext, refresh_type: str) -> None:
- """Refresh the model."""
- run_tool(ctx, "model_operations", {"operation": "Refresh", "refreshType": refresh_type})
-
-
-@model.command()
-@click.argument("new_name")
-@pass_context
-def rename(ctx: PbiContext, new_name: str) -> None:
- """Rename the model."""
- run_tool(ctx, "model_operations", {"operation": "Rename", "newName": new_name})
-
-
-@model.command(name="export-tmdl")
-@pass_context
-def export_tmdl(ctx: PbiContext) -> None:
- """Export the model as TMDL."""
- run_tool(ctx, "model_operations", {"operation": "ExportTMDL"})
+ session = get_session_for_command(ctx)
+ run_command(ctx, model_get_stats, model=session.model)
diff --git a/src/pbi_cli/commands/partition.py b/src/pbi_cli/commands/partition.py
index cd2b3c1..d20fbcb 100644
--- a/src/pbi_cli/commands/partition.py
+++ b/src/pbi_cli/commands/partition.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -18,7 +18,11 @@ def partition() -> None:
@pass_context
def partition_list(ctx: PbiContext, table: str) -> None:
"""List partitions in a table."""
- run_tool(ctx, "partition_operations", {"operation": "List", "tableName": table})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import partition_list as _partition_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _partition_list, model=session.model, table_name=table)
@partition.command()
@@ -31,11 +35,19 @@ def create(
ctx: PbiContext, name: str, table: str, expression: str | None, mode: str | None
) -> None:
"""Create a partition."""
- definition = build_definition(
- required={"name": name, "tableName": table},
- optional={"expression": expression, "mode": mode},
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import partition_create
+
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ partition_create,
+ model=session.model,
+ table_name=table,
+ name=name,
+ expression=expression,
+ mode=mode,
)
- run_tool(ctx, "partition_operations", {"operation": "Create", "definitions": [definition]})
@partition.command()
@@ -44,7 +56,11 @@ def create(
@pass_context
def delete(ctx: PbiContext, name: str, table: str) -> None:
"""Delete a partition."""
- run_tool(ctx, "partition_operations", {"operation": "Delete", "name": name, "tableName": table})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import partition_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, partition_delete, model=session.model, table_name=table, name=name)
@partition.command()
@@ -53,12 +69,8 @@ def delete(ctx: PbiContext, name: str, table: str) -> None:
@pass_context
def refresh(ctx: PbiContext, name: str, table: str) -> None:
"""Refresh a partition."""
- run_tool(
- ctx,
- "partition_operations",
- {
- "operation": "Refresh",
- "name": name,
- "tableName": table,
- },
- )
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import partition_refresh
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, partition_refresh, model=session.model, table_name=table, name=name)
diff --git a/src/pbi_cli/commands/perspective.py b/src/pbi_cli/commands/perspective.py
index 505647f..eadb9a0 100644
--- a/src/pbi_cli/commands/perspective.py
+++ b/src/pbi_cli/commands/perspective.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def perspective() -> None:
@pass_context
def perspective_list(ctx: PbiContext) -> None:
"""List all perspectives."""
- run_tool(ctx, "perspective_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import perspective_list as _perspective_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _perspective_list, model=session.model)
@perspective.command()
@@ -26,8 +30,11 @@ def perspective_list(ctx: PbiContext) -> None:
@pass_context
def create(ctx: PbiContext, name: str, description: str | None) -> None:
"""Create a perspective."""
- definition = build_definition(required={"name": name}, optional={"description": description})
- run_tool(ctx, "perspective_operations", {"operation": "Create", "definitions": [definition]})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import perspective_create
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, perspective_create, model=session.model, name=name, description=description)
@perspective.command()
@@ -35,4 +42,8 @@ def create(ctx: PbiContext, name: str, description: str | None) -> None:
@pass_context
def delete(ctx: PbiContext, name: str) -> None:
"""Delete a perspective."""
- run_tool(ctx, "perspective_operations", {"operation": "Delete", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import perspective_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, perspective_delete, model=session.model, name=name)
diff --git a/src/pbi_cli/commands/relationship.py b/src/pbi_cli/commands/relationship.py
index b7615b6..ac1801e 100644
--- a/src/pbi_cli/commands/relationship.py
+++ b/src/pbi_cli/commands/relationship.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def relationship() -> None:
@pass_context
def relationship_list(ctx: PbiContext) -> None:
"""List all relationships."""
- run_tool(ctx, "relationship_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_list as _rel_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _rel_list, model=session.model)
@relationship.command()
@@ -25,7 +29,11 @@ def relationship_list(ctx: PbiContext) -> None:
@pass_context
def get(ctx: PbiContext, name: str) -> None:
"""Get details of a specific relationship."""
- run_tool(ctx, "relationship_operations", {"operation": "Get", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, relationship_get, model=session.model, name=name)
@relationship.command()
@@ -53,20 +61,22 @@ def create(
active: bool,
) -> None:
"""Create a new relationship."""
- definition = build_definition(
- required={
- "fromTable": from_table,
- "fromColumn": from_column,
- "toTable": to_table,
- "toColumn": to_column,
- },
- optional={
- "name": name,
- "crossFilteringBehavior": cross_filter,
- "isActive": active,
- },
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_create
+
+ session = get_session_for_command(ctx)
+ run_command(
+ ctx,
+ relationship_create,
+ model=session.model,
+ from_table=from_table,
+ from_column=from_column,
+ to_table=to_table,
+ to_column=to_column,
+ name=name,
+ cross_filter=cross_filter,
+ is_active=active,
)
- run_tool(ctx, "relationship_operations", {"operation": "Create", "definitions": [definition]})
@relationship.command()
@@ -74,7 +84,11 @@ def create(
@pass_context
def delete(ctx: PbiContext, name: str) -> None:
"""Delete a relationship."""
- run_tool(ctx, "relationship_operations", {"operation": "Delete", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, relationship_delete, model=session.model, name=name)
@relationship.command()
@@ -82,7 +96,11 @@ def delete(ctx: PbiContext, name: str) -> None:
@pass_context
def activate(ctx: PbiContext, name: str) -> None:
"""Activate a relationship."""
- run_tool(ctx, "relationship_operations", {"operation": "Activate", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_set_active
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, relationship_set_active, model=session.model, name=name, active=True)
@relationship.command()
@@ -90,7 +108,11 @@ def activate(ctx: PbiContext, name: str) -> None:
@pass_context
def deactivate(ctx: PbiContext, name: str) -> None:
"""Deactivate a relationship."""
- run_tool(ctx, "relationship_operations", {"operation": "Deactivate", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_set_active
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, relationship_set_active, model=session.model, name=name, active=False)
@relationship.command()
@@ -98,12 +120,8 @@ def deactivate(ctx: PbiContext, name: str) -> None:
@pass_context
def find(ctx: PbiContext, table: str) -> None:
"""Find relationships involving a table."""
- run_tool(ctx, "relationship_operations", {"operation": "Find", "tableName": table})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import relationship_find
-
-@relationship.command(name="export-tmdl")
-@click.argument("name")
-@pass_context
-def export_tmdl(ctx: PbiContext, name: str) -> None:
- """Export a relationship as TMDL."""
- run_tool(ctx, "relationship_operations", {"operation": "ExportTMDL", "name": name})
+ session = get_session_for_command(ctx)
+ run_command(ctx, relationship_find, model=session.model, table_name=table)
diff --git a/src/pbi_cli/commands/repl_cmd.py b/src/pbi_cli/commands/repl_cmd.py
index 0bec589..2982c7e 100644
--- a/src/pbi_cli/commands/repl_cmd.py
+++ b/src/pbi_cli/commands/repl_cmd.py
@@ -12,9 +12,8 @@ from pbi_cli.main import PbiContext, pass_context
def repl(ctx: PbiContext) -> None:
"""Start an interactive REPL session.
- Keeps the MCP server process alive across commands, avoiding the
- 2-3 second startup cost on each invocation. Type 'exit' or press
- Ctrl+D to quit.
+ Keeps a persistent .NET connection alive across commands for
+ near-instant execution. Type 'exit' or press Ctrl+D to quit.
"""
from pbi_cli.utils.repl import start_repl
diff --git a/src/pbi_cli/commands/security.py b/src/pbi_cli/commands/security.py
index 94d0e93..9245db2 100644
--- a/src/pbi_cli/commands/security.py
+++ b/src/pbi_cli/commands/security.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def security_role() -> None:
@pass_context
def role_list(ctx: PbiContext) -> None:
"""List all security roles."""
- run_tool(ctx, "security_role_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import role_list as _role_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _role_list, model=session.model)
@security_role.command()
@@ -25,7 +29,11 @@ def role_list(ctx: PbiContext) -> None:
@pass_context
def get(ctx: PbiContext, name: str) -> None:
"""Get details of a security role."""
- run_tool(ctx, "security_role_operations", {"operation": "Get", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import role_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, role_get, model=session.model, name=name)
@security_role.command()
@@ -34,11 +42,11 @@ def get(ctx: PbiContext, name: str) -> None:
@pass_context
def create(ctx: PbiContext, name: str, description: str | None) -> None:
"""Create a new security role."""
- definition = build_definition(
- required={"name": name},
- optional={"description": description},
- )
- run_tool(ctx, "security_role_operations", {"operation": "Create", "definitions": [definition]})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import role_create
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, role_create, model=session.model, name=name, description=description)
@security_role.command()
@@ -46,12 +54,8 @@ def create(ctx: PbiContext, name: str, description: str | None) -> None:
@pass_context
def delete(ctx: PbiContext, name: str) -> None:
"""Delete a security role."""
- run_tool(ctx, "security_role_operations", {"operation": "Delete", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import role_delete
-
-@security_role.command(name="export-tmdl")
-@click.argument("name")
-@pass_context
-def export_tmdl(ctx: PbiContext, name: str) -> None:
- """Export a security role as TMDL."""
- run_tool(ctx, "security_role_operations", {"operation": "ExportTMDL", "name": name})
+ session = get_session_for_command(ctx)
+ run_command(ctx, role_delete, model=session.model, name=name)
diff --git a/src/pbi_cli/commands/setup_cmd.py b/src/pbi_cli/commands/setup_cmd.py
index 76998a2..5b1e22b 100644
--- a/src/pbi_cli/commands/setup_cmd.py
+++ b/src/pbi_cli/commands/setup_cmd.py
@@ -1,75 +1,102 @@
-"""pbi setup: download and manage the Power BI MCP binary."""
+"""pbi setup: verify environment and install skills."""
from __future__ import annotations
import click
-from pbi_cli.core.binary_manager import (
- check_for_updates,
- download_and_extract,
- get_binary_info,
-)
-from pbi_cli.core.output import print_error, print_info, print_json, print_key_value, print_success
+from pbi_cli.core.output import print_error, print_info, print_json, print_success
from pbi_cli.main import PbiContext, pass_context
@click.command()
-@click.option("--version", "target_version", default=None, help="Specific version to install.")
-@click.option("--check", is_flag=True, default=False, help="Check for updates without installing.")
-@click.option("--info", is_flag=True, default=False, help="Show info about the current binary.")
+@click.option("--info", is_flag=True, default=False, help="Show environment info.")
@pass_context
-def setup(ctx: PbiContext, target_version: str | None, check: bool, info: bool) -> None:
- """Download and set up the Power BI MCP server binary.
+def setup(ctx: PbiContext, info: bool) -> None:
+ """Verify the pbi-cli environment is ready.
- Run this once after installing pbi-cli to download the binary.
+ Checks that pythonnet and the bundled .NET DLLs are available.
+ Also installs Claude Code skills if applicable.
"""
if info:
_show_info(ctx.json_output)
return
- if check:
- _check_updates(ctx.json_output)
- return
-
- _install(target_version, ctx.json_output)
+ _verify(ctx.json_output)
def _show_info(json_output: bool) -> None:
- """Show binary info."""
- info = get_binary_info()
+ """Show environment info."""
+ from pbi_cli import __version__
+ from pbi_cli.core.dotnet_loader import _dll_dir
+
+ dll_path = _dll_dir()
+ dlls_found = list(dll_path.glob("*.dll")) if dll_path.exists() else []
+
+ result = {
+ "version": __version__,
+ "dll_path": str(dll_path),
+ "dlls_found": len(dlls_found),
+ "dll_names": [d.name for d in dlls_found],
+ }
+
+ # Check pythonnet
+ try:
+ import pythonnet # noqa: F401
+
+ result["pythonnet"] = "installed"
+ except ImportError:
+ result["pythonnet"] = "missing"
+
if json_output:
- print_json(info)
+ print_json(result)
else:
- print_key_value("Power BI MCP Binary", info)
+ print_info(f"pbi-cli v{result['version']}")
+ print_info(f"DLL path: {result['dll_path']}")
+ print_info(f"DLLs found: {result['dlls_found']}")
+ print_info(f"pythonnet: {result['pythonnet']}")
-def _check_updates(json_output: bool) -> None:
- """Check for available updates."""
+def _verify(json_output: bool) -> None:
+ """Verify the environment is ready."""
+ errors: list[str] = []
+
+ # Check pythonnet
try:
- installed, latest, update_available = check_for_updates()
- result = {
- "installed_version": installed,
- "latest_version": latest,
- "update_available": update_available,
- }
+ import pythonnet # noqa: F401
+ except ImportError:
+ errors.append("pythonnet not installed. Run: pip install pythonnet")
+
+ # Check DLLs
+ from pbi_cli.core.dotnet_loader import _dll_dir
+
+ dll_path = _dll_dir()
+ if not dll_path.exists():
+ errors.append(f"DLL directory not found: {dll_path}")
+ else:
+ required = [
+ "Microsoft.AnalysisServices.Tabular.dll",
+ "Microsoft.AnalysisServices.AdomdClient.dll",
+ ]
+ for name in required:
+ if not (dll_path / name).exists():
+ errors.append(f"Missing DLL: {name}")
+
+ if errors:
+ for err in errors:
+ print_error(err)
if json_output:
- print_json(result)
- elif update_available:
- print_info(f"Update available: {installed} -> {latest}")
- print_info("Run 'pbi setup' to update.")
- else:
- print_success(f"Up to date: v{installed}")
- except Exception as e:
- print_error(f"Failed to check for updates: {e}")
+ print_json({"status": "error", "errors": errors})
raise SystemExit(1)
-
-def _install(version: str | None, json_output: bool) -> None:
- """Download and install the binary."""
+ # Install skills
try:
- bin_path = download_and_extract(version)
- if json_output:
- print_json({"binary_path": str(bin_path), "status": "installed"})
- except Exception as e:
- print_error(f"Setup failed: {e}")
- raise SystemExit(1)
+ from pbi_cli.commands.connection import _ensure_ready
+
+ _ensure_ready()
+ except Exception:
+ pass
+
+ if json_output:
+ print_json({"status": "ready"})
+ else:
+ print_success("Environment is ready.")
diff --git a/src/pbi_cli/commands/table.py b/src/pbi_cli/commands/table.py
index e427d2a..5448f5c 100644
--- a/src/pbi_cli/commands/table.py
+++ b/src/pbi_cli/commands/table.py
@@ -6,7 +6,7 @@ import sys
import click
-from pbi_cli.commands._helpers import build_definition, run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -19,7 +19,11 @@ def table() -> None:
@pass_context
def table_list(ctx: PbiContext) -> None:
"""List all tables."""
- run_tool(ctx, "table_operations", {"operation": "List"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_list as _table_list
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, _table_list, model=session.model)
@table.command()
@@ -27,7 +31,11 @@ def table_list(ctx: PbiContext) -> None:
@pass_context
def get(ctx: PbiContext, name: str) -> None:
"""Get details of a specific table."""
- run_tool(ctx, "table_operations", {"operation": "Get", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_get
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, table_get, model=session.model, table_name=name)
@table.command()
@@ -40,7 +48,6 @@ def get(ctx: PbiContext, name: str) -> None:
)
@click.option("--m-expression", default=None, help="M/Power Query expression (use - for stdin).")
@click.option("--dax-expression", default=None, help="DAX expression for calculated tables.")
-@click.option("--sql-query", default=None, help="SQL query for DirectQuery.")
@click.option("--description", default=None, help="Table description.")
@click.option("--hidden", is_flag=True, default=False, help="Hide from client tools.")
@pass_context
@@ -50,7 +57,6 @@ def create(
mode: str,
m_expression: str | None,
dax_expression: str | None,
- sql_query: str | None,
description: str | None,
hidden: bool,
) -> None:
@@ -60,24 +66,20 @@ def create(
if dax_expression == "-":
dax_expression = sys.stdin.read().strip()
- definition = build_definition(
- required={"name": name},
- optional={
- "mode": mode,
- "mExpression": m_expression,
- "daxExpression": dax_expression,
- "sqlQuery": sql_query,
- "description": description,
- "isHidden": hidden if hidden else None,
- },
- )
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_create
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "table_operations",
- {
- "operation": "Create",
- "definitions": [definition],
- },
+ table_create,
+ model=session.model,
+ name=name,
+ mode=mode,
+ m_expression=m_expression,
+ dax_expression=dax_expression,
+ description=description,
+ is_hidden=hidden,
)
@@ -86,7 +88,11 @@ def create(
@pass_context
def delete(ctx: PbiContext, name: str) -> None:
"""Delete a table."""
- run_tool(ctx, "table_operations", {"operation": "Delete", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_delete
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, table_delete, model=session.model, table_name=name)
@table.command()
@@ -101,14 +107,16 @@ def delete(ctx: PbiContext, name: str) -> None:
@pass_context
def refresh(ctx: PbiContext, name: str, refresh_type: str) -> None:
"""Refresh a table."""
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_refresh
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "table_operations",
- {
- "operation": "Refresh",
- "name": name,
- "refreshType": refresh_type,
- },
+ table_refresh,
+ model=session.model,
+ table_name=name,
+ refresh_type=refresh_type,
)
@@ -117,15 +125,11 @@ def refresh(ctx: PbiContext, name: str, refresh_type: str) -> None:
@pass_context
def schema(ctx: PbiContext, name: str) -> None:
"""Get the schema of a table."""
- run_tool(ctx, "table_operations", {"operation": "GetSchema", "name": name})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_get_schema
-
-@table.command(name="export-tmdl")
-@click.argument("name")
-@pass_context
-def export_tmdl(ctx: PbiContext, name: str) -> None:
- """Export a table as TMDL."""
- run_tool(ctx, "table_operations", {"operation": "ExportTMDL", "name": name})
+ session = get_session_for_command(ctx)
+ run_command(ctx, table_get_schema, model=session.model, table_name=name)
@table.command()
@@ -134,14 +138,16 @@ def export_tmdl(ctx: PbiContext, name: str) -> None:
@pass_context
def rename(ctx: PbiContext, old_name: str, new_name: str) -> None:
"""Rename a table."""
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_rename
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "table_operations",
- {
- "operation": "Rename",
- "name": old_name,
- "newName": new_name,
- },
+ table_rename,
+ model=session.model,
+ old_name=old_name,
+ new_name=new_name,
)
@@ -151,12 +157,14 @@ def rename(ctx: PbiContext, old_name: str, new_name: str) -> None:
@pass_context
def mark_date_table(ctx: PbiContext, name: str, date_column: str) -> None:
"""Mark a table as a date table."""
- run_tool(
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import table_mark_as_date
+
+ session = get_session_for_command(ctx)
+ run_command(
ctx,
- "table_operations",
- {
- "operation": "MarkAsDateTable",
- "name": name,
- "dateColumn": date_column,
- },
+ table_mark_as_date,
+ model=session.model,
+ table_name=name,
+ date_column=date_column,
)
diff --git a/src/pbi_cli/commands/trace.py b/src/pbi_cli/commands/trace.py
index 3b9b5dc..d313610 100644
--- a/src/pbi_cli/commands/trace.py
+++ b/src/pbi_cli/commands/trace.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,21 +17,29 @@ def trace() -> None:
@pass_context
def start(ctx: PbiContext) -> None:
"""Start a diagnostic trace."""
- run_tool(ctx, "trace_operations", {"operation": "Start"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import trace_start
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, trace_start, server=session.server)
@trace.command()
@pass_context
def stop(ctx: PbiContext) -> None:
"""Stop the active trace."""
- run_tool(ctx, "trace_operations", {"operation": "Stop"})
+ from pbi_cli.core.tom_backend import trace_stop
+
+ run_command(ctx, trace_stop)
@trace.command()
@pass_context
def fetch(ctx: PbiContext) -> None:
"""Fetch trace events."""
- run_tool(ctx, "trace_operations", {"operation": "Fetch"})
+ from pbi_cli.core.tom_backend import trace_fetch
+
+ run_command(ctx, trace_fetch)
@trace.command()
@@ -39,4 +47,6 @@ def fetch(ctx: PbiContext) -> None:
@pass_context
def export(ctx: PbiContext, path: str) -> None:
"""Export trace events to a file."""
- run_tool(ctx, "trace_operations", {"operation": "Export", "filePath": path})
+ from pbi_cli.core.tom_backend import trace_export
+
+ run_command(ctx, trace_export, path=path)
diff --git a/src/pbi_cli/commands/transaction.py b/src/pbi_cli/commands/transaction.py
index 6a6f52b..961fcb7 100644
--- a/src/pbi_cli/commands/transaction.py
+++ b/src/pbi_cli/commands/transaction.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
-from pbi_cli.commands._helpers import run_tool
+from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@@ -17,7 +17,11 @@ def transaction() -> None:
@pass_context
def begin(ctx: PbiContext) -> None:
"""Begin a new transaction."""
- run_tool(ctx, "transaction_operations", {"operation": "Begin"})
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import transaction_begin
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, transaction_begin, server=session.server)
@transaction.command()
@@ -25,10 +29,11 @@ def begin(ctx: PbiContext) -> None:
@pass_context
def commit(ctx: PbiContext, transaction_id: str) -> None:
"""Commit the active or specified transaction."""
- request: dict[str, object] = {"operation": "Commit"}
- if transaction_id:
- request["transactionId"] = transaction_id
- run_tool(ctx, "transaction_operations", request)
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import transaction_commit
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, transaction_commit, server=session.server, transaction_id=transaction_id)
@transaction.command()
@@ -36,7 +41,8 @@ def commit(ctx: PbiContext, transaction_id: str) -> None:
@pass_context
def rollback(ctx: PbiContext, transaction_id: str) -> None:
"""Rollback the active or specified transaction."""
- request: dict[str, object] = {"operation": "Rollback"}
- if transaction_id:
- request["transactionId"] = transaction_id
- run_tool(ctx, "transaction_operations", request)
+ from pbi_cli.core.session import get_session_for_command
+ from pbi_cli.core.tom_backend import transaction_rollback
+
+ session = get_session_for_command(ctx)
+ run_command(ctx, transaction_rollback, server=session.server, transaction_id=transaction_id)
diff --git a/src/pbi_cli/core/adomd_backend.py b/src/pbi_cli/core/adomd_backend.py
new file mode 100644
index 0000000..4c2514e
--- /dev/null
+++ b/src/pbi_cli/core/adomd_backend.py
@@ -0,0 +1,126 @@
+"""ADOMD.NET operations: DAX query execution.
+
+Provides DAX execute, validate, and cache clearing via direct
+ADOMD.NET interop. Results are returned as plain Python dicts.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def execute_dax(
+ adomd_connection: Any,
+ query: str,
+ max_rows: int | None = None,
+ timeout: int = 200,
+) -> dict[str, Any]:
+ """Execute a DAX query and return results.
+
+ Args:
+ adomd_connection: An open AdomdConnection.
+ query: The DAX query string (must start with EVALUATE).
+ max_rows: Optional row limit.
+ timeout: Query timeout in seconds.
+
+ Returns:
+ Dict with ``columns`` and ``rows`` keys.
+ """
+ from pbi_cli.core.dotnet_loader import get_adomd_command_class
+
+ AdomdCommand = get_adomd_command_class()
+
+ cmd = AdomdCommand(query, adomd_connection)
+ cmd.CommandTimeout = timeout
+
+ reader = cmd.ExecuteReader()
+
+ # Read column headers
+ columns: list[str] = []
+ for i in range(reader.FieldCount):
+ columns.append(str(reader.GetName(i)))
+
+ # Read rows
+ rows: list[dict[str, Any]] = []
+ row_count = 0
+ while reader.Read():
+ if max_rows is not None and row_count >= max_rows:
+ break
+ row: dict[str, Any] = {}
+ for i, col_name in enumerate(columns):
+ val = reader.GetValue(i)
+ row[col_name] = _convert_value(val)
+ rows.append(row)
+ row_count += 1
+
+ reader.Close()
+
+ return {"columns": columns, "rows": rows}
+
+
+def validate_dax(
+ adomd_connection: Any,
+ query: str,
+ timeout: int = 10,
+) -> dict[str, Any]:
+ """Validate a DAX query without returning data.
+
+ Wraps the query in EVALUATE ROW("v", 0) pattern to test parsing
+ without full execution.
+ """
+ from pbi_cli.core.dotnet_loader import get_adomd_command_class
+
+ AdomdCommand = get_adomd_command_class()
+
+ # Use a lightweight wrapper to validate syntax
+ validate_query = query.strip()
+ cmd = AdomdCommand(validate_query, adomd_connection)
+ cmd.CommandTimeout = timeout
+
+ try:
+ reader = cmd.ExecuteReader()
+ reader.Close()
+ return {"valid": True, "query": query.strip()}
+ except Exception as e:
+ return {"valid": False, "error": str(e), "query": query.strip()}
+
+
+def clear_cache(
+ adomd_connection: Any,
+ database_id: str = "",
+) -> dict[str, str]:
+ """Clear the Analysis Services cache via XMLA."""
+ from pbi_cli.core.dotnet_loader import get_adomd_command_class
+
+ AdomdCommand = get_adomd_command_class()
+
+ object_xml = ""
+ if database_id:
+ object_xml = f"{database_id}"
+
+ xmla = (
+ ''
+ f""
+ ""
+ )
+ cmd = AdomdCommand(xmla, adomd_connection)
+ cmd.ExecuteNonQuery()
+ return {"status": "cache_cleared"}
+
+
+def _convert_value(val: Any) -> Any:
+ """Convert a .NET value to a Python-native type."""
+ if val is None:
+ return None
+ type_name = type(val).__name__
+ if type_name in ("Int32", "Int64", "Int16"):
+ return int(val)
+ if type_name in ("Double", "Single", "Decimal"):
+ return float(val)
+ if type_name == "Boolean":
+ return bool(val)
+ if type_name == "DateTime":
+ return str(val)
+ if type_name == "DBNull":
+ return None
+ return str(val)
diff --git a/src/pbi_cli/core/binary_manager.py b/src/pbi_cli/core/binary_manager.py
deleted file mode 100644
index 20228da..0000000
--- a/src/pbi_cli/core/binary_manager.py
+++ /dev/null
@@ -1,247 +0,0 @@
-"""Binary manager: download, extract, and resolve the Power BI MCP server binary.
-
-The binary is a .NET executable distributed as part of a VS Code extension (VSIX).
-This module handles downloading the VSIX from the VS Marketplace, extracting the
-server binary, and resolving the binary path for the MCP client.
-"""
-
-from __future__ import annotations
-
-import os
-import shutil
-import tempfile
-import zipfile
-from pathlib import Path
-
-import httpx
-
-from pbi_cli.core.config import PBI_CLI_HOME, ensure_home_dir, load_config, save_config
-from pbi_cli.core.output import print_info, print_success
-from pbi_cli.utils.platform import (
- binary_name,
- detect_platform,
- ensure_executable,
- find_vscode_extension_binary,
-)
-
-EXTENSION_ID = "analysis-services.powerbi-modeling-mcp"
-PUBLISHER = "analysis-services"
-EXTENSION_NAME = "powerbi-modeling-mcp"
-
-MARKETPLACE_API = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery"
-VSIX_URL_TEMPLATE = (
- "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/"
- "{publisher}/vsextensions/{extension}/{version}/vspackage"
- "?targetPlatform={platform}"
-)
-
-
-def resolve_binary() -> Path:
- """Resolve the MCP server binary path using the priority chain.
-
- Priority:
- 1. PBI_MCP_BINARY environment variable
- 2. ~/.pbi-cli/bin/{version}/ (auto-downloaded on first connect)
- 3. VS Code extension fallback
-
- Raises FileNotFoundError if no binary is found.
- """
- env_path = os.environ.get("PBI_MCP_BINARY")
- if env_path:
- p = Path(env_path)
- if p.exists():
- return p
- raise FileNotFoundError(f"PBI_MCP_BINARY points to non-existent path: {env_path}")
-
- config = load_config()
- if config.binary_path:
- p = Path(config.binary_path)
- if p.exists():
- return p
-
- managed = _find_managed_binary()
- if managed:
- return managed
-
- vscode_bin = find_vscode_extension_binary()
- if vscode_bin:
- print_info(f"Using VS Code extension binary: {vscode_bin}")
- return vscode_bin
-
- raise FileNotFoundError(
- "Power BI MCP binary not found. Run 'pbi connect' or 'pbi setup' to download it, "
- "or set PBI_MCP_BINARY environment variable."
- )
-
-
-def _find_managed_binary() -> Path | None:
- """Look for a binary in ~/.pbi-cli/bin/."""
- bin_dir = PBI_CLI_HOME / "bin"
- if not bin_dir.exists():
- return None
- versions = sorted(bin_dir.iterdir(), reverse=True)
- for version_dir in versions:
- candidate = version_dir / binary_name()
- if candidate.exists():
- return candidate
- return None
-
-
-def query_latest_version() -> str:
- """Query the VS Marketplace for the latest extension version.
-
- Returns the version string (e.g., '0.4.0').
- """
- payload = {
- "filters": [
- {
- "criteria": [
- {"filterType": 7, "value": EXTENSION_ID},
- ],
- "pageNumber": 1,
- "pageSize": 1,
- }
- ],
- "flags": 914,
- }
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json;api-version=6.1-preview.1",
- }
-
- with httpx.Client(timeout=30.0) as client:
- resp = client.post(MARKETPLACE_API, json=payload, headers=headers)
- resp.raise_for_status()
- data = resp.json()
-
- results = data.get("results", [])
- if not results:
- raise RuntimeError("No results from VS Marketplace query")
-
- extensions = results[0].get("extensions", [])
- if not extensions:
- raise RuntimeError(f"Extension {EXTENSION_ID} not found on VS Marketplace")
-
- versions = extensions[0].get("versions", [])
- if not versions:
- raise RuntimeError(f"No versions found for {EXTENSION_ID}")
-
- return str(versions[0]["version"])
-
-
-def download_and_extract(version: str | None = None) -> Path:
- """Download the VSIX and extract the server binary.
-
- Args:
- version: Specific version to download. If None, queries latest.
-
- Returns:
- Path to the extracted binary.
- """
- if version is None:
- print_info("Querying VS Marketplace for latest version...")
- version = query_latest_version()
-
- target_platform = detect_platform()
- print_info(f"Downloading pbi-mcp v{version} for {target_platform}...")
-
- url = VSIX_URL_TEMPLATE.format(
- publisher=PUBLISHER,
- extension=EXTENSION_NAME,
- version=version,
- platform=target_platform,
- )
-
- dest_dir = ensure_home_dir() / "bin" / version
- dest_dir.mkdir(parents=True, exist_ok=True)
-
- with tempfile.TemporaryDirectory() as tmp:
- vsix_path = Path(tmp) / "extension.vsix"
-
- with httpx.Client(timeout=120.0, follow_redirects=True) as client:
- with client.stream("GET", url) as resp:
- resp.raise_for_status()
- total = int(resp.headers.get("content-length", 0))
- downloaded = 0
- with open(vsix_path, "wb") as f:
- for chunk in resp.iter_bytes(chunk_size=8192):
- f.write(chunk)
- downloaded += len(chunk)
- if total > 0:
- pct = downloaded * 100 // total
- print(f"\r Downloading... {pct}%", end="", flush=True)
- print()
-
- print_info("Extracting server binary...")
- with zipfile.ZipFile(vsix_path, "r") as zf:
- server_prefix = "extension/server/"
- server_files = [n for n in zf.namelist() if n.startswith(server_prefix)]
- if not server_files:
- raise RuntimeError("No server/ directory found in VSIX package")
-
- for file_name in server_files:
- rel_path = file_name[len(server_prefix) :]
- if not rel_path:
- continue
- target_path = dest_dir / rel_path
- target_path.parent.mkdir(parents=True, exist_ok=True)
- with zf.open(file_name) as src, open(target_path, "wb") as dst:
- shutil.copyfileobj(src, dst)
-
- bin_path = dest_dir / binary_name()
- if not bin_path.exists():
- raise RuntimeError(f"Binary not found after extraction: {bin_path}")
-
- ensure_executable(bin_path)
-
- config = load_config().with_updates(
- binary_version=version,
- binary_path=str(bin_path),
- )
- save_config(config)
-
- print_success(f"Installed pbi-mcp v{version} at {dest_dir}")
- return bin_path
-
-
-def check_for_updates() -> tuple[str, str, bool]:
- """Compare installed version with latest available.
-
- Returns (installed_version, latest_version, update_available).
- """
- config = load_config()
- installed = config.binary_version or "none"
- latest = query_latest_version()
- return installed, latest, installed != latest
-
-
-def get_binary_info() -> dict[str, str]:
- """Return info about the currently resolved binary."""
- try:
- path = resolve_binary()
- config = load_config()
- return {
- "binary_path": str(path),
- "version": config.binary_version or "unknown",
- "platform": detect_platform(),
- "source": _binary_source(path),
- }
- except FileNotFoundError:
- return {
- "binary_path": "not found",
- "version": "none",
- "platform": detect_platform(),
- "source": "none",
- }
-
-
-def _binary_source(path: Path) -> str:
- """Determine the source of a resolved binary path."""
- path_str = str(path)
- if "PBI_MCP_BINARY" in os.environ:
- return "environment variable (PBI_MCP_BINARY)"
- if ".pbi-cli" in path_str:
- return "managed (auto-downloaded)"
- if ".vscode" in path_str:
- return "VS Code extension (fallback)"
- return "unknown"
diff --git a/src/pbi_cli/core/config.py b/src/pbi_cli/core/config.py
index 83183ab..d95a1b7 100644
--- a/src/pbi_cli/core/config.py
+++ b/src/pbi_cli/core/config.py
@@ -1,12 +1,12 @@
"""Configuration management for pbi-cli.
-Manages ~/.pbi-cli/config.json for binary paths, versions, and preferences.
+Manages ~/.pbi-cli/config.json for user preferences.
"""
from __future__ import annotations
import json
-from dataclasses import asdict, dataclass, field
+from dataclasses import asdict, dataclass
from pathlib import Path
PBI_CLI_HOME = Path.home() / ".pbi-cli"
@@ -17,10 +17,7 @@ CONFIG_FILE = PBI_CLI_HOME / "config.json"
class PbiConfig:
"""Immutable configuration object."""
- binary_version: str = ""
- binary_path: str = ""
default_connection: str = ""
- binary_args: list[str] = field(default_factory=lambda: ["--start", "--skipconfirmation"])
def with_updates(self, **kwargs: object) -> PbiConfig:
"""Return a new config with the specified fields updated."""
@@ -42,10 +39,7 @@ def load_config() -> PbiConfig:
try:
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return PbiConfig(
- binary_version=raw.get("binary_version", ""),
- binary_path=raw.get("binary_path", ""),
default_connection=raw.get("default_connection", ""),
- binary_args=raw.get("binary_args", ["--start", "--skipconfirmation"]),
)
except (json.JSONDecodeError, KeyError):
return PbiConfig()
diff --git a/src/pbi_cli/core/dotnet_loader.py b/src/pbi_cli/core/dotnet_loader.py
new file mode 100644
index 0000000..e646081
--- /dev/null
+++ b/src/pbi_cli/core/dotnet_loader.py
@@ -0,0 +1,111 @@
+"""CLR bootstrap: load pythonnet and Microsoft Analysis Services DLLs.
+
+Uses .NET Framework (net45) DLLs bundled in ``pbi_cli/dlls/``.
+Lazy-loaded on first access so import cost is zero until needed.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from typing import Any
+
+_initialized = False
+
+
+def _dll_dir() -> Path:
+ """Return the path to the bundled DLL directory."""
+ return Path(__file__).resolve().parent.parent / "dlls"
+
+
+def _ensure_initialized() -> None:
+ """Initialize the CLR runtime and load Analysis Services assemblies.
+
+ Idempotent: safe to call multiple times.
+ """
+ global _initialized
+ if _initialized:
+ return
+
+ try:
+ import pythonnet
+ from clr_loader import get_netfx
+ except ImportError as e:
+ raise ImportError(
+ "pythonnet is required for direct Power BI connection.\n"
+ "Install it with: pip install pythonnet"
+ ) from e
+
+ rt = get_netfx()
+ pythonnet.set_runtime(rt)
+
+ import clr # noqa: E402 (must import after set_runtime)
+
+ dll_path = _dll_dir()
+ if not dll_path.exists():
+ raise FileNotFoundError(
+ f"Bundled DLL directory not found: {dll_path}\n"
+ "Reinstall pbi-cli-tool: pipx install pbi-cli-tool --force"
+ )
+
+ sys.path.insert(0, str(dll_path))
+
+ clr.AddReference("Microsoft.AnalysisServices.Tabular")
+ clr.AddReference("Microsoft.AnalysisServices.AdomdClient")
+
+ _initialized = True
+
+
+def get_server_class() -> Any:
+ """Return the ``Microsoft.AnalysisServices.Tabular.Server`` class."""
+ _ensure_initialized()
+ from Microsoft.AnalysisServices.Tabular import Server # type: ignore[import-untyped]
+
+ return Server
+
+
+def get_adomd_connection_class() -> Any:
+ """Return the ``AdomdConnection`` class."""
+ _ensure_initialized()
+ from Microsoft.AnalysisServices.AdomdClient import (
+ AdomdConnection, # type: ignore[import-untyped]
+ )
+
+ return AdomdConnection
+
+
+def get_adomd_command_class() -> Any:
+ """Return the ``AdomdCommand`` class."""
+ _ensure_initialized()
+ from Microsoft.AnalysisServices.AdomdClient import AdomdCommand # type: ignore[import-untyped]
+
+ return AdomdCommand
+
+
+def get_tmdl_serializer() -> Any:
+ """Return the ``TmdlSerializer`` class."""
+ _ensure_initialized()
+ from Microsoft.AnalysisServices.Tabular import TmdlSerializer # type: ignore[import-untyped]
+
+ return TmdlSerializer
+
+
+def get_tom_classes(*names: str) -> tuple[Any, ...]:
+ """Return one or more classes from ``Microsoft.AnalysisServices.Tabular``.
+
+ Example::
+
+ Measure, Table = get_tom_classes("Measure", "Table")
+ """
+ _ensure_initialized()
+ import Microsoft.AnalysisServices.Tabular as TOM # type: ignore[import-untyped]
+
+ results: list[Any] = []
+ for name in names:
+ cls = getattr(TOM, name, None)
+ if cls is None:
+ raise AttributeError(
+ f"Class '{name}' not found in Microsoft.AnalysisServices.Tabular"
+ )
+ results.append(cls)
+ return tuple(results)
diff --git a/src/pbi_cli/core/errors.py b/src/pbi_cli/core/errors.py
index c6e17fc..133902b 100644
--- a/src/pbi_cli/core/errors.py
+++ b/src/pbi_cli/core/errors.py
@@ -16,13 +16,13 @@ class PbiCliError(click.ClickException):
super().__init__(message)
-class BinaryNotFoundError(PbiCliError):
- """Raised when the MCP server binary cannot be resolved."""
+class DotNetNotFoundError(PbiCliError):
+ """Raised when pythonnet or the bundled .NET DLLs are missing."""
def __init__(
self,
message: str = (
- "Power BI MCP binary not found. Run 'pbi connect' or 'pbi setup' to download it."
+ "pythonnet is required. Install it with: pip install pythonnet"
),
) -> None:
super().__init__(message)
@@ -35,10 +35,10 @@ class ConnectionRequiredError(PbiCliError):
super().__init__(message)
-class McpToolError(PbiCliError):
- """Raised when an MCP tool call fails."""
+class TomError(PbiCliError):
+ """Raised when a TOM operation fails."""
- def __init__(self, tool_name: str, detail: str) -> None:
- self.tool_name = tool_name
+ def __init__(self, operation: str, detail: str) -> None:
+ self.operation = operation
self.detail = detail
- super().__init__(f"{tool_name}: {detail}")
+ super().__init__(f"{operation}: {detail}")
diff --git a/src/pbi_cli/core/mcp_client.py b/src/pbi_cli/core/mcp_client.py
deleted file mode 100644
index 87eb3de..0000000
--- a/src/pbi_cli/core/mcp_client.py
+++ /dev/null
@@ -1,252 +0,0 @@
-"""MCP client: communicates with the Power BI MCP server binary over stdio.
-
-Uses the official `mcp` Python SDK to handle JSON-RPC framing and protocol
-negotiation. Exposes a synchronous API for Click commands while managing
-an async event loop internally.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import atexit
-from pathlib import Path
-from typing import Any
-
-from mcp import ClientSession
-from mcp.client.stdio import StdioServerParameters, stdio_client
-
-from pbi_cli.core.binary_manager import resolve_binary
-from pbi_cli.core.config import load_config
-
-
-class McpClientError(Exception):
- """Raised when the MCP server returns an error."""
-
-
-class PbiMcpClient:
- """Synchronous wrapper around the async MCP stdio client.
-
- Usage:
- client = PbiMcpClient()
- result = client.call_tool("measure_operations", {
- "operation": "List",
- "connectionName": "my-conn",
- })
- """
-
- def __init__(
- self,
- binary_path: str | Path | None = None,
- args: list[str] | None = None,
- ) -> None:
- self._binary_path = str(binary_path) if binary_path else None
- self._args = args
- self._loop: asyncio.AbstractEventLoop | None = None
- self._session: ClientSession | None = None
- self._cleanup_stack: Any = None
- self._started = False
-
- def _resolve_binary(self) -> str:
- """Resolve binary path lazily."""
- if self._binary_path:
- return self._binary_path
- return str(resolve_binary())
-
- def _resolve_args(self) -> list[str]:
- """Resolve binary args from config or defaults."""
- if self._args is not None:
- return self._args
- config = load_config()
- return list(config.binary_args)
-
- def _ensure_loop(self) -> asyncio.AbstractEventLoop:
- """Get or create the event loop."""
- if self._loop is None or self._loop.is_closed():
- self._loop = asyncio.new_event_loop()
- return self._loop
-
- def start(self) -> None:
- """Start the MCP server process and initialize the session."""
- if self._started:
- return
-
- loop = self._ensure_loop()
- loop.run_until_complete(self._async_start())
- self._started = True
- atexit.register(self.stop)
-
- async def _async_start(self) -> None:
- """Async startup: spawn the server and initialize MCP session."""
- binary = self._resolve_binary()
- args = self._resolve_args()
-
- server_params = StdioServerParameters(
- command=binary,
- args=args,
- )
-
- # Create the stdio transport
- self._read_stream, self._write_stream = await self._enter_context(
- stdio_client(server_params)
- )
-
- # Create and initialize the MCP session
- self._session = await self._enter_context(
- ClientSession(self._read_stream, self._write_stream)
- )
-
- await self._session.initialize()
-
- async def _enter_context(self, cm: Any) -> Any:
- """Enter an async context manager and track it for cleanup."""
- if self._cleanup_stack is None:
- self._cleanup_stack = []
- result = await cm.__aenter__()
- self._cleanup_stack.append(cm)
- return result
-
- def call_tool(self, tool_name: str, request: dict[str, Any]) -> Any:
- """Call an MCP tool synchronously.
-
- Args:
- tool_name: The MCP tool name (e.g., "measure_operations").
- request: The request dict (will be wrapped as {"request": request}).
-
- Returns:
- The parsed result from the MCP server.
-
- Raises:
- McpClientError: If the server returns an error.
- """
- if not self._started:
- self.start()
-
- loop = self._ensure_loop()
- return loop.run_until_complete(self._async_call_tool(tool_name, request))
-
- async def _async_call_tool(self, tool_name: str, request: dict[str, Any]) -> Any:
- """Execute a tool call via the MCP session."""
- if self._session is None:
- raise McpClientError("MCP session not initialized. Call start() first.")
-
- result = await self._session.call_tool(
- tool_name,
- arguments={"request": request},
- )
-
- if result.isError:
- error_text = _extract_text(result.content)
- raise McpClientError(f"MCP tool error: {error_text}")
-
- return _parse_content(result.content)
-
- def list_tools(self) -> list[dict[str, Any]]:
- """List all available MCP tools."""
- if not self._started:
- self.start()
-
- loop = self._ensure_loop()
- return loop.run_until_complete(self._async_list_tools())
-
- async def _async_list_tools(self) -> list[dict[str, Any]]:
- """List tools from the MCP session."""
- if self._session is None:
- raise McpClientError("MCP session not initialized.")
-
- result = await self._session.list_tools()
- return [
- {
- "name": tool.name,
- "description": tool.description or "",
- }
- for tool in result.tools
- ]
-
- def stop(self) -> None:
- """Shut down the MCP server process."""
- if not self._started:
- return
-
- loop = self._ensure_loop()
- loop.run_until_complete(self._async_stop())
- self._started = False
- self._session = None
-
- async def _async_stop(self) -> None:
- """Clean up all async context managers in reverse order."""
- if self._cleanup_stack:
- for cm in reversed(self._cleanup_stack):
- try:
- await cm.__aexit__(None, None, None)
- except Exception:
- pass
- self._cleanup_stack = []
-
- def __del__(self) -> None:
- try:
- self.stop()
- except Exception:
- pass
-
-
-def _extract_text(content: Any) -> str:
- """Extract text from MCP content blocks."""
- if isinstance(content, list):
- parts = []
- for block in content:
- if hasattr(block, "text"):
- parts.append(block.text)
- return "\n".join(parts) if parts else str(content)
- return str(content)
-
-
-def _parse_content(content: Any) -> Any:
- """Parse MCP content blocks into Python data.
-
- MCP returns content as a list of TextContent blocks. This function
- tries to parse the text as JSON, falling back to raw text.
- """
- import json
-
- if isinstance(content, list):
- texts = []
- for block in content:
- if hasattr(block, "text"):
- texts.append(block.text)
-
- if len(texts) == 1:
- try:
- return json.loads(texts[0])
- except (json.JSONDecodeError, ValueError):
- return texts[0]
-
- combined = "\n".join(texts)
- try:
- return json.loads(combined)
- except (json.JSONDecodeError, ValueError):
- return combined
-
- return content
-
-
-# Module-level singleton for REPL mode (keeps server alive across commands).
-_shared_client: PbiMcpClient | None = None
-
-
-def get_shared_client() -> PbiMcpClient:
- """Get or create a shared MCP client instance."""
- global _shared_client
- if _shared_client is None:
- _shared_client = PbiMcpClient()
- return _shared_client
-
-
-def get_client(repl_mode: bool = False) -> PbiMcpClient:
- """Get an MCP client.
-
- In REPL mode, returns a shared long-lived client.
- In one-shot mode, returns a fresh client (caller should stop() it).
- """
- if repl_mode:
- return get_shared_client()
- return PbiMcpClient()
diff --git a/src/pbi_cli/core/output.py b/src/pbi_cli/core/output.py
index fec8c94..c90a82f 100644
--- a/src/pbi_cli/core/output.py
+++ b/src/pbi_cli/core/output.py
@@ -60,8 +60,8 @@ def print_key_value(title: str, data: dict[str, Any]) -> None:
console.print(Panel("\n".join(lines), title=title, border_style="cyan"))
-def format_mcp_result(result: Any, json_output: bool) -> None:
- """Format and print an MCP tool result.
+def format_result(result: Any, json_output: bool) -> None:
+ """Format and print a command result.
In JSON mode, prints the raw result. In human mode, attempts to render
a table or key-value display based on the shape of the data.
diff --git a/src/pbi_cli/core/session.py b/src/pbi_cli/core/session.py
new file mode 100644
index 0000000..0cb0f72
--- /dev/null
+++ b/src/pbi_cli/core/session.py
@@ -0,0 +1,141 @@
+"""Connection session manager for Power BI Desktop.
+
+Maintains a persistent connection to the Analysis Services engine,
+reusable across commands in both REPL and one-shot modes.
+"""
+
+from __future__ import annotations
+
+import atexit
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Session:
+ """An active connection to a Power BI Analysis Services instance."""
+
+ server: Any # Microsoft.AnalysisServices.Tabular.Server
+ database: Any # Microsoft.AnalysisServices.Tabular.Database
+ model: Any # Microsoft.AnalysisServices.Tabular.Model
+ adomd_connection: Any # Microsoft.AnalysisServices.AdomdClient.AdomdConnection
+ connection_name: str
+ data_source: str
+
+
+# Module-level session for REPL mode persistence
+_current_session: Session | None = None
+
+
+def connect(data_source: str, catalog: str = "") -> Session:
+ """Connect to an Analysis Services instance.
+
+ Args:
+ data_source: The data source (e.g., ``localhost:57947``).
+ catalog: Optional initial catalog / database name.
+
+ Returns:
+ A new ``Session`` with active TOM and ADOMD connections.
+ """
+ from pbi_cli.core.dotnet_loader import get_adomd_connection_class, get_server_class
+
+ Server = get_server_class()
+ AdomdConnection = get_adomd_connection_class()
+
+ conn_str = f"Provider=MSOLAP;Data Source={data_source}"
+ if catalog:
+ conn_str += f";Initial Catalog={catalog}"
+
+ server = Server()
+ server.Connect(conn_str)
+
+ # Pick the first database (PBI Desktop has exactly one)
+ db = server.Databases[0]
+ model = db.Model
+
+ # Build connection name from database info
+ db_name = str(db.Name) if db.Name else ""
+ connection_name = f"PBIDesktop-{db_name[:20]}-{data_source.split(':')[-1]}"
+
+ # ADOMD connection for DAX queries
+ adomd_conn = AdomdConnection(conn_str)
+ adomd_conn.Open()
+
+ session = Session(
+ server=server,
+ database=db,
+ model=model,
+ adomd_connection=adomd_conn,
+ connection_name=connection_name,
+ data_source=data_source,
+ )
+
+ global _current_session
+ _current_session = session
+
+ return session
+
+
+def disconnect(session: Session | None = None) -> None:
+ """Disconnect an active session."""
+ global _current_session
+ target = session or _current_session
+
+ if target is None:
+ return
+
+ try:
+ target.adomd_connection.Close()
+ except Exception:
+ pass
+ try:
+ target.server.Disconnect()
+ except Exception:
+ pass
+
+ if target is _current_session:
+ _current_session = None
+
+
+def get_current_session() -> Session | None:
+ """Return the current session, or None if not connected."""
+ return _current_session
+
+
+def ensure_connected() -> Session:
+ """Return the current session, raising if not connected."""
+ from pbi_cli.core.errors import ConnectionRequiredError
+
+ if _current_session is None:
+ raise ConnectionRequiredError()
+ return _current_session
+
+
+def get_session_for_command(ctx: Any) -> Session:
+ """Get or establish a session for a CLI command.
+
+ In REPL mode, returns the existing session.
+ In one-shot mode, reconnects from the saved connection store.
+ """
+ global _current_session
+
+ if ctx.repl_mode and _current_session is not None:
+ return _current_session
+
+ # One-shot mode: reconnect from saved connection
+ from pbi_cli.core.connection_store import get_active_connection, load_connections
+
+ store = load_connections()
+ conn = get_active_connection(store, override=ctx.connection)
+ if conn is None:
+ from pbi_cli.core.errors import ConnectionRequiredError
+
+ raise ConnectionRequiredError()
+
+ return connect(conn.data_source, conn.initial_catalog)
+
+
+@atexit.register
+def _cleanup() -> None:
+ """Disconnect on process exit."""
+ disconnect()
diff --git a/src/pbi_cli/core/tom_backend.py b/src/pbi_cli/core/tom_backend.py
new file mode 100644
index 0000000..ff1e718
--- /dev/null
+++ b/src/pbi_cli/core/tom_backend.py
@@ -0,0 +1,1289 @@
+"""TOM (Tabular Object Model) operations.
+
+All functions accept TOM objects (model, server, database) and return
+plain Python dicts. This is the single point of .NET interop for
+read/write operations on the semantic model.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+def _get_table(model: Any, table_name: str) -> Any:
+ """Get a table by name, raising on not found."""
+ for table in model.Tables:
+ if table.Name == table_name:
+ return table
+ raise ValueError(f"Table '{table_name}' not found")
+
+
+def _get_column(table: Any, column_name: str) -> Any:
+ """Get a column by name, raising on not found."""
+ for col in table.Columns:
+ if col.Name == column_name:
+ return col
+ raise ValueError(f"Column '{column_name}' not found in table '{table.Name}'")
+
+
+def _get_measure(table: Any, measure_name: str) -> Any:
+ """Get a measure by name, raising on not found."""
+ for m in table.Measures:
+ if m.Name == measure_name:
+ return m
+ raise ValueError(f"Measure '{measure_name}' not found in table '{table.Name}'")
+
+
+def _get_relationship(model: Any, name: str) -> Any:
+ """Get a relationship by name, raising on not found."""
+ for rel in model.Relationships:
+ if rel.Name == name:
+ return rel
+ raise ValueError(f"Relationship '{name}' not found")
+
+
+def _first_partition_mode(table: Any) -> str:
+ """Get the mode of the first partition, or empty string."""
+ for p in table.Partitions:
+ return _safe_str(p.Mode)
+ return ""
+
+
+def _safe_str(val: Any) -> str:
+ """Convert a .NET value to string, handling None."""
+ if val is None:
+ return ""
+ try:
+ return str(val)
+ except Exception:
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Model operations
+# ---------------------------------------------------------------------------
+
+
+def model_get(model: Any, database: Any) -> dict[str, Any]:
+ """Return model metadata."""
+ return {
+ "name": str(model.Name),
+ "culture": _safe_str(model.Culture),
+ "compatibilityLevel": int(database.CompatibilityLevel),
+ "defaultMode": str(model.DefaultMode),
+ "tables": model.Tables.Count,
+ "relationships": model.Relationships.Count,
+ }
+
+
+def model_get_stats(model: Any) -> dict[str, Any]:
+ """Return model statistics: counts of tables, columns, measures, etc."""
+ table_count = 0
+ column_count = 0
+ measure_count = 0
+ relationship_count = model.Relationships.Count
+ partition_count = 0
+
+ for table in model.Tables:
+ table_count += 1
+ column_count += table.Columns.Count
+ measure_count += table.Measures.Count
+ partition_count += table.Partitions.Count
+
+ return {
+ "tables": table_count,
+ "columns": column_count,
+ "measures": measure_count,
+ "relationships": relationship_count,
+ "partitions": partition_count,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Table operations
+# ---------------------------------------------------------------------------
+
+
+def table_list(model: Any) -> list[dict[str, Any]]:
+ """List all tables with summary info."""
+ results: list[dict[str, Any]] = []
+ for table in model.Tables:
+ results.append({
+ "name": str(table.Name),
+ "columns": table.Columns.Count,
+ "measures": table.Measures.Count,
+ "partitions": table.Partitions.Count,
+ "isHidden": bool(table.IsHidden),
+ "description": _safe_str(table.Description),
+ })
+ return results
+
+
+def table_get(model: Any, table_name: str) -> dict[str, Any]:
+ """Get detailed metadata for a single table."""
+ table = _get_table(model, table_name)
+ return {
+ "name": str(table.Name),
+ "columns": table.Columns.Count,
+ "measures": table.Measures.Count,
+ "partitions": table.Partitions.Count,
+ "isHidden": bool(table.IsHidden),
+ "description": _safe_str(table.Description),
+ "defaultMode": _first_partition_mode(table),
+ }
+
+
+def table_get_schema(model: Any, table_name: str) -> list[dict[str, Any]]:
+ """Get the column schema for a table."""
+ table = _get_table(model, table_name)
+ results: list[dict[str, Any]] = []
+ for col in table.Columns:
+ results.append({
+ "name": str(col.Name),
+ "dataType": str(col.DataType),
+ "type": str(col.Type),
+ "isHidden": bool(col.IsHidden),
+ "formatString": _safe_str(col.FormatString),
+ })
+ return results
+
+
+def table_create(
+ model: Any,
+ name: str,
+ mode: str = "Import",
+ m_expression: str | None = None,
+ dax_expression: str | None = None,
+ description: str | None = None,
+ is_hidden: bool = False,
+) -> dict[str, Any]:
+ """Create a new table with a single partition."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ Table, Partition, ModeType = get_tom_classes("Table", "Partition", "ModeType")
+
+ t = Table()
+ t.Name = name
+ if description is not None:
+ t.Description = description
+ if is_hidden:
+ t.IsHidden = True
+
+ p = Partition()
+ p.Name = name
+
+ mode_map = {
+ "Import": ModeType.Import,
+ "DirectQuery": ModeType.DirectQuery,
+ "Dual": ModeType.Dual,
+ }
+ if mode in mode_map:
+ p.Mode = mode_map[mode]
+
+ if m_expression is not None:
+ from pbi_cli.core.dotnet_loader import get_tom_classes as _gtc
+
+ (MPartitionSource,) = _gtc("MPartitionSource")
+ src = MPartitionSource()
+ src.Expression = m_expression
+ p.Source = src
+ elif dax_expression is not None:
+ from pbi_cli.core.dotnet_loader import get_tom_classes as _gtc
+
+ (CalculatedPartitionSource,) = _gtc("CalculatedPartitionSource")
+ src = CalculatedPartitionSource()
+ src.Expression = dax_expression
+ p.Source = src
+
+ t.Partitions.Add(p)
+ model.Tables.Add(t)
+ model.SaveChanges()
+ return {"status": "created", "name": name}
+
+
+def table_delete(model: Any, table_name: str) -> dict[str, Any]:
+ """Delete a table."""
+ table = _get_table(model, table_name)
+ model.Tables.Remove(table)
+ model.SaveChanges()
+ return {"status": "deleted", "name": table_name}
+
+
+def table_refresh(
+ model: Any, table_name: str, refresh_type: str = "Automatic"
+) -> dict[str, Any]:
+ """Request a table refresh."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (RefreshType,) = get_tom_classes("RefreshType")
+
+ table = _get_table(model, table_name)
+ rt_map = {
+ "Full": RefreshType.Full,
+ "Automatic": RefreshType.Automatic,
+ "Calculate": RefreshType.Calculate,
+ "DataOnly": RefreshType.DataOnly,
+ }
+ rt = rt_map.get(refresh_type, RefreshType.Automatic)
+ table.RequestRefresh(rt)
+ model.SaveChanges()
+ return {"status": "refreshed", "name": table_name, "refreshType": refresh_type}
+
+
+def table_rename(model: Any, old_name: str, new_name: str) -> dict[str, Any]:
+ """Rename a table."""
+ table = _get_table(model, old_name)
+ table.Name = new_name
+ model.SaveChanges()
+ return {"status": "renamed", "oldName": old_name, "newName": new_name}
+
+
+def table_mark_as_date(
+ model: Any, table_name: str, date_column: str
+) -> dict[str, Any]:
+ """Mark a table as a date table."""
+ table = _get_table(model, table_name)
+ col = _get_column(table, date_column)
+ table.DataCategory = "Time"
+ # Set the column as the key for the date table
+ col.IsKey = True
+ model.SaveChanges()
+ return {"status": "marked_as_date", "name": table_name, "dateColumn": date_column}
+
+
+# ---------------------------------------------------------------------------
+# Column operations
+# ---------------------------------------------------------------------------
+
+
+def column_list(model: Any, table_name: str) -> list[dict[str, Any]]:
+ """List all columns in a table."""
+ table = _get_table(model, table_name)
+ results: list[dict[str, Any]] = []
+ for col in table.Columns:
+ results.append({
+ "name": str(col.Name),
+ "dataType": str(col.DataType),
+ "type": str(col.Type),
+ "isHidden": bool(col.IsHidden),
+ "displayFolder": _safe_str(col.DisplayFolder),
+ "description": _safe_str(col.Description),
+ "formatString": _safe_str(col.FormatString),
+ })
+ return results
+
+
+def column_get(model: Any, table_name: str, column_name: str) -> dict[str, Any]:
+ """Get detailed metadata for a single column."""
+ table = _get_table(model, table_name)
+ col = _get_column(table, column_name)
+ result: dict[str, Any] = {
+ "name": str(col.Name),
+ "tableName": str(table.Name),
+ "dataType": str(col.DataType),
+ "type": str(col.Type),
+ "isHidden": bool(col.IsHidden),
+ "displayFolder": _safe_str(col.DisplayFolder),
+ "description": _safe_str(col.Description),
+ "formatString": _safe_str(col.FormatString),
+ "isKey": bool(col.IsKey),
+ }
+ # Include expression for calculated columns
+ if _safe_str(col.Type) == "Calculated":
+ result["expression"] = _safe_str(col.Expression)
+ # Include source column for data columns
+ source = _safe_str(col.SourceColumn)
+ if source:
+ result["sourceColumn"] = source
+ return result
+
+
+def column_create(
+ model: Any,
+ table_name: str,
+ name: str,
+ data_type: str,
+ source_column: str | None = None,
+ expression: str | None = None,
+ format_string: str | None = None,
+ description: str | None = None,
+ display_folder: str | None = None,
+ is_hidden: bool = False,
+ is_key: bool = False,
+) -> dict[str, Any]:
+ """Create a new column (data or calculated)."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ table = _get_table(model, table_name)
+
+ dt_map = _data_type_map()
+ if data_type not in dt_map:
+ raise ValueError(
+ f"Unknown data type '{data_type}'. "
+ f"Valid types: {', '.join(sorted(dt_map.keys()))}"
+ )
+
+ if expression is not None:
+ (CalculatedColumn,) = get_tom_classes("CalculatedColumn")
+ col = CalculatedColumn()
+ col.Expression = expression
+ else:
+ (DataColumn,) = get_tom_classes("DataColumn")
+ col = DataColumn()
+ if source_column is not None:
+ col.SourceColumn = source_column
+
+ col.Name = name
+ col.DataType = dt_map[data_type]
+ if format_string is not None:
+ col.FormatString = format_string
+ if description is not None:
+ col.Description = description
+ if display_folder is not None:
+ col.DisplayFolder = display_folder
+ if is_hidden:
+ col.IsHidden = True
+ if is_key:
+ col.IsKey = True
+
+ table.Columns.Add(col)
+ model.SaveChanges()
+ return {"status": "created", "name": name, "tableName": table_name}
+
+
+def column_delete(model: Any, table_name: str, column_name: str) -> dict[str, Any]:
+ """Delete a column."""
+ table = _get_table(model, table_name)
+ col = _get_column(table, column_name)
+ table.Columns.Remove(col)
+ model.SaveChanges()
+ return {"status": "deleted", "name": column_name, "tableName": table_name}
+
+
+def column_rename(
+ model: Any, table_name: str, old_name: str, new_name: str
+) -> dict[str, Any]:
+ """Rename a column."""
+ table = _get_table(model, table_name)
+ col = _get_column(table, old_name)
+ col.Name = new_name
+ model.SaveChanges()
+ return {"status": "renamed", "oldName": old_name, "newName": new_name}
+
+
+def _data_type_map() -> dict[str, Any]:
+ """Return a mapping from string data type names to TOM DataType enums."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (DataType,) = get_tom_classes("DataType")
+ return {
+ "string": DataType.String,
+ "int64": DataType.Int64,
+ "double": DataType.Double,
+ "datetime": DataType.DateTime,
+ "decimal": DataType.Decimal,
+ "boolean": DataType.Boolean,
+ "binary": DataType.Binary,
+ "variant": DataType.Variant,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Measure operations
+# ---------------------------------------------------------------------------
+
+
+def measure_list(model: Any, table_name: str | None = None) -> list[dict[str, Any]]:
+ """List measures, optionally filtered by table."""
+ results: list[dict[str, Any]] = []
+ for table in model.Tables:
+ if table_name and str(table.Name) != table_name:
+ continue
+ for m in table.Measures:
+ results.append({
+ "name": str(m.Name),
+ "tableName": str(table.Name),
+ "expression": _safe_str(m.Expression),
+ "displayFolder": _safe_str(m.DisplayFolder),
+ "description": _safe_str(m.Description),
+ "isHidden": bool(m.IsHidden),
+ })
+ return results
+
+
+def measure_get(
+ model: Any, table_name: str, measure_name: str
+) -> dict[str, Any]:
+ """Get detailed metadata for a single measure."""
+ table = _get_table(model, table_name)
+ m = _get_measure(table, measure_name)
+ return {
+ "name": str(m.Name),
+ "tableName": str(table.Name),
+ "expression": _safe_str(m.Expression),
+ "displayFolder": _safe_str(m.DisplayFolder),
+ "description": _safe_str(m.Description),
+ "formatString": _safe_str(m.FormatString),
+ "isHidden": bool(m.IsHidden),
+ }
+
+
+def measure_create(
+ model: Any,
+ table_name: str,
+ name: str,
+ expression: str,
+ format_string: str | None = None,
+ description: str | None = None,
+ display_folder: str | None = None,
+ is_hidden: bool = False,
+) -> dict[str, Any]:
+ """Create a new measure."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (Measure,) = get_tom_classes("Measure")
+
+ table = _get_table(model, table_name)
+ m = Measure()
+ m.Name = name
+ m.Expression = expression
+ if format_string is not None:
+ m.FormatString = format_string
+ if description is not None:
+ m.Description = description
+ if display_folder is not None:
+ m.DisplayFolder = display_folder
+ if is_hidden:
+ m.IsHidden = True
+ table.Measures.Add(m)
+ model.SaveChanges()
+ return {"status": "created", "name": name, "tableName": table_name}
+
+
+def measure_update(
+ model: Any,
+ table_name: str,
+ name: str,
+ expression: str | None = None,
+ format_string: str | None = None,
+ description: str | None = None,
+ display_folder: str | None = None,
+) -> dict[str, Any]:
+ """Update an existing measure's properties."""
+ table = _get_table(model, table_name)
+ m = _get_measure(table, name)
+ if expression is not None:
+ m.Expression = expression
+ if format_string is not None:
+ m.FormatString = format_string
+ if description is not None:
+ m.Description = description
+ if display_folder is not None:
+ m.DisplayFolder = display_folder
+ model.SaveChanges()
+ return {"status": "updated", "name": name, "tableName": table_name}
+
+
+def measure_delete(model: Any, table_name: str, name: str) -> dict[str, Any]:
+ """Delete a measure."""
+ table = _get_table(model, table_name)
+ m = _get_measure(table, name)
+ table.Measures.Remove(m)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name, "tableName": table_name}
+
+
+def measure_rename(
+ model: Any, table_name: str, old_name: str, new_name: str
+) -> dict[str, Any]:
+ """Rename a measure."""
+ table = _get_table(model, table_name)
+ m = _get_measure(table, old_name)
+ m.Name = new_name
+ model.SaveChanges()
+ return {"status": "renamed", "oldName": old_name, "newName": new_name}
+
+
+def measure_move(
+ model: Any, table_name: str, name: str, dest_table_name: str
+) -> dict[str, Any]:
+ """Move a measure to a different table."""
+ src_table = _get_table(model, table_name)
+ dest_table = _get_table(model, dest_table_name)
+ m = _get_measure(src_table, name)
+
+ # Store properties, remove from source, recreate in dest
+ expr = _safe_str(m.Expression)
+ fmt = _safe_str(m.FormatString)
+ desc = _safe_str(m.Description)
+ folder = _safe_str(m.DisplayFolder)
+ hidden = bool(m.IsHidden)
+
+ src_table.Measures.Remove(m)
+
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (Measure,) = get_tom_classes("Measure")
+ new_m = Measure()
+ new_m.Name = name
+ new_m.Expression = expr
+ if fmt:
+ new_m.FormatString = fmt
+ if desc:
+ new_m.Description = desc
+ if folder:
+ new_m.DisplayFolder = folder
+ if hidden:
+ new_m.IsHidden = True
+ dest_table.Measures.Add(new_m)
+ model.SaveChanges()
+ return {
+ "status": "moved",
+ "name": name,
+ "fromTable": table_name,
+ "toTable": dest_table_name,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Relationship operations
+# ---------------------------------------------------------------------------
+
+
+def relationship_list(model: Any) -> list[dict[str, Any]]:
+ """List all relationships."""
+ results: list[dict[str, Any]] = []
+ for rel in model.Relationships:
+ results.append(_relationship_to_dict(rel))
+ return results
+
+
+def relationship_get(model: Any, name: str) -> dict[str, Any]:
+ """Get a specific relationship by name."""
+ rel = _get_relationship(model, name)
+ return _relationship_to_dict(rel)
+
+
+def relationship_find(model: Any, table_name: str) -> list[dict[str, Any]]:
+ """Find all relationships involving a table."""
+ results: list[dict[str, Any]] = []
+ for rel in model.Relationships:
+ from_table = _safe_str(rel.FromTable.Name)
+ to_table = _safe_str(rel.ToTable.Name)
+ if from_table == table_name or to_table == table_name:
+ results.append(_relationship_to_dict(rel))
+ return results
+
+
+def relationship_create(
+ model: Any,
+ from_table: str,
+ from_column: str,
+ to_table: str,
+ to_column: str,
+ name: str | None = None,
+ cross_filter: str = "OneDirection",
+ is_active: bool = True,
+) -> dict[str, Any]:
+ """Create a new relationship between two tables."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ Relationship, CrossFilteringBehavior = get_tom_classes(
+ "SingleColumnRelationship", "CrossFilteringBehavior"
+ )
+
+ ft = _get_table(model, from_table)
+ fc = _get_column(ft, from_column)
+ tt = _get_table(model, to_table)
+ tc = _get_column(tt, to_column)
+
+ rel = Relationship()
+ if name:
+ rel.Name = name
+ rel.FromColumn = fc
+ rel.ToColumn = tc
+ rel.IsActive = is_active
+
+ cf_map = {
+ "OneDirection": CrossFilteringBehavior.OneDirection,
+ "BothDirections": CrossFilteringBehavior.BothDirections,
+ "Automatic": CrossFilteringBehavior.Automatic,
+ }
+ if cross_filter in cf_map:
+ rel.CrossFilteringBehavior = cf_map[cross_filter]
+
+ model.Relationships.Add(rel)
+ model.SaveChanges()
+ return {"status": "created", "name": str(rel.Name)}
+
+
+def relationship_delete(model: Any, name: str) -> dict[str, Any]:
+ """Delete a relationship by name."""
+ rel = _get_relationship(model, name)
+ model.Relationships.Remove(rel)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name}
+
+
+def relationship_set_active(model: Any, name: str, active: bool) -> dict[str, Any]:
+ """Activate or deactivate a relationship."""
+ rel = _get_relationship(model, name)
+ rel.IsActive = active
+ model.SaveChanges()
+ state = "activated" if active else "deactivated"
+ return {"status": state, "name": name}
+
+
+def _relationship_to_dict(rel: Any) -> dict[str, Any]:
+ """Convert a TOM Relationship to a plain dict."""
+ return {
+ "name": str(rel.Name),
+ "fromTable": _safe_str(rel.FromTable.Name),
+ "fromColumn": _safe_str(rel.FromColumn.Name),
+ "toTable": _safe_str(rel.ToTable.Name),
+ "toColumn": _safe_str(rel.ToColumn.Name),
+ "crossFilteringBehavior": str(rel.CrossFilteringBehavior),
+ "isActive": bool(rel.IsActive),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Partition operations
+# ---------------------------------------------------------------------------
+
+
+def partition_list(model: Any, table_name: str) -> list[dict[str, Any]]:
+ """List partitions in a table."""
+ table = _get_table(model, table_name)
+ results: list[dict[str, Any]] = []
+ for p in table.Partitions:
+ results.append({
+ "name": str(p.Name),
+ "tableName": str(table.Name),
+ "mode": _safe_str(p.Mode),
+ "sourceType": _safe_str(p.SourceType),
+ "state": _safe_str(p.State),
+ })
+ return results
+
+
+def _get_partition(table: Any, partition_name: str) -> Any:
+ """Get a partition by name."""
+ for p in table.Partitions:
+ if p.Name == partition_name:
+ return p
+ raise ValueError(f"Partition '{partition_name}' not found in table '{table.Name}'")
+
+
+def partition_create(
+ model: Any,
+ table_name: str,
+ name: str,
+ expression: str | None = None,
+ mode: str | None = None,
+) -> dict[str, Any]:
+ """Create a partition."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ Partition, MPartitionSource, ModeType = get_tom_classes(
+ "Partition", "MPartitionSource", "ModeType"
+ )
+
+ table = _get_table(model, table_name)
+ p = Partition()
+ p.Name = name
+
+ if expression is not None:
+ src = MPartitionSource()
+ src.Expression = expression
+ p.Source = src
+
+ if mode is not None:
+ mode_map = {
+ "Import": ModeType.Import,
+ "DirectQuery": ModeType.DirectQuery,
+ "Dual": ModeType.Dual,
+ }
+ if mode in mode_map:
+ p.Mode = mode_map[mode]
+
+ table.Partitions.Add(p)
+ model.SaveChanges()
+ return {"status": "created", "name": name, "tableName": table_name}
+
+
+def partition_delete(model: Any, table_name: str, name: str) -> dict[str, Any]:
+ """Delete a partition."""
+ table = _get_table(model, table_name)
+ p = _get_partition(table, name)
+ table.Partitions.Remove(p)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name, "tableName": table_name}
+
+
+def partition_refresh(
+ model: Any, table_name: str, name: str
+) -> dict[str, Any]:
+ """Refresh a partition."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (RefreshType,) = get_tom_classes("RefreshType")
+
+ table = _get_table(model, table_name)
+ p = _get_partition(table, name)
+ p.RequestRefresh(RefreshType.Full)
+ model.SaveChanges()
+ return {"status": "refreshed", "name": name, "tableName": table_name}
+
+
+# ---------------------------------------------------------------------------
+# Security role operations
+# ---------------------------------------------------------------------------
+
+
+def role_list(model: Any) -> list[dict[str, Any]]:
+ """List all security roles."""
+ results: list[dict[str, Any]] = []
+ for role in model.Roles:
+ results.append({
+ "name": str(role.Name),
+ "description": _safe_str(role.Description),
+ "modelPermission": str(role.ModelPermission),
+ })
+ return results
+
+
+def _get_role(model: Any, name: str) -> Any:
+ """Get a role by name."""
+ for role in model.Roles:
+ if role.Name == name:
+ return role
+ raise ValueError(f"Role '{name}' not found")
+
+
+def role_get(model: Any, name: str) -> dict[str, Any]:
+ """Get details of a security role."""
+ role = _get_role(model, name)
+ filters: list[dict[str, str]] = []
+ for tp in role.TablePermissions:
+ filters.append({
+ "tableName": str(tp.Table.Name),
+ "filterExpression": _safe_str(tp.FilterExpression),
+ })
+ return {
+ "name": str(role.Name),
+ "description": _safe_str(role.Description),
+ "modelPermission": str(role.ModelPermission),
+ "tablePermissions": filters,
+ }
+
+
+def role_create(
+ model: Any, name: str, description: str | None = None
+) -> dict[str, Any]:
+ """Create a security role."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ ModelRole, ModelPermission = get_tom_classes("ModelRole", "ModelPermission")
+ role = ModelRole()
+ role.Name = name
+ role.ModelPermission = ModelPermission.Read
+ if description is not None:
+ role.Description = description
+ model.Roles.Add(role)
+ model.SaveChanges()
+ return {"status": "created", "name": name}
+
+
+def role_delete(model: Any, name: str) -> dict[str, Any]:
+ """Delete a security role."""
+ role = _get_role(model, name)
+ model.Roles.Remove(role)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name}
+
+
+# ---------------------------------------------------------------------------
+# Perspective operations
+# ---------------------------------------------------------------------------
+
+
+def perspective_list(model: Any) -> list[dict[str, Any]]:
+ """List all perspectives."""
+ results: list[dict[str, Any]] = []
+ for p in model.Perspectives:
+ results.append({
+ "name": str(p.Name),
+ "description": _safe_str(p.Description),
+ })
+ return results
+
+
+def perspective_create(
+ model: Any, name: str, description: str | None = None
+) -> dict[str, Any]:
+ """Create a perspective."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (Perspective,) = get_tom_classes("Perspective")
+ p = Perspective()
+ p.Name = name
+ if description is not None:
+ p.Description = description
+ model.Perspectives.Add(p)
+ model.SaveChanges()
+ return {"status": "created", "name": name}
+
+
+def _get_perspective(model: Any, name: str) -> Any:
+ """Get a perspective by name."""
+ for p in model.Perspectives:
+ if p.Name == name:
+ return p
+ raise ValueError(f"Perspective '{name}' not found")
+
+
+def perspective_delete(model: Any, name: str) -> dict[str, Any]:
+ """Delete a perspective."""
+ p = _get_perspective(model, name)
+ model.Perspectives.Remove(p)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name}
+
+
+# ---------------------------------------------------------------------------
+# Hierarchy operations
+# ---------------------------------------------------------------------------
+
+
+def hierarchy_list(
+ model: Any, table_name: str | None = None
+) -> list[dict[str, Any]]:
+ """List hierarchies, optionally filtered by table."""
+ results: list[dict[str, Any]] = []
+ for table in model.Tables:
+ if table_name and str(table.Name) != table_name:
+ continue
+ for h in table.Hierarchies:
+ levels = [str(lv.Name) for lv in h.Levels]
+ results.append({
+ "name": str(h.Name),
+ "tableName": str(table.Name),
+ "description": _safe_str(h.Description),
+ "levels": levels,
+ })
+ return results
+
+
+def _get_hierarchy(table: Any, name: str) -> Any:
+ """Get a hierarchy by name."""
+ for h in table.Hierarchies:
+ if h.Name == name:
+ return h
+ raise ValueError(f"Hierarchy '{name}' not found in table '{table.Name}'")
+
+
+def hierarchy_get(model: Any, table_name: str, name: str) -> dict[str, Any]:
+ """Get hierarchy details."""
+ table = _get_table(model, table_name)
+ h = _get_hierarchy(table, name)
+ levels = []
+ for lv in h.Levels:
+ levels.append({
+ "name": str(lv.Name),
+ "ordinal": int(lv.Ordinal),
+ "column": _safe_str(lv.Column.Name) if lv.Column else "",
+ })
+ return {
+ "name": str(h.Name),
+ "tableName": table_name,
+ "description": _safe_str(h.Description),
+ "levels": levels,
+ }
+
+
+def hierarchy_create(
+ model: Any,
+ table_name: str,
+ name: str,
+ description: str | None = None,
+) -> dict[str, Any]:
+ """Create a hierarchy (levels added separately)."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (Hierarchy,) = get_tom_classes("Hierarchy")
+ table = _get_table(model, table_name)
+ h = Hierarchy()
+ h.Name = name
+ if description is not None:
+ h.Description = description
+ table.Hierarchies.Add(h)
+ model.SaveChanges()
+ return {"status": "created", "name": name, "tableName": table_name}
+
+
+def hierarchy_delete(model: Any, table_name: str, name: str) -> dict[str, Any]:
+ """Delete a hierarchy."""
+ table = _get_table(model, table_name)
+ h = _get_hierarchy(table, name)
+ table.Hierarchies.Remove(h)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name, "tableName": table_name}
+
+
+# ---------------------------------------------------------------------------
+# Calculation group operations
+# ---------------------------------------------------------------------------
+
+
+def calc_group_list(model: Any) -> list[dict[str, Any]]:
+ """List calculation groups (tables with CalculationGroup set)."""
+ results: list[dict[str, Any]] = []
+ for table in model.Tables:
+ cg = table.CalculationGroup
+ if cg is not None:
+ items = [str(ci.Name) for ci in cg.CalculationItems]
+ results.append({
+ "name": str(table.Name),
+ "description": _safe_str(table.Description),
+ "precedence": int(cg.Precedence),
+ "items": items,
+ })
+ return results
+
+
+def calc_group_create(
+ model: Any,
+ name: str,
+ description: str | None = None,
+ precedence: int | None = None,
+) -> dict[str, Any]:
+ """Create a calculation group table."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ Table, Partition, CalculationGroup, DataColumn, DataType = get_tom_classes(
+ "Table", "Partition", "CalculationGroup", "DataColumn", "DataType"
+ )
+
+ t = Table()
+ t.Name = name
+ if description is not None:
+ t.Description = description
+
+ cg = CalculationGroup()
+ if precedence is not None:
+ cg.Precedence = precedence
+ t.CalculationGroup = cg
+
+ # CG tables require a "Name" column of type String
+ col = DataColumn()
+ col.Name = "Name"
+ col.DataType = DataType.String
+ col.SourceColumn = "Name"
+ t.Columns.Add(col)
+
+ p = Partition()
+ p.Name = name
+ t.Partitions.Add(p)
+
+ model.Tables.Add(t)
+ model.SaveChanges()
+ return {"status": "created", "name": name}
+
+
+def calc_group_delete(model: Any, name: str) -> dict[str, Any]:
+ """Delete a calculation group table."""
+ table = _get_table(model, name)
+ if table.CalculationGroup is None:
+ raise ValueError(f"Table '{name}' is not a calculation group")
+ model.Tables.Remove(table)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name}
+
+
+def calc_item_list(model: Any, group_name: str) -> list[dict[str, Any]]:
+ """List calculation items in a group."""
+ table = _get_table(model, group_name)
+ cg = table.CalculationGroup
+ if cg is None:
+ raise ValueError(f"Table '{group_name}' is not a calculation group")
+ results: list[dict[str, Any]] = []
+ for ci in cg.CalculationItems:
+ results.append({
+ "name": str(ci.Name),
+ "expression": _safe_str(ci.Expression),
+ "ordinal": int(ci.Ordinal),
+ })
+ return results
+
+
+def calc_item_create(
+ model: Any,
+ group_name: str,
+ name: str,
+ expression: str,
+ ordinal: int | None = None,
+) -> dict[str, Any]:
+ """Create a calculation item in a group."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (CalculationItem,) = get_tom_classes("CalculationItem")
+
+ table = _get_table(model, group_name)
+ cg = table.CalculationGroup
+ if cg is None:
+ raise ValueError(f"Table '{group_name}' is not a calculation group")
+
+ ci = CalculationItem()
+ ci.Name = name
+ ci.Expression = expression
+ if ordinal is not None:
+ ci.Ordinal = ordinal
+ cg.CalculationItems.Add(ci)
+ model.SaveChanges()
+ return {"status": "created", "name": name, "groupName": group_name}
+
+
+# ---------------------------------------------------------------------------
+# Named expression operations
+# ---------------------------------------------------------------------------
+
+
+def expression_list(model: Any) -> list[dict[str, Any]]:
+ """List all named expressions (shared expressions / parameters)."""
+ results: list[dict[str, Any]] = []
+ for expr in model.Expressions:
+ results.append({
+ "name": str(expr.Name),
+ "kind": _safe_str(expr.Kind),
+ "expression": _safe_str(expr.Expression),
+ "description": _safe_str(expr.Description),
+ })
+ return results
+
+
+def _get_expression(model: Any, name: str) -> Any:
+ """Get a named expression by name."""
+ for expr in model.Expressions:
+ if expr.Name == name:
+ return expr
+ raise ValueError(f"Expression '{name}' not found")
+
+
+def expression_get(model: Any, name: str) -> dict[str, Any]:
+ """Get a named expression."""
+ expr = _get_expression(model, name)
+ return {
+ "name": str(expr.Name),
+ "kind": _safe_str(expr.Kind),
+ "expression": _safe_str(expr.Expression),
+ "description": _safe_str(expr.Description),
+ }
+
+
+def expression_create(
+ model: Any,
+ name: str,
+ expression: str,
+ description: str | None = None,
+) -> dict[str, Any]:
+ """Create a named expression."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ NamedExpression, ExpressionKind = get_tom_classes(
+ "NamedExpression", "ExpressionKind"
+ )
+ e = NamedExpression()
+ e.Name = name
+ e.Kind = ExpressionKind.M
+ e.Expression = expression
+ if description is not None:
+ e.Description = description
+ model.Expressions.Add(e)
+ model.SaveChanges()
+ return {"status": "created", "name": name}
+
+
+def expression_delete(model: Any, name: str) -> dict[str, Any]:
+ """Delete a named expression."""
+ expr = _get_expression(model, name)
+ model.Expressions.Remove(expr)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name}
+
+
+# ---------------------------------------------------------------------------
+# Culture operations
+# ---------------------------------------------------------------------------
+
+
+def culture_list(model: Any) -> list[dict[str, Any]]:
+ """List all cultures."""
+ results: list[dict[str, Any]] = []
+ for c in model.Cultures:
+ results.append({"name": str(c.Name)})
+ return results
+
+
+def culture_create(model: Any, name: str) -> dict[str, Any]:
+ """Create a culture."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (Culture,) = get_tom_classes("Culture")
+ c = Culture()
+ c.Name = name
+ model.Cultures.Add(c)
+ model.SaveChanges()
+ return {"status": "created", "name": name}
+
+
+def _get_culture(model: Any, name: str) -> Any:
+ """Get a culture by name."""
+ for c in model.Cultures:
+ if c.Name == name:
+ return c
+ raise ValueError(f"Culture '{name}' not found")
+
+
+def culture_delete(model: Any, name: str) -> dict[str, Any]:
+ """Delete a culture."""
+ c = _get_culture(model, name)
+ model.Cultures.Remove(c)
+ model.SaveChanges()
+ return {"status": "deleted", "name": name}
+
+
+# ---------------------------------------------------------------------------
+# Database operations (TMDL / TMSL / list)
+# ---------------------------------------------------------------------------
+
+
+def database_list(server: Any) -> list[dict[str, Any]]:
+ """List all databases on the server."""
+ results: list[dict[str, Any]] = []
+ for db in server.Databases:
+ results.append({
+ "name": str(db.Name),
+ "id": str(db.ID),
+ "compatibilityLevel": int(db.CompatibilityLevel),
+ "lastUpdated": str(db.LastUpdate),
+ })
+ return results
+
+
+def export_tmdl(database: Any, folder_path: str) -> dict[str, str]:
+ """Export a database to a TMDL folder."""
+ from pbi_cli.core.dotnet_loader import get_tmdl_serializer
+
+ TmdlSerializer = get_tmdl_serializer()
+ TmdlSerializer.SerializeDatabaseToFolder(database, folder_path)
+ return {"status": "exported", "path": folder_path}
+
+
+def import_tmdl(server: Any, folder_path: str) -> dict[str, str]:
+ """Import a model from a TMDL folder into the first database."""
+ from pbi_cli.core.dotnet_loader import get_tmdl_serializer
+
+ TmdlSerializer = get_tmdl_serializer()
+ db = TmdlSerializer.DeserializeDatabaseFromFolder(folder_path)
+ # Apply to the existing database
+ target = server.Databases[0]
+ target.Model.CopyFrom(db.Model)
+ target.Model.SaveChanges()
+ return {"status": "imported", "path": folder_path}
+
+
+def export_tmsl(database: Any) -> dict[str, str]:
+ """Export the database as a TMSL (JSON) script."""
+ from pbi_cli.core.dotnet_loader import get_tom_classes
+
+ (JsonSerializer,) = get_tom_classes("JsonSerializer")
+ script = JsonSerializer.SerializeDatabase(database)
+ return {"tmsl": str(script)}
+
+
+# ---------------------------------------------------------------------------
+# Trace operations
+# ---------------------------------------------------------------------------
+
+
+_active_trace: Any = None
+_trace_events: list[dict[str, Any]] = []
+
+
+def trace_start(server: Any) -> dict[str, str]:
+ """Start a diagnostic trace on the server."""
+ global _active_trace, _trace_events
+
+ if _active_trace is not None:
+ raise ValueError("A trace is already active. Stop it first.")
+
+ _trace_events = []
+
+ trace = server.Traces.Add()
+ trace.Name = "pbi-cli-trace"
+ trace.AutoRestart = True
+ trace.Update()
+ trace.Start()
+ _active_trace = trace
+ return {"status": "started", "traceId": str(trace.ID)}
+
+
+def trace_stop() -> dict[str, str]:
+ """Stop the active trace."""
+ global _active_trace
+
+ if _active_trace is None:
+ raise ValueError("No active trace to stop.")
+
+ _active_trace.Stop()
+ _active_trace.Drop()
+ _active_trace = None
+ return {"status": "stopped"}
+
+
+def trace_fetch() -> list[dict[str, Any]]:
+ """Fetch collected trace events."""
+ return list(_trace_events)
+
+
+def trace_export(path: str) -> dict[str, str]:
+ """Export trace events to a JSON file."""
+ import json
+
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(_trace_events, f, indent=2, default=str)
+ return {"status": "exported", "path": path, "eventCount": len(_trace_events)}
+
+
+# ---------------------------------------------------------------------------
+# Transaction operations
+# ---------------------------------------------------------------------------
+
+
+def transaction_begin(server: Any) -> dict[str, str]:
+ """Begin an explicit transaction."""
+ tx_id = server.BeginTransaction()
+ return {"status": "begun", "transactionId": str(tx_id)}
+
+
+def transaction_commit(server: Any, transaction_id: str = "") -> dict[str, str]:
+ """Commit the active or specified transaction."""
+ if transaction_id:
+ server.CommitTransaction(transaction_id)
+ else:
+ server.CommitTransaction()
+ return {"status": "committed", "transactionId": transaction_id}
+
+
+def transaction_rollback(server: Any, transaction_id: str = "") -> dict[str, str]:
+ """Rollback the active or specified transaction."""
+ if transaction_id:
+ server.RollbackTransaction(transaction_id)
+ else:
+ server.RollbackTransaction()
+ return {"status": "rolled_back", "transactionId": transaction_id}
diff --git a/src/pbi_cli/dlls/Microsoft.AnalysisServices.AdomdClient.dll b/src/pbi_cli/dlls/Microsoft.AnalysisServices.AdomdClient.dll
new file mode 100644
index 0000000..3d1f52d
Binary files /dev/null and b/src/pbi_cli/dlls/Microsoft.AnalysisServices.AdomdClient.dll differ
diff --git a/src/pbi_cli/dlls/Microsoft.AnalysisServices.Core.dll b/src/pbi_cli/dlls/Microsoft.AnalysisServices.Core.dll
new file mode 100644
index 0000000..2b7f85a
Binary files /dev/null and b/src/pbi_cli/dlls/Microsoft.AnalysisServices.Core.dll differ
diff --git a/src/pbi_cli/dlls/Microsoft.AnalysisServices.Tabular.Json.dll b/src/pbi_cli/dlls/Microsoft.AnalysisServices.Tabular.Json.dll
new file mode 100644
index 0000000..2d28a9a
Binary files /dev/null and b/src/pbi_cli/dlls/Microsoft.AnalysisServices.Tabular.Json.dll differ
diff --git a/src/pbi_cli/dlls/Microsoft.AnalysisServices.Tabular.dll b/src/pbi_cli/dlls/Microsoft.AnalysisServices.Tabular.dll
new file mode 100644
index 0000000..34cfd39
Binary files /dev/null and b/src/pbi_cli/dlls/Microsoft.AnalysisServices.Tabular.dll differ
diff --git a/src/pbi_cli/dlls/Microsoft.AnalysisServices.dll b/src/pbi_cli/dlls/Microsoft.AnalysisServices.dll
new file mode 100644
index 0000000..285affb
Binary files /dev/null and b/src/pbi_cli/dlls/Microsoft.AnalysisServices.dll differ
diff --git a/src/pbi_cli/dlls/__init__.py b/src/pbi_cli/dlls/__init__.py
new file mode 100644
index 0000000..a2a448e
--- /dev/null
+++ b/src/pbi_cli/dlls/__init__.py
@@ -0,0 +1,3 @@
+# Bundled Microsoft Analysis Services .NET DLLs.
+# Sourced from NuGet: Microsoft.AnalysisServices.NetCore.retail.amd64
+# and Microsoft.AnalysisServices.AdomdClient.NetCore.retail.amd64
diff --git a/src/pbi_cli/dlls/pbi_cli.runtimeconfig.json b/src/pbi_cli/dlls/pbi_cli.runtimeconfig.json
new file mode 100644
index 0000000..305361c
--- /dev/null
+++ b/src/pbi_cli/dlls/pbi_cli.runtimeconfig.json
@@ -0,0 +1 @@
+{"runtimeOptions": {"tfm": "net9.0", "framework": {"name": "Microsoft.NETCore.App", "version": "9.0.0"}, "rollForward": "LatestMajor"}}
\ No newline at end of file
diff --git a/src/pbi_cli/main.py b/src/pbi_cli/main.py
index 0c0c318..43c2a02 100644
--- a/src/pbi_cli/main.py
+++ b/src/pbi_cli/main.py
@@ -40,10 +40,10 @@ pass_context = click.make_pass_decorator(PbiContext, ensure=True)
def cli(ctx: click.Context, json_output: bool, connection: str | None) -> None:
"""pbi-cli: Power BI semantic model CLI.
- Wraps the Power BI MCP server for token-efficient usage with
- Claude Code and other AI agents.
+ Connects directly to Power BI Desktop's Analysis Services engine
+ for token-efficient usage with Claude Code and other AI agents.
- Run 'pbi connect' to auto-detect Power BI Desktop and download the MCP binary.
+ Run 'pbi connect' to auto-detect a running Power BI Desktop instance.
"""
ctx.ensure_object(PbiContext)
ctx.obj = PbiContext(json_output=json_output, connection=connection)
@@ -55,7 +55,7 @@ def _register_commands() -> None:
from pbi_cli.commands.calc_group import calc_group
from pbi_cli.commands.calendar import calendar
from pbi_cli.commands.column import column
- from pbi_cli.commands.connection import connect, connect_fabric, connections, disconnect
+ from pbi_cli.commands.connection import connect, connections, disconnect
from pbi_cli.commands.database import database
from pbi_cli.commands.dax import dax
from pbi_cli.commands.expression import expression
@@ -75,7 +75,6 @@ def _register_commands() -> None:
cli.add_command(setup)
cli.add_command(connect)
- cli.add_command(connect_fabric)
cli.add_command(disconnect)
cli.add_command(connections)
cli.add_command(dax)
diff --git a/src/pbi_cli/skills/power-bi-dax/SKILL.md b/src/pbi_cli/skills/power-bi-dax/SKILL.md
index 541b718..dd65832 100644
--- a/src/pbi_cli/skills/power-bi-dax/SKILL.md
+++ b/src/pbi_cli/skills/power-bi-dax/SKILL.md
@@ -12,7 +12,7 @@ Execute and validate DAX queries against connected Power BI models.
```bash
pipx install pbi-cli-tool
-pbi connect # Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # Auto-detects Power BI Desktop and installs skills
```
## Executing Queries
@@ -30,8 +30,6 @@ echo "EVALUATE Sales" | pbi dax execute -
# With options
pbi dax execute "EVALUATE Sales" --max-rows 100
-pbi dax execute "EVALUATE Sales" --metrics # Include execution metrics
-pbi dax execute "EVALUATE Sales" --metrics-only # Metrics without data
pbi dax execute "EVALUATE Sales" --timeout 300 # Custom timeout (seconds)
# JSON output for scripting
@@ -163,7 +161,6 @@ TOPN(
## Performance Tips
-- Use `--metrics` to identify slow queries
- Use `--max-rows` to limit result sets during development
- Run `pbi dax clear-cache` before benchmarking
- Prefer `SUMMARIZECOLUMNS` over `SUMMARIZE` for grouping
diff --git a/src/pbi_cli/skills/power-bi-deployment/SKILL.md b/src/pbi_cli/skills/power-bi-deployment/SKILL.md
index 9b08fc3..80b0fbb 100644
--- a/src/pbi_cli/skills/power-bi-deployment/SKILL.md
+++ b/src/pbi_cli/skills/power-bi-deployment/SKILL.md
@@ -1,18 +1,18 @@
---
name: Power BI Deployment
-description: Deploy Power BI semantic models to Fabric workspaces, import and export TMDL and TMSL formats, and manage model lifecycle. Use when the user mentions deploying, publishing, migrating, or version-controlling Power BI models.
+description: Import and export TMDL and TMSL formats, manage model lifecycle with transactions, and version-control Power BI semantic models. Use when the user mentions deploying, publishing, migrating, or version-controlling Power BI models.
tools: pbi-cli
---
# Power BI Deployment Skill
-Manage model lifecycle with TMDL export/import and Fabric workspace deployment.
+Manage model lifecycle with TMDL export/import, transactions, and version control.
## Prerequisites
```bash
pipx install pbi-cli-tool
-pbi connect # Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # Auto-detects Power BI Desktop and installs skills
```
## Connecting to Targets
@@ -24,13 +24,11 @@ pbi connect
# Local with explicit port
pbi connect -d localhost:54321
-# Fabric workspace (cloud)
-pbi connect-fabric --workspace "Production" --model "Sales Model"
-
# Named connections for switching
-pbi connect --name dev
-pbi connect-fabric --workspace "Production" --model "Sales" --name prod
+pbi connect -d localhost:54321 --name dev
pbi connections list
+pbi connections last
+pbi disconnect
```
## TMDL Export and Import
@@ -43,13 +41,6 @@ pbi database export-tmdl ./model-tmdl/
# Import TMDL folder into connected model
pbi database import-tmdl ./model-tmdl/
-
-# Export individual objects
-pbi model export-tmdl # Full model definition
-pbi table export-tmdl Sales # Single table
-pbi measure export-tmdl "Total Revenue" -t Sales # Single measure
-pbi relationship export-tmdl RelName # Single relationship
-pbi security-role export-tmdl "Readers" # Security role
```
## TMSL Export
@@ -85,18 +76,14 @@ pbi transaction commit
pbi transaction rollback
```
-## Model Refresh
+## Table Refresh
```bash
-# Refresh entire model
-pbi model refresh # Automatic (default)
-pbi model refresh --type Full # Full refresh
-pbi model refresh --type Calculate # Recalculate only
-pbi model refresh --type DataOnly # Data only, no recalc
-pbi model refresh --type Defragment # Defragment storage
-
# Refresh individual tables
pbi table refresh Sales --type Full
+pbi table refresh Sales --type Automatic
+pbi table refresh Sales --type Calculate
+pbi table refresh Sales --type DataOnly
```
## Workflow: Version Control with Git
@@ -110,26 +97,11 @@ cd model/
git add .
git commit -m "feat: add new revenue measures"
-# 3. Deploy to another environment
-pbi connect-fabric --workspace "Staging" --model "Sales Model"
+# 3. Later, import back into Power BI Desktop
+pbi connect
pbi database import-tmdl ./model/
```
-## Workflow: Promote Dev to Production
-
-```bash
-# 1. Connect to dev and export
-pbi connect --data-source localhost:54321 --name dev
-pbi database export-tmdl ./staging-model/
-
-# 2. Connect to production and import
-pbi connect-fabric --workspace "Production" --model "Sales" --name prod
-pbi database import-tmdl ./staging-model/
-
-# 3. Refresh production data
-pbi model refresh --type Full
-```
-
## Workflow: Inspect Model Before Deploy
```bash
@@ -152,4 +124,4 @@ pbi --json relationship list
- Test changes in dev before deploying to production
- Use `--json` for scripted deployments
- Store TMDL in git for version history
-- Use named connections (`--name`) to avoid accidental deployments to wrong environment
+- Use named connections (`--name`) to avoid accidental changes to wrong environment
diff --git a/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md b/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md
new file mode 100644
index 0000000..049ecfa
--- /dev/null
+++ b/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md
@@ -0,0 +1,139 @@
+---
+name: Power BI Diagnostics
+description: Troubleshoot Power BI model performance, trace query execution, manage caches, and verify the pbi-cli environment. Use when the user mentions slow queries, performance issues, tracing, profiling, or setup problems.
+tools: pbi-cli
+---
+
+# Power BI Diagnostics Skill
+
+Troubleshoot performance, trace queries, and verify the pbi-cli environment.
+
+## Prerequisites
+
+```bash
+pipx install pbi-cli-tool
+pbi connect # Auto-detects Power BI Desktop and installs skills
+```
+
+## Environment Check
+
+```bash
+# Verify pythonnet and .NET DLLs are installed
+pbi setup
+
+# Show detailed environment info (version, DLL paths, pythonnet status)
+pbi setup --info
+pbi --json setup --info
+
+# Check CLI version
+pbi --version
+```
+
+## Model Health Check
+
+```bash
+# Quick model overview
+pbi --json model get
+
+# Object counts (tables, columns, measures, relationships, partitions)
+pbi --json model stats
+
+# List all tables with column/measure counts
+pbi --json table list
+```
+
+## Query Tracing
+
+Capture diagnostic events during DAX query execution:
+
+```bash
+# Start a trace
+pbi trace start
+
+# Execute the query you want to profile
+pbi dax execute "EVALUATE SUMMARIZECOLUMNS(Products[Category], \"Total\", SUM(Sales[Amount]))"
+
+# Stop the trace
+pbi trace stop
+
+# Fetch captured trace events
+pbi --json trace fetch
+
+# Export trace events to a file
+pbi trace export ./trace-output.json
+```
+
+## Cache Management
+
+```bash
+# Clear the formula engine cache (do this before benchmarking)
+pbi dax clear-cache
+```
+
+## Connection Diagnostics
+
+```bash
+# List all saved connections
+pbi connections list
+pbi --json connections list
+
+# Show the last-used connection
+pbi connections last
+
+# Reconnect to a specific data source
+pbi connect -d localhost:54321
+
+# Disconnect
+pbi disconnect
+```
+
+## Workflow: Profile a Slow Query
+
+```bash
+# 1. Clear cache for a clean benchmark
+pbi dax clear-cache
+
+# 2. Start tracing
+pbi trace start
+
+# 3. Run the slow query
+pbi dax execute "EVALUATE SUMMARIZECOLUMNS(Products[Category], \"Total\", SUM(Sales[Amount]))" --timeout 300
+
+# 4. Stop tracing
+pbi trace stop
+
+# 5. Export trace for analysis
+pbi trace export ./slow-query-trace.json
+
+# 6. Review trace events
+pbi --json trace fetch
+```
+
+## Workflow: Model Health Audit
+
+```bash
+# 1. Model overview
+pbi --json model get
+pbi --json model stats
+
+# 2. Check table sizes and structure
+pbi --json table list
+
+# 3. Review relationships
+pbi --json relationship list
+
+# 4. Check security roles
+pbi --json security-role list
+
+# 5. Export full model for offline review
+pbi database export-tmdl ./audit-export/
+```
+
+## Best Practices
+
+- Clear cache before benchmarking: `pbi dax clear-cache`
+- Use `--timeout` for long-running queries to avoid premature cancellation
+- Export traces to files for sharing with teammates
+- Run `pbi setup --info` first when troubleshooting environment issues
+- Use `--json` output for automated monitoring scripts
+- Use `pbi repl` for interactive debugging sessions with persistent connection
diff --git a/src/pbi_cli/skills/power-bi-docs/SKILL.md b/src/pbi_cli/skills/power-bi-docs/SKILL.md
index 20ca194..db31a64 100644
--- a/src/pbi_cli/skills/power-bi-docs/SKILL.md
+++ b/src/pbi_cli/skills/power-bi-docs/SKILL.md
@@ -12,7 +12,7 @@ Generate comprehensive documentation for Power BI semantic models.
```bash
pipx install pbi-cli-tool
-pbi connect # Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # Auto-detects Power BI Desktop and installs skills
```
## Quick Model Overview
@@ -55,8 +55,14 @@ pbi --json calc-group list
# Perspectives
pbi --json perspective list
-# Named expressions
+# Named expressions (M queries)
pbi --json expression list
+
+# Partitions
+pbi --json partition list --table Sales
+
+# Calendar/date tables
+pbi --json calendar list
```
## Export Full Model as TMDL
@@ -117,23 +123,23 @@ Create a complete measure inventory:
# List all measures with expressions
pbi --json measure list
-# Export individual measure definitions as TMDL
-pbi measure export-tmdl "Total Revenue" --table Sales
-pbi measure export-tmdl "YTD Revenue" --table Sales
+# Export full model as TMDL (includes all measure definitions)
+pbi database export-tmdl ./tmdl-export/
```
-## Translation and Culture Management
+## Culture Management
-For multi-language documentation:
+For multi-language models:
```bash
-# List cultures/translations
+# List cultures (locales)
pbi --json advanced culture list
-pbi --json advanced translation list
-# Create culture for localization
+# Create a culture for localization
pbi advanced culture create "fr-FR"
-pbi advanced translation create --culture "fr-FR" --object "Total Sales" --translation "Ventes Totales"
+
+# Delete a culture
+pbi advanced culture delete "fr-FR"
```
## Best Practices
diff --git a/src/pbi_cli/skills/power-bi-modeling/SKILL.md b/src/pbi_cli/skills/power-bi-modeling/SKILL.md
index 00a0770..371558d 100644
--- a/src/pbi_cli/skills/power-bi-modeling/SKILL.md
+++ b/src/pbi_cli/skills/power-bi-modeling/SKILL.md
@@ -12,7 +12,7 @@ Use pbi-cli to manage semantic model structure. Requires `pipx install pbi-cli-t
```bash
pipx install pbi-cli-tool
-pbi connect # Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # Auto-detects Power BI Desktop and installs skills
```
## Tables
@@ -26,7 +26,6 @@ pbi table rename OldName NewName # Rename table
pbi table refresh Sales --type Full # Refresh table data
pbi table schema Sales # Get table schema
pbi table mark-date Calendar --date-column Date # Mark as date table
-pbi table export-tmdl Sales # Export as TMDL
```
## Columns
@@ -53,7 +52,6 @@ pbi measure update "Total Revenue" -t Sales -e "SUMX(Sales, Sales[Qty]*Sales[Pri
pbi measure delete "Old Measure" -t Sales # Delete
pbi measure rename "Old" "New" -t Sales # Rename
pbi measure move "Revenue" -t Sales --to-table Finance # Move to another table
-pbi measure export-tmdl "Total Revenue" -t Sales # Export as TMDL
```
## Relationships
@@ -65,7 +63,9 @@ pbi relationship create \
--from-table Sales --from-column ProductKey \
--to-table Products --to-column ProductKey # Create relationship
pbi relationship delete RelName # Delete
-pbi relationship export-tmdl RelName # Export as TMDL
+pbi relationship find --table Sales # Find relationships for a table
+pbi relationship activate RelName # Activate
+pbi relationship deactivate RelName # Deactivate
```
## Hierarchies
@@ -74,7 +74,6 @@ pbi relationship export-tmdl RelName # Export as TMDL
pbi hierarchy list --table Date # List hierarchies
pbi hierarchy get "Calendar" --table Date # Get details
pbi hierarchy create "Calendar" --table Date # Create
-pbi hierarchy add-level "Calendar" --table Date --column Year --ordinal 0 # Add level
pbi hierarchy delete "Calendar" --table Date # Delete
```
@@ -124,4 +123,4 @@ pbi relationship list
- Organize measures into display folders by business domain
- Always mark calendar tables with `mark-date` for time intelligence
- Use `--json` flag when scripting: `pbi --json measure list`
-- Export TMDL for version control: `pbi table export-tmdl Sales`
+- Export TMDL for version control: `pbi database export-tmdl ./model/`
diff --git a/src/pbi_cli/skills/power-bi-partitions/SKILL.md b/src/pbi_cli/skills/power-bi-partitions/SKILL.md
new file mode 100644
index 0000000..c92d1b2
--- /dev/null
+++ b/src/pbi_cli/skills/power-bi-partitions/SKILL.md
@@ -0,0 +1,133 @@
+---
+name: Power BI Partitions & Expressions
+description: Manage Power BI table partitions, named expressions (M/Power Query sources), and calendar table configuration. Use when the user mentions partitions, data sources, M expressions, Power Query, incremental refresh, or calendar/date tables.
+tools: pbi-cli
+---
+
+# Power BI Partitions & Expressions Skill
+
+Manage table partitions, named expressions (M queries), and calendar tables.
+
+## Prerequisites
+
+```bash
+pipx install pbi-cli-tool
+pbi connect # Auto-detects Power BI Desktop and installs skills
+```
+
+## Partitions
+
+Partitions define how data is loaded into a table. Each table has at least one partition.
+
+```bash
+# List partitions in a table
+pbi partition list --table Sales
+pbi --json partition list --table Sales
+
+# Create a partition with an M expression
+pbi partition create "Sales_2024" --table Sales \
+ --expression "let Source = Sql.Database(\"server\", \"db\"), Sales = Source{[Schema=\"dbo\",Item=\"Sales\"]}[Data], Filtered = Table.SelectRows(Sales, each [Year] = 2024) in Filtered" \
+ --mode Import
+
+# Create a partition with DirectQuery mode
+pbi partition create "Sales_Live" --table Sales --mode DirectQuery
+
+# Delete a partition
+pbi partition delete "Sales_Old" --table Sales
+
+# Refresh a specific partition
+pbi partition refresh "Sales_2024" --table Sales
+```
+
+## Named Expressions
+
+Named expressions are shared M/Power Query definitions used as data sources or reusable query logic.
+
+```bash
+# List all named expressions
+pbi expression list
+pbi --json expression list
+
+# Get a specific expression
+pbi expression get "ServerURL"
+pbi --json expression get "ServerURL"
+
+# Create a named expression (M query)
+pbi expression create "ServerURL" \
+ --expression '"https://api.example.com/data"' \
+ --description "API endpoint for data refresh"
+
+# Create a parameterized data source
+pbi expression create "DatabaseServer" \
+ --expression '"sqlserver.company.com"' \
+ --description "Production database server name"
+
+# Delete a named expression
+pbi expression delete "OldSource"
+```
+
+## Calendar Tables
+
+Calendar/date tables enable time intelligence in DAX. Mark a table as a date table to unlock functions like TOTALYTD, SAMEPERIODLASTYEAR, etc.
+
+```bash
+# List all calendar/date tables
+pbi calendar list
+pbi --json calendar list
+
+# Mark a table as a calendar table
+pbi calendar mark Calendar --date-column Date
+
+# Alternative: use the table command
+pbi table mark-date Calendar --date-column Date
+```
+
+## Workflow: Set Up Partitioned Table
+
+```bash
+# 1. Create a table
+pbi table create Sales --mode Import
+
+# 2. Create partitions for different date ranges
+pbi partition create "Sales_2023" --table Sales \
+ --expression "let Source = ... in Filtered2023" \
+ --mode Import
+
+pbi partition create "Sales_2024" --table Sales \
+ --expression "let Source = ... in Filtered2024" \
+ --mode Import
+
+# 3. Refresh specific partitions
+pbi partition refresh "Sales_2024" --table Sales
+
+# 4. Verify partitions
+pbi --json partition list --table Sales
+```
+
+## Workflow: Manage Data Sources
+
+```bash
+# 1. List current data source expressions
+pbi --json expression list
+
+# 2. Create shared connection parameters
+pbi expression create "ServerName" \
+ --expression '"prod-sql-01.company.com"' \
+ --description "Production SQL Server"
+
+pbi expression create "DatabaseName" \
+ --expression '"SalesDB"' \
+ --description "Production database"
+
+# 3. Verify
+pbi --json expression list
+```
+
+## Best Practices
+
+- Use partitions for large tables to enable incremental refresh
+- Refresh only the partitions that have new data (`pbi partition refresh`)
+- Use named expressions for shared connection parameters (server names, URLs)
+- Always mark calendar tables with `pbi calendar mark` for time intelligence
+- Use `--json` output for scripted partition management
+- Export model as TMDL to version-control partition definitions: `pbi database export-tmdl ./model/`
diff --git a/src/pbi_cli/skills/power-bi-security/SKILL.md b/src/pbi_cli/skills/power-bi-security/SKILL.md
index 1c7f06c..318ed15 100644
--- a/src/pbi_cli/skills/power-bi-security/SKILL.md
+++ b/src/pbi_cli/skills/power-bi-security/SKILL.md
@@ -12,7 +12,7 @@ Manage row-level security (RLS) and perspectives for Power BI models.
```bash
pipx install pbi-cli-tool
-pbi connect # Auto-detects Power BI Desktop, downloads binary, installs skills
+pbi connect # Auto-detects Power BI Desktop and installs skills
```
## Security Roles (RLS)
@@ -30,9 +30,6 @@ pbi security-role create "Regional Manager" \
# Delete a role
pbi security-role delete "Regional Manager"
-
-# Export role as TMDL
-pbi security-role export-tmdl "Regional Manager"
```
## Perspectives
@@ -60,9 +57,8 @@ pbi security-role create "Finance Team" --description "Finance data only"
# 2. Verify roles were created
pbi --json security-role list
-# 3. Export for version control
-pbi security-role export-tmdl "Sales Team"
-pbi security-role export-tmdl "Finance Team"
+# 3. Export full model for version control (includes roles)
+pbi database export-tmdl ./model-backup/
```
## Workflow: Create User-Focused Perspectives
@@ -108,7 +104,7 @@ pbi security-role create "Manager View" \
- Create roles with clear, descriptive names
- Always add descriptions explaining the access restriction
-- Export roles as TMDL for version control
+- Export model as TMDL for version control (`pbi database export-tmdl`)
- Test RLS thoroughly before publishing to production
- Use perspectives to simplify the model for different user groups
- Document role-to-group mappings externally (RLS roles map to Azure AD groups in Power BI Service)
diff --git a/src/pbi_cli/utils/platform.py b/src/pbi_cli/utils/platform.py
index 01974a7..8effbc1 100644
--- a/src/pbi_cli/utils/platform.py
+++ b/src/pbi_cli/utils/platform.py
@@ -1,61 +1,10 @@
-"""Platform and architecture detection for binary resolution."""
+"""Platform detection and Power BI Desktop port discovery."""
from __future__ import annotations
import platform
-import stat
from pathlib import Path
-# Maps (system, machine) to VS Marketplace target platform identifier.
-PLATFORM_MAP: dict[tuple[str, str], str] = {
- ("Windows", "AMD64"): "win32-x64",
- ("Windows", "x86_64"): "win32-x64",
- ("Windows", "ARM64"): "win32-arm64",
- ("Darwin", "arm64"): "darwin-arm64",
- ("Linux", "x86_64"): "linux-x64",
- ("Linux", "aarch64"): "linux-arm64",
-}
-
-# Binary name per OS.
-BINARY_NAMES: dict[str, str] = {
- "Windows": "powerbi-modeling-mcp.exe",
- "Darwin": "powerbi-modeling-mcp",
- "Linux": "powerbi-modeling-mcp",
-}
-
-
-def detect_platform() -> str:
- """Return the VS Marketplace target platform string for this machine.
-
- Raises ValueError if the platform is unsupported.
- """
- system = platform.system()
- machine = platform.machine()
- key = (system, machine)
- target = PLATFORM_MAP.get(key)
- if target is None:
- raise ValueError(
- f"Unsupported platform: {system}/{machine}. "
- f"Supported: {', '.join(f'{s}/{m}' for s, m in PLATFORM_MAP)}"
- )
- return target
-
-
-def binary_name() -> str:
- """Return the expected binary filename for this OS."""
- system = platform.system()
- name = BINARY_NAMES.get(system)
- if name is None:
- raise ValueError(f"Unsupported OS: {system}")
- return name
-
-
-def ensure_executable(path: Path) -> None:
- """Set executable permission on non-Windows systems."""
- if platform.system() != "Windows":
- current = path.stat().st_mode
- path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
-
def _workspace_candidates() -> list[Path]:
"""Return candidate AnalysisServicesWorkspaces directories.
@@ -116,27 +65,3 @@ def discover_pbi_port() -> int | None:
return int(port_text)
except (ValueError, OSError):
return None
-
-
-def find_vscode_extension_binary() -> Path | None:
- """Look for the binary in the VS Code extension install directory.
-
- This is the fallback resolution path when the user has the VS Code
- extension installed but hasn't run 'pbi connect' or 'pbi setup'.
- """
- vscode_ext_dir = Path.home() / ".vscode" / "extensions"
- if not vscode_ext_dir.exists():
- return None
-
- matches = sorted(
- vscode_ext_dir.glob("analysis-services.powerbi-modeling-mcp-*/server"),
- reverse=True,
- )
- if not matches:
- return None
-
- server_dir = matches[0]
- bin_path = server_dir / binary_name()
- if bin_path.exists():
- return bin_path
- return None
diff --git a/src/pbi_cli/utils/repl.py b/src/pbi_cli/utils/repl.py
index 0fb4b5c..3a14517 100644
--- a/src/pbi_cli/utils/repl.py
+++ b/src/pbi_cli/utils/repl.py
@@ -1,7 +1,7 @@
-"""Interactive REPL for pbi-cli with persistent MCP connection.
+"""Interactive REPL for pbi-cli with persistent session.
-Keeps the Power BI MCP server process alive across commands so that
-subsequent calls skip the startup cost (~2-3 seconds per invocation).
+Keeps a direct .NET connection alive across commands so that
+subsequent calls are near-instant (no reconnection overhead).
Usage:
pbi repl
@@ -19,9 +19,8 @@ from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
from pbi_cli.core.config import PBI_CLI_HOME, ensure_home_dir
-from pbi_cli.core.connection_store import load_connections
-from pbi_cli.core.mcp_client import get_shared_client
from pbi_cli.core.output import print_error, print_info, print_warning
+from pbi_cli.core.session import get_current_session
_QUIT_COMMANDS = frozenset({"exit", "quit", "q"})
_HISTORY_FILE = PBI_CLI_HOME / "repl_history"
@@ -54,9 +53,9 @@ class PbiRepl:
def _get_prompt(self) -> str:
"""Dynamic prompt showing active connection name."""
- store = load_connections()
- if store.last_used:
- return f"pbi({store.last_used})> "
+ session = get_current_session()
+ if session is not None:
+ return f"pbi({session.connection_name})> "
return "pbi> "
def _execute_line(self, line: str) -> None:
@@ -119,14 +118,6 @@ class PbiRepl:
print_info("pbi-cli interactive mode. Type 'exit' or Ctrl+D to quit.")
- # Pre-warm the shared MCP server
- try:
- client = get_shared_client()
- client.start()
- except Exception as e:
- print_warning(f"Could not pre-warm MCP server: {e}")
- print_info("Commands will start the server on first use.")
-
try:
while True:
try:
@@ -139,11 +130,9 @@ class PbiRepl:
except EOFError:
pass
finally:
- # Shut down the shared MCP server
- try:
- get_shared_client().stop()
- except Exception:
- pass
+ from pbi_cli.core.session import disconnect
+
+ disconnect()
print_info("Goodbye.")
diff --git a/tests/conftest.py b/tests/conftest.py
index 2c6465b..26b4bbf 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,8 @@
-"""Shared test fixtures for pbi-cli."""
+"""Shared test fixtures for pbi-cli v2 (direct .NET backend)."""
from __future__ import annotations
+from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -9,96 +10,241 @@ import pytest
from click.testing import CliRunner
# ---------------------------------------------------------------------------
-# Canned MCP responses used by the mock client
-# ---------------------------------------------------------------------------
-
-CANNED_RESPONSES: dict[str, dict[str, Any]] = {
- "connection_operations": {
- "Connect": {"status": "connected", "connectionName": "test-conn"},
- "ConnectFabric": {"status": "connected", "connectionName": "ws/model"},
- "Disconnect": {"status": "disconnected"},
- },
- "dax_query_operations": {
- "Execute": {"columns": ["Amount"], "rows": [{"Amount": 42}]},
- "Validate": {"isValid": True},
- "ClearCache": {"status": "cleared"},
- },
- "measure_operations": {
- "List": [
- {"name": "Total Sales", "expression": "SUM(Sales[Amount])", "tableName": "Sales"},
- ],
- "Get": {"name": "Total Sales", "expression": "SUM(Sales[Amount])"},
- "Create": {"status": "created"},
- "Update": {"status": "updated"},
- "Delete": {"status": "deleted"},
- "Rename": {"status": "renamed"},
- "Move": {"status": "moved"},
- "ExportTMDL": "measure 'Total Sales'\n expression = SUM(Sales[Amount])",
- },
- "table_operations": {
- "List": [{"name": "Sales", "mode": "Import"}],
- "Get": {"name": "Sales", "mode": "Import", "columns": []},
- "Create": {"status": "created"},
- "Delete": {"status": "deleted"},
- "Refresh": {"status": "refreshed"},
- "GetSchema": {"name": "Sales", "columns": [{"name": "Amount", "type": "double"}]},
- "ExportTMDL": "table Sales\n mode: Import",
- "Rename": {"status": "renamed"},
- "MarkAsDateTable": {"status": "marked"},
- },
- "model_operations": {
- "Get": {"name": "My Model", "compatibilityLevel": 1600},
- "GetStats": {"tables": 5, "measures": 10, "columns": 30},
- "Refresh": {"status": "refreshed"},
- "Rename": {"status": "renamed"},
- "ExportTMDL": "model Model\n culture: en-US",
- },
- "column_operations": {
- "List": [{"name": "Amount", "tableName": "Sales", "dataType": "double"}],
- "Get": {"name": "Amount", "dataType": "double"},
- "Create": {"status": "created"},
- "Update": {"status": "updated"},
- "Delete": {"status": "deleted"},
- "Rename": {"status": "renamed"},
- "ExportTMDL": "column Amount\n dataType: double",
- },
-}
-
-
-# ---------------------------------------------------------------------------
-# Mock MCP client
+# Mock TOM objects used by the mock session
# ---------------------------------------------------------------------------
-class MockPbiMcpClient:
- """Fake MCP client returning canned responses without spawning a process."""
+class MockCollection:
+ """Simulates a .NET ICollection (iterable, with Count/Add/Remove)."""
- def __init__(self, responses: dict[str, dict[str, Any]] | None = None) -> None:
- self.responses = responses or CANNED_RESPONSES
- self.started = False
- self.stopped = False
- self.calls: list[tuple[str, dict[str, Any]]] = []
+ def __init__(self, items: list[Any] | None = None) -> None:
+ self._items = list(items or [])
- def start(self) -> None:
- self.started = True
+ def __iter__(self) -> Any:
+ return iter(self._items)
- def stop(self) -> None:
- self.stopped = True
+ def __getitem__(self, index: int) -> Any:
+ return self._items[index]
- def call_tool(self, tool_name: str, request: dict[str, Any]) -> Any:
- self.calls.append((tool_name, request))
- operation = request.get("operation", "")
- tool_responses = self.responses.get(tool_name, {})
- if operation in tool_responses:
- return tool_responses[operation]
- return {"status": "ok"}
+ @property
+ def Count(self) -> int:
+ return len(self._items)
- def list_tools(self) -> list[dict[str, Any]]:
- return [
- {"name": "measure_operations", "description": "Measure CRUD"},
- {"name": "table_operations", "description": "Table CRUD"},
- {"name": "dax_query_operations", "description": "DAX queries"},
- ]
+ def Add(self, item: Any = None) -> Any:
+ if item is not None:
+ self._items.append(item)
+ return item
+ # Parameterless Add() -- create a simple object and return it
+ obj = type("TraceObj", (), {
+ "Name": "", "AutoRestart": False, "ID": "trace-1",
+ "Update": lambda self: None,
+ "Start": lambda self: None,
+ "Stop": lambda self: None,
+ })()
+ self._items.append(obj)
+ return obj
+
+ def Remove(self, item: Any) -> None:
+ self._items.remove(item)
+
+
+@dataclass
+class MockMeasure:
+ Name: str = "Total Sales"
+ Expression: str = "SUM(Sales[Amount])"
+ DisplayFolder: str = ""
+ Description: str = ""
+ FormatString: str = ""
+ IsHidden: bool = False
+
+
+@dataclass
+class MockColumn:
+ Name: str = "Amount"
+ DataType: str = "Double"
+ Type: str = "DataColumn"
+ SourceColumn: str = "Amount"
+ DisplayFolder: str = ""
+ Description: str = ""
+ FormatString: str = ""
+ IsHidden: bool = False
+ IsKey: bool = False
+
+
+@dataclass
+class MockPartition:
+ Name: str = "Partition1"
+ Mode: str = "Import"
+ SourceType: str = "M"
+ State: str = "Ready"
+
+
+@dataclass
+class MockRelationship:
+ Name: str = "rel1"
+ FromTable: Any = None
+ FromColumn: Any = None
+ ToTable: Any = None
+ ToColumn: Any = None
+ CrossFilteringBehavior: str = "OneDirection"
+ IsActive: bool = True
+
+
+@dataclass
+class MockHierarchy:
+ Name: str = "DateHierarchy"
+ Description: str = ""
+ Levels: Any = None
+
+ def __post_init__(self) -> None:
+ if self.Levels is None:
+ self.Levels = MockCollection()
+
+
+@dataclass
+class MockLevel:
+ Name: str = "Year"
+ Ordinal: int = 0
+ Column: Any = None
+
+
+@dataclass
+class MockRole:
+ Name: str = "Reader"
+ Description: str = ""
+ ModelPermission: str = "Read"
+ TablePermissions: Any = None
+
+ def __post_init__(self) -> None:
+ if self.TablePermissions is None:
+ self.TablePermissions = MockCollection()
+
+
+@dataclass
+class MockPerspective:
+ Name: str = "Sales View"
+ Description: str = ""
+
+
+@dataclass
+class MockExpression:
+ Name: str = "ServerURL"
+ Kind: str = "M"
+ Expression: str = '"https://example.com"'
+ Description: str = ""
+
+
+@dataclass
+class MockCulture:
+ Name: str = "en-US"
+
+
+class MockTable:
+ """Simulates a TOM Table with nested collections."""
+
+ def __init__(
+ self,
+ name: str = "Sales",
+ data_category: str = "",
+ description: str = "",
+ ) -> None:
+ self.Name = name
+ self.DataCategory = data_category
+ self.Description = description
+ self.IsHidden = False
+ self.CalculationGroup = None
+ self.Measures = MockCollection([MockMeasure()])
+ self.Columns = MockCollection([MockColumn()])
+ self.Partitions = MockCollection([MockPartition()])
+ self.Hierarchies = MockCollection()
+
+
+class MockModel:
+ """Simulates a TOM Model."""
+
+ def __init__(self) -> None:
+ self.Name = "TestModel"
+ self.Description = ""
+ self.DefaultMode = "Import"
+ self.Culture = "en-US"
+ self.CompatibilityLevel = 1600
+
+ self.Tables = MockCollection([MockTable()])
+ self.Relationships = MockCollection()
+ self.Roles = MockCollection()
+ self.Perspectives = MockCollection()
+ self.Expressions = MockCollection()
+ self.Cultures = MockCollection()
+
+ def SaveChanges(self) -> None:
+ pass
+
+ def RequestRefresh(self, refresh_type: Any) -> None:
+ pass
+
+
+class MockDatabase:
+ """Simulates a TOM Database."""
+
+ def __init__(self, model: MockModel | None = None) -> None:
+ self.Name = "TestDB"
+ self.ID = "TestDB-ID"
+ self.CompatibilityLevel = 1600
+ self.LastUpdate = "2026-01-01"
+ self.Model = model or MockModel()
+
+
+class MockServer:
+ """Simulates a TOM Server."""
+
+ def __init__(self, database: MockDatabase | None = None) -> None:
+ db = database or MockDatabase()
+ self.Databases = MockCollection([db])
+ self.Traces = MockCollection()
+
+ def Connect(self, conn_str: str) -> None:
+ pass
+
+ def Disconnect(self) -> None:
+ pass
+
+ def BeginTransaction(self) -> str:
+ return "tx-001"
+
+ def CommitTransaction(self, tx_id: str = "") -> None:
+ pass
+
+ def RollbackTransaction(self, tx_id: str = "") -> None:
+ pass
+
+
+class MockAdomdConnection:
+ """Simulates an AdomdConnection."""
+
+ def Open(self) -> None:
+ pass
+
+ def Close(self) -> None:
+ pass
+
+
+def build_mock_session() -> Any:
+ """Build a complete mock Session for testing."""
+ from pbi_cli.core.session import Session
+
+ model = MockModel()
+ database = MockDatabase(model)
+ server = MockServer(database)
+ adomd = MockAdomdConnection()
+
+ return Session(
+ server=server,
+ database=database,
+ model=model,
+ adomd_connection=adomd,
+ connection_name="test-conn",
+ data_source="localhost:12345",
+ )
# ---------------------------------------------------------------------------
@@ -107,28 +253,31 @@ class MockPbiMcpClient:
@pytest.fixture
-def mock_client() -> MockPbiMcpClient:
- """A fresh mock MCP client."""
- return MockPbiMcpClient()
+def mock_session() -> Any:
+ """A fresh mock session."""
+ return build_mock_session()
@pytest.fixture
-def patch_get_client(
- monkeypatch: pytest.MonkeyPatch, mock_client: MockPbiMcpClient
-) -> MockPbiMcpClient:
- """Monkeypatch get_client in _helpers and connection modules."""
- factory = lambda repl_mode=False: mock_client # noqa: E731
-
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", factory)
- monkeypatch.setattr("pbi_cli.commands.connection.get_client", factory)
-
- # Also patch dax.py which calls get_client directly
- monkeypatch.setattr("pbi_cli.commands.dax.get_client", factory)
-
- # Skip auto-setup (binary download + skills install) in tests
+def patch_session(monkeypatch: pytest.MonkeyPatch, mock_session: Any) -> Any:
+ """Monkeypatch get_session_for_command to return mock session."""
+ monkeypatch.setattr(
+ "pbi_cli.core.session.get_session_for_command",
+ lambda ctx: mock_session,
+ )
+ # Also patch modules that import get_session_for_command at module level
+ monkeypatch.setattr(
+ "pbi_cli.commands.model.get_session_for_command",
+ lambda ctx: mock_session,
+ )
+ # Also patch connection commands that call session.connect directly
+ monkeypatch.setattr(
+ "pbi_cli.core.session.connect",
+ lambda data_source, catalog="": mock_session,
+ )
+ # Skip skill install in connect
monkeypatch.setattr("pbi_cli.commands.connection._ensure_ready", lambda: None)
-
- return mock_client
+ return mock_session
@pytest.fixture
diff --git a/tests/test_binary_manager.py b/tests/test_binary_manager.py
deleted file mode 100644
index 80bd190..0000000
--- a/tests/test_binary_manager.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""Tests for pbi_cli.core.binary_manager."""
-
-from __future__ import annotations
-
-import os
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-
-from pbi_cli.core.binary_manager import (
- _binary_source,
- _find_managed_binary,
- get_binary_info,
- resolve_binary,
-)
-
-
-def test_resolve_binary_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- fake_bin = tmp_path / "powerbi-modeling-mcp.exe"
- fake_bin.write_text("fake", encoding="utf-8")
- monkeypatch.setenv("PBI_MCP_BINARY", str(fake_bin))
-
- result = resolve_binary()
- assert result == fake_bin
-
-
-def test_resolve_binary_env_var_missing_file(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setenv("PBI_MCP_BINARY", "/nonexistent/path")
- with pytest.raises(FileNotFoundError, match="non-existent"):
- resolve_binary()
-
-
-def test_resolve_binary_not_found(tmp_config: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.delenv("PBI_MCP_BINARY", raising=False)
- with (
- patch("pbi_cli.core.binary_manager.find_vscode_extension_binary", return_value=None),
- patch("pbi_cli.core.binary_manager.PBI_CLI_HOME", tmp_config),
- ):
- with pytest.raises(FileNotFoundError, match="not found"):
- resolve_binary()
-
-
-def test_find_managed_binary(tmp_config: Path) -> None:
- bin_dir = tmp_config / "bin" / "0.4.0"
- bin_dir.mkdir(parents=True)
- fake_bin = bin_dir / "powerbi-modeling-mcp.exe"
- fake_bin.write_text("fake", encoding="utf-8")
-
- with (
- patch("pbi_cli.core.binary_manager.PBI_CLI_HOME", tmp_config),
- patch("pbi_cli.core.binary_manager.binary_name", return_value="powerbi-modeling-mcp.exe"),
- ):
- result = _find_managed_binary()
- assert result is not None
- assert result.name == "powerbi-modeling-mcp.exe"
-
-
-def test_find_managed_binary_empty_dir(tmp_config: Path) -> None:
- bin_dir = tmp_config / "bin"
- bin_dir.mkdir(parents=True)
-
- with patch("pbi_cli.core.binary_manager.PBI_CLI_HOME", tmp_config):
- result = _find_managed_binary()
- assert result is None
-
-
-def test_binary_source_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setenv("PBI_MCP_BINARY", "/some/path")
- result = _binary_source(Path("/some/path"))
- assert "environment variable" in result
-
-
-def test_binary_source_managed() -> None:
- with patch.dict(os.environ, {}, clear=False):
- if "PBI_MCP_BINARY" in os.environ:
- del os.environ["PBI_MCP_BINARY"]
- result = _binary_source(Path("/home/user/.pbi-cli/bin/0.4.0/binary"))
- assert "managed" in result
-
-
-def test_binary_source_vscode() -> None:
- with patch.dict(os.environ, {}, clear=False):
- if "PBI_MCP_BINARY" in os.environ:
- del os.environ["PBI_MCP_BINARY"]
- result = _binary_source(Path("/home/user/.vscode/extensions/ext/server/binary"))
- assert "VS Code" in result
-
-
-def test_get_binary_info_not_found(tmp_config: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.delenv("PBI_MCP_BINARY", raising=False)
- with (
- patch("pbi_cli.core.binary_manager.find_vscode_extension_binary", return_value=None),
- patch("pbi_cli.core.binary_manager.PBI_CLI_HOME", tmp_config),
- ):
- info = get_binary_info()
- assert info["binary_path"] == "not found"
- assert info["version"] == "none"
diff --git a/tests/test_commands/test_connection.py b/tests/test_commands/test_connection.py
index 5b35b90..4d39dbf 100644
--- a/tests/test_commands/test_connection.py
+++ b/tests/test_commands/test_connection.py
@@ -3,27 +3,25 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
from click.testing import CliRunner
from pbi_cli.main import cli
-from tests.conftest import MockPbiMcpClient
def test_connect_success(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["connect", "-d", "localhost:54321"])
assert result.exit_code == 0
- assert len(patch_get_client.calls) == 1
- assert patch_get_client.calls[0][0] == "connection_operations"
def test_connect_json_output(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "connect", "-d", "localhost:54321"])
@@ -31,19 +29,9 @@ def test_connect_json_output(
assert "connected" in result.output
-def test_connect_fabric(
- cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
- tmp_connections: Path,
-) -> None:
- result = cli_runner.invoke(cli, ["connect-fabric", "-w", "My Workspace", "-m", "My Model"])
- assert result.exit_code == 0
- assert patch_get_client.calls[0][1]["operation"] == "ConnectFabric"
-
-
def test_disconnect(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
# First connect, then disconnect
@@ -54,7 +42,6 @@ def test_disconnect(
def test_disconnect_no_active_connection(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["disconnect"])
@@ -71,7 +58,7 @@ def test_connections_list_empty(
def test_connections_list_json(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
cli_runner.invoke(cli, ["connect", "-d", "localhost:54321"])
diff --git a/tests/test_commands/test_dax.py b/tests/test_commands/test_dax.py
index 963ac5a..253ad1a 100644
--- a/tests/test_commands/test_dax.py
+++ b/tests/test_commands/test_dax.py
@@ -3,39 +3,56 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
+from unittest.mock import patch
from click.testing import CliRunner
from pbi_cli.main import cli
-from tests.conftest import MockPbiMcpClient
+
+
+def _mock_execute_dax(**kwargs: Any) -> dict:
+ return {"columns": ["Amount"], "rows": [{"Amount": 42}]}
+
+
+def _mock_validate_dax(**kwargs: Any) -> dict:
+ return {"valid": True, "query": kwargs.get("query", "")}
+
+
+def _mock_clear_cache(**kwargs: Any) -> dict:
+ return {"status": "cache_cleared"}
def test_dax_execute(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
- result = cli_runner.invoke(cli, ["--json", "dax", "execute", "EVALUATE Sales"])
+ with patch("pbi_cli.core.adomd_backend.execute_dax", side_effect=_mock_execute_dax):
+ result = cli_runner.invoke(cli, ["--json", "dax", "execute", "EVALUATE Sales"])
assert result.exit_code == 0
assert "42" in result.output
def test_dax_execute_from_file(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
tmp_path: Path,
) -> None:
query_file = tmp_path / "query.dax"
query_file.write_text("EVALUATE Sales", encoding="utf-8")
- result = cli_runner.invoke(cli, ["--json", "dax", "execute", "--file", str(query_file)])
+ with patch("pbi_cli.core.adomd_backend.execute_dax", side_effect=_mock_execute_dax):
+ result = cli_runner.invoke(
+ cli, ["--json", "dax", "execute", "--file", str(query_file)]
+ )
assert result.exit_code == 0
def test_dax_execute_no_query(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["dax", "execute"])
@@ -44,18 +61,20 @@ def test_dax_execute_no_query(
def test_dax_validate(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
- result = cli_runner.invoke(cli, ["--json", "dax", "validate", "EVALUATE Sales"])
+ with patch("pbi_cli.core.adomd_backend.validate_dax", side_effect=_mock_validate_dax):
+ result = cli_runner.invoke(cli, ["--json", "dax", "validate", "EVALUATE Sales"])
assert result.exit_code == 0
- assert "isValid" in result.output
+ assert "valid" in result.output
def test_dax_clear_cache(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
- result = cli_runner.invoke(cli, ["--json", "dax", "clear-cache"])
+ with patch("pbi_cli.core.adomd_backend.clear_cache", side_effect=_mock_clear_cache):
+ result = cli_runner.invoke(cli, ["--json", "dax", "clear-cache"])
assert result.exit_code == 0
diff --git a/tests/test_commands/test_measure.py b/tests/test_commands/test_measure.py
index f805144..0355f75 100644
--- a/tests/test_commands/test_measure.py
+++ b/tests/test_commands/test_measure.py
@@ -3,16 +3,16 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
from click.testing import CliRunner
from pbi_cli.main import cli
-from tests.conftest import MockPbiMcpClient
def test_measure_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "measure", "list"])
@@ -22,7 +22,7 @@ def test_measure_list(
def test_measure_list_with_table_filter(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "measure", "list", "--table", "Sales"])
@@ -31,111 +31,47 @@ def test_measure_list_with_table_filter(
def test_measure_get(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
- result = cli_runner.invoke(cli, ["--json", "measure", "get", "Total Sales", "--table", "Sales"])
+ result = cli_runner.invoke(
+ cli, ["--json", "measure", "get", "Total Sales", "--table", "Sales"]
+ )
assert result.exit_code == 0
assert "Total Sales" in result.output
def test_measure_create(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(
cli,
- [
- "--json",
- "measure",
- "create",
- "Revenue",
- "-e",
- "SUM(Sales[Revenue])",
- "-t",
- "Sales",
- ],
- )
- assert result.exit_code == 0
-
-
-def test_measure_update(
- cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
- tmp_connections: Path,
-) -> None:
- result = cli_runner.invoke(
- cli,
- [
- "--json",
- "measure",
- "update",
- "Revenue",
- "-t",
- "Sales",
- "-e",
- "SUM(Sales[Amount])",
- ],
+ ["--json", "measure", "create", "Revenue", "-e", "SUM(Sales[Revenue])", "-t", "Sales"],
)
assert result.exit_code == 0
+ assert "created" in result.output
def test_measure_delete(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(
- cli,
- [
- "--json",
- "measure",
- "delete",
- "Revenue",
- "-t",
- "Sales",
- ],
+ cli, ["--json", "measure", "delete", "Total Sales", "-t", "Sales"]
)
assert result.exit_code == 0
+ assert "deleted" in result.output
def test_measure_rename(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(
- cli,
- [
- "--json",
- "measure",
- "rename",
- "OldName",
- "NewName",
- "-t",
- "Sales",
- ],
- )
- assert result.exit_code == 0
-
-
-def test_measure_move(
- cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
- tmp_connections: Path,
-) -> None:
- result = cli_runner.invoke(
- cli,
- [
- "--json",
- "measure",
- "move",
- "Revenue",
- "-t",
- "Sales",
- "--to-table",
- "Finance",
- ],
+ cli, ["--json", "measure", "rename", "Total Sales", "Revenue", "-t", "Sales"]
)
assert result.exit_code == 0
diff --git a/tests/test_commands/test_misc_commands.py b/tests/test_commands/test_misc_commands.py
index 83c457b..e27a1a2 100644
--- a/tests/test_commands/test_misc_commands.py
+++ b/tests/test_commands/test_misc_commands.py
@@ -3,16 +3,16 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
from click.testing import CliRunner
from pbi_cli.main import cli
-from tests.conftest import MockPbiMcpClient
def test_column_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "column", "list", "--table", "Sales"])
@@ -21,7 +21,7 @@ def test_column_list(
def test_relationship_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "relationship", "list"])
@@ -30,7 +30,7 @@ def test_relationship_list(
def test_database_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "database", "list"])
@@ -39,7 +39,7 @@ def test_database_list(
def test_security_role_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "security-role", "list"])
@@ -48,7 +48,7 @@ def test_security_role_list(
def test_calc_group_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "calc-group", "list"])
@@ -57,7 +57,7 @@ def test_calc_group_list(
def test_partition_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "partition", "list", "--table", "Sales"])
@@ -66,7 +66,7 @@ def test_partition_list(
def test_perspective_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "perspective", "list"])
@@ -75,16 +75,16 @@ def test_perspective_list(
def test_hierarchy_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
- result = cli_runner.invoke(cli, ["--json", "hierarchy", "list", "--table", "Date"])
+ result = cli_runner.invoke(cli, ["--json", "hierarchy", "list"])
assert result.exit_code == 0
def test_expression_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "expression", "list"])
@@ -93,7 +93,7 @@ def test_expression_list(
def test_calendar_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "calendar", "list"])
@@ -102,7 +102,7 @@ def test_calendar_list(
def test_trace_start(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "trace", "start"])
@@ -111,7 +111,7 @@ def test_trace_start(
def test_transaction_begin(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "transaction", "begin"])
@@ -120,7 +120,7 @@ def test_transaction_begin(
def test_transaction_commit(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "transaction", "commit"])
@@ -129,7 +129,7 @@ def test_transaction_commit(
def test_transaction_rollback(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "transaction", "rollback"])
@@ -138,7 +138,7 @@ def test_transaction_rollback(
def test_advanced_culture_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "advanced", "culture", "list"])
diff --git a/tests/test_commands/test_model.py b/tests/test_commands/test_model.py
index 2ca1757..665d4d5 100644
--- a/tests/test_commands/test_model.py
+++ b/tests/test_commands/test_model.py
@@ -3,36 +3,27 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
from click.testing import CliRunner
from pbi_cli.main import cli
-from tests.conftest import MockPbiMcpClient
def test_model_get(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "model", "get"])
assert result.exit_code == 0
- assert "My Model" in result.output
+ assert "TestModel" in result.output
def test_model_stats(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "model", "stats"])
assert result.exit_code == 0
-
-
-def test_model_refresh(
- cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
- tmp_connections: Path,
-) -> None:
- result = cli_runner.invoke(cli, ["--json", "model", "refresh"])
- assert result.exit_code == 0
diff --git a/tests/test_commands/test_repl.py b/tests/test_commands/test_repl.py
index 5f4d374..abca4e5 100644
--- a/tests/test_commands/test_repl.py
+++ b/tests/test_commands/test_repl.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-from pathlib import Path
-
import pytest
from click.testing import CliRunner
@@ -27,29 +25,24 @@ def test_repl_build_completer() -> None:
assert "repl" in completer.words
-def test_repl_get_prompt_no_connection(tmp_connections: Path) -> None:
+def test_repl_get_prompt_no_connection() -> None:
repl = PbiRepl()
prompt = repl._get_prompt()
assert prompt == "pbi> "
-def test_repl_get_prompt_with_connection(tmp_connections: Path) -> None:
- from pbi_cli.core.connection_store import (
- ConnectionInfo,
- ConnectionStore,
- add_connection,
- save_connections,
- )
+def test_repl_get_prompt_with_session(monkeypatch: pytest.MonkeyPatch) -> None:
+ from tests.conftest import build_mock_session
- store = add_connection(
- ConnectionStore(),
- ConnectionInfo(name="test-conn", data_source="localhost"),
- )
- save_connections(store)
+ session = build_mock_session()
+ monkeypatch.setattr("pbi_cli.core.session._current_session", session)
repl = PbiRepl()
prompt = repl._get_prompt()
- assert prompt == "pbi(test-conn)> "
+ assert "test-conn" in prompt
+
+ # Clean up
+ monkeypatch.setattr("pbi_cli.core.session._current_session", None)
def test_repl_execute_line_empty() -> None:
@@ -61,35 +54,16 @@ def test_repl_execute_line_empty() -> None:
def test_repl_execute_line_exit() -> None:
repl = PbiRepl()
- import pytest
-
with pytest.raises(EOFError):
repl._execute_line("exit")
def test_repl_execute_line_quit() -> None:
repl = PbiRepl()
- import pytest
-
with pytest.raises(EOFError):
repl._execute_line("quit")
-def test_repl_execute_line_strips_pbi_prefix(
- monkeypatch: pytest.MonkeyPatch,
- tmp_connections: Path,
-) -> None:
- from tests.conftest import MockPbiMcpClient
-
- mock = MockPbiMcpClient()
- factory = lambda repl_mode=False: mock # noqa: E731
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", factory)
-
- repl = PbiRepl(json_output=True)
- # "pbi measure list" should work like "measure list"
- repl._execute_line("pbi --json measure list")
-
-
def test_repl_execute_line_help() -> None:
repl = PbiRepl()
# --help should not crash the REPL (Click raises SystemExit)
diff --git a/tests/test_commands/test_setup.py b/tests/test_commands/test_setup.py
index b14a248..38f8c50 100644
--- a/tests/test_commands/test_setup.py
+++ b/tests/test_commands/test_setup.py
@@ -11,32 +11,33 @@ from pbi_cli.main import cli
def test_setup_info(cli_runner: CliRunner, tmp_config: Path) -> None:
- fake_info = {
- "binary_path": "/test/binary",
- "version": "0.4.0",
- "platform": "win32-x64",
- "source": "managed",
- }
- with patch("pbi_cli.commands.setup_cmd.get_binary_info", return_value=fake_info):
+ with patch("pbi_cli.core.dotnet_loader._dll_dir", return_value=tmp_config / "dlls"):
result = cli_runner.invoke(cli, ["--json", "setup", "--info"])
- assert result.exit_code == 0
- assert "0.4.0" in result.output
+ assert result.exit_code == 0
+ assert "version" in result.output
-def test_setup_check(cli_runner: CliRunner, tmp_config: Path) -> None:
- with patch(
- "pbi_cli.commands.setup_cmd.check_for_updates",
- return_value=("0.3.0", "0.4.0", True),
+def test_setup_verify_missing_pythonnet(cli_runner: CliRunner, tmp_config: Path) -> None:
+ with (
+ patch("pbi_cli.core.dotnet_loader._dll_dir", return_value=tmp_config / "dlls"),
+ patch.dict("sys.modules", {"pythonnet": None}),
):
- result = cli_runner.invoke(cli, ["--json", "setup", "--check"])
- assert result.exit_code == 0
- assert "0.4.0" in result.output
+ result = cli_runner.invoke(cli, ["setup"])
+ # Should fail because pythonnet is "missing" and dlls dir doesn't exist
+ assert result.exit_code != 0
-def test_setup_check_up_to_date(cli_runner: CliRunner, tmp_config: Path) -> None:
- with patch(
- "pbi_cli.commands.setup_cmd.check_for_updates",
- return_value=("0.4.0", "0.4.0", False),
+def test_setup_verify_success(cli_runner: CliRunner, tmp_config: Path) -> None:
+ # Create fake DLL directory with required files
+ dll_dir = tmp_config / "dlls"
+ dll_dir.mkdir()
+ (dll_dir / "Microsoft.AnalysisServices.Tabular.dll").write_text("fake")
+ (dll_dir / "Microsoft.AnalysisServices.AdomdClient.dll").write_text("fake")
+
+ with (
+ patch("pbi_cli.core.dotnet_loader._dll_dir", return_value=dll_dir),
+ patch("pbi_cli.commands.connection._ensure_ready", lambda: None),
):
- result = cli_runner.invoke(cli, ["setup", "--check"])
- assert result.exit_code == 0
+ result = cli_runner.invoke(cli, ["--json", "setup"])
+ assert result.exit_code == 0
+ assert "ready" in result.output
diff --git a/tests/test_commands/test_table.py b/tests/test_commands/test_table.py
index 24b398d..d222f66 100644
--- a/tests/test_commands/test_table.py
+++ b/tests/test_commands/test_table.py
@@ -3,16 +3,16 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
from click.testing import CliRunner
from pbi_cli.main import cli
-from tests.conftest import MockPbiMcpClient
def test_table_list(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "table", "list"])
@@ -22,45 +22,18 @@ def test_table_list(
def test_table_get(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
result = cli_runner.invoke(cli, ["--json", "table", "get", "Sales"])
assert result.exit_code == 0
-def test_table_create(
- cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
- tmp_connections: Path,
-) -> None:
- result = cli_runner.invoke(
- cli,
- [
- "--json",
- "table",
- "create",
- "NewTable",
- "--mode",
- "Import",
- ],
- )
- assert result.exit_code == 0
-
-
def test_table_delete(
cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
+ patch_session: Any,
tmp_connections: Path,
) -> None:
- result = cli_runner.invoke(cli, ["--json", "table", "delete", "OldTable"])
- assert result.exit_code == 0
-
-
-def test_table_refresh(
- cli_runner: CliRunner,
- patch_get_client: MockPbiMcpClient,
- tmp_connections: Path,
-) -> None:
- result = cli_runner.invoke(cli, ["--json", "table", "refresh", "Sales"])
+ result = cli_runner.invoke(cli, ["--json", "table", "delete", "Sales"])
assert result.exit_code == 0
+ assert "deleted" in result.output
diff --git a/tests/test_config.py b/tests/test_config.py
index f0feee7..fa81b71 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -9,40 +9,28 @@ from pbi_cli.core.config import PbiConfig, load_config, save_config
def test_default_config() -> None:
config = PbiConfig()
- assert config.binary_version == ""
- assert config.binary_path == ""
assert config.default_connection == ""
- assert config.binary_args == ["--start", "--skipconfirmation"]
def test_with_updates_returns_new_instance() -> None:
- original = PbiConfig(binary_version="1.0")
- updated = original.with_updates(binary_version="2.0")
+ original = PbiConfig(default_connection="conn1")
+ updated = original.with_updates(default_connection="conn2")
- assert updated.binary_version == "2.0"
- assert original.binary_version == "1.0" # unchanged
-
-
-def test_with_updates_preserves_other_fields() -> None:
- original = PbiConfig(binary_version="1.0", binary_path="/bin/test")
- updated = original.with_updates(binary_version="2.0")
-
- assert updated.binary_path == "/bin/test"
+ assert updated.default_connection == "conn2"
+ assert original.default_connection == "conn1" # unchanged
def test_load_config_missing_file(tmp_config: Path) -> None:
config = load_config()
- assert config.binary_version == ""
- assert config.binary_args == ["--start", "--skipconfirmation"]
+ assert config.default_connection == ""
def test_save_and_load_roundtrip(tmp_config: Path) -> None:
- original = PbiConfig(binary_version="0.4.0", binary_path="/test/path")
+ original = PbiConfig(default_connection="my-conn")
save_config(original)
loaded = load_config()
- assert loaded.binary_version == "0.4.0"
- assert loaded.binary_path == "/test/path"
+ assert loaded.default_connection == "my-conn"
def test_load_config_corrupt_json(tmp_config: Path) -> None:
@@ -50,13 +38,13 @@ def test_load_config_corrupt_json(tmp_config: Path) -> None:
config_file.write_text("not valid json{{{", encoding="utf-8")
config = load_config()
- assert config.binary_version == "" # falls back to defaults
+ assert config.default_connection == "" # falls back to defaults
def test_config_is_frozen() -> None:
config = PbiConfig()
try:
- config.binary_version = "new" # type: ignore[misc]
+ config.default_connection = "new" # type: ignore[misc]
assert False, "Should have raised"
except AttributeError:
pass
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index eccea70..22603d0 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -1,6 +1,6 @@
-"""End-to-end tests requiring the real Power BI MCP binary.
+"""End-to-end tests requiring a running Power BI Desktop instance.
-These tests are skipped in CI unless a binary is available.
+These tests are skipped in CI unless PBI Desktop is available.
Run with: pytest -m e2e
"""
@@ -24,14 +24,6 @@ def _pbi(*args: str) -> subprocess.CompletedProcess[str]:
)
-@pytest.fixture(autouse=True)
-def _skip_if_no_binary() -> None:
- """Skip all e2e tests if the binary is not available."""
- result = _pbi("--json", "setup", "--info")
- if "not found" in result.stdout:
- pytest.skip("Power BI MCP binary not available")
-
-
def test_version() -> None:
result = _pbi("--version")
assert result.returncode == 0
@@ -47,3 +39,4 @@ def test_help() -> None:
def test_setup_info() -> None:
result = _pbi("--json", "setup", "--info")
assert result.returncode == 0
+ assert "version" in result.stdout
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 1648eb1..e7b2385 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -5,10 +5,10 @@ from __future__ import annotations
import click
from pbi_cli.core.errors import (
- BinaryNotFoundError,
ConnectionRequiredError,
- McpToolError,
+ DotNetNotFoundError,
PbiCliError,
+ TomError,
)
@@ -18,9 +18,9 @@ def test_pbi_cli_error_is_click_exception() -> None:
assert err.format_message() == "test message"
-def test_binary_not_found_default_message() -> None:
- err = BinaryNotFoundError()
- assert "pbi connect" in err.format_message()
+def test_dotnet_not_found_default_message() -> None:
+ err = DotNetNotFoundError()
+ assert "pythonnet" in err.format_message()
def test_connection_required_default_message() -> None:
@@ -28,9 +28,9 @@ def test_connection_required_default_message() -> None:
assert "pbi connect" in err.format_message()
-def test_mcp_tool_error_includes_tool_name() -> None:
- err = McpToolError("measure_operations", "not found")
- assert "measure_operations" in err.format_message()
+def test_tom_error_includes_operation() -> None:
+ err = TomError("measure_list", "not found")
+ assert "measure_list" in err.format_message()
assert "not found" in err.format_message()
- assert err.tool_name == "measure_operations"
+ assert err.operation == "measure_list"
assert err.detail == "not found"
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 73228f9..388c661 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -4,10 +4,9 @@ from __future__ import annotations
import pytest
-from pbi_cli.commands._helpers import build_definition, run_tool
-from pbi_cli.core.errors import McpToolError
+from pbi_cli.commands._helpers import build_definition, run_command
+from pbi_cli.core.errors import TomError
from pbi_cli.main import PbiContext
-from tests.conftest import MockPbiMcpClient
def test_build_definition_required_only() -> None:
@@ -37,89 +36,37 @@ def test_build_definition_preserves_falsy_non_none() -> None:
assert result["label"] == ""
-def test_run_tool_adds_connection(monkeypatch: pytest.MonkeyPatch) -> None:
- mock = MockPbiMcpClient()
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", lambda repl_mode=False: mock)
-
- # Mock connection store with the named connection
- from pbi_cli.core.connection_store import ConnectionInfo, ConnectionStore
-
- store = ConnectionStore(
- last_used="my-conn",
- connections={"my-conn": ConnectionInfo(name="my-conn", data_source="localhost:12345")},
- )
- monkeypatch.setattr(
- "pbi_cli.core.connection_store.load_connections",
- lambda: store,
- )
-
- ctx = PbiContext(json_output=True, connection="my-conn")
- run_tool(ctx, "measure_operations", {"operation": "List"})
-
- # First call is auto-reconnect (Connect), second is the actual tool call.
- # The connectionName comes from the server response ("test-conn"), not our saved name.
- assert mock.calls[1][1]["connectionName"] == "test-conn"
-
-
-def test_run_tool_no_connection(monkeypatch: pytest.MonkeyPatch) -> None:
- mock = MockPbiMcpClient()
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", lambda repl_mode=False: mock)
- # Ensure no last-used connection is found
- from pbi_cli.core.connection_store import ConnectionStore
-
- monkeypatch.setattr(
- "pbi_cli.core.connection_store.load_connections",
- lambda: ConnectionStore(),
- )
-
+def test_run_command_formats_result() -> None:
ctx = PbiContext(json_output=True)
- run_tool(ctx, "measure_operations", {"operation": "List"})
-
- assert "connectionName" not in mock.calls[0][1]
+ result = run_command(ctx, lambda: {"status": "ok"})
+ assert result == {"status": "ok"}
-def test_run_tool_stops_client_in_oneshot(monkeypatch: pytest.MonkeyPatch) -> None:
- mock = MockPbiMcpClient()
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", lambda repl_mode=False: mock)
- from pbi_cli.core.connection_store import ConnectionStore
-
- monkeypatch.setattr(
- "pbi_cli.core.connection_store.load_connections",
- lambda: ConnectionStore(),
- )
-
+def test_run_command_exits_on_error_oneshot() -> None:
ctx = PbiContext(json_output=True, repl_mode=False)
- run_tool(ctx, "measure_operations", {"operation": "List"})
- assert mock.stopped is True
+ def failing_fn() -> None:
+ raise RuntimeError("boom")
+
+ with pytest.raises(SystemExit):
+ run_command(ctx, failing_fn)
-def test_run_tool_keeps_client_in_repl(monkeypatch: pytest.MonkeyPatch) -> None:
- mock = MockPbiMcpClient()
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", lambda repl_mode=False: mock)
-
+def test_run_command_raises_tom_error_in_repl() -> None:
ctx = PbiContext(json_output=True, repl_mode=True)
- run_tool(ctx, "measure_operations", {"operation": "List"})
- assert mock.stopped is False
+ def failing_fn() -> None:
+ raise RuntimeError("boom")
+
+ with pytest.raises(TomError):
+ run_command(ctx, failing_fn)
-def test_run_tool_raises_mcp_tool_error_on_failure(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- class FailingClient(MockPbiMcpClient):
- def call_tool(self, tool_name: str, request: dict) -> None:
- raise RuntimeError("server crashed")
-
- mock = FailingClient()
- monkeypatch.setattr("pbi_cli.commands._helpers.get_client", lambda repl_mode=False: mock)
- from pbi_cli.core.connection_store import ConnectionStore
-
- monkeypatch.setattr(
- "pbi_cli.core.connection_store.load_connections",
- lambda: ConnectionStore(),
- )
-
+def test_run_command_passes_kwargs() -> None:
ctx = PbiContext(json_output=True)
- with pytest.raises(McpToolError):
- run_tool(ctx, "measure_operations", {"operation": "List"})
+
+ def fn_with_args(name: str, count: int) -> dict:
+ return {"name": name, "count": count}
+
+ result = run_command(ctx, fn_with_args, name="test", count=42)
+ assert result == {"name": "test", "count": 42}
diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py
deleted file mode 100644
index a238546..0000000
--- a/tests/test_mcp_client.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""Tests for pbi_cli.core.mcp_client (unit-level, no real server)."""
-
-from __future__ import annotations
-
-from pbi_cli.core.mcp_client import (
- _extract_text,
- _parse_content,
- get_client,
- get_shared_client,
-)
-
-# ---------------------------------------------------------------------------
-# _parse_content tests
-# ---------------------------------------------------------------------------
-
-
-class FakeTextContent:
- """Mimics mcp TextContent blocks."""
-
- def __init__(self, text: str) -> None:
- self.text = text
-
-
-def test_parse_content_single_json() -> None:
- blocks = [FakeTextContent('{"name": "Sales"}')]
- result = _parse_content(blocks)
- assert result == {"name": "Sales"}
-
-
-def test_parse_content_single_plain_text() -> None:
- blocks = [FakeTextContent("just a string")]
- result = _parse_content(blocks)
- assert result == "just a string"
-
-
-def test_parse_content_multiple_blocks() -> None:
- blocks = [FakeTextContent("hello"), FakeTextContent(" world")]
- result = _parse_content(blocks)
- assert result == "hello\n world"
-
-
-def test_parse_content_non_list() -> None:
- result = _parse_content("raw value")
- assert result == "raw value"
-
-
-def test_parse_content_json_array() -> None:
- blocks = [FakeTextContent('[{"a": 1}]')]
- result = _parse_content(blocks)
- assert result == [{"a": 1}]
-
-
-# ---------------------------------------------------------------------------
-# _extract_text tests
-# ---------------------------------------------------------------------------
-
-
-def test_extract_text_from_blocks() -> None:
- blocks = [FakeTextContent("error occurred")]
- result = _extract_text(blocks)
- assert result == "error occurred"
-
-
-def test_extract_text_non_list() -> None:
- result = _extract_text("plain error")
- assert result == "plain error"
-
-
-# ---------------------------------------------------------------------------
-# get_client / get_shared_client tests
-# ---------------------------------------------------------------------------
-
-
-def test_get_client_oneshot_returns_fresh() -> None:
- c1 = get_client(repl_mode=False)
- c2 = get_client(repl_mode=False)
- assert c1 is not c2
-
-
-def test_get_shared_client_returns_same_instance() -> None:
- c1 = get_shared_client()
- c2 = get_shared_client()
- assert c1 is c2
diff --git a/tests/test_output.py b/tests/test_output.py
index f721aa4..42efc28 100644
--- a/tests/test_output.py
+++ b/tests/test_output.py
@@ -3,14 +3,13 @@
from __future__ import annotations
import json
+import sys
+from io import StringIO
-from pbi_cli.core.output import format_mcp_result, print_json
+from pbi_cli.core.output import format_result, print_json
-def test_print_json_outputs_valid_json(capsys: object) -> None:
- import sys
- from io import StringIO
-
+def test_print_json_outputs_valid_json() -> None:
old_stdout = sys.stdout
sys.stdout = buf = StringIO()
try:
@@ -22,9 +21,7 @@ def test_print_json_outputs_valid_json(capsys: object) -> None:
assert parsed == {"key": "value"}
-def test_print_json_handles_non_serializable(capsys: object) -> None:
- import sys
- from io import StringIO
+def test_print_json_handles_non_serializable() -> None:
from pathlib import Path
old_stdout = sys.stdout
@@ -38,14 +35,11 @@ def test_print_json_handles_non_serializable(capsys: object) -> None:
assert "tmp" in parsed["path"]
-def test_format_mcp_result_json_mode(capsys: object) -> None:
- import sys
- from io import StringIO
-
+def test_format_result_json_mode() -> None:
old_stdout = sys.stdout
sys.stdout = buf = StringIO()
try:
- format_mcp_result({"name": "Sales"}, json_output=True)
+ format_result({"name": "Sales"}, json_output=True)
finally:
sys.stdout = old_stdout
@@ -53,21 +47,21 @@ def test_format_mcp_result_json_mode(capsys: object) -> None:
assert parsed["name"] == "Sales"
-def test_format_mcp_result_empty_list() -> None:
+def test_format_result_empty_list() -> None:
# Should not raise; prints "No results." to stderr
- format_mcp_result([], json_output=False)
+ format_result([], json_output=False)
-def test_format_mcp_result_dict() -> None:
+def test_format_result_dict() -> None:
# Should not raise; prints key-value panel
- format_mcp_result({"name": "Test"}, json_output=False)
+ format_result({"name": "Test"}, json_output=False)
-def test_format_mcp_result_list_of_dicts() -> None:
+def test_format_result_list_of_dicts() -> None:
# Should not raise; prints table
- format_mcp_result([{"name": "A"}, {"name": "B"}], json_output=False)
+ format_result([{"name": "A"}, {"name": "B"}], json_output=False)
-def test_format_mcp_result_string() -> None:
+def test_format_result_string() -> None:
# Should not raise; prints string
- format_mcp_result("some text", json_output=False)
+ format_result("some text", json_output=False)
diff --git a/tests/test_platform.py b/tests/test_platform.py
index a76a783..4c48941 100644
--- a/tests/test_platform.py
+++ b/tests/test_platform.py
@@ -9,105 +9,10 @@ import pytest
from pbi_cli.utils.platform import (
_workspace_candidates,
- binary_name,
- detect_platform,
discover_pbi_port,
- ensure_executable,
- find_vscode_extension_binary,
)
-def test_detect_platform_windows() -> None:
- with (
- patch("pbi_cli.utils.platform.platform.system", return_value="Windows"),
- patch("pbi_cli.utils.platform.platform.machine", return_value="AMD64"),
- ):
- assert detect_platform() == "win32-x64"
-
-
-def test_detect_platform_macos_arm() -> None:
- with (
- patch("pbi_cli.utils.platform.platform.system", return_value="Darwin"),
- patch("pbi_cli.utils.platform.platform.machine", return_value="arm64"),
- ):
- assert detect_platform() == "darwin-arm64"
-
-
-def test_detect_platform_linux_x64() -> None:
- with (
- patch("pbi_cli.utils.platform.platform.system", return_value="Linux"),
- patch("pbi_cli.utils.platform.platform.machine", return_value="x86_64"),
- ):
- assert detect_platform() == "linux-x64"
-
-
-def test_detect_platform_unsupported() -> None:
- with (
- patch("pbi_cli.utils.platform.platform.system", return_value="FreeBSD"),
- patch("pbi_cli.utils.platform.platform.machine", return_value="sparc"),
- ):
- with pytest.raises(ValueError, match="Unsupported platform"):
- detect_platform()
-
-
-def test_binary_name_windows() -> None:
- with patch("pbi_cli.utils.platform.platform.system", return_value="Windows"):
- assert binary_name() == "powerbi-modeling-mcp.exe"
-
-
-def test_binary_name_unix() -> None:
- with patch("pbi_cli.utils.platform.platform.system", return_value="Linux"):
- assert binary_name() == "powerbi-modeling-mcp"
-
-
-def test_binary_name_unsupported() -> None:
- with patch("pbi_cli.utils.platform.platform.system", return_value="FreeBSD"):
- with pytest.raises(ValueError, match="Unsupported OS"):
- binary_name()
-
-
-def test_ensure_executable_noop_on_windows(tmp_path: Path) -> None:
- f = tmp_path / "test.exe"
- f.write_text("fake", encoding="utf-8")
- with patch("pbi_cli.utils.platform.platform.system", return_value="Windows"):
- ensure_executable(f) # should be a no-op
-
-
-def test_find_vscode_extension_binary_no_dir(tmp_path: Path) -> None:
- with patch("pbi_cli.utils.platform.Path.home", return_value=tmp_path):
- result = find_vscode_extension_binary()
- assert result is None
-
-
-def test_find_vscode_extension_binary_no_match(tmp_path: Path) -> None:
- ext_dir = tmp_path / ".vscode" / "extensions"
- ext_dir.mkdir(parents=True)
- with patch("pbi_cli.utils.platform.Path.home", return_value=tmp_path):
- result = find_vscode_extension_binary()
- assert result is None
-
-
-def test_find_vscode_extension_binary_found(tmp_path: Path) -> None:
- ext_name = "analysis-services.powerbi-modeling-mcp-0.4.0"
- server_dir = tmp_path / ".vscode" / "extensions" / ext_name / "server"
- server_dir.mkdir(parents=True)
- fake_bin = server_dir / "powerbi-modeling-mcp.exe"
- fake_bin.write_text("fake", encoding="utf-8")
-
- with (
- patch("pbi_cli.utils.platform.Path.home", return_value=tmp_path),
- patch("pbi_cli.utils.platform.binary_name", return_value="powerbi-modeling-mcp.exe"),
- ):
- result = find_vscode_extension_binary()
- assert result is not None
- assert result.name == "powerbi-modeling-mcp.exe"
-
-
-# ---------------------------------------------------------------------------
-# discover_pbi_port tests
-# ---------------------------------------------------------------------------
-
-
def test_discover_pbi_port_no_pbi(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Returns None when Power BI Desktop workspace dir doesn't exist."""
with (
@@ -198,7 +103,6 @@ def test_discover_pbi_port_store_version(tmp_path: Path, monkeypatch: pytest.Mon
store_ws = tmp_path / "Microsoft" / "Power BI Desktop Store App" / "AnalysisServicesWorkspaces"
data_dir = store_ws / "AnalysisServicesWorkspace_xyz" / "Data"
data_dir.mkdir(parents=True)
- # Store version writes UTF-16 LE without BOM
(data_dir / "msmdsrv.port.txt").write_bytes("57426".encode("utf-16-le"))
with (