stml-cli 0.1.8__tar.gz → 0.1.9__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 (28) hide show
  1. {stml_cli-0.1.8 → stml_cli-0.1.9}/PKG-INFO +7 -1
  2. {stml_cli-0.1.8 → stml_cli-0.1.9}/README.md +6 -0
  3. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/__init__.py +1 -1
  4. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/__main__.py +10 -2
  5. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/commands.py +11 -1
  6. stml_cli-0.1.9/stml_cli/config.py +110 -0
  7. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/http.py +4 -12
  8. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/packaging.py +9 -0
  9. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/paths.py +9 -0
  10. stml_cli-0.1.9/stml_cli/update_check.py +115 -0
  11. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/PKG-INFO +7 -1
  12. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/SOURCES.txt +3 -0
  13. stml_cli-0.1.9/tests/test_sessions.py +76 -0
  14. stml_cli-0.1.9/tests/test_update_check.py +95 -0
  15. stml_cli-0.1.8/stml_cli/config.py +0 -64
  16. {stml_cli-0.1.8 → stml_cli-0.1.9}/pyproject.toml +0 -0
  17. {stml_cli-0.1.8 → stml_cli-0.1.9}/setup.cfg +0 -0
  18. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/oauth.py +0 -0
  19. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/refs.py +0 -0
  20. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/dependency_links.txt +0 -0
  21. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/entry_points.txt +0 -0
  22. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/requires.txt +0 -0
  23. {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/top_level.txt +0 -0
  24. {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_disambiguation.py +0 -0
  25. {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_init.py +0 -0
  26. {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_paths.py +0 -0
  27. {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_refs.py +0 -0
  28. {stml_cli-0.1.8 → stml_cli-0.1.9}/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.8
3
+ Version: 0.1.9
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
@@ -35,6 +35,12 @@ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-s
35
35
  Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
36
36
  platform-specific beyond the filesystem and HTTPS.
37
37
 
38
+ **Staying current**: after a command finishes, the CLI prints a one-line
39
+ notice when a newer release exists on PyPI (checked at most once a day,
40
+ cached, quiet when stderr isn't a terminal, never blocks or fails a command).
41
+ Update with `pipx upgrade stml-cli`. Set `STML4_NO_UPDATE_CHECK=1` to turn
42
+ the check off entirely.
43
+
38
44
  ## Quickstart
39
45
 
40
46
  ```sh
@@ -25,6 +25,12 @@ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-s
25
25
  Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
26
26
  platform-specific beyond the filesystem and HTTPS.
27
27
 
28
+ **Staying current**: after a command finishes, the CLI prints a one-line
29
+ notice when a newer release exists on PyPI (checked at most once a day,
30
+ cached, quiet when stderr isn't a terminal, never blocks or fails a command).
31
+ Update with `pipx upgrade stml-cli`. Set `STML4_NO_UPDATE_CHECK=1` to turn
32
+ the check off entirely.
33
+
28
34
  ## Quickstart
29
35
 
30
36
  ```sh
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
 
12
- __version__ = "0.1.8"
12
+ __version__ = "0.1.9"
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
@@ -1,6 +1,6 @@
1
1
  """`stml` CLI entry point — argparse dispatch (024 §CLI surface).
2
2
 
3
- stml login | logout | whoami
3
+ stml login | logout | whoami | token
4
4
  stml list [--workspace <ws>]
5
5
  stml pull <app-url> [<dir>]
6
6
  stml push [<app-url>] <dir>
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from . import __version__, commands, http
22
+ from . import __version__, commands, http, update_check
23
23
  from .config import DEFAULT_BACKEND
24
24
 
25
25
 
@@ -32,6 +32,10 @@ def build_parser() -> argparse.ArgumentParser:
32
32
  sub.add_parser("login", help="Authenticate in the browser and cache a session").set_defaults(func=commands.cmd_login)
33
33
  sub.add_parser("logout", help="Delete the cached session").set_defaults(func=commands.cmd_logout)
34
34
  sub.add_parser("whoami", help="Show the resolved user + backend").set_defaults(func=commands.cmd_whoami)
35
+ sub.add_parser(
36
+ "token",
37
+ help="Print a fresh access token for the backend (for scripts: STML4_TOKEN=$(stml token))",
38
+ ).set_defaults(func=commands.cmd_token)
35
39
 
36
40
  ls = sub.add_parser(
37
41
  "list",
@@ -146,6 +150,10 @@ def main(argv: list[str] | None = None) -> int:
146
150
  print(e.code, file=sys.stderr)
147
151
  return 1
148
152
  return e.code or 0
153
+ finally:
154
+ # Post-command so the (at most daily) PyPI round-trip lands at exit,
155
+ # never ahead of a command's own output. Silent in scripts/CI.
156
+ update_check.maybe_notify()
149
157
  return 0
150
158
 
151
159
 
@@ -47,7 +47,17 @@ def cmd_login(args) -> None:
47
47
 
48
48
 
49
49
  def cmd_logout(args) -> None:
50
- _log("✓ Logged out" if config.clear_session() else "Not logged in.")
50
+ if config.clear_session(args.backend):
51
+ _log(f"✓ Logged out of {args.backend}")
52
+ else:
53
+ _log(f"Not logged in to {args.backend}.")
54
+
55
+
56
+ def cmd_token(args) -> None:
57
+ # stdout carries ONLY the token, so `STML4_TOKEN=$(stml token)` (and the
58
+ # monorepo publish wrappers) can substitute it cleanly; any chatter a
59
+ # refresh produces goes to stderr.
60
+ print(http.resolve_token(args.backend))
51
61
 
52
62
 
53
63
  def cmd_whoami(args) -> None:
@@ -0,0 +1,110 @@
1
+ """Per-OS config dir + the cached login sessions (024 §OS & runtime support §4).
2
+
3
+ One session file PER BACKEND (``session-<host[-port]>.json``), so signing in to
4
+ a second backend (a dev stack next to prod, say) never clobbers the first —
5
+ a partner with a single backend still sees exactly one file. The refresh token
6
+ lives here between commands; access tokens are minted per call and never
7
+ persisted. Files are written user-only where the OS supports it (``chmod`` is
8
+ a no-op on Windows, which relies on the per-user profile dir).
9
+
10
+ Installs older than 0.1.9 kept a single ``session.json``; it is adopted into
11
+ the per-backend file on first read against the backend it was minted for.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import urllib.parse
21
+ from pathlib import Path
22
+
23
+ DEFAULT_BACKEND = os.environ.get("STML4_BACKEND", "https://api.stml.io")
24
+
25
+ _LEGACY_SESSION_FILE = "session.json"
26
+
27
+
28
+ def config_dir() -> Path:
29
+ """OS-appropriate config directory for stml (created on demand)."""
30
+ if sys.platform == "darwin":
31
+ base = Path.home() / "Library" / "Application Support"
32
+ elif os.name == "nt":
33
+ base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
34
+ else: # Linux / other POSIX
35
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
36
+ return base / "stml"
37
+
38
+
39
+ def _session_filename(backend: str) -> str:
40
+ """``session-<host[-port]>.json`` for the backend URL — the per-backend key."""
41
+ u = urllib.parse.urlsplit(backend)
42
+ host = (u.hostname or "").lower()
43
+ if not host: # not URL-shaped; sanitize the whole string rather than guess
44
+ return f"session-{re.sub(r'[^A-Za-z0-9._-]+', '-', backend.strip().lower())}.json"
45
+ return f"session-{host}-{u.port}.json" if u.port else f"session-{host}.json"
46
+
47
+
48
+ def _session_path(backend: str) -> Path:
49
+ return config_dir() / _session_filename(backend)
50
+
51
+
52
+ def _read_json(p: Path) -> dict | None:
53
+ try:
54
+ return json.loads(p.read_text(encoding="utf-8"))
55
+ except (json.JSONDecodeError, OSError):
56
+ return None
57
+
58
+
59
+ def read_session(backend: str) -> dict | None:
60
+ """Return the cached session for this backend, or None if not logged in.
61
+
62
+ Falls back to the pre-0.1.9 single ``session.json`` when it was minted for
63
+ this same backend, migrating it to the per-backend file as a side effect.
64
+ A legacy file for a DIFFERENT backend is left alone — it gets adopted when
65
+ a command targets that backend.
66
+ """
67
+ p = _session_path(backend)
68
+ if p.exists():
69
+ return _read_json(p)
70
+
71
+ legacy = config_dir() / _LEGACY_SESSION_FILE
72
+ if legacy.exists():
73
+ sess = _read_json(legacy)
74
+ if sess and (sess.get("backend") or "").rstrip("/") == backend.rstrip("/"):
75
+ write_session(sess)
76
+ legacy.unlink(missing_ok=True)
77
+ return sess
78
+ return None
79
+
80
+
81
+ def write_session(session: dict) -> None:
82
+ """Persist the session dict (keyed by its ``backend``) with user-only perms."""
83
+ backend = session.get("backend") or ""
84
+ if not backend:
85
+ raise ValueError("session is missing 'backend' — cannot pick a session file")
86
+ d = config_dir()
87
+ d.mkdir(parents=True, exist_ok=True)
88
+ p = _session_path(backend)
89
+ p.write_text(json.dumps(session, indent=2), encoding="utf-8")
90
+ try: # best-effort; POSIX only
91
+ os.chmod(p, 0o600)
92
+ except OSError: # pragma: no cover
93
+ pass
94
+
95
+
96
+ def clear_session(backend: str) -> bool:
97
+ """Delete the cached session for this backend (the legacy single file too,
98
+ when it belongs to it). Returns True if anything was removed."""
99
+ removed = False
100
+ p = _session_path(backend)
101
+ if p.exists():
102
+ p.unlink()
103
+ removed = True
104
+ legacy = config_dir() / _LEGACY_SESSION_FILE
105
+ if legacy.exists():
106
+ sess = _read_json(legacy)
107
+ if sess and (sess.get("backend") or "").rstrip("/") == backend.rstrip("/"):
108
+ legacy.unlink()
109
+ removed = True
110
+ return removed
@@ -140,19 +140,11 @@ def resolve_token(backend: str) -> str:
140
140
  if override:
141
141
  return override.strip()
142
142
 
143
- sess = config.read_session()
143
+ # Sessions are cached per backend (config._session_filename), so the token
144
+ # we read here was minted by exactly the backend this command targets.
145
+ sess = config.read_session(backend)
144
146
  if not sess or not sess.get("refresh_token"):
145
- raise SystemExit("Not logged in. Run: stml login (or set STML4_TOKEN)")
146
-
147
- # The cached session is minted by ONE backend; sending its token elsewhere
148
- # yields confusing server errors (and whoami would claim a login that isn't
149
- # valid there). Call the mismatch out instead.
150
- sess_backend = (sess.get("backend") or "").rstrip("/")
151
- if sess_backend and sess_backend != backend.rstrip("/"):
152
- raise SystemExit(
153
- f"Logged in to {sess_backend}, but this command targets {backend}.\n"
154
- f"Run: stml --backend {backend} login (a session is valid for one backend)"
155
- )
147
+ raise SystemExit(f"Not logged in to {backend}. Run: stml login (or set STML4_TOKEN)")
156
148
 
157
149
  now = int(time.time())
158
150
  if not sess.get("expires_at") or int(sess["expires_at"]) - now < 60:
@@ -30,6 +30,13 @@ EXCLUDE_FILES = {"uv.lock", "poetry.lock", "Pipfile.lock", ".DS_Store"}
30
30
  EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
31
31
 
32
32
 
33
+ def _is_env_file(name: str) -> bool:
34
+ """SECURITY: `.env` / `.env.<anything>` hold local credentials and must
35
+ never be packaged (`.env.example` is the secret-free template and may
36
+ ship). Matches paths._is_env_file (the push path)."""
37
+ return (name == ".env" or name.startswith(".env.")) and name != ".env.example"
38
+
39
+
33
40
  def build_tarball(lib_dir: Path) -> bytes:
34
41
  buf = io.BytesIO()
35
42
  with tarfile.open(fileobj=buf, mode="w:gz") as tar:
@@ -41,6 +48,8 @@ def build_tarball(lib_dir: Path) -> bytes:
41
48
  continue
42
49
  if path.name in EXCLUDE_FILES or path.suffix in EXCLUDE_SUFFIXES:
43
50
  continue
51
+ if _is_env_file(path.name):
52
+ continue
44
53
  if not path.is_file():
45
54
  continue
46
55
  arcname = f"{lib_dir.name}/{path.relative_to(lib_dir).as_posix()}"
@@ -57,6 +57,13 @@ _EXCLUDE_NAMES = {".DS_Store"}
57
57
  _EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
58
58
 
59
59
 
60
+ def _is_env_file(name: str) -> bool:
61
+ """SECURITY: `.env` / `.env.<anything>` hold local credentials and must
62
+ never be pushed (`.env.example` is the secret-free template and may ship).
63
+ Matches packaging._is_env_file (the publish path)."""
64
+ return (name == ".env" or name.startswith(".env.")) and name != ".env.example"
65
+
66
+
60
67
  def to_posix_key(rel: Path) -> str:
61
68
  """A native relative path → the POSIX overlay key (forward slashes)."""
62
69
  return PurePosixPath(*rel.parts).as_posix()
@@ -87,6 +94,8 @@ def read_tree(root: Path) -> list[dict]:
87
94
  continue
88
95
  if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
89
96
  continue
97
+ if _is_env_file(p.name):
98
+ continue
90
99
  if not p.is_file():
91
100
  continue
92
101
  # Top-level assets/ is binary, bucket-backed media (written by pull's
@@ -0,0 +1,115 @@
1
+ """New-release notice (024 §Distributing the partner CLI).
2
+
3
+ After a command finishes, print a one-line stderr notice when a newer
4
+ ``stml-cli`` release exists on PyPI — the way ``gh`` does it. Designed to
5
+ never get in the way:
6
+
7
+ - PyPI is asked at most once per day; the answer is cached in the config dir
8
+ (``update-check.json``) and reused between runs.
9
+ - The fetch has a short timeout and every failure is swallowed (a run must
10
+ never fail or hang because PyPI is unreachable).
11
+ - Quiet when stderr is not a TTY (scripts, CI, cron) and when
12
+ ``STML4_NO_UPDATE_CHECK`` is set.
13
+ - Only plain ``X.Y.Z`` versions are compared; anything else (pre-releases,
14
+ odd tags) never triggers a notice.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import time
24
+ import urllib.request
25
+
26
+ from . import __version__, config
27
+
28
+ PYPI_JSON_URL = "https://pypi.org/pypi/stml-cli/json"
29
+ CHECK_INTERVAL_S = 24 * 3600
30
+ FETCH_TIMEOUT_S = 1.5
31
+
32
+ _STATE_FILE = "update-check.json"
33
+ _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
34
+
35
+
36
+ def _parse(version: str) -> tuple[int, int, int] | None:
37
+ m = _VERSION_RE.match((version or "").strip())
38
+ return (int(m[1]), int(m[2]), int(m[3])) if m else None
39
+
40
+
41
+ def is_newer(latest: str, current: str) -> bool:
42
+ """True only when both parse as plain X.Y.Z and latest > current."""
43
+ lat, cur = _parse(latest), _parse(current)
44
+ return bool(lat and cur and lat > cur)
45
+
46
+
47
+ def _state_path():
48
+ return config.config_dir() / _STATE_FILE
49
+
50
+
51
+ def _read_state() -> dict:
52
+ try:
53
+ state = json.loads(_state_path().read_text(encoding="utf-8"))
54
+ return state if isinstance(state, dict) else {}
55
+ except (OSError, json.JSONDecodeError, ValueError):
56
+ return {}
57
+
58
+
59
+ def _write_state(state: dict) -> None:
60
+ try:
61
+ d = config.config_dir()
62
+ d.mkdir(parents=True, exist_ok=True)
63
+ _state_path().write_text(json.dumps(state), encoding="utf-8")
64
+ except OSError: # read-only home, etc. — the notice is best-effort anyway
65
+ pass
66
+
67
+
68
+ def fetch_latest() -> str | None:
69
+ """The newest version on PyPI, or None on any failure."""
70
+ try:
71
+ req = urllib.request.Request(
72
+ PYPI_JSON_URL, headers={"User-Agent": f"stml-cli/{__version__}"}
73
+ )
74
+ with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_S) as resp:
75
+ info = json.loads(resp.read().decode("utf-8")).get("info") or {}
76
+ return info.get("version") or None
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def _interactive() -> bool:
82
+ """Notice-worthy terminal? Scripts and CI (non-TTY stderr) stay quiet."""
83
+ try:
84
+ return sys.stderr.isatty()
85
+ except Exception:
86
+ return False
87
+
88
+
89
+ def maybe_notify(now: float | None = None) -> None:
90
+ """Refresh the cached latest version (at most daily) and print the notice.
91
+
92
+ Called once per invocation, after the command ran — so the one-time fetch
93
+ latency lands at exit, never before a command's output.
94
+ """
95
+ if os.environ.get("STML4_NO_UPDATE_CHECK"):
96
+ return
97
+ if not _interactive():
98
+ return
99
+
100
+ now = time.time() if now is None else now
101
+ state = _read_state()
102
+ if now - float(state.get("checked_at") or 0) >= CHECK_INTERVAL_S:
103
+ latest = fetch_latest()
104
+ # Stamp checked_at even on failure so an offline machine retries
105
+ # daily, not on every command.
106
+ state = {"checked_at": now, "latest": latest or state.get("latest")}
107
+ _write_state(state)
108
+
109
+ latest = state.get("latest")
110
+ if latest and is_newer(latest, __version__):
111
+ print(
112
+ f"\nA newer stml is available: {__version__} → {latest} — "
113
+ f"update with: pipx upgrade stml-cli",
114
+ file=sys.stderr,
115
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stml-cli
3
- Version: 0.1.8
3
+ Version: 0.1.9
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
@@ -35,6 +35,12 @@ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-s
35
35
  Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
36
36
  platform-specific beyond the filesystem and HTTPS.
37
37
 
38
+ **Staying current**: after a command finishes, the CLI prints a one-line
39
+ notice when a newer release exists on PyPI (checked at most once a day,
40
+ cached, quiet when stderr isn't a terminal, never blocks or fails a command).
41
+ Update with `pipx upgrade stml-cli`. Set `STML4_NO_UPDATE_CHECK=1` to turn
42
+ the check off entirely.
43
+
38
44
  ## Quickstart
39
45
 
40
46
  ```sh
@@ -9,6 +9,7 @@ stml_cli/oauth.py
9
9
  stml_cli/packaging.py
10
10
  stml_cli/paths.py
11
11
  stml_cli/refs.py
12
+ stml_cli/update_check.py
12
13
  stml_cli.egg-info/PKG-INFO
13
14
  stml_cli.egg-info/SOURCES.txt
14
15
  stml_cli.egg-info/dependency_links.txt
@@ -19,4 +20,6 @@ tests/test_disambiguation.py
19
20
  tests/test_init.py
20
21
  tests/test_paths.py
21
22
  tests/test_refs.py
23
+ tests/test_sessions.py
24
+ tests/test_update_check.py
22
25
  tests/test_whoami_expiry.py
@@ -0,0 +1,76 @@
1
+ """Per-backend session store: file keying, isolation, legacy adoption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from stml_cli import config
10
+
11
+
12
+ @pytest.fixture
13
+ def cfg_dir(tmp_path, monkeypatch):
14
+ monkeypatch.setattr(config, "config_dir", lambda: tmp_path)
15
+ return tmp_path
16
+
17
+
18
+ def _session(backend: str, tag: str) -> dict:
19
+ return {
20
+ "backend": backend,
21
+ "access_token": f"at-{tag}",
22
+ "refresh_token": f"rt-{tag}",
23
+ "expires_at": 2_000_000_000,
24
+ }
25
+
26
+
27
+ def test_session_filename_per_backend():
28
+ assert config._session_filename("https://api.stml.io") == "session-api.stml.io.json"
29
+ assert config._session_filename("http://localhost:8010") == "session-localhost-8010.json"
30
+ # trailing slash and case don't change the key
31
+ assert config._session_filename("https://API.stml.io/") == "session-api.stml.io.json"
32
+
33
+
34
+ def test_sessions_are_isolated_per_backend(cfg_dir):
35
+ config.write_session(_session("http://localhost:8010", "dev"))
36
+ config.write_session(_session("https://api.stml.io", "prod"))
37
+
38
+ assert config.read_session("http://localhost:8010")["access_token"] == "at-dev"
39
+ assert config.read_session("https://api.stml.io")["access_token"] == "at-prod"
40
+ # logging out of one backend leaves the other untouched
41
+ assert config.clear_session("http://localhost:8010") is True
42
+ assert config.read_session("http://localhost:8010") is None
43
+ assert config.read_session("https://api.stml.io")["access_token"] == "at-prod"
44
+
45
+
46
+ def test_write_session_requires_backend(cfg_dir):
47
+ with pytest.raises(ValueError):
48
+ config.write_session({"access_token": "x", "refresh_token": "y"})
49
+
50
+
51
+ def test_legacy_session_adopted_for_matching_backend(cfg_dir):
52
+ legacy = cfg_dir / "session.json"
53
+ legacy.write_text(json.dumps(_session("http://localhost:8010", "old")))
54
+
55
+ sess = config.read_session("http://localhost:8010")
56
+ assert sess and sess["access_token"] == "at-old"
57
+ # migrated: per-backend file exists, legacy single file is gone
58
+ assert (cfg_dir / "session-localhost-8010.json").exists()
59
+ assert not legacy.exists()
60
+
61
+
62
+ def test_legacy_session_left_alone_for_other_backend(cfg_dir):
63
+ legacy = cfg_dir / "session.json"
64
+ legacy.write_text(json.dumps(_session("https://api.stml.io", "prod")))
65
+
66
+ assert config.read_session("http://localhost:8010") is None
67
+ assert legacy.exists() # still owned by prod; adopted when prod is targeted
68
+
69
+
70
+ def test_clear_session_removes_matching_legacy_file(cfg_dir):
71
+ legacy = cfg_dir / "session.json"
72
+ legacy.write_text(json.dumps(_session("https://api.stml.io", "prod")))
73
+
74
+ assert config.clear_session("https://api.stml.io") is True
75
+ assert not legacy.exists()
76
+ assert config.clear_session("https://api.stml.io") is False
@@ -0,0 +1,95 @@
1
+ """Update notice: version comparison, daily cache, and notify gating."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from stml_cli import config, update_check
10
+
11
+
12
+ @pytest.fixture
13
+ def cfg_dir(tmp_path, monkeypatch):
14
+ monkeypatch.setattr(config, "config_dir", lambda: tmp_path)
15
+ return tmp_path
16
+
17
+
18
+ @pytest.fixture
19
+ def tty_stderr(monkeypatch):
20
+ """maybe_notify is TTY-gated; pretend we're interactive. (Patching the
21
+ gate, not sys.stderr — capsys swaps the stream object mid-test.)"""
22
+ monkeypatch.setattr(update_check, "_interactive", lambda: True)
23
+
24
+
25
+ def test_is_newer_plain_triples():
26
+ assert update_check.is_newer("0.2.0", "0.1.9")
27
+ assert update_check.is_newer("1.0.0", "0.9.9")
28
+ assert not update_check.is_newer("0.1.9", "0.1.9")
29
+ assert not update_check.is_newer("0.1.8", "0.1.9")
30
+
31
+
32
+ def test_is_newer_rejects_non_plain_versions():
33
+ # Pre-releases / odd tags never trigger a notice.
34
+ assert not update_check.is_newer("0.2.0rc1", "0.1.9")
35
+ assert not update_check.is_newer("banana", "0.1.9")
36
+ assert not update_check.is_newer("0.2", "0.1.9")
37
+ assert not update_check.is_newer("0.2.0", "0.1.9.dev0")
38
+
39
+
40
+ def test_notifies_from_fresh_cache_without_fetch(cfg_dir, tty_stderr, monkeypatch, capsys):
41
+ (cfg_dir / "update-check.json").write_text(
42
+ json.dumps({"checked_at": 1_000_000, "latest": "99.0.0"})
43
+ )
44
+ monkeypatch.setattr(
45
+ update_check, "fetch_latest", lambda: pytest.fail("fresh cache must not fetch")
46
+ )
47
+ update_check.maybe_notify(now=1_000_000 + 60)
48
+ assert "99.0.0" in capsys.readouterr().err
49
+
50
+
51
+ def test_stale_cache_fetches_and_persists(cfg_dir, tty_stderr, monkeypatch, capsys):
52
+ monkeypatch.setattr(update_check, "fetch_latest", lambda: "99.0.0")
53
+ update_check.maybe_notify(now=5_000_000)
54
+ assert "99.0.0" in capsys.readouterr().err
55
+ state = json.loads((cfg_dir / "update-check.json").read_text())
56
+ assert state == {"checked_at": 5_000_000, "latest": "99.0.0"}
57
+
58
+
59
+ def test_failed_fetch_stamps_checked_at_and_keeps_old_latest(
60
+ cfg_dir, tty_stderr, monkeypatch, capsys
61
+ ):
62
+ (cfg_dir / "update-check.json").write_text(
63
+ json.dumps({"checked_at": 0, "latest": "99.0.0"})
64
+ )
65
+ monkeypatch.setattr(update_check, "fetch_latest", lambda: None)
66
+ update_check.maybe_notify(now=5_000_000)
67
+ assert "99.0.0" in capsys.readouterr().err # old answer still notifies
68
+ state = json.loads((cfg_dir / "update-check.json").read_text())
69
+ assert state["checked_at"] == 5_000_000 # offline machines retry daily, not per-command
70
+ assert state["latest"] == "99.0.0"
71
+
72
+
73
+ def test_no_notice_when_up_to_date(cfg_dir, tty_stderr, monkeypatch, capsys):
74
+ monkeypatch.setattr(update_check, "fetch_latest", lambda: update_check.__version__)
75
+ update_check.maybe_notify(now=5_000_000)
76
+ assert capsys.readouterr().err == ""
77
+
78
+
79
+ def test_silent_when_disabled_by_env(cfg_dir, tty_stderr, monkeypatch, capsys):
80
+ monkeypatch.setenv("STML4_NO_UPDATE_CHECK", "1")
81
+ monkeypatch.setattr(
82
+ update_check, "fetch_latest", lambda: pytest.fail("disabled must not fetch")
83
+ )
84
+ update_check.maybe_notify(now=5_000_000)
85
+ assert capsys.readouterr().err == ""
86
+ assert not (cfg_dir / "update-check.json").exists()
87
+
88
+
89
+ def test_silent_when_stderr_is_not_a_tty(cfg_dir, monkeypatch, capsys):
90
+ monkeypatch.setattr(update_check, "_interactive", lambda: False)
91
+ monkeypatch.setattr(
92
+ update_check, "fetch_latest", lambda: pytest.fail("non-tty must not fetch")
93
+ )
94
+ update_check.maybe_notify(now=5_000_000)
95
+ assert capsys.readouterr().err == ""
@@ -1,64 +0,0 @@
1
- """Per-OS config dir + the cached login session (024 §OS & runtime support §4).
2
-
3
- The refresh token lives here between commands; access tokens are minted per
4
- call and never persisted. The file is written user-only where the OS supports
5
- it (``chmod`` is a no-op on Windows, which relies on the per-user profile dir).
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- import os
12
- import sys
13
- from pathlib import Path
14
-
15
- DEFAULT_BACKEND = os.environ.get("STML4_BACKEND", "https://api.stml.io")
16
-
17
- _SESSION_FILE = "session.json"
18
-
19
-
20
- def config_dir() -> Path:
21
- """OS-appropriate config directory for stml (created on demand)."""
22
- if sys.platform == "darwin":
23
- base = Path.home() / "Library" / "Application Support"
24
- elif os.name == "nt":
25
- base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
26
- else: # Linux / other POSIX
27
- base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
28
- return base / "stml"
29
-
30
-
31
- def _session_path() -> Path:
32
- return config_dir() / _SESSION_FILE
33
-
34
-
35
- def read_session() -> dict | None:
36
- """Return the cached session dict, or None if not logged in."""
37
- p = _session_path()
38
- if not p.exists():
39
- return None
40
- try:
41
- return json.loads(p.read_text(encoding="utf-8"))
42
- except (json.JSONDecodeError, OSError):
43
- return None
44
-
45
-
46
- def write_session(session: dict) -> None:
47
- """Persist the session dict with user-only perms where supported."""
48
- d = config_dir()
49
- d.mkdir(parents=True, exist_ok=True)
50
- p = _session_path()
51
- p.write_text(json.dumps(session, indent=2), encoding="utf-8")
52
- try: # best-effort; POSIX only
53
- os.chmod(p, 0o600)
54
- except OSError: # pragma: no cover
55
- pass
56
-
57
-
58
- def clear_session() -> bool:
59
- """Delete the cached session. Returns True if one was present."""
60
- p = _session_path()
61
- if p.exists():
62
- p.unlink()
63
- return True
64
- return False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes