xberg-cli 1.0.0rc14__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.
- xberg_cli-1.0.0rc14/.gitignore +323 -0
- xberg_cli-1.0.0rc14/PKG-INFO +29 -0
- xberg_cli-1.0.0rc14/README.md +7 -0
- xberg_cli-1.0.0rc14/__init__.py +1 -0
- xberg_cli-1.0.0rc14/hatch_build.py +151 -0
- xberg_cli-1.0.0rc14/pyproject.toml +32 -0
- xberg_cli-1.0.0rc14/xberg_cli/__init__.py +3 -0
- xberg_cli-1.0.0rc14/xberg_cli/cli.py +46 -0
- xberg_cli-1.0.0rc14/xberg_cli/downloader.py +264 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
target/
|
|
3
|
+
build/
|
|
4
|
+
dist/
|
|
5
|
+
.polylint/
|
|
6
|
+
# Ad-hoc PDF/image debug binaries
|
|
7
|
+
/check_pdf_oxide
|
|
8
|
+
/check_*
|
|
9
|
+
# Hand-authored WASM JS distribution files (intentionally committed)
|
|
10
|
+
!crates/xberg-wasm/dist/index.js
|
|
11
|
+
!crates/xberg-wasm/dist/extraction/files.js
|
|
12
|
+
!crates/xberg-wasm/dist/ocr/enabler.js
|
|
13
|
+
.lycheecache
|
|
14
|
+
dist-musl/
|
|
15
|
+
*.so
|
|
16
|
+
*.dylib
|
|
17
|
+
*.dll
|
|
18
|
+
*.pdb
|
|
19
|
+
*.node
|
|
20
|
+
*.whl
|
|
21
|
+
*.tar.gz
|
|
22
|
+
*.gem
|
|
23
|
+
*.jar
|
|
24
|
+
wheels/
|
|
25
|
+
**/bin/
|
|
26
|
+
!**/src/bin/
|
|
27
|
+
**/obj/
|
|
28
|
+
|
|
29
|
+
# Rust
|
|
30
|
+
*.rs.bk
|
|
31
|
+
*.profraw
|
|
32
|
+
rust-coverage.lcov
|
|
33
|
+
rust_out
|
|
34
|
+
|
|
35
|
+
# Python
|
|
36
|
+
*.py[cod]
|
|
37
|
+
*$py.class
|
|
38
|
+
*.egg-info/
|
|
39
|
+
__pycache__/
|
|
40
|
+
.venv/
|
|
41
|
+
.mypy_cache/
|
|
42
|
+
.ruff_cache/
|
|
43
|
+
.pytest_cache/
|
|
44
|
+
.hypothesis/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage*
|
|
47
|
+
.e2e-logs/
|
|
48
|
+
htmlcov/
|
|
49
|
+
share/python-wheels/
|
|
50
|
+
prompt_template.egg-info/
|
|
51
|
+
packages/python/xberg/xberg-cli
|
|
52
|
+
|
|
53
|
+
# Node.js / WASM
|
|
54
|
+
node_modules/
|
|
55
|
+
*.tsbuildinfo
|
|
56
|
+
npm-debug.log*
|
|
57
|
+
pnpm-debug.log*
|
|
58
|
+
yarn-debug.log*
|
|
59
|
+
yarn-error.log*
|
|
60
|
+
# NAPI-RS generated files (index.js and index.d.ts are committed for TypeScript type resolution)
|
|
61
|
+
crates/xberg-node/*.tgz
|
|
62
|
+
# ORT dylibs are copied to crates/xberg-node/ at build time and included in npm publish
|
|
63
|
+
# despite the global *.dylib/*.so/*.dll rules below
|
|
64
|
+
!crates/xberg-node/*.dylib
|
|
65
|
+
!crates/xberg-node/*.so
|
|
66
|
+
!crates/xberg-node/*.dll
|
|
67
|
+
.wrangler/
|
|
68
|
+
.pnpm-store/
|
|
69
|
+
|
|
70
|
+
# Ruby
|
|
71
|
+
packages/ruby/.bundle/
|
|
72
|
+
packages/ruby/vendor/
|
|
73
|
+
!packages/ruby/vendor/rb-sys/
|
|
74
|
+
!vendor/rb-sys/build/
|
|
75
|
+
!vendor/rb-sys/build/**
|
|
76
|
+
*.Cache
|
|
77
|
+
.yardoc/
|
|
78
|
+
|
|
79
|
+
# PHP
|
|
80
|
+
vendor/
|
|
81
|
+
!crates/xberg-candle-ocr/src/vendor/
|
|
82
|
+
php-xberg.ini
|
|
83
|
+
packages/php/php-xberg.ini
|
|
84
|
+
packages/php/tests.xml
|
|
85
|
+
packages/php/var/
|
|
86
|
+
.php-cs-fixer.cache
|
|
87
|
+
|
|
88
|
+
# Swift / SwiftPM / Xcode
|
|
89
|
+
.build/
|
|
90
|
+
.swiftpm/
|
|
91
|
+
# Legacy SwiftPM resolved-packages dir — anchored to packages/swift/ so it
|
|
92
|
+
# does NOT match our top-level packages/ directory on macOS case-insensitive
|
|
93
|
+
# filesystems (where the bare `Packages/` pattern previously matched and
|
|
94
|
+
# silently dropped packages/dart/pubspec.yaml + 9 other scaffold files from
|
|
95
|
+
# the index during regen).
|
|
96
|
+
packages/swift/**/Packages/
|
|
97
|
+
Package.resolved
|
|
98
|
+
*.xcodeproj/
|
|
99
|
+
*.xcworkspace/
|
|
100
|
+
DerivedData/
|
|
101
|
+
|
|
102
|
+
# Java / C# / .NET
|
|
103
|
+
*.suo
|
|
104
|
+
*.user
|
|
105
|
+
*.cscfg
|
|
106
|
+
*.cobertura.xml
|
|
107
|
+
TestResults/
|
|
108
|
+
|
|
109
|
+
# Generated test apps (e2e-generator --mode published)
|
|
110
|
+
test_apps/
|
|
111
|
+
|
|
112
|
+
# Elixir
|
|
113
|
+
.nox/
|
|
114
|
+
|
|
115
|
+
# Go
|
|
116
|
+
packages/go/v*/install
|
|
117
|
+
|
|
118
|
+
# C FFI
|
|
119
|
+
crates/xberg-ffi/tests/c/test_*
|
|
120
|
+
!crates/xberg-ffi/tests/c/test_*.c
|
|
121
|
+
crates/xberg-ffi/tests/c/*.o
|
|
122
|
+
crates/xberg-ffi/tests/c/*.dSYM/
|
|
123
|
+
e2e/c/test_*
|
|
124
|
+
!e2e/c/test_*.c
|
|
125
|
+
e2e/c/*.o
|
|
126
|
+
e2e/c/*.dSYM/
|
|
127
|
+
|
|
128
|
+
# Generated pkg-config
|
|
129
|
+
*.pc
|
|
130
|
+
|
|
131
|
+
# Coverage
|
|
132
|
+
coverage/
|
|
133
|
+
coverage.info
|
|
134
|
+
coverage.json
|
|
135
|
+
coverage.lcov
|
|
136
|
+
coverage.out
|
|
137
|
+
coverage.xml
|
|
138
|
+
packages/*/.coverage
|
|
139
|
+
packages/*/coverage.json
|
|
140
|
+
packages/*/coverage.out
|
|
141
|
+
packages/*/htmlcov/
|
|
142
|
+
packages/*/test-results/
|
|
143
|
+
packages/*/TestResults/
|
|
144
|
+
|
|
145
|
+
# IDE / Editor
|
|
146
|
+
.idea/
|
|
147
|
+
*.iml
|
|
148
|
+
.vscode/
|
|
149
|
+
.run/
|
|
150
|
+
.DS_store
|
|
151
|
+
|
|
152
|
+
# CI / Tools
|
|
153
|
+
.cache/
|
|
154
|
+
.tmp/
|
|
155
|
+
*temp/
|
|
156
|
+
*.temp
|
|
157
|
+
*.tmp
|
|
158
|
+
*.log
|
|
159
|
+
**/logs/
|
|
160
|
+
**/test_output.log
|
|
161
|
+
.mvn/
|
|
162
|
+
.tox/
|
|
163
|
+
.artifacts/
|
|
164
|
+
.remote-cache/
|
|
165
|
+
.rumdl_cache/
|
|
166
|
+
docker-logs/
|
|
167
|
+
|
|
168
|
+
# Benchmarks
|
|
169
|
+
benchmark-results/
|
|
170
|
+
benchmark_results.json
|
|
171
|
+
benchmarks/results/
|
|
172
|
+
!benchmarks/baselines/
|
|
173
|
+
aggregated-results/
|
|
174
|
+
results/
|
|
175
|
+
reports/
|
|
176
|
+
tests/benchmarks/results/
|
|
177
|
+
profiling-results/
|
|
178
|
+
profiling-results-*/
|
|
179
|
+
|
|
180
|
+
# Local perf-iteration artifacts (flamegraphs/ is committed; intermediate outputs are not)
|
|
181
|
+
profile.json.gz
|
|
182
|
+
profile.pb.gz
|
|
183
|
+
*.heap
|
|
184
|
+
bench/
|
|
185
|
+
|
|
186
|
+
# Docs
|
|
187
|
+
docs/_build/
|
|
188
|
+
docs/build/
|
|
189
|
+
docs/benchmarks/charts/
|
|
190
|
+
site/
|
|
191
|
+
|
|
192
|
+
# Elixir build artifacts (lock files MUST stay tracked — see CLAUDE.md
|
|
193
|
+
# task-runner / dependency-awareness rules and the brotli/alloc-no-stdlib
|
|
194
|
+
# regression that bit CI run 27492590406 because the NIF lock wasn't
|
|
195
|
+
# committed and the resolver picked a fresh broken combo).
|
|
196
|
+
packages/elixir/_build/
|
|
197
|
+
packages/elixir/deps/
|
|
198
|
+
e2e/elixir/_build/
|
|
199
|
+
e2e/elixir/deps/
|
|
200
|
+
e2e/elixir/config/
|
|
201
|
+
test_documents/_build/
|
|
202
|
+
test_documents/deps/
|
|
203
|
+
|
|
204
|
+
# Maven plugin artifacts
|
|
205
|
+
*.versionsBackup
|
|
206
|
+
|
|
207
|
+
# Gradle (Kotlin e2e)
|
|
208
|
+
e2e/kotlin/.gradle/
|
|
209
|
+
e2e/kotlin/build/
|
|
210
|
+
e2e/kotlin_android/.gradle/
|
|
211
|
+
e2e/kotlin_android/build/
|
|
212
|
+
|
|
213
|
+
# Ruby native build artifacts
|
|
214
|
+
packages/ruby/lib/*.bundle
|
|
215
|
+
packages/ruby/tmp/
|
|
216
|
+
|
|
217
|
+
# Tests
|
|
218
|
+
test_report.json
|
|
219
|
+
tests/e2e/logs/
|
|
220
|
+
tests/e2e/test_report.json
|
|
221
|
+
temp-test-*
|
|
222
|
+
packages/csharp/test_html_debug.cs
|
|
223
|
+
|
|
224
|
+
# AI / MCP / Agent config
|
|
225
|
+
.claude/
|
|
226
|
+
.codex/
|
|
227
|
+
.gemini/
|
|
228
|
+
.xberg/
|
|
229
|
+
.mcp.json
|
|
230
|
+
.playwright-mcp/
|
|
231
|
+
.fastembed_cache/
|
|
232
|
+
*/.fastembed_cache/
|
|
233
|
+
AGENTS.md
|
|
234
|
+
CLAUDE.md
|
|
235
|
+
GEMINI.md
|
|
236
|
+
.cursorrules
|
|
237
|
+
.windsurfrules
|
|
238
|
+
[Tt][Oo][Dd][Oo]*
|
|
239
|
+
|
|
240
|
+
# Git worktrees
|
|
241
|
+
.worktrees/
|
|
242
|
+
|
|
243
|
+
# Misc
|
|
244
|
+
.env
|
|
245
|
+
output.txt
|
|
246
|
+
examples.txt
|
|
247
|
+
requirements.txt
|
|
248
|
+
docker-compose.yaml
|
|
249
|
+
/tmp/xberg-docker-test-results.json
|
|
250
|
+
tools/benchmark-harness/datasets/
|
|
251
|
+
tools/benchmark-harness/.fixtures-pdf-subset/
|
|
252
|
+
tools/benchmark-harness/vendored/docling/md/iso_21111_10.md
|
|
253
|
+
visualizations/
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Additional generated artifacts
|
|
257
|
+
.alef/
|
|
258
|
+
*.pyd
|
|
259
|
+
.gems/
|
|
260
|
+
vendor/bundle/
|
|
261
|
+
*.h.bak
|
|
262
|
+
*.test
|
|
263
|
+
*.class
|
|
264
|
+
bin/
|
|
265
|
+
obj/
|
|
266
|
+
*.nupkg
|
|
267
|
+
pkg/
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# Local dev artifacts
|
|
271
|
+
docs-site/public/demo-dev.html
|
|
272
|
+
docs-site/dist/
|
|
273
|
+
docs-site/.astro/
|
|
274
|
+
demo-loaded.png
|
|
275
|
+
scratch/
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
.php-cs-fixer.*
|
|
279
|
+
|
|
280
|
+
e2e/zig/.zig-cache/
|
|
281
|
+
|
|
282
|
+
# Swift e2e build artifacts (Swift Package Manager)
|
|
283
|
+
e2e/swift/.build/
|
|
284
|
+
e2e/swift/.swiftpm/
|
|
285
|
+
e2e/swift/Package.resolved
|
|
286
|
+
e2e/swift_e2e/.build/
|
|
287
|
+
e2e/swift_e2e/.swiftpm/
|
|
288
|
+
e2e/swift_e2e/Package.resolved
|
|
289
|
+
|
|
290
|
+
# Dart e2e build artifacts (lock file tracked for reproducibility)
|
|
291
|
+
e2e/dart/.dart_tool/
|
|
292
|
+
e2e/dart/build/
|
|
293
|
+
|
|
294
|
+
# Dart FRB codegen cache (incremental build optimization)
|
|
295
|
+
packages/dart/rust/.frb_codegen_hash
|
|
296
|
+
|
|
297
|
+
# Go e2e build artifacts
|
|
298
|
+
e2e/go/vendor/
|
|
299
|
+
|
|
300
|
+
erl_crash.dump
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
.gradle/
|
|
304
|
+
|
|
305
|
+
packages/go/.lib/
|
|
306
|
+
|
|
307
|
+
.build/
|
|
308
|
+
|
|
309
|
+
# basemind code-map index (local tooling)
|
|
310
|
+
.basemind/
|
|
311
|
+
|
|
312
|
+
.bench-rc32/
|
|
313
|
+
build-output-musl-node/
|
|
314
|
+
|
|
315
|
+
# BEGIN ai-rulez (DO NOT EDIT - managed by ai-rulez)
|
|
316
|
+
.agents/
|
|
317
|
+
.ai-rulez/.generated-manifest.json
|
|
318
|
+
.cursor/
|
|
319
|
+
.github/agents/
|
|
320
|
+
.github/commands/
|
|
321
|
+
.github/copilot-instructions.md
|
|
322
|
+
.github/skills/
|
|
323
|
+
# END ai-rulez
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xberg-cli
|
|
3
|
+
Version: 1.0.0rc14
|
|
4
|
+
Summary: CLI proxy for xberg — downloads and runs the native xberg binary from GitHub releases.
|
|
5
|
+
Project-URL: Homepage, https://github.com/xberg-io/xberg
|
|
6
|
+
Project-URL: Issues, https://github.com/xberg-io/xberg/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/xberg-io/xberg.git
|
|
8
|
+
Author-email: Na'aman Hirschfeld <naaman@xberg.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# xberg-cli
|
|
24
|
+
|
|
25
|
+
CLI proxy for [`xberg`](https://github.com/xberg-io/xberg). Installing
|
|
26
|
+
this package provides a `xberg` command that downloads the matching native
|
|
27
|
+
binary from GitHub releases and runs it.
|
|
28
|
+
|
|
29
|
+
Pin a specific release tag with the `XBERG_CLI_VERSION` environment variable.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# xberg-cli
|
|
2
|
+
|
|
3
|
+
CLI proxy for [`xberg`](https://github.com/xberg-io/xberg). Installing
|
|
4
|
+
this package provides a `xberg` command that downloads the matching native
|
|
5
|
+
binary from GitHub releases and runs it.
|
|
6
|
+
|
|
7
|
+
Pin a specific release tag with the `XBERG_CLI_VERSION` environment variable.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Build package marker for the CLI proxy project."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Custom hatchling build hook that bundles the native xberg binary into a wheel.
|
|
2
|
+
|
|
3
|
+
When building a platform-specific wheel in CI, the target triple is supplied via
|
|
4
|
+
the ``XBERG_CLI_TARGET`` env var (the build host is always linux/amd64, so the
|
|
5
|
+
triple cannot be inferred from ``platform.*``). The matching
|
|
6
|
+
``xberg-cli-<target>.tar.gz`` / ``.zip`` is located (repo root or ``dist/``),
|
|
7
|
+
the binary is extracted into ``xberg_cli/bin/<target>/``, force-included in the
|
|
8
|
+
wheel, and the wheel is tagged for that platform so PyPI serves the right artifact.
|
|
9
|
+
|
|
10
|
+
If no target/binary is found (e.g. the sdist build, or an unknown platform), the
|
|
11
|
+
hook is a no-op and the package falls back to the runtime downloader in
|
|
12
|
+
``xberg_cli/downloader.py`` (see ``cli.py``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import tarfile
|
|
20
|
+
import zipfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
24
|
+
|
|
25
|
+
# Rust target triple -> wheel platform tag. PyPI uses the platform tag to serve the
|
|
26
|
+
# correct wheel per OS/arch/libc.
|
|
27
|
+
_TAG_MAP = {
|
|
28
|
+
"x86_64-pc-windows-msvc": "win_amd64",
|
|
29
|
+
"x86_64-unknown-linux-gnu": "manylinux_2_28_x86_64",
|
|
30
|
+
"aarch64-unknown-linux-gnu": "manylinux_2_28_aarch64",
|
|
31
|
+
"x86_64-unknown-linux-musl": "musllinux_1_2_x86_64",
|
|
32
|
+
"aarch64-unknown-linux-musl": "musllinux_1_2_aarch64",
|
|
33
|
+
"aarch64-apple-darwin": "macosx_11_0_arm64",
|
|
34
|
+
"x86_64-apple-darwin": "macosx_11_0_x86_64",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _safe_destination(root: Path, member_name: str) -> Path:
|
|
39
|
+
"""Resolve an archive member path and require it to stay under root."""
|
|
40
|
+
root = root.resolve()
|
|
41
|
+
target = (root / member_name.replace("\\", "/")).resolve(strict=False)
|
|
42
|
+
if target != root and not target.is_relative_to(root):
|
|
43
|
+
raise RuntimeError(f"archive member escapes extraction directory: {member_name}")
|
|
44
|
+
return target
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_zip_bounded(archive: Path, extract_dir: Path) -> None:
|
|
48
|
+
"""Extract zip entries after bounding each destination path."""
|
|
49
|
+
with zipfile.ZipFile(archive) as zf:
|
|
50
|
+
for member in zf.infolist():
|
|
51
|
+
target = _safe_destination(extract_dir, member.filename)
|
|
52
|
+
if member.is_dir():
|
|
53
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with zf.open(member) as source, target.open("wb") as destination:
|
|
58
|
+
shutil.copyfileobj(source, destination)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_tar_bounded(archive: Path, extract_dir: Path) -> None:
|
|
62
|
+
"""Extract regular tar entries after bounding each destination path."""
|
|
63
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
64
|
+
for member in tf.getmembers():
|
|
65
|
+
target = _safe_destination(extract_dir, member.name)
|
|
66
|
+
if member.isdir():
|
|
67
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
continue
|
|
69
|
+
if not member.isfile():
|
|
70
|
+
raise RuntimeError(f"unsupported archive member type: {member.name}")
|
|
71
|
+
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
source = tf.extractfile(member)
|
|
74
|
+
if source is None:
|
|
75
|
+
raise RuntimeError(f"could not read archive member: {member.name}")
|
|
76
|
+
with source, target.open("wb") as destination:
|
|
77
|
+
shutil.copyfileobj(source, destination)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CustomBuildHook(BuildHookInterface):
|
|
81
|
+
"""Inject the matching native binary into a platform-tagged wheel."""
|
|
82
|
+
|
|
83
|
+
PLUGIN_NAME = "custom"
|
|
84
|
+
|
|
85
|
+
def initialize(self, version: str, build_data: dict) -> None: # noqa: ARG002
|
|
86
|
+
"""Bundle a staged native binary when building a targeted wheel."""
|
|
87
|
+
target = os.environ.get("XBERG_CLI_TARGET", "").strip()
|
|
88
|
+
if not target:
|
|
89
|
+
# sdist build or unbundled wheel: leave a pure, download-at-runtime package.
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
archive = self._find_archive(target)
|
|
93
|
+
if archive is None:
|
|
94
|
+
# Target requested but no binary staged — fail loudly rather than ship an
|
|
95
|
+
# empty platform wheel that shadows the working sdist on PyPI.
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"XBERG_CLI_TARGET={target} but no xberg-cli-{target}.(tar.gz|zip) "
|
|
98
|
+
f"found in repo root or dist/; refusing to build an empty platform wheel."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
wheel_tag = _TAG_MAP.get(target)
|
|
102
|
+
if wheel_tag is None:
|
|
103
|
+
raise RuntimeError(f"no wheel platform tag mapped for target {target}")
|
|
104
|
+
|
|
105
|
+
binary = self._extract_binary(archive, target)
|
|
106
|
+
|
|
107
|
+
# force_include maps absolute source paths -> in-wheel relative paths.
|
|
108
|
+
relative = f"xberg_cli/bin/{target}/{binary.name}"
|
|
109
|
+
force_include = build_data.setdefault("force_include", {})
|
|
110
|
+
force_include[str(binary)] = relative
|
|
111
|
+
|
|
112
|
+
# Bundle runtime dylibs staged next to the binary. The native Intel-macOS
|
|
113
|
+
# build loads ONNX Runtime dynamically and ships libonnxruntime.dylib (plus
|
|
114
|
+
# its vendored deps) alongside the executable; ort resolves them relative to
|
|
115
|
+
# the binary, so they must sit in the same in-wheel directory.
|
|
116
|
+
for lib in sorted(binary.parent.glob("*.dylib")):
|
|
117
|
+
force_include[str(lib)] = f"xberg_cli/bin/{target}/{lib.name}"
|
|
118
|
+
|
|
119
|
+
# Make it a platform wheel (not pure-python, not py3-none-any).
|
|
120
|
+
build_data["pure_python"] = False
|
|
121
|
+
build_data["infer_tag"] = False
|
|
122
|
+
build_data["tag"] = f"py3-none-{wheel_tag}"
|
|
123
|
+
|
|
124
|
+
def _find_archive(self, target: str) -> Path | None:
|
|
125
|
+
root = Path(self.root) # the project dir hatchling is building (cli-proxy/pypi)
|
|
126
|
+
repo_root = root.parent.parent
|
|
127
|
+
for base in (repo_root, repo_root / "dist", root, root / "dist"):
|
|
128
|
+
for ext in ("tar.gz", "zip"):
|
|
129
|
+
candidate = base / f"xberg-cli-{target}.{ext}"
|
|
130
|
+
if candidate.is_file():
|
|
131
|
+
return candidate
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _extract_binary(self, archive: Path, target: str) -> Path:
|
|
135
|
+
is_windows = target.endswith("windows-msvc")
|
|
136
|
+
binary_name = "xberg.exe" if is_windows else "xberg"
|
|
137
|
+
extract_dir = Path(self.root) / ".build-extract" / target
|
|
138
|
+
if extract_dir.exists():
|
|
139
|
+
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
140
|
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
if str(archive).lower().endswith(".zip"):
|
|
143
|
+
_extract_zip_bounded(archive, extract_dir)
|
|
144
|
+
else:
|
|
145
|
+
_extract_tar_bounded(archive, extract_dir)
|
|
146
|
+
|
|
147
|
+
for candidate in extract_dir.rglob(binary_name):
|
|
148
|
+
if candidate.is_file():
|
|
149
|
+
candidate.chmod(0o755)
|
|
150
|
+
return candidate
|
|
151
|
+
raise RuntimeError(f"binary {binary_name} not found inside {archive.name}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "hatchling.build"
|
|
3
|
+
requires = ["hatchling"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xberg-cli"
|
|
7
|
+
version = "1.0.0rc14"
|
|
8
|
+
description = "CLI proxy for xberg — downloads and runs the native xberg binary from GitHub releases."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Na'aman Hirschfeld", email = "naaman@xberg.io" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
]
|
|
25
|
+
urls.Homepage = "https://github.com/xberg-io/xberg"
|
|
26
|
+
urls.Issues = "https://github.com/xberg-io/xberg/issues"
|
|
27
|
+
urls.Repository = "https://github.com/xberg-io/xberg.git"
|
|
28
|
+
scripts.xberg = "xberg_cli.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.hatch]
|
|
31
|
+
build.hooks.custom.path = "hatch_build.py"
|
|
32
|
+
build.targets.wheel.packages = ["xberg_cli"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""CLI entry point for the xberg proxy.
|
|
2
|
+
|
|
3
|
+
A platform-specific wheel bundles the native binary under
|
|
4
|
+
``xberg_cli/bin/<target>/``; the sdist (and unknown platforms) ship no binary
|
|
5
|
+
and fall back to the runtime downloader. Each wheel contains exactly one ``bin/<target>``
|
|
6
|
+
directory, so we locate the binary by globbing rather than recomputing the target
|
|
7
|
+
triple (which cannot distinguish glibc from musl at runtime).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .downloader import run
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_bundled_binary() -> str | None:
|
|
22
|
+
"""Return the path to the bundled native binary if this wheel shipped one."""
|
|
23
|
+
bin_root = Path(__file__).parent / "bin"
|
|
24
|
+
if not bin_root.is_dir():
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
binary_name = "xberg.exe" if platform.system().lower() == "windows" else "xberg"
|
|
28
|
+
for candidate in bin_root.glob(f"*/{binary_name}"):
|
|
29
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
30
|
+
return str(candidate)
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
"""Resolve the native binary (bundled or downloaded) and exec it with forwarded argv."""
|
|
36
|
+
bundled = _find_bundled_binary()
|
|
37
|
+
if bundled:
|
|
38
|
+
completed = subprocess.run([bundled, *sys.argv[1:]], check=False)
|
|
39
|
+
sys.exit(completed.returncode)
|
|
40
|
+
|
|
41
|
+
# Fall back to the runtime download path (sdist / unknown platform).
|
|
42
|
+
sys.exit(run(sys.argv[1:]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Resolve, download, verify, and run the native xberg binary.
|
|
2
|
+
|
|
3
|
+
Self-healing asset discovery: query the GitHub releases API and pick the asset
|
|
4
|
+
whose name contains the platform target triple, instead of hardcoding one exact
|
|
5
|
+
asset name. This keeps working as release asset naming standardizes over time.
|
|
6
|
+
All diagnostics go to stderr.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tarfile
|
|
19
|
+
import tempfile
|
|
20
|
+
import zipfile
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from urllib.error import URLError
|
|
23
|
+
from urllib.parse import quote, urlsplit
|
|
24
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
25
|
+
|
|
26
|
+
REPO = "xberg-io/xberg"
|
|
27
|
+
BIN_NAME = "xberg"
|
|
28
|
+
PKG_NAME = "xberg-cli"
|
|
29
|
+
VERSION_ENV = "XBERG_CLI_VERSION"
|
|
30
|
+
_USER_AGENT = "xberg-cli-python-proxy"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _target_triple() -> str:
|
|
34
|
+
system = platform.system().lower()
|
|
35
|
+
machine = platform.machine().lower()
|
|
36
|
+
|
|
37
|
+
if system == "windows":
|
|
38
|
+
if machine in {"amd64", "x86_64"}:
|
|
39
|
+
return "x86_64-pc-windows-msvc"
|
|
40
|
+
raise RuntimeError(f"unsupported Windows arch: {machine}")
|
|
41
|
+
if system == "linux":
|
|
42
|
+
if machine in {"amd64", "x86_64"}:
|
|
43
|
+
return "x86_64-unknown-linux-gnu"
|
|
44
|
+
if machine in {"aarch64", "arm64"}:
|
|
45
|
+
return "aarch64-unknown-linux-gnu"
|
|
46
|
+
raise RuntimeError(f"unsupported Linux arch: {machine}")
|
|
47
|
+
if system == "darwin":
|
|
48
|
+
if machine in {"aarch64", "arm64"}:
|
|
49
|
+
return "aarch64-apple-darwin"
|
|
50
|
+
if machine in {"amd64", "x86_64"}:
|
|
51
|
+
return "x86_64-apple-darwin"
|
|
52
|
+
raise RuntimeError(f"unsupported macOS arch: {machine}")
|
|
53
|
+
raise RuntimeError(f"unsupported platform: {system} {machine}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _binary_name() -> str:
|
|
57
|
+
return f"{BIN_NAME}.exe" if platform.system().lower() == "windows" else BIN_NAME
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _HttpsOnlyRedirectHandler(HTTPRedirectHandler):
|
|
61
|
+
"""Reject any redirect whose target is not https (downgrade/SSRF guard)."""
|
|
62
|
+
|
|
63
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
64
|
+
if urlsplit(newurl).scheme.lower() != "https":
|
|
65
|
+
raise URLError(f"refusing non-https redirect to: {newurl}")
|
|
66
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_opener = build_opener(_HttpsOnlyRedirectHandler())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _http_get(url: str, accept: str | None = None) -> bytes:
|
|
73
|
+
if urlsplit(url).scheme.lower() != "https":
|
|
74
|
+
raise RuntimeError(f"refusing non-https URL: {url}")
|
|
75
|
+
headers = {"User-Agent": _USER_AGENT}
|
|
76
|
+
if accept:
|
|
77
|
+
headers["Accept"] = accept
|
|
78
|
+
request = Request(url, headers=headers)
|
|
79
|
+
try:
|
|
80
|
+
with _opener.open(request, timeout=60) as response:
|
|
81
|
+
if response.status != 200:
|
|
82
|
+
raise RuntimeError(f"HTTP {response.status} for {url}")
|
|
83
|
+
return response.read()
|
|
84
|
+
except URLError as exc:
|
|
85
|
+
raise RuntimeError(f"failed to fetch {url}: {exc}") from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _asset_score(name: str) -> int:
|
|
89
|
+
lowered = name.lower()
|
|
90
|
+
score = 0
|
|
91
|
+
if BIN_NAME.lower() in lowered:
|
|
92
|
+
score += 2
|
|
93
|
+
if "cli" in lowered:
|
|
94
|
+
score += 1
|
|
95
|
+
return score
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_release() -> tuple[str, dict, dict | None]:
|
|
99
|
+
"""Return (tag, archive_asset, checksums_asset_or_none) for this platform."""
|
|
100
|
+
triple = _target_triple()
|
|
101
|
+
pinned = os.getenv(VERSION_ENV)
|
|
102
|
+
if pinned:
|
|
103
|
+
api_url = f"https://api.github.com/repos/{REPO}/releases/tags/{quote(pinned, safe='')}"
|
|
104
|
+
else:
|
|
105
|
+
api_url = f"https://api.github.com/repos/{REPO}/releases/latest"
|
|
106
|
+
|
|
107
|
+
release = json.loads(_http_get(api_url, accept="application/vnd.github+json"))
|
|
108
|
+
assets = release.get("assets") or []
|
|
109
|
+
tag = release.get("tag_name") or pinned or "latest"
|
|
110
|
+
|
|
111
|
+
archives = [
|
|
112
|
+
a
|
|
113
|
+
for a in assets
|
|
114
|
+
if triple in (a.get("name") or "").lower()
|
|
115
|
+
and ((a.get("name") or "").lower().endswith(".tar.gz") or (a.get("name") or "").lower().endswith(".zip"))
|
|
116
|
+
]
|
|
117
|
+
if not archives:
|
|
118
|
+
raise RuntimeError(f'no release asset matching target triple "{triple}" in {REPO} release {tag}')
|
|
119
|
+
archives.sort(key=lambda a: _asset_score(a.get("name") or ""), reverse=True)
|
|
120
|
+
archive = archives[0]
|
|
121
|
+
|
|
122
|
+
checksums = next((a for a in assets if "SHA256SUMS" in (a.get("name") or "").upper()), None)
|
|
123
|
+
return tag, archive, checksums
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _expected_digest(text: str, asset_name: str) -> str | None:
|
|
127
|
+
for line in text.splitlines():
|
|
128
|
+
stripped = line.strip()
|
|
129
|
+
if not stripped:
|
|
130
|
+
continue
|
|
131
|
+
parts = stripped.split()
|
|
132
|
+
if len(parts) < 2:
|
|
133
|
+
continue
|
|
134
|
+
name = parts[-1].lstrip("*")
|
|
135
|
+
if name == asset_name:
|
|
136
|
+
return parts[0].lower()
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _verify_or_warn(archive_path: Path, asset_name: str, checksums: dict | None) -> None:
|
|
141
|
+
if not checksums:
|
|
142
|
+
print(
|
|
143
|
+
f"WARNING: no SHA256SUMS asset found for {asset_name}; "
|
|
144
|
+
"installing over HTTPS without checksum verification.",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
sums_text = _http_get(checksums["browser_download_url"]).decode("utf-8")
|
|
149
|
+
expected = _expected_digest(sums_text, asset_name)
|
|
150
|
+
if not expected:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"no checksum entry for {asset_name} in {checksums['name']} — refusing to install unverified binary"
|
|
153
|
+
)
|
|
154
|
+
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower()
|
|
155
|
+
if digest != expected:
|
|
156
|
+
raise RuntimeError(f"checksum mismatch for {asset_name} (expected {expected}, got {digest})")
|
|
157
|
+
print(f"Checksum verified for {asset_name}.", file=sys.stderr)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_within(base: Path, target: Path) -> bool:
|
|
161
|
+
"""True if `target`'s resolved path stays inside `base`."""
|
|
162
|
+
base_resolved = base.resolve()
|
|
163
|
+
try:
|
|
164
|
+
target.resolve().relative_to(base_resolved)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _reject_unsafe_member(name: str) -> None:
|
|
171
|
+
"""Reject absolute paths and any `..` component (zip-slip / tar-slip)."""
|
|
172
|
+
normalized = name.replace("\\", "/")
|
|
173
|
+
pure = PurePosixPath(normalized)
|
|
174
|
+
if pure.is_absolute() or normalized.startswith("/"):
|
|
175
|
+
raise RuntimeError(f"refusing absolute path in archive: {name}")
|
|
176
|
+
# Windows drive letters / UNC prefixes are also absolute escapes.
|
|
177
|
+
if len(normalized) >= 2 and normalized[1] == ":":
|
|
178
|
+
raise RuntimeError(f"refusing absolute path in archive: {name}")
|
|
179
|
+
if any(part == ".." for part in pure.parts):
|
|
180
|
+
raise RuntimeError(f"refusing parent-directory escape in archive: {name}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _safe_extract(archive_path: Path, asset_name: str, dest: Path) -> None:
|
|
184
|
+
"""Extract a tar/zip, validating every member stays within `dest`.
|
|
185
|
+
|
|
186
|
+
Does not rely on extractall() defaults or the 3.12-only ``filter='data'``:
|
|
187
|
+
each member name is rejected up front if it is absolute or contains ``..``,
|
|
188
|
+
and the resolved destination path is re-checked against ``dest``.
|
|
189
|
+
"""
|
|
190
|
+
dest = dest.resolve()
|
|
191
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
|
|
193
|
+
if asset_name.lower().endswith(".zip"):
|
|
194
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
195
|
+
for member in zf.namelist():
|
|
196
|
+
_reject_unsafe_member(member)
|
|
197
|
+
if not _is_within(dest, dest / member):
|
|
198
|
+
raise RuntimeError(f"refusing archive entry escaping dest: {member}")
|
|
199
|
+
for member in zf.namelist():
|
|
200
|
+
zf.extract(member, dest)
|
|
201
|
+
else:
|
|
202
|
+
with tarfile.open(archive_path, "r:gz") as tf:
|
|
203
|
+
for member in tf.getmembers():
|
|
204
|
+
_reject_unsafe_member(member.name)
|
|
205
|
+
if not _is_within(dest, dest / member.name):
|
|
206
|
+
raise RuntimeError(f"refusing archive entry escaping dest: {member.name}")
|
|
207
|
+
# Reject link members that point outside dest as well.
|
|
208
|
+
if member.islnk() or member.issym():
|
|
209
|
+
link_target = dest / member.name
|
|
210
|
+
if not _is_within(dest, link_target.parent / member.linkname):
|
|
211
|
+
raise RuntimeError(f"refusing link escaping dest: {member.name}")
|
|
212
|
+
tf.extractall(dest) # noqa: S202 (members validated above)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _find_binary(root: Path, name: str) -> Path | None:
|
|
216
|
+
for candidate in root.rglob(name):
|
|
217
|
+
if candidate.is_file():
|
|
218
|
+
return candidate
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cache_dir(tag: str) -> Path:
|
|
223
|
+
cache = Path.home() / ".cache" / PKG_NAME / tag
|
|
224
|
+
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
225
|
+
return cache
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def ensure_binary() -> str:
|
|
229
|
+
"""Ensure the native binary exists locally, downloading if necessary."""
|
|
230
|
+
override = os.getenv("XBERG_BINARY")
|
|
231
|
+
if override:
|
|
232
|
+
return override
|
|
233
|
+
|
|
234
|
+
tag, archive, checksums = _resolve_release()
|
|
235
|
+
cache = _cache_dir(tag)
|
|
236
|
+
binary_path = cache / _binary_name()
|
|
237
|
+
if binary_path.exists() and os.access(binary_path, os.X_OK):
|
|
238
|
+
return str(binary_path)
|
|
239
|
+
|
|
240
|
+
print(f"Downloading {BIN_NAME} {tag} asset {archive['name']}...", file=sys.stderr)
|
|
241
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
242
|
+
tmp = Path(tmpdir)
|
|
243
|
+
archive_path = tmp / Path(archive["name"]).name
|
|
244
|
+
archive_path.write_bytes(_http_get(archive["browser_download_url"]))
|
|
245
|
+
_verify_or_warn(archive_path, archive["name"], checksums)
|
|
246
|
+
extract_dir = tmp / "extract"
|
|
247
|
+
extract_dir.mkdir()
|
|
248
|
+
_safe_extract(archive_path, archive["name"], extract_dir)
|
|
249
|
+
found = _find_binary(extract_dir, _binary_name())
|
|
250
|
+
if not found:
|
|
251
|
+
raise RuntimeError(f"binary {_binary_name()} not found after extracting {archive['name']}")
|
|
252
|
+
shutil.move(str(found), str(binary_path))
|
|
253
|
+
|
|
254
|
+
if platform.system().lower() != "windows":
|
|
255
|
+
binary_path.chmod(0o755)
|
|
256
|
+
print(f"{BIN_NAME} installed.", file=sys.stderr)
|
|
257
|
+
return str(binary_path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run(args: list[str]) -> int:
|
|
261
|
+
"""Run the native binary with the given args, returning its exit code."""
|
|
262
|
+
binary_path = ensure_binary()
|
|
263
|
+
completed = subprocess.run([binary_path, *args], check=False)
|
|
264
|
+
return completed.returncode
|