mquery-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mquery_toolkit/THIRD_PARTY_NOTICES.txt +34 -0
- mquery_toolkit/__init__.py +35 -0
- mquery_toolkit/_bridge.cjs +45531 -0
- mquery_toolkit/cli.py +118 -0
- mquery_toolkit/core.py +600 -0
- mquery_toolkit/fabric.py +213 -0
- mquery_toolkit/pqtest.py +93 -0
- mquery_toolkit/py.typed +0 -0
- mquery_toolkit-0.1.0.dist-info/METADATA +189 -0
- mquery_toolkit-0.1.0.dist-info/RECORD +15 -0
- mquery_toolkit-0.1.0.dist-info/WHEEL +5 -0
- mquery_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- mquery_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mquery_toolkit-0.1.0.dist-info/licenses/NOTICE +9 -0
- mquery_toolkit-0.1.0.dist-info/top_level.txt +1 -0
mquery_toolkit/fabric.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Mockable, caller-authenticated Fabric Execute Query state machine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from time import monotonic, sleep
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
from .core import MAX_BYTES, AdapterError
|
|
14
|
+
|
|
15
|
+
Response = Mapping[str, Any]
|
|
16
|
+
Transport = Callable[
|
|
17
|
+
[str, Mapping[str, str], Mapping[str, Any] | None, float], Response
|
|
18
|
+
]
|
|
19
|
+
ArrowValidator = Callable[[bytes], None]
|
|
20
|
+
_OPERATION_ID = re.compile(r"^[A-Za-z0-9-]{1,128}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def redact(value: str) -> str:
|
|
24
|
+
return "<redacted>" if value else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _same_origin(start: str, candidate: str) -> bool:
|
|
28
|
+
left, right = urlsplit(start), urlsplit(candidate)
|
|
29
|
+
return (left.scheme, left.netloc) == (right.scheme, right.netloc)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_arrow(body: bytes) -> None:
|
|
33
|
+
if len(body) > MAX_BYTES:
|
|
34
|
+
raise AdapterError("Fabric Arrow stream exceeds 10 MiB")
|
|
35
|
+
try:
|
|
36
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
37
|
+
except ImportError as error:
|
|
38
|
+
raise AdapterError(
|
|
39
|
+
"Arrow validation requires the mquery-toolkit[fabric] extra"
|
|
40
|
+
) from error
|
|
41
|
+
stream = io.BytesIO(body)
|
|
42
|
+
streams = 0
|
|
43
|
+
total_bytes = 0
|
|
44
|
+
try:
|
|
45
|
+
while stream.tell() < len(body):
|
|
46
|
+
start = stream.tell()
|
|
47
|
+
reader = pa.ipc.open_stream(stream)
|
|
48
|
+
metadata = {
|
|
49
|
+
key.decode("utf-8", "replace"): value.decode("utf-8", "replace")
|
|
50
|
+
for key, value in (reader.schema.metadata or {}).items()
|
|
51
|
+
}
|
|
52
|
+
if metadata.get("IsError", "").lower() == "true":
|
|
53
|
+
code = metadata.get("FaultCode", "unknown")
|
|
54
|
+
message = metadata.get("FaultString", "Fabric query failed")
|
|
55
|
+
raise AdapterError(f"Fabric Arrow error [{code}]: {message}")
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
batch = reader.read_next_batch()
|
|
59
|
+
except StopIteration:
|
|
60
|
+
break
|
|
61
|
+
total_bytes += batch.nbytes
|
|
62
|
+
if total_bytes > MAX_BYTES:
|
|
63
|
+
raise AdapterError("Fabric Arrow stream exceeds 10 MiB")
|
|
64
|
+
streams += 1
|
|
65
|
+
if stream.tell() <= start:
|
|
66
|
+
raise AdapterError("Fabric Arrow decoder made no progress")
|
|
67
|
+
except AdapterError:
|
|
68
|
+
raise
|
|
69
|
+
except Exception as error:
|
|
70
|
+
raise AdapterError("Fabric returned an invalid Arrow stream") from error
|
|
71
|
+
if streams == 0:
|
|
72
|
+
raise AdapterError("Fabric returned an empty Arrow stream")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _headers(response: Response) -> Mapping[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
str(key).lower(): value for key, value in response.get("headers", {}).items()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class FabricClient:
|
|
83
|
+
transport: Transport
|
|
84
|
+
timeout_seconds: int = 90
|
|
85
|
+
sleeper: Callable[[float], None] = sleep
|
|
86
|
+
clock: Callable[[], float] = monotonic
|
|
87
|
+
arrow_validator: ArrowValidator = _validate_arrow
|
|
88
|
+
|
|
89
|
+
def _request(
|
|
90
|
+
self,
|
|
91
|
+
url: str,
|
|
92
|
+
headers: Mapping[str, str],
|
|
93
|
+
payload: Mapping[str, Any] | None,
|
|
94
|
+
deadline: float,
|
|
95
|
+
) -> Response:
|
|
96
|
+
remaining = deadline - self.clock()
|
|
97
|
+
if remaining <= 0:
|
|
98
|
+
raise AdapterError("Fabric operation exceeded caller timeout")
|
|
99
|
+
try:
|
|
100
|
+
return self.transport(url, headers, payload, remaining)
|
|
101
|
+
except TimeoutError as error:
|
|
102
|
+
raise AdapterError("Fabric transport timed out") from error
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _require_https_same_origin(start: str, candidate: str) -> None:
|
|
106
|
+
if urlsplit(candidate).scheme != "https" or not _same_origin(start, candidate):
|
|
107
|
+
raise AdapterError(
|
|
108
|
+
"Fabric operation Location must remain HTTPS and same-origin"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _poll_url(start: str, location: str, operation_id: str) -> str:
|
|
113
|
+
candidate = urljoin(start, location) if location else ""
|
|
114
|
+
if not candidate:
|
|
115
|
+
if not _OPERATION_ID.fullmatch(operation_id):
|
|
116
|
+
raise AdapterError("Fabric operation id is missing or invalid")
|
|
117
|
+
origin = urlsplit(start)
|
|
118
|
+
candidate = (
|
|
119
|
+
f"{origin.scheme}://{origin.netloc}/v1/operations/"
|
|
120
|
+
f"{quote(operation_id, safe='')}"
|
|
121
|
+
)
|
|
122
|
+
FabricClient._require_https_same_origin(start, candidate)
|
|
123
|
+
return candidate
|
|
124
|
+
|
|
125
|
+
def execute(self, url: str, token: str, payload: Mapping[str, Any]) -> Response:
|
|
126
|
+
if not token:
|
|
127
|
+
raise AdapterError("Fabric caller token is required")
|
|
128
|
+
if urlsplit(url).scheme != "https":
|
|
129
|
+
raise AdapterError("Fabric URL must use HTTPS")
|
|
130
|
+
headers = {
|
|
131
|
+
"Authorization": f"Bearer {token}",
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
}
|
|
134
|
+
deadline = self.clock() + self.timeout_seconds
|
|
135
|
+
request_url: str = url
|
|
136
|
+
request_payload: Mapping[str, Any] | None = payload
|
|
137
|
+
operation_id: str | None = None
|
|
138
|
+
result_requested = False
|
|
139
|
+
while True:
|
|
140
|
+
if self.clock() > deadline:
|
|
141
|
+
raise AdapterError("Fabric operation exceeded caller timeout")
|
|
142
|
+
response = self._request(request_url, headers, request_payload, deadline)
|
|
143
|
+
status = response.get("status")
|
|
144
|
+
response_headers = _headers(response)
|
|
145
|
+
if status == 200:
|
|
146
|
+
content_type = str(response_headers.get("content-type", "")).lower()
|
|
147
|
+
body = response.get("body")
|
|
148
|
+
if "json" in content_type and isinstance(body, Mapping):
|
|
149
|
+
state = str(body.get("status", ""))
|
|
150
|
+
if state in {"Running", "NotStarted"}:
|
|
151
|
+
request_payload = None
|
|
152
|
+
self.sleeper(1)
|
|
153
|
+
continue
|
|
154
|
+
if state == "Failed":
|
|
155
|
+
raise AdapterError("Fabric operation reported Failed")
|
|
156
|
+
if state == "Succeeded":
|
|
157
|
+
if request_url == url:
|
|
158
|
+
raise AdapterError(
|
|
159
|
+
"Fabric returned a JSON status instead of an "
|
|
160
|
+
"Arrow stream"
|
|
161
|
+
)
|
|
162
|
+
if result_requested:
|
|
163
|
+
raise AdapterError(
|
|
164
|
+
"Fabric result endpoint returned JSON instead of Arrow"
|
|
165
|
+
)
|
|
166
|
+
parts = urlsplit(request_url)
|
|
167
|
+
result_url = urlunsplit(
|
|
168
|
+
parts._replace(path=parts.path.rstrip("/") + "/result")
|
|
169
|
+
)
|
|
170
|
+
self._require_https_same_origin(url, result_url)
|
|
171
|
+
request_url = result_url
|
|
172
|
+
request_payload = None
|
|
173
|
+
result_requested = True
|
|
174
|
+
continue
|
|
175
|
+
if (
|
|
176
|
+
"arrow" not in content_type
|
|
177
|
+
or not isinstance(body, bytes)
|
|
178
|
+
or not body
|
|
179
|
+
):
|
|
180
|
+
raise AdapterError(
|
|
181
|
+
"Fabric 200 response is not a non-empty Arrow stream"
|
|
182
|
+
)
|
|
183
|
+
self.arrow_validator(body)
|
|
184
|
+
return response
|
|
185
|
+
retry_raw = response_headers.get("retry-after", 1)
|
|
186
|
+
try:
|
|
187
|
+
retry = max(1, int(str(retry_raw)))
|
|
188
|
+
except ValueError as error:
|
|
189
|
+
raise AdapterError(
|
|
190
|
+
"Fabric Retry-After must be whole seconds"
|
|
191
|
+
) from error
|
|
192
|
+
if status == 429:
|
|
193
|
+
self.sleeper(min(retry, max(0, deadline - self.clock())))
|
|
194
|
+
continue
|
|
195
|
+
if status != 202:
|
|
196
|
+
shown = status if status is not None else "unknown"
|
|
197
|
+
raise AdapterError(f"Fabric returned HTTP {shown}")
|
|
198
|
+
location = str(response_headers.get("location", ""))
|
|
199
|
+
next_operation = str(response_headers.get("x-ms-operation-id", ""))
|
|
200
|
+
if not location and not next_operation:
|
|
201
|
+
raise AdapterError(
|
|
202
|
+
"Fabric 202 response requires Location or x-ms-operation-id"
|
|
203
|
+
)
|
|
204
|
+
if (
|
|
205
|
+
operation_id is not None
|
|
206
|
+
and next_operation
|
|
207
|
+
and operation_id != next_operation
|
|
208
|
+
):
|
|
209
|
+
raise AdapterError("Fabric operation id changed during polling")
|
|
210
|
+
operation_id = next_operation or operation_id
|
|
211
|
+
request_url = self._poll_url(url, location, operation_id or "")
|
|
212
|
+
request_payload = None
|
|
213
|
+
self.sleeper(min(retry, max(0, deadline - self.clock())))
|
mquery_toolkit/pqtest.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Bounded wrapper for a user-installed PQTest executable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .core import (
|
|
12
|
+
MAX_BYTES,
|
|
13
|
+
AdapterError,
|
|
14
|
+
_ProcessOutputLimit,
|
|
15
|
+
_run_process_bounded,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
PQTEST_VERSION = "2.155.2"
|
|
19
|
+
PQTEST_TIMEOUT_SECONDS = 300
|
|
20
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(
|
|
24
|
+
runner: Runner, command: Sequence[str], timeout: int = PQTEST_TIMEOUT_SECONDS
|
|
25
|
+
) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
if runner is subprocess.run:
|
|
27
|
+
return _run_bounded(command, timeout)
|
|
28
|
+
try:
|
|
29
|
+
result = runner(
|
|
30
|
+
command,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
errors="strict",
|
|
35
|
+
check=False,
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
)
|
|
38
|
+
except (OSError, subprocess.SubprocessError, UnicodeError) as error:
|
|
39
|
+
raise AdapterError("PQTest process failed") from error
|
|
40
|
+
if (
|
|
41
|
+
len(result.stdout.encode()) > MAX_BYTES
|
|
42
|
+
or len(result.stderr.encode()) > MAX_BYTES
|
|
43
|
+
):
|
|
44
|
+
raise AdapterError("PQTest output exceeds 10 MiB")
|
|
45
|
+
if result.returncode:
|
|
46
|
+
raise AdapterError(f"PQTest exited {result.returncode}")
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run_bounded(
|
|
51
|
+
command: Sequence[str], timeout: int = PQTEST_TIMEOUT_SECONDS
|
|
52
|
+
) -> subprocess.CompletedProcess[str]:
|
|
53
|
+
try:
|
|
54
|
+
result = _run_process_bounded(list(command), None, timeout)
|
|
55
|
+
except subprocess.TimeoutExpired as error:
|
|
56
|
+
raise AdapterError(f"PQTest timed out after {timeout} seconds") from error
|
|
57
|
+
except _ProcessOutputLimit as error:
|
|
58
|
+
raise AdapterError("PQTest output exceeds 10 MiB") from error
|
|
59
|
+
except OSError as error:
|
|
60
|
+
raise AdapterError("PQTest process failed") from error
|
|
61
|
+
try:
|
|
62
|
+
output = result.stdout.decode("utf-8", "strict")
|
|
63
|
+
error_output = result.stderr.decode("utf-8", "strict")
|
|
64
|
+
except UnicodeDecodeError as error:
|
|
65
|
+
raise AdapterError("PQTest output is not valid UTF-8") from error
|
|
66
|
+
if result.returncode:
|
|
67
|
+
raise AdapterError(f"PQTest exited {result.returncode}")
|
|
68
|
+
return subprocess.CompletedProcess(command, result.returncode, output, error_output)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_pqtest(
|
|
72
|
+
path: Path,
|
|
73
|
+
runner: Runner = subprocess.run,
|
|
74
|
+
timeout: int = PQTEST_TIMEOUT_SECONDS,
|
|
75
|
+
) -> Path:
|
|
76
|
+
if platform.system() != "Windows":
|
|
77
|
+
raise AdapterError("PQTest adapter is supported on Windows only")
|
|
78
|
+
if not path.is_file() or path.is_symlink() or path.suffix.lower() != ".exe":
|
|
79
|
+
raise AdapterError("PQTest path must name a user-installed regular .exe")
|
|
80
|
+
version = _run(runner, [str(path), "version"], timeout).stdout
|
|
81
|
+
if not re.search(rf"(?<![0-9.]){re.escape(PQTEST_VERSION)}(?![0-9.])", version):
|
|
82
|
+
raise AdapterError(f"PQTest {PQTEST_VERSION} is required")
|
|
83
|
+
return path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_pqtest(
|
|
87
|
+
path: Path,
|
|
88
|
+
args: Sequence[str],
|
|
89
|
+
runner: Runner = subprocess.run,
|
|
90
|
+
timeout: int = PQTEST_TIMEOUT_SECONDS,
|
|
91
|
+
) -> str:
|
|
92
|
+
validate_pqtest(path, runner, timeout)
|
|
93
|
+
return _run(runner, [str(path), *args], timeout).stdout
|
mquery_toolkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mquery-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial offline tooling for Power Query M source (parse, format, check, rename) via Microsoft's official parser and formatter
|
|
5
|
+
Author: Gopal Bagaswar
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/GopalGB/mquery-toolkit
|
|
8
|
+
Project-URL: Repository, https://github.com/GopalGB/mquery-toolkit
|
|
9
|
+
Project-URL: Issues, https://github.com/GopalGB/mquery-toolkit/issues
|
|
10
|
+
Keywords: power-query,m-language,power-bi,fabric,linter,formatter
|
|
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
|
+
# mquery-toolkit
|
|
38
|
+
|
|
39
|
+
Offline command-line and Python tooling for Power Query M source: parse, format,
|
|
40
|
+
lint (`check`), and safely rename a `let` binding.
|
|
41
|
+
|
|
42
|
+
> **Unofficial.** Not affiliated with or endorsed by Microsoft. Not an M runtime -
|
|
43
|
+
> it parses and formats M source text; it does not evaluate queries.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install mquery-toolkit
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires **Node.js 22 or newer** on `PATH`, or point `MQUERY_NODE` at a Node
|
|
52
|
+
binary. The Microsoft parser and formatter packages are bundled inside the
|
|
53
|
+
wheel (`_bridge.cjs`) - no `npm install` needed.
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Parse to deterministic JSON (tokens, root kind, bindings/references)
|
|
59
|
+
mquery parse query.pq
|
|
60
|
+
|
|
61
|
+
# Format - dry run prints a unified diff, nothing is written
|
|
62
|
+
mquery format query.pq
|
|
63
|
+
|
|
64
|
+
# Format and write in place (atomic replace, preserves mode/newline/encoding)
|
|
65
|
+
mquery format query.pq --write
|
|
66
|
+
|
|
67
|
+
# Lint, machine-readable output; exit code 2 if any diagnostic is severity=error
|
|
68
|
+
mquery check query.pq --json
|
|
69
|
+
|
|
70
|
+
# Rename one top-level let binding - dry run first
|
|
71
|
+
mquery rename query.pq --old OldName --new NewName
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Python API
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from mquery_toolkit import check, format_source, parse, rename, update_file
|
|
78
|
+
|
|
79
|
+
parsed = parse(source_text) # dict: tokens, rootKind, analysis
|
|
80
|
+
formatted = format_source(source_text) # formatted M source, same encoding
|
|
81
|
+
diagnostics = check(source_text, "query.pq") # list[Diagnostic]
|
|
82
|
+
renamed = rename(source_text, "OldName", "NewName")
|
|
83
|
+
|
|
84
|
+
# File-level edit with the same dry-run/--write safety model as the CLI
|
|
85
|
+
diff = update_file(path, format_source) # dry run: unified diff
|
|
86
|
+
diff = update_file(path, format_source, write=True) # atomic write
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Diagnostics
|
|
90
|
+
|
|
91
|
+
| Code | Severity | Meaning |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `M_PARSE_ERROR` | error | source does not parse |
|
|
94
|
+
| `M001` | error | duplicate `let` binding name |
|
|
95
|
+
| `M002` | warning | `Web.Contents` called with a non-literal (dynamic) URL |
|
|
96
|
+
| `M003` | warning | credential-like literal (`password`/`token`/`secret` = `"..."`) |
|
|
97
|
+
| `M004` | warning | `let` binding unreachable from the result |
|
|
98
|
+
| `M005` | warning | unresolved unqualified reference |
|
|
99
|
+
| `M006` | info | source-function inventory (`*.Contents` dependency) |
|
|
100
|
+
|
|
101
|
+
`M002` and `M003` are conservative text-pattern checks on the raw source, not
|
|
102
|
+
AST checks; expect occasional false positives inside comments or strings.
|
|
103
|
+
Every matching occurrence is reported, one diagnostic per call site or
|
|
104
|
+
literal.
|
|
105
|
+
|
|
106
|
+
`check --json` emits stable objects; `check` without `--json` prints
|
|
107
|
+
`file:line:column: severity code: message` per diagnostic. The CLI exits `2`
|
|
108
|
+
when any diagnostic has severity `error`, `0` otherwise.
|
|
109
|
+
|
|
110
|
+
## Safety model
|
|
111
|
+
|
|
112
|
+
- **Dry-run by default.** Every edit command (`format`, `rename`,
|
|
113
|
+
`replace-source`) prints a unified diff and touches nothing unless `--write`
|
|
114
|
+
is passed.
|
|
115
|
+
- **`--write` is an atomic replace**: the file is written to a sibling temp
|
|
116
|
+
file, `fsync`'d, `chmod`'d to match the original, then moved into place with
|
|
117
|
+
`os.replace`.
|
|
118
|
+
- **Layout is preserved**: UTF-8 encoding, a leading BOM (present in every
|
|
119
|
+
Power Query SDK connector file), newline convention (`\n` vs `\r\n`),
|
|
120
|
+
final-newline state, and file mode all round-trip unchanged.
|
|
121
|
+
- **Refuses symlinks and hardlinks** - writes require a regular, single-link
|
|
122
|
+
file.
|
|
123
|
+
- **Detects concurrent change**: the source is snapshotted before the
|
|
124
|
+
transform and re-checked immediately before the atomic replace - this final
|
|
125
|
+
snapshot check, not the lock, is the guarantee against lost updates; a
|
|
126
|
+
change in that microsecond window raises `SafeWriteError`.
|
|
127
|
+
- **Advisory lock while writing only** - a `--write` call takes a
|
|
128
|
+
cross-process advisory lock (`fcntl`/`msvcrt`) for the duration of the
|
|
129
|
+
write and removes the lock file afterward, best-effort. It only serialises
|
|
130
|
+
cooperating `mquery` processes and is not a correctness guarantee: because
|
|
131
|
+
the lock file is removed after use, a waiting process and a freshly
|
|
132
|
+
started one can end up locking different inodes. Dry-run calls take no
|
|
133
|
+
lock and create no lock file.
|
|
134
|
+
- This is **not mandatory locking** - no OS provides a portable mandatory
|
|
135
|
+
lock, and the advisory lock is not itself the correctness guard. Use
|
|
136
|
+
source control or external exclusive ownership for concurrent editors.
|
|
137
|
+
|
|
138
|
+
## Limits
|
|
139
|
+
|
|
140
|
+
- Input and output are capped at **10 MiB**.
|
|
141
|
+
- The Node subprocess is bounded to a **30 second** timeout.
|
|
142
|
+
- Supported extensions: `.pq`, `.m`, `.pqm`, and any `*.query.pq` file.
|
|
143
|
+
- `rename` scope: exactly **one unquoted top-level `let` binding**. It refuses
|
|
144
|
+
quoted identifiers (`#"..."`), record literals, lambda expressions, and
|
|
145
|
+
non-ASCII source.
|
|
146
|
+
- `Retry-After` on the Fabric adapter must be whole seconds; HTTP-date values
|
|
147
|
+
are rejected.
|
|
148
|
+
|
|
149
|
+
## Optional adapters
|
|
150
|
+
|
|
151
|
+
- **`fabric` extra** (`pip install "mquery-toolkit[fabric]"`) - a Fabric
|
|
152
|
+
Execute Query client that takes a caller-provided bearer token and an
|
|
153
|
+
injected HTTP transport. It never manages credentials itself and is fully
|
|
154
|
+
mocked in tests (no network access in the test suite).
|
|
155
|
+
- **`pqtest`** - a bounded wrapper around a user-installed Microsoft PQTest
|
|
156
|
+
executable, Windows-only, pinned to version `2.155.2`. It never downloads a
|
|
157
|
+
binary; it only validates and runs one already on disk.
|
|
158
|
+
|
|
159
|
+
## What it is not
|
|
160
|
+
|
|
161
|
+
- Not an M language runtime or evaluator.
|
|
162
|
+
- Not a Power BI or Fabric client, and it does not manage credentials.
|
|
163
|
+
- Not a general-purpose file editor - it only touches files with a supported
|
|
164
|
+
extension and only through the safety model above.
|
|
165
|
+
- Not a replacement for Microsoft's own parser/formatter - it vendors and
|
|
166
|
+
calls them directly rather than reimplementing M syntax.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
git clone https://github.com/GopalGB/mquery-toolkit
|
|
172
|
+
cd mquery-toolkit
|
|
173
|
+
python -m venv .venv && source .venv/bin/activate
|
|
174
|
+
pip install -e ".[dev,fabric]"
|
|
175
|
+
npm ci --ignore-scripts
|
|
176
|
+
|
|
177
|
+
pytest -q --cov=mquery_toolkit --cov-fail-under=80
|
|
178
|
+
mypy src
|
|
179
|
+
ruff check .
|
|
180
|
+
ruff format --check .
|
|
181
|
+
npm test
|
|
182
|
+
python -m build
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT - see `LICENSE`. Bundled Microsoft packages
|
|
188
|
+
(`@microsoft/powerquery-parser`, `@microsoft/powerquery-formatter`) and their
|
|
189
|
+
dependencies are also MIT; see `THIRD_PARTY_NOTICES.txt` and `NOTICE`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
mquery_toolkit/THIRD_PARTY_NOTICES.txt,sha256=D77F5aSJndizys2F9xVBof9KLyJlXZ-dbJmUC7XKvSU,1438
|
|
2
|
+
mquery_toolkit/__init__.py,sha256=kqj3em44u-DjdVMhTo0kszbKeeU6SBnUbcY7aIeqzrw,578
|
|
3
|
+
mquery_toolkit/_bridge.cjs,sha256=obBJjhiN2LdYF_cTgyaV_O0gy9UcPiXzbZi0YkLXMXM,2601383
|
|
4
|
+
mquery_toolkit/cli.py,sha256=tnK8-UMsmAlQvW46f97NKCRK1rUaD8hsgKsU-iqnVm4,3627
|
|
5
|
+
mquery_toolkit/core.py,sha256=1w1ocGduLXjLwgRsMRs-wg5sRYc8a7OvdHQ5sdJhCPU,20363
|
|
6
|
+
mquery_toolkit/fabric.py,sha256=-8HfRfVxEhrw-xuYzo0l-zXuftROkGkMQCfSqvv-v7c,8703
|
|
7
|
+
mquery_toolkit/pqtest.py,sha256=5Fh5l6X8updrezAgkm-5pB4uBuzBPw1xK9mrfjVenIw,3152
|
|
8
|
+
mquery_toolkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
mquery_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=gszjyPUNaVUz78eaIo48Yi0qo0GvBv9lLMerAT-C5lk,1071
|
|
10
|
+
mquery_toolkit-0.1.0.dist-info/licenses/NOTICE,sha256=XchoRITIzvbsT8KGQc3RLpb-Tbuz0a_76Skq3J_ZwCg,509
|
|
11
|
+
mquery_toolkit-0.1.0.dist-info/METADATA,sha256=aehnhePuQYzibBaRzPMJp90lkwB5R8-WUexVxibPMjw,7645
|
|
12
|
+
mquery_toolkit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
mquery_toolkit-0.1.0.dist-info/entry_points.txt,sha256=uT91guSn55M3YHnhCxV6Vw6bo2VuqXcovQwrT9ATl_s,51
|
|
14
|
+
mquery_toolkit-0.1.0.dist-info/top_level.txt,sha256=tlfo50yrjuNCMRVyi2HM_yIEWB24nhp7Px2hyKxJ3Rc,15
|
|
15
|
+
mquery_toolkit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -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
|
+
`mquery_toolkit/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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mquery_toolkit
|