appmem 0.1.0__py3-none-any.whl
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.
- appmem/__init__.py +7 -0
- appmem/__main__.py +14 -0
- appmem/cli.py +646 -0
- appmem/collect.py +870 -0
- appmem/command_name.py +203 -0
- appmem/fmt.py +215 -0
- appmem/naming.py +186 -0
- appmem/render.py +190 -0
- appmem/report.py +290 -0
- appmem/schema.py +853 -0
- appmem/theme.py +219 -0
- appmem/ui/__init__.py +1 -0
- appmem/ui/app.py +192 -0
- appmem/ui/header.py +519 -0
- appmem/ui/layout.py +82 -0
- appmem/ui/process_rows.py +303 -0
- appmem/ui/rows.py +167 -0
- appmem/ui/screens/__init__.py +1 -0
- appmem/ui/screens/help.py +318 -0
- appmem/ui/screens/main.py +924 -0
- appmem/ui/screens/processes.py +950 -0
- appmem/ui/table.py +155 -0
- appmem/ui/table_order.py +35 -0
- appmem/ui/theme_picker.py +169 -0
- appmem/writeback.py +56 -0
- appmem-0.1.0.dist-info/METADATA +136 -0
- appmem-0.1.0.dist-info/RECORD +30 -0
- appmem-0.1.0.dist-info/WHEEL +4 -0
- appmem-0.1.0.dist-info/entry_points.txt +3 -0
- appmem-0.1.0.dist-info/licenses/LICENSE +19 -0
appmem/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""appmem: live terminal view of RAM and swap usage per application."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
# pyproject.toml's [project] version is the single source; read it back from the
|
|
6
|
+
# installed distribution's metadata instead of duplicating it here by hand.
|
|
7
|
+
__version__ = version(__name__)
|
appmem/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""CLI entry point (SPEC.md "Command line")."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from appmem.cli import main as _main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
"""Run the CLI and exit with its process exit code."""
|
|
10
|
+
raise SystemExit(_main())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
appmem/cli.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""CLI: argument parsing, pre-start checks and process exit codes.
|
|
2
|
+
|
|
3
|
+
`snapshot`, `app` and `schema` follow the CLI Design Standard 0.1.0 and
|
|
4
|
+
describe themselves through `appmem schema`; SPEC.md owns the live view. Every parser is built
|
|
5
|
+
from the descriptors in `schema.py`, so a flag or default declared there is
|
|
6
|
+
the one the parser actually accepts: nothing here re-states a flag name,
|
|
7
|
+
help text or default on its own. All pre-start failures print a human line
|
|
8
|
+
to stderr, then one JSON error object as the last stderr line; `main()`
|
|
9
|
+
returns the process exit code rather than calling `sys.exit` itself, so
|
|
10
|
+
tests can call it directly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, NoReturn, Protocol
|
|
26
|
+
|
|
27
|
+
from appmem import __version__, report, schema
|
|
28
|
+
from appmem.collect import CgroupUnavailableError, find_units
|
|
29
|
+
from appmem.render import render_app_text, render_snapshot_text
|
|
30
|
+
from appmem.theme import (
|
|
31
|
+
APPMEM_THEME_ENV,
|
|
32
|
+
TEXTUAL_THEME_ENV,
|
|
33
|
+
THEME_NAMES,
|
|
34
|
+
canonical_theme_name,
|
|
35
|
+
config_path,
|
|
36
|
+
resolve_theme,
|
|
37
|
+
)
|
|
38
|
+
from appmem.ui.app import AppMemApp
|
|
39
|
+
|
|
40
|
+
MIN_INTERVAL = 0.2
|
|
41
|
+
|
|
42
|
+
ERROR_KINDS: tuple[str, ...] = (
|
|
43
|
+
"invalid_input",
|
|
44
|
+
"terminal_required",
|
|
45
|
+
"cgroup_unavailable",
|
|
46
|
+
"not_found",
|
|
47
|
+
"interrupted",
|
|
48
|
+
)
|
|
49
|
+
"""Every `kind` an error object can carry. The single source for both the
|
|
50
|
+
runtime check below and `tests/test_docs.py`, so a kind renamed here without
|
|
51
|
+
updating README.md or SKILL.md fails the build instead of drifting quietly."""
|
|
52
|
+
|
|
53
|
+
_HELP_TEXT = """\
|
|
54
|
+
appmem: live terminal view of RAM and swap usage per application, not per process.
|
|
55
|
+
|
|
56
|
+
Usage:
|
|
57
|
+
appmem [-i SECONDS] [--system] [--theme NAME]
|
|
58
|
+
appmem snapshot [--system] [--limit N] [--json]
|
|
59
|
+
appmem app NAME [--scope user|system] [--limit N] [--json]
|
|
60
|
+
appmem schema [COMMAND]
|
|
61
|
+
appmem --help | -h
|
|
62
|
+
appmem --version | -V
|
|
63
|
+
|
|
64
|
+
Flags:
|
|
65
|
+
-i, --interval SECONDS Refresh interval, a number >= 0.2 (default: 1); the live view only
|
|
66
|
+
--system Start with system services shown (same as pressing x)
|
|
67
|
+
--theme NAME One of appmem's theme names (appmem schema lists them; terminal-dark
|
|
68
|
+
and terminal-light use your terminal's own colours); overrides
|
|
69
|
+
APPMEM_THEME and the config file for this run, the live view only,
|
|
70
|
+
never written back
|
|
71
|
+
--json Write JSON instead of text; the default when stdout isn't a terminal
|
|
72
|
+
-h, --help Show this help and exit
|
|
73
|
+
-V, --version Show the version and exit
|
|
74
|
+
|
|
75
|
+
Commands:
|
|
76
|
+
snapshot One sample of the machine and every app
|
|
77
|
+
app NAME One app's units, processes, commands and remainder
|
|
78
|
+
schema Describe the commands, flags, output shapes and exit codes as JSON
|
|
79
|
+
|
|
80
|
+
Run `appmem schema` for the full machine-readable interface, or
|
|
81
|
+
`appmem snapshot --help` (any command works the same way) for that command's own flags.
|
|
82
|
+
|
|
83
|
+
Keys:
|
|
84
|
+
click header sort by that column, click again to reverse
|
|
85
|
+
r / s / t / d / z sort by RAM / SWAP / TOTAL / ΔSWAP / ZSWAP (repeat to reverse;
|
|
86
|
+
z only where the ZSWAP column is shown); other columns sort by click
|
|
87
|
+
up/down PgUp PgDn move
|
|
88
|
+
Enter open the process view for the selected app;
|
|
89
|
+
grouped: the processes of the selected command
|
|
90
|
+
g process view: toggle grouping by command
|
|
91
|
+
Esc back to the main view
|
|
92
|
+
c toggle the CACHE column
|
|
93
|
+
w toggle the ZSWAP column (shown by default where zswap is on)
|
|
94
|
+
x toggle system services
|
|
95
|
+
b reset the Δ baseline to now
|
|
96
|
+
T / Ctrl+P change the theme (opens on the current theme, remembered in the
|
|
97
|
+
config file)
|
|
98
|
+
? help screen
|
|
99
|
+
q / Ctrl+C quit
|
|
100
|
+
|
|
101
|
+
Memory pressure:
|
|
102
|
+
none few memory stalls in the last 10 s
|
|
103
|
+
some (X.X %) some time spent waiting; shown with the percentage
|
|
104
|
+
high a lot of time spent waiting: memory stalls are happening,
|
|
105
|
+
but this alone doesn't say which app is causing them
|
|
106
|
+
Big swap with pressure none just means idle pages were paged out.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
appmem -i 2 --system
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_SNAPSHOT_HELP_TEXT = f"""\
|
|
113
|
+
appmem snapshot: {schema.SNAPSHOT_DESCRIPTION}
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
appmem snapshot [--system] [--limit N] [--json]
|
|
117
|
+
appmem snapshot --help | -h
|
|
118
|
+
|
|
119
|
+
Flags:
|
|
120
|
+
--system {schema.SNAPSHOT_SYSTEM.description} (default: false)
|
|
121
|
+
--limit N {schema.SNAPSHOT_LIMIT.description} (default: {schema.SNAPSHOT_LIMIT.default})
|
|
122
|
+
--json {schema.JSON_FLAG.description}
|
|
123
|
+
-h, --help Show this help and exit
|
|
124
|
+
|
|
125
|
+
Text on a terminal, JSON otherwise. `appmem schema snapshot` describes the JSON shape.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
_APP_HELP_TEXT = f"""\
|
|
129
|
+
appmem app: {schema.APP_DESCRIPTION}
|
|
130
|
+
|
|
131
|
+
Usage:
|
|
132
|
+
appmem app NAME [--scope user|system] [--limit N] [--json]
|
|
133
|
+
appmem app --help | -h
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
NAME {schema.APP_NAME.description}
|
|
137
|
+
|
|
138
|
+
Flags:
|
|
139
|
+
--scope VALUE {schema.APP_SCOPE.description} (default: {schema.APP_SCOPE.default})
|
|
140
|
+
--limit N {schema.APP_LIMIT.description} (default: {schema.APP_LIMIT.default})
|
|
141
|
+
--json {schema.JSON_FLAG.description}
|
|
142
|
+
-h, --help Show this help and exit
|
|
143
|
+
|
|
144
|
+
Note: a process's ram_bytes is RSS -- shared pages count once in every process
|
|
145
|
+
that maps it, so don't sum processes; use the app's own ram_bytes instead.
|
|
146
|
+
|
|
147
|
+
Text on a terminal, JSON otherwise. `appmem schema app` describes the JSON shape.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
_SCHEMA_HELP_TEXT = """\
|
|
151
|
+
appmem schema: describe the commands, flags, output shapes and exit codes as JSON.
|
|
152
|
+
|
|
153
|
+
Usage:
|
|
154
|
+
appmem schema [COMMAND]
|
|
155
|
+
appmem schema --help | -h
|
|
156
|
+
|
|
157
|
+
With no argument, prints the introspection index (every command, the shared
|
|
158
|
+
flags, format defaults and exit codes). With a command name ("snapshot" or
|
|
159
|
+
"app"), prints that command's own flags, defaults and output schema. Always
|
|
160
|
+
writes JSON, with or without --json.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _SubparserFactory(Protocol):
|
|
165
|
+
"""What `_add_*_parser` needs from `ArgumentParser.add_subparsers()`'s
|
|
166
|
+
result: just enough to add one named subparser, without naming argparse's
|
|
167
|
+
own (private) type for it."""
|
|
168
|
+
|
|
169
|
+
def add_parser(self, name: str, **kwargs: Any) -> argparse.ArgumentParser: ...
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
173
|
+
"""Routes argparse's own error path through the standard's JSON error format."""
|
|
174
|
+
|
|
175
|
+
def error(self, message: str) -> NoReturn:
|
|
176
|
+
usage = self.format_usage()
|
|
177
|
+
sys.stderr.write(usage) # fails "with the accepted form"
|
|
178
|
+
accepted = " ".join(usage.removeprefix("usage:").split())
|
|
179
|
+
_fail("invalid_input", message, 2, action="agent", hint=f"Accepted form: {accepted}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _make_help_action(text: str) -> type[argparse.Action]:
|
|
183
|
+
"""Build a `--help` action that prints a fixed cheat sheet instead of
|
|
184
|
+
argparse's generated help; one per command, since each has its own text."""
|
|
185
|
+
|
|
186
|
+
class _Help(argparse.Action):
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
option_strings: Sequence[str],
|
|
190
|
+
dest: str = argparse.SUPPRESS,
|
|
191
|
+
default: str = argparse.SUPPRESS,
|
|
192
|
+
help: str | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
super().__init__(
|
|
195
|
+
option_strings=option_strings, dest=dest, default=default, nargs=0, help=help
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def __call__(
|
|
199
|
+
self,
|
|
200
|
+
parser: argparse.ArgumentParser,
|
|
201
|
+
namespace: argparse.Namespace,
|
|
202
|
+
values: str | Sequence[Any] | None,
|
|
203
|
+
option_string: str | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
print(text, end="")
|
|
206
|
+
parser.exit()
|
|
207
|
+
|
|
208
|
+
return _Help
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _interval(value: str) -> float:
|
|
212
|
+
try:
|
|
213
|
+
parsed = float(value)
|
|
214
|
+
except ValueError as exc:
|
|
215
|
+
raise argparse.ArgumentTypeError(
|
|
216
|
+
f"invalid interval {value!r}: must be a number >= {MIN_INTERVAL}"
|
|
217
|
+
) from exc
|
|
218
|
+
if not math.isfinite(parsed) or parsed < MIN_INTERVAL:
|
|
219
|
+
raise argparse.ArgumentTypeError(f"invalid interval {value!r}: must be >= {MIN_INTERVAL}")
|
|
220
|
+
return parsed
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _theme_name(value: str) -> str:
|
|
224
|
+
canonical = canonical_theme_name(value)
|
|
225
|
+
if canonical not in THEME_NAMES:
|
|
226
|
+
valid = ", ".join(THEME_NAMES)
|
|
227
|
+
raise argparse.ArgumentTypeError(f"invalid theme {value!r}: must be one of {valid}")
|
|
228
|
+
return canonical
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _positive_int(value: str) -> int:
|
|
232
|
+
try:
|
|
233
|
+
parsed = int(value)
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise argparse.ArgumentTypeError(
|
|
236
|
+
f"invalid limit {value!r}: must be an integer >= 1"
|
|
237
|
+
) from exc
|
|
238
|
+
if parsed < 1:
|
|
239
|
+
raise argparse.ArgumentTypeError(f"invalid limit {value!r}: must be an integer >= 1")
|
|
240
|
+
return parsed
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _option_strings(flag: schema.Flag) -> list[str]:
|
|
244
|
+
short = [f"-{alias}" for alias in flag.aliases if len(alias) == 1]
|
|
245
|
+
long_aliases = [f"--{alias}" for alias in flag.aliases if len(alias) != 1]
|
|
246
|
+
return [*short, f"--{flag.name}", *long_aliases]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _add_json_flag(parser: argparse.ArgumentParser, *, suppress: bool) -> None:
|
|
250
|
+
parser.add_argument(
|
|
251
|
+
*_option_strings(schema.JSON_FLAG),
|
|
252
|
+
action="store_true",
|
|
253
|
+
default=argparse.SUPPRESS if suppress else schema.JSON_FLAG.default,
|
|
254
|
+
help=schema.JSON_FLAG.description,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
259
|
+
parser = _ArgumentParser(prog="appmem", add_help=False)
|
|
260
|
+
parser.add_argument(
|
|
261
|
+
"-h", "--help", action=_make_help_action(_HELP_TEXT), help="show this help and exit"
|
|
262
|
+
)
|
|
263
|
+
parser.add_argument(
|
|
264
|
+
*_option_strings(schema.ROOT_INTERVAL),
|
|
265
|
+
type=_interval,
|
|
266
|
+
default=None, # None means "not given"; a named command rejects an explicit value
|
|
267
|
+
metavar="SECONDS",
|
|
268
|
+
help=schema.ROOT_INTERVAL.description,
|
|
269
|
+
)
|
|
270
|
+
parser.add_argument(
|
|
271
|
+
*_option_strings(schema.ROOT_SYSTEM),
|
|
272
|
+
action="store_true",
|
|
273
|
+
default=schema.ROOT_SYSTEM.default,
|
|
274
|
+
help=schema.ROOT_SYSTEM.description,
|
|
275
|
+
)
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
*_option_strings(schema.ROOT_THEME),
|
|
278
|
+
# Not `choices=`: that would list `ansi-dark`/`ansi-light` (still
|
|
279
|
+
# accepted, see `_theme_name`) in argparse's own usage/error text
|
|
280
|
+
# right alongside the honest names, or reject them outright if left
|
|
281
|
+
# out of `choices` -- `_theme_name` accepts both and only ever
|
|
282
|
+
# reports the canonical `THEME_NAMES` on a real miss.
|
|
283
|
+
type=_theme_name,
|
|
284
|
+
default=None, # None means "not given"; a named command rejects an explicit value
|
|
285
|
+
metavar="NAME",
|
|
286
|
+
help=schema.ROOT_THEME.description,
|
|
287
|
+
)
|
|
288
|
+
_add_json_flag(parser, suppress=False)
|
|
289
|
+
parser.add_argument("-V", "--version", action="version", version=__version__)
|
|
290
|
+
|
|
291
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
292
|
+
_add_snapshot_parser(subparsers)
|
|
293
|
+
_add_app_parser(subparsers)
|
|
294
|
+
_add_schema_parser(subparsers)
|
|
295
|
+
return parser
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _add_snapshot_parser(subparsers: _SubparserFactory) -> None:
|
|
299
|
+
parser = subparsers.add_parser("snapshot", add_help=False)
|
|
300
|
+
parser.add_argument(
|
|
301
|
+
"-h",
|
|
302
|
+
"--help",
|
|
303
|
+
action=_make_help_action(_SNAPSHOT_HELP_TEXT),
|
|
304
|
+
help="show this help and exit",
|
|
305
|
+
)
|
|
306
|
+
parser.add_argument(
|
|
307
|
+
*_option_strings(schema.SNAPSHOT_SYSTEM),
|
|
308
|
+
action="store_true",
|
|
309
|
+
# SUPPRESS: a bare "appmem --system snapshot" must keep the root's value,
|
|
310
|
+
# not have this parser's own default silently overwrite it.
|
|
311
|
+
default=argparse.SUPPRESS,
|
|
312
|
+
help=schema.SNAPSHOT_SYSTEM.description,
|
|
313
|
+
)
|
|
314
|
+
parser.add_argument(
|
|
315
|
+
*_option_strings(schema.SNAPSHOT_LIMIT),
|
|
316
|
+
type=_positive_int,
|
|
317
|
+
default=schema.SNAPSHOT_LIMIT.default,
|
|
318
|
+
metavar="N",
|
|
319
|
+
help=schema.SNAPSHOT_LIMIT.description,
|
|
320
|
+
)
|
|
321
|
+
_add_json_flag(parser, suppress=True)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _add_app_parser(subparsers: _SubparserFactory) -> None:
|
|
325
|
+
parser = subparsers.add_parser("app", add_help=False)
|
|
326
|
+
parser.add_argument(
|
|
327
|
+
"-h", "--help", action=_make_help_action(_APP_HELP_TEXT), help="show this help and exit"
|
|
328
|
+
)
|
|
329
|
+
parser.add_argument("name", metavar="NAME", help=schema.APP_NAME.description)
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
*_option_strings(schema.APP_SCOPE),
|
|
332
|
+
choices=schema.APP_SCOPE.enum,
|
|
333
|
+
default=schema.APP_SCOPE.default,
|
|
334
|
+
help=schema.APP_SCOPE.description,
|
|
335
|
+
)
|
|
336
|
+
parser.add_argument(
|
|
337
|
+
*_option_strings(schema.APP_LIMIT),
|
|
338
|
+
type=_positive_int,
|
|
339
|
+
default=schema.APP_LIMIT.default,
|
|
340
|
+
metavar="N",
|
|
341
|
+
help=schema.APP_LIMIT.description,
|
|
342
|
+
)
|
|
343
|
+
_add_json_flag(parser, suppress=True)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _add_schema_parser(subparsers: _SubparserFactory) -> None:
|
|
347
|
+
parser = subparsers.add_parser("schema", add_help=False)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"-h", "--help", action=_make_help_action(_SCHEMA_HELP_TEXT), help="show this help and exit"
|
|
350
|
+
)
|
|
351
|
+
parser.add_argument(
|
|
352
|
+
"path",
|
|
353
|
+
nargs="?",
|
|
354
|
+
default=None,
|
|
355
|
+
metavar="COMMAND",
|
|
356
|
+
help="command to describe; omit for the index",
|
|
357
|
+
)
|
|
358
|
+
_add_json_flag(parser, suppress=True)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _print_error_json(
|
|
362
|
+
kind: str,
|
|
363
|
+
message: str,
|
|
364
|
+
*,
|
|
365
|
+
action: str | None = None,
|
|
366
|
+
hint: str | None = None,
|
|
367
|
+
next_argv: list[str] | None = None,
|
|
368
|
+
) -> None:
|
|
369
|
+
assert kind in ERROR_KINDS, f"undeclared error kind: {kind!r}"
|
|
370
|
+
print(f"appmem: {message}", file=sys.stderr)
|
|
371
|
+
error: dict[str, object] = {"kind": kind, "message": message}
|
|
372
|
+
if action is not None:
|
|
373
|
+
error["action"] = action
|
|
374
|
+
if hint is not None:
|
|
375
|
+
error["hint"] = hint
|
|
376
|
+
if next_argv is not None:
|
|
377
|
+
error["next"] = next_argv
|
|
378
|
+
print(json.dumps({"error": error}), file=sys.stderr)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _fail(
|
|
382
|
+
kind: str,
|
|
383
|
+
message: str,
|
|
384
|
+
code: int,
|
|
385
|
+
*,
|
|
386
|
+
action: str | None = None,
|
|
387
|
+
hint: str | None = None,
|
|
388
|
+
next_argv: list[str] | None = None,
|
|
389
|
+
) -> NoReturn:
|
|
390
|
+
_print_error_json(kind, message, action=action, hint=hint, next_argv=next_argv)
|
|
391
|
+
raise SystemExit(code)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _write_result(text: str) -> None:
|
|
395
|
+
"""Print a command's result. If a downstream reader has already closed
|
|
396
|
+
the pipe, stop writing and exit 0 instead of letting the interpreter's
|
|
397
|
+
own shutdown print a 'Broken pipe' traceback."""
|
|
398
|
+
try:
|
|
399
|
+
sys.stdout.write(text + "\n")
|
|
400
|
+
sys.stdout.flush()
|
|
401
|
+
except BrokenPipeError:
|
|
402
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
403
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
404
|
+
raise SystemExit(0) from None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _run_app(app: AppMemApp) -> int:
|
|
408
|
+
"""Run the Textual app, translating an external SIGTERM/SIGINT into a graceful exit.
|
|
409
|
+
|
|
410
|
+
Ctrl+C typed in the running TUI never reaches these handlers: Textual's raw
|
|
411
|
+
terminal mode stops the terminal from turning it into a signal in the first
|
|
412
|
+
place, so it arrives as an ordinary key event instead (SPEC.md "Errors").
|
|
413
|
+
|
|
414
|
+
Registered with `loop.add_signal_handler` rather than `signal.signal`: a
|
|
415
|
+
`signal.signal` handler only runs the next time the event loop wakes up on
|
|
416
|
+
its own, so `app.exit()` could sit unapplied until the next periodic tick;
|
|
417
|
+
`add_signal_handler` wakes the loop immediately via its self-pipe instead.
|
|
418
|
+
Requires an explicit loop, created and closed here (`loop.close()` also
|
|
419
|
+
removes the handlers registered on it), instead of the implicit one
|
|
420
|
+
`app.run()` would otherwise create.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
def _handle_sigterm() -> None:
|
|
424
|
+
app.exit(return_code=143)
|
|
425
|
+
|
|
426
|
+
def _handle_sigint() -> None:
|
|
427
|
+
app.exit(return_code=130)
|
|
428
|
+
|
|
429
|
+
loop = asyncio.new_event_loop()
|
|
430
|
+
loop.add_signal_handler(signal.SIGTERM, _handle_sigterm)
|
|
431
|
+
loop.add_signal_handler(signal.SIGINT, _handle_sigint)
|
|
432
|
+
try:
|
|
433
|
+
app.run(loop=loop)
|
|
434
|
+
finally:
|
|
435
|
+
loop.close()
|
|
436
|
+
# A screen recorded a vanished cgroup tree (SPEC.md "Errors"): the JSON
|
|
437
|
+
# line prints here, after Textual has restored the terminal, reusing the
|
|
438
|
+
# same error-printing path as the pre-start checks.
|
|
439
|
+
if app.cgroup_error_message is not None:
|
|
440
|
+
_print_error_json("cgroup_unavailable", app.cgroup_error_message, action="user")
|
|
441
|
+
return 1
|
|
442
|
+
return app.return_code if app.return_code is not None else 0
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _run_root(
|
|
446
|
+
args: argparse.Namespace,
|
|
447
|
+
*,
|
|
448
|
+
root: Path,
|
|
449
|
+
uid: int,
|
|
450
|
+
stdin_isatty: Callable[[], bool],
|
|
451
|
+
stdout_isatty: Callable[[], bool],
|
|
452
|
+
) -> int:
|
|
453
|
+
"""Bare `appmem`: the live view, but only in a terminal context."""
|
|
454
|
+
json_flag = bool(args.json)
|
|
455
|
+
no_input = bool(os.environ.get("NO_INPUT"))
|
|
456
|
+
if not (stdin_isatty() and stdout_isatty()) or json_flag or no_input:
|
|
457
|
+
next_argv = ["appmem", "snapshot"]
|
|
458
|
+
if json_flag:
|
|
459
|
+
next_argv.append("--json")
|
|
460
|
+
# A context failure, not a usage error: exit 1 alongside cgroup_unavailable
|
|
461
|
+
# and not_found, so exit 2 stays reserved for a real invalid_input call.
|
|
462
|
+
_fail(
|
|
463
|
+
"terminal_required",
|
|
464
|
+
"the live view needs a terminal; for a one-shot report run: appmem snapshot",
|
|
465
|
+
1,
|
|
466
|
+
action="agent",
|
|
467
|
+
hint="Run appmem snapshot for a one-shot report",
|
|
468
|
+
next_argv=next_argv,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
find_units(root, uid, include_system=args.system)
|
|
473
|
+
except CgroupUnavailableError as exc:
|
|
474
|
+
_fail("cgroup_unavailable", str(exc), 1, action="user")
|
|
475
|
+
|
|
476
|
+
interval = args.interval if args.interval is not None else 1.0
|
|
477
|
+
theme = resolve_theme(
|
|
478
|
+
cli_theme=args.theme,
|
|
479
|
+
env_theme=os.environ.get(APPMEM_THEME_ENV) or None, # empty means unset, as NO_INPUT
|
|
480
|
+
config_path=config_path(),
|
|
481
|
+
textual_theme=os.environ.get(TEXTUAL_THEME_ENV) or None,
|
|
482
|
+
)
|
|
483
|
+
app = AppMemApp(
|
|
484
|
+
root=root,
|
|
485
|
+
uid=uid,
|
|
486
|
+
interval=interval,
|
|
487
|
+
include_system=args.system,
|
|
488
|
+
theme=theme.effective,
|
|
489
|
+
config_theme=theme.config_theme,
|
|
490
|
+
theme_warnings=theme.warnings,
|
|
491
|
+
)
|
|
492
|
+
return _run_app(app)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _run_snapshot(
|
|
496
|
+
args: argparse.Namespace, *, root: Path, uid: int, stdout_isatty: Callable[[], bool]
|
|
497
|
+
) -> int:
|
|
498
|
+
json_flag = bool(args.json)
|
|
499
|
+
include_system = bool(args.system)
|
|
500
|
+
now = datetime.now().astimezone()
|
|
501
|
+
try:
|
|
502
|
+
document, total_apps = report.snapshot_document(
|
|
503
|
+
root, uid, include_system=include_system, limit=args.limit, now=now
|
|
504
|
+
)
|
|
505
|
+
except KeyboardInterrupt:
|
|
506
|
+
_fail("interrupted", "interrupted while reading the snapshot", 130, action="user")
|
|
507
|
+
except CgroupUnavailableError as exc:
|
|
508
|
+
_fail("cgroup_unavailable", str(exc), 1, action="user")
|
|
509
|
+
|
|
510
|
+
next_argv = document.get("next")
|
|
511
|
+
if json_flag and next_argv:
|
|
512
|
+
next_argv.append("--json")
|
|
513
|
+
|
|
514
|
+
if json_flag or not stdout_isatty():
|
|
515
|
+
_write_result(json.dumps(document))
|
|
516
|
+
else:
|
|
517
|
+
_write_result(render_snapshot_text(document, total_apps=total_apps))
|
|
518
|
+
return 0
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _run_app_command(
|
|
522
|
+
args: argparse.Namespace, *, root: Path, uid: int, stdout_isatty: Callable[[], bool]
|
|
523
|
+
) -> int:
|
|
524
|
+
json_flag = bool(args.json)
|
|
525
|
+
now = datetime.now().astimezone()
|
|
526
|
+
try:
|
|
527
|
+
document, total_processes, total_commands = report.app_document(
|
|
528
|
+
root, uid, args.name, args.scope, limit=args.limit, now=now
|
|
529
|
+
)
|
|
530
|
+
except KeyboardInterrupt:
|
|
531
|
+
_fail("interrupted", "interrupted while reading the app", 130, action="user")
|
|
532
|
+
except CgroupUnavailableError as exc:
|
|
533
|
+
_fail("cgroup_unavailable", str(exc), 1, action="user")
|
|
534
|
+
except report.AppNotFoundError:
|
|
535
|
+
next_argv = ["appmem", "snapshot"]
|
|
536
|
+
if args.scope == "system":
|
|
537
|
+
next_argv.append("--system")
|
|
538
|
+
if json_flag:
|
|
539
|
+
next_argv.append("--json")
|
|
540
|
+
_fail(
|
|
541
|
+
"not_found",
|
|
542
|
+
f"no app named {args.name!r} in scope {args.scope!r}",
|
|
543
|
+
1,
|
|
544
|
+
action="agent",
|
|
545
|
+
hint="Names are as listed by appmem snapshot; system services need --scope system",
|
|
546
|
+
next_argv=next_argv,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if json_flag or not stdout_isatty():
|
|
550
|
+
_write_result(json.dumps(document))
|
|
551
|
+
else:
|
|
552
|
+
_write_result(
|
|
553
|
+
render_app_text(
|
|
554
|
+
document, total_processes=total_processes, total_commands=total_commands
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _run_schema(args: argparse.Namespace) -> int:
|
|
561
|
+
path: list[str] = [] if args.path is None else [args.path]
|
|
562
|
+
if not path:
|
|
563
|
+
document = schema.index()
|
|
564
|
+
else:
|
|
565
|
+
found = schema.detail(path)
|
|
566
|
+
if found is None:
|
|
567
|
+
_fail(
|
|
568
|
+
"invalid_input",
|
|
569
|
+
f"unknown schema path {' '.join(path)!r}",
|
|
570
|
+
2,
|
|
571
|
+
action="agent",
|
|
572
|
+
hint="Run appmem schema for the command index",
|
|
573
|
+
next_argv=["appmem", "schema"],
|
|
574
|
+
)
|
|
575
|
+
document = found
|
|
576
|
+
_write_result(json.dumps(document))
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _reject_live_view_flags(args: argparse.Namespace) -> None:
|
|
581
|
+
"""Root flags belong to the live view; a named command that has no such
|
|
582
|
+
flag of its own must refuse them rather than silently drop them."""
|
|
583
|
+
if args.command is not None and args.interval is not None:
|
|
584
|
+
_fail(
|
|
585
|
+
"invalid_input",
|
|
586
|
+
"--interval applies to the live view; a named command takes one sample and ignores it",
|
|
587
|
+
2,
|
|
588
|
+
action="agent",
|
|
589
|
+
hint="Drop -i/--interval, or run appmem with no command for the live view",
|
|
590
|
+
)
|
|
591
|
+
if args.command in ("app", "schema") and args.system:
|
|
592
|
+
_fail(
|
|
593
|
+
"invalid_input",
|
|
594
|
+
f"--system applies to the live view and snapshot, not to {args.command}",
|
|
595
|
+
2,
|
|
596
|
+
action="agent",
|
|
597
|
+
hint="Drop --system; for a system service run appmem app NAME --scope system",
|
|
598
|
+
)
|
|
599
|
+
if args.command is not None and args.theme is not None:
|
|
600
|
+
_fail(
|
|
601
|
+
"invalid_input",
|
|
602
|
+
"--theme applies to the live view; a named command has nothing to colour",
|
|
603
|
+
2,
|
|
604
|
+
action="agent",
|
|
605
|
+
hint="Drop --theme, or run appmem with no command for the live view",
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def main(
|
|
610
|
+
argv: Sequence[str] | None = None,
|
|
611
|
+
*,
|
|
612
|
+
root: Path | None = None,
|
|
613
|
+
uid: int | None = None,
|
|
614
|
+
stdin_isatty: Callable[[], bool] = lambda: sys.stdin.isatty(),
|
|
615
|
+
stdout_isatty: Callable[[], bool] = lambda: sys.stdout.isatty(),
|
|
616
|
+
) -> int:
|
|
617
|
+
"""Parse args, dispatch to a command, and return its process exit code.
|
|
618
|
+
|
|
619
|
+
Returns the exit code instead of calling `sys.exit` so tests (and
|
|
620
|
+
`--help`/`--version`/usage errors, which raise `SystemExit` from
|
|
621
|
+
argparse) stay easy to drive directly.
|
|
622
|
+
"""
|
|
623
|
+
args = _build_parser().parse_args(argv)
|
|
624
|
+
resolved_root = Path("/") if root is None else root
|
|
625
|
+
resolved_uid = os.getuid() if uid is None else uid
|
|
626
|
+
|
|
627
|
+
_reject_live_view_flags(args)
|
|
628
|
+
|
|
629
|
+
if args.command == "snapshot":
|
|
630
|
+
return _run_snapshot(
|
|
631
|
+
args, root=resolved_root, uid=resolved_uid, stdout_isatty=stdout_isatty
|
|
632
|
+
)
|
|
633
|
+
if args.command == "app":
|
|
634
|
+
return _run_app_command(
|
|
635
|
+
args, root=resolved_root, uid=resolved_uid, stdout_isatty=stdout_isatty
|
|
636
|
+
)
|
|
637
|
+
if args.command == "schema":
|
|
638
|
+
return _run_schema(args)
|
|
639
|
+
|
|
640
|
+
return _run_root(
|
|
641
|
+
args,
|
|
642
|
+
root=resolved_root,
|
|
643
|
+
uid=resolved_uid,
|
|
644
|
+
stdin_isatty=stdin_isatty,
|
|
645
|
+
stdout_isatty=stdout_isatty,
|
|
646
|
+
)
|