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.
- {aiuse-3.3.0/src/aiuse.egg-info → aiuse-3.3.2}/PKG-INFO +1 -1
- {aiuse-3.3.0 → aiuse-3.3.2}/pyproject.toml +1 -1
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/__init__.py +1 -1
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/cli.py +13 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/base.py +75 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/openusage.py +75 -3
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/runner.py +18 -1
- aiuse-3.3.2/src/aiuse/tty.py +88 -0
- {aiuse-3.3.0 → aiuse-3.3.2/src/aiuse.egg-info}/PKG-INFO +1 -1
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/SOURCES.txt +1 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_base_run_json.py +35 -1
- aiuse-3.3.2/tests/test_openusage_cli_resolution.py +162 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_query_throttle.py +2 -2
- aiuse-3.3.2/tests/test_tty.py +117 -0
- aiuse-3.3.0/src/aiuse/tty.py +0 -41
- aiuse-3.3.0/tests/test_tty.py +0 -20
- {aiuse-3.3.0 → aiuse-3.3.2}/LICENSE +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/README.md +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/setup.cfg +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/__main__.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/history.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/selfdescribe.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/attribute.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/codexbar.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/cswap.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/muse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/throttle.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/config.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/credentials.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/ledger.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/models.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/report.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/sampler.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/serve.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse/watch.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_attribution.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_bailian.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_caut_parse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_chat_format.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_claude_sublimits.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_cli.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_clinepass.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_config.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_credentials.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_grok.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_history.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_install_deps.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_macos_trust.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_models_pace.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_muse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_opencode_go.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_openrouter.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_pace.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_qwencloud.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_release_script.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_report.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_selfdescribe.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_serve.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_suggest.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_tui.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_use_or_lose.py +0 -0
- {aiuse-3.3.0 → aiuse-3.3.2}/tests/test_watch.py +0 -0
|
@@ -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,
|
|
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
|
|
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 =
|
|
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":
|
|
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
|
|
@@ -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, "
|
|
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, "
|
|
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"]
|
aiuse-3.3.0/src/aiuse/tty.py
DELETED
|
@@ -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
|
aiuse-3.3.0/tests/test_tty.py
DELETED
|
@@ -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
|
|
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
|