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.
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/PKG-INFO +13 -3
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/README.md +11 -1
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/pyproject.toml +2 -2
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/cli.py +176 -12
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/conftest.py +7 -1
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_cli.py +7 -4
- generic_ml_cache_cli-0.7.0/tests/test_session_cli.py +111 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/.gitignore +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/LICENSE +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/NOTICE +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/__init__.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/__main__.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/config.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/fake_client.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_config.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_discover.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_effort.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_encrypted_run.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_encryption_cli.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_interrupt.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_models.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_passthrough.py +0 -0
- {generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/tests/test_robustness.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
908
|
-
|
|
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 ·
|
|
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
|
|
915
|
-
|
|
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),
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
121
|
+
for var in (
|
|
122
|
+
"GMLCACHE_MODE",
|
|
123
|
+
"GMLCACHE_PERSIST",
|
|
124
|
+
"GMLCACHE_TIMEOUT",
|
|
125
|
+
"GMLCACHE_TOKEN",
|
|
126
|
+
"GMLCACHE_SESSION",
|
|
127
|
+
):
|
|
122
128
|
monkeypatch.delenv(var, raising=False)
|
|
123
129
|
|
|
124
130
|
|
|
@@ -134,8 +134,11 @@ def test_run_rejects_retired_location_flags(tmp_path):
|
|
|
134
134
|
def test_render_banner_lines_align():
|
|
135
135
|
from generic_ml_cache_cli.cli import render_banner
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
lines = render_banner(color=False).splitlines()
|
|
138
|
+
widths = {len(line) for line in lines}
|
|
139
|
+
assert len(widths) == 1 # every box line (top, four mark rows, bottom) is one width
|
|
140
|
+
assert len(lines) == 6 # the mark adds four bar rows inside the box
|
|
141
|
+
assert "═" in render_banner(color=False) # the hollow mark renders
|
|
139
142
|
|
|
140
143
|
|
|
141
144
|
def test_render_banner_color_is_opt_in():
|
|
@@ -162,7 +165,7 @@ def test_bare_invocation_prints_help_not_an_error(capsys):
|
|
|
162
165
|
out = capsys.readouterr().out
|
|
163
166
|
assert rc == 0
|
|
164
167
|
assert "gmlcache" in out
|
|
165
|
-
assert "record · replay · check ·
|
|
168
|
+
assert "record · replay · check · sessions · encryption" in out
|
|
166
169
|
assert "usage:" in out
|
|
167
170
|
|
|
168
171
|
|
|
@@ -176,7 +179,7 @@ def test_help_flag_shows_the_banner(capsys):
|
|
|
176
179
|
with pytest.raises(SystemExit) as excinfo:
|
|
177
180
|
main(["-h"])
|
|
178
181
|
assert excinfo.value.code == 0
|
|
179
|
-
assert "record · replay · check ·
|
|
182
|
+
assert "record · replay · check · sessions · encryption" in capsys.readouterr().out
|
|
180
183
|
|
|
181
184
|
|
|
182
185
|
# --- list (grouped by client/model) ---------------------------------------
|
|
@@ -0,0 +1,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/__init__.py
RENAMED
|
File without changes
|
{generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/__main__.py
RENAMED
|
File without changes
|
{generic_ml_cache_cli-0.5.0 → generic_ml_cache_cli-0.7.0}/src/generic_ml_cache_cli/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|