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 ADDED
@@ -0,0 +1,4 @@
1
+ from runcorder._session import instrument, session
2
+ from runcorder._context import context
3
+
4
+ __all__ = ["instrument", "session", "context"]
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
+ )