generic-ml-cache-cli 0.8.0__tar.gz → 0.9.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 (27) hide show
  1. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/PKG-INFO +3 -2
  2. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/README.md +1 -0
  3. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/pyproject.toml +2 -2
  4. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/src/generic_ml_cache_cli/cli.py +166 -65
  5. generic_ml_cache_cli-0.9.0/tests/test_alias.py +118 -0
  6. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/.gitignore +0 -0
  7. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/LICENSE +0 -0
  8. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/NOTICE +0 -0
  9. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/src/generic_ml_cache_cli/__init__.py +0 -0
  10. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/src/generic_ml_cache_cli/__main__.py +0 -0
  11. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/src/generic_ml_cache_cli/async_jobs.py +0 -0
  12. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/src/generic_ml_cache_cli/config.py +0 -0
  13. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/conftest.py +0 -0
  14. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/fake_client.py +0 -0
  15. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_async_jobs.py +0 -0
  16. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_cli.py +0 -0
  17. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_config.py +0 -0
  18. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_discover.py +0 -0
  19. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_effort.py +0 -0
  20. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_encrypted_run.py +0 -0
  21. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_encryption_cli.py +0 -0
  22. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_interrupt.py +0 -0
  23. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_models.py +0 -0
  24. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_passthrough.py +0 -0
  25. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_robustness.py +0 -0
  26. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.0}/tests/test_session_cli.py +0 -0
  27. {generic_ml_cache_cli-0.8.0 → generic_ml_cache_cli-0.9.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.8.0
3
+ Version: 0.9.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.8.0
27
+ Requires-Dist: generic-ml-cache-core>=0.9.0
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: coverage>=7; extra == 'dev'
30
30
  Requires-Dist: pytest-cov; extra == 'dev'
@@ -78,6 +78,7 @@ This installs the `gmlcache` command and pulls in the engine,
78
78
  gmlcache run --client claude --model sonnet --prompt "…" # record on a miss, replay on a hit
79
79
  gmlcache check --client claude --model sonnet --prompt "…" # forecast: is this exact call cached?
80
80
  gmlcache run --client claude --model sonnet --prompt "…" --detach # run detached → prints an execution id
81
+ gmlcache alias claude -- -p "…" --model sonnet # thin wrapper: cache a raw native call
81
82
  gmlcache execution watch <id> # follow a detached run's live progress
82
83
  gmlcache session report <id> # token usage by provider/model for a workflow
83
84
  gmlcache encrypt # encrypt the whole store at rest
@@ -43,6 +43,7 @@ This installs the `gmlcache` command and pulls in the engine,
43
43
  gmlcache run --client claude --model sonnet --prompt "…" # record on a miss, replay on a hit
44
44
  gmlcache check --client claude --model sonnet --prompt "…" # forecast: is this exact call cached?
45
45
  gmlcache run --client claude --model sonnet --prompt "…" --detach # run detached → prints an execution id
46
+ gmlcache alias claude -- -p "…" --model sonnet # thin wrapper: cache a raw native call
46
47
  gmlcache execution watch <id> # follow a detached run's live progress
47
48
  gmlcache session report <id> # token usage by provider/model for a workflow
48
49
  gmlcache encrypt # encrypt the whole store at rest
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "generic-ml-cache-cli"
7
- version = "0.8.0"
7
+ version = "0.9.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.8.0", "argcomplete>=3,<4"]
28
+ dependencies = ["generic-ml-cache-core>=0.9.0", "argcomplete>=3,<4"]
29
29
 
30
30
  [project.urls]
31
31
  Homepage = "https://github.com/danielslobozian/generic-ml-cache"
@@ -53,6 +53,9 @@ from generic_ml_cache_cli import async_jobs
53
53
  from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import (
54
54
  RunManagedLocalExecutionCommand,
55
55
  )
56
+ from generic_ml_cache_core.application.port.inbound.run_passthrough_execution_command import (
57
+ RunPassthroughExecutionCommand,
58
+ )
56
59
  from generic_ml_cache_core.application.port.out.base import ClientAdapter
