generic-ml-cache-cli 0.3.0__tar.gz → 0.5.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.
Files changed (23) hide show
  1. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/PKG-INFO +3 -3
  2. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/README.md +1 -1
  3. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/pyproject.toml +2 -2
  4. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/cli.py +381 -7
  5. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/config.py +28 -7
  6. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/conftest.py +1 -1
  7. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_cli.py +235 -0
  8. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_config.py +25 -0
  9. generic_ml_cache_cli-0.5.0/tests/test_encrypted_run.py +91 -0
  10. generic_ml_cache_cli-0.5.0/tests/test_encryption_cli.py +101 -0
  11. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/.gitignore +0 -0
  12. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/LICENSE +0 -0
  13. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/NOTICE +0 -0
  14. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/__init__.py +0 -0
  15. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/src/generic_ml_cache_cli/__main__.py +0 -0
  16. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/fake_client.py +0 -0
  17. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_discover.py +0 -0
  18. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_effort.py +0 -0
  19. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_interrupt.py +0 -0
  20. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_models.py +0 -0
  21. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_passthrough.py +0 -0
  22. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.0}/tests/test_robustness.py +0 -0
  23. {generic_ml_cache_cli-0.3.0 → generic_ml_cache_cli-0.5.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.0
3
+ Version: 0.5.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.3.0
27
+ Requires-Dist: generic-ml-cache-core>=0.5.0
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: coverage>=7; extra == 'dev'
30
30
  Requires-Dist: pytest-cov; extra == 'dev'
@@ -80,7 +80,7 @@ gmlcache doctor | models | status | init # environment & con
80
80
  `gmlcache` is the terminal client — one inbound driver over the engine. The whole cache
81
81
  logic and every adapter live in
82
82
  [`generic-ml-cache-core`](https://github.com/danielslobozian/generic-ml-cache/tree/main/packages/core),
83
- a **stateless, dependency-free** library. To embed the cache in your own application
83
+ a **stateless** library. To embed the cache in your own application
84
84
  instead of driving it from a terminal, depend on the core and inject your own data
85
85
  source — you never reimplement the adapters.
86
86
 
@@ -45,7 +45,7 @@ gmlcache doctor | models | status | init # environment & con
45
45
  `gmlcache` is the terminal client — one inbound driver over the engine. The whole cache
46
46
  logic and every adapter live in
47
47
  [`generic-ml-cache-core`](https://github.com/danielslobozian/generic-ml-cache/tree/main/packages/core),
48
- a **stateless, dependency-free** library. To embed the cache in your own application
48
+ a **stateless** library. To embed the cache in your own application
49
49
  instead of driving it from a terminal, depend on the core and inject your own data
50
50
  source — you never reimplement the adapters.
51
51
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "generic-ml-cache-cli"
7
- version = "0.3.0"
7
+ version = "0.5.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.3.0", "argcomplete>=3,<4"]
28
+ dependencies = ["generic-ml-cache-core>=0.5.0", "argcomplete>=3,<4"]
29
29
 
30
30
  [project.urls]
31
31
  Homepage = "https://github.com/danielslobozian/generic-ml-cache"
@@ -31,16 +31,34 @@ 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
- from generic_ml_cache_core.application.domain.model.execution.artifact import ArtifactType
40
+ from generic_ml_cache_core.application.domain.model.execution.artifact import (
41
+ INPUT_ARTIFACT_TYPES,
42
+ ArtifactType,
43
+ )
36
44
  from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
45
+ from generic_ml_cache_core.application.domain.model.run.persistence_depth import PersistenceDepth
37
46
  from generic_ml_cache_core.application.domain.model.execution.execution_state import ExecutionState
38
47
  from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
39
48
  from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import (
40
49
  RunManagedLocalExecutionCommand,
41
50
  )
42
51
  from generic_ml_cache_core.application.port.out.base import ClientAdapter
43
- from generic_ml_cache_core.common.errors import CacheError, CacheMiss, ConfigError, RunInterrupted
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
+ )
44
62
 
45
63
  from . import __version__, config
46
64
 
@@ -142,7 +160,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
142
160
 
143
161
  try:
144
162
  file_cfg = config.load()
145
- settings = config.resolve_settings(file_cfg, mode_flag=args.mode, timeout_flag=args.timeout)
163
+ settings = config.resolve_settings(
164
+ file_cfg, mode_flag=args.mode, persist_flag=args.persist, timeout_flag=args.timeout
165
+ )
146
166
  except ConfigError as exc:
147
167
  print(f"gmlc: {exc}", file=sys.stderr)
148
168
  return 4
@@ -157,6 +177,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
157
177
  cache_mode = CacheMode.REFRESH
158
178
  else:
159
179
  cache_mode = CacheMode(str(settings["mode"][0]))
180
+ persistence_depth = PersistenceDepth(str(settings["persist"][0]))
160
181
 
161
182
  command = RunManagedLocalExecutionCommand(
162
183
  client=args.client,
@@ -171,6 +192,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
171
192
  client_args=list(getattr(args, "client_arg", None) or []),
172
193
  grants=list(getattr(args, "grant", None) or []),
173
194
  cache_mode=cache_mode,
195
+ persistence_depth=persistence_depth,
174
196
  record_on_error=args.record_on_error,
175
197
  tags=list(getattr(args, "tag", None) or []),
176
198
  )
@@ -178,9 +200,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
178
200
  def executable_override(client: str):
179
201
  return config.executable_for(file_cfg, client, flag=args.executable)
180
202
 
181
- wired = build_use_cases(store_root, executable_override, timeout)
182
-
203
+ token = _resolve_token(args)
183
204
  try:
205
+ wired = build_use_cases(store_root, executable_override, timeout, encryption_token=token)
184
206
  execution = wired.run_managed.execute(command)
185
207
  except RunInterrupted as exc:
186
208
  # A requested stop, not a failure: nothing was recorded. Exit 130 is the
@@ -198,6 +220,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
198
220
  except CacheMiss as exc:
199
221
  print(f"gmlc: {exc}", file=sys.stderr)
200
222
  return 3
223
+ except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
224
+ print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
225
+ return 4
201
226
  except CacheError as exc:
202
227
  print(f"gmlc: {exc}", file=sys.stderr)
203
228
  return 4
@@ -319,6 +344,14 @@ def _cmd_inspect(args: argparse.Namespace) -> int:
319
344
  print(f"files : {len(output_files)}")
320
345
  for artifact in output_files:
321
346
  print(f" - {artifact.name} ({artifact.encoding}, {artifact.size_bytes} bytes)")
347
+ input_parts = [a for a in execution.artifacts if a.artifact_type in INPUT_ARTIFACT_TYPES]
348
+ if input_parts:
349
+ print(f"input : stored ({len(input_parts)} part(s))")
350
+ for artifact in input_parts:
351
+ label = artifact.artifact_type.value.replace("input_", "")
352
+ print(f" - {label} ({artifact.encoding}, {artifact.size_bytes} bytes)")
353
+ else:
354
+ print("input : not stored")
322
355
  usage = execution.token_usage
323
356
  if usage is None:
324
357
  print("usage : (none captured)")
@@ -412,6 +445,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
412
445
 
413
446
  path = config.resolve_config_path()
414
447
  loaded = file_cfg.source is not None
448
+ encryption = FilesystemEncryptionManifestStore(Path(str(settings["store"][0]))).state().value
415
449
 
416
450
  if args.json:
417
451
  import json
@@ -421,6 +455,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
421
455
  {
422
456
  "config_file": str(path),
423
457
  "loaded": loaded,
458
+ "encryption": encryption,
424
459
  "settings": {k: {"value": v[0], "source": v[1]} for k, v in settings.items()},
425
460
  "executables": dict(file_cfg.executables),
426
461
  },
@@ -430,8 +465,9 @@ def _cmd_status(args: argparse.Namespace) -> int:
430
465
  return 0
431
466
 
432
467
  print(f"config file : {path} ({'loaded' if loaded else 'not present'})")
468
+ print(f"encryption : {encryption}")
433
469
  print("effective settings (no run flags applied):")
434
- for key in ("mode", "store", "timeout", "trust_scan", "max_size"):
470
+ for key in ("mode", "persist", "store", "timeout", "trust_scan", "max_size"):
435
471
  value, source = settings[key]
436
472
  shown = "none" if value is None else value
437
473
  if isinstance(shown, bool):
@@ -522,7 +558,13 @@ def _cmd_stats(args: argparse.Namespace) -> int:
522
558
  for (client, model), count in sorted(by_client_model.items()):
523
559
  print(f" {client:<8} {model:<26} {count:>5}")
524
560
  if access:
525
- event_styles = {"hit": (_GREEN,), "miss": (_AMBER,), "record": (_TEAL,)}
561
+ event_styles = {
562
+ "hit": (_GREEN,),
563
+ "miss": (_AMBER,),
564
+ "record": (_TEAL,),
565
+ "would_hit": (_GREEN,),
566
+ "would_miss": (_AMBER,),
567
+ }
526
568
  parts = ", ".join(
527
569
  f"{_paint(event, *event_styles.get(event, ()))}={count}"
528
570
  for event, count in sorted(access.items())
@@ -560,6 +602,9 @@ def _cmd_list(args: argparse.Namespace) -> int:
560
602
  wanted_tags = set(getattr(args, "tag", None) or [])
561
603
  if wanted_tags:
562
604
  entries = [entry for entry in entries if wanted_tags & set(entry["tags"])]
605
+ excluded_tags = set(getattr(args, "exclude_tag", None) or [])
606
+ if excluded_tags:
607
+ entries = [entry for entry in entries if not excluded_tags & set(entry["tags"])]
563
608
 
564
609
  if args.json:
565
610
  print(json.dumps({"executions": entries}, indent=2))
@@ -583,6 +628,255 @@ def _cmd_list(args: argparse.Namespace) -> int:
583
628
  return 0
584
629
 
585
630
 
631
+ def _cmd_tags(args: argparse.Namespace) -> int:
632
+ import json
633
+
634
+ try:
635
+ settings = config.resolve_settings(config.load())
636
+ except ConfigError as exc:
637
+ print(f"gmlc: {exc}", file=sys.stderr)
638
+ return 4
639
+
640
+ wired = build_use_cases(Path(str(settings["store"][0])))
641
+ counts: dict = {}
642
+ for summary in wired.repository.current_execution_summaries():
643
+ for tag in wired.repository.tags_for(summary.execution_key):
644
+ counts[tag] = counts.get(tag, 0) + 1
645
+
646
+ tags = [{"tag": tag, "count": counts[tag]} for tag in sorted(counts)]
647
+
648
+ if args.json:
649
+ print(json.dumps({"tags": tags}, indent=2))
650
+ return 0
651
+
652
+ if not tags:
653
+ print("no tags")
654
+ return 0
655
+
656
+ print(f"tags : {_paint(str(len(tags)), _TEAL, _BOLD)}")
657
+ for entry in tags:
658
+ count_text = _paint("count:" + str(entry["count"]), _GREY)
659
+ print(f" {entry['tag']:<24} {count_text}")
660
+ return 0
661
+
662
+
663
+ _INPUT_FIELD_BY_TYPE = {
664
+ ArtifactType.INPUT_CONTEXT: "context",
665
+ ArtifactType.INPUT_PROMPT: "prompt",
666
+ ArtifactType.INPUT_SYSTEM: "system",
667
+ }
668
+
669
+
670
+ def _export_record(summary, execution, tags, blob_store) -> dict:
671
+ """Assemble one raw corpus record: the stored input parts and the output,
672
+ hydrated from the blob store. Curation is the user's (tags); this never
673
+ judges quality."""
674
+ import base64
675
+ import json
676
+
677
+ def text(artifact) -> str:
678
+ return (blob_store.get(artifact.blob_key) or b"").decode("utf-8", "replace")
679
+
680
+ input_obj: dict = {}
681
+ stdout = ""
682
+ files = []
683
+ for artifact in execution.artifacts:
684
+ field_name = _INPUT_FIELD_BY_TYPE.get(artifact.artifact_type)
685
+ if field_name is not None:
686
+ input_obj[field_name] = text(artifact)
687
+ elif artifact.artifact_type is ArtifactType.INPUT_MESSAGES:
688
+ input_obj["messages"] = json.loads(text(artifact))
689
+ elif artifact.artifact_type is ArtifactType.INPUT_ARGS:
690
+ input_obj["args"] = json.loads(text(artifact))
691
+ elif artifact.artifact_type is ArtifactType.STDOUT:
692
+ stdout = text(artifact)
693
+ elif artifact.artifact_type is ArtifactType.OUTPUT_FILE:
694
+ if artifact.encoding == "binary":
695
+ raw = blob_store.get(artifact.blob_key) or b""
696
+ files.append(
697
+ {"name": artifact.name, "content_base64": base64.b64encode(raw).decode("ascii")}
698
+ )
699
+ else:
700
+ files.append({"name": artifact.name, "content": text(artifact)})
701
+
702
+ output_obj: dict = {"stdout": stdout}
703
+ if files:
704
+ output_obj["files"] = files
705
+ return {
706
+ "key": summary.execution_key,
707
+ "kind": summary.kind,
708
+ "client": summary.client,
709
+ "model": summary.model,
710
+ "tags": tags,
711
+ "input": input_obj,
712
+ "output": output_obj,
713
+ }
714
+
715
+
716
+ def _cmd_export(args: argparse.Namespace) -> int:
717
+ import json
718
+
719
+ try:
720
+ settings = config.resolve_settings(config.load())
721
+ except ConfigError as exc:
722
+ print(f"gmlc: {exc}", file=sys.stderr)
723
+ return 4
724
+
725
+ include = set(getattr(args, "tag", None) or [])
726
+ exclude = set(getattr(args, "exclude_tag", None) or [])
727
+
728
+ lines = []
729
+ skipped_no_input = 0
730
+ try:
731
+ wired = build_use_cases(
732
+ Path(str(settings["store"][0])), encryption_token=_resolve_token(args)
733
+ )
734
+ for summary in wired.repository.current_execution_summaries():
735
+ tags = wired.repository.tags_for(summary.execution_key)
736
+ if include and not include & set(tags):
737
+ continue
738
+ if exclude and exclude & set(tags):
739
+ continue
740
+ execution = wired.repository.find_current(summary.execution_key)
741
+ # Only DATASET-depth entries carry the input side of the corpus.
742
+ if execution is None or not execution.input_persisted:
743
+ skipped_no_input += 1
744
+ continue
745
+ lines.append(json.dumps(_export_record(summary, execution, tags, wired.blob_store)))
746
+ except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
747
+ print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
748
+ return 4
749
+
750
+ if args.output:
751
+ Path(args.output).write_text("".join(line + "\n" for line in lines), encoding="utf-8")
752
+ destination = args.output
753
+ else:
754
+ for line in lines:
755
+ print(line)
756
+ destination = "stdout"
757
+
758
+ # Summary on stderr so stdout stays a clean JSONL stream.
759
+ note = f"exported {len(lines)} record(s) to {destination}"
760
+ if skipped_no_input:
761
+ entries = "entry" if skipped_no_input == 1 else "entries"
762
+ note += f"; skipped {skipped_no_input} matching {entries} without stored input (not dataset depth)"
763
+ print(note, file=sys.stderr)
764
+ return 0
765
+
766
+
767
+ # -- encryption -------------------------------------------------------------
768
+
769
+
770
+ def _resolve_token(args: argparse.Namespace) -> Optional[str]:
771
+ """The encryption token for this call: the --token flag, else GMLCACHE_TOKEN.
772
+ A token is a secret, so it is never read from the config file."""
773
+ flag = getattr(args, "token", None)
774
+ return flag if flag else (os.environ.get("GMLCACHE_TOKEN") or None)
775
+
776
+
777
+ def _load_cipher():
778
+ """Build the cipher, with a friendly error if the optional extra is missing."""
779
+ try:
780
+ from generic_ml_cache_core.adapter.out.crypto.aesgcm_cipher import AesGcmCipher
781
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
782
+ raise SystemExit(
783
+ "error: encryption needs an optional dependency — install with "
784
+ '`pip install "generic-ml-cache-core[encryption]"`'
785
+ ) from exc
786
+ return AesGcmCipher()
787
+
788
+
789
+ def _store_encryptor(store_root: Path, cipher=None) -> StoreEncryptor:
790
+ return StoreEncryptor(
791
+ store_root,
792
+ FilesystemEncryptionManifestStore(store_root),
793
+ SqliteStoreLock(store_root),
794
+ cipher,
795
+ )
796
+
797
+
798
+ def _store_root() -> Optional[Path]:
799
+ try:
800
+ return Path(str(config.resolve_settings(config.load())["store"][0]))
801
+ except ConfigError as exc:
802
+ print(f"gmlc: {exc}", file=sys.stderr)
803
+ return None
804
+
805
+
806
+ def _cmd_encrypt(args: argparse.Namespace) -> int:
807
+ store_root = _store_root()
808
+ if store_root is None:
809
+ return 4
810
+ cipher = _load_cipher()
811
+ token = cipher.generate_token()
812
+ try:
813
+ _store_encryptor(store_root, cipher).enable(token)
814
+ except (EncryptionStateError, StoreLocked) as exc:
815
+ print(f"gmlc: {exc}", file=sys.stderr)
816
+ return 4
817
+ print("encryption enabled. Save this token — it is shown once and cannot be recovered:")
818
+ print(f"\n {token}\n")
819
+ print("Pass it with --token or GMLCACHE_TOKEN to read or write this store.")
820
+ return 0
821
+
822
+
823
+ def _cmd_decrypt(args: argparse.Namespace) -> int:
824
+ store_root = _store_root()
825
+ if store_root is None:
826
+ return 4
827
+ token = _resolve_token(args)
828
+ if not token:
829
+ print("gmlc: provide the token with --token or GMLCACHE_TOKEN", file=sys.stderr)
830
+ return 4
831
+ try:
832
+ _store_encryptor(store_root, _load_cipher()).disable(token)
833
+ except (WrongEncryptionToken, EncryptionStateError, StoreLocked) as exc:
834
+ print(f"gmlc: {exc}", file=sys.stderr)
835
+ return 4
836
+ print("encryption disabled. The store is now public; no token is needed.")
837
+ return 0
838
+
839
+
840
+ def _cmd_rotate(args: argparse.Namespace) -> int:
841
+ store_root = _store_root()
842
+ if store_root is None:
843
+ return 4
844
+ old_token = _resolve_token(args)
845
+ if not old_token:
846
+ print("gmlc: provide the current token with --token or GMLCACHE_TOKEN", file=sys.stderr)
847
+ return 4
848
+ cipher = _load_cipher()
849
+ new_token = cipher.generate_token()
850
+ try:
851
+ _store_encryptor(store_root, cipher).rotate(old_token, new_token)
852
+ except (WrongEncryptionToken, EncryptionStateError, StoreLocked) as exc:
853
+ print(f"gmlc: {exc}", file=sys.stderr)
854
+ return 4
855
+ print("token rotated. Save the new token — it is shown once:")
856
+ print(f"\n {new_token}\n")
857
+ return 0
858
+
859
+
860
+ def _cmd_invalidate(args: argparse.Namespace) -> int:
861
+ store_root = _store_root()
862
+ if store_root is None:
863
+ return 4
864
+ if not args.yes:
865
+ print(
866
+ "gmlc: this permanently wipes the cache (crypto-shred) and cannot be undone. "
867
+ "Re-run with --yes to confirm.",
868
+ file=sys.stderr,
869
+ )
870
+ return 4
871
+ try:
872
+ _store_encryptor(store_root).invalidate() # no token needed
873
+ except StoreLocked as exc:
874
+ print(f"gmlc: {exc}", file=sys.stderr)
875
+ return 4
876
+ print("store invalidated: the cache was wiped and is now empty and public.")
877
+ return 0
878
+
879
+
586
880
  def _use_color() -> bool:
587
881
  """Colour only when writing to a real terminal and NO_COLOR is unset, so piped
588
882
  or redirected output never carries escape codes (the conventional contract)."""
@@ -742,6 +1036,15 @@ def build_parser() -> argparse.ArgumentParser:
742
1036
  default=None,
743
1037
  help="resolution mode (default: cache, or config/env)",
744
1038
  )
1039
+ run.add_argument(
1040
+ "--persist",
1041
+ choices=[d.value for d in PersistenceDepth],
1042
+ default=None,
1043
+ help=(
1044
+ "how much to keep: meter (usage only, never replays), cache (+output, "
1045
+ "the default), or dataset (+input) (default: cache, or config/env)"
1046
+ ),
1047
+ )
745
1048
  run.add_argument("--offline", action="store_true", help="shortcut for --mode offline")
746
1049
  run.add_argument("--force", action="store_true", help="shortcut for --mode refresh")
747
1050
  run.add_argument(
@@ -750,6 +1053,9 @@ def build_parser() -> argparse.ArgumentParser:
750
1053
  help="also cache a call that fails (non-zero exit); default is to store only successes",
751
1054
  )
752
1055
  run.add_argument("--executable", help="override the client executable (the seam)")
1056
+ run.add_argument(
1057
+ "--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
1058
+ )
753
1059
  run.add_argument(
754
1060
  "--timeout", type=float, default=None, help="seconds before the real call is killed"
755
1061
  )
@@ -865,9 +1171,77 @@ def build_parser() -> argparse.ArgumentParser:
865
1171
  metavar="TAG",
866
1172
  help="only executions carrying any of these tags (repeatable; match-any)",
867
1173
  )
