stml-cli 0.1.4__tar.gz → 0.1.5__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.
- {stml_cli-0.1.4 → stml_cli-0.1.5}/PKG-INFO +1 -1
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/__init__.py +1 -1
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/commands.py +15 -1
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/http.py +21 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/paths.py +31 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/PKG-INFO +1 -1
- {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_paths.py +34 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/README.md +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/pyproject.toml +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/setup.cfg +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/__main__.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/config.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/oauth.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/packaging.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/refs.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/SOURCES.txt +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/dependency_links.txt +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/entry_points.txt +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/requires.txt +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/top_level.txt +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_disambiguation.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_refs.py +0 -0
- {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_whoami_expiry.py +0 -0
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
11
|
|
|
12
|
-
__version__ = "0.1.
|
|
12
|
+
__version__ = "0.1.5"
|
|
13
13
|
|
|
14
14
|
# TLS trust: framework/python.org builds have no OpenSSL CA path, so a real
|
|
15
15
|
# https backend fails with "unable to get local issuer certificate". certifi's
|
|
@@ -135,9 +135,23 @@ def cmd_pull(args) -> None:
|
|
|
135
135
|
dest.mkdir(parents=True, exist_ok=True)
|
|
136
136
|
written = paths.write_tree(dest, files)
|
|
137
137
|
|
|
138
|
+
# Assets: the text overlay carries no binaries — the app's assets live in
|
|
139
|
+
# the app-assets bucket. Fetch them as one tarball and extract under
|
|
140
|
+
# assets/, so the pulled folder is a COMPLETE library dir (publish ships
|
|
141
|
+
# assets/ back through the normal split). Non-fatal: an older backend
|
|
142
|
+
# without the endpoint just means no assets/ on disk.
|
|
143
|
+
asset_count = 0
|
|
144
|
+
try:
|
|
145
|
+
asset_url = f"{args.backend}/api/app-source/assets?" + urllib.parse.urlencode(query)
|
|
146
|
+
blob = http.get_bytes(asset_url, token)
|
|
147
|
+
asset_count = paths.extract_assets(dest, blob)
|
|
148
|
+
except http.HttpError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
138
151
|
# Record the source coordinate so a later push can be run from the folder.
|
|
139
152
|
_write_link(dest, args.backend, workspace, app)
|
|
140
|
-
|
|
153
|
+
tail = f" (+ {asset_count} asset(s))" if asset_count else ""
|
|
154
|
+
_log(f"✓ Pulled {len(written)} file(s) from {result.get('app', app)} → {dest}{tail}")
|
|
141
155
|
|
|
142
156
|
|
|
143
157
|
def cmd_push(args) -> None:
|
|
@@ -60,6 +60,27 @@ def get_json(url: str, token: str) -> dict:
|
|
|
60
60
|
return _open(req)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def get_bytes(url: str, token: str) -> bytes:
|
|
64
|
+
"""GET a binary response (e.g. the app-assets tarball). Same error shaping
|
|
65
|
+
as ``_open`` but returns raw bytes instead of parsed JSON."""
|
|
66
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET")
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req) as resp:
|
|
69
|
+
return resp.read()
|
|
70
|
+
except urllib.error.HTTPError as e:
|
|
71
|
+
raw = e.read().decode("utf-8", "replace")
|
|
72
|
+
detail = raw
|
|
73
|
+
try:
|
|
74
|
+
parsed = json.loads(raw)
|
|
75
|
+
if isinstance(parsed, dict) and "detail" in parsed:
|
|
76
|
+
detail = str(parsed["detail"])
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
pass
|
|
79
|
+
raise HttpError(e.code, detail)
|
|
80
|
+
except urllib.error.URLError as e: # network / DNS / TLS
|
|
81
|
+
raise HttpError(0, f"cannot reach {req.full_url}: {e.reason}")
|
|
82
|
+
|
|
83
|
+
|
|
63
84
|
def post_json(url: str, body: dict, token: str | None = None) -> dict:
|
|
64
85
|
headers = {"Content-Type": "application/json"}
|
|
65
86
|
if token:
|
|
@@ -88,6 +88,11 @@ def read_tree(root: Path) -> list[dict]:
|
|
|
88
88
|
continue
|
|
89
89
|
if not p.is_file():
|
|
90
90
|
continue
|
|
91
|
+
# Top-level assets/ is binary, bucket-backed media (written by pull's
|
|
92
|
+
# assets fetch) — never part of the text overlay. Publish ships it from
|
|
93
|
+
# disk via build_tarball; push must neither send it nor crash decoding it.
|
|
94
|
+
if rel.parts[:1] == ("assets",):
|
|
95
|
+
continue
|
|
91
96
|
key = to_posix_key(rel)
|
|
92
97
|
if key.startswith("src/"):
|
|
93
98
|
key = key[len("src/"):]
|
|
@@ -123,3 +128,29 @@ def write_tree(root: Path, files: list[dict]) -> list[str]:
|
|
|
123
128
|
dest.write_text(f.get("content") or "", encoding="utf-8", newline="")
|
|
124
129
|
written.append(key)
|
|
125
130
|
return written
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def extract_assets(root: Path, tarball: bytes) -> int:
|
|
134
|
+
"""Extract a pulled ``assets/…`` tarball (the /api/app-source/assets
|
|
135
|
+
response) into the folder. Members are already ``assets/<rel>``; anything
|
|
136
|
+
else is refused (defensive — never let a tar write outside the tree).
|
|
137
|
+
Returns the number of files written."""
|
|
138
|
+
import io
|
|
139
|
+
import tarfile
|
|
140
|
+
|
|
141
|
+
count = 0
|
|
142
|
+
with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as tar:
|
|
143
|
+
for m in tar.getmembers():
|
|
144
|
+
if not m.isfile():
|
|
145
|
+
continue
|
|
146
|
+
rel = PurePosixPath(m.name)
|
|
147
|
+
if rel.parts[:1] != ("assets",) or ".." in rel.parts or rel.is_absolute():
|
|
148
|
+
continue
|
|
149
|
+
f = tar.extractfile(m)
|
|
150
|
+
if f is None:
|
|
151
|
+
continue
|
|
152
|
+
dest = root / key_to_native(str(rel))
|
|
153
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
dest.write_bytes(f.read())
|
|
155
|
+
count += 1
|
|
156
|
+
return count
|
|
@@ -114,6 +114,40 @@ def test_local_junk_is_excluded(tmp_path: Path):
|
|
|
114
114
|
assert keys == {"flows/x.py"} # .git, __pycache__, .stml, *.pyc all dropped
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
def test_assets_dir_is_never_pushed(tmp_path: Path):
|
|
118
|
+
"""Top-level assets/ is bucket-backed binary media (written by pull) — push
|
|
119
|
+
must skip it (a .xlsx would crash read_text) but publish ships it from disk."""
|
|
120
|
+
paths.write_tree(tmp_path, [{"path": "pkg/x.py", "content": "x=1\n"}])
|
|
121
|
+
(tmp_path / "assets").mkdir()
|
|
122
|
+
(tmp_path / "assets" / "sample.xlsx").write_bytes(b"\x00\x01binary")
|
|
123
|
+
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
124
|
+
assert keys == {"pkg/x.py"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_extract_assets_writes_only_under_assets(tmp_path: Path):
|
|
128
|
+
import io
|
|
129
|
+
import tarfile
|
|
130
|
+
|
|
131
|
+
buf = io.BytesIO()
|
|
132
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
133
|
+
for name, data in [
|
|
134
|
+
("assets/sample.xlsx", b"\x00\x01"),
|
|
135
|
+
("assets/sub/img.png", b"\x89PNG"),
|
|
136
|
+
("../evil.txt", b"nope"), # traversal → refused
|
|
137
|
+
("pyproject.toml", b"nope"), # outside assets/ → refused
|
|
138
|
+
]:
|
|
139
|
+
info = tarfile.TarInfo(name=name)
|
|
140
|
+
info.size = len(data)
|
|
141
|
+
tar.addfile(info, io.BytesIO(data))
|
|
142
|
+
n = paths.extract_assets(tmp_path, buf.getvalue())
|
|
143
|
+
assert n == 2
|
|
144
|
+
assert (tmp_path / "assets" / "sample.xlsx").read_bytes() == b"\x00\x01"
|
|
145
|
+
assert (tmp_path / "assets" / "sub" / "img.png").read_bytes() == b"\x89PNG"
|
|
146
|
+
assert not (tmp_path / "evil.txt").exists()
|
|
147
|
+
assert not (tmp_path.parent / "evil.txt").exists()
|
|
148
|
+
assert not (tmp_path / "pyproject.toml").exists()
|
|
149
|
+
|
|
150
|
+
|
|
117
151
|
def test_key_to_native_and_back():
|
|
118
152
|
native = paths.key_to_native("flows/sub/x.py")
|
|
119
153
|
assert native == Path("flows") / "sub" / "x.py"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|