stml-cli 0.1.2__tar.gz → 0.1.4__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.2 → stml_cli-0.1.4}/PKG-INFO +1 -1
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/__init__.py +1 -1
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/commands.py +26 -6
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/packaging.py +7 -1
- stml_cli-0.1.4/stml_cli/paths.py +125 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli.egg-info/PKG-INFO +1 -1
- stml_cli-0.1.4/tests/test_paths.py +120 -0
- stml_cli-0.1.2/stml_cli/paths.py +0 -70
- stml_cli-0.1.2/tests/test_paths.py +0 -65
- {stml_cli-0.1.2 → stml_cli-0.1.4}/README.md +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/pyproject.toml +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/setup.cfg +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/__main__.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/config.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/http.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/oauth.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli/refs.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli.egg-info/SOURCES.txt +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli.egg-info/dependency_links.txt +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli.egg-info/entry_points.txt +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli.egg-info/requires.txt +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/stml_cli.egg-info/top_level.txt +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/tests/test_disambiguation.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/tests/test_refs.py +0 -0
- {stml_cli-0.1.2 → stml_cli-0.1.4}/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.4"
|
|
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
|
|
@@ -70,16 +70,30 @@ def cmd_list(args) -> None:
|
|
|
70
70
|
if not apps:
|
|
71
71
|
_log("No apps found.")
|
|
72
72
|
return
|
|
73
|
-
#
|
|
74
|
-
# exact handle `stml pull`/`push` accept (the URL's app slug is
|
|
75
|
-
# it never collides even when a library is installed twice).
|
|
76
|
-
#
|
|
77
|
-
|
|
73
|
+
# Name + org + workspace + mode (035: attached/detached/scratch) + the app
|
|
74
|
+
# URL — the exact handle `stml pull`/`push` accept (the URL's app slug is
|
|
75
|
+
# unique, so it never collides even when a library is installed twice). Org
|
|
76
|
+
# and workspace disambiguate same-named apps across the account. Names fall
|
|
77
|
+
# back to slugs against older backends. Rows to stdout so `stml list | grep …`
|
|
78
|
+
# works; header to stderr.
|
|
79
|
+
def _org(a):
|
|
80
|
+
return a.get("org_name") or a.get("org_slug") or ""
|
|
81
|
+
|
|
82
|
+
def _ws(a):
|
|
83
|
+
return a.get("workspace_name") or a.get("workspace_slug") or ""
|
|
84
|
+
|
|
85
|
+
name_w = max((len(a.get("name") or "") for a in apps), default=3)
|
|
86
|
+
org_w = max((len(_org(a)) for a in apps), default=3)
|
|
87
|
+
ws_w = max((len(_ws(a)) for a in apps), default=2)
|
|
78
88
|
mode_w = max((len(a.get("mode") or "") for a in apps), default=4)
|
|
79
|
-
_log(
|
|
89
|
+
_log(
|
|
90
|
+
f"{'APP'.ljust(name_w)} {'ORG'.ljust(org_w)} "
|
|
91
|
+
f"{'WORKSPACE'.ljust(ws_w)} {'MODE'.ljust(mode_w)} URL"
|
|
92
|
+
)
|
|
80
93
|
for a in apps:
|
|
81
94
|
print(
|
|
82
95
|
f"{(a.get('name') or '').ljust(name_w)} "
|
|
96
|
+
f"{_org(a).ljust(org_w)} {_ws(a).ljust(ws_w)} "
|
|
83
97
|
f"{(a.get('mode') or '').ljust(mode_w)} {a.get('url') or ''}"
|
|
84
98
|
)
|
|
85
99
|
|
|
@@ -238,6 +252,12 @@ def cmd_publish(args) -> None:
|
|
|
238
252
|
if not org_slug:
|
|
239
253
|
raise SystemExit("Pass --org or set [tool.stml].issuer-org in pyproject.toml")
|
|
240
254
|
|
|
255
|
+
# Pre-flight: show the resolved coordinate BEFORE uploading, and say where
|
|
256
|
+
# the issuer came from — a pulled fork silently carries the ORIGINAL
|
|
257
|
+
# issuer-org in its pyproject, which is exactly when you want `--org`.
|
|
258
|
+
origin = "--org" if args.org else "[tool.stml].issuer-org in pyproject.toml"
|
|
259
|
+
_log(f"Publishing as {org_slug}/{name}@{version} (issuer from {origin})")
|
|
260
|
+
|
|
241
261
|
token = http.resolve_token(args.backend)
|
|
242
262
|
|
|
243
263
|
# Ensure the library exists (409 = already there, which is fine).
|
|
@@ -4,6 +4,12 @@ One directory → a ``<dir-name>/…`` gzip tarball, excluding local dev junk. T
|
|
|
4
4
|
platform publish endpoint reads the manifest and explodes ``src/``; this only
|
|
5
5
|
packages the bytes. Kept byte-compatible with the monorepo builder so a lib
|
|
6
6
|
published by either path lands identically.
|
|
7
|
+
|
|
8
|
+
The publish process deliberately stays layout-strict (src/ required for
|
|
9
|
+
runnable Python — story 020's guard): a folder made by ``stml pull`` is
|
|
10
|
+
ALREADY written in the src/ library layout (the overlay's flat keys are a
|
|
11
|
+
platform-internal storage detail — see ``paths.write_tree``), so a pulled
|
|
12
|
+
fork publishes here without repackaging.
|
|
7
13
|
"""
|
|
8
14
|
|
|
9
15
|
from __future__ import annotations
|
|
@@ -13,7 +19,7 @@ import tarfile
|
|
|
13
19
|
from pathlib import Path
|
|
14
20
|
|
|
15
21
|
EXCLUDE_DIRS = {
|
|
16
|
-
".git", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache",
|
|
22
|
+
".git", ".stml", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache",
|
|
17
23
|
"dist", "build", ".mypy_cache", ".ruff_cache",
|
|
18
24
|
# Unit tests live in a top-level tests/ per lib (pytest testpaths); test-
|
|
19
25
|
# named files inside packages are intentional runnable flows, so dir-only.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Cross-platform overlay ↔ local-folder mapping (024 §OS & runtime support).
|
|
2
|
+
|
|
3
|
+
The overlay keys the platform stores are always POSIX (``flows/x.py``). "Runs on
|
|
4
|
+
Windows" is only real if the CLI:
|
|
5
|
+
1. maps a POSIX key to a NATIVE path when writing to disk, and back to a POSIX
|
|
6
|
+
key (never backslashes) when reading — via ``PurePosixPath`` on the wire,
|
|
7
|
+
``Path`` on disk;
|
|
8
|
+
2. reads/writes UTF-8 and preserves ``\n`` exactly, so a pulled file survives
|
|
9
|
+
an untouched push byte-for-byte (no CRLF injection on Windows).
|
|
10
|
+
|
|
11
|
+
Layout mapping (authoring vs deploying): on DISK the CLI presents the standard
|
|
12
|
+
**src/ library layout** — the same shape as a real library repo, what
|
|
13
|
+
``stml publish`` expects, and what a human authors. The platform's overlay
|
|
14
|
+
stores the FLAT deploy-side keys (``pkg/flow.py``); that is an internal storage
|
|
15
|
+
detail that must not leak to disk. So:
|
|
16
|
+
|
|
17
|
+
* ``write_tree`` (pull) — a flat overlay with Python is written with its
|
|
18
|
+
runnable files under ``src/`` (packaging/metadata — pyproject, top-level
|
|
19
|
+
``*.md``, ``page/**``, LICENSE — stays at the root, mirroring the publish
|
|
20
|
+
tarball layout and the server's ``_is_packaging_path``).
|
|
21
|
+
* ``read_tree`` (push) — a folder with a top-level ``src/`` has that prefix
|
|
22
|
+
stripped back to the flat overlay keys, so an overlay row always shadows
|
|
23
|
+
its base-library path exactly. A flat folder (pre-mapping pulls, hand-made
|
|
24
|
+
trees) passes through unchanged.
|
|
25
|
+
|
|
26
|
+
The two directions are inverse by construction, so pull → push is lossless.
|
|
27
|
+
|
|
28
|
+
These functions are the whole contract; they are unit-tested in isolation so
|
|
29
|
+
the Windows guarantee doesn't rest on a live round-trip.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from pathlib import Path, PurePosixPath
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_packaging_path(rel: str) -> bool:
|
|
38
|
+
"""Paths that live at the ROOT in the src-layout view (not under ``src/``) —
|
|
39
|
+
metadata the publish endpoint reads there and the deploy bundler ignores.
|
|
40
|
+
Must match the server's ``_is_packaging_path`` so the disk layout equals
|
|
41
|
+
the publish-tarball layout."""
|
|
42
|
+
if rel in ("pyproject.toml", "README.md", "LICENSE", "py.typed"):
|
|
43
|
+
return True
|
|
44
|
+
if rel.startswith("page/"): # app-page assets, served separately
|
|
45
|
+
return True
|
|
46
|
+
if "/" not in rel and rel.endswith(".md"): # top-level docs incl. AGENTS.md
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
# Local-only artifacts that must never be pushed into the overlay.
|
|
51
|
+
_EXCLUDE_DIRS = {
|
|
52
|
+
".git", "__pycache__", ".venv", "venv", ".stml",
|
|
53
|
+
"node_modules", ".mypy_cache", ".ruff_cache", ".pytest_cache",
|
|
54
|
+
}
|
|
55
|
+
_EXCLUDE_NAMES = {".DS_Store"}
|
|
56
|
+
_EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_posix_key(rel: Path) -> str:
|
|
60
|
+
"""A native relative path → the POSIX overlay key (forward slashes)."""
|
|
61
|
+
return PurePosixPath(*rel.parts).as_posix()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def key_to_native(key: str) -> Path:
|
|
65
|
+
"""A POSIX overlay key → a native relative Path for this OS."""
|
|
66
|
+
return Path(*PurePosixPath(key).parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_tree(root: Path) -> list[dict]:
|
|
70
|
+
"""Read a local folder into ``[{path, content}]`` with POSIX overlay keys.
|
|
71
|
+
|
|
72
|
+
A top-level ``src/`` (the on-disk library layout ``write_tree`` produces)
|
|
73
|
+
is stripped back to the flat overlay keys — the canonical form the platform
|
|
74
|
+
stores, and the form in which an overlay row shadows its base-library path.
|
|
75
|
+
A same-key collision (``src/pkg/x.py`` next to ``pkg/x.py``) is ambiguous
|
|
76
|
+
and raises rather than silently picking a winner.
|
|
77
|
+
|
|
78
|
+
``read_text`` (universal newlines) collapses any CRLF to ``\n``, so the
|
|
79
|
+
content pushed is always LF-normalised regardless of the author's OS.
|
|
80
|
+
"""
|
|
81
|
+
out: list[dict] = []
|
|
82
|
+
seen: dict[str, str] = {} # overlay key → original rel (collision detection)
|
|
83
|
+
for p in sorted(root.rglob("*")):
|
|
84
|
+
rel = p.relative_to(root)
|
|
85
|
+
if any(part in _EXCLUDE_DIRS for part in rel.parts):
|
|
86
|
+
continue
|
|
87
|
+
if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
|
|
88
|
+
continue
|
|
89
|
+
if not p.is_file():
|
|
90
|
+
continue
|
|
91
|
+
key = to_posix_key(rel)
|
|
92
|
+
if key.startswith("src/"):
|
|
93
|
+
key = key[len("src/"):]
|
|
94
|
+
if key in seen:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"ambiguous tree: {rel.as_posix()!r} and {seen[key]!r} both map to "
|
|
97
|
+
f"overlay key {key!r} — keep runnable code under src/ only."
|
|
98
|
+
)
|
|
99
|
+
seen[key] = rel.as_posix()
|
|
100
|
+
out.append({"path": key, "content": p.read_text(encoding="utf-8")})
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_tree(root: Path, files: list[dict]) -> list[str]:
|
|
105
|
+
"""Write ``[{path, content}]`` (POSIX overlay keys) into a local folder in
|
|
106
|
+
the standard **src/ library layout**: when the overlay carries Python (and
|
|
107
|
+
isn't already src-laid-out), runnable files land under ``src/`` and
|
|
108
|
+
packaging/metadata stays at the root — so the folder is a valid library
|
|
109
|
+
directory (`stml publish` accepts it as-is) and diffs cleanly against a
|
|
110
|
+
real library repo. Returns the overlay keys written (not disk paths).
|
|
111
|
+
|
|
112
|
+
``newline=""`` disables newline translation so ``\n`` is written verbatim —
|
|
113
|
+
a pulled file is not silently rewritten to CRLF on Windows.
|
|
114
|
+
"""
|
|
115
|
+
py_keys = [f["path"] for f in files if f["path"].endswith(".py")]
|
|
116
|
+
relocate = bool(py_keys) and not any(k.startswith("src/") for k in py_keys)
|
|
117
|
+
written: list[str] = []
|
|
118
|
+
for f in files:
|
|
119
|
+
key = f["path"]
|
|
120
|
+
disk_key = f"src/{key}" if relocate and not _is_packaging_path(key) else key
|
|
121
|
+
dest = root / key_to_native(disk_key)
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
dest.write_text(f.get("content") or "", encoding="utf-8", newline="")
|
|
124
|
+
written.append(key)
|
|
125
|
+
return written
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Cross-platform overlay ↔ folder mapping (024 acceptance: CLI cross-platform).
|
|
2
|
+
|
|
3
|
+
The Windows guarantee ("a pulled tree round-trips; overlay keys stay POSIX and
|
|
4
|
+
content stays UTF-8/\\n") rests entirely on paths.read_tree / write_tree, so
|
|
5
|
+
they are pinned here in isolation — no live backend, no OS assumptions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from stml_cli import paths
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_keys_are_posix_even_for_nested_paths(tmp_path: Path):
|
|
16
|
+
files = [
|
|
17
|
+
{"path": "flows/greeting.py", "content": "print('hi')\n"},
|
|
18
|
+
{"path": "page/index.html", "content": "<h1>hi</h1>\n"},
|
|
19
|
+
{"path": "pyproject.toml", "content": "[project]\n"},
|
|
20
|
+
]
|
|
21
|
+
paths.write_tree(tmp_path, files)
|
|
22
|
+
# On disk: the standard src/ library layout — runnable Python under src/,
|
|
23
|
+
# packaging (page/, pyproject) at the root (authoring vs deploying: the
|
|
24
|
+
# flat overlay key is a platform-internal storage detail).
|
|
25
|
+
assert (tmp_path / "src" / "flows" / "greeting.py").is_file()
|
|
26
|
+
assert (tmp_path / "page" / "index.html").is_file()
|
|
27
|
+
assert (tmp_path / "pyproject.toml").is_file()
|
|
28
|
+
# ...but reading back yields the flat forward-slash overlay keys.
|
|
29
|
+
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
30
|
+
assert keys == {"flows/greeting.py", "page/index.html", "pyproject.toml"}
|
|
31
|
+
assert not any("\\" in k for k in keys)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_page_only_overlay_stays_flat_on_disk(tmp_path: Path):
|
|
35
|
+
"""No Python in the overlay (fresh attached install) → nothing to wrap."""
|
|
36
|
+
paths.write_tree(tmp_path, [{"path": "page/index.html", "content": "<h1/>\n"}])
|
|
37
|
+
assert (tmp_path / "page" / "index.html").is_file()
|
|
38
|
+
assert not (tmp_path / "src").exists()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_src_layout_folder_pushes_flat_keys(tmp_path: Path):
|
|
42
|
+
"""A real library folder (hand-made or pulled) pushes the FLAT overlay keys,
|
|
43
|
+
so an overlay row shadows its base-library path exactly."""
|
|
44
|
+
(tmp_path / "src" / "pkg").mkdir(parents=True)
|
|
45
|
+
(tmp_path / "src" / "pkg" / "x.py").write_text("x = 1\n")
|
|
46
|
+
(tmp_path / "pyproject.toml").write_text("[project]\n")
|
|
47
|
+
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
48
|
+
assert keys == {"pkg/x.py", "pyproject.toml"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_flat_folder_passes_through_unchanged(tmp_path: Path):
|
|
52
|
+
"""Pre-mapping pulls / hand-made flat trees keep working (no src/ → no strip)."""
|
|
53
|
+
(tmp_path / "pkg").mkdir()
|
|
54
|
+
(tmp_path / "pkg" / "x.py").write_text("x = 1\n")
|
|
55
|
+
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
56
|
+
assert keys == {"pkg/x.py"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_pull_push_round_trip_is_lossless(tmp_path: Path):
|
|
60
|
+
files = [
|
|
61
|
+
{"path": "pkg/flow.py", "content": "F = 1\n"},
|
|
62
|
+
{"path": "pkg/data.csv", "content": "a,b\n"},
|
|
63
|
+
{"path": "page/index.html", "content": "<h1/>\n"},
|
|
64
|
+
{"path": "AGENTS.md", "content": "# doc\n"},
|
|
65
|
+
{"path": "pyproject.toml", "content": "[project]\n"},
|
|
66
|
+
]
|
|
67
|
+
paths.write_tree(tmp_path, files)
|
|
68
|
+
got = sorted(paths.read_tree(tmp_path), key=lambda f: f["path"])
|
|
69
|
+
assert got == sorted(files, key=lambda f: f["path"])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_ambiguous_src_and_flat_collision_raises(tmp_path: Path):
|
|
73
|
+
(tmp_path / "src" / "pkg").mkdir(parents=True)
|
|
74
|
+
(tmp_path / "src" / "pkg" / "x.py").write_text("a = 1\n")
|
|
75
|
+
(tmp_path / "pkg").mkdir()
|
|
76
|
+
(tmp_path / "pkg" / "x.py").write_text("b = 2\n")
|
|
77
|
+
try:
|
|
78
|
+
paths.read_tree(tmp_path)
|
|
79
|
+
raise AssertionError("expected ValueError on ambiguous tree")
|
|
80
|
+
except ValueError as e:
|
|
81
|
+
assert "ambiguous" in str(e)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_content_round_trips_lf_exactly(tmp_path: Path):
|
|
85
|
+
original = [{"path": "flows/x.py", "content": "a = 1\nb = 2\n"}]
|
|
86
|
+
paths.write_tree(tmp_path, original)
|
|
87
|
+
# Written verbatim — no CRLF injection, even on Windows (newline="").
|
|
88
|
+
# Runnable Python lands under src/ (the on-disk library layout).
|
|
89
|
+
raw = (tmp_path / "src" / "flows" / "x.py").read_bytes()
|
|
90
|
+
assert b"\r\n" not in raw
|
|
91
|
+
assert raw == b"a = 1\nb = 2\n"
|
|
92
|
+
# And read_tree returns the same LF content under the flat overlay key.
|
|
93
|
+
assert paths.read_tree(tmp_path)[0]["content"] == "a = 1\nb = 2\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_crlf_on_disk_is_normalised_to_lf_on_read(tmp_path: Path):
|
|
97
|
+
# Simulate a file that somehow has CRLF on disk (e.g. a Windows editor).
|
|
98
|
+
(tmp_path / "flows").mkdir()
|
|
99
|
+
(tmp_path / "flows" / "y.py").write_bytes(b"a = 1\r\nb = 2\r\n")
|
|
100
|
+
got = {f["path"]: f["content"] for f in paths.read_tree(tmp_path)}
|
|
101
|
+
assert got["flows/y.py"] == "a = 1\nb = 2\n" # pushed content is LF-normalised
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_local_junk_is_excluded(tmp_path: Path):
|
|
105
|
+
paths.write_tree(tmp_path, [{"path": "flows/x.py", "content": "x=1\n"}])
|
|
106
|
+
(tmp_path / ".git").mkdir()
|
|
107
|
+
(tmp_path / ".git" / "config").write_text("[core]\n")
|
|
108
|
+
(tmp_path / "__pycache__").mkdir()
|
|
109
|
+
(tmp_path / "__pycache__" / "x.pyc").write_bytes(b"\x00")
|
|
110
|
+
(tmp_path / ".stml").mkdir()
|
|
111
|
+
(tmp_path / ".stml" / "app.json").write_text("{}")
|
|
112
|
+
(tmp_path / "src" / "flows" / "x.pyc").write_bytes(b"\x00")
|
|
113
|
+
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
114
|
+
assert keys == {"flows/x.py"} # .git, __pycache__, .stml, *.pyc all dropped
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_key_to_native_and_back():
|
|
118
|
+
native = paths.key_to_native("flows/sub/x.py")
|
|
119
|
+
assert native == Path("flows") / "sub" / "x.py"
|
|
120
|
+
assert paths.to_posix_key(native) == "flows/sub/x.py"
|
stml_cli-0.1.2/stml_cli/paths.py
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"""Cross-platform overlay ↔ local-folder mapping (024 §OS & runtime support).
|
|
2
|
-
|
|
3
|
-
The overlay keys the platform stores are always POSIX (``flows/x.py``). "Runs on
|
|
4
|
-
Windows" is only real if the CLI:
|
|
5
|
-
1. maps a POSIX key to a NATIVE path when writing to disk, and back to a POSIX
|
|
6
|
-
key (never backslashes) when reading — via ``PurePosixPath`` on the wire,
|
|
7
|
-
``Path`` on disk;
|
|
8
|
-
2. reads/writes UTF-8 and preserves ``\n`` exactly, so a pulled file survives
|
|
9
|
-
an untouched push byte-for-byte (no CRLF injection on Windows).
|
|
10
|
-
|
|
11
|
-
These two functions are the whole contract; they are unit-tested in isolation so
|
|
12
|
-
the Windows guarantee doesn't rest on a live round-trip.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
from pathlib import Path, PurePosixPath
|
|
18
|
-
|
|
19
|
-
# Local-only artifacts that must never be pushed into the overlay.
|
|
20
|
-
_EXCLUDE_DIRS = {
|
|
21
|
-
".git", "__pycache__", ".venv", "venv", ".stml",
|
|
22
|
-
"node_modules", ".mypy_cache", ".ruff_cache", ".pytest_cache",
|
|
23
|
-
}
|
|
24
|
-
_EXCLUDE_NAMES = {".DS_Store"}
|
|
25
|
-
_EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def to_posix_key(rel: Path) -> str:
|
|
29
|
-
"""A native relative path → the POSIX overlay key (forward slashes)."""
|
|
30
|
-
return PurePosixPath(*rel.parts).as_posix()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def key_to_native(key: str) -> Path:
|
|
34
|
-
"""A POSIX overlay key → a native relative Path for this OS."""
|
|
35
|
-
return Path(*PurePosixPath(key).parts)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def read_tree(root: Path) -> list[dict]:
|
|
39
|
-
"""Read a local folder into ``[{path, content}]`` with POSIX keys.
|
|
40
|
-
|
|
41
|
-
``read_text`` (universal newlines) collapses any CRLF to ``\n``, so the
|
|
42
|
-
content pushed is always LF-normalised regardless of the author's OS.
|
|
43
|
-
"""
|
|
44
|
-
out: list[dict] = []
|
|
45
|
-
for p in sorted(root.rglob("*")):
|
|
46
|
-
rel = p.relative_to(root)
|
|
47
|
-
if any(part in _EXCLUDE_DIRS for part in rel.parts):
|
|
48
|
-
continue
|
|
49
|
-
if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
|
|
50
|
-
continue
|
|
51
|
-
if not p.is_file():
|
|
52
|
-
continue
|
|
53
|
-
out.append({"path": to_posix_key(rel), "content": p.read_text(encoding="utf-8")})
|
|
54
|
-
return out
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def write_tree(root: Path, files: list[dict]) -> list[str]:
|
|
58
|
-
"""Write ``[{path, content}]`` (POSIX keys) into a local folder.
|
|
59
|
-
|
|
60
|
-
``newline=""`` disables newline translation so ``\n`` is written verbatim —
|
|
61
|
-
a pulled file is not silently rewritten to CRLF on Windows.
|
|
62
|
-
"""
|
|
63
|
-
written: list[str] = []
|
|
64
|
-
for f in files:
|
|
65
|
-
key = f["path"]
|
|
66
|
-
dest = root / key_to_native(key)
|
|
67
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
-
dest.write_text(f.get("content") or "", encoding="utf-8", newline="")
|
|
69
|
-
written.append(key)
|
|
70
|
-
return written
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
"""Cross-platform overlay ↔ folder mapping (024 acceptance: CLI cross-platform).
|
|
2
|
-
|
|
3
|
-
The Windows guarantee ("a pulled tree round-trips; overlay keys stay POSIX and
|
|
4
|
-
content stays UTF-8/\\n") rests entirely on paths.read_tree / write_tree, so
|
|
5
|
-
they are pinned here in isolation — no live backend, no OS assumptions.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from stml_cli import paths
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_keys_are_posix_even_for_nested_paths(tmp_path: Path):
|
|
16
|
-
files = [
|
|
17
|
-
{"path": "flows/greeting.py", "content": "print('hi')\n"},
|
|
18
|
-
{"path": "page/index.html", "content": "<h1>hi</h1>\n"},
|
|
19
|
-
{"path": "pyproject.toml", "content": "[project]\n"},
|
|
20
|
-
]
|
|
21
|
-
paths.write_tree(tmp_path, files)
|
|
22
|
-
# Nested folders are created natively on disk...
|
|
23
|
-
assert (tmp_path / "flows" / "greeting.py").is_file()
|
|
24
|
-
# ...but reading back yields forward-slash POSIX keys, never backslashes.
|
|
25
|
-
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
26
|
-
assert keys == {"flows/greeting.py", "page/index.html", "pyproject.toml"}
|
|
27
|
-
assert not any("\\" in k for k in keys)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_content_round_trips_lf_exactly(tmp_path: Path):
|
|
31
|
-
original = [{"path": "flows/x.py", "content": "a = 1\nb = 2\n"}]
|
|
32
|
-
paths.write_tree(tmp_path, original)
|
|
33
|
-
# Written verbatim — no CRLF injection, even on Windows (newline="").
|
|
34
|
-
raw = (tmp_path / "flows" / "x.py").read_bytes()
|
|
35
|
-
assert b"\r\n" not in raw
|
|
36
|
-
assert raw == b"a = 1\nb = 2\n"
|
|
37
|
-
# And read_tree returns the same LF content.
|
|
38
|
-
assert paths.read_tree(tmp_path)[0]["content"] == "a = 1\nb = 2\n"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_crlf_on_disk_is_normalised_to_lf_on_read(tmp_path: Path):
|
|
42
|
-
# Simulate a file that somehow has CRLF on disk (e.g. a Windows editor).
|
|
43
|
-
(tmp_path / "flows").mkdir()
|
|
44
|
-
(tmp_path / "flows" / "y.py").write_bytes(b"a = 1\r\nb = 2\r\n")
|
|
45
|
-
got = {f["path"]: f["content"] for f in paths.read_tree(tmp_path)}
|
|
46
|
-
assert got["flows/y.py"] == "a = 1\nb = 2\n" # pushed content is LF-normalised
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_local_junk_is_excluded(tmp_path: Path):
|
|
50
|
-
paths.write_tree(tmp_path, [{"path": "flows/x.py", "content": "x=1\n"}])
|
|
51
|
-
(tmp_path / ".git").mkdir()
|
|
52
|
-
(tmp_path / ".git" / "config").write_text("[core]\n")
|
|
53
|
-
(tmp_path / "__pycache__").mkdir()
|
|
54
|
-
(tmp_path / "__pycache__" / "x.pyc").write_bytes(b"\x00")
|
|
55
|
-
(tmp_path / ".stml").mkdir()
|
|
56
|
-
(tmp_path / ".stml" / "app.json").write_text("{}")
|
|
57
|
-
(tmp_path / "flows" / "x.pyc").write_bytes(b"\x00")
|
|
58
|
-
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
59
|
-
assert keys == {"flows/x.py"} # .git, __pycache__, .stml, *.pyc all dropped
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_key_to_native_and_back():
|
|
63
|
-
native = paths.key_to_native("flows/sub/x.py")
|
|
64
|
-
assert native == Path("flows") / "sub" / "x.py"
|
|
65
|
-
assert paths.to_posix_key(native) == "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
|