57
60
  from generic_ml_cache_core.common.errors import (
58
61
  CacheError,
@@ -185,13 +188,7 @@ def _resolve_managed_run(args: argparse.Namespace):
185
188
  settings = config.resolve_settings(
186
189
  file_cfg, mode_flag=args.mode, persist_flag=args.persist, timeout_flag=args.timeout
187
190
  )
188
- # --offline / --force are explicit flags and win over the resolved mode.
189
- if args.offline:
190
- cache_mode = CacheMode.OFFLINE
191
- elif args.force:
192
- cache_mode = CacheMode.REFRESH
193
- else:
194
- cache_mode = CacheMode(str(settings["mode"][0]))
191
+ cache_mode = _resolve_cache_mode(args, settings)
195
192
 
196
193
  spec = {
197
194
  "client": args.client,
@@ -242,31 +239,29 @@ def _spec_executable_override(spec: dict):
242
239
  return lambda client: executable
243
240
 
244
241
 
245
- def _cmd_run(args: argparse.Namespace) -> int:
246
- try:
247
- spec, store_root, token = _resolve_managed_run(args)
248
- except ConfigError as exc:
249
- print(f"gmlc: {exc}", file=sys.stderr)
250
- return 4
242
+ def _resolve_cache_mode(args: argparse.Namespace, settings: dict) -> CacheMode:
243
+ """The cache mode for a run: --offline / --force are explicit flags and win over
244
+ the resolved (config/env/default) mode. Shared by managed `run` and `alias`."""
245
+ if args.offline:
246
+ return CacheMode.OFFLINE
247
+ if args.force:
248
+ return CacheMode.REFRESH
249
+ return CacheMode(str(settings["mode"][0]))
251
250
 
252
- if getattr(args, "detach", False):
253
- return _submit_detached(spec, store_root, token)
254
251
 
255
- command = _command_from_spec(spec)
252
+ def _run_cached_execution(execute):
253
+ """Run a wired ``execute()`` call, translating the failure modes shared by every
254
+ cached command into ``(None, exit_code)``; on success returns ``(execution, None)``.
255
+
256
+ Centralising the ladder keeps `run` and `alias` byte-identical on errors and means
257
+ the rarely-hit branches (interrupt, timeout) are covered once, by the `run` tests."""
256
258
  try:
257
- wired = build_use_cases(
258
- store_root,
259
- _spec_executable_override(spec),
260
- spec["timeout"],
261
- encryption_token=token,
262
- stream_path=getattr(args, "stream", None),
263
- )
264
- execution = wired.run_managed.execute(command)
259
+ return execute(), None
265
260
  except RunInterrupted as exc:
266
261
  # A requested stop, not a failure: nothing was recorded. Exit 130 is the
267
262
  # conventional "terminated by Ctrl-C".
268
263
  print(f"gmlc: {exc}", file=sys.stderr)
269
- return 130
264
+ return None, 130
270
265
  except subprocess.TimeoutExpired as exc:
271
266
  # The real call ran past --timeout and was killed before any record. Exit
272
267
  # 124 is the timeout(1) convention, distinct from miss (3) and error (4).
@@ -274,28 +269,102 @@ def _cmd_run(args: argparse.Namespace) -> int:
274
269
  f"gmlc: real call exceeded the {exc.timeout}s timeout and was killed; nothing recorded",
275
270
  file=sys.stderr,
276
271
  )
277
- return 124
272
+ return None, 124
278
273
  except CacheMiss as exc:
279
274
  print(f"gmlc: {exc}", file=sys.stderr)
280
- return 3
275
+ return None, 3
281
276
  except (EncryptionTokenRequired, WrongEncryptionToken) as exc:
282
277
  print(f"gmlc: {exc} (set --token or GMLCACHE_TOKEN)", file=sys.stderr)
283
- return 4
278
+ return None, 4
284
279
  except CacheError as exc:
280
+ print(f"gmlc: {exc}", file=sys.stderr)
281
+ return None, 4
282
+
283
+
284
+ def _relay_execution(execution: MlExecution) -> int:
285
+ """Reproduce a (live or replayed) call's stdout, stderr and exit code exactly --
286
+ the quiet-mode fidelity contract shared by `run` and `alias`."""
287
+ sys.stdout.write(_artifact_text(execution, ArtifactType.STDOUT))
288
+ sys.stdout.flush()
289
+ sys.stderr.write(_artifact_text(execution, ArtifactType.STDERR))
290
+ sys.stderr.flush()
291
+ return _run_exit_code(execution)
292
+
293
+
294
+ def _cmd_run(args: argparse.Namespace) -> int:
295
+ try:
296
+ spec, store_root, token = _resolve_managed_run(args)
297
+ except ConfigError as exc:
285
298
  print(f"gmlc: {exc}", file=sys.stderr)
286
299
  return 4
287
300
 
301
+ if getattr(args, "detach", False):
302
+ return _submit_detached(spec, store_root, token)
303
+
304
+ command = _command_from_spec(spec)
305
+ execution, error = _run_cached_execution(
306
+ lambda: build_use_cases(
307
+ store_root,
308
+ _spec_executable_override(spec),
309
+ spec["timeout"],
310
+ encryption_token=token,
311
+ stream_path=getattr(args, "stream", None),
312
+ ).run_managed.execute(command)
313
+ )
314
+ if error is not None:
315
+ return error
316
+
288
317
  # Materialise captured files into the cwd, exactly as the real client would.
289
318
  _apply_output_files(execution, Path.cwd())
290
319
 
291
320
  if getattr(args, "json", False):
292
321
  return _print_run_json(execution, command)
293
322
 
294
- sys.stdout.write(_artifact_text(execution, ArtifactType.STDOUT))
295
- sys.stdout.flush()
296
- sys.stderr.write(_artifact_text(execution, ArtifactType.STDERR))
297
- sys.stderr.flush()
298
- return _run_exit_code(execution)
323
+ return _relay_execution(execution)
324
+
325
+
326
+ def _cmd_alias(args: argparse.Namespace) -> int:
327
+ """`alias`: the thin native-client wrapper. Everything after the client is an
328
+ opaque native-argument tail, forwarded verbatim and keyed (by fingerprint) as
329
+ the cache identity. No isolation and no file capture -- a replay reproduces the
330
+ native call's stdout/stderr/exit, exactly as the default `run` does in quiet mode.
331
+ gmlcache's own options precede the client; the tail belongs to the native client."""
332
+ native_args = list(getattr(args, "native_args", None) or [])
333
+ # Accept an explicit `--` before the tail (`alias claude -- -p hi`) so a tail
334
+ # that opens with a dash never has to fight argparse; it is not a native arg.
335
+ if native_args and native_args[0] == "--":
336
+ native_args = native_args[1:]
337
+
338
+ try:
339
+ file_cfg = config.load()
340
+ settings = config.resolve_settings(
341
+ file_cfg, mode_flag=args.mode, persist_flag=args.persist, timeout_flag=args.timeout
342
+ )
343
+ executable = config.executable_for(file_cfg, args.client, flag=args.executable)
344
+ except ConfigError as exc:
345
+ print(f"gmlc: {exc}", file=sys.stderr)
346
+ return 4
347
+
348
+ command = RunPassthroughExecutionCommand(
349
+ client=args.client,
350
+ native_args=native_args,
351
+ cache_mode=_resolve_cache_mode(args, settings),
352
+ persistence_depth=PersistenceDepth(str(settings["persist"][0])),
353
+ record_on_error=bool(args.record_on_error),
354
+ session_id=_resolve_session(args),
355
+ )
356
+ store_root = Path(str(settings["store"][0]))
357
+ execution, error = _run_cached_execution(
358
+ lambda: build_use_cases(
359
+ store_root,
360
+ lambda _client: executable,
361
+ settings["timeout"][0],
362
+ encryption_token=_resolve_token(args),
363
+ ).run_passthrough.execute(command)
364
+ )
365
+ if error is not None:
366
+ return error
367
+ return _relay_execution(execution)
299
368
 
300
369
 
301
370
  def _submit_detached(spec: dict, store_root: Path, token: Optional[str]) -> int:
@@ -1506,6 +1575,44 @@ class _BannerParser(argparse.ArgumentParser):
1506
1575
  return render_banner(_use_color()) + "\n\n" + super().format_help()
1507
1576
 
1508
1577
 
1578
+ def _add_shared_run_options(parser: argparse.ArgumentParser) -> None:
1579
+ """Add the run-resolution options shared by `run` and `alias` (mode, persistence,
1580
+ record policy, the executable seam, encryption token, session, timeout). Both
1581
+ commands resolve a cached call the same way, so they share this surface verbatim."""
1582
+ parser.add_argument(
1583
+ "--mode",
1584
+ choices=[m.value for m in CacheMode],
1585
+ default=None,
1586
+ help="resolution mode (default: cache, or config/env)",
1587
+ )
1588
+ parser.add_argument(
1589
+ "--persist",
1590
+ choices=[d.value for d in PersistenceDepth],
1591
+ default=None,
1592
+ help=(
1593
+ "how much to keep: meter (usage only, never replays), cache (+output, "
1594
+ "the default), or dataset (+input) (default: cache, or config/env)"
1595
+ ),
1596
+ )
1597
+ parser.add_argument("--offline", action="store_true", help="shortcut for --mode offline")
1598
+ parser.add_argument("--force", action="store_true", help="shortcut for --mode refresh")
1599
+ parser.add_argument(
1600
+ "--record-on-error",
1601
+ action="store_true",
1602
+ help="also cache a call that fails (non-zero exit); default is to store only successes",
1603
+ )
1604
+ parser.add_argument("--executable", help="override the client executable (the seam)")
1605
+ parser.add_argument(
1606
+ "--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
1607
+ )
1608
+ parser.add_argument(
1609
+ "--session", help="group this run under a session id (or set GMLCACHE_SESSION)"
1610
+ )
1611
+ parser.add_argument(
1612
+ "--timeout", type=float, default=None, help="seconds before the real call is killed"
1613
+ )
1614
+
1615
+
1509
1616
  def build_parser() -> argparse.ArgumentParser:
1510
1617
  parser = _BannerParser(
1511
1618
  prog="gmlcache",
@@ -1596,38 +1703,7 @@ def build_parser() -> argparse.ArgumentParser:
1596
1703
  "as the workflow engine reading usage. Files are still written to the cwd."
1597
1704
  ),
1598
1705
  )