1174
+ listp.add_argument(
1175
+ "--exclude-tag",
1176
+ action="append",
1177
+ dest="exclude_tag",
1178
+ metavar="TAG",
1179
+ help="drop executions carrying any of these tags (repeatable; match-any)",
1180
+ )
868
1181
  listp.add_argument("--json", action="store_true", help="emit machine-readable JSON")
869
1182
  listp.set_defaults(func=_cmd_list)
870
1183
 
1184
+ tagsp = sub.add_parser(
1185
+ "tags",
1186
+ help="list the distinct tags in use across current executions, with counts (read-only)",
1187
+ )
1188
+ tagsp.add_argument("--json", action="store_true", help="emit machine-readable JSON")
1189
+ tagsp.set_defaults(func=_cmd_tags)
1190
+
1191
+ exportp = sub.add_parser(
1192
+ "export",
1193
+ help="export the (input, output) dataset corpus as JSONL (read-only). Only entries "
1194
+ "stored at --persist dataset carry an input; others are skipped.",
1195
+ )
1196
+ exportp.add_argument(
1197
+ "--tag",
1198
+ action="append",
1199
+ dest="tag",
1200
+ metavar="TAG",
1201
+ help="only entries carrying any of these tags (repeatable; match-any)",
1202
+ )
1203
+ exportp.add_argument(
1204
+ "--exclude-tag",
1205
+ action="append",
1206
+ dest="exclude_tag",
1207
+ metavar="TAG",
1208
+ help="drop entries carrying any of these tags (repeatable; match-any)",
1209
+ )
1210
+ exportp.add_argument(
1211
+ "-o",
1212
+ "--output",
1213
+ metavar="FILE",
1214
+ help="write JSONL to FILE instead of stdout (a per-record summary still goes to stderr)",
1215
+ )
1216
+ exportp.add_argument(
1217
+ "--token", help="encryption token if the store is encrypted (or set GMLCACHE_TOKEN)"
1218
+ )
1219
+ exportp.set_defaults(func=_cmd_export)
1220
+
1221
+ encryptp = sub.add_parser(
1222
+ "encrypt", help="enable at-rest encryption of the store (generates and shows a token)"
1223
+ )
1224
+ encryptp.set_defaults(func=_cmd_encrypt)
1225
+
1226
+ decryptp = sub.add_parser(
1227
+ "decrypt", help="disable encryption (decrypts the store back to plaintext; needs the token)"
1228
+ )
1229
+ decryptp.add_argument("--token", help="the encryption token (or set GMLCACHE_TOKEN)")
1230
+ decryptp.set_defaults(func=_cmd_decrypt)
1231
+
1232
+ rotatep = sub.add_parser(
1233
+ "rotate", help="rotate the encryption token (needs the current token; shows the new one)"
1234
+ )
1235
+ rotatep.add_argument("--token", help="the current encryption token (or set GMLCACHE_TOKEN)")
1236
+ rotatep.set_defaults(func=_cmd_rotate)
1237
+
1238
+ invalidatep = sub.add_parser(
1239
+ "invalidate",
1240
+ help="wipe the cache (crypto-shred) — the escape when the token is lost. Needs --yes.",
1241
+ )
1242
+ invalidatep.add_argument("--yes", action="store_true", help="confirm the irreversible wipe")
1243
+ invalidatep.set_defaults(func=_cmd_invalidate)
1244
+
871
1245
  init = sub.add_parser(
872
1246
  "init",
873
1247
  help="create the config file in the default location (if absent), then show the store",
@@ -8,14 +8,13 @@ Three rules keep this predictable:
8
8
  writes it -- the cache works with no file present. :func:`write_default_config`
9
9
  (the ``gmlcache init`` command) writes one on explicit request, never on
10
10
  install or first run.
11
- * **Overridable, with explicit precedence.** For ``mode`` and ``timeout`` the
12
- winner is, in order: a CLI flag, an environment variable, the config file, the
13
- built-in default. The ``store`` location is the exception -- config file or
11
+ * **Overridable, with explicit precedence.** For ``mode``, ``persist`` and
12
+ ``timeout`` the winner is, in order: a CLI flag, an environment variable, the
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
- * **Zero dependencies.** The format is INI (stdlib :mod:`configparser`) and the
17
- per-user location is resolved inline, so nothing beyond the standard library is
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
 
@@ -27,6 +26,7 @@ File shape::
27
26
 
28
27
  [defaults]
29
28
  mode = cache
29
+ persist = cache
30
30
  # store defaults to the per-user data dir (XDG data home); set a path to change it
31
31
  store = /path/to/store
32
32
  timeout = 120
@@ -66,6 +66,7 @@ from pathlib import Path
66
66
  from typing import Dict, Optional, Tuple
67
67
 
68
68
  from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
69
+ from generic_ml_cache_core.application.domain.model.run.persistence_depth import PersistenceDepth
69
70
  from generic_ml_cache_core.common.errors import ConfigError
70
71
 
71
72
  CONFIG_ENV = "GMLCACHE_CONFIG"
@@ -77,9 +78,10 @@ EXECUTABLES_SECTION = "executables"
77
78
  #: built-in defaults; ``timeout`` of ``None`` means "no timeout". The store has
78
79
  #: no static default here -- it resolves to :func:`default_store_path` (per-user
79
80
  #: data dir) and has no flag/env layer, only the config file.
80
- DEFAULTS: Dict[str, Optional[str]] = {"mode": "cache", "timeout": None}
81
+ DEFAULTS: Dict[str, Optional[str]] = {"mode": "cache", "persist": "cache", "timeout": None}
81
82
 
82
83
  _MODES = {m.value for m in CacheMode}
84
+ _DEPTHS = {d.value for d in PersistenceDepth}
83
85
 
84
86
  #: written by ``gmlcache init`` (and only then); ``{store}`` is filled with the
85
87
  #: resolved per-user default so the user can see and edit where the store lives.
@@ -96,6 +98,10 @@ _DEFAULT_CONFIG_TEMPLATE = """\
96
98
 
97
99
  [defaults]
98
100
  mode = cache
101
+ # How much each call keeps on disk: meter (usage/metadata only, never replays),
102
+ # cache (+ output, the default -- replay on hit), or dataset (+ input, for an
103
+ # exportable (input, output) corpus).
104
+ persist = cache
99
105
  # Where the store lives. This is the per-user data dir by default; change freely.
100
106
  store = {store}
101
107
  # timeout = 120
@@ -161,6 +167,7 @@ class FileConfig:
161
167
  or ``None`` when no file was present."""
162
168
 
163
169
  mode: Optional[str] = None
170
+ persist: Optional[str] = None
164
171
  store: Optional[str] = None
165
172
  timeout: Optional[float] = None
166
173
  trust_scan: Optional[bool] = None
@@ -226,6 +233,10 @@ def load(path: Optional[Path] = None) -> FileConfig:
226
233
  if mode is not None and mode not in _MODES:
227
234
  raise ConfigError(f"invalid mode {mode!r} in {p}; expected one of {sorted(_MODES)}")
228
235
 
236
+ persist = get("persist")
237
+ if persist is not None and persist not in _DEPTHS:
238
+ raise ConfigError(f"invalid persist {persist!r} in {p}; expected one of {sorted(_DEPTHS)}")
239
+
229
240
  timeout_raw = get("timeout")
230
241
  timeout = _parse_timeout(timeout_raw, f"in {p}") if timeout_raw else None
231
242
 
@@ -246,6 +257,7 @@ def load(path: Optional[Path] = None) -> FileConfig:
246
257
 
247
258
  return FileConfig(
248
259
  mode=mode,
260
+ persist=persist,
249
261
  store=get("store"),
250
262
  timeout=timeout,
251
263
  trust_scan=trust_scan,
@@ -287,6 +299,7 @@ def resolve_settings(
287
299
  file_cfg: FileConfig,
288
300
  *,
289
301
  mode_flag: Optional[str] = None,
302
+ persist_flag: Optional[str] = None,
290
303
  timeout_flag: Optional[float] = None,
291
304
  ) -> Dict[str, Tuple[object, str]]:
292
305
  """Resolve each setting to ``(value, source)`` by the documented precedence.
@@ -304,6 +317,12 @@ def resolve_settings(
304
317
  f"invalid mode {mode_env!r} in GMLCACHE_MODE; expected one of {sorted(_MODES)}"
305
318
  )
306
319
 
320
+ persist_env = env.get("GMLCACHE_PERSIST")
321
+ if persist_env and persist_env not in _DEPTHS:
322
+ raise ConfigError(
323
+ f"invalid persist {persist_env!r} in GMLCACHE_PERSIST; expected one of {sorted(_DEPTHS)}"
324
+ )
325
+
307
326
  timeout_env_raw = env.get("GMLCACHE_TIMEOUT")
308
327
  timeout_env = (
309
328
  _parse_timeout(timeout_env_raw, "in GMLCACHE_TIMEOUT") if timeout_env_raw else None
@@ -319,6 +338,8 @@ def resolve_settings(
319
338
 
320
339
  return {
321
340
  "mode": _pick(mode_flag, mode_env, file_cfg.mode, DEFAULTS["mode"]),
341
+ # persist: per-call depth (meter/cache/dataset), same precedence as mode.
342
+ "persist": _pick(persist_flag, persist_env, file_cfg.persist, DEFAULTS["persist"]),
322
343
  # store: config file or built-in per-user default only. No flag, no env --
323
344
  # a per-call store override would fork the cache and defeat reuse.
324
345
  "store": _pick(None, None, file_cfg.store, str(default_store_path())),
@@ -118,7 +118,7 @@ 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 ("GMLCACHE_MODE", "GMLCACHE_TIMEOUT"):
121
+ for var in ("GMLCACHE_MODE", "GMLCACHE_PERSIST", "GMLCACHE_TIMEOUT", "GMLCACHE_TOKEN"):
122
122
  monkeypatch.delenv(var, raising=False)
123
123
 
124
124
 
@@ -369,3 +369,238 @@ def test_list_filters_by_tag_and_shows_tags(capsys):
369
369
  listed = json.loads(capsys.readouterr().out)["executions"]
370
370
  assert len(listed) == 1 # match-any filter keeps only the alpha-tagged entry
371
371
  assert listed[0]["tags"] == ["alpha"]
372
+
373
+
374
+ def test_list_excludes_by_tag(capsys):
375
+ import json
376
+
377
+ base = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
378
+ run_cli(base + ["--prompt", "STDOUT a", "--tag", "alpha"])
379
+ run_cli(base + ["--prompt", "STDOUT b", "--tag", "beta"])
380
+ capsys.readouterr()
381
+
382
+ rc = main(["list", "--exclude-tag", "beta", "--json"])
383
+ assert rc == 0
384
+ listed = json.loads(capsys.readouterr().out)["executions"]
385
+ assert len(listed) == 1 # the beta-tagged entry is dropped
386
+ assert listed[0]["tags"] == ["alpha"]
387
+
388
+
389
+ def test_list_exclude_tag_overrides_include(capsys):
390
+ import json
391
+
392
+ base = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
393
+ # one entry carrying both tags
394
+ run_cli(base + ["--prompt", "STDOUT a", "--tag", "alpha", "--tag", "beta"])
395
+ capsys.readouterr()
396
+
397
+ rc = main(["list", "--tag", "alpha", "--exclude-tag", "beta", "--json"])
398
+ assert rc == 0
399
+ listed = json.loads(capsys.readouterr().out)["executions"]
400
+ assert listed == [] # exclude wins when a tag is both included and excluded
401
+
402
+
403
+ def test_tags_lists_distinct_tags_with_counts(capsys):
404
+ import json
405
+
406
+ base = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
407
+ run_cli(base + ["--prompt", "STDOUT a", "--tag", "alpha", "--tag", "shared"])
408
+ run_cli(base + ["--prompt", "STDOUT b", "--tag", "beta", "--tag", "shared"])
409
+ capsys.readouterr()
410
+
411
+ rc = main(["tags", "--json"])
412
+ assert rc == 0
413
+ tags = json.loads(capsys.readouterr().out)["tags"]
414
+ assert tags == [
415
+ {"tag": "alpha", "count": 1},
416
+ {"tag": "beta", "count": 1},
417
+ {"tag": "shared", "count": 2},
418
+ ]
419
+
420
+
421
+ def test_tags_empty_when_no_tags(capsys):
422
+ base = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
423
+ run_cli(base + ["--prompt", "STDOUT a"])
424
+ capsys.readouterr()
425
+
426
+ rc = main(["tags"])
427
+ assert rc == 0
428
+ assert "no tags" in capsys.readouterr().out
429
+
430
+
431
+ def test_persist_meter_stores_no_output_so_offline_misses(capsys):
432
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
433
+ # meter records the run but keeps no output ...
434
+ rc = run_cli(common + ["--prompt", "STDOUT hello", "--persist", "meter"])
435
+ assert rc == 0
436
+ assert "hello" in capsys.readouterr().out
437
+
438
+ # ... so there is nothing servable: a later offline call misses (exit 3).
439
+ rc = run_cli(common + ["--prompt", "STDOUT hello", "--offline"])
440
+ assert rc == 3
441
+ assert "offline miss" in capsys.readouterr().err
442
+
443
+
444
+ def test_persist_default_cache_replays_offline(capsys):
445
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
446
+ # default depth is cache: output is stored ...
447
+ run_cli(common + ["--prompt", "STDOUT hello", "--persist", "cache"])
448
+ capsys.readouterr()
449
+ # ... so a later offline call replays from cache.
450
+ rc = run_cli(common + ["--prompt", "STDOUT hello", "--offline"])
451
+ assert rc == 0
452
+
453
+
454
+ def _only_key(capsys):
455
+ import json
456
+
457
+ main(["list", "--json"])
458
+ return json.loads(capsys.readouterr().out)["executions"][0]["key"]
459
+
460
+
461
+ def test_persist_dataset_stores_input_visible_in_inspect(capsys):
462
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
463
+ run_cli(common + ["--prompt", "STDOUT hi", "--context", "some context", "--persist", "dataset"])
464
+ capsys.readouterr()
465
+
466
+ # dataset still replays output normally ...
467
+ rc = run_cli(common + ["--prompt", "STDOUT hi", "--context", "some context", "--offline"])
468
+ assert rc == 0
469
+ capsys.readouterr()
470
+
471
+ # ... and inspect shows the input was stored (prompt + context parts).
472
+ rc = main(["inspect", _only_key(capsys)[:12]])
473
+ assert rc == 0
474
+ out = capsys.readouterr().out
475
+ assert "input : stored" in out
476
+ assert "prompt" in out and "context" in out
477
+
478
+
479
+ def test_persist_cache_does_not_store_input_in_inspect(capsys):
480
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
481
+ run_cli(common + ["--prompt", "STDOUT hi", "--persist", "cache"])
482
+ capsys.readouterr()
483
+
484
+ rc = main(["inspect", _only_key(capsys)[:12]])
485
+ assert rc == 0
486
+ out = capsys.readouterr().out
487
+ assert "input : not stored" in out
488
+
489
+
490
+ def test_export_emits_jsonl_for_dataset_entries_and_skips_others(capsys):
491
+ import json
492
+
493
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
494
+ run_cli(
495
+ common
496
+ + [
497
+ "--prompt",
498
+ "STDOUT theanswer",
499
+ "--context",
500
+ "ctx",
501
+ "--system-prompt",
502
+ "terse",
503
+ "--persist",
504
+ "dataset",
505
+ ]
506
+ )
507
+ run_cli(common + ["--prompt", "STDOUT other", "--persist", "cache"]) # no input stored
508
+ capsys.readouterr()
509
+
510
+ rc = main(["export"])
511
+ captured = capsys.readouterr()
512
+ assert rc == 0
513
+ lines = [line for line in captured.out.splitlines() if line.strip()]
514
+ assert len(lines) == 1 # only the dataset entry carries an input
515
+ record = json.loads(lines[0])
516
+ assert record["input"] == {"context": "ctx", "prompt": "STDOUT theanswer", "system": "terse"}
517
+ assert "theanswer" in record["output"]["stdout"]
518
+ assert record["client"] == "fake" and record["model"] == "m1"
519
+ # the cache-only entry is reported as skipped, never silently dropped
520
+ assert "skipped 1" in captured.err
521
+
522
+
523
+ def test_export_filters_by_include_and_exclude_tags(capsys):
524
+ import json
525
+
526
+ common = [
527
+ "run",
528
+ "--client",
529
+ "fake",
530
+ "--model",
531
+ "m1",
532
+ "--effort",
533
+ "high",
534
+ "--persist",
535
+ "dataset",
536
+ ]
537
+ run_cli(common + ["--prompt", "STDOUT a", "--tag", "keep"])
538
+ run_cli(common + ["--prompt", "STDOUT b", "--tag", "drop"])
539
+ capsys.readouterr()
540
+
541
+ main(["export", "--tag", "keep"])
542
+ recs = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()]
543
+ assert len(recs) == 1 and recs[0]["tags"] == ["keep"]
544
+
545
+ main(["export", "--exclude-tag", "drop"])
546
+ recs = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()]
547
+ assert len(recs) == 1 and recs[0]["tags"] == ["keep"]
548
+
549
+
550
+ def test_export_writes_to_output_file(tmp_path, capsys):
551
+ import json
552
+
553
+ common = [
554
+ "run",
555
+ "--client",
556
+ "fake",
557
+ "--model",
558
+ "m1",
559
+ "--effort",
560
+ "high",
561
+ "--persist",
562
+ "dataset",
563
+ ]
564
+ run_cli(common + ["--prompt", "STDOUT a"])
565
+ capsys.readouterr()
566
+
567
+ out_file = tmp_path / "corpus.jsonl"
568
+ rc = main(["export", "--output", str(out_file)])
569
+ captured = capsys.readouterr()
570
+ assert rc == 0
571
+ records = [
572
+ json.loads(line)
573
+ for line in out_file.read_text(encoding="utf-8").splitlines()
574
+ if line.strip()
575
+ ]
576
+ assert len(records) == 1
577
+ assert captured.out == "" # nothing on stdout when writing a file
578
+ assert f"exported 1 record(s) to {out_file}" in captured.err
579
+
580
+
581
+ def test_dataset_hit_backfills_input_then_exports(capsys):
582
+ import json
583
+
584
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
585
+ run_cli(common + ["--prompt", "STDOUT hi", "--context", "ctx"]) # cache: output only
586
+ # same input at dataset depth: a hit that back-fills the input onto the entry
587
+ run_cli(common + ["--prompt", "STDOUT hi", "--context", "ctx", "--persist", "dataset"])
588
+ capsys.readouterr()
589
+
590
+ main(["export"])
591
+ recs = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()]
592
+ assert len(recs) == 1 # the (now-)dataset entry is exportable
593
+ assert recs[0]["input"] == {"context": "ctx", "prompt": "STDOUT hi"}
594
+
595
+
596
+ def test_export_empty_when_no_dataset_entries(capsys):
597
+ common = ["run", "--client", "fake", "--model", "m1", "--effort", "high"]
598
+ run_cli(common + ["--prompt", "STDOUT a", "--persist", "cache"])
599
+ capsys.readouterr()
600
+
601
+ rc = main(["export"])
602
+ captured = capsys.readouterr()
603
+ assert rc == 0
604
+ assert captured.out.strip() == ""
605
+ assert "exported 0 record(s)" in captured.err
606
+ assert "skipped 1" in captured.err
@@ -58,6 +58,7 @@ def test_precedence_default_then_config_then_env_then_flag(tmp_path, monkeypatch
58
58
  def test_default_when_nothing_set(tmp_path):
59
59
  settings = config.resolve_settings(config.load(tmp_path / "absent.ini"))
60
60
  assert settings["mode"] == ("cache", "default")
61
+ assert settings["persist"] == ("cache", "default")
61
62
  assert settings["store"] == (str(config.default_store_path()), "default")
62
63
  assert settings["timeout"] == (None, "default")
63
64
 
@@ -68,6 +69,30 @@ def test_invalid_env_mode_raises(monkeypatch, tmp_path):
68
69
  config.resolve_settings(config.load(tmp_path / "absent.ini"))
69
70
 
70
71
 
72
+ def test_persist_precedence_default_then_config_then_env_then_flag(tmp_path, monkeypatch):
73
+ # config file sets meter ...
74
+ p = _write(tmp_path / "c.ini", "[defaults]\npersist = meter\n")
75
+ cfg = config.load(p)
76
+ assert config.resolve_settings(cfg)["persist"] == ("meter", "config")
77
+ # ... env overrides the file ...
78
+ monkeypatch.setenv("GMLCACHE_PERSIST", "dataset")
79
+ assert config.resolve_settings(cfg)["persist"] == ("dataset", "env")
80
+ # ... and an explicit flag overrides env.
81
+ assert config.resolve_settings(cfg, persist_flag="cache")["persist"] == ("cache", "flag")
82
+
83
+
84
+ def test_invalid_persist_in_file_raises(tmp_path):
85
+ p = _write(tmp_path / "c.ini", "[defaults]\npersist = hoard\n")
86
+ with pytest.raises(ConfigError):
87
+ config.load(p)
88
+
89
+
90
+ def test_invalid_env_persist_raises(monkeypatch, tmp_path):
91
+ monkeypatch.setenv("GMLCACHE_PERSIST", "hoard")
92
+ with pytest.raises(ConfigError):
93
+ config.resolve_settings(config.load(tmp_path / "absent.ini"))
94
+
95
+
71
96
  def test_status_cli_reports_source_and_settings(tmp_path, monkeypatch, capsys):
72
97
  p = _write(tmp_path / "c.ini", "[defaults]\nmode = offline\nstore = vault\n")
73
98
  monkeypatch.setenv("GMLCACHE_CONFIG", str(p))
@@ -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