generic-ml-cache-cli 0.5.0__tar.gz → 0.7.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.7.0}/PKG-INFO +13 -3
  2. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/README.md +11 -1
  3. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/pyproject.toml +2 -2
  4. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/cli.py +176 -12
  5. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/conftest.py +7 -1
  6. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_cli.py +7 -4
  7. generic_ml_cache_cli-0.7.0/tests/test_session_cli.py +111 -0
  8. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/.gitignore +0 -0
  9. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/LICENSE +0 -0
  10. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/NOTICE +0 -0
  11. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/__init__.py +0 -0
  12. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/__main__.py +0 -0
  13. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/config.py +0 -0
  14. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/fake_client.py +0 -0
  15. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_config.py +0 -0
  16. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_discover.py +0 -0
  17. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_effort.py +0 -0
  18. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_encrypted_run.py +0 -0
  19. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_encryption_cli.py +0 -0
  20. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_interrupt.py +0 -0
  21. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_models.py +0 -0
  22. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_passthrough.py +0 -0
  23. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_robustness.py +0 -0
  24. {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.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.7.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.7.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.7.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.7.0", "argcomplete>=3,<4"]
29
29
 
30
30
  [project.urls]
31
31
  Homepage = "https://github.com/danielslobozian/generic-ml-cache"
@@ -45,6 +45,7 @@ from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheM
45
45
  from generic_ml_cache_core.application.domain.model.run.persistence_depth import PersistenceDepth
46
46
  from generic_ml_cache_core.application.domain.model.execution.execution_state import ExecutionState
47
47
  from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
48
+ from generic_ml_cache_core.application.usecase.session_report import build_session_report
48
49
  from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import (
49
50
  RunManagedLocalExecutionCommand,
50
51
  )
@@ -195,6 +196,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
195
196
  persistence_depth=persistence_depth,
196
197
  record_on_error=args.record_on_error,
197
198
  tags=list(getattr(args, "tag", None) or []),
199
+ session_id=_resolve_session(args),
198
200
  )
199
201
 
200
202
  def executable_override(client: str):
@@ -877,6 +879,141 @@ def _cmd_invalidate(args: argparse.Namespace) -> int:
877
879
  return 0
878
880
 
879
881
 
