generic-ml-cache-cli 0.5.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.
Files changed (24) hide show
  1. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/PKG-INFO +13 -3
  2. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/README.md +11 -1
  3. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/pyproject.toml +2 -2
  4. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/cli.py +105 -12
  5. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/conftest.py +7 -1
  6. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_cli.py +7 -4
  7. generic_ml_cache_cli-0.6.0/tests/test_session_cli.py +97 -0
  8. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/.gitignore +0 -0
  9. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/LICENSE +0 -0
  10. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/NOTICE +0 -0
  11. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/__init__.py +0 -0
  12. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/__main__.py +0 -0
  13. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/src/generic_ml_cache_cli/config.py +0 -0
  14. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/fake_client.py +0 -0
  15. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_config.py +0 -0
  16. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_discover.py +0 -0
  17. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_effort.py +0 -0
  18. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_encrypted_run.py +0 -0
  19. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_encryption_cli.py +0 -0
  20. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_interrupt.py +0 -0
  21. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_models.py +0 -0
  22. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_passthrough.py +0 -0
  23. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.6.0}/tests/test_robustness.py +0 -0
  24. {generic_ml_cache_cli-0.5.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.5.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.5.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
- # gmlcache
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>
@@ -1,4 +1,9 @@
1
- # gmlcache
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "generic-ml-cache-cli"
7
- version = "0.5.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.5.0", "argcomplete>=3,<4"]
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"
@@ -195,6 +195,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
195
195
  persistence_depth=persistence_depth,
196
196
  record_on_error=args.record_on_error,
197
197
  tags=list(getattr(args, "tag", None) or []),
198
+ session_id=_resolve_session(args),
198
199
  )
199
200
 
200
201
  def executable_override(client: str):
@@ -877,6 +878,71 @@ def _cmd_invalidate(args: argparse.Namespace) -> int:
877
878
  return 0
878
879
 
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
+
880
946
  def _use_color() -> bool:
881
947
  """Colour only when writing to a real terminal and NO_COLOR is unset, so piped
882
948
  or redirected output never carries escape codes (the conventional contract)."""
@@ -904,34 +970,48 @@ def _paint(text: str, *codes: str) -> str:
904
970
 
905
971
 
906
972
  def render_banner(color: bool = False) -> str:
907
- """The boxed gmlcache banner. Width is derived from the content, so any version
908
- string or tagline stays aligned. ``color`` adds teal ANSI; off yields plain text."""
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."""
909
976
  title = "gmlcache"
910
977
  ver = __version__
911
- tag = "record · replay · check · tokens"
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)
912
984
 
913
985
  if color:
914
- rule = _TEAL # teal box
915
- name = _BOLD # bold title
916
- vers = _TEAL_BRIGHT # bright-teal version
917
- sub = _GREY # dim-grey tagline
918
- off = _RESET
986
+ rule, name, vers, sub, off = _TEAL, _BOLD, _TEAL_BRIGHT, _GREY, _RESET
987
+ bar_colors = [_GREEN, _GREY, _GREY, _GREY]
919
988
  else:
920
989
  rule = name = vers = sub = off = ""
990
+ bar_colors = ["", "", "", ""]
991
+
992
+ left_pad, gap = " ", " "
993
+ texts = ["", tag, "", ""] # the tagline sits on the second bar row
921
994
 
995
+ body_w = max(len(left_pad) + bar_w + len(gap) + len(t) for t in texts)
922
996
  left_top = f"─ {title} "
923
997
  right_top = f" {ver} ─"
924
- inner = max(len(left_top) + 6 + len(right_top), len(tag) + 4)
998
+ inner = max(len(left_top) + 6 + len(right_top), body_w + 1)
925
999
  top_dashes = inner - len(left_top) - len(right_top)
926
- pad_right = inner - 2 - len(tag)
927
1000
 
928
1001
  top = (
929
1002
  f"{rule}┌─ {off}{name}{title}{off}"
930
1003
  f"{rule} {'─' * top_dashes} {off}{vers}{ver}{off}{rule} ─┐{off}"
931
1004
  )
932
- mid = f"{rule}│{off} {sub}{tag}{off}{' ' * pad_right}{rule}│{off}"
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
+ )
933
1013
  bot = f"{rule}└{'─' * inner}┘{off}"
934
- return "\n".join([top, mid, bot])
1014
+ return "\n".join([top, *rows, bot])
935
1015
 
936
1016
 
937
1017
  class _BannerParser(argparse.ArgumentParser):
@@ -1056,6 +1136,9 @@ def build_parser() -> argparse.ArgumentParser:
1056
1136
  run.add_argument(
1057
1137
  "--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
1058
1138
  )
1139
+ run.add_argument(
1140
+ "--session", help="group this run under a session id (or set GMLCACHE_SESSION)"
1141
+ )
1059
1142
  run.add_argument(
1060
1143
  "--timeout", type=float, default=None, help="seconds before the real call is killed"
1061
1144
  )
@@ -1242,6 +1325,16 @@ def build_parser() -> argparse.ArgumentParser:
1242
1325
  invalidatep.add_argument("--yes", action="store_true", help="confirm the irreversible wipe")
1243
1326
  invalidatep.set_defaults(func=_cmd_invalidate)
1244
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
+
1245
1338
  init = sub.add_parser(
1246
1339
  "init",
1247
1340
  help="create the config file in the default location (if absent), then show the store",
@@ -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 ("GMLCACHE_MODE", "GMLCACHE_PERSIST", "GMLCACHE_TIMEOUT", "GMLCACHE_TOKEN"):
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
- widths = {len(line) for line in render_banner(color=False).splitlines()}
138
- assert len(widths) == 1 # all three box lines are the same width
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 · tokens" in out
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 · tokens" in capsys.readouterr().out
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,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