1599
- run.add_argument(
1600
- "--mode",
1601
- choices=[m.value for m in CacheMode],
1602
- default=None,
1603
- help="resolution mode (default: cache, or config/env)",
1604
- )
1605
- run.add_argument(
1606
- "--persist",
1607
- choices=[d.value for d in PersistenceDepth],
1608
- default=None,
1609
- help=(
1610
- "how much to keep: meter (usage only, never replays), cache (+output, "
1611
- "the default), or dataset (+input) (default: cache, or config/env)"
1612
- ),
1613
- )
1614
- run.add_argument("--offline", action="store_true", help="shortcut for --mode offline")
1615
- run.add_argument("--force", action="store_true", help="shortcut for --mode refresh")
1616
- run.add_argument(
1617
- "--record-on-error",
1618
- action="store_true",
1619
- help="also cache a call that fails (non-zero exit); default is to store only successes",
1620
- )
1621
- run.add_argument("--executable", help="override the client executable (the seam)")
1622
- run.add_argument(
1623
- "--token", help="encryption token for an encrypted store (or set GMLCACHE_TOKEN)"
1624
- )
1625
- run.add_argument(
1626
- "--session", help="group this run under a session id (or set GMLCACHE_SESSION)"
1627
- )
1628
- run.add_argument(
1629
- "--timeout", type=float, default=None, help="seconds before the real call is killed"
1630
- )
1706
+ _add_shared_run_options(run)
1631
1707
  run.add_argument(
1632
1708
  "-v",
1633
1709
  "--verbose",
@@ -1656,6 +1732,31 @@ def build_parser() -> argparse.ArgumentParser:
1656
1732
  )