882
+ # -- sessions ---------------------------------------------------------------
883
+
884
+
885
+ def _resolve_session(args: argparse.Namespace) -> Optional[str]:
886
+ """The session id for this run: the --session flag, else GMLCACHE_SESSION. A session
887
+ groups a workflow's calls; it is journal metadata, never part of the cache key."""
888
+ flag = getattr(args, "session", None)
889
+ return flag if flag else (os.environ.get("GMLCACHE_SESSION") or None)
890
+
891
+
892
+ def _cmd_session_start(args: argparse.Namespace) -> int:
893
+ import secrets
894
+
895
+ # Print only the id, so it is scriptable: SESSION=$(gmlcache session start)
896
+ print(secrets.token_hex(8))
897
+ return 0
898
+
899
+
900
+ _TOKEN_BLOCKS = " ▏▎▍▌▋▊▉█"
901
+
902
+
903
+ def _activity_bar(value: int, maxval: int, width: int = 10) -> str:
904
+ if maxval <= 0:
905
+ return " " * width
906
+ filled = value / maxval * width
907
+ full = int(filled)
908
+ bar = "█" * full + (_TOKEN_BLOCKS[int((filled - full) * 8)] if full < width else "")
909
+ return (bar + " " * width)[:width]
910
+
911
+
912
+ def _comma(n: int) -> str:
913
+ return f"{n:,}"
914
+
915
+
916
+ def _render_session_report(report) -> str:
917
+ lines = [f"session : {report.session_id}"]
918
+ if report.span_start:
919
+ span = (
920
+ report.span_start
921
+ if report.day_count == 1
922
+ else f"{report.span_start} → {report.span_end}"
923
+ )
924
+ plural = "" if report.day_count == 1 else "s"
925
+ lines.append(f"span : {span} ({report.day_count} day{plural})")
926
+ lines.append(
927
+ f"invocations : {report.invocations} "
928
+ f"executions : {report.executions} hits : {report.hits}"
929
+ )
930
+ if report.unknown_usage:
931
+ lines.append(f"unknown : {report.unknown_usage} execution(s) reported no usage")
932
+ if report.by_model:
933
+ lines.append("")
934
+ lines.append("by provider / model:")
935
+ for m in report.by_model:
936
+ lines.append(
937
+ f" {m.client + ' / ' + m.model:<16} spent {_comma(m.spent_tokens):>9} tok"
938
+ f" (in {_comma(m.spent_input):>8} · out {_comma(m.spent_output):>7})"
939
+ f" saved {_comma(m.saved_tokens):>9} tok {m.executions} exec · {m.hits} hit"
940
+ )
941
+ if report.by_day:
942
+ lines.append("")
943
+ lines.append("by day (activity):")
944
+ maxinv = max(d.invocations for d in report.by_day)
945
+ for d in report.by_day:
946
+ lines.append(
947
+ f" {d.day} {_activity_bar(d.invocations, maxinv)} {d.invocations:>3} calls"
948
+ f" ({d.executions} exec · {d.hits} hit)"
949
+ )
950
+ return "\n".join(lines)
951
+
952
+
953
+ def _session_report_json(report) -> dict:
954
+ return {
955
+ "session": report.session_id,
956
+ "invocations": report.invocations,
957
+ "executions": report.executions,
958
+ "hits": report.hits,
959
+ "unknown_usage": report.unknown_usage,
960
+ "span": {"start": report.span_start, "end": report.span_end, "days": report.day_count},
961
+ "by_model": [
962
+ {
963
+ "client": m.client,
964
+ "model": m.model,
965
+ "spent_input": m.spent_input,
966
+ "spent_output": m.spent_output,
967
+ "spent_tokens": m.spent_tokens,
968
+ "saved_tokens": m.saved_tokens,
969
+ "executions": m.executions,
970
+ "hits": m.hits,
971
+ }
972
+ for m in report.by_model
973
+ ],
974
+ "by_day": [
975
+ {
976
+ "day": d.day,
977
+ "invocations": d.invocations,
978
+ "executions": d.executions,
979
+ "hits": d.hits,
980
+ }
981
+ for d in report.by_day
982
+ ],
983
+ }
984
+
985
+
986
+ def _cmd_session_report(args: argparse.Namespace) -> int:
987
+ store_root = _store_root()
988
+ if store_root is None:
989
+ return 4
990
+ wired = build_use_cases(store_root)
991
+ events = wired.metrics.session_events(args.session_id)
992
+ # Join each event's execution to its token usage (the current execution per key).
993
+ usage_by_key = {}
994
+ for key in {e.execution_key for e in events if e.execution_key}:
995
+ execution = wired.repository.find_current(key)
996
+ if execution is not None:
997
+ usage_by_key[key] = execution.token_usage
998
+ report = build_session_report(args.session_id, events, usage_by_key)
999
+
1000
+ if args.json:
1001
+ import json
1002
+
1003
+ print(json.dumps(_session_report_json(report), indent=2))
1004
+ return 0
1005
+ if report.invocations == 0:
1006
+ print(f"no events recorded for session {args.session_id!r}")
1007
+ return 0
1008
+ print(_render_session_report(report))
1009
+ return 0
1010
+
1011
+
1012
+ def _cmd_session(args: argparse.Namespace) -> int:
1013
+ print("usage: gmlcache session start | gmlcache session report <id>", file=sys.stderr)
1014
+ return 2
1015
+
1016
+
880
1017
  def _use_color() -> bool:
881
1018
  """Colour only when writing to a real terminal and NO_COLOR is unset, so piped
882
1019
  or redirected output never carries escape codes (the conventional contract)."""
@@ -904,34 +1041,48 @@ def _paint(text: str, *codes: str) -> str:
904
1041
 
905
1042
 
