runcorder 0.5.1__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.
- runcorder/__init__.py +4 -0
- runcorder/__main__.py +45 -0
- runcorder/_capture.py +38 -0
- runcorder/_context.py +49 -0
- runcorder/_display.py +147 -0
- runcorder/_frames.py +72 -0
- runcorder/_location.py +86 -0
- runcorder/_report.py +299 -0
- runcorder/_session.py +274 -0
- runcorder/_tracker.py +69 -0
- runcorder/cli.py +68 -0
- runcorder/watch.py +369 -0
- runcorder-0.5.1.dist-info/METADATA +72 -0
- runcorder-0.5.1.dist-info/RECORD +17 -0
- runcorder-0.5.1.dist-info/WHEEL +4 -0
- runcorder-0.5.1.dist-info/entry_points.txt +2 -0
- runcorder-0.5.1.dist-info/licenses/LICENSE +21 -0
runcorder/__init__.py
ADDED
runcorder/__main__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Entry point for ``python -m runcorder path/to/script.py [args...]``."""
|
|
2
|
+
|
|
3
|
+
import runpy
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
if len(sys.argv) < 2:
|
|
9
|
+
print(
|
|
10
|
+
"Usage: python -m runcorder <script.py> [args...]\n"
|
|
11
|
+
" runcorder clean [--age AGE]",
|
|
12
|
+
file=sys.stderr,
|
|
13
|
+
)
|
|
14
|
+
sys.exit(1)
|
|
15
|
+
|
|
16
|
+
# If the first argument looks like a sub-command, delegate to the CLI app
|
|
17
|
+
if sys.argv[1] in ("clean",):
|
|
18
|
+
from runcorder.cli import app
|
|
19
|
+
app()
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
script = sys.argv[1]
|
|
23
|
+
# Shift argv so the script sees its own name and arguments
|
|
24
|
+
sys.argv = sys.argv[1:]
|
|
25
|
+
|
|
26
|
+
from runcorder._session import InstrumentContext
|
|
27
|
+
|
|
28
|
+
ctx = InstrumentContext()
|
|
29
|
+
ctx.start()
|
|
30
|
+
exc_info = None
|
|
31
|
+
try:
|
|
32
|
+
runpy.run_path(script, run_name="__main__")
|
|
33
|
+
except SystemExit:
|
|
34
|
+
ctx.stop()
|
|
35
|
+
raise
|
|
36
|
+
except BaseException:
|
|
37
|
+
exc_info = sys.exc_info()
|
|
38
|
+
ctx.stop(exception_info=exc_info)
|
|
39
|
+
raise
|
|
40
|
+
else:
|
|
41
|
+
ctx.stop()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
runcorder/_capture.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""sys.excepthook install / uninstall helpers."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
_original_excepthook: Optional[Callable] = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def install_exception_hook(on_exception: Callable) -> None:
|
|
10
|
+
"""Wrap ``sys.excepthook`` to call *on_exception* before delegating.
|
|
11
|
+
|
|
12
|
+
*on_exception* receives ``(exc_type, exc_value, exc_tb)``.
|
|
13
|
+
The previously-installed hook (or ``sys.__excepthook__``) is still called
|
|
14
|
+
afterward so that the default traceback printout is preserved.
|
|
15
|
+
"""
|
|
16
|
+
global _original_excepthook
|
|
17
|
+
_original_excepthook = sys.excepthook
|
|
18
|
+
|
|
19
|
+
def _hook(exc_type, exc_value, exc_tb):
|
|
20
|
+
try:
|
|
21
|
+
on_exception(exc_type, exc_value, exc_tb)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass # never let our callback suppress the original behaviour
|
|
24
|
+
previous = _original_excepthook
|
|
25
|
+
if previous is not None and previous is not _hook:
|
|
26
|
+
previous(exc_type, exc_value, exc_tb)
|
|
27
|
+
else:
|
|
28
|
+
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
|
29
|
+
|
|
30
|
+
sys.excepthook = _hook
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def uninstall_exception_hook() -> None:
|
|
34
|
+
"""Restore the excepthook that was in place before :func:`install_exception_hook`."""
|
|
35
|
+
global _original_excepthook
|
|
36
|
+
if _original_excepthook is not None:
|
|
37
|
+
sys.excepthook = _original_excepthook
|
|
38
|
+
_original_excepthook = None
|
runcorder/_context.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Session-level key/value store for runcorder context variables."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
_active_store: dict | None = None
|
|
6
|
+
_warned: bool = False
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def context(**kwargs) -> None:
|
|
10
|
+
"""Set or update session-level context variables.
|
|
11
|
+
|
|
12
|
+
Keys are additive; setting a key to None removes it.
|
|
13
|
+
Warns once per process if called outside an active session.
|
|
14
|
+
"""
|
|
15
|
+
global _active_store, _warned
|
|
16
|
+
if _active_store is None:
|
|
17
|
+
if not _warned:
|
|
18
|
+
warnings.warn(
|
|
19
|
+
"runcorder.context() called outside an active session; call has no effect",
|
|
20
|
+
stacklevel=2,
|
|
21
|
+
)
|
|
22
|
+
_warned = True
|
|
23
|
+
return
|
|
24
|
+
for k, v in kwargs.items():
|
|
25
|
+
if v is None:
|
|
26
|
+
_active_store.pop(k, None)
|
|
27
|
+
else:
|
|
28
|
+
_active_store[k] = v
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _install() -> dict:
|
|
32
|
+
"""Install (activate) the context store. Called by session start."""
|
|
33
|
+
global _active_store, _warned
|
|
34
|
+
_active_store = {}
|
|
35
|
+
_warned = False
|
|
36
|
+
return _active_store
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _uninstall() -> None:
|
|
40
|
+
"""Uninstall (deactivate) the context store. Called by session stop."""
|
|
41
|
+
global _active_store
|
|
42
|
+
_active_store = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get() -> dict:
|
|
46
|
+
"""Return a copy of the current context store, or empty dict if no session."""
|
|
47
|
+
if _active_store is None:
|
|
48
|
+
return {}
|
|
49
|
+
return dict(_active_store)
|
runcorder/_display.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Centralised output for runcorder.
|
|
2
|
+
|
|
3
|
+
All runcorder-originated messages go through the ``runcorder`` logger so they
|
|
4
|
+
route predictably under batch-job logging configurations. The watch line is
|
|
5
|
+
special-cased: when ``watch_inplace=True`` and the session is writing to a
|
|
6
|
+
tty, it uses ANSI escape sequences for in-place updates; otherwise it falls
|
|
7
|
+
through to the logger with per-line dedup against the previous sample.
|
|
8
|
+
|
|
9
|
+
Per spec: runcorder does not change logging settings. If the ``runcorder``
|
|
10
|
+
logger (or any ancestor) already has handlers, we respect that and do not
|
|
11
|
+
install our own. When nothing is configured, we attach a minimal stderr
|
|
12
|
+
handler so default installs are usable out of the box.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
from typing import TYPE_CHECKING, Optional
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from runcorder._tracker import _WriteTracker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("runcorder")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _StderrHandler(logging.Handler):
|
|
29
|
+
"""StreamHandler variant that reads ``sys.stderr`` on every emit.
|
|
30
|
+
|
|
31
|
+
The stdlib StreamHandler captures ``sys.stderr`` at construction time.
|
|
32
|
+
That breaks when ``sys.stderr`` is later redirected (pytest's capsys,
|
|
33
|
+
batch-job log collectors, etc.), so we resolve it dynamically.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
37
|
+
try:
|
|
38
|
+
msg = self.format(record)
|
|
39
|
+
sys.stderr.write(msg + "\n")
|
|
40
|
+
sys.stderr.flush()
|
|
41
|
+
except Exception:
|
|
42
|
+
self.handleError(record)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_handler() -> None:
|
|
46
|
+
"""Install a default stderr handler if neither the runcorder logger nor
|
|
47
|
+
any ancestor has been configured. Called on every message so that a
|
|
48
|
+
user who calls ``logging.basicConfig()`` *before* the first runcorder
|
|
49
|
+
message wins — their root handler is used instead of ours, and records
|
|
50
|
+
propagate without duplication."""
|
|
51
|
+
if logger.hasHandlers():
|
|
52
|
+
return
|
|
53
|
+
handler = _StderrHandler()
|
|
54
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
55
|
+
logger.addHandler(handler)
|
|
56
|
+
if logger.level == logging.NOTSET:
|
|
57
|
+
logger.setLevel(logging.INFO)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def info(msg: str) -> None:
|
|
61
|
+
_ensure_handler()
|
|
62
|
+
logger.info(msg)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def warning(msg: str) -> None:
|
|
66
|
+
_ensure_handler()
|
|
67
|
+
logger.warning(msg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def error(msg: str) -> None:
|
|
71
|
+
_ensure_handler()
|
|
72
|
+
logger.error(msg)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# WatchSink
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class WatchSink:
|
|
80
|
+
"""Routes the watch line to either a tty (in-place escape updates) or the
|
|
81
|
+
runcorder logger (with dedup against the previous line)."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
orig_stderr,
|
|
86
|
+
tracker: Optional["_WriteTracker"],
|
|
87
|
+
watch_inplace: bool,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._orig_stderr = orig_stderr
|
|
90
|
+
self._tracker = tracker
|
|
91
|
+
self._watch_inplace = watch_inplace
|
|
92
|
+
self._wrote_last_inplace: bool = False
|
|
93
|
+
self._last_logged_line: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def tty_sink(self):
|
|
97
|
+
return self._orig_stderr if self._orig_stderr is not None else sys.stderr
|
|
98
|
+
|
|
99
|
+
def _is_tty(self) -> bool:
|
|
100
|
+
sink = self.tty_sink
|
|
101
|
+
try:
|
|
102
|
+
return hasattr(sink, "isatty") and sink.isatty()
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def emit(self, line: str) -> None:
|
|
107
|
+
if self._watch_inplace and self._is_tty():
|
|
108
|
+
self._emit_inplace(line)
|
|
109
|
+
else:
|
|
110
|
+
self._emit_logged(line)
|
|
111
|
+
|
|
112
|
+
def _emit_inplace(self, line: str) -> None:
|
|
113
|
+
sink = self.tty_sink
|
|
114
|
+
if self._tracker is not None and self._tracker.foreign_wrote:
|
|
115
|
+
self._tracker.reset_foreign()
|
|
116
|
+
self._wrote_last_inplace = False
|
|
117
|
+
try:
|
|
118
|
+
if self._wrote_last_inplace:
|
|
119
|
+
sink.write(f"\033[A\r\033[K{line}\n")
|
|
120
|
+
else:
|
|
121
|
+
sink.write(f"{line}\n")
|
|
122
|
+
sink.flush()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
self._wrote_last_inplace = True
|
|
126
|
+
self._last_logged_line = None
|
|
127
|
+
|
|
128
|
+
def _emit_logged(self, line: str) -> None:
|
|
129
|
+
if line == self._last_logged_line:
|
|
130
|
+
return
|
|
131
|
+
_ensure_handler()
|
|
132
|
+
logger.info(line)
|
|
133
|
+
self._last_logged_line = line
|
|
134
|
+
self._wrote_last_inplace = False
|
|
135
|
+
|
|
136
|
+
def clear_inplace(self) -> None:
|
|
137
|
+
"""Erase the last in-place status line (tty path only)."""
|
|
138
|
+
if not self._wrote_last_inplace:
|
|
139
|
+
return
|
|
140
|
+
sink = self.tty_sink
|
|
141
|
+
try:
|
|
142
|
+
if self._is_tty():
|
|
143
|
+
sink.write("\r\033[K")
|
|
144
|
+
sink.flush()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
self._wrote_last_inplace = False
|
runcorder/_frames.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Frame inspection utilities shared by watch display and report writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import sysconfig
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_exclusion_prefixes() -> tuple[str, ...]:
|
|
11
|
+
prefixes: set[str] = set()
|
|
12
|
+
paths = sysconfig.get_paths()
|
|
13
|
+
for key in ("stdlib", "platstdlib", "purelib", "platlib"):
|
|
14
|
+
p = paths.get(key)
|
|
15
|
+
if p:
|
|
16
|
+
try:
|
|
17
|
+
prefixes.add(str(Path(p).resolve()))
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
pass
|
|
20
|
+
for attr in ("prefix", "exec_prefix", "base_prefix"):
|
|
21
|
+
p = getattr(sys, attr, None)
|
|
22
|
+
if p:
|
|
23
|
+
lib_dir = Path(p) / "lib"
|
|
24
|
+
try:
|
|
25
|
+
prefixes.add(str(lib_dir.resolve()))
|
|
26
|
+
except (OSError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
return tuple(prefixes)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_EXCLUSION_PREFIXES: tuple[str, ...] = _build_exclusion_prefixes()
|
|
32
|
+
_RUNCORDER_PREFIX: str = str(Path(__file__).parent.resolve())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_user_frame(frame) -> bool:
|
|
36
|
+
"""Return True if *frame* is from user code (not stdlib/site-packages/runcorder)."""
|
|
37
|
+
filename = frame.f_code.co_filename
|
|
38
|
+
if not filename or filename.startswith("<"):
|
|
39
|
+
return False
|
|
40
|
+
try:
|
|
41
|
+
p = str(Path(filename).resolve())
|
|
42
|
+
except (OSError, ValueError):
|
|
43
|
+
return False
|
|
44
|
+
if p.startswith(_RUNCORDER_PREFIX):
|
|
45
|
+
return False
|
|
46
|
+
for prefix in _EXCLUSION_PREFIXES:
|
|
47
|
+
if p.startswith(prefix):
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_param_names(code) -> list[str]:
|
|
53
|
+
"""Return parameter names (excluding non-param locals) from a code object."""
|
|
54
|
+
n = code.co_argcount + code.co_kwonlyargcount
|
|
55
|
+
return list(code.co_varnames[:n])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_param_reprs(frame) -> dict[str, str]:
|
|
59
|
+
"""Return {param_name: repr(value)} for up to 4 parameters of a frame."""
|
|
60
|
+
param_names = _get_param_names(frame.f_code)
|
|
61
|
+
if not param_names:
|
|
62
|
+
return {}
|
|
63
|
+
try:
|
|
64
|
+
locals_dict = frame.f_locals
|
|
65
|
+
except Exception:
|
|
66
|
+
return {}
|
|
67
|
+
result: dict[str, str] = {}
|
|
68
|
+
for name in param_names[:4]:
|
|
69
|
+
if name not in locals_dict:
|
|
70
|
+
continue
|
|
71
|
+
result[name] = repr(locals_dict[name])
|
|
72
|
+
return result
|
runcorder/_location.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Report path resolution and log-space management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from runcorder import _display
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_log_dir() -> Path:
|
|
12
|
+
"""Return the default log directory for runcorder reports.
|
|
13
|
+
|
|
14
|
+
On macOS or Windows, if ``~/.cache`` already exists, uses
|
|
15
|
+
``~/.cache/runcorder/logs/`` instead of the platform-specific location.
|
|
16
|
+
On Linux, always uses ``~/.cache/runcorder/logs/``.
|
|
17
|
+
"""
|
|
18
|
+
home = Path.home()
|
|
19
|
+
dot_cache = home / ".cache"
|
|
20
|
+
if sys.platform == "linux":
|
|
21
|
+
return dot_cache / "runcorder" / "logs"
|
|
22
|
+
# macOS or Windows: prefer ~/.cache if it already exists
|
|
23
|
+
if dot_cache.is_dir():
|
|
24
|
+
return dot_cache / "runcorder" / "logs"
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
27
|
+
if local:
|
|
28
|
+
return Path(local) / "runcorder" / "logs"
|
|
29
|
+
return home / "AppData" / "Local" / "runcorder" / "logs"
|
|
30
|
+
# macOS without ~/.cache
|
|
31
|
+
return home / "Library" / "Caches" / "runcorder" / "logs"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def auto_name() -> Path:
|
|
35
|
+
"""Return a timestamped report path inside the default log dir.
|
|
36
|
+
|
|
37
|
+
Format: ``YYMMDD-HHMMSS.md``
|
|
38
|
+
The directory is created if it does not exist.
|
|
39
|
+
"""
|
|
40
|
+
log_dir = default_log_dir()
|
|
41
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
ts = datetime.now().strftime("%y%m%d-%H%M%S")
|
|
44
|
+
return log_dir / f"{ts}.md"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_log_size() -> None:
|
|
48
|
+
"""Check whether the log directory exceeds 100 MB and warn if so.
|
|
49
|
+
|
|
50
|
+
Uses a ``size_check`` sentinel file to cache the result; only
|
|
51
|
+
recalculates when the file is absent or older than 1 day.
|
|
52
|
+
"""
|
|
53
|
+
log_dir = default_log_dir()
|
|
54
|
+
if not log_dir.exists():
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
size_check = log_dir / "size_check"
|
|
58
|
+
total: int | None = None
|
|
59
|
+
|
|
60
|
+
if size_check.exists():
|
|
61
|
+
age = time.time() - size_check.stat().st_mtime
|
|
62
|
+
if age < 86400: # 1 day in seconds
|
|
63
|
+
try:
|
|
64
|
+
total = int(size_check.read_text().strip())
|
|
65
|
+
except (ValueError, OSError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
if total is None:
|
|
69
|
+
total = sum(
|
|
70
|
+
f.stat().st_size
|
|
71
|
+
for f in log_dir.iterdir()
|
|
72
|
+
if f.is_file() and f.name != "size_check"
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
size_check.write_text(str(total))
|
|
76
|
+
# Touch to reset mtime explicitly (write_text already does this,
|
|
77
|
+
# but be explicit for clarity)
|
|
78
|
+
os.utime(size_check, None)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
mb = total / (1024 * 1024)
|
|
83
|
+
if mb > 100:
|
|
84
|
+
_display.warning(
|
|
85
|
+
f"runcorder log size is {mb:.0f} MB. Clean with `runcorder clean`"
|
|
86
|
+
)
|