1657
1733
  run.set_defaults(func=_cmd_run)
1658
1734
 
1735
+ aliasp = sub.add_parser(
1736
+ "alias",
1737
+ help=(
1738
+ "thin native-client wrapper: cache a raw native invocation -- everything "
1739
+ "after the client is passed to it verbatim and is the cache identity"
1740
+ ),
1741
+ description=(
1742
+ "Run a client through the cache as a thin wrapper. gmlcache's own options "
1743
+ "(below) come BEFORE the client; everything after the client is forwarded to "
1744
+ "it verbatim and keyed (by fingerprint) as the cache identity. No options are "
1745
+ "modelled or auto-completed. A replay reproduces the native call's stdout, "
1746
+ "stderr and exit; generated files are written by the live call only (no capture). "
1747
+ "Drop-in: alias claude='gmlcache alias claude'."
1748
+ ),
1749
+ )
1750
+ _add_shared_run_options(aliasp)
1751
+ aliasp.add_argument("client", choices=registered_names(), help="the native client to wrap")
1752
+ aliasp.add_argument(
1753
+ "native_args",
1754
+ nargs=argparse.REMAINDER,
1755
+ metavar="-- NATIVE_ARGS",
1756
+ help="the native client arguments, forwarded verbatim (this is the cache identity)",
1757
+ )
1758
+ aliasp.set_defaults(func=_cmd_alias)
1759
+
1659
1760
  # Internal: no help= so it never appears as a help row; metavar hides it from the list.
1660
1761
  worker = sub.add_parser("__worker")
1661
1762
  worker.add_argument("store_root")
@@ -0,0 +1,118 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Alias mode (`gmlcache alias <client> -- <native args>`).
4
+
5
+ The thin native-client wrapper: everything after the client is an opaque tail,
6
+ forwarded verbatim and keyed (by fingerprint) as the cache identity. No isolation,
7
+ no file capture -- a replay reproduces the native call's stdout/stderr/exit.
8
+
9
+ The ``fake`` adapter's executable is the Python interpreter, so a native tail of
10
+ ``-c <snippet>`` runs that snippet -- a portable stand-in for a real native call.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from generic_ml_cache_core.common.errors import CacheError
16
+
17
+ from generic_ml_cache_cli.cli import _run_cached_execution, main
18
+
19
+ # `fake`'s executable is sys.executable (python), so these tails run python directly.
20
+ # Write raw bytes via the stdout/stderr buffer so the output is identical on every OS
21
+ # (text-mode Python on Windows would translate "\n" -> "\r\n"); the cache relays bytes
22
+ # verbatim, so this is also the faithful thing to assert.
23
+ _HELLO = ["-c", "import sys; sys.stdout.buffer.write(b'hello\\n')"]
24
+ _BYE = ["-c", "import sys; sys.stdout.buffer.write(b'bye\\n')"]
25
+ _FAIL = ["-c", "import sys; sys.stderr.buffer.write(b'boom\\n'); sys.exit(7)"]
26
+
27
+
28
+ def test_alias_runs_the_native_call_and_relays_output(capsys):
29
+ assert main(["alias", "fake", *_HELLO]) == 0
30
+ assert capsys.readouterr().out == "hello\n"
31
+
32
+
33
+ def test_alias_replays_an_identical_tail_from_cache(capsys):
34
+ # First call records; a non-deterministic source proves the second is a replay.
35
+ tail = ["-c", "import os; print(os.getpid())"]
36
+ assert main(["alias", "fake", *tail]) == 0
37
+ first = capsys.readouterr().out
38
+ assert main(["alias", "fake", *tail]) == 0
39
+ second = capsys.readouterr().out
40
+ assert first == second # a fresh run would print a different pid
41
+
42
+
43
+ def test_alias_keys_on_the_raw_tail(capsys):
44
+ assert main(["alias", "fake", *_HELLO]) == 0
45
+ assert capsys.readouterr().out == "hello\n"
46
+ # A different tail is a different identity -> its own call, its own output.
47
+ assert main(["alias", "fake", *_BYE]) == 0
48
+ assert capsys.readouterr().out == "bye\n"
49
+
50
+
51
+ def test_alias_offline_miss_is_exit_3(capsys):
52
+ rc = main(["alias", "--offline", "fake", "-c", "print('never recorded')"])
53
+ assert rc == 3
54
+ assert "offline miss" in capsys.readouterr().err
55
+
56
+
57
+ def test_alias_force_refreshes_a_recorded_call(capsys):
58
+ tail = ["-c", "import os; print(os.getpid())"]
59
+ assert main(["alias", "fake", *tail]) == 0
60
+ recorded = capsys.readouterr().out
61
+ assert main(["alias", "--force", "fake", *tail]) == 0
62
+ refreshed = capsys.readouterr().out
63
+ assert recorded != refreshed # --force re-ran the native call
64
+
65
+
66
+ def test_alias_relays_a_native_failure_verbatim(capsys):
67
+ # The native exit code and stderr surface exactly; nothing is rewritten.
68
+ assert main(["alias", "fake", *_FAIL]) == 7
69
+ assert capsys.readouterr().err == "boom\n"
70
+
71
+
72
+ def test_alias_never_serves_a_recorded_failure_as_a_hit(capsys):
73
+ # --record-on-error keeps the failed call as history, but the cache never serves
74
+ # a failure as a hit -- a later offline call still misses (exit 3).
75
+ assert main(["alias", "--record-on-error", "fake", *_FAIL]) == 7
76
+ capsys.readouterr()
77
+ assert main(["alias", "--offline", "fake", *_FAIL]) == 3
78
+ assert "offline miss" in capsys.readouterr().err
79
+
80
+
81
+ def test_alias_accepts_an_explicit_double_dash_separator(capsys):
82
+ # `alias fake -- <tail>` strips the separator; identity matches the bare form.
83
+ assert main(["alias", "fake", "--", *_HELLO]) == 0
84
+ assert capsys.readouterr().out == "hello\n"
85
+ # Same call without the separator hits the cache (offline proves it).
86
+ assert main(["alias", "--offline", "fake", *_HELLO]) == 0
87
+ assert capsys.readouterr().out == "hello\n"
88
+
89
+
90
+ def test_alias_does_not_interpret_native_flags_as_its_own(capsys):
91
+ # `--offline` AFTER the client is native (here, ignored by python -c), not a
92
+ # gmlcache flag: the call runs and records rather than failing as an offline miss.
93
+ assert (
94
+ main(["alias", "fake", "-c", "import sys; sys.stdout.buffer.write(b'ok\\n')", "--offline"])
95
+ == 0
96
+ )
97
+ assert capsys.readouterr().out == "ok\n"
98
+
99
+
100
+ def test_alias_strips_a_redundant_double_dash_from_the_tail(capsys):
101
+ # argparse's REMAINDER already eats the first `--`; a second literal `--` (a tail
102
+ # that genuinely begins with `--`) is stripped by alias itself, so it is forwarded
103
+ # cleanly. `python -- -c <snippet>` would fail, so a clean `ok` proves the strip.
104
+ rc = main(["alias", "fake", "--", "--", "-c", "import sys; sys.stdout.buffer.write(b'ok\\n')"])
105
+ assert rc == 0
106
+ assert capsys.readouterr().out == "ok\n"
107
+
108
+
109
+ def test_run_cached_execution_maps_a_cache_error_to_exit_4(capsys):
110
+ # The shared ladder's catch-all: any CacheError that is not a more specific
111
+ # subclass becomes a clean exit 4, never an uncaught traceback.
112
+ def boom():
113
+ raise CacheError("something went wrong")
114
+
115
+ execution, code = _run_cached_execution(boom)
116
+ assert execution is None
117
+ assert code == 4
118
+ assert "something went wrong" in capsys.readouterr().err