906
1043
  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."""
1044
+ """The boxed gmlcache banner: the cache mark (four hollow bars; the top one is
1045
+ the accent 'hit') beside the title, version, and tagline. Width is derived from
1046
+ the content so everything stays aligned. ``color`` adds ANSI; off yields plain."""
909
1047
  title = "gmlcache"
910
1048
  ver = __version__
911
- tag = "record · replay · check · tokens"
1049
+ tag = "record · replay · check · sessions · encryption"
1050
+
1051
+ # The mark: four hollow bars -- thin walls (▏ ▕) around a double-line body (═),
1052
+ # widths echoing the logo. The first bar is the accent ("hit"); the rest are dim.
1053
+ bars = ["▏" + "═" * n + "▕" for n in (11, 7, 10, 5)]
1054
+ bar_w = max(len(b) for b in bars)
912
1055
 
913
1056
  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
1057
+ rule, name, vers, sub, off = _TEAL, _BOLD, _TEAL_BRIGHT, _GREY, _RESET
1058
+ bar_colors = [_GREEN, _GREY, _GREY, _GREY]
919
1059
  else:
920
1060
  rule = name = vers = sub = off = ""
1061
+ bar_colors = ["", "", "", ""]
921
1062
 
1063
+ left_pad, gap = " ", " "
1064
+ texts = ["", tag, "", ""] # the tagline sits on the second bar row
1065
+
1066
+ body_w = max(len(left_pad) + bar_w + len(gap) + len(t) for t in texts)
922
1067
  left_top = f"─ {title} "
923
1068
  right_top = f" {ver} ─"
924
- inner = max(len(left_top) + 6 + len(right_top), len(tag) + 4)
1069
+ inner = max(len(left_top) + 6 + len(right_top), body_w + 1)
925
1070
  top_dashes = inner - len(left_top) - len(right_top)
926
- pad_right = inner - 2 - len(tag)
927
1071
 
928
1072
  top = (
929
1073
  f"{rule}┌─ {off}{name}{title}{off}"
930
1074
  f"{rule} {'─' * top_dashes} {off}{vers}{ver}{off}{rule} ─┐{off}"
931
1075
  )
932
- mid = f"{rule}│{off} {sub}{tag}{off}{' ' * pad_right}{rule}│{off}"
1076
+ rows = []
1077
+ for bar, bar_color, text in zip(bars, bar_colors, texts):
1078
+ bar_cell = f"{bar_color}{bar}{off}" + " " * (bar_w - len(bar))
1079
+ used = len(left_pad) + bar_w + len(gap) + len(text)
1080
+ rows.append(
1081
+ f"{rule}│{off}{left_pad}{bar_cell}{gap}{sub}{text}{off}"
1082
+ f"{' ' * (inner - used)}{rule}│{off}"
1083
+ )
933
1084
  bot = f"{rule}└{'─' * inner}┘{off}"
934
- return "\n".join([top, mid, bot])
1085
+ return "\n".join([top, *rows, bot])
935
1086
 
936
1087
 
937
1088
  class _BannerParser(argparse.ArgumentParser):
@@ -1056,6 +1207,9 @@ def build_parser() -> argparse.ArgumentParser:
1056
1207
  run.add_argument(
1057
1208
  "--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
1058
1209
  )
1210
+ run.add_argument(
1211
+ "--session", help="group this run under a session id (or set GMLCACHE_SESSION)"
1212
+ )
1059
1213
  run.add_argument(
1060
1214
  "--timeout", type=float, default=None, help="seconds before the real call is killed"
1061
1215
  )
@@ -1242,6 +1396,16 @@ def build_parser() -> argparse.ArgumentParser:
1242
1396
  invalidatep.add_argument("--yes", action="store_true", help="confirm the irreversible wipe")
1243
1397
  invalidatep.set_defaults(func=_cmd_invalidate)
1244
1398
 
1399
+ session = sub.add_parser("session", help="group a workflow's runs under a session id")
1400
+ session_sub = session.add_subparsers(dest="session_command")
1401
+ session_start = session_sub.add_parser("start", help="generate a new session id and print it")
1402
+ session_start.set_defaults(func=_cmd_session_start)
1403
+ session_report = session_sub.add_parser("report", help="summarise a session's activity")
1404
+ session_report.add_argument("session_id", help="the session id to report on")
1405
+ session_report.add_argument("--json", action="store_true", help="emit machine-readable JSON")
1406
+ session_report.set_defaults(func=_cmd_session_report)
1407
+ session.set_defaults(func=_cmd_session)
1408
+
1245
1409
  init = sub.add_parser(
1246
1410
  "init",
1247
1411
  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,111 @@
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 executions : 1 hits : 1" in out
75
+ assert "by provider / model:" in out and "fake / m1" in out
76
+ assert "by day (activity):" in out
77
+ # no dollars anywhere in the render (cost is a client-specific advisory estimate)
78
+ assert "$" not in out and "cost" not in out.lower()
79
+
80
+
81
+ def test_session_report_json(capsys):
82
+ import json
83
+
84
+ main(_RUN + ["--session", "wf"]) # one record (the fake client reports no usage)
85
+ capsys.readouterr()
86
+ assert main(["session", "report", "wf", "--json"]) == 0
87
+ out = capsys.readouterr().out
88
+ assert "cost" not in out.lower() and "usd" not in out.lower() and "$" not in out
89
+ data = json.loads(out)
90
+ assert data["session"] == "wf"
91
+ assert (data["invocations"], data["executions"], data["hits"]) == (1, 1, 0)
92
+ assert data["unknown_usage"] == 1
93
+ assert data["span"]["days"] == 1
94
+ assert data["by_model"] == [
95
+ {
96
+ "client": "fake",
97
+ "model": "m1",
98
+ "spent_input": 0,
99
+ "spent_output": 0,
100
+ "spent_tokens": 0,
101
+ "saved_tokens": 0,
102
+ "executions": 1,
103
+ "hits": 0,
104
+ }
105
+ ]
106
+ assert len(data["by_day"]) == 1 and data["by_day"][0]["invocations"] == 1
107
+
108
+
109
+ def test_session_report_unknown_session_is_clean(capsys):
110
+ assert main(["session", "report", "nope"]) == 0
111
+ assert "no events" in capsys.readouterr().out