Metadata-Version: 2.4
Name: pqtools
Version: 0.8.0
Summary: Run, lint and format Power Query M without Power BI. Executes a .pq query end to end on local CSV files in pure Python - like pandas for Power Query - plus a linter, formatter and safe rename for .pq and the queries inside .pbix/.xlsx
Author: Gopal Bagaswar
License-Expression: MIT
Project-URL: Homepage, https://github.com/GopalGB/pqtools
Project-URL: Documentation, https://github.com/GopalGB/pqtools#readme
Project-URL: Changelog, https://github.com/GopalGB/pqtools/releases
Project-URL: Repository, https://github.com/GopalGB/pqtools
Project-URL: Issues, https://github.com/GopalGB/pqtools/issues
Keywords: power-query,powerquery,power-query-m,m-language,power-bi,powerbi,fabric,mashup,linter,formatter,static-analysis,etl,data-engineering,csv,excel,pbix,dataframe,offline,pq
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Compilers
Classifier: Topic :: Software Development :: Interpreters
Classifier: Topic :: Database
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Topic :: Utilities
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Provides-Extra: fabric
Requires-Dist: pyarrow>=14; extra == "fabric"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: pytest-xdist>=3; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=6.1; extra == "dev"
Requires-Dist: pip-audit>=2.7; extra == "dev"
Dynamic: license-file

# pqtools - run, lint and format Power Query M without Power BI

**pqtools runs a Power Query `.pq` query end to end on your own CSV files, in
pure Python, with no Power BI, no Excel and no dependencies.** It is also a
linter, a formatter and a safe renamer for M source - `ruff` and `black` for
Power Query - and it reads the queries already stored inside `.pbix`, `.pbit`
and `.xlsx` files.

```bash
pip install pqtools
pq list  report.pbix         # what queries are in this file?
pq eval  report.pbix --member Sales   # run one of them
pq eval  report.pq           # or run a .pq directly
pq format report.pq          # format it
pq check report.pq           # lint it in CI
```

What it is for, in one line each:

- **Run a query offline.** Paste a query out of Power Query's Advanced Editor.
  If its source is a local CSV, `pq eval` runs the whole thing - `Csv.Document`,
  `Table.PromoteHeaders`, type conversion, filters, `Table.Group`, joins, pivots
  - and prints JSON or CSV.
- **Lint and format M in CI.** `pq check` and `pq format` give Power Query the
  code-review tooling every other language already has. Exit codes are CI-shaped.
- **Test a transformation without opening Power BI.** Swap the real data source
  for a fixture with `--bind` and assert on the result from `pytest`.
- **Refactor safely.** `pq rename` renames a `let` binding across a query without
  a find-and-replace touching a string literal that happens to match.
