dcc-mcp-cli 0.20.35__py3-none-win_amd64.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.
- dcc_mcp_cli/__init__.py +32 -0
- dcc_mcp_cli/__main__.py +10 -0
- dcc_mcp_cli/_bootstrap.py +461 -0
- dcc_mcp_cli/_payload/dcc-mcp-cli-0.20.35-windows-x86_64.zip +0 -0
- dcc_mcp_cli/_payload/payload.json +10 -0
- dcc_mcp_cli-0.20.35.dist-info/METADATA +156 -0
- dcc_mcp_cli-0.20.35.dist-info/RECORD +9 -0
- dcc_mcp_cli-0.20.35.dist-info/WHEEL +5 -0
- dcc_mcp_cli-0.20.35.dist-info/entry_points.txt +2 -0
dcc_mcp_cli/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""PyPI wrapper that installs and runs the ``dcc-mcp-cli`` binary.
|
|
2
|
+
|
|
3
|
+
``pip install dcc-mcp-cli`` (or ``uvx dcc-mcp-cli``) puts a ``dcc-mcp-cli``
|
|
4
|
+
command on ``PATH``. The wheel carries the compressed release archive of the
|
|
5
|
+
Rust binary for the installing platform and unpacks it into the environment's
|
|
6
|
+
scripts directory on first use, so the executing process is the real binary.
|
|
7
|
+
|
|
8
|
+
Most users never import this package. Programmatic callers can use
|
|
9
|
+
:func:`binary_path` to spawn the binary without searching ``PATH``::
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
from dcc_mcp_cli import binary_path
|
|
13
|
+
|
|
14
|
+
subprocess.run([str(binary_path()), "--version"])
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._bootstrap import PACKAGE_MANAGER_MARKER
|
|
20
|
+
from ._bootstrap import PayloadError
|
|
21
|
+
from ._bootstrap import binary_path
|
|
22
|
+
from ._bootstrap import main
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"PACKAGE_MANAGER_MARKER",
|
|
26
|
+
"PayloadError",
|
|
27
|
+
"__version__",
|
|
28
|
+
"binary_path",
|
|
29
|
+
"main",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.20.35" # x-release-please-version
|
dcc_mcp_cli/__main__.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
r"""Unpack and execute the ``dcc-mcp-cli`` binary shipped inside this wheel.
|
|
2
|
+
|
|
3
|
+
The wheel carries the same compressed release archive the GitHub Release
|
|
4
|
+
publishes (``dcc-mcp-cli-<version>-<platform>.zip``) rather than the raw
|
|
5
|
+
executable: PyPI meters a project's *total* storage (10 GB by default), and the
|
|
6
|
+
deflated archive costs roughly a third of the raw binary per release.
|
|
7
|
+
|
|
8
|
+
The archive is unpacked lazily, on the first invocation, into the environment's
|
|
9
|
+
scripts directory (``<venv>/bin`` or ``<venv>\\Scripts``) and then executed. Two
|
|
10
|
+
properties fall out of that choice:
|
|
11
|
+
|
|
12
|
+
* The running process *is* the real binary, so ``std::env::current_exe()``
|
|
13
|
+
resolves inside the scripts directory. ``dcc-mcp-cli components ensure
|
|
14
|
+
dcc-cua`` installs its companion executable as a sibling of
|
|
15
|
+
``current_exe``, so a single directory keeps the component contract intact.
|
|
16
|
+
* The unpacked binary is package-manager owned. A marker file next to it
|
|
17
|
+
(``dcc-mcp-cli.package-manager.json``) tells the CLI that self-update is
|
|
18
|
+
disabled and that upgrades belong to the package manager.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import sysconfig
|
|
32
|
+
import tempfile
|
|
33
|
+
import zipfile
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"PACKAGE_MANAGER_MARKER",
|
|
37
|
+
"binary_path",
|
|
38
|
+
"main",
|
|
39
|
+
"payload_dir",
|
|
40
|
+
"resolve_binary",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: Marker written next to the unpacked binary. ``crates/dcc-mcp-cli`` reads the
|
|
44
|
+
#: same file name to decide whether a package manager owns the installation.
|
|
45
|
+
PACKAGE_MANAGER_MARKER = "dcc-mcp-cli.package-manager.json"
|
|
46
|
+
|
|
47
|
+
#: Name of the manifest the release script stages next to the archive.
|
|
48
|
+
PAYLOAD_METADATA = "payload.json"
|
|
49
|
+
|
|
50
|
+
#: Package manager recorded in the marker. Read by the CLI's update command.
|
|
51
|
+
PACKAGE_MANAGER_NAME = "pypi"
|
|
52
|
+
|
|
53
|
+
_PAYLOAD_SCHEMA_VERSION = 1
|
|
54
|
+
_COPY_CHUNK_BYTES = 1024 * 1024
|
|
55
|
+
_EXECUTABLE_MODE = 0o755
|
|
56
|
+
#: Python console-script launchers start with a shebang. A native binary
|
|
57
|
+
#: never does, which is how a reinstalled launcher is told apart from the
|
|
58
|
+
#: binary it replaced.
|
|
59
|
+
_LAUNCHER_MAGIC = b"#!"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PayloadError(RuntimeError):
|
|
63
|
+
"""Raised when the wheel does not carry a usable CLI payload."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def payload_dir() -> Path:
|
|
67
|
+
"""Return the directory holding the bundled CLI archive.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Absolute path of ``dcc_mcp_cli/_payload`` inside the installation.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
PayloadError: if the payload directory is missing.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
directory = Path(__file__).resolve().parent / "_payload"
|
|
77
|
+
if not directory.is_dir():
|
|
78
|
+
raise PayloadError(
|
|
79
|
+
f"{directory} is missing; this wheel was built without a dcc-mcp-cli payload. "
|
|
80
|
+
"Build it with scripts/release/build_cli_wrapper_wheel.py."
|
|
81
|
+
)
|
|
82
|
+
return directory
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_payload() -> dict:
|
|
86
|
+
"""Read and validate the bundled payload manifest.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The parsed ``payload.json`` mapping.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
PayloadError: if the manifest or its archive is missing or malformed.
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
directory = payload_dir()
|
|
96
|
+
metadata_path = directory / PAYLOAD_METADATA
|
|
97
|
+
try:
|
|
98
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
raise PayloadError(f"cannot read {metadata_path}: {exc}") from exc
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise PayloadError(f"{metadata_path} is not valid JSON: {exc}") from exc
|
|
103
|
+
|
|
104
|
+
if not isinstance(metadata, dict):
|
|
105
|
+
raise PayloadError(f"{metadata_path} must contain a JSON object")
|
|
106
|
+
if int(metadata.get("schema_version", 0)) != _PAYLOAD_SCHEMA_VERSION:
|
|
107
|
+
raise PayloadError(
|
|
108
|
+
f"{metadata_path} declares schema_version {metadata.get('schema_version')!r}, "
|
|
109
|
+
f"expected {_PAYLOAD_SCHEMA_VERSION}"
|
|
110
|
+
)
|
|
111
|
+
for key in ("version", "platform", "archive", "member"):
|
|
112
|
+
value = metadata.get(key)
|
|
113
|
+
if not isinstance(value, str) or not value:
|
|
114
|
+
raise PayloadError(f"{metadata_path} is missing a non-empty {key!r}")
|
|
115
|
+
if not (directory / metadata["archive"]).is_file():
|
|
116
|
+
raise PayloadError(f"payload archive {metadata['archive']!r} is missing from {directory}")
|
|
117
|
+
return metadata
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _binary_names() -> tuple:
|
|
121
|
+
"""Return candidate file names for the unpacked binary, best first.
|
|
122
|
+
|
|
123
|
+
POSIX may reuse ``dcc-mcp-cli`` itself: replacing the console script with
|
|
124
|
+
the real binary puts the native executable directly on ``PATH``. Windows
|
|
125
|
+
cannot — the pip-generated ``dcc-mcp-cli.exe`` launcher is the running
|
|
126
|
+
process image and cannot be replaced while it is mapped — so the unpacked
|
|
127
|
+
binary always takes a distinct name there.
|
|
128
|
+
"""
|
|
129
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
130
|
+
if os.name == "nt":
|
|
131
|
+
return (f"dcc-mcp-cli-bin{suffix}",)
|
|
132
|
+
return (f"dcc-mcp-cli{suffix}", f"dcc-mcp-cli-bin{suffix}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _user_fallback_dir() -> Path:
|
|
136
|
+
"""Return a per-user directory used when the scripts dir is not writable."""
|
|
137
|
+
if os.name == "nt":
|
|
138
|
+
appdata = os.environ.get("LOCALAPPDATA")
|
|
139
|
+
base = Path(appdata) if appdata else Path.home() / "AppData" / "Local"
|
|
140
|
+
return base / "dcc-mcp-cli" / "bin"
|
|
141
|
+
return Path.home() / ".local" / "share" / "dcc-mcp-cli" / "bin"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def candidate_dirs() -> list:
|
|
145
|
+
"""Return directories the binary may live in, most preferred first.
|
|
146
|
+
|
|
147
|
+
``DCC_MCP_CLI_BIN_DIR`` wins so operators can pin the location (read-only
|
|
148
|
+
environments, container images). Otherwise the interpreter's scripts
|
|
149
|
+
directory comes first, then the user-local fallback for prefixes the
|
|
150
|
+
current user cannot write to.
|
|
151
|
+
"""
|
|
152
|
+
candidates = []
|
|
153
|
+
override = os.environ.get("DCC_MCP_CLI_BIN_DIR")
|
|
154
|
+
if override:
|
|
155
|
+
candidates.append(Path(override).expanduser())
|
|
156
|
+
scripts = sysconfig.get_path("scripts")
|
|
157
|
+
if scripts:
|
|
158
|
+
candidates.append(Path(scripts))
|
|
159
|
+
candidates.append(Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin"))
|
|
160
|
+
candidates.append(_user_fallback_dir())
|
|
161
|
+
|
|
162
|
+
ordered = []
|
|
163
|
+
for candidate in candidates:
|
|
164
|
+
resolved = Path(os.path.normpath(str(candidate)))
|
|
165
|
+
if resolved not in ordered:
|
|
166
|
+
ordered.append(resolved)
|
|
167
|
+
return ordered
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def marker_path(directory: Path) -> Path:
|
|
171
|
+
"""Return the package-manager marker path for one directory.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
directory: Directory holding (or about to hold) the CLI binary.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Path of the marker file inside ``directory``.
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
return directory / PACKAGE_MANAGER_MARKER
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _read_marker(directory: Path) -> dict | None:
|
|
184
|
+
"""Return the package-manager marker in ``directory``, if it parses."""
|
|
185
|
+
path = marker_path(directory)
|
|
186
|
+
try:
|
|
187
|
+
marker = json.loads(path.read_text(encoding="utf-8"))
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
return None
|
|
190
|
+
return marker if isinstance(marker, dict) else None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _marker_matches(marker: dict, payload: dict) -> bool:
|
|
194
|
+
"""Report whether ``marker`` was written for this exact payload version."""
|
|
195
|
+
return marker.get("version") == payload.get("version") and marker.get("platform") == payload.get("platform")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_launcher(path: Path) -> bool:
|
|
199
|
+
"""Report whether ``path`` looks like a Python console-script launcher.
|
|
200
|
+
|
|
201
|
+
Reinstalling the *same* wheel version rewrites the pip-generated console
|
|
202
|
+
script over the unpacked binary. The marker is not part of the
|
|
203
|
+
distribution RECORD, so it survives that reinstall and would otherwise
|
|
204
|
+
vouch for a file that is a launcher again: :func:`_execute` would then
|
|
205
|
+
``execv`` it, re-enter ``main()`` and loop forever without output.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
with path.open("rb") as stream:
|
|
209
|
+
return stream.read(len(_LAUNCHER_MAGIC)) == _LAUNCHER_MAGIC
|
|
210
|
+
except OSError:
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _binary_is_intact(path: Path, marker: dict) -> bool:
|
|
215
|
+
"""Report whether ``path`` still holds the binary ``marker`` describes.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
path: Candidate binary sitting next to the marker.
|
|
219
|
+
marker: Parsed package-manager marker.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
``True`` when the file is neither a console-script launcher nor a
|
|
223
|
+
differently sized file than the one that was unpacked.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
if _is_launcher(path):
|
|
227
|
+
return False
|
|
228
|
+
recorded = marker.get("binary_size")
|
|
229
|
+
if not isinstance(recorded, int) or isinstance(recorded, bool):
|
|
230
|
+
# Marker written before the fingerprint existed: fall back to the
|
|
231
|
+
# launcher check alone so an older installation is not orphaned.
|
|
232
|
+
return True
|
|
233
|
+
try:
|
|
234
|
+
return path.stat().st_size == recorded
|
|
235
|
+
except OSError:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _find_existing(payload: dict) -> Path | None:
|
|
240
|
+
"""Return an already unpacked binary matching ``payload``, if any."""
|
|
241
|
+
for directory in candidate_dirs():
|
|
242
|
+
marker = _read_marker(directory)
|
|
243
|
+
if marker is None or not _marker_matches(marker, payload):
|
|
244
|
+
continue
|
|
245
|
+
for name in _binary_names():
|
|
246
|
+
candidate = directory / name
|
|
247
|
+
if candidate.is_file() and _binary_is_intact(candidate, marker):
|
|
248
|
+
return candidate
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _extract(archive: Path, member: str, destination: Path) -> None:
|
|
253
|
+
"""Unpack one archive member to ``destination`` atomically.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
archive: Release zip holding the CLI binary.
|
|
257
|
+
member: Archive member to unpack.
|
|
258
|
+
destination: Final path of the executable.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
PayloadError: if the archive does not contain ``member``.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
handle, temporary_name = tempfile.mkstemp(
|
|
266
|
+
prefix=destination.name + ".",
|
|
267
|
+
suffix=".tmp",
|
|
268
|
+
dir=str(destination.parent),
|
|
269
|
+
)
|
|
270
|
+
os.close(handle)
|
|
271
|
+
temporary = Path(temporary_name)
|
|
272
|
+
try:
|
|
273
|
+
with zipfile.ZipFile(archive) as archive_file:
|
|
274
|
+
try:
|
|
275
|
+
source = archive_file.open(member)
|
|
276
|
+
except KeyError as exc:
|
|
277
|
+
raise PayloadError(f"{archive.name} does not contain {member!r}") from exc
|
|
278
|
+
with source, temporary.open("wb") as target:
|
|
279
|
+
shutil.copyfileobj(source, target, length=_COPY_CHUNK_BYTES)
|
|
280
|
+
temporary.chmod(_EXECUTABLE_MODE)
|
|
281
|
+
temporary.replace(destination)
|
|
282
|
+
except BaseException:
|
|
283
|
+
if temporary.exists():
|
|
284
|
+
with contextlib.suppress(OSError): # best effort cleanup
|
|
285
|
+
temporary.unlink()
|
|
286
|
+
raise
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _write_marker(directory: Path, payload: dict, binary_name: str) -> None:
|
|
290
|
+
"""Record that a package manager owns the binary in ``directory``.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
directory: Directory holding the unpacked binary.
|
|
294
|
+
payload: Payload manifest the binary was unpacked from.
|
|
295
|
+
binary_name: File name of the unpacked binary.
|
|
296
|
+
|
|
297
|
+
"""
|
|
298
|
+
marker = {
|
|
299
|
+
"schema_version": _PAYLOAD_SCHEMA_VERSION,
|
|
300
|
+
"distribution": payload.get("distribution", "dcc-mcp-cli"),
|
|
301
|
+
"manager": PACKAGE_MANAGER_NAME,
|
|
302
|
+
"version": payload["version"],
|
|
303
|
+
"platform": payload["platform"],
|
|
304
|
+
"binary": binary_name,
|
|
305
|
+
}
|
|
306
|
+
# Fingerprint the unpacked binary so a reinstall that restores the pip
|
|
307
|
+
# console script cannot be mistaken for a healthy installation.
|
|
308
|
+
with contextlib.suppress(OSError):
|
|
309
|
+
marker["binary_size"] = (directory / binary_name).stat().st_size
|
|
310
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
temporary = directory / (PACKAGE_MANAGER_MARKER + ".tmp")
|
|
312
|
+
temporary.write_text(json.dumps(marker, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
313
|
+
temporary.replace(marker_path(directory))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _archive_digest(archive: Path, expected: str | None) -> None:
|
|
317
|
+
"""Verify the staged archive against the digest recorded at build time.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
archive: Release zip to verify.
|
|
321
|
+
expected: Lowercase hex SHA-256 recorded in ``payload.json``.
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
PayloadError: if the digest is missing or does not match.
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
if not expected:
|
|
328
|
+
raise PayloadError(f"{archive.name} has no archive_sha256 in {PAYLOAD_METADATA}; rebuild the wrapper wheel")
|
|
329
|
+
digest = hashlib.sha256()
|
|
330
|
+
with archive.open("rb") as stream:
|
|
331
|
+
for chunk in iter(lambda: stream.read(_COPY_CHUNK_BYTES), b""):
|
|
332
|
+
digest.update(chunk)
|
|
333
|
+
if digest.hexdigest() != expected:
|
|
334
|
+
raise PayloadError(f"{archive.name} sha256 mismatch: expected {expected}, got {digest.hexdigest()}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def install_binary(payload: dict) -> Path:
|
|
338
|
+
"""Unpack the bundled archive into the first writable candidate directory.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
payload: Parsed payload manifest.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Path of the unpacked executable.
|
|
345
|
+
|
|
346
|
+
Raises:
|
|
347
|
+
PayloadError: if no candidate directory accepts the binary.
|
|
348
|
+
|
|
349
|
+
"""
|
|
350
|
+
archive = payload_dir() / payload["archive"]
|
|
351
|
+
_archive_digest(archive, payload.get("archive_sha256"))
|
|
352
|
+
|
|
353
|
+
failures = []
|
|
354
|
+
for directory in candidate_dirs():
|
|
355
|
+
for name in _binary_names():
|
|
356
|
+
destination = directory / name
|
|
357
|
+
try:
|
|
358
|
+
_extract(archive, payload["member"], destination)
|
|
359
|
+
except (OSError, PayloadError) as exc:
|
|
360
|
+
failures.append(f"{destination}: {exc}")
|
|
361
|
+
continue
|
|
362
|
+
try:
|
|
363
|
+
_write_marker(directory, payload, name)
|
|
364
|
+
except OSError as exc: # pragma: no cover - rare, directory just worked
|
|
365
|
+
failures.append(f"{marker_path(directory)}: {exc}")
|
|
366
|
+
continue
|
|
367
|
+
print(
|
|
368
|
+
f"info: unpacked dcc-mcp-cli {payload['version']} ({payload['platform']}) to {destination}",
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
return destination
|
|
372
|
+
|
|
373
|
+
raise PayloadError(
|
|
374
|
+
"could not unpack the dcc-mcp-cli binary into any candidate directory:\n " + "\n ".join(failures)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def resolve_binary() -> Path:
|
|
379
|
+
"""Return the path of the CLI binary, unpacking it on first use.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Path of the executable to run.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
PayloadError: if the payload is unusable or cannot be unpacked.
|
|
386
|
+
|
|
387
|
+
"""
|
|
388
|
+
payload = load_payload()
|
|
389
|
+
existing = _find_existing(payload)
|
|
390
|
+
return existing if existing is not None else install_binary(payload)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def binary_path() -> Path:
|
|
394
|
+
"""Return the filesystem path of the bundled ``dcc-mcp-cli`` binary.
|
|
395
|
+
|
|
396
|
+
Resolution order:
|
|
397
|
+
|
|
398
|
+
1. ``DCC_MCP_CLI_BIN`` env var — operator override to an existing binary.
|
|
399
|
+
2. An already unpacked binary whose marker matches this wheel's payload.
|
|
400
|
+
3. The bundled archive unpacked into the environment's scripts directory.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Path of the executable.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
PayloadError: if no binary can be resolved.
|
|
407
|
+
|
|
408
|
+
"""
|
|
409
|
+
override = os.environ.get("DCC_MCP_CLI_BIN")
|
|
410
|
+
if override:
|
|
411
|
+
candidate = Path(override).expanduser()
|
|
412
|
+
if candidate.is_file():
|
|
413
|
+
return candidate
|
|
414
|
+
return resolve_binary()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _execute(binary: Path, argv: list) -> int:
|
|
418
|
+
"""Run the binary and return its exit code.
|
|
419
|
+
|
|
420
|
+
POSIX replaces the current process so signals, exit codes and TTY state
|
|
421
|
+
behave exactly like a native invocation. Windows cannot replace a running
|
|
422
|
+
image, so it runs the binary as a child process instead.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
binary: Executable to run.
|
|
426
|
+
argv: Arguments to pass through.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
The process exit code (Windows only; POSIX never returns).
|
|
430
|
+
|
|
431
|
+
"""
|
|
432
|
+
sys.stdout.flush()
|
|
433
|
+
sys.stderr.flush()
|
|
434
|
+
command = [str(binary)]
|
|
435
|
+
command.extend(argv)
|
|
436
|
+
if os.name == "nt":
|
|
437
|
+
try:
|
|
438
|
+
return int(subprocess.run(command).returncode)
|
|
439
|
+
except KeyboardInterrupt:
|
|
440
|
+
return 130
|
|
441
|
+
os.execv(str(binary), command)
|
|
442
|
+
return 1 # pragma: no cover - os.execv does not return
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def main(argv: list | None = None) -> int:
|
|
446
|
+
"""Unpack (if needed) and run the bundled ``dcc-mcp-cli`` binary.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
argv: Arguments forwarded to the CLI. Defaults to ``sys.argv[1:]``.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
The CLI's exit code.
|
|
453
|
+
|
|
454
|
+
"""
|
|
455
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
456
|
+
try:
|
|
457
|
+
binary = binary_path()
|
|
458
|
+
except PayloadError as exc:
|
|
459
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
460
|
+
return 1
|
|
461
|
+
return _execute(binary, arguments)
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"archive": "dcc-mcp-cli-0.20.35-windows-x86_64.zip",
|
|
3
|
+
"archive_sha256": "65947d1a13f9a46fb810d50583a3834e920b3b11b6976083dec64f9618a25f71",
|
|
4
|
+
"distribution": "dcc-mcp-cli",
|
|
5
|
+
"member": "dcc-mcp-cli.exe",
|
|
6
|
+
"platform": "windows-x86_64",
|
|
7
|
+
"schema_version": 1,
|
|
8
|
+
"version": "0.20.35",
|
|
9
|
+
"wheel_platform_tag": "win_amd64"
|
|
10
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dcc-mcp-cli
|
|
3
|
+
Version: 0.20.35
|
|
4
|
+
Summary: Standalone dcc-mcp-cli binary — DCC-MCP install, gateway and component control (Rust)
|
|
5
|
+
Project-URL: Homepage, https://github.com/dcc-mcp/dcc-mcp-core
|
|
6
|
+
Project-URL: Repository, https://github.com/dcc-mcp/dcc-mcp-core
|
|
7
|
+
Project-URL: Issues, https://github.com/dcc-mcp/dcc-mcp-core/issues
|
|
8
|
+
Author-email: Hal Long <hal.long@outlook.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: blender,cli,dcc,gateway,houdini,installer,maya,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Programming Language :: Rust
|
|
26
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
27
|
+
Classifier: Topic :: Software Development
|
|
28
|
+
Classifier: Topic :: System :: Systems Administration
|
|
29
|
+
Requires-Python: >=3.7
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
32
|
+
Requires-Dist: hatchling>=1.25; extra == 'dev'
|
|
33
|
+
Requires-Dist: wheel>=0.46; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# dcc-mcp-cli-bin — PyPI wrapper for the `dcc-mcp-cli` binary
|
|
37
|
+
|
|
38
|
+
This directory builds the `dcc-mcp-cli` PyPI project: one platform-specific
|
|
39
|
+
wheel per release that puts the Rust CLI on `PATH`.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Run it without installing anything
|
|
43
|
+
uvx dcc-mcp-cli --version
|
|
44
|
+
|
|
45
|
+
# Or install it into a tool environment / venv
|
|
46
|
+
uv tool install dcc-mcp-cli
|
|
47
|
+
pip install dcc-mcp-cli
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Why a wrapper instead of a raw binary wheel?
|
|
51
|
+
|
|
52
|
+
`pkg/dcc-mcp-server-bin` ships its raw executable through maturin's
|
|
53
|
+
`bindings = "bin"`. `dcc-mcp-cli` deliberately does not:
|
|
54
|
+
|
|
55
|
+
| | Raw binary wheel | This wrapper |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| Per-release PyPI storage | ~165 MB (three platforms) | ~66 MB (three platforms) |
|
|
58
|
+
| PyPI 10 GB budget | ~60 releases | ~152 releases |
|
|
59
|
+
| Payload | the executable | the release zip, unpacked on first use |
|
|
60
|
+
|
|
61
|
+
The wheel embeds the same `dcc-mcp-cli-<version>-<platform>.zip` the GitHub
|
|
62
|
+
Release publishes, verifies its SHA-256 against `payload.json`, and unpacks it
|
|
63
|
+
into the environment's scripts directory on the first invocation.
|
|
64
|
+
|
|
65
|
+
## Layout
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
pkg/dcc-mcp-cli-bin/
|
|
69
|
+
├── pyproject.toml # hatchling; console script dcc-mcp-cli
|
|
70
|
+
├── python/dcc_mcp_cli/
|
|
71
|
+
│ ├── __init__.py # public API: binary_path(), main()
|
|
72
|
+
│ ├── __main__.py # python -m dcc_mcp_cli
|
|
73
|
+
│ ├── _bootstrap.py # unpack + exec
|
|
74
|
+
│ └── _payload/ # staged at build time, git-ignored
|
|
75
|
+
│ ├── payload.json
|
|
76
|
+
│ └── dcc-mcp-cli-<version>-<platform>.zip
|
|
77
|
+
└── tests/test_cli_bootstrap.py
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Where the binary lands, and why it matters
|
|
81
|
+
|
|
82
|
+
The bootstrap unpacks the executable into the scripts directory of the
|
|
83
|
+
interpreter that installed the wheel — `<venv>/bin` on POSIX,
|
|
84
|
+
`<venv>\Scripts` on Windows — and then executes it. Consequences:
|
|
85
|
+
|
|
86
|
+
- **Component contract.** `current_exe.parent()` resolves to that directory, so
|
|
87
|
+
`dcc-mcp-cli components ensure dcc-cua` installs `dcc-cua` as a sibling and
|
|
88
|
+
detects it on the next run. A package manager must therefore keep the CLI in
|
|
89
|
+
a *single* directory; unpacking into `site-packages` would break it.
|
|
90
|
+
- **POSIX vs Windows naming.** On POSIX the unpacked binary replaces the
|
|
91
|
+
console script, so `dcc-mcp-cli` becomes the native executable directly.
|
|
92
|
+
On Windows the pip-generated `dcc-mcp-cli.exe` launcher is the running
|
|
93
|
+
process image and cannot be replaced while mapped, so the binary is unpacked
|
|
94
|
+
as `dcc-mcp-cli-bin.exe` and the launcher execs it.
|
|
95
|
+
- **Read-only prefixes.** If the scripts directory is not writable, the
|
|
96
|
+
bootstrap falls back to a per-user directory
|
|
97
|
+
(`~/.local/share/dcc-mcp-cli/bin`, or `%LOCALAPPDATA%\dcc-mcp-cli\bin`).
|
|
98
|
+
Set `DCC_MCP_CLI_BIN_DIR` to pin the location explicitly.
|
|
99
|
+
|
|
100
|
+
`dcc-cua` is intentionally **not** distributed here. It is an independently
|
|
101
|
+
released companion executable that `dcc-mcp-cli components ensure` reconciles
|
|
102
|
+
by version next to the CLI; shipping it as a second distribution would create
|
|
103
|
+
two copies and version drift.
|
|
104
|
+
|
|
105
|
+
## Self-update is disabled
|
|
106
|
+
|
|
107
|
+
The bootstrap writes `dcc-mcp-cli.package-manager.json` next to the unpacked
|
|
108
|
+
binary. `crates/dcc-mcp-cli` reads that marker and refuses `update apply`:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
error: this dcc-mcp-cli was installed by a package manager (pypi)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The GitHub Release build keeps self-update; the package-manager build does
|
|
115
|
+
not, because replacing `current_exe` would fight the package manager's version
|
|
116
|
+
authority. Upgrade through the package manager instead:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv tool upgrade dcc-mcp-cli
|
|
120
|
+
pipx upgrade dcc-mcp-cli
|
|
121
|
+
pip install --upgrade dcc-mcp-cli
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Building locally
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# 1. Build the release archive (example: current platform)
|
|
128
|
+
cargo build --release -p dcc-mcp-cli
|
|
129
|
+
python scripts/release/build_standalone_bundle.py \
|
|
130
|
+
--version 0.20.34 \
|
|
131
|
+
--platform windows-x86_64 \
|
|
132
|
+
--binary-name dcc-mcp-cli \
|
|
133
|
+
--binary-path target/release/dcc-mcp-cli.exe \
|
|
134
|
+
--out-dir dist
|
|
135
|
+
|
|
136
|
+
# 2. Stage the archive and build the retagged wheel
|
|
137
|
+
python -m pip install "build>=1.2" "hatchling>=1.25" "wheel>=0.46"
|
|
138
|
+
python scripts/release/build_cli_wrapper_wheel.py \
|
|
139
|
+
--version 0.20.34 \
|
|
140
|
+
--platform windows-x86_64 \
|
|
141
|
+
--zip dist/dcc-mcp-cli-0.20.34-windows-x86_64.zip \
|
|
142
|
+
--out-dir wheels
|
|
143
|
+
python scripts/release/cli_wheel_tags.py validate --wheel-dir wheels --version 0.20.34
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
CI runs the same three steps in the `build-cli-wheels` job of
|
|
147
|
+
`.github/workflows/release.yml`, once per platform, from the archives the
|
|
148
|
+
`build-binaries` job already produced.
|
|
149
|
+
|
|
150
|
+
## Python compatibility
|
|
151
|
+
|
|
152
|
+
The bootstrap is pure Python with no third-party imports and runs unchanged on
|
|
153
|
+
Python 3.7 (Maya 2022), so the project's LTS floor applies: the wheel declares
|
|
154
|
+
`Requires-Python: >=3.7` and carries the full 3.7–3.14 classifier set. The
|
|
155
|
+
binary it unpacks has no CPython ABI dependency — the interpreter only ever
|
|
156
|
+
starts it as a child process.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dcc_mcp_cli/__init__.py,sha256=mFJ7psGJ8zHlPmjqeu5DAbZgqwVqblHKStYuGQ48x98,989
|
|
2
|
+
dcc_mcp_cli/__main__.py,sha256=A9lAA87yd2cwQyN8CIproOD9MO55PdCDBT49VyZh_Ps,209
|
|
3
|
+
dcc_mcp_cli/_bootstrap.py,sha256=g7g4bz0UqwdT8uTVZm-oqV3mWQ76qjE-QUo_ATNEYSo,16092
|
|
4
|
+
dcc_mcp_cli/_payload/dcc-mcp-cli-0.20.35-windows-x86_64.zip,sha256=ZZR9GhP5pG-4ENUFg6ODTpILOxG2l2CD3sZPlhiiX3E,16016560
|
|
5
|
+
dcc_mcp_cli/_payload/payload.json,sha256=GwcaiG8z7ZIScaiIAdD_AikOsuDs1jxRa4Du7Mpwto8,326
|
|
6
|
+
dcc_mcp_cli-0.20.35.dist-info/METADATA,sha256=udBbn1txRZYbE7orUd9j3WmMN-dpWX2flgztr1iIFnM,6398
|
|
7
|
+
dcc_mcp_cli-0.20.35.dist-info/WHEEL,sha256=n-eighM0qgE1od73sDmNJWeHnMjpj01ZqRpq9FHlr0c,94
|
|
8
|
+
dcc_mcp_cli-0.20.35.dist-info/entry_points.txt,sha256=OZDQEDvH1UCwC1ihNlmLAPwogTH1ish-C41eXpDuelM,49
|
|
9
|
+
dcc_mcp_cli-0.20.35.dist-info/RECORD,,
|