pqtools 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pqtools-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gopal Bagaswar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pqtools-0.4.0/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ Unofficial project, not affiliated with Microsoft. The bundled JavaScript
2
+ components retain their copyright and MIT permission notices in
3
+ `pqtools/THIRD_PARTY_NOTICES.txt`.
4
+ The test corpus includes a short Power Query M let-expression example from the
5
+ Microsoft M language specification:
6
+ https://learn.microsoft.com/en-us/powerquery-m/m-spec-let
7
+
8
+ The vendored DataConnectors samples remain Copyright (c) Microsoft Corporation
9
+ and are redistributed under the MIT License included beside those fixtures.
pqtools-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,357 @@
1
+ Metadata-Version: 2.4
2
+ Name: pqtools
3
+ Version: 0.4.0
4
+ Summary: Power Query M from Python: lint, format and refactor .pq or the queries inside .pbix/.xlsx, and run the transformation chain on your own data
5
+ Author: Gopal Bagaswar
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/GopalGB/pqtools
8
+ Project-URL: Repository, https://github.com/GopalGB/pqtools
9
+ Project-URL: Issues, https://github.com/GopalGB/pqtools/issues
10
+ Keywords: power-query,m-language,power-bi,fabric,linter,formatter,pq
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ License-File: NOTICE
25
+ Provides-Extra: fabric
26
+ Requires-Dist: pyarrow>=14; extra == "fabric"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-cov>=5; extra == "dev"
30
+ Requires-Dist: mypy>=1.10; extra == "dev"
31
+ Requires-Dist: ruff>=0.5; extra == "dev"
32
+ Requires-Dist: build>=1.2; extra == "dev"
33
+ Requires-Dist: twine>=6.1; extra == "dev"
34
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # pqtools
38
+
39
+ Offline command-line and Python tooling for Power Query M source: parse, format,
40
+ lint (`check`), safely rename a `let` binding, and run (`eval`) the
41
+ transformation chain of a query against data you supply.
42
+
43
+ > **Unofficial.** Not affiliated with or endorsed by Microsoft. Not a Power Query
44
+ > runtime - `pq eval` runs the transformation chain of a query locally; it never
45
+ > runs a connector (`Web.Contents`, `Sql.Database`, `Csv.Document`, ...). See
46
+ > [Running M](#running-m) below.
47
+
48
+ > **Renamed.** Published as `mquery-toolkit` 0.1.0 on 2026-09-03 and renamed the
49
+ > same day to `pqtools` to avoid a CLI name collision with the existing `mquery`
50
+ > package on PyPI (a Yara malware-query tool). `mquery-toolkit` 0.1.0 is yanked.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install pqtools
56
+ ```
57
+
58
+ Requires **Node.js 22 or newer** on `PATH`, or point `MQUERY_NODE` at a Node
59
+ binary. The Microsoft parser and formatter packages are bundled inside the
60
+ wheel (`_bridge.cjs`) - no `npm install` needed.
61
+
62
+ ## Quick start
63
+
64
+ ```bash
65
+ # Parse to deterministic JSON (tokens, root kind, bindings/references)
66
+ pq parse query.pq
67
+
68
+ # Format - dry run prints a unified diff, nothing is written
69
+ pq format query.pq
70
+
71
+ # Format and write in place (atomic replace, preserves mode/newline/encoding)
72
+ pq format query.pq --write
73
+
74
+ # Lint, machine-readable output; exit code 2 if any diagnostic is severity=error
75
+ pq check query.pq --json
76
+
77
+ # Rename one top-level let binding - dry run first
78
+ pq rename query.pq --old OldName --new NewName
79
+
80
+ # Run a query's transformation chain locally, against your own data
81
+ pq eval report.pq --bind Source=data.csv
82
+ ```
83
+
84
+ ## Python API
85
+
86
+ ```python
87
+ from pqtools import check, format_source, parse, rename, update_file
88
+
89
+ parsed = parse(source_text) # dict: tokens, rootKind, analysis
90
+ formatted = format_source(source_text) # formatted M source, same encoding
91
+ diagnostics = check(source_text, "query.pq") # list[Diagnostic]
92
+ renamed = rename(source_text, "OldName", "NewName")
93
+
94
+ from pqtools.evaluate import evaluate
95
+
96
+ result = evaluate(source_text, bindings={"Source": [{"a": "1"}, {"a": "2"}]})
97
+
98
+ # File-level edit with the same dry-run/--write safety model as the CLI
99
+ diff = update_file(path, format_source) # dry run: unified diff
100
+ diff = update_file(path, format_source, write=True) # atomic write
101
+ ```
102
+
103
+ ## Diagnostics
104
+
105
+ | Code | Severity | Meaning |
106
+ |---|---|---|
107
+ | `M_PARSE_ERROR` | error | source does not parse |
108
+ | `M001` | error | duplicate `let` binding name |
109
+ | `M002` | warning | `Web.Contents` called with a non-literal (dynamic) URL |
110
+ | `M003` | warning | credential-like literal (`password`/`token`/`secret` = `"..."`) |
111
+ | `M004` | warning | `let` binding unreachable from the result |
112
+ | `M005` | warning | unresolved unqualified reference |
113
+ | `M006` | info | source-function inventory (`*.Contents` dependency) |
114
+
115
+ `M002` and `M003` are token-based checks over the parsed source, so they no
116
+ longer fire inside comments or strings. Every matching occurrence is
117
+ reported, one diagnostic per call site or literal.
118
+
119
+ `check --json` emits stable objects; `check` without `--json` prints
120
+ `file:line:column: severity code: message` per diagnostic. The CLI exits `2`
121
+ when any diagnostic has severity `error`, `0` otherwise.
122
+
123
+ ## Running M
124
+
125
+ pandas does not run Excel's formulas; it replaces Excel's data connections with
126
+ your data, in Python. `pq eval` does the same for Power Query. A real M query is
127
+ a `Source = <connector>(...)` step followed by a chain of `Table.*`
128
+ transformations. `pqtools` cannot run the connector step - that is Microsoft's
129
+ proprietary Mashup Engine, and this project does not reimplement it. But if
130
+ *you* supply the source table, the rest of the transformation chain runs
131
+ locally, offline, in Python:
132
+
133
+ ```bash
134
+ pq eval report.pq --bind Source=data.csv
135
+ ```
136
+
137
+ `--bind NAME=PATH` loads `PATH` (a `.csv`, read as a list of records with
138
+ `csv.DictReader` - every value stays text, or a `.json` file, loaded as
139
+ whatever it holds) and, wherever `NAME` is used as a `let` binding in the
140
+ query, substitutes it directly - the binding's own right-hand-side expression
141
+ (the connector call) is never evaluated, which is exactly what makes it
142
+ irrelevant that `pqtools` cannot run it.
143
+
144
+ A **table** is simply `list[dict[str, Any]]` - a list of records. A record is
145
+ `dict[str, Any]`. A list is `list[Any]`. That is the whole data model.
146
+
147
+ **Worked example.** Given `report.pq`:
148
+
149
+ ```m
150
+ let
151
+ Source = Csv.Document(File.Contents("ignored.csv")),
152
+ Kept = Table.SelectRows(Source, each [b] <> "y"),
153
+ Renamed = Table.RenameColumns(Kept, {{"a", "id"}})
154
+ in
155
+ Renamed
156
+ ```
157
+
158
+ and `data.csv`:
159
+
160
+ ```csv
161
+ a,b
162
+ 1,x
163
+ 2,y
164
+ 3,z
165
+ ```
166
+
167
+ ```bash
168
+ $ pq eval report.pq --bind Source=data.csv
169
+ [{"b": "x", "id": "1"}, {"b": "z", "id": "3"}]
170
+ ```
171
+
172
+ `Csv.Document(File.Contents("ignored.csv"))` is never called - `ignored.csv` is
173
+ never opened. `Source` is the CSV you bound, `Kept` drops the `b = "y"` row, and
174
+ `Renamed` renames `a` to `id`. Without `--bind`, the same query fails with a
175
+ typed, exit-`2` error naming the connector:
176
+
177
+ ```bash
178
+ $ pq eval report.pq
179
+ error M_EVAL_UNSUPPORTED: Csv.Document is a connector - Power Query's Mashup
180
+ Engine runs it (Fabric or PQTest is the host that can); pqtools evaluates only
181
+ the transformation chain after you supply its result table with --bind
182
+ ```
183
+
184
+ **Supported:** number/text/logical/null literals; `+ - * /`; `= <> < <= > >=`;
185
+ `and or not`; text `&`; `if/then/else`; `let/in` (lazy, memoised, correctly
186
+ shadowed - a binding's expression is only ever evaluated once, and only if
187
+ something actually references it); records (`[a = 1]`) and field access
188
+ (`r[a]`, `r[a]?`, and the `each`-scoped `[a]` shorthand for `_[a]`); lists
189
+ (`{1, 2}`) and index access (`l{0}`, `l{0}?`); `each` and `(x) => ...` lambdas
190
+ and calling them; `try ... otherwise ...`; and this builtin set (verbatim from
191
+ `pqtools.evaluate.BUILTINS`, so it cannot drift out of sync with the code):
192
+
193
+ ```
194
+ Text.From Text.Upper Text.Lower Text.Length Text.Combine Text.Contains
195
+ Text.Replace Text.Split Text.Start Text.End Text.Trim
196
+ Number.From Number.Round Number.Abs
197
+ List.Count List.Sum List.Max List.Min List.Average List.Transform List.Select
198
+ List.First List.Last List.Reverse List.Sort List.Contains List.Distinct
199
+ List.Range
200
+ Record.Field Record.FieldNames Record.HasFields Record.AddField
201
+ Record.RemoveFields
202
+ Table.FromRecords Table.ToRecords Table.RowCount Table.ColumnNames
203
+ Table.SelectRows Table.SelectColumns Table.RemoveColumns Table.RenameColumns
204
+ Table.AddColumn Table.TransformColumns Table.Sort Table.FirstN Table.LastN
205
+ Table.Distinct
206
+ Json.Document (text only - not the binary overload)
207
+ Logical.From
208
+ ```
209
+
210
+ **Everything else raises a typed `UnsupportedError` (`M_EVAL_UNSUPPORTED`)
211
+ naming the exact construct** - never approximated, never guessed at. That
212
+ includes: any connector (`Web.Contents`, `Sql.Database`, `File.Contents`,
213
+ `Excel.Workbook`, `Csv.Document`, `Binary.*` - the error names the construct
214
+ and says it needs Fabric or PQTest, the two hosts that can actually run it);
215
+ `#shared`; `meta`; type ascription (`as`, `is`, parameter/return types,
216
+ `type ...`); `??`; field projection (`r[[a],[b]]`); any identifier this
217
+ evaluator does not know; and any builtin call with an argument shape not
218
+ listed above. A wrong number would be worse than a refusal, so `pqtools` never
219
+ approximates a connector's result or a builtin's documented behaviour - it
220
+ either runs the real, documented semantics or it stops and tells you exactly
221
+ where. `max_steps` (default 1,000,000, an `evaluate()` keyword argument) bounds
222
+ the total number of AST nodes visited, so a runaway query cannot hang the
223
+ caller either.
224
+
225
+ `pq eval` does not replace Power Query - it replaces the connector's *data*,
226
+ the same trade pandas makes when it replaces a spreadsheet's data connections.
227
+
228
+ ## Safety model
229
+
230
+ - **Dry-run by default.** Every edit command (`format`, `rename`,
231
+ `replace-source`) prints a unified diff and touches nothing unless `--write`
232
+ is passed.
233
+ - **`--write` is an atomic replace**: the file is written to a sibling temp
234
+ file, `fsync`'d, `chmod`'d to match the original, then moved into place with
235
+ `os.replace`, after which the parent directory is `fsync`'d so the rename
236
+ itself is durable.
237
+ - **Layout is preserved**: UTF-8 encoding, a leading BOM (present in every
238
+ Power Query SDK connector file), newline convention (`\n` vs `\r\n`),
239
+ final-newline state, and file mode all round-trip unchanged.
240
+ - **Refuses symlinks and hardlinks** - writes require a regular, single-link
241
+ file.
242
+ - **Detects concurrent change**: the source is snapshotted before the
243
+ transform and re-checked immediately before the atomic replace - this final
244
+ snapshot check, not the lock, is the guarantee against lost updates; a
245
+ change in that microsecond window raises `SafeWriteError`.
246
+ - **Advisory lock while writing only** - a `--write` call takes a
247
+ cross-process advisory lock (`fcntl`/`msvcrt`) for the duration of the
248
+ write and removes the lock file afterward, best-effort. It only serialises
249
+ cooperating `pq` processes and is not a correctness guarantee: because
250
+ the lock file is removed after use, a waiting process and a freshly
251
+ started one can end up locking different inodes. Dry-run calls take no
252
+ lock and create no lock file.
253
+ - This is **not mandatory locking** - no OS provides a portable mandatory
254
+ lock, and the advisory lock is not itself the correctness guard. Use
255
+ source control or external exclusive ownership for concurrent editors.
256
+
257
+ ## Limits
258
+
259
+ - Input and output are capped at **10 MiB**.
260
+ - The Node subprocess is bounded to a **30 second** timeout.
261
+ - Supported extensions: `.pq`, `.m`, `.pqm`, and any `*.query.pq` file.
262
+ - `rename` scope: exactly **one unquoted top-level `let` binding**. It refuses
263
+ quoted identifiers (`#"..."`), record literals, lambda expressions, and
264
+ non-ASCII source.
265
+ - `Retry-After` on the Fabric adapter must be whole seconds; HTTP-date values
266
+ are rejected.
267
+ - **Windows:** two guarantees are weaker there and the code says so rather than pretending.
268
+ A directory `fsync` after the atomic replace is impossible on Windows, so the rename is durable
269
+ only as far as the filesystem makes it; and if the Node subprocess spawns a grandchild that
270
+ inherits its stdout, a reader already blocked in `ReadFile` is not released by closing the pipe,
271
+ so a timed-out call can run until that grandchild exits. Neither affects the bundled bridge,
272
+ which spawns nothing.
273
+ - 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.
274
+ - `eval` walks at most `max_steps` AST nodes (default 1,000,000, an
275
+ `evaluate()` keyword argument, not yet exposed as a CLI flag) before raising
276
+ a typed `EvalError` - a runaway or hostile query cannot hang the caller. A
277
+ `--bind` file goes through the same `--bind`-only read path as everything
278
+ else: 10 MiB cap, no symlinks, no non-regular files.
279
+
280
+ ## Working inside .xlsx and .pbix
281
+
282
+ `pqtools` can read the Power Query M source out of the real files it lives
283
+ in - no need to open Excel or Power BI to see or lint a query.
284
+
285
+ **Supported:** `pq check`, `pq parse`, `pq dependencies` and `pq eval` accept
286
+ an `.xlsx`, `.pbix`, `.pbit`, or a `.pbip` project (or its directory) directly.
287
+ Each finds the Power Query section(s) inside the container and runs
288
+ normally; `check` diagnostics and JSON output are labelled
289
+ `container!part` (e.g. `report.pbix!Formulas/Section1.m`) so the output
290
+ stays greppable across a batch of files. `pq eval` needs `--member NAME` to
291
+ pick one `shared` query out of a container that holds more than one.
292
+
293
+ ```bash
294
+ pq check report.pbix
295
+ pq check "Sales.pbip" --json
296
+ pq dependencies workbook.xlsx
297
+ ```
298
+
299
+ **Not supported (yet):** writing back into a container. `pq format`,
300
+ `pq rename` and `pq replace-source` refuse with a clear error on a
301
+ container path. The underlying logic exists
302
+ (`pqtools.containers.write_sections`) and is exercised in this repo's test
303
+ suite against synthesized fixtures and a real Power BI Desktop sample - it
304
+ rebuilds the container with only the M source changed, then re-reads its
305
+ own output and verifies nothing else moved before ever touching disk - but
306
+ it has not been validated against the wide range of real-world files this
307
+ format can take, so it is deliberately kept out of the CLI.
308
+
309
+ `pqtools` is not a Power BI or Excel client: `pq eval` runs a query's own
310
+ transformation chain against data you supply (see [Running M](#running-m)) -
311
+ it never opens a workbook, runs a connector, or writes anything back through
312
+ the CLI.
313
+
314
+ ## Optional adapters
315
+
316
+ - **`fabric` extra** (`pip install "pqtools[fabric]"`) - a Fabric
317
+ Execute Query client that takes a caller-provided bearer token and an
318
+ injected HTTP transport. It never manages credentials itself and is fully
319
+ mocked in tests (no network access in the test suite).
320
+ - **`pqtest`** - a bounded wrapper around a user-installed Microsoft PQTest
321
+ executable, Windows-only, pinned to version `2.155.2`. It never downloads a
322
+ binary; it only validates and runs one already on disk.
323
+
324
+ ## What it is not
325
+
326
+ - Not the Power Query Mashup Engine. `pq eval` runs a query's transformation
327
+ chain against data you supply (see [Running M](#running-m)); it never runs a
328
+ connector, and anything it does not implement raises a typed error instead
329
+ of approximating one.
330
+ - Not a Power BI or Fabric client, and it does not manage credentials.
331
+ - Not a general-purpose file editor - it only touches files with a supported
332
+ extension and only through the safety model above.
333
+ - Not a replacement for Microsoft's own parser/formatter - it vendors and
334
+ calls them directly rather than reimplementing M syntax.
335
+
336
+ ## Development
337
+
338
+ ```bash
339
+ git clone https://github.com/GopalGB/pqtools
340
+ cd pqtools
341
+ python -m venv .venv && source .venv/bin/activate
342
+ pip install -e ".[dev,fabric]"
343
+ npm ci --ignore-scripts
344
+
345
+ pytest -q --cov=pqtools --cov-fail-under=80
346
+ mypy src
347
+ ruff check .
348
+ ruff format --check .
349
+ npm test
350
+ python -m build
351
+ ```
352
+
353
+ ## License
354
+
355
+ MIT - see `LICENSE`. Bundled Microsoft packages
356
+ (`@microsoft/powerquery-parser`, `@microsoft/powerquery-formatter`) and their
357
+ dependencies are also MIT; see `THIRD_PARTY_NOTICES.txt` and `NOTICE`.