- **Work with the queries inside a .pbix or .xlsx.** `pq list` names them,
  `pq eval --member` runs one, `pq check` lints them, and `pq format` /
  `pq rename` print the edited M, and `--write` saves it back into the file.
  `pq add` puts a brand-new query in. See
  [Working inside .xlsx and .pbix](#working-inside-xlsx-and-pbix).

> **Unofficial.** Not affiliated with or endorsed by Microsoft. Not a Power Query
> runtime - `pq eval` runs the transformation chain of a query locally; it never
> runs an engine-backed connector (`Web.Contents`, `Sql.Database`, ...). Local
> files it does read: `Csv.Document(File.Contents(...))` runs natively. See
> [Running M](#running-m) below.

> **Renamed.** Published as `mquery-toolkit` 0.1.0 on 2026-09-03 and renamed the
> same day to `pqtools` to avoid a CLI name collision with the existing `mquery`
> package on PyPI (a Yara malware-query tool). `mquery-toolkit` 0.1.0 is yanked.

## Install

```bash
pip install pqtools
```

Requires **Node.js 22 or newer** on `PATH`, or point `MQUERY_NODE` at a Node
binary. The Microsoft parser and formatter packages are bundled inside the
wheel (`_bridge.cjs`) - no `npm install` needed.

## Quick start

```bash
# Parse to deterministic JSON (tokens, root kind, bindings/references)
pq parse query.pq

# Format - dry run prints a unified diff, nothing is written
pq format query.pq

# Format and write in place (atomic replace, preserves mode/newline/encoding)
pq format query.pq --write

# Lint, machine-readable output; exit code 2 if any diagnostic is severity=error
pq check query.pq --json

# Rename one top-level let binding - dry run first
pq rename query.pq --old OldName --new NewName

# Run a query's transformation chain locally, against your own data
pq eval report.pq --bind Source=data.csv
```

## Python API

```python
from pqtools import check, format_source, parse, rename, update_file

parsed = parse(source_text)  # dict: tokens, rootKind, analysis
formatted = format_source(source_text)  # formatted M source, same encoding
diagnostics = check(source_text, "query.pq")  # list[Diagnostic]
renamed = rename(source_text, "OldName", "NewName")

from pqtools.evaluate import evaluate

result = evaluate(source_text, bindings={"Source": [{"a": "1"}, {"a": "2"}]})

# File-level edit with the same dry-run/--write safety model as the CLI
diff = update_file(path, format_source)  # dry run: unified diff
diff = update_file(path, format_source, write=True)  # atomic write
```

## Diagnostics

| Code | Severity | Meaning |
|---|---|---|
| `M_PARSE_ERROR` | error | source does not parse |
| `M001` | error | duplicate `let` binding name |
| `M002` | warning | `Web.Contents` called with a non-literal (dynamic) URL |
| `M003` | warning | credential-like literal (`password`/`token`/`secret` = `"..."`) |
| `M004` | warning | `let` binding unreachable from the result |
| `M005` | warning | unresolved unqualified reference |
| `M006` | info | source-function inventory (`*.Contents` dependency) |

`M002` and `M003` are token-based checks over the parsed source, so they no
longer fire inside comments or strings. Every matching occurrence is
reported, one diagnostic per call site or literal.

`check --json` emits stable objects; `check` without `--json` prints
`file:line:column: severity code: message` per diagnostic. The CLI exits `2`
when any diagnostic has severity `error`, `0` otherwise.

## Running M

pandas reads a CSV and transforms it. `pq eval` does the same for Power Query.
A real M query is a `Source = <connector>(...)` step followed by a chain of
`Table.*` transformations, and both halves now run locally:

```bash
pq eval report.pq          # the query reads its own CSV - nothing to supply
```

**Local-file sources run natively.** `Csv.Document`, `File.Contents` and
`Text.FromBinary` are implemented in `pqtools` itself, in pure Python with no
dependencies. A query copied verbatim out of Power Query's Advanced Editor -
`Source` step included - evaluates without any help, as long as its file path
exists on this machine.

**Engine-backed sources do not, and will not.** `Sql.Database`, `Web.Contents`,
`SharePoint.*`, `Odbc.*` and friends need credentials, a network identity,
driver-specific type mapping, or query folding into a remote engine. Those are
Microsoft's Mashup Engine, this project does not reimplement it, and each one
raises a typed error naming itself. For those - and for any query carrying the
authoring machine's `C:\Users\...` path - supply the source table yourself:

```bash
pq eval report.pq --bind Source=data.csv
```

`--bind NAME=PATH` loads `PATH` (a `.csv`, read as a list of records with
`csv.DictReader` - every value stays text, or a `.json` file, loaded as
whatever it holds) and, wherever `NAME` is used as a `let` binding in the
query, substitutes it directly - the binding's own right-hand-side expression
(the connector call) is never evaluated, which is exactly what makes it
irrelevant that `pqtools` cannot run it.

A **table** is simply `list[dict[str, Any]]` - a list of records. A record is
`dict[str, Any]`. A list is `list[Any]`. That is the whole data model.

**Worked example.** Given `report.pq`:

```m
let
  Source = Csv.Document(File.Contents("ignored.csv")),
  Kept = Table.SelectRows(Source, each [b] <> "y"),
  Renamed = Table.RenameColumns(Kept, {{"a", "id"}})
in
  Renamed
```

and `data.csv`:

```csv
a,b
1,x
2,y
3,z
```

```bash
$ pq eval report.pq --bind Source=data.csv
[{"b": "x", "id": "1"}, {"b": "z", "id": "3"}]
```

`--bind` wins over the query's own `Source` expression, so
`Csv.Document(File.Contents("ignored.csv"))` is never called and `ignored.csv`
is never opened. `Source` is the CSV you bound, `Kept` drops the `b = "y"` row,
and `Renamed` renames `a` to `id`.

Drop the `--bind` and the query reads its own file instead. Here that file does
not exist, so it fails with a typed, exit-`2` error naming the path and the way
forward - it does not invent data and does not create the file:

```bash
$ pq eval report.pq
error M_EVAL_ERROR: File.Contents: no such file: ignored.csv - if this path came
from the machine that authored the query, bind the step's result instead:
--bind Source=<local file>
```

**Supported:** number/text/logical/null literals; `+ - * /`; `= <> < <= > >=`;
`and or not`; text `&`; `if/then/else`; `let/in` (lazy, memoised, correctly
shadowed - a binding's expression is only ever evaluated once, and only if
something actually references it); records (`[a = 1]`) and field access
(`r[a]`, `r[a]?`, and the `each`-scoped `[a]` shorthand for `_[a]`); lists
(`{1, 2}`) and index access (`l{0}`, `l{0}?`); `each` and `(x) => ...` lambdas
and calling them; `try ... otherwise ...`; and these 299 builtins.
The list below is generated from `pqtools.evaluate.BUILTINS` and
`tests/test_readme_builtins.py` fails if the two ever disagree - so it cannot
silently drift, which a hand-maintained list can and did:

```
Text.AfterDelimiter Text.At Text.BeforeDelimiter Text.BetweenDelimiters
Text.Clean Text.Combine Text.Contains Text.End Text.EndsWith Text.From
Text.Insert Text.Length Text.Lower Text.Middle Text.NewGuid Text.PadEnd
Text.PadStart Text.PositionOf Text.PositionOfAny Text.Proper Text.Remove
Text.Repeat Text.Replace Text.Reverse Text.Select Text.Split Text.SplitAny
Text.Start Text.StartsWith Text.ToList Text.Trim Text.TrimEnd Text.TrimStart
Text.Type Text.Upper
Number.Abs Number.BitwiseAnd Number.BitwiseOr Number.BitwiseXor Number.Exp
Number.Factorial Number.From Number.IntegerDivide Number.IsEven Number.IsNaN
Number.IsOdd Number.Ln Number.Log Number.Log10 Number.Mod Number.Power
Number.Random Number.RandomBetween Number.Round Number.RoundAwayFromZero
Number.RoundDown Number.RoundTowardZero Number.RoundUp Number.Sign Number.Sqrt
Number.ToText Number.Type
List.Accumulate List.AllTrue List.AnyTrue List.Average List.Buffer
List.Combine List.Contains List.ContainsAll List.ContainsAny List.Count
List.Difference List.Distinct List.First List.FirstN List.Generate
List.InsertRange List.Intersect List.IsEmpty List.Last List.LastN List.Max
List.Median List.Min List.Mode List.NonNullCount List.Numbers List.Percentile
List.PositionOf List.Positions List.Range List.RemoveItems List.RemoveNulls
List.Repeat List.ReplaceValue List.Reverse List.Select List.Skip List.Sort
List.Split List.StandardDeviation List.Sum List.Transform List.Union List.Zip
Record.AddField Record.Combine Record.Field Record.FieldCount
Record.FieldNames Record.FieldOrDefault Record.FromList Record.HasFields
Record.RemoveFields Record.RenameFields Record.ReorderFields
Record.SelectFields Record.ToList Record.ToTable Record.TransformFields
Table.AddColumn Table.AddIndexColumn Table.Buffer Table.ColumnCount
Table.ColumnNames Table.Combine Table.DemoteHeaders Table.Distinct
Table.DuplicateColumn Table.ExpandListColumn Table.ExpandRecordColumn
Table.ExpandTableColumn
Table.FillDown Table.FillUp Table.FirstN Table.FromColumns Table.FromList
Table.FromRecords Table.FromRows Table.FromValue Table.Group Table.HasColumns
Table.IsEmpty Table.Join Table.LastN Table.Max Table.Min Table.NestedJoin
Table.Pivot Table.PromoteHeaders Table.Range Table.RemoveColumns
Table.RemoveRowsWithErrors Table.RenameColumns Table.Repeat
Table.ReorderColumns Table.ReplaceErrorValues Table.ReplaceValue
Table.ReverseRows Table.RowCount Table.SelectColumns Table.SelectDuplicates
Table.SelectRows Table.SelectRowsWithErrors Table.Skip Table.Sort
Table.SplitColumn Table.ToColumns
Table.ToList Table.ToRecords Table.ToRows Table.TransformColumnNames
Table.TransformColumnTypes Table.TransformColumns Table.Transpose
Table.Unpivot Table.UnpivotOtherColumns #table
Csv.Document File.Contents Text.FromBinary Binary.FromText Binary.ToText
Binary.Decompress Binary.Length BinaryEncoding.Base64 BinaryEncoding.Hex
Compression.None Compression.Deflate Compression.GZip #binary
Date.AddDays Date.AddMonths Date.AddWeeks Date.AddYears Date.Day
Date.DayOfWeek Date.DayOfWeekName Date.DayOfYear Date.EndOfMonth
Date.EndOfWeek Date.EndOfYear Date.From Date.IsInCurrentMonth
Date.IsInCurrentYear Date.Month Date.MonthName Date.QuarterOfYear
Date.StartOfMonth Date.StartOfWeek Date.StartOfYear Date.ToText Date.Type
Date.WeekOfYear Date.Year
DateTime.AddZone DateTime.Date DateTime.FixedLocalNow DateTime.From
DateTime.LocalNow DateTime.Time DateTime.ToText DateTime.Type
Duration.Days Duration.From Duration.Hours Duration.Minutes Duration.Seconds
Duration.ToText Duration.TotalDays Duration.TotalHours Duration.TotalMinutes
Duration.TotalSeconds
Time.From Time.Hour Time.Minute Time.Second Time.ToText
Splitter.SplitTextByCharacterTransition Splitter.SplitTextByDelimiter
Splitter.SplitTextByEachDelimiter Splitter.SplitTextByPositions
Replacer.ReplaceText Replacer.ReplaceValue
Value.Compare Value.Equals Value.Is Value.Type
Type.Is
Json.Document
Logical.From Logical.Type
Order.Ascending Order.Descending
Occurrence.All Occurrence.First Occurrence.Last
MissingField.Error MissingField.Ignore MissingField.UseNull
RelativePosition.FromEnd RelativePosition.FromStart
JoinKind.FullOuter JoinKind.Inner JoinKind.LeftAnti JoinKind.LeftOuter
JoinKind.RightAnti JoinKind.RightOuter
GroupKind.Global GroupKind.Local
Day.Friday Day.Monday Day.Saturday Day.Sunday Day.Thursday Day.Tuesday
Day.Wednesday
Any.Type
Byte.Type
Currency.Type
Decimal.Type
Double.Type
ExtraValues.Error ExtraValues.Ignore ExtraValues.List
Int16.Type
Int32.Type
Int64.Type
Int8.Type
Percentage.Type
QuoteStyle.Csv QuoteStyle.None
Single.Type
#date #datetime #datetimezone #duration #time
```

Also supported: the M **type system** (`type text`, `type date`, `Int64.Type`
and the other nominal number subtypes) as real values, which is what makes
`Table.TransformColumnTypes` - step two of every query Power Query's UI writes -
actually run; **column projection** (`[Amount]` on a table yields that column's
values, so `each List.Sum([Amount])` works as a `Table.Group` aggregation); and
**temporal values** (`#date`, `#datetime`, `#time`, `#duration`) which compare
and order by value, so date filters and date ranges behave.

**Everything else raises a typed `UnsupportedError` (`M_EVAL_UNSUPPORTED`)
naming the exact construct** - never approximated, never guessed at. That
includes: any engine-backed connector (`Web.Contents`, `Sql.Database`,
`Excel.Workbook`, `SharePoint.*`, `Odbc.*`, `Folder.*` - the error
names the construct and says it needs Fabric or PQTest, the two hosts that can
actually run it; `Csv.Document` and `File.Contents` are *not* in this list, they
run natively);
`#shared`; `meta`; `??`; field projection (`r[[a],[b]]`); culture-aware date and
number parsing (a supplied culture is refused by name rather than silently
parsed as en-US); `RoundingMode.*`, `TextEncoding.*` and `BinaryEncoding.*`
(deliberately unregistered - their numeric values could not be verified, and a
wrong enum number would silently do the wrong thing rather than fail); any
identifier this evaluator does not know; and any builtin call with an argument
shape not listed above. A wrong number would be worse than a refusal, so `pqtools` never
approximates a connector's result or a builtin's documented behaviour - it
either runs the real, documented semantics or it stops and tells you exactly
where. `max_steps` (default 1,000,000, an `evaluate()` keyword argument) bounds
the total number of AST nodes visited, so a runaway query cannot hang the
caller either.

`pq eval` does not replace Power Query. It runs local-file sources and the
whole transformation chain; for anything that needs a live engine it stops and
says so, rather than guessing at what that engine would have returned.

## Frequently asked

### Can I run Power Query without Power BI or Excel?

For local-file sources, yes. `pq eval report.pq` runs `Csv.Document`,
`File.Contents` and the entire `Table.*` / `List.*` / `Text.*` transformation
chain in Python. For sources that need a live engine - `Sql.Database`,
`Web.Contents`, `SharePoint.*`, `Odbc.*` - no tool outside Microsoft's Mashup
Engine can, and `pqtools` says so with a typed error instead of guessing.

### Is there a linter or formatter for Power Query M?

That is what `pq check` and `pq format` are. Formatting is delegated to
Microsoft's own `@microsoft/powerquery-formatter`, and parsing to Microsoft's
`@microsoft/powerquery-parser`, both pinned - so the syntax pqtools accepts is
the syntax Power Query accepts, not a reimplementation that drifts.

### Is pqtools the pandas of Power Query?

For the transformation half, that is a fair description: 299 M builtins, real
`Table.Group` aggregations, all six `JoinKind` values, pivot/unpivot, the type
system and date/time handling. The difference from pandas is the boundary -
pandas has no equivalent of a `Sql.Database` connector that only a proprietary
engine can open, and where pqtools meets one it stops rather than approximating.

### How do I test a Power Query transformation?

Bind the source step to a fixture and assert on the output:

```python
from pqtools import evaluate

query = """
let
    Source = Csv.Document(File.Contents("live.csv")),
    Renamed = Table.RenameColumns(Source, {{"a", "id"}})
in
    Renamed
"""

# --bind's Python equivalent: Source is substituted, so Csv.Document and
# File.Contents are never called and live.csv is never opened.
assert evaluate(query, bindings={"Source": [{"a": 1}]}) == [{"id": 1}]
```

### Can it read the queries inside a .pbix or .xlsx file?

Yes - `pq check report.pbix` and `pq format book.xlsx` extract and analyse the M
already stored in the container, and `--write` saves changes back into it.
See [Working inside .xlsx and .pbix](#working-inside-xlsx-and-pbix).

### Does it send my data anywhere?

No. There is no network call in the runtime, no telemetry, and no runtime
dependency. See [Safety model](#safety-model).

## Safety model

- **Dry-run by default.** Every edit command (`format`, `rename`,
  `replace-source`) prints a unified diff and touches nothing unless `--write`
  is passed.
- **`--write` is an atomic replace**: the file is written to a sibling temp
  file, `fsync`'d, `chmod`'d to match the original, then moved into place with
  `os.replace`, after which the parent directory is `fsync`'d so the rename
  itself is durable.
- **Layout is preserved**: UTF-8 encoding, a leading BOM (present in every
  Power Query SDK connector file), newline convention (`\n` vs `\r\n`),
  final-newline state, and file mode all round-trip unchanged.
- **Refuses symlinks and hardlinks** - writes require a regular, single-link
  file.
- **Detects concurrent change**: the source is snapshotted before the
  transform and re-checked immediately before the atomic replace - this final
  snapshot check, not the lock, is the guarantee against lost updates; a
  change in that microsecond window raises `SafeWriteError`.
- **Advisory lock while writing only** - a `--write` call takes a
  cross-process advisory lock (`fcntl`/`msvcrt`) for the duration of the
  write and removes the lock file afterward, best-effort. It only serialises
  cooperating `pq` processes and is not a correctness guarantee: because
  the lock file is removed after use, a waiting process and a freshly
  started one can end up locking different inodes. Dry-run calls take no
  lock and create no lock file.
- This is **not mandatory locking** - no OS provides a portable mandatory
  lock, and the advisory lock is not itself the correctness guard. Use
  source control or external exclusive ownership for concurrent editors.

## Limits

**Each evaluation spawns Node.** `parse`, `check`, `format`, `rename` and `eval`
each start a Node subprocess to reach Microsoft's parser - measured at roughly
**0.75 s per call**, almost entirely process startup rather than parsing. That is
fine for a CLI invocation and for linting a file in CI, but it means evaluating
hundreds of queries in a loop from Python is dominated by process spawn, not by
your data. The test suite hits this hard enough that it runs with `pytest -n auto`
(945 s serial, 126 s parallel). Making the bridge a persistent worker process
would remove the per-call cost; that is a real change to the most
safety-critical code in the package, so it is not being rushed into a release.


- Input and output are capped at **10 MiB**.
- The Node subprocess is bounded to a **30 second** timeout.
- Supported extensions: `.pq`, `.m`, `.pqm`, and any `*.query.pq` file.
- `rename` scope: exactly **one unquoted top-level `let` binding**. It refuses
  quoted identifiers (`#"..."`), record literals, lambda expressions, and
  non-ASCII source.
- `Retry-After` on the Fabric adapter must be whole seconds; HTTP-date values
  are rejected.
- **Windows:** two guarantees are weaker there and the code says so rather than pretending.
  A directory `fsync` after the atomic replace is impossible on Windows, so the rename is durable
  only as far as the filesystem makes it; and if the Node subprocess spawns a grandchild that
  inherits its stdout, a reader already blocked in `ReadFile` is not released by closing the pipe,
  so a timed-out call can run until that grandchild exits. Neither affects the bundled bridge,
  which spawns nothing.
- The parse response is roughly 40x the size of the source, and it is capped at 10 MiB, so `parse`, `check`, `dependencies` and `rename` fail with a typed `NodeError` on sources above roughly 240 KiB. `format` returns only text and is not affected.
- `eval` walks at most `max_steps` AST nodes (default 1,000,000, an
  `evaluate()` keyword argument, not yet exposed as a CLI flag) before raising
  a typed `EvalError` - a runaway or hostile query cannot hang the caller. A
  `--bind` file goes through the same `--bind`-only read path as everything
  else: 10 MiB cap, no symlinks, no non-regular files.

## Working inside .xlsx and .pbix

`pqtools` can read the Power Query M source out of the real files it lives
in - no need to open Excel or Power BI to see or lint a query.

**Supported:** `pq check`, `pq parse`, `pq dependencies` and `pq eval` accept
an `.xlsx`, `.pbix`, `.pbit`, or a `.pbip` project (or its directory) directly.
Each finds the Power Query section(s) inside the container and runs
normally; `check` diagnostics and JSON output are labelled
`container!part` (e.g. `report.pbix!Formulas/Section1.m`) so the output
stays greppable across a batch of files. `pq eval` needs `--member NAME` to
pick one `shared` query out of a container that holds more than one.

```bash
pq check report.pbix
pq check "Sales.pbip" --json
pq dependencies workbook.xlsx
```

**Listing and adding:**

```bash
pq list report.pbix                      # name every query inside
pq eval report.pbix --member Sales       # run one of them
pq add  book.xlsx --name "Top Colors" \
        --source 'let S = ... in S'      # preview a new query
pq add  book.xlsx --name "Top Colors" \
        --source 'let S = ... in S' --write   # ...and save it in
```

**Writing back:** `pq format`, `pq rename`, `pq replace-source` and `pq add`
print the result by default and save it with `--write`. A `--write` on a
container copies the original to `<file>.bak` first, because this is the one
command here that can destroy its input and the damage would surface in Excel
rather than in this process.

The rebuild changes only the M source: every other zip member and all three
opaque DataMashup segments are carried through byte-for-byte, and the result
is re-read from scratch and verified before anything reaches disk. It has been
validated against synthesized fixtures, a real Power BI `.pbix`, and real
Excel-authored `.xlsx` workbooks - on those, **openpyxl (an independent xlsx
parser) opens the rewritten file to the same sheets and cell values as the
original**, which is the check that matters, since it is not this code
agreeing with itself.

**Residual risk, stated plainly:** Excel itself has not opened a rewritten
workbook, because Excel was not installed where this was validated. The checks
above are the strongest available substitute, not a replacement - hence the
`.bak`.

`pqtools` is not a Power BI or Excel client: `pq eval` runs a query's own
transformation chain against data you supply (see [Running M](#running-m)) -
it never opens a workbook, runs a connector, or writes anything back through
the CLI.

## Optional adapters

- **`fabric` extra** (`pip install "pqtools[fabric]"`) - a Fabric
  Execute Query client that takes a caller-provided bearer token and an
  injected HTTP transport. It never manages credentials itself and is fully
  mocked in tests (no network access in the test suite).
- **`pqtest`** - a bounded wrapper around a user-installed Microsoft PQTest
  executable, Windows-only, pinned to version `2.155.2`. It never downloads a
  binary; it only validates and runs one already on disk.

## What it is not

- Not the Power Query Mashup Engine. `pq eval` runs a query's transformation
  chain against data you supply (see [Running M](#running-m)); it never runs a
  connector, and anything it does not implement raises a typed error instead
  of approximating one.
- Not a Power BI or Fabric client, and it does not manage credentials.
- Not a general-purpose file editor - it only touches files with a supported
  extension and only through the safety model above.
- Not a replacement for Microsoft's own parser/formatter - it vendors and
  calls them directly rather than reimplementing M syntax.

## Development

```bash
git clone https://github.com/GopalGB/pqtools
cd pqtools
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,fabric]"
npm ci --ignore-scripts

pytest -q --cov=pqtools --cov-fail-under=80
mypy src
ruff check .
ruff format --check .
npm test
python -m build
```

## License

MIT - see `LICENSE`. Bundled Microsoft packages
(`@microsoft/powerquery-parser`, `@microsoft/powerquery-formatter`) and their
dependencies are also MIT; see `THIRD_PARTY_NOTICES.txt` and `NOTICE`.
