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.
Files changed (23) hide show
  1. {stml_cli-0.1.4 → stml_cli-0.1.5}/PKG-INFO +1 -1
  2. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/__init__.py +1 -1
  3. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/commands.py +15 -1
  4. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/http.py +21 -0
  5. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/paths.py +31 -0
  6. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/PKG-INFO +1 -1
  7. {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_paths.py +34 -0
  8. {stml_cli-0.1.4 → stml_cli-0.1.5}/README.md +0 -0
  9. {stml_cli-0.1.4 → stml_cli-0.1.5}/pyproject.toml +0 -0
  10. {stml_cli-0.1.4 → stml_cli-0.1.5}/setup.cfg +0 -0
  11. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/__main__.py +0 -0
  12. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/config.py +0 -0
  13. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/oauth.py +0 -0
  14. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/packaging.py +0 -0
  15. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli/refs.py +0 -0
  16. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/SOURCES.txt +0 -0
  17. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/dependency_links.txt +0 -0
  18. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/entry_points.txt +0 -0
  19. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/requires.txt +0 -0
  20. {stml_cli-0.1.4 → stml_cli-0.1.5}/stml_cli.egg-info/top_level.txt +0 -0
  21. {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_disambiguation.py +0 -0
  22. {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_refs.py +0 -0
  23. {stml_cli-0.1.4 → stml_cli-0.1.5}/tests/test_whoami_expiry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stml-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: The stml partner CLI — pull, edit, push, and publish stml apps and libraries.
5
5
  Author: stml
6
6
  Project-URL: Homepage, https://stml.io
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
 
12
- __version__ = "0.1.4"
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
- _log(f"✓ Pulled {len(written)} file(s) from {result.get('app', app)} → {dest}")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stml-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: The stml partner CLI — pull, edit, push, and publish stml apps and libraries.
5
5
  Author: stml
6
6
  Project-URL: Homepage, https://stml.io
@@ -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