generic-ml-cache-cli 0.4.0__tar.gz → 0.6.0__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.
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/PKG-INFO +14 -4
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/README.md +12 -2
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/pyproject.toml +2 -2
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/cli.py +290 -28
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/config.py +2 -3
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/conftest.py +7 -1
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_cli.py +7 -4
- generic_ml_cache_cli-0.6.0/tests/test_encrypted_run.py +91 -0
- generic_ml_cache_cli-0.6.0/tests/test_encryption_cli.py +101 -0
- generic_ml_cache_cli-0.6.0/tests/test_session_cli.py +97 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/.gitignore +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/LICENSE +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/NOTICE +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/__init__.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/__main__.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/fake_client.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_config.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_discover.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_effort.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_interrupt.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_models.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_passthrough.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_robustness.py +0 -0
- {generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/tests/test_stdin_delivery.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: generic-ml-cache-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Terminal UI for generic-ml-cache: the gmlcache command. A thin inbound driver over generic-ml-cache-core -- reads config, provides the data source, maps commands onto the core library.
|
|
5
5
|
Project-URL: Homepage, https://github.com/danielslobozian/generic-ml-cache
|
|
6
6
|
Project-URL: Repository, https://github.com/danielslobozian/generic-ml-cache
|
|
@@ -24,7 +24,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
24
24
|
Classifier: Topic :: Utilities
|
|
25
25
|
Requires-Python: >=3.9
|
|
26
26
|
Requires-Dist: argcomplete<4,>=3
|
|
27
|
-
Requires-Dist: generic-ml-cache-core>=0.
|
|
27
|
+
Requires-Dist: generic-ml-cache-core>=0.6.0
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: coverage>=7; extra == 'dev'
|
|
30
30
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
@@ -33,7 +33,12 @@ Requires-Dist: ruff>=0.15; extra == 'dev'
|
|
|
33
33
|
Requires-Dist: vulture>=2; extra == 'dev'
|
|
34
34
|
Description-Content-Type: text/markdown
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
<p align="center">
|
|
37
|
+
<picture>
|
|
38
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/danielslobozian/generic-ml-cache/main/docs/images/gmlcache-lockup-dark.png">
|
|
39
|
+
<img src="https://raw.githubusercontent.com/danielslobozian/generic-ml-cache/main/docs/images/gmlcache-lockup.png" alt="gmlcache" width="300">
|
|
40
|
+
</picture>
|
|
41
|
+
</p>
|
|
37
42
|
|
|
38
43
|
#### Detached ML Execution Cache — the terminal client
|
|
39
44
|
|
|
@@ -43,6 +48,11 @@ Description-Content-Type: text/markdown
|
|
|
43
48
|
`gmlcache` runs, records, and replays detached ML workloads — record a real client (or
|
|
44
49
|
API) call once, replay it forever by its content key, offline and byte-for-byte.
|
|
45
50
|
|
|
51
|
+
> **Single-user, local — not a gateway.** gmlcache runs on your machine, as you, across the
|
|
52
|
+
> subscriptions and APIs you already hold. It is **not** a multi-user router and **not** a way
|
|
53
|
+
> to share one subscription — see
|
|
54
|
+
> [Positioning](https://github.com/danielslobozian/generic-ml-cache/blob/main/docs/design/positioning.md).
|
|
55
|
+
|
|
46
56
|
<p align="center">
|
|
47
57
|
<img src="https://raw.githubusercontent.com/danielslobozian/generic-ml-cache/main/docs/images/gmlcache-demo.gif" alt="gmlcache: a miss records the real client call; the same command again is served instantly from cache, byte-identical" width="760">
|
|
48
58
|
</p>
|
|
@@ -80,7 +90,7 @@ gmlcache doctor | models | status | init # environment & con
|
|
|
80
90
|
`gmlcache` is the terminal client — one inbound driver over the engine. The whole cache
|
|
81
91
|
logic and every adapter live in
|
|
82
92
|
[`generic-ml-cache-core`](https://github.com/danielslobozian/generic-ml-cache/tree/main/packages/core),
|
|
83
|
-
a **stateless
|
|
93
|
+
a **stateless** library. To embed the cache in your own application
|
|
84
94
|
instead of driving it from a terminal, depend on the core and inject your own data
|
|
85
95
|
source — you never reimplement the adapters.
|
|
86
96
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/danielslobozian/generic-ml-cache/main/docs/images/gmlcache-lockup-dark.png">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/danielslobozian/generic-ml-cache/main/docs/images/gmlcache-lockup.png" alt="gmlcache" width="300">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
8
|
#### Detached ML Execution Cache — the terminal client
|
|
4
9
|
|
|
@@ -8,6 +13,11 @@
|
|
|
8
13
|
`gmlcache` runs, records, and replays detached ML workloads — record a real client (or
|
|
9
14
|
API) call once, replay it forever by its content key, offline and byte-for-byte.
|
|
10
15
|
|
|
16
|
+
> **Single-user, local — not a gateway.** gmlcache runs on your machine, as you, across the
|
|
17
|
+
> subscriptions and APIs you already hold. It is **not** a multi-user router and **not** a way
|
|
18
|
+
> to share one subscription — see
|
|
19
|
+
> [Positioning](https://github.com/danielslobozian/generic-ml-cache/blob/main/docs/design/positioning.md).
|
|
20
|
+
|
|
11
21
|
<p align="center">
|
|
12
22
|
<img src="https://raw.githubusercontent.com/danielslobozian/generic-ml-cache/main/docs/images/gmlcache-demo.gif" alt="gmlcache: a miss records the real client call; the same command again is served instantly from cache, byte-identical" width="760">
|
|
13
23
|
</p>
|
|
@@ -45,7 +55,7 @@ gmlcache doctor | models | status | init # environment & con
|
|
|
45
55
|
`gmlcache` is the terminal client — one inbound driver over the engine. The whole cache
|
|
46
56
|
logic and every adapter live in
|
|
47
57
|
[`generic-ml-cache-core`](https://github.com/danielslobozian/generic-ml-cache/tree/main/packages/core),
|
|
48
|
-
a **stateless
|
|
58
|
+
a **stateless** library. To embed the cache in your own application
|
|
49
59
|
instead of driving it from a terminal, depend on the core and inject your own data
|
|
50
60
|
source — you never reimplement the adapters.
|
|
51
61
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "generic-ml-cache-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Terminal UI for generic-ml-cache: the gmlcache command. A thin inbound driver over generic-ml-cache-core -- reads config, provides the data source, maps commands onto the core library."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -25,7 +25,7 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python :: 3.13",
|
|
26
26
|
"Topic :: Utilities",
|
|
27
27
|
]
|
|
28
|
-
dependencies = ["generic-ml-cache-core>=0.
|
|
28
|
+
dependencies = ["generic-ml-cache-core>=0.6.0", "argcomplete>=3,<4"]
|
|
29
29
|
|
|
30
30
|
[project.urls]
|
|
31
31
|
Homepage = "https://github.com/danielslobozian/generic-ml-cache"
|
|
@@ -31,6 +31,11 @@ except ImportError: # completion is a convenience; never let its absence break
|
|
|
31
31
|
argcomplete = None
|
|
32
32
|
|
|
33
33
|
from generic_ml_cache_core.adapter.inbound.composition import build_use_cases
|
|
34
|
+
from generic_ml_cache_core.adapter.out.crypto.filesystem_encryption_manifest_store import (
|
|
35
|
+
FilesystemEncryptionManifestStore,
|
|
36
|
+
)
|
|
37
|
+
from generic_ml_cache_core.adapter.out.crypto.store_encryptor import StoreEncryptor
|
|
38
|
+
from generic_ml_cache_core.adapter.out.persistence.sqlite_store_lock import SqliteStoreLock
|
|
34
39
|
from generic_ml_cache_core.adapter.out.client.registry import registered_names
|
|
35
40
|
from generic_ml_cache_core.application.domain.model.execution.artifact import (
|
|
36
41
|
INPUT_ARTIFACT_TYPES,
|
|
@@ -44,7 +49,16 @@ from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_
|
|
|
44
49
|
RunManagedLocalExecutionCommand,
|
|
45
50
|
)
|
|
46
51
|
from generic_ml_cache_core.application.port.out.base import ClientAdapter
|
|
47
|
-
from generic_ml_cache_core.common.errors import
|
|
52
|
+
from generic_ml_cache_core.common.errors import (
|
|
53
|
+
CacheError,
|
|
54
|
+
CacheMiss,
|
|
55
|
+
ConfigError,
|
|
56
|
+
EncryptionStateError,
|
|
57
|
+
EncryptionTokenRequired,
|
|
58
|
+
RunInterrupted,
|
|
59
|
+
StoreLocked,
|
|
60
|
+
WrongEncryptionToken,
|
|
61
|
+
)
|
|
48
62
|
|
|
49
63
|
from . import __version__, config
|
|
50
64
|
|
|
@@ -181,14 +195,15 @@ def _cmd_run(args: argparse.Namespace) -> int:
|
|
|
181
195
|
persistence_depth=persistence_depth,
|
|
182
196
|
record_on_error=args.record_on_error,
|
|
183
197
|
tags=list(getattr(args, "tag", None) or []),
|
|
198
|
+
session_id=_resolve_session(args),
|
|
184
199
|
)
|
|
185
200
|
|
|
186
201
|
def executable_override(client: str):
|
|
187
202
|
return config.executable_for(file_cfg, client, flag=args.executable)
|
|
188
203
|
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
token = _resolve_token(args)
|
|
191
205
|
try:
|
|
206
|
+
wired = build_use_cases(store_root, executable_override, timeout, encryption_token=token)
|
|
192
207
|
execution = wired.run_managed.execute(command)
|
|
193
208
|
except RunInterrupted as exc:
|
|
194
209
|
# A requested stop, not a failure: nothing was recorded. Exit 130 is the
|
|
@@ -206,6 +221,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
|
|
|
206
221
|
except CacheMiss as exc:
|
|
207
222
|
print(f"gmlc: {exc}", file=sys.stderr)
|
|
208
223
|
return 3
|
|
224
|
+
except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
|
|
225
|
+
print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
|
|
226
|
+
return 4
|
|
209
227
|
except CacheError as exc:
|
|
210
228
|
print(f"gmlc: {exc}", file=sys.stderr)
|
|
211
229
|
return 4
|
|
@@ -428,6 +446,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
428
446
|
|
|
429
447
|
path = config.resolve_config_path()
|
|
430
448
|
loaded = file_cfg.source is not None
|
|
449
|
+
encryption = FilesystemEncryptionManifestStore(Path(str(settings["store"][0]))).state().value
|
|
431
450
|
|
|
432
451
|
if args.json:
|
|
433
452
|
import json
|
|
@@ -437,6 +456,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
437
456
|
{
|
|
438
457
|
"config_file": str(path),
|
|
439
458
|
"loaded": loaded,
|
|
459
|
+
"encryption": encryption,
|
|
440
460
|
"settings": {k: {"value": v[0], "source": v[1]} for k, v in settings.items()},
|
|
441
461
|
"executables": dict(file_cfg.executables),
|
|
442
462
|
},
|
|
@@ -446,6 +466,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
446
466
|
return 0
|
|
447
467
|
|
|
448
468
|
print(f"config file : {path} ({'loaded' if loaded else 'not present'})")
|
|
469
|
+
print(f"encryption : {encryption}")
|
|
449
470
|
print("effective settings (no run flags applied):")
|
|
450
471
|
for key in ("mode", "persist", "store", "timeout", "trust_scan", "max_size"):
|
|
451
472
|
value, source = settings[key]
|
|
@@ -702,24 +723,30 @@ def _cmd_export(args: argparse.Namespace) -> int:
|
|
|
702
723
|
print(f"gmlc: {exc}", file=sys.stderr)
|
|
703
724
|
return 4
|
|
704
725
|
|
|
705
|
-
wired = build_use_cases(Path(str(settings["store"][0])))
|
|
706
726
|
include = set(getattr(args, "tag", None) or [])
|
|
707
727
|
exclude = set(getattr(args, "exclude_tag", None) or [])
|
|
708
728
|
|
|
709
729
|
lines = []
|
|
710
730
|
skipped_no_input = 0
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
731
|
+
try:
|
|
732
|
+
wired = build_use_cases(
|
|
733
|
+
Path(str(settings["store"][0])), encryption_token=_resolve_token(args)
|
|
734
|
+
)
|
|
735
|
+
for summary in wired.repository.current_execution_summaries():
|
|
736
|
+
tags = wired.repository.tags_for(summary.execution_key)
|
|
737
|
+
if include and not include & set(tags):
|
|
738
|
+
continue
|
|
739
|
+
if exclude and exclude & set(tags):
|
|
740
|
+
continue
|
|
741
|
+
execution = wired.repository.find_current(summary.execution_key)
|
|
742
|
+
# Only DATASET-depth entries carry the input side of the corpus.
|
|
743
|
+
if execution is None or not execution.input_persisted:
|
|
744
|
+
skipped_no_input += 1
|
|
745
|
+
continue
|
|
746
|
+
lines.append(json.dumps(_export_record(summary, execution, tags, wired.blob_store)))
|
|
747
|
+
except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
|
|
748
|
+
print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
|
|
749
|
+
return 4
|
|
723
750
|
|
|
724
751
|
if args.output:
|
|
725
752
|
Path(args.output).write_text("".join(line + "\n" for line in lines), encoding="utf-8")
|
|
@@ -738,6 +765,184 @@ def _cmd_export(args: argparse.Namespace) -> int:
|
|
|
738
765
|
return 0
|
|
739
766
|
|
|
740
767
|
|
|
768
|
+
# -- encryption -------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _resolve_token(args: argparse.Namespace) -> Optional[str]:
|
|
772
|
+
"""The encryption token for this call: the --token flag, else GMLCACHE_TOKEN.
|
|
773
|
+
A token is a secret, so it is never read from the config file."""
|
|
774
|
+
flag = getattr(args, "token", None)
|
|
775
|
+
return flag if flag else (os.environ.get("GMLCACHE_TOKEN") or None)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _load_cipher():
|
|
779
|
+
"""Build the cipher, with a friendly error if the optional extra is missing."""
|
|
780
|
+
try:
|
|
781
|
+
from generic_ml_cache_core.adapter.out.crypto.aesgcm_cipher import AesGcmCipher
|
|
782
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
783
|
+
raise SystemExit(
|
|
784
|
+
"error: encryption needs an optional dependency — install with "
|
|
785
|
+
'`pip install "generic-ml-cache-core[encryption]"`'
|
|
786
|
+
) from exc
|
|
787
|
+
return AesGcmCipher()
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _store_encryptor(store_root: Path, cipher=None) -> StoreEncryptor:
|
|
791
|
+
return StoreEncryptor(
|
|
792
|
+
store_root,
|
|
793
|
+
FilesystemEncryptionManifestStore(store_root),
|
|
794
|
+
SqliteStoreLock(store_root),
|
|
795
|
+
cipher,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _store_root() -> Optional[Path]:
|
|
800
|
+
try:
|
|
801
|
+
return Path(str(config.resolve_settings(config.load())["store"][0]))
|
|
802
|
+
except ConfigError as exc:
|
|
803
|
+
print(f"gmlc: {exc}", file=sys.stderr)
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _cmd_encrypt(args: argparse.Namespace) -> int:
|
|
808
|
+
store_root = _store_root()
|
|
809
|
+
if store_root is None:
|
|
810
|
+
return 4
|
|
811
|
+
cipher = _load_cipher()
|
|
812
|
+
token = cipher.generate_token()
|
|
813
|
+
try:
|
|
814
|
+
_store_encryptor(store_root, cipher).enable(token)
|
|
815
|
+
except (EncryptionStateError, StoreLocked) as exc:
|
|
816
|
+
print(f"gmlc: {exc}", file=sys.stderr)
|
|
817
|
+
return 4
|
|
818
|
+
print("encryption enabled. Save this token — it is shown once and cannot be recovered:")
|
|
819
|
+
print(f"\n {token}\n")
|
|
820
|
+
print("Pass it with --token or GMLCACHE_TOKEN to read or write this store.")
|
|
821
|
+
return 0
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _cmd_decrypt(args: argparse.Namespace) -> int:
|
|
825
|
+
store_root = _store_root()
|
|
826
|
+
if store_root is None:
|
|
827
|
+
return 4
|
|
828
|
+
token = _resolve_token(args)
|
|
829
|
+
if not token:
|
|
830
|
+
print("gmlc: provide the token with --token or GMLCACHE_TOKEN", file=sys.stderr)
|
|
831
|
+
return 4
|
|
832
|
+
try:
|
|
833
|
+
_store_encryptor(store_root, _load_cipher()).disable(token)
|
|
834
|
+
except (WrongEncryptionToken, EncryptionStateError, StoreLocked) as exc:
|
|
835
|
+
print(f"gmlc: {exc}", file=sys.stderr)
|
|
836
|
+
return 4
|
|
837
|
+
print("encryption disabled. The store is now public; no token is needed.")
|
|
838
|
+
return 0
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _cmd_rotate(args: argparse.Namespace) -> int:
|
|
842
|
+
store_root = _store_root()
|
|
843
|
+
if store_root is None:
|
|
844
|
+
return 4
|
|
845
|
+
old_token = _resolve_token(args)
|
|
846
|
+
if not old_token:
|
|
847
|
+
print("gmlc: provide the current token with --token or GMLCACHE_TOKEN", file=sys.stderr)
|
|
848
|
+
return 4
|
|
849
|
+
cipher = _load_cipher()
|
|
850
|
+
new_token = cipher.generate_token()
|
|
851
|
+
try:
|
|
852
|
+
_store_encryptor(store_root, cipher).rotate(old_token, new_token)
|
|
853
|
+
except (WrongEncryptionToken, EncryptionStateError, StoreLocked) as exc:
|
|
854
|
+
print(f"gmlc: {exc}", file=sys.stderr)
|
|
855
|
+
return 4
|
|
856
|
+
print("token rotated. Save the new token — it is shown once:")
|
|
857
|
+
print(f"\n {new_token}\n")
|
|
858
|
+
return 0
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _cmd_invalidate(args: argparse.Namespace) -> int:
|
|
862
|
+
store_root = _store_root()
|
|
863
|
+
if store_root is None:
|
|
864
|
+
return 4
|
|
865
|
+
if not args.yes:
|
|
866
|
+
print(
|
|
867
|
+
"gmlc: this permanently wipes the cache (crypto-shred) and cannot be undone. "
|
|
868
|
+
"Re-run with --yes to confirm.",
|
|
869
|
+
file=sys.stderr,
|
|
870
|
+
)
|
|
871
|
+
return 4
|
|
872
|
+
try:
|
|
873
|
+
_store_encryptor(store_root).invalidate() # no token needed
|
|
874
|
+
except StoreLocked as exc:
|
|
875
|
+
print(f"gmlc: {exc}", file=sys.stderr)
|
|
876
|
+
return 4
|
|
877
|
+
print("store invalidated: the cache was wiped and is now empty and public.")
|
|
878
|
+
return 0
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# -- sessions ---------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _resolve_session(args: argparse.Namespace) -> Optional[str]:
|
|
885
|
+
"""The session id for this run: the --session flag, else GMLCACHE_SESSION. A session
|
|
886
|
+
groups a workflow's calls; it is journal metadata, never part of the cache key."""
|
|
887
|
+
flag = getattr(args, "session", None)
|
|
888
|
+
return flag if flag else (os.environ.get("GMLCACHE_SESSION") or None)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _cmd_session_start(args: argparse.Namespace) -> int:
|
|
892
|
+
import secrets
|
|
893
|
+
|
|
894
|
+
# Print only the id, so it is scriptable: SESSION=$(gmlcache session start)
|
|
895
|
+
print(secrets.token_hex(8))
|
|
896
|
+
return 0
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
#: events where a real client call ran (vs. HIT, which replayed, or an offline MISS).
|
|
900
|
+
_EXECUTED_EVENTS = {"record", "run", "would_hit", "would_miss"}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _cmd_session_report(args: argparse.Namespace) -> int:
|
|
904
|
+
store_root = _store_root()
|
|
905
|
+
if store_root is None:
|
|
906
|
+
return 4
|
|
907
|
+
counts = build_use_cases(store_root).metrics.session_event_counts(args.session_id)
|
|
908
|
+
invocations = sum(counts.values())
|
|
909
|
+
executions = sum(n for event, n in counts.items() if event in _EXECUTED_EVENTS)
|
|
910
|
+
hits = counts.get("hit", 0)
|
|
911
|
+
|
|
912
|
+
if args.json:
|
|
913
|
+
import json
|
|
914
|
+
|
|
915
|
+
print(
|
|
916
|
+
json.dumps(
|
|
917
|
+
{
|
|
918
|
+
"session": args.session_id,
|
|
919
|
+
"invocations": invocations,
|
|
920
|
+
"executions": executions,
|
|
921
|
+
"hits": hits,
|
|
922
|
+
"events": counts,
|
|
923
|
+
},
|
|
924
|
+
indent=2,
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
return 0
|
|
928
|
+
|
|
929
|
+
if invocations == 0:
|
|
930
|
+
print(f"no events recorded for session {args.session_id!r}")
|
|
931
|
+
return 0
|
|
932
|
+
print(f"session : {args.session_id}")
|
|
933
|
+
print(f"invocations : {invocations}")
|
|
934
|
+
print(f"executions : {executions} (real client calls)")
|
|
935
|
+
print(f"hits : {hits} (served from cache)")
|
|
936
|
+
breakdown = ", ".join(f"{event}={counts[event]}" for event in sorted(counts))
|
|
937
|
+
print(f"events : {breakdown}")
|
|
938
|
+
return 0
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _cmd_session(args: argparse.Namespace) -> int:
|
|
942
|
+
print("usage: gmlcache session start | gmlcache session report <id>", file=sys.stderr)
|
|
943
|
+
return 2
|
|
944
|
+
|
|
945
|
+
|
|
741
946
|
def _use_color() -> bool:
|
|
742
947
|
"""Colour only when writing to a real terminal and NO_COLOR is unset, so piped
|
|
743
948
|
or redirected output never carries escape codes (the conventional contract)."""
|
|
@@ -765,34 +970,48 @@ def _paint(text: str, *codes: str) -> str:
|
|
|
765
970
|
|
|
766
971
|
|
|
767
972
|
def render_banner(color: bool = False) -> str:
|
|
768
|
-
"""The boxed gmlcache banner
|
|
769
|
-
|
|
973
|
+
"""The boxed gmlcache banner: the cache mark (four hollow bars; the top one is
|
|
974
|
+
the accent 'hit') beside the title, version, and tagline. Width is derived from
|
|
975
|
+
the content so everything stays aligned. ``color`` adds ANSI; off yields plain."""
|
|
770
976
|
title = "gmlcache"
|
|
771
977
|
ver = __version__
|
|
772
|
-
tag = "record · replay · check ·
|
|
978
|
+
tag = "record · replay · check · sessions · encryption"
|
|
979
|
+
|
|
980
|
+
# The mark: four hollow bars -- thin walls (▏ ▕) around a double-line body (═),
|
|
981
|
+
# widths echoing the logo. The first bar is the accent ("hit"); the rest are dim.
|
|
982
|
+
bars = ["▏" + "═" * n + "▕" for n in (11, 7, 10, 5)]
|
|
983
|
+
bar_w = max(len(b) for b in bars)
|
|
773
984
|
|
|
774
985
|
if color:
|
|
775
|
-
rule = _TEAL
|
|
776
|
-
|
|
777
|
-
vers = _TEAL_BRIGHT # bright-teal version
|
|
778
|
-
sub = _GREY # dim-grey tagline
|
|
779
|
-
off = _RESET
|
|
986
|
+
rule, name, vers, sub, off = _TEAL, _BOLD, _TEAL_BRIGHT, _GREY, _RESET
|
|
987
|
+
bar_colors = [_GREEN, _GREY, _GREY, _GREY]
|
|
780
988
|
else:
|
|
781
989
|
rule = name = vers = sub = off = ""
|
|
990
|
+
bar_colors = ["", "", "", ""]
|
|
782
991
|
|
|
992
|
+
left_pad, gap = " ", " "
|
|
993
|
+
texts = ["", tag, "", ""] # the tagline sits on the second bar row
|
|
994
|
+
|
|
995
|
+
body_w = max(len(left_pad) + bar_w + len(gap) + len(t) for t in texts)
|
|
783
996
|
left_top = f"─ {title} "
|
|
784
997
|
right_top = f" {ver} ─"
|
|
785
|
-
inner = max(len(left_top) + 6 + len(right_top),
|
|
998
|
+
inner = max(len(left_top) + 6 + len(right_top), body_w + 1)
|
|
786
999
|
top_dashes = inner - len(left_top) - len(right_top)
|
|
787
|
-
pad_right = inner - 2 - len(tag)
|
|
788
1000
|
|
|
789
1001
|
top = (
|
|
790
1002
|
f"{rule}┌─ {off}{name}{title}{off}"
|
|
791
1003
|
f"{rule} {'─' * top_dashes} {off}{vers}{ver}{off}{rule} ─┐{off}"
|
|
792
1004
|
)
|
|
793
|
-
|
|
1005
|
+
rows = []
|
|
1006
|
+
for bar, bar_color, text in zip(bars, bar_colors, texts):
|
|
1007
|
+
bar_cell = f"{bar_color}{bar}{off}" + " " * (bar_w - len(bar))
|
|
1008
|
+
used = len(left_pad) + bar_w + len(gap) + len(text)
|
|
1009
|
+
rows.append(
|
|
1010
|
+
f"{rule}│{off}{left_pad}{bar_cell}{gap}{sub}{text}{off}"
|
|
1011
|
+
f"{' ' * (inner - used)}{rule}│{off}"
|
|
1012
|
+
)
|
|
794
1013
|
bot = f"{rule}└{'─' * inner}┘{off}"
|
|
795
|
-
return "\n".join([top,
|
|
1014
|
+
return "\n".join([top, *rows, bot])
|
|
796
1015
|
|
|
797
1016
|
|
|
798
1017
|
class _BannerParser(argparse.ArgumentParser):
|
|
@@ -914,6 +1133,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
914
1133
|
help="also cache a call that fails (non-zero exit); default is to store only successes",
|
|
915
1134
|
)
|
|
916
1135
|
run.add_argument("--executable", help="override the client executable (the seam)")
|
|
1136
|
+
run.add_argument(
|
|
1137
|
+
"--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
|
|
1138
|
+
)
|
|
1139
|
+
run.add_argument(
|
|
1140
|
+
"--session", help="group this run under a session id (or set GMLCACHE_SESSION)"
|
|
1141
|
+
)
|
|
917
1142
|
run.add_argument(
|
|
918
1143
|
"--timeout", type=float, default=None, help="seconds before the real call is killed"
|
|
919
1144
|
)
|
|
@@ -1071,8 +1296,45 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1071
1296
|
metavar="FILE",
|
|
1072
1297
|
help="write JSONL to FILE instead of stdout (a per-record summary still goes to stderr)",
|
|
1073
1298
|
)
|
|
1299
|
+
exportp.add_argument(
|
|
1300
|
+
"--token", help="encryption token if the store is encrypted (or set GMLCACHE_TOKEN)"
|
|
1301
|
+
)
|
|
1074
1302
|
exportp.set_defaults(func=_cmd_export)
|
|
1075
1303
|
|
|
1304
|
+
encryptp = sub.add_parser(
|
|
1305
|
+
"encrypt", help="enable at-rest encryption of the store (generates and shows a token)"
|
|
1306
|
+
)
|
|
1307
|
+
encryptp.set_defaults(func=_cmd_encrypt)
|
|
1308
|
+
|
|
1309
|
+
decryptp = sub.add_parser(
|
|
1310
|
+
"decrypt", help="disable encryption (decrypts the store back to plaintext; needs the token)"
|
|
1311
|
+
)
|
|
1312
|
+
decryptp.add_argument("--token", help="the encryption token (or set GMLCACHE_TOKEN)")
|
|
1313
|
+
decryptp.set_defaults(func=_cmd_decrypt)
|
|
1314
|
+
|
|
1315
|
+
rotatep = sub.add_parser(
|
|
1316
|
+
"rotate", help="rotate the encryption token (needs the current token; shows the new one)"
|
|
1317
|
+
)
|
|
1318
|
+
rotatep.add_argument("--token", help="the current encryption token (or set GMLCACHE_TOKEN)")
|
|
1319
|
+
rotatep.set_defaults(func=_cmd_rotate)
|
|
1320
|
+
|
|
1321
|
+
invalidatep = sub.add_parser(
|
|
1322
|
+
"invalidate",
|
|
1323
|
+
help="wipe the cache (crypto-shred) — the escape when the token is lost. Needs --yes.",
|
|
1324
|
+
)
|
|
1325
|
+
invalidatep.add_argument("--yes", action="store_true", help="confirm the irreversible wipe")
|
|
1326
|
+
invalidatep.set_defaults(func=_cmd_invalidate)
|
|
1327
|
+
|
|
1328
|
+
session = sub.add_parser("session", help="group a workflow's runs under a session id")
|
|
1329
|
+
session_sub = session.add_subparsers(dest="session_command")
|
|
1330
|
+
session_start = session_sub.add_parser("start", help="generate a new session id and print it")
|
|
1331
|
+
session_start.set_defaults(func=_cmd_session_start)
|
|
1332
|
+
session_report = session_sub.add_parser("report", help="summarise a session's activity")
|
|
1333
|
+
session_report.add_argument("session_id", help="the session id to report on")
|
|
1334
|
+
session_report.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
1335
|
+
session_report.set_defaults(func=_cmd_session_report)
|
|
1336
|
+
session.set_defaults(func=_cmd_session)
|
|
1337
|
+
|
|
1076
1338
|
init = sub.add_parser(
|
|
1077
1339
|
"init",
|
|
1078
1340
|
help="create the config file in the default location (if absent), then show the store",
|
{generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/config.py
RENAMED
|
@@ -13,9 +13,8 @@ Three rules keep this predictable:
|
|
|
13
13
|
config file, the built-in default. The ``store`` location is the exception -- config file or
|
|
14
14
|
built-in default only, with **no flag and no environment** -- because where the
|
|
15
15
|
stored executions live is the cache's own concern, not a per-call knob.
|
|
16
|
-
* **
|
|
17
|
-
|
|
18
|
-
needed on any supported Python.
|
|
16
|
+
* **Plain INI.** The format is INI (stdlib :mod:`configparser`) and the per-user
|
|
17
|
+
location is resolved inline.
|
|
19
18
|
|
|
20
19
|
Location (override everything with ``GMLCACHE_CONFIG=/path/to/file``):
|
|
21
20
|
|
|
@@ -118,7 +118,13 @@ def _isolate_config(monkeypatch, tmp_path):
|
|
|
118
118
|
monkeypatch.setenv("GMLCACHE_CONFIG", str(tmp_path / "no-such-config.ini"))
|
|
119
119
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data"))
|
|
120
120
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "localappdata"))
|
|
121
|
-
for var in (
|
|
121
|
+
for var in (
|
|
122
|
+
"GMLCACHE_MODE",
|
|
123
|
+
"GMLCACHE_PERSIST",
|
|
124
|
+
"GMLCACHE_TIMEOUT",
|
|
125
|
+
"GMLCACHE_TOKEN",
|
|
126
|
+
"GMLCACHE_SESSION",
|
|
127
|
+
):
|
|
122
128
|
monkeypatch.delenv(var, raising=False)
|
|
123
129
|
|
|
124
130
|
|
|
@@ -134,8 +134,11 @@ def test_run_rejects_retired_location_flags(tmp_path):
|
|
|
134
134
|
def test_render_banner_lines_align():
|
|
135
135
|
from generic_ml_cache_cli.cli import render_banner
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
lines = render_banner(color=False).splitlines()
|
|
138
|
+
widths = {len(line) for line in lines}
|
|
139
|
+
assert len(widths) == 1 # every box line (top, four mark rows, bottom) is one width
|
|
140
|
+
assert len(lines) == 6 # the mark adds four bar rows inside the box
|
|
141
|
+
assert "═" in render_banner(color=False) # the hollow mark renders
|
|
139
142
|
|
|
140
143
|
|
|
141
144
|
def test_render_banner_color_is_opt_in():
|
|
@@ -162,7 +165,7 @@ def test_bare_invocation_prints_help_not_an_error(capsys):
|
|
|
162
165
|
out = capsys.readouterr().out
|
|
163
166
|
assert rc == 0
|
|
164
167
|
assert "gmlcache" in out
|
|
165
|
-
assert "record · replay · check ·
|
|
168
|
+
assert "record · replay · check · sessions · encryption" in out
|
|
166
169
|
assert "usage:" in out
|
|
167
170
|
|
|
168
171
|
|
|
@@ -176,7 +179,7 @@ def test_help_flag_shows_the_banner(capsys):
|
|
|
176
179
|
with pytest.raises(SystemExit) as excinfo:
|
|
177
180
|
main(["-h"])
|
|
178
181
|
assert excinfo.value.code == 0
|
|
179
|
-
assert "record · replay · check ·
|
|
182
|
+
assert "record · replay · check · sessions · encryption" in capsys.readouterr().out
|
|
180
183
|
|
|
181
184
|
|
|
182
185
|
# --- list (grouped by client/model) ---------------------------------------
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""End-to-end: a managed run against an encrypted store (via build_use_cases)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
pytest.importorskip("cryptography")
|
|
10
|
+
|
|
11
|
+
from generic_ml_cache_core.adapter.inbound.composition import build_use_cases # noqa: E402
|
|
12
|
+
from generic_ml_cache_core.adapter.out.crypto.aesgcm_cipher import AesGcmCipher # noqa: E402
|
|
13
|
+
from generic_ml_cache_core.adapter.out.crypto.filesystem_encryption_manifest_store import ( # noqa: E402
|
|
14
|
+
FilesystemEncryptionManifestStore,
|
|
15
|
+
)
|
|
16
|
+
from generic_ml_cache_core.application.domain.model.execution.artifact import ( # noqa: E402
|
|
17
|
+
ArtifactType,
|
|
18
|
+
)
|
|
19
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_state import ( # noqa: E402
|
|
20
|
+
ExecutionState,
|
|
21
|
+
)
|
|
22
|
+
from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import ( # noqa: E402
|
|
23
|
+
RunManagedLocalExecutionCommand,
|
|
24
|
+
)
|
|
25
|
+
from generic_ml_cache_core.common.errors import ( # noqa: E402
|
|
26
|
+
EncryptionTokenRequired,
|
|
27
|
+
WrongEncryptionToken,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_MARKER = "ENCRYPTME-distinctive-123"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _encrypt_store(store_root, token):
|
|
34
|
+
manifest, _ = AesGcmCipher().create_envelope(token)
|
|
35
|
+
FilesystemEncryptionManifestStore(store_root).save(manifest)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _command():
|
|
39
|
+
return RunManagedLocalExecutionCommand(
|
|
40
|
+
client="fake", model="m1", effort="high", context="", prompt=f"STDOUT {_MARKER}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _blob_bytes(store_root):
|
|
45
|
+
return b"".join(p.read_bytes() for p in (store_root / "blobs").rglob("*") if p.is_file())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_encrypted_run_stores_ciphertext_and_replays_with_token(tmp_path):
|
|
49
|
+
store = tmp_path / "store"
|
|
50
|
+
token = AesGcmCipher().generate_token()
|
|
51
|
+
_encrypt_store(store, token)
|
|
52
|
+
|
|
53
|
+
execution = build_use_cases(store, encryption_token=token).run_managed.execute(_command())
|
|
54
|
+
assert execution.execution_state is ExecutionState.SUCCESS
|
|
55
|
+
|
|
56
|
+
# the marker must NOT appear in the persisted blobs — they are ciphertext
|
|
57
|
+
assert _MARKER.encode() not in _blob_bytes(store)
|
|
58
|
+
|
|
59
|
+
# replay (a cache hit) with the token decrypts the output correctly
|
|
60
|
+
served = build_use_cases(store, encryption_token=token).run_managed.execute(_command())
|
|
61
|
+
stdout = next(a.content for a in served.artifacts if a.artifact_type is ArtifactType.STDOUT)
|
|
62
|
+
assert _MARKER.encode() in stdout
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_public_store_keeps_plaintext_and_ignores_a_token(tmp_path):
|
|
66
|
+
store = tmp_path / "store" # no manifest -> public
|
|
67
|
+
execution = build_use_cases(store, encryption_token="ignored").run_managed.execute(_command())
|
|
68
|
+
assert execution.execution_state is ExecutionState.SUCCESS
|
|
69
|
+
assert _MARKER.encode() in _blob_bytes(store) # plaintext on disk
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_encrypted_store_without_token_blocks_content_but_not_metadata(tmp_path):
|
|
73
|
+
store = tmp_path / "store"
|
|
74
|
+
token = AesGcmCipher().generate_token()
|
|
75
|
+
_encrypt_store(store, token)
|
|
76
|
+
recorded = build_use_cases(store, encryption_token=token).run_managed.execute(_command())
|
|
77
|
+
key = recorded.call_identity.generate_key()
|
|
78
|
+
|
|
79
|
+
wired = build_use_cases(store) # no token
|
|
80
|
+
# metadata is still readable (it is not encrypted) ...
|
|
81
|
+
assert wired.repository.find_current(key) is not None
|
|
82
|
+
# ... but serving the hit must hydrate the blob, which needs the token.
|
|
83
|
+
with pytest.raises(EncryptionTokenRequired):
|
|
84
|
+
wired.run_managed.execute(_command())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_wrong_token_is_rejected_when_opening_the_store(tmp_path):
|
|
88
|
+
store = tmp_path / "store"
|
|
89
|
+
_encrypt_store(store, AesGcmCipher().generate_token())
|
|
90
|
+
with pytest.raises(WrongEncryptionToken):
|
|
91
|
+
build_use_cases(store, encryption_token=AesGcmCipher().generate_token())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""End-to-end CLI tests for encrypt / decrypt / rotate / invalidate."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
pytest.importorskip("cryptography")
|
|
10
|
+
|
|
11
|
+
from generic_ml_cache_cli.cli import main # noqa: E402
|
|
12
|
+
|
|
13
|
+
_RUN = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _token_from(out: str) -> str:
|
|
17
|
+
"""The token is printed on its own indented, space-free line."""
|
|
18
|
+
for line in out.splitlines():
|
|
19
|
+
s = line.strip()
|
|
20
|
+
if s and " " not in s and len(s) >= 20:
|
|
21
|
+
return s
|
|
22
|
+
raise AssertionError(f"no token found in:\n{out}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _enable(capsys) -> str:
|
|
26
|
+
assert main(["encrypt"]) == 0
|
|
27
|
+
return _token_from(capsys.readouterr().out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_encrypt_then_run_with_token_round_trips(capsys, monkeypatch):
|
|
31
|
+
token = _enable(capsys)
|
|
32
|
+
monkeypatch.setenv("GMLCACHE_TOKEN", token)
|
|
33
|
+
|
|
34
|
+
assert main(_RUN + ["--prompt", "STDOUT SECRETMARKER"]) == 0
|
|
35
|
+
assert "SECRETMARKER" in capsys.readouterr().out
|
|
36
|
+
# offline replay against the encrypted store, with the token
|
|
37
|
+
assert main(_RUN + ["--prompt", "STDOUT SECRETMARKER", "--offline"]) == 0
|
|
38
|
+
capsys.readouterr()
|
|
39
|
+
|
|
40
|
+
assert main(["status"]) == 0
|
|
41
|
+
assert "encryption : encrypted" in capsys.readouterr().out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_run_without_token_on_encrypted_store_is_blocked(capsys, monkeypatch):
|
|
45
|
+
token = _enable(capsys)
|
|
46
|
+
monkeypatch.setenv("GMLCACHE_TOKEN", token)
|
|
47
|
+
main(_RUN + ["--prompt", "STDOUT hi"]) # record one entry
|
|
48
|
+
capsys.readouterr()
|
|
49
|
+
|
|
50
|
+
monkeypatch.delenv("GMLCACHE_TOKEN", raising=False)
|
|
51
|
+
rc = main(_RUN + ["--prompt", "STDOUT hi"]) # hit -> hydrate -> needs the token
|
|
52
|
+
assert rc == 4
|
|
53
|
+
assert "token" in capsys.readouterr().err.lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_decrypt_returns_store_to_public(capsys):
|
|
57
|
+
token = _enable(capsys)
|
|
58
|
+
assert main(["decrypt", "--token", token]) == 0
|
|
59
|
+
capsys.readouterr()
|
|
60
|
+
main(["status"])
|
|
61
|
+
assert "encryption : public" in capsys.readouterr().out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_decrypt_with_wrong_token_fails(capsys):
|
|
65
|
+
_enable(capsys)
|
|
66
|
+
assert main(["decrypt", "--token", "definitely-not-the-right-token-xyz"]) == 4
|
|
67
|
+
assert "gmlc:" in capsys.readouterr().err
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_rotate_swaps_the_token(capsys, monkeypatch):
|
|
71
|
+
run = _RUN + ["--prompt", "STDOUT zz"]
|
|
72
|
+
old = _enable(capsys)
|
|
73
|
+
monkeypatch.setenv("GMLCACHE_TOKEN", old)
|
|
74
|
+
main(run)
|
|
75
|
+
capsys.readouterr()
|
|
76
|
+
|
|
77
|
+
assert main(["rotate", "--token", old]) == 0
|
|
78
|
+
new = _token_from(capsys.readouterr().out)
|
|
79
|
+
assert new != old
|
|
80
|
+
|
|
81
|
+
monkeypatch.setenv("GMLCACHE_TOKEN", new)
|
|
82
|
+
assert main(run + ["--offline"]) == 0 # new token reads
|
|
83
|
+
capsys.readouterr()
|
|
84
|
+
monkeypatch.setenv("GMLCACHE_TOKEN", old)
|
|
85
|
+
assert main(run + ["--offline"]) == 4 # old token rejected
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_invalidate_requires_yes_then_wipes_to_public(capsys, monkeypatch):
|
|
89
|
+
token = _enable(capsys)
|
|
90
|
+
monkeypatch.setenv("GMLCACHE_TOKEN", token)
|
|
91
|
+
main(_RUN + ["--prompt", "STDOUT x"])
|
|
92
|
+
capsys.readouterr()
|
|
93
|
+
|
|
94
|
+
assert main(["invalidate"]) == 4 # refused without --yes
|
|
95
|
+
assert "yes" in capsys.readouterr().err.lower()
|
|
96
|
+
|
|
97
|
+
assert main(["invalidate", "--yes"]) == 0
|
|
98
|
+
capsys.readouterr()
|
|
99
|
+
monkeypatch.delenv("GMLCACHE_TOKEN", raising=False)
|
|
100
|
+
main(["status"])
|
|
101
|
+
assert "encryption : public" in capsys.readouterr().out
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""CLI tests for sessions: run --session / GMLCACHE_SESSION and `session start`."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
import sqlite3
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_cli.cli import main
|
|
11
|
+
|
|
12
|
+
_RUN = ["run", "--client", "fake", "--model", "m1", "--effort", "high", "--prompt", "STDOUT hi"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _session_ids(tmp_path):
|
|
16
|
+
dbs = glob.glob(str(tmp_path / "**" / "registry.sqlite3"), recursive=True)
|
|
17
|
+
if not dbs:
|
|
18
|
+
return []
|
|
19
|
+
conn = sqlite3.connect(dbs[0])
|
|
20
|
+
try:
|
|
21
|
+
return [r[0] for r in conn.execute("SELECT session_id FROM access_events ORDER BY id")]
|
|
22
|
+
finally:
|
|
23
|
+
conn.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_run_with_session_flag_records_the_session_id(tmp_path, capsys):
|
|
27
|
+
assert main(_RUN + ["--session", "workflow-1"]) == 0
|
|
28
|
+
capsys.readouterr()
|
|
29
|
+
assert _session_ids(tmp_path) == ["workflow-1"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_run_reads_session_from_env(tmp_path, capsys, monkeypatch):
|
|
33
|
+
monkeypatch.setenv("GMLCACHE_SESSION", "env-session")
|
|
34
|
+
assert main(_RUN) == 0
|
|
35
|
+
capsys.readouterr()
|
|
36
|
+
assert _session_ids(tmp_path) == ["env-session"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_flag_wins_over_env(tmp_path, capsys, monkeypatch):
|
|
40
|
+
monkeypatch.setenv("GMLCACHE_SESSION", "env-session")
|
|
41
|
+
assert main(_RUN + ["--session", "flag-session"]) == 0
|
|
42
|
+
capsys.readouterr()
|
|
43
|
+
assert _session_ids(tmp_path) == ["flag-session"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_run_without_a_session_records_null(tmp_path, capsys):
|
|
47
|
+
assert main(_RUN) == 0
|
|
48
|
+
capsys.readouterr()
|
|
49
|
+
assert _session_ids(tmp_path) == [None]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_session_start_prints_a_scriptable_id(capsys):
|
|
53
|
+
assert main(["session", "start"]) == 0
|
|
54
|
+
out = capsys.readouterr().out.strip()
|
|
55
|
+
assert out and " " not in out # a single bare id, usable as $(gmlcache session start)
|
|
56
|
+
# two starts yield distinct ids
|
|
57
|
+
main(["session", "start"])
|
|
58
|
+
assert capsys.readouterr().out.strip() != out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_bare_session_shows_usage(capsys):
|
|
62
|
+
assert main(["session"]) == 2
|
|
63
|
+
assert "session start" in capsys.readouterr().err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_session_report_rolls_up_invocations_executions_hits(capsys):
|
|
67
|
+
run = _RUN + ["--session", "wf"]
|
|
68
|
+
main(run) # miss -> record (a real execution)
|
|
69
|
+
main(run) # same input -> hit (no execution)
|
|
70
|
+
capsys.readouterr()
|
|
71
|
+
|
|
72
|
+
assert main(["session", "report", "wf"]) == 0
|
|
73
|
+
out = capsys.readouterr().out
|
|
74
|
+
assert "invocations : 2" in out
|
|
75
|
+
assert "executions : 1" in out
|
|
76
|
+
assert "hits : 1" in out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_session_report_json(capsys):
|
|
80
|
+
import json
|
|
81
|
+
|
|
82
|
+
main(_RUN + ["--session", "wf"])
|
|
83
|
+
capsys.readouterr()
|
|
84
|
+
assert main(["session", "report", "wf", "--json"]) == 0
|
|
85
|
+
data = json.loads(capsys.readouterr().out)
|
|
86
|
+
assert data == {
|
|
87
|
+
"session": "wf",
|
|
88
|
+
"invocations": 1,
|
|
89
|
+
"executions": 1,
|
|
90
|
+
"hits": 0,
|
|
91
|
+
"events": {"record": 1},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_session_report_unknown_session_is_clean(capsys):
|
|
96
|
+
assert main(["session", "report", "nope"]) == 0
|
|
97
|
+
assert "no events" in capsys.readouterr().out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/__init__.py
RENAMED
|
File without changes
|
{generic_ml_cache_cli-0.4.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/__main__.py
RENAMED
|
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
|