aiuse 3.3.0__tar.gz → 3.3.2__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 (102) hide show
  1. {aiuse-3.3.0/src/aiuse.egg-info → aiuse-3.3.2}/PKG-INFO +1 -1
  2. {aiuse-3.3.0 → aiuse-3.3.2}/pyproject.toml +1 -1
  3. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/__init__.py +1 -1
  4. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/cli.py +13 -0
  5. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/base.py +75 -0
  6. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/openusage.py +75 -3
  7. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/runner.py +18 -1
  8. aiuse-3.3.2/src/aiuse/tty.py +88 -0
  9. {aiuse-3.3.0 → aiuse-3.3.2/src/aiuse.egg-info}/PKG-INFO +1 -1
  10. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/SOURCES.txt +1 -0
  11. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_base_run_json.py +35 -1
  12. aiuse-3.3.2/tests/test_openusage_cli_resolution.py +162 -0
  13. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_query_throttle.py +2 -2
  14. aiuse-3.3.2/tests/test_tty.py +117 -0
  15. aiuse-3.3.0/src/aiuse/tty.py +0 -41
  16. aiuse-3.3.0/tests/test_tty.py +0 -20
  17. {aiuse-3.3.0 → aiuse-3.3.2}/LICENSE +0 -0
  18. {aiuse-3.3.0 → aiuse-3.3.2}/README.md +0 -0
  19. {aiuse-3.3.0 → aiuse-3.3.2}/setup.cfg +0 -0
  20. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/__main__.py +0 -0
  21. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/agent_notes.py +0 -0
  22. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/ai_stub.py +0 -0
  23. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/__init__.py +0 -0
  24. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/history.py +0 -0
  25. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/local_runtimes.py +0 -0
  26. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/pace.py +0 -0
  27. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/selfdescribe.py +0 -0
  28. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/suggest.py +0 -0
  29. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/use_or_lose.py +0 -0
  30. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/attribute.py +0 -0
  31. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/chat_format.py +0 -0
  32. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/__init__.py +0 -0
  33. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/bailian.py +0 -0
  34. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/caut.py +0 -0
  35. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/clinepass.py +0 -0
  36. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/codexbar.py +0 -0
  37. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/cswap.py +0 -0
  38. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/grok.py +0 -0
  39. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/hermes.py +0 -0
  40. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/muse.py +0 -0
  41. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/opencode_go.py +0 -0
  42. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/opencode_zen.py +0 -0
  43. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/openrouter.py +0 -0
  44. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/openusage_sh.py +0 -0
  45. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/qwencloud.py +0 -0
  46. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/throttle.py +0 -0
  47. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/tokscale.py +0 -0
  48. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/config.py +0 -0
  49. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/credentials.py +0 -0
  50. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/ledger.py +0 -0
  51. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/macos_trust.py +0 -0
  52. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/models.py +0 -0
  53. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/report.py +0 -0
  54. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/sampler.py +0 -0
  55. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/secretspec.py +0 -0
  56. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/serve.py +0 -0
  57. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/tui/__init__.py +0 -0
  58. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/tui/app.py +0 -0
  59. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/tui/builders.py +0 -0
  60. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/watch.py +0 -0
  61. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/dependency_links.txt +0 -0
  62. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/entry_points.txt +0 -0
  63. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/requires.txt +0 -0
  64. {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/top_level.txt +0 -0
  65. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_attribution.py +0 -0
  66. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_bailian.py +0 -0
  67. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_caut_parse.py +0 -0
  68. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_chat_format.py +0 -0
  69. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_claude_sublimits.py +0 -0
  70. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_cli.py +0 -0
  71. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_clinepass.py +0 -0
  72. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_codexbar_parse.py +0 -0
  73. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_config.py +0 -0
  74. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_credentials.py +0 -0
  75. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_cswap_parse.py +0 -0
  76. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_grok.py +0 -0
  77. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_hermes_parse.py +0 -0
  78. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_history.py +0 -0
  79. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_install_deps.py +0 -0
  80. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_local_runtimes.py +0 -0
  81. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_macos_trust.py +0 -0
  82. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_models_cli_binary.py +0 -0
  83. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_models_pace.py +0 -0
  84. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_muse.py +0 -0
  85. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_opencode_go.py +0 -0
  86. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_opencode_zen.py +0 -0
  87. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_openrouter.py +0 -0
  88. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_openusage_parse.py +0 -0
  89. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_pace.py +0 -0
  90. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_qwencloud.py +0 -0
  91. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_release_script.py +0 -0
  92. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_report.py +0 -0
  93. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_runner_consolidation.py +0 -0
  94. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_selfdescribe.py +0 -0
  95. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_serve.py +0 -0
  96. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_shared_quota_semantics.py +0 -0
  97. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_snapshots_persistence.py +0 -0
  98. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_suggest.py +0 -0
  99. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_tokscale_parse.py +0 -0
  100. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_tui.py +0 -0
  101. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_use_or_lose.py +0 -0
  102. {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
5
5
  Author: djbclark
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.3.0"
7
+ version = "3.3.2"
8
8
  description = "Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
2
 
3
- __version__ = "3.3.0"
3
+ __version__ = "3.3.2"
@@ -878,6 +878,19 @@ def diagnose(
878
878
  for collector_key, cmd, version_argv in _EXTERNAL_TOOLS:
879
879
  enabled = _collector_enabled(config, collector_key)
880
880
  path = lookup(cmd)
881
+ if collector_key == "openusage_ai" and which_fn is None and probe:
882
+ # Two products ship an ``openusage`` binary; only OpenUsage.app's
883
+ # prints JSON. Report the one the collector will actually run and
884
+ # warn about any same-named binary it had to skip.
885
+ from aiuse.collectors.openusage import resolve_app_cli
886
+
887
+ path, foreign = resolve_app_cli()
888
+ if foreign:
889
+ used = f"using {path}" if path else "no OpenUsage.app CLI found, loopback HTTP only"
890
+ lines.append(
891
+ f" warning {', '.join(foreign)} is not OpenUsage.app's CLI "
892
+ f"(openusage.sh's terminal dashboard?) — {used}"
893
+ )
881
894
  if path:
882
895
  status = "ok"
883
896
  detail = path
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import shutil
7
8
  import subprocess
9
+ from collections.abc import Callable
8
10
  from typing import Any
9
11
 
10
12
 
@@ -21,6 +23,70 @@ def which(cmd: str) -> str | None:
21
23
  return shutil.which(cmd)
22
24
 
23
25
 
26
+ def which_all(cmd: str, *, extra_paths: tuple[str, ...] = ()) -> list[str]:
27
+ """Every executable named ``cmd`` on PATH, in PATH order, then ``extra_paths``.
28
+
29
+ Duplicates (the same file reached through different symlinks or PATH
30
+ entries) are dropped, keeping the first. Unlike :func:`which` this lets a
31
+ collector look past a same-named binary from an unrelated product that
32
+ happens to sit earlier on PATH.
33
+ """
34
+ seen: set[str] = set()
35
+ found: list[str] = []
36
+ search = [os.path.join(d, cmd) for d in os.environ.get("PATH", "").split(os.pathsep) if d]
37
+ search.extend(os.path.expanduser(p) for p in extra_paths)
38
+ for path in search:
39
+ if not (os.path.isfile(path) and os.access(path, os.X_OK)):
40
+ continue
41
+ real = os.path.realpath(path)
42
+ if real in seen:
43
+ continue
44
+ seen.add(real)
45
+ found.append(path)
46
+ return found
47
+
48
+
49
+ def first_tool(
50
+ cmd: str,
51
+ accept: Callable[[str], bool],
52
+ *,
53
+ extra_paths: tuple[str, ...] = (),
54
+ ) -> tuple[str | None, list[str]]:
55
+ """The first ``cmd`` candidate that ``accept`` approves, plus the rejected ones.
56
+
57
+ ``accept`` is called with each candidate path in :func:`which_all` order
58
+ until one passes; the rejected candidates are returned for diagnostics.
59
+ """
60
+ rejected: list[str] = []
61
+ for path in which_all(cmd, extra_paths=extra_paths):
62
+ if accept(path):
63
+ return path, rejected
64
+ rejected.append(path)
65
+ return None, rejected
66
+
67
+
68
+ def probe_output(argv: list[str], *, timeout: float = 5.0) -> str:
69
+ """stdout+stderr of a short, detached, non-interactive probe ("" on failure).
70
+
71
+ Same isolation as :func:`run_json` (no stdin, new session) so a binary
72
+ that would open a TUI on /dev/tty fails fast instead of taking over the
73
+ user's terminal.
74
+ """
75
+ try:
76
+ proc = subprocess.run(
77
+ argv,
78
+ stdin=subprocess.DEVNULL,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=timeout,
82
+ check=False,
83
+ start_new_session=True,
84
+ )
85
+ except (OSError, subprocess.SubprocessError):
86
+ return ""
87
+ return (proc.stdout or "") + (proc.stderr or "")
88
+
89
+
24
90
  def run_json(
25
91
  argv: list[str],
26
92
  *,
@@ -32,6 +98,14 @@ def run_json(
32
98
  # Never inherit the caller's TTY on stdin. Tools like ``caut usage``
33
99
  # put stdin into raw mode; on timeout/kill that leaves the shell with
34
100
  # echo off until the user runs ``reset``.
101
+ #
102
+ # ``start_new_session`` also detaches the child from the controlling
103
+ # terminal, so a tool that opens /dev/tty directly (a Go/bubbletea TUI
104
+ # such as openusage.sh's ``openusage`` does this when stdin is not a
105
+ # terminal) cannot switch the user's terminal into raw mode at all:
106
+ # its open() fails and it exits instead of hanging until the timeout.
107
+ # Collector children are non-interactive by contract, so nothing
108
+ # legitimate needs the terminal.
35
109
  proc = subprocess.run(
36
110
  argv,
37
111
  stdin=subprocess.DEVNULL,
@@ -39,6 +113,7 @@ def run_json(
39
113
  text=True,
40
114
  timeout=timeout,
41
115
  check=False,
116
+ start_new_session=True,
42
117
  )
43
118
  except FileNotFoundError as exc:
44
119
  raise CollectorError(f"command not found: {argv[0]}") from exc
@@ -11,6 +11,7 @@ Schema: ``openusage.limits.v1`` — providers keyed by id with ``resources``.
11
11
  from __future__ import annotations
12
12
 
13
13
  import json
14
+ import os
14
15
  import subprocess
15
16
  import urllib.error
16
17
  import urllib.parse
@@ -30,9 +31,80 @@ from aiuse.models import (
30
31
  coerce_float as _f,
31
32
  )
32
33
 
33
- from .base import CollectorError, run_json, which
34
+ from .base import CollectorError, first_tool, probe_output, run_json
34
35
  from .throttle import QueryGate
35
36
 
37
+ # Two unrelated products ship a binary called ``openusage``. OpenUsage.app's
38
+ # CLI (a symlink to the bundled helper below, installed from Settings →
39
+ # Command Line) prints JSON and exits; openusage.sh's Go binary opens a
40
+ # full-screen terminal dashboard on /dev/tty when run without a subcommand,
41
+ # hangs until the collector timeout, and when killed leaves the terminal in
42
+ # raw mode. So the collector never trusts the first ``openusage`` on PATH: it
43
+ # walks every candidate (PATH order, then the usual install locations) and
44
+ # runs the first one that proves to be the app CLI — by resolving into
45
+ # OpenUsage.app, or failing that by answering ``--help`` with the app CLI's
46
+ # usage text. Verdicts are cached per path for the life of the process.
47
+ _APP_HELPER_SUFFIX = os.path.join("OpenUsage.app", "Contents", "Helpers", "openusage")
48
+ _KNOWN_APP_CLI_PATHS: tuple[str, ...] = (
49
+ "/usr/local/bin/openusage",
50
+ "/Applications/OpenUsage.app/Contents/Helpers/openusage",
51
+ "~/Applications/OpenUsage.app/Contents/Helpers/openusage",
52
+ )
53
+ # Fragments of OpenUsage.app's ``openusage --help`` banner ("Read limits
54
+ # through OpenUsage's shared five-minute cache and exit. Output is always
55
+ # JSON.") — any one of them identifies it; openusage.sh's help says
56
+ # "terminal dashboard" and lists subcommands instead.
57
+ _APP_HELP_MARKERS: tuple[str, ...] = ("Read limits through OpenUsage", "Output is always JSON")
58
+ _PROBE_TIMEOUT_S = 5.0
59
+ _cli_verdicts: dict[str, bool] = {}
60
+
61
+
62
+ def _is_app_bundle_path(path: str) -> bool:
63
+ try:
64
+ real = os.path.realpath(os.path.expanduser(path))
65
+ except (OSError, ValueError):
66
+ return False
67
+ return real.endswith(_APP_HELPER_SUFFIX)
68
+
69
+
70
+ def is_app_cli(path: str, *, probe: bool = True) -> bool:
71
+ """True when ``path`` is OpenUsage.app's CLI.
72
+
73
+ Cheap structural check first (resolves into OpenUsage.app); otherwise, with
74
+ ``probe``, a detached ``--help`` run that must print the app CLI's usage
75
+ text. Verdicts are cached per path.
76
+ """
77
+ cached = _cli_verdicts.get(path)
78
+ if cached is not None:
79
+ return cached
80
+ verdict = _is_app_bundle_path(path)
81
+ if not verdict and probe:
82
+ banner = probe_output([path, "--help"], timeout=_PROBE_TIMEOUT_S)
83
+ verdict = any(marker in banner for marker in _APP_HELP_MARKERS)
84
+ _cli_verdicts[path] = verdict
85
+ return verdict
86
+
87
+
88
+ def clear_cli_verdicts() -> None:
89
+ """Forget cached ``is_app_cli`` verdicts (tests; after an install)."""
90
+ _cli_verdicts.clear()
91
+
92
+
93
+ def resolve_app_cli() -> tuple[str | None, list[str]]:
94
+ """(path of OpenUsage.app's CLI or None, same-named binaries rejected on the way)."""
95
+ return first_tool("openusage", is_app_cli, extra_paths=_KNOWN_APP_CLI_PATHS)
96
+
97
+
98
+ def app_cli_path() -> str | None:
99
+ """Path of OpenUsage.app's CLI, or None when only loopback HTTP is available."""
100
+ return resolve_app_cli()[0]
101
+
102
+
103
+ def foreign_openusage_binaries() -> list[str]:
104
+ """``openusage`` binaries found that are not OpenUsage.app's CLI (doctor warning)."""
105
+ return resolve_app_cli()[1]
106
+
107
+
36
108
  DEFAULT_OPENUSAGE_BASE = "http://127.0.0.1:6736"
37
109
  DEFAULT_HTTP_TIMEOUT = 30.0
38
110
 
@@ -168,7 +240,7 @@ def _fetch_limits_gated(
168
240
  payload. Without ``--force`` OpenUsage serves its own cached reading.
169
241
  """
170
242
  gated = sorted(_gated_providers(min_intervals))
171
- if not force_refresh or not gated or not which("openusage"):
243
+ if not force_refresh or not gated or not app_cli_path():
172
244
  payload, via = _fetch_limits(
173
245
  timeout=timeout, base_url=base_url, force_refresh=force_refresh, try_launch_app=try_launch_app
174
246
  )
@@ -201,7 +273,7 @@ def _fetch_limits(
201
273
  force_refresh: bool,
202
274
  try_launch_app: bool,
203
275
  ) -> tuple[dict[str, Any], str]:
204
- cli = which("openusage")
276
+ cli = app_cli_path()
205
277
  if cli:
206
278
  argv = [cli]
207
279
  if force_refresh:
@@ -19,6 +19,7 @@ from aiuse.models import (
19
19
  provider_display_name,
20
20
  utcnow,
21
21
  )
22
+ from aiuse.tty import restore_stdin_tty, save_stdin_tty
22
23
 
23
24
  from .bailian import collect_bailian
24
25
  from .base import which
@@ -32,6 +33,7 @@ from .muse import collect_muse
32
33
  from .opencode_go import collect_opencode_go
33
34
  from .opencode_zen import collect_opencode_zen
34
35
  from .openrouter import collect_openrouter
36
+ from .openusage import app_cli_path as openusage_app_cli_path
35
37
  from .openusage import collect_openusage_ai
36
38
  from .openusage_sh import collect_openusage_sh
37
39
  from .qwencloud import collect_qwencloud
@@ -113,6 +115,21 @@ _PROVIDER_ALIASES = PROVIDER_ID_ALIASES
113
115
 
114
116
 
115
117
  def run_collectors(config: dict[str, Any] | None = None) -> Snapshot:
118
+ """Collect from every enabled source, leaving the terminal as it was found.
119
+
120
+ A collector child that grabs the terminal (a TUI opened on /dev/tty, a tool
121
+ killed at its timeout in raw mode) must not corrupt the report printed right
122
+ after collection, so termios is restored here, before any rendering, as well
123
+ as at process exit (cli.main).
124
+ """
125
+ saved_tty = save_stdin_tty()
126
+ try:
127
+ return _run_collectors(config)
128
+ finally:
129
+ restore_stdin_tty(saved_tty)
130
+
131
+
132
+ def _run_collectors(config: dict[str, Any] | None = None) -> Snapshot:
116
133
  config = config or {}
117
134
  collectors_cfg = config.get("collectors") or {}
118
135
  snapshot = Snapshot(collected_at=utcnow())
@@ -988,7 +1005,7 @@ def collector_tools_present() -> dict[str, bool]:
988
1005
  "cswap": which("cswap") is not None,
989
1006
  "codexbar": which("codexbar") is not None,
990
1007
  "caut": which("caut") is not None,
991
- "openusage_ai": which("openusage") is not None,
1008
+ "openusage_ai": openusage_app_cli_path() is not None,
992
1009
  "openusage_sh": which("openusage-sh") is not None,
993
1010
  "tokscale": which("tokscale") is not None,
994
1011
  "hermes": True,
@@ -0,0 +1,88 @@
1
+ """Terminal attribute save/restore helpers.
2
+
3
+ Some external CLIs put the terminal into raw/cbreak mode: ``caut usage`` on an
4
+ inherited stdin, and openusage.sh's ``openusage`` on /dev/tty directly when it
5
+ is started without a subcommand. If they are killed mid-run (collector
6
+ timeout) or fail to restore, the terminal is left without echo and without
7
+ output post-processing (every line of the report then starts where the
8
+ previous one ended) until the shell or ``reset`` fixes it. Collectors pass
9
+ ``stdin=DEVNULL`` and run in a new session (see ``run_json``); this module is
10
+ belt-and-suspenders for any path that still reaches the terminal.
11
+
12
+ The snapshot covers whichever of stdin, stdout and stderr is the terminal (all
13
+ three are normally the same device), so ``aiuse </dev/null`` is protected too.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from typing import Any
21
+
22
+
23
+ def _terminal_fd() -> int | None:
24
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
25
+ try:
26
+ if stream is not None and stream.isatty():
27
+ return stream.fileno()
28
+ except (AttributeError, OSError, ValueError):
29
+ continue
30
+ return None
31
+
32
+
33
+ def save_stdin_tty() -> Any | None:
34
+ """Snapshot termios attrs of the terminal (stdin, else stdout, else stderr).
35
+
36
+ Returns an opaque token for ``restore_stdin_tty``; None when there is no
37
+ terminal.
38
+ """
39
+ fd = _terminal_fd()
40
+ if fd is None:
41
+ return None
42
+ try:
43
+ import termios
44
+ except ImportError:
45
+ return None
46
+ try:
47
+ return (fd, termios.tcgetattr(fd))
48
+ except (termios.error, OSError, ValueError):
49
+ return None
50
+
51
+
52
+ def _owns_terminal(fd: int) -> bool:
53
+ """True when this process may write terminal attributes without SIGTTOU.
54
+
55
+ ``tcsetattr`` from a process that is not in the terminal's foreground
56
+ process group raises SIGTTOU, which *stops* the process (``aiuse &``,
57
+ ``brew test``, any job-control runner): a hang, not an error. Such a
58
+ process has no business resetting someone else's terminal anyway; the
59
+ foreground shell restores it at its next prompt.
60
+ """
61
+ try:
62
+ return os.tcgetpgrp(fd) == os.getpgrp()
63
+ except (OSError, AttributeError):
64
+ return False
65
+
66
+
67
+ def restore_stdin_tty(saved: Any | None) -> None:
68
+ """Restore termios attrs previously returned by ``save_stdin_tty``.
69
+
70
+ A no-op when the attributes are unchanged (the common case) and when this
71
+ process is not the terminal's foreground job (see ``_owns_terminal``).
72
+ """
73
+ if saved is None:
74
+ return
75
+ try:
76
+ import termios
77
+ except ImportError:
78
+ return
79
+ try:
80
+ fd, attrs = saved
81
+ fd = int(fd)
82
+ if termios.tcgetattr(fd) == attrs:
83
+ return
84
+ if not _owns_terminal(fd):
85
+ return
86
+ termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
87
+ except (termios.error, OSError, ValueError, TypeError):
88
+ return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
5
5
  Author: djbclark
6
6
  License-Expression: MIT
@@ -78,6 +78,7 @@ tests/test_muse.py
78
78
  tests/test_opencode_go.py
79
79
  tests/test_opencode_zen.py
80
80
  tests/test_openrouter.py
81
+ tests/test_openusage_cli_resolution.py
81
82
  tests/test_openusage_parse.py
82
83
  tests/test_pace.py
83
84
  tests/test_query_throttle.py
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import subprocess
6
7
  from types import SimpleNamespace
7
8
 
8
9
  import pytest
9
10
 
10
- from aiuse.collectors.base import CollectorError, run_json
11
+ from aiuse.collectors.base import CollectorError, probe_output, run_json
11
12
 
12
13
 
13
14
  def _fake_run(stdout: str, *, returncode: int = 0):
@@ -93,3 +94,36 @@ def test_run_json_passes_stdin_devnull(monkeypatch):
93
94
  monkeypatch.setattr("aiuse.collectors.base.subprocess.run", _run)
94
95
  assert run_json(["tool"]) == {"ok": True}
95
96
  assert seen.get("stdin") is subprocess.DEVNULL
97
+
98
+
99
+ def test_run_json_detaches_children_from_the_terminal(monkeypatch):
100
+ """No stdin and a new session: a child cannot open /dev/tty and leave it raw."""
101
+ seen: dict[str, object] = {}
102
+
103
+ def _run(argv, **kwargs): # noqa: ARG001
104
+ seen.update(kwargs)
105
+ return SimpleNamespace(stdout="{}", stderr="", returncode=0)
106
+
107
+ monkeypatch.setattr("aiuse.collectors.base.subprocess.run", _run)
108
+ assert run_json(["tool"]) == {}
109
+ assert seen["stdin"] == subprocess.DEVNULL
110
+ assert seen["start_new_session"] is True
111
+
112
+
113
+ def test_probe_output_is_detached_and_swallows_failure(monkeypatch):
114
+ seen: dict[str, object] = {}
115
+
116
+ def _run(argv, **kwargs): # noqa: ARG001
117
+ seen.update(kwargs)
118
+ return SimpleNamespace(stdout="out ", stderr="err", returncode=1)
119
+
120
+ monkeypatch.setattr("aiuse.collectors.base.subprocess.run", _run)
121
+ assert probe_output(["tool", "--help"]) == "out err"
122
+ assert seen["stdin"] == subprocess.DEVNULL
123
+ assert seen["start_new_session"] is True
124
+
125
+ def _boom(argv, **kwargs): # noqa: ARG001
126
+ raise subprocess.TimeoutExpired(argv, 1)
127
+
128
+ monkeypatch.setattr("aiuse.collectors.base.subprocess.run", _boom)
129
+ assert probe_output(["tool", "--help"]) == ""
@@ -0,0 +1,162 @@
1
+ """The OpenUsage.ai collector must run OpenUsage.app's CLI, never a same-named binary.
2
+
3
+ Two products ship an ``openusage`` binary: OpenUsage.app's helper prints JSON
4
+ and exits; openusage.sh's Go dashboard opens a TUI on /dev/tty, hangs until the
5
+ collector timeout and leaves the terminal in raw mode when killed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import stat
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from aiuse.collectors import base, openusage
17
+
18
+ APP_HELP = "Usage: openusage [provider] [--force]\n\nRead limits through OpenUsage's shared five-minute cache and exit. Output is always JSON.\n"
19
+ SH_HELP = (
20
+ "OpenUsage is a terminal dashboard for monitoring AI coding tool usage and spend.\n\nUsage:\n openusage [flags]\n"
21
+ )
22
+
23
+
24
+ def _executable(path: Path) -> Path:
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ path.write_text("#!/bin/sh\nexit 0\n")
27
+ path.chmod(path.stat().st_mode | stat.S_IXUSR)
28
+ return path
29
+
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def _fresh_verdicts():
33
+ openusage.clear_cli_verdicts()
34
+ yield
35
+ openusage.clear_cli_verdicts()
36
+
37
+
38
+ def _bundle_cli(tmp_path: Path) -> Path:
39
+ return _executable(tmp_path / "OpenUsage.app" / "Contents" / "Helpers" / "openusage")
40
+
41
+
42
+ def test_which_all_walks_every_path_entry_in_order_and_dedupes(tmp_path, monkeypatch):
43
+ first = _executable(tmp_path / "a" / "tool")
44
+ second = _executable(tmp_path / "b" / "tool")
45
+ link_dir = tmp_path / "c"
46
+ link_dir.mkdir()
47
+ (link_dir / "tool").symlink_to(first) # same file again via a symlink
48
+ _executable(tmp_path / "d" / "other") # different name, ignored
49
+ monkeypatch.setenv(
50
+ "PATH", os.pathsep.join(str(d) for d in (tmp_path / "a", tmp_path / "b", link_dir, tmp_path / "d"))
51
+ )
52
+
53
+ assert base.which_all("tool") == [str(first), str(second)]
54
+ assert base.which_all("tool", extra_paths=(str(second), str(tmp_path / "missing"))) == [str(first), str(second)]
55
+
56
+
57
+ def test_first_tool_returns_first_accepted_and_the_rejected_before_it(tmp_path, monkeypatch):
58
+ bad = _executable(tmp_path / "a" / "tool")
59
+ good = _executable(tmp_path / "b" / "tool")
60
+ monkeypatch.setenv("PATH", os.pathsep.join([str(tmp_path / "a"), str(tmp_path / "b")]))
61
+
62
+ chosen, rejected = base.first_tool("tool", lambda p: p == str(good))
63
+ assert chosen == str(good)
64
+ assert rejected == [str(bad)]
65
+
66
+ chosen, rejected = base.first_tool("tool", lambda _p: False)
67
+ assert chosen is None
68
+ assert rejected == [str(bad), str(good)]
69
+
70
+
71
+ def test_bundle_symlink_is_accepted_without_a_probe(tmp_path, monkeypatch):
72
+ helper = _bundle_cli(tmp_path)
73
+ link = tmp_path / "bin" / "openusage"
74
+ link.parent.mkdir()
75
+ link.symlink_to(helper)
76
+ monkeypatch.setattr(openusage, "probe_output", lambda *_a, **_k: pytest.fail("probed a bundle path"))
77
+
78
+ assert openusage.is_app_cli(str(link))
79
+
80
+
81
+ def test_foreign_binary_is_rejected_by_its_help_banner(tmp_path, monkeypatch):
82
+ foreign = _executable(tmp_path / "homebrew" / "openusage")
83
+ probes: list[list[str]] = []
84
+
85
+ def fake_probe(argv, *, timeout):
86
+ probes.append(argv)
87
+ return SH_HELP
88
+
89
+ monkeypatch.setattr(openusage, "probe_output", fake_probe)
90
+ assert not openusage.is_app_cli(str(foreign))
91
+ assert not openusage.is_app_cli(str(foreign)) # cached: no second probe
92
+ assert probes == [[str(foreign), "--help"]]
93
+
94
+
95
+ def test_app_cli_anywhere_is_accepted_by_its_help_banner(tmp_path, monkeypatch):
96
+ relocated = _executable(tmp_path / "elsewhere" / "openusage")
97
+ monkeypatch.setattr(openusage, "probe_output", lambda *_a, **_k: APP_HELP)
98
+ assert openusage.is_app_cli(str(relocated))
99
+
100
+
101
+ def test_resolver_skips_the_shadowing_binary_and_uses_the_app_cli_later_on_path(tmp_path, monkeypatch):
102
+ foreign = _executable(tmp_path / "homebrew" / "openusage")
103
+ helper = _bundle_cli(tmp_path)
104
+ link = tmp_path / "usr-local" / "openusage"
105
+ link.parent.mkdir()
106
+ link.symlink_to(helper)
107
+ monkeypatch.setenv("PATH", os.pathsep.join([str(tmp_path / "homebrew"), str(link.parent)]))
108
+ monkeypatch.setattr(openusage, "_KNOWN_APP_CLI_PATHS", ())
109
+ monkeypatch.setattr(openusage, "probe_output", lambda *_a, **_k: SH_HELP)
110
+
111
+ assert openusage.resolve_app_cli() == (str(link), [str(foreign)])
112
+ assert openusage.app_cli_path() == str(link)
113
+ assert openusage.foreign_openusage_binaries() == [str(foreign)]
114
+
115
+
116
+ def test_resolver_falls_back_to_known_install_locations(tmp_path, monkeypatch):
117
+ foreign = _executable(tmp_path / "homebrew" / "openusage")
118
+ helper = _bundle_cli(tmp_path)
119
+ monkeypatch.setenv("PATH", str(tmp_path / "homebrew"))
120
+ monkeypatch.setattr(openusage, "_KNOWN_APP_CLI_PATHS", (str(helper),))
121
+ monkeypatch.setattr(openusage, "probe_output", lambda *_a, **_k: SH_HELP)
122
+
123
+ assert openusage.resolve_app_cli() == (str(helper), [str(foreign)])
124
+
125
+
126
+ def test_resolver_reports_no_cli_when_only_foreign_binaries_exist(tmp_path, monkeypatch):
127
+ foreign = _executable(tmp_path / "homebrew" / "openusage")
128
+ monkeypatch.setenv("PATH", str(tmp_path / "homebrew"))
129
+ monkeypatch.setattr(openusage, "_KNOWN_APP_CLI_PATHS", ())
130
+ monkeypatch.setattr(openusage, "probe_output", lambda *_a, **_k: SH_HELP)
131
+
132
+ assert openusage.resolve_app_cli() == (None, [str(foreign)])
133
+
134
+
135
+ def test_fetch_limits_never_runs_a_foreign_binary(monkeypatch):
136
+ monkeypatch.setattr(openusage, "app_cli_path", lambda: None)
137
+ monkeypatch.setattr(openusage, "run_json", lambda *_a, **_k: pytest.fail("ran a CLI that is not OpenUsage.app's"))
138
+ payload = {"providers": {}}
139
+ monkeypatch.setattr(openusage, "_http_limits", lambda *, base_url, timeout: payload)
140
+
141
+ got, via = openusage._fetch_limits(
142
+ timeout=5.0, base_url="http://127.0.0.1:6736", force_refresh=True, try_launch_app=False
143
+ )
144
+ assert (got, via) == (payload, "http")
145
+
146
+
147
+ def test_fetch_limits_runs_the_verified_app_cli(monkeypatch):
148
+ calls: list[list[str]] = []
149
+ payload = {"providers": {}}
150
+
151
+ def fake_run_json(argv, *, timeout):
152
+ calls.append(argv)
153
+ return payload
154
+
155
+ monkeypatch.setattr(openusage, "app_cli_path", lambda: "/Applications/OpenUsage.app/Contents/Helpers/openusage")
156
+ monkeypatch.setattr(openusage, "run_json", fake_run_json)
157
+
158
+ got, via = openusage._fetch_limits(
159
+ timeout=5.0, base_url="http://127.0.0.1:6736", force_refresh=True, try_launch_app=False
160
+ )
161
+ assert (got, via) == (payload, "cli")
162
+ assert calls == [["/Applications/OpenUsage.app/Contents/Helpers/openusage", "--force"]]
@@ -151,7 +151,7 @@ def test_openusage_withholds_force_while_antigravity_is_throttled(monkeypatch):
151
151
  forced.append(force_refresh)
152
152
  return payload, "cli"
153
153
 
154
- monkeypatch.setattr(openusage, "which", lambda _cmd: "/usr/local/bin/openusage")
154
+ monkeypatch.setattr(openusage, "app_cli_path", lambda: "/usr/local/bin/openusage")
155
155
  monkeypatch.setattr(openusage, "_fetch_limits", fake_fetch)
156
156
  limits = {"antigravity": 900.0}
157
157
 
@@ -173,7 +173,7 @@ def test_openusage_force_without_antigravity_does_not_spend_the_slot(monkeypatch
173
173
  }
174
174
  }
175
175
  }
176
- monkeypatch.setattr(openusage, "which", lambda _cmd: "/usr/local/bin/openusage")
176
+ monkeypatch.setattr(openusage, "app_cli_path", lambda: "/usr/local/bin/openusage")
177
177
  monkeypatch.setattr(openusage, "_fetch_limits", lambda **_kw: (payload, "cli"))
178
178
  openusage.collect_openusage_ai(min_intervals={"antigravity": 900.0})
179
179
 
@@ -0,0 +1,117 @@
1
+ """Tests for terminal attribute save/restore helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from aiuse import tty
8
+ from aiuse.collectors import runner
9
+ from aiuse.tty import restore_stdin_tty, save_stdin_tty
10
+
11
+
12
+ class _Stream:
13
+ def __init__(self, *, is_tty: bool, fd: int) -> None:
14
+ self._is_tty = is_tty
15
+ self._fd = fd
16
+
17
+ def isatty(self) -> bool:
18
+ return self._is_tty
19
+
20
+ def fileno(self) -> int:
21
+ return self._fd
22
+
23
+
24
+ def _streams(monkeypatch, *, stdin: bool, stdout: bool, stderr: bool) -> None:
25
+ monkeypatch.setattr(tty.sys, "stdin", _Stream(is_tty=stdin, fd=0))
26
+ monkeypatch.setattr(tty.sys, "stdout", _Stream(is_tty=stdout, fd=1))
27
+ monkeypatch.setattr(tty.sys, "stderr", _Stream(is_tty=stderr, fd=2))
28
+
29
+
30
+ def test_save_restore_noop_when_not_tty(monkeypatch):
31
+ _streams(monkeypatch, stdin=False, stdout=False, stderr=False)
32
+ assert save_stdin_tty() is None
33
+ restore_stdin_tty(None) # must not raise
34
+
35
+
36
+ def test_restore_ignores_corrupt_saved(monkeypatch):
37
+ # Passing an invalid snapshot must not raise.
38
+ restore_stdin_tty(["not", "real", "termios"])
39
+ restore_stdin_tty((999999, ["nope"]))
40
+
41
+
42
+ def test_save_uses_stdout_when_stdin_is_redirected(monkeypatch):
43
+ """``aiuse </dev/null`` still has a terminal to protect on stdout."""
44
+ import termios
45
+
46
+ _streams(monkeypatch, stdin=False, stdout=True, stderr=True)
47
+ monkeypatch.setattr(termios, "tcgetattr", lambda fd: ["attrs", fd])
48
+ assert save_stdin_tty() == (1, ["attrs", 1])
49
+
50
+
51
+ def test_restore_targets_the_saved_fd(monkeypatch):
52
+ import termios
53
+
54
+ calls: list[tuple[int, int, object]] = []
55
+ monkeypatch.setattr(termios, "tcgetattr", lambda fd: ["raw"])
56
+ monkeypatch.setattr(termios, "tcsetattr", lambda fd, when, attrs: calls.append((fd, when, attrs)))
57
+ monkeypatch.setattr(tty, "_owns_terminal", lambda fd: True)
58
+ restore_stdin_tty((2, ["attrs"]))
59
+ assert calls == [(2, termios.TCSADRAIN, ["attrs"])]
60
+
61
+
62
+ def test_restore_is_a_noop_when_nothing_changed(monkeypatch):
63
+ """No tcsetattr at all in the common case (no SIGTTOU risk, no drain)."""
64
+ import termios
65
+
66
+ monkeypatch.setattr(termios, "tcgetattr", lambda fd: ["attrs"])
67
+ monkeypatch.setattr(termios, "tcsetattr", lambda *_a: pytest.fail("tcsetattr on unchanged attrs"))
68
+ monkeypatch.setattr(tty, "_owns_terminal", lambda fd: pytest.fail("checked pgrp on unchanged attrs"))
69
+ restore_stdin_tty((2, ["attrs"]))
70
+
71
+
72
+ def test_restore_never_writes_from_a_background_job(monkeypatch):
73
+ """``aiuse --version &`` with stderr on the tty: tcsetattr would SIGTTOU-stop us.
74
+
75
+ Regression: 3.3.1's ``brew test`` hung for five minutes in state T.
76
+ """
77
+ import termios
78
+
79
+ monkeypatch.setattr(termios, "tcgetattr", lambda fd: ["raw"])
80
+ monkeypatch.setattr(termios, "tcsetattr", lambda *_a: pytest.fail("tcsetattr from a background process group"))
81
+ monkeypatch.setattr(tty.os, "tcgetpgrp", lambda fd: 4242)
82
+ monkeypatch.setattr(tty.os, "getpgrp", lambda: 1)
83
+ restore_stdin_tty((2, ["attrs"]))
84
+
85
+
86
+ def test_owns_terminal_matches_foreground_pgrp(monkeypatch):
87
+ monkeypatch.setattr(tty.os, "tcgetpgrp", lambda fd: 7)
88
+ monkeypatch.setattr(tty.os, "getpgrp", lambda: 7)
89
+ assert tty._owns_terminal(2)
90
+ monkeypatch.setattr(tty.os, "tcgetpgrp", lambda fd: (_ for _ in ()).throw(OSError("not a tty")))
91
+ assert not tty._owns_terminal(2)
92
+
93
+
94
+ def test_run_collectors_restores_the_terminal_before_returning(monkeypatch):
95
+ """A child that leaves the terminal raw must not corrupt the report printed next."""
96
+ events: list[str] = []
97
+ monkeypatch.setattr(runner, "save_stdin_tty", lambda: events.append("save") or "token")
98
+ monkeypatch.setattr(runner, "restore_stdin_tty", lambda saved: events.append(f"restore:{saved}"))
99
+
100
+ def fake_inner(config):
101
+ events.append("collect")
102
+ return "snapshot"
103
+
104
+ monkeypatch.setattr(runner, "_run_collectors", fake_inner)
105
+ assert runner.run_collectors({}) == "snapshot"
106
+ assert events == ["save", "collect", "restore:token"]
107
+
108
+ def failing_inner(config):
109
+ raise RuntimeError("boom")
110
+
111
+ monkeypatch.setattr(runner, "_run_collectors", failing_inner)
112
+ events.clear()
113
+ try:
114
+ runner.run_collectors({})
115
+ except RuntimeError:
116
+ pass
117
+ assert events == ["save", "restore:token"]
@@ -1,41 +0,0 @@
1
- """Terminal attribute save/restore helpers.
2
-
3
- Some external CLIs (notably ``caut usage``) put the inherited stdin TTY into
4
- raw/cbreak mode. If they are killed mid-run (collector timeout) or fail to
5
- restore, the parent shell loses echo until the user runs ``reset``. Collectors
6
- should pass ``stdin=DEVNULL``; this module is belt-and-suspenders for any path
7
- that still inherits a TTY.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import sys
13
- from typing import Any
14
-
15
-
16
- def save_stdin_tty() -> Any | None:
17
- """Snapshot stdin termios attrs when stdin is a TTY; else None."""
18
- if not getattr(sys.stdin, "isatty", lambda: False)():
19
- return None
20
- try:
21
- import termios
22
- except ImportError:
23
- return None
24
- try:
25
- return termios.tcgetattr(sys.stdin.fileno())
26
- except (termios.error, OSError, ValueError):
27
- return None
28
-
29
-
30
- def restore_stdin_tty(saved: Any | None) -> None:
31
- """Restore stdin termios attrs previously returned by ``save_stdin_tty``."""
32
- if saved is None:
33
- return
34
- try:
35
- import termios
36
- except ImportError:
37
- return
38
- try:
39
- termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, saved)
40
- except (termios.error, OSError, ValueError):
41
- return
@@ -1,20 +0,0 @@
1
- """Tests for stdin TTY save/restore helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- from aiuse.tty import restore_stdin_tty, save_stdin_tty
6
-
7
-
8
- def test_save_restore_noop_when_not_tty(monkeypatch):
9
- class _Fake:
10
- def isatty(self) -> bool:
11
- return False
12
-
13
- monkeypatch.setattr("aiuse.tty.sys.stdin", _Fake())
14
- assert save_stdin_tty() is None
15
- restore_stdin_tty(None) # must not raise
16
-
17
-
18
- def test_restore_ignores_corrupt_saved(monkeypatch):
19
- # Passing an invalid snapshot must not raise.
20
- restore_stdin_tty(["not", "real", "termios"])
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
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
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
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
File without changes
File without changes