stml-cli 0.1.6__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.
- {stml_cli-0.1.6 → stml_cli-0.1.9}/PKG-INFO +7 -1
- {stml_cli-0.1.6 → stml_cli-0.1.9}/README.md +6 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/pyproject.toml +5 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/__init__.py +1 -1
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/__main__.py +10 -2
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/commands.py +89 -4
- stml_cli-0.1.9/stml_cli/config.py +110 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/http.py +4 -12
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/packaging.py +9 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/paths.py +12 -2
- stml_cli-0.1.9/stml_cli/update_check.py +115 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/PKG-INFO +7 -1
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/SOURCES.txt +4 -0
- stml_cli-0.1.9/tests/test_init.py +42 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/tests/test_paths.py +12 -0
- stml_cli-0.1.9/tests/test_sessions.py +76 -0
- stml_cli-0.1.9/tests/test_update_check.py +95 -0
- stml_cli-0.1.6/stml_cli/config.py +0 -64
- {stml_cli-0.1.6 → stml_cli-0.1.9}/setup.cfg +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/oauth.py +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/refs.py +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/dependency_links.txt +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/entry_points.txt +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/requires.txt +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/top_level.txt +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/tests/test_disambiguation.py +0 -0
- {stml_cli-0.1.6 → stml_cli-0.1.9}/tests/test_refs.py +0 -0
- {stml_cli-0.1.6 → 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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
@@ -152,6 +162,16 @@ def cmd_pull(args) -> None:
|
|
|
152
162
|
_write_link(dest, args.backend, workspace, app)
|
|
153
163
|
tail = f" (+ {asset_count} asset(s))" if asset_count else ""
|
|
154
164
|
_log(f"✓ Pulled {len(written)} file(s) from {result.get('app', app)} → {dest}{tail}")
|
|
165
|
+
if result.get("generated_manifest"):
|
|
166
|
+
# The app's overlay had no manifest — the backend reconstructed one from
|
|
167
|
+
# the platform's live state (flow rows, dependency pins, provenance).
|
|
168
|
+
_log(" ℹ pyproject.toml was generated from the app's platform state — "
|
|
169
|
+
"review it (name/version/issuer-org) before `stml publish`.")
|
|
170
|
+
if result.get("base_files"):
|
|
171
|
+
# Attached app: files the app never forked come from its base library
|
|
172
|
+
# version, so the folder is the complete effective tree.
|
|
173
|
+
_log(f" ℹ {result['base_files']} file(s) came from the app's base "
|
|
174
|
+
"library (never forked in the app).")
|
|
155
175
|
|
|
156
176
|
|
|
157
177
|
def cmd_push(args) -> None:
|
|
@@ -305,6 +325,7 @@ def cmd_init(args) -> None:
|
|
|
305
325
|
raise SystemExit(f"{target} exists and is not empty")
|
|
306
326
|
target.mkdir(parents=True, exist_ok=True)
|
|
307
327
|
name = args.name or target.name
|
|
328
|
+
pkg_name = name.replace("-", "_")
|
|
308
329
|
(target / "pyproject.toml").write_text(
|
|
309
330
|
f'''[project]
|
|
310
331
|
name = "{name}"
|
|
@@ -316,15 +337,79 @@ dependencies = []
|
|
|
316
337
|
[tool.stml]
|
|
317
338
|
requires-runtime = ">=0.1"
|
|
318
339
|
issuer-org = "" # set to your org slug
|
|
319
|
-
exports-flows = [
|
|
340
|
+
exports-flows = [
|
|
341
|
+
{{ name = "hello", entrypoint = "{pkg_name}.hello:app" }},
|
|
342
|
+
]
|
|
320
343
|
depends-on = []
|
|
344
|
+
|
|
345
|
+
[dependency-groups]
|
|
346
|
+
dev = [
|
|
347
|
+
"pytest",
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
[tool.pytest.ini_options]
|
|
351
|
+
pythonpath = ["src"]
|
|
321
352
|
''',
|
|
322
353
|
encoding="utf-8",
|
|
323
354
|
)
|
|
324
|
-
|
|
325
|
-
|
|
355
|
+
# src layout — the shape `stml pull` writes and deployment-version 2
|
|
356
|
+
# expects (flows ship under src/); keeps init/pull/push round-trips stable.
|
|
357
|
+
pkg = target / "src" / pkg_name
|
|
358
|
+
pkg.mkdir(parents=True)
|
|
326
359
|
(pkg / "__init__.py").write_text('"""Library entry point."""\n', encoding="utf-8")
|
|
360
|
+
(pkg / "hello.py").write_text(
|
|
361
|
+
f'''"""A minimal runnable flow — replace its steps with your own and keep
|
|
362
|
+
`exports-flows` in pyproject.toml pointing at each flow's `module:app`."""
|
|
363
|
+
|
|
364
|
+
from stml.runtime import App
|
|
365
|
+
|
|
366
|
+
app = App("hello")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@app.step(name="Greet", description="Says hello.")
|
|
370
|
+
def greet() -> dict:
|
|
371
|
+
return {{"message": "Hello from {name}!"}}
|
|
372
|
+
''',
|
|
373
|
+
encoding="utf-8",
|
|
374
|
+
)
|
|
327
375
|
(target / "README.md").write_text(f"# {name}\n", encoding="utf-8")
|
|
376
|
+
(target / "AGENTS.md").write_text(
|
|
377
|
+
f'''# `{name}` — usage for the assistant
|
|
378
|
+
|
|
379
|
+
<What this library does, who installs it, and how its flows are meant to be
|
|
380
|
+
run — write it for the AI assistant working on or with this library.>
|
|
381
|
+
|
|
382
|
+
## Layout
|
|
383
|
+
|
|
384
|
+
- `src/{pkg_name}/` — the package. Flows live here and are listed in
|
|
385
|
+
`pyproject.toml` under `[tool.stml] exports-flows` as `module:app`.
|
|
386
|
+
- `tests/` — local-only: `stml push` and `stml publish` both skip it.
|
|
387
|
+
Run with `uv run pytest`.
|
|
388
|
+
''',
|
|
389
|
+
encoding="utf-8",
|
|
390
|
+
)
|
|
391
|
+
(target / ".gitignore").write_text(
|
|
392
|
+
".venv/\n__pycache__/\n*.pyc\n.stml/\n.pytest_cache/\n.ruff_cache/\n",
|
|
393
|
+
encoding="utf-8",
|
|
394
|
+
)
|
|
395
|
+
# tests/ is LOCAL-ONLY by convention: push and publish both skip it
|
|
396
|
+
# (paths._EXCLUDE_DIRS / packaging.EXCLUDE_DIRS), so scaffold it with a
|
|
397
|
+
# stub that says so — runnable code belongs in the package, never here.
|
|
398
|
+
tests_dir = target / "tests"
|
|
399
|
+
tests_dir.mkdir()
|
|
400
|
+
(tests_dir / "__init__.py").write_text("", encoding="utf-8")
|
|
401
|
+
(tests_dir / "test_smoke.py").write_text(
|
|
402
|
+
f'''"""tests/ is local-only: `stml push` and `stml publish` both skip this
|
|
403
|
+
folder — put runnable code in the package, never in here."""
|
|
404
|
+
|
|
405
|
+
import {pkg_name}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_import():
|
|
409
|
+
assert {pkg_name}.__doc__
|
|
410
|
+
''',
|
|
411
|
+
encoding="utf-8",
|
|
412
|
+
)
|
|
328
413
|
_log(f"✓ Scaffolded {target}")
|
|
329
414
|
|
|
330
415
|
|
|
@@ -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
|
-
|
|
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()}"
|
|
@@ -47,15 +47,23 @@ def _is_packaging_path(rel: str) -> bool:
|
|
|
47
47
|
return True
|
|
48
48
|
return False
|
|
49
49
|
|
|
50
|
-
# Local-only artifacts that must never be pushed into the overlay.
|
|
50
|
+
# Local-only artifacts that must never be pushed into the overlay. `tests`
|
|
51
|
+
# matches packaging.EXCLUDE_DIRS: unit tests are test-only, never shipped.
|
|
51
52
|
_EXCLUDE_DIRS = {
|
|
52
|
-
".git", "__pycache__", ".venv", "venv", ".stml",
|
|
53
|
+
".git", "__pycache__", ".venv", "venv", ".stml", "tests",
|
|
53
54
|
"node_modules", ".mypy_cache", ".ruff_cache", ".pytest_cache",
|
|
54
55
|
}
|
|
55
56
|
_EXCLUDE_NAMES = {".DS_Store"}
|
|
56
57
|
_EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
57
58
|
|
|
58
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
|
+
|
|
59
67
|
def to_posix_key(rel: Path) -> str:
|
|
60
68
|
"""A native relative path → the POSIX overlay key (forward slashes)."""
|
|
61
69
|
return PurePosixPath(*rel.parts).as_posix()
|
|
@@ -86,6 +94,8 @@ def read_tree(root: Path) -> list[dict]:
|
|
|
86
94
|
continue
|
|
87
95
|
if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
|
|
88
96
|
continue
|
|
97
|
+
if _is_env_file(p.name):
|
|
98
|
+
continue
|
|
89
99
|
if not p.is_file():
|
|
90
100
|
continue
|
|
91
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.
|
|
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
|
|
@@ -16,6 +17,9 @@ stml_cli.egg-info/entry_points.txt
|
|
|
16
17
|
stml_cli.egg-info/requires.txt
|
|
17
18
|
stml_cli.egg-info/top_level.txt
|
|
18
19
|
tests/test_disambiguation.py
|
|
20
|
+
tests/test_init.py
|
|
19
21
|
tests/test_paths.py
|
|
20
22
|
tests/test_refs.py
|
|
23
|
+
tests/test_sessions.py
|
|
24
|
+
tests/test_update_check.py
|
|
21
25
|
tests/test_whoami_expiry.py
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
from stml_cli import commands, paths
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_init_scaffolds_src_layout_lib(tmp_path: Path):
|
|
8
|
+
target = tmp_path / "my-lib"
|
|
9
|
+
commands.cmd_init(SimpleNamespace(dir=str(target), name=None))
|
|
10
|
+
|
|
11
|
+
# src layout with a runnable example flow, exported in pyproject.
|
|
12
|
+
assert (target / "src" / "my_lib" / "__init__.py").is_file()
|
|
13
|
+
assert (target / "src" / "my_lib" / "hello.py").is_file()
|
|
14
|
+
pyproject = (target / "pyproject.toml").read_text()
|
|
15
|
+
assert 'entrypoint = "my_lib.hello:app"' in pyproject
|
|
16
|
+
# pytest works out of the box: dev dep + src on the test pythonpath.
|
|
17
|
+
assert "pytest" in pyproject and 'pythonpath = ["src"]' in pyproject
|
|
18
|
+
assert (target / "AGENTS.md").is_file()
|
|
19
|
+
assert (target / ".gitignore").is_file()
|
|
20
|
+
|
|
21
|
+
# tests/ is scaffolded but local-only: the stub imports the package name
|
|
22
|
+
# and push (read_tree) must not pick the folder up.
|
|
23
|
+
stub = (target / "tests" / "test_smoke.py").read_text()
|
|
24
|
+
assert "import my_lib" in stub
|
|
25
|
+
keys = {f["path"] for f in paths.read_tree(target)}
|
|
26
|
+
assert "my_lib/hello.py" in keys # src/ stripped to flat overlay keys
|
|
27
|
+
assert not any(k.startswith("tests/") for k in keys)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_init_scaffolded_smoke_test_passes(tmp_path: Path):
|
|
31
|
+
"""The generated tests/test_smoke.py must actually pass in the scaffold."""
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
target = tmp_path / "demo-lib"
|
|
36
|
+
commands.cmd_init(SimpleNamespace(dir=str(target), name=None))
|
|
37
|
+
res = subprocess.run(
|
|
38
|
+
[sys.executable, "-m", "pytest", "tests/", "-q", "-p", "no:cacheprovider",
|
|
39
|
+
"--rootdir", str(target), "-o", 'pythonpath=src'],
|
|
40
|
+
cwd=target, capture_output=True, text=True,
|
|
41
|
+
)
|
|
42
|
+
assert res.returncode == 0, res.stdout + res.stderr
|
|
@@ -114,6 +114,18 @@ 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_tests_dir_is_never_pushed(tmp_path: Path):
|
|
118
|
+
"""tests/ is test-only and never shipped — push skips it like publish does
|
|
119
|
+
(packaging.EXCLUDE_DIRS), both top-level and under src/."""
|
|
120
|
+
paths.write_tree(tmp_path, [{"path": "flows/x.py", "content": "x=1\n"}])
|
|
121
|
+
(tmp_path / "tests").mkdir()
|
|
122
|
+
(tmp_path / "tests" / "test_x.py").write_text("def test_x(): pass\n")
|
|
123
|
+
(tmp_path / "src" / "tests").mkdir()
|
|
124
|
+
(tmp_path / "src" / "tests" / "test_y.py").write_text("def test_y(): pass\n")
|
|
125
|
+
keys = {f["path"] for f in paths.read_tree(tmp_path)}
|
|
126
|
+
assert keys == {"flows/x.py"}
|
|
127
|
+
|
|
128
|
+
|
|
117
129
|
def test_assets_dir_is_never_pushed(tmp_path: Path):
|
|
118
130
|
"""Top-level assets/ is bucket-backed binary media (written by pull) — push
|
|
119
131
|
must skip it (a .xlsx would crash read_text) but publish ships it from disk